Перейти к содержанию

PostgreSQL: подключение

Как hare подключается к PostgreSQL: два драйвера, URL и его параметры TLS, меняющиеся учётные данные, как подключение переживает перезапуск сервера или обрыв соединения и что каждый драйвер делает со значениями готового SQL.

Драйверы

Подключение выбирает драйвер через engine — это то же слово, что схема в адресе базы:

engine / схема адреса базы Драйвер
postgresql rust_pg, драйвер hare на Rust, — ставится вместе с hare
postgresql+asyncpg asyncpg (нужен hare-orm[asyncpg])

Схема драйвера — <dialect>+<driver>; простая postgresql:// — это rust_pg. Схемы всех баз — в разделе Диалекты и возможности.

connections = {
    "reporting": {"engine": "postgresql+asyncpg", "credentials": {"host": "db.internal", "database": "reporting"}},
    "analytics": "postgresql://user:pass@host:5432/db",
}

Если модуля на Rust нет — стоит универсальный wheel на платформе, для которой готовый wheel не собирается, или рабочая копия репозитория не собрана, — подключение postgresql даёт ConfigurationError с командой maturin develop, которая его собирает; для postgresql+asyncpg он не нужен.

Рабочие потоки rust_pg

rust_pg работает со своими соединениями в собственных рабочих потоках — до четырёх, но не больше, чем ядер у машины: запросы запускает один поток с циклом событий, и нескольких рабочих потоков ему хватает — пока один разбирает большой результат, остальные обслуживают другие соединения; лишние ждали бы работы на ядрах, которые нужны циклу событий и базе. Их число задаёт собственная переменная окружения tokio до импорта hare:

TOKIO_WORKER_THREADS=4 python app.py

Параметры подключения

Оба драйвера принимают эти настройки как параметры адреса (?connect_max_retries=3&...) или как ключи словаря credentials — так же, как передаётся max_size. Число повторов — целое от 0 до 100, начальная пауза — конечное число секунд от 0 до 3600; всё остальное ("inf", "nan", отрицательное число) даёт ConfigurationError ещё при настройке:

Параметр По умолчанию Что задаёт
connect_max_retries 0 Сколько раз повторить первое подключение пула; пауза перед каждой следующей попыткой вдвое длиннее предыдущей (connect_retry_backoff_base_seconds * 2**attempt). При 0 ошибка первой попытки передаётся без изменений.
connect_retry_backoff_base_seconds 0.1 Начальная пауза для повторов выше.
port 5432 Целое число от 1 до 65535, иначе ConfigurationError.
statement_cache_size как у драйвера Целое число от 0 до 1000000 — см. statement_cache_size
transaction_pooling false Соединение идёт через пулер в режиме транзакций (PgBouncer) (см. Пулеры соединений).
direct_host / direct_port нет / port Адрес самого сервера в обход пулера — для LISTEN и настроек сессии. Только вместе с transaction_pooling.
read_retry_max_retries 0 Сколько раз повторить запрос, прерванный обрывом соединения посреди запроса (переключение сервера RDS на резервный, кратковременный сбой сети), а не только при подключении — см. read_retry_max_retries
read_retry_backoff_base_seconds 0.1 Начальная пауза для повторов выше.
min_size / max_size 1 / 16 Наименьшее и наибольшее число соединений в пуле: целые числа, 0 <= min_size <= max_size, 1 <= max_size <= 10000. Неверный тип или значение вне диапазона — ConfigurationError.
max_inactive_connection_lifetime 300 Сколько секунд соединение пула может не использоваться, прежде чем пул его закроет, — и соединения min_size тоже, они открываются снова при надобности; 0 оставляет их открытыми (0 <= value <= 86400, иначе ConfigurationError). Одинаково на обоих драйверах.
pool_acquire_timeout нет (ждать сколько угодно) Сколько секунд ждать свободного соединения из пула, прежде чем выдать PoolTimeoutError — это DBConnectionError (0 < value <= 86400, иначе ConfigurationError). Значения «не ждать» нет: 0 отклоняется. Проверка и смысл одинаковы на обоих драйверах.
command_timeout нет (выключено) Наибольшее время в секундах, которое одна команда может ждать ответа базы (0 < value <= 86400, иначе ConfigurationError) — см. command_timeout

statement_cache_size

Целое число от 0 до 1000000. 0 выключает кэш: каждая команда готовится для одного своего выполнения — с rust_pg команда, у всех значений которой свой тип (UUID, дата, время, datetime с часовым поясом, timedelta, Decimal, float, bytes, bool, None), уходит вместе с ними за один сетевой круг.

read_retry_max_retries

Сколько раз повторить запрос, прерванный обрывом соединения посреди запроса (переключение сервера RDS на резервный, кратковременный сбой сети), а не только при подключении. Паузы растут так же, число повторов считается отдельно. Повторяется только запрос, который по своему виду лишь читает данные (AwaitableQuery.is_read_only), и только вне явной транзакции: запись вслепую не повторяется никогда (она могла уже выполниться), запрос внутри Transactions.atomic() — тоже (повторять пришлось бы всю транзакцию, а не одну команду). Повтор вызывает только DBConnectionError, но не OperationalError: например, после отмены команды соединение остаётся рабочим, и повтор ничего бы не дал.

command_timeout

Наибольшее время в секундах, которое одна команда может ждать ответа базы (0 < value <= 86400, иначе ConfigurationError). Без него запрос к серверу, который перестал отвечать, но не закрыл соединение (сеть разделилась, процесс завис), ждёт бесконечно. Когда время выходит, вызов бросает TimeoutError, а запрос отменяется. По умолчанию выключено, чтобы не прерывать долгие миграции и массовые операции. Время отсчитывает сам hare на обоих драйверах (собственный command_timeout у asyncpg не срабатывает, если сервер перестал отвечать, а у rust_pg такой настройки нет) — для каждого вызова, выполняющего запрос, и для массовой загрузки COPY, но не для begin/commit/rollback/savepoint и не для stream(). Команды управления транзакцией не ограничены: отмена одной из них посреди выполнения оставила бы состояние транзакции неизвестным. Соединение отменённой команды остаётся рабочим.

В rust_pg команда транзакции, отменённая во время выполнения, отправляет PostgreSQL запрос отмены по отдельному соединению; следующая команда транзакции ждёт, пока этот запрос будет доставлен, поэтому запоздавший запрос отмены никогда не отменит вместо неё следующую команду.

Отменённый запрос останавливается и на сервере, в том числе за пулером вроде PgBouncer. Пулер передаёт запрос отмены дальше, только пока клиентское соединение, которое он называет, ещё открыто. Поэтому соединение, вынутое из пула из-за превышения времени, закрывается только после того, как его запрос отмены доставлен, — или, если ответа на него нет ещё за один command_timeout (сеть перестала отвечать), закрывается тогда, и вызов всё равно заканчивается примерно через два command_timeout. См. Проверка связи и неотвечающий сервер.

Меняющийся пароль

Пароль, который меняется, — токен облачного IAM, динамический пароль хранилища секретов — берётся не из настройки password, а из функции. Её задаёт password_provider: обычная или асинхронная функция, возвращающая пароль, или её путь через точку в адресе базы или файле настроек:

async def get_database_password() -> str:
    return await secrets.get("library-db")


await Hare.init(
    config={
        **HARE_CONFIG,
        "connections": {
            "default": {
                "engine": "postgresql",
                "credentials": {
                    "host": "db.internal",
                    "user": "library",
                    "database": "library",
                    "password_provider": get_database_password,
                    "password_refresh_seconds": 300,
                },
            }
        },
    }
)
# или: postgresql://library@db.internal/library?password_provider=library.secrets.get_database_password
Параметр По умолчанию Что задаёт
password_provider нет Функция, дающая пароль. Вместе с password — ConfigurationError. Она должна вернуть непустую строку, иначе ConfigurationError.
password_refresh_seconds 600 Сколько секунд пароль используется, прежде чем функцию спросят снова (0 < value <= 86400, иначе ConfigurationError). Без password_provider — ConfigurationError.

Пул открывает новые соединения с паролем, который функция дала последним; уже открытые соединения остаются. asyncpg спрашивает пароль, открывая соединение, — когда тот старше password_refresh_seconds; rust_pg открывает соединения сам, поэтому hare спрашивает в фоне каждые password_refresh_seconds и передаёт пулу новый пароль. Если функция не смогла дать новый пароль, на обоих драйверах остаётся последний — с предупреждением в журнале; если пароля ещё нет или сервер в нём отказал, её ошибка пробрасывается. Если сервер отказал в пароле — его сменили до обновления, — функцию спрашивают сразу, а команда выполняется ещё раз: отказ во входе случился до того, как команда дошла до сервера, поэтому это верно и для записи. Хост с несколькими адресами (localhost — 127.0.0.1 и ::1) сообщает отказ того адреса, который ответил, а не отказ в соединении на другом. Внутри транзакции её соединение уже открыто, и ничего не повторяется. Диалект без смены пароля (SQLite, ClickHouse) отвергает password_provider как неизвестную настройку.

Параметры адреса и TLS

Шифрование соединения (TLS) задаётся одним из параметров: ?sslmode= (значения libpq disable, allow, prefer, require, verify-ca, verify-full), ?ssl= (те же значения или true/false: true — это require, false — disable) или ?ssl_mode= — в одном адресе только один из них. Значение передаётся в настройку конкретного драйвера: ssl_mode у rust_pg (режима allow у него нет — UnSupportedError), ssl у asyncpg. Любое другое значение даёт ConfigurationError.

?ssl_root_cert= задаёт файл сертификатов, по которым проверяется сертификат сервера, на обоих драйверах (у asyncpg — вместе с режимом TLS: SSLContext в ssl= хранит свои сертификаты сам, и вместе они дают ConfigurationError). Файл, который нельзя прочитать или в котором нет сертификата, даёт ConfigurationError до открытия соединения. rust_pg также сообщает через ConfigurationError об ошибках настройки TLS, в том числе если включена проверка сертификата, а в системе нет пригодных корневых сертификатов. Такие ошибки не повторяются как сетевые.

С rust_pg (postgresql://) каждый параметр адреса должен быть понятен драйверу: min_size/max_size, max_inactive_connection_lifetime, statement_cache_size, ssl_root_cert, application_name, schema, tenant_schema_template, tenant_row_level_security, host, port, параметры TLS, password_provider/password_refresh_seconds и параметры из таблицы выше. Любой другой (например, max_queries из asyncpg) даёт ConfigurationError, а не отбрасывается молча. С asyncpg (postgresql+asyncpg://) параметр должен быть ssl_root_cert или одним из тех, что принимают asyncpg.connect()/create_pool(), а числовые параметры asyncpg проверяются по диапазону: max_queries — от 1, timeout — больше 0, max_inactive_connection_lifetime и max_cached_statement_lifetime — от 0 до 86400 секунд, max_cacheable_statement_size — от 0. Значение неподходящего типа или вне диапазона (?min_size=abc, ?max_queries=0) даёт ConfigurationError при настройке подключения, а не на первом запросе. Значение, заданное дважды — в самом адресе и параметром (...@host:5432/db?port=6432), — тоже даёт ConfigurationError, а не тихо теряет одно из двух.

Если подключиться не удалось, в сообщении остаётся причина, которую назвал драйвер. Неверный пароль, неизвестная роль или несуществующая база дают ConfigurationError на обоих драйверах: повтор тут не поможет, и connect_max_retries их не повторяет. Недоступный сервер и сервер, у которого кончились подключения, память или диск (SQLSTATE класса 53, «too many clients»), дают DBConnectionError, и connect_max_retries их повторяет. Пул, который не смог открыться, закрывает уже открытые им соединения. Если user, password, host или database не заданы, оба драйвера берут их из переменных окружения libpq PGUSER, PGPASSWORD, PGHOST, PGDATABASE (а затем — имя пользователя ОС и localhost).

Пароль может содержать @, /, ?, #, [ и ] как есть (кодирование через % тоже работает), — кроме @, за которым идёт ?имя=значение без следующего ? (p@s?a=b): это читается как хост и параметры, поэтому такой пароль кодируйте через % (p%40s%3Fa%3Db); значение параметра может содержать @, закодированное через % имя базы раскодируется (/my%20db → my db). Каталог unix-сокета можно указать как ?host=/var/run/postgresql с пустым именем хоста или закодировать через % прямо на месте хоста (postgresql://user@%2Fvar%2Frun%2Fpostgresql/db). Идентификатор зоны адреса IPv6 тоже кодируется через % ([fe80::1%25eth0] → fe80::1%eth0).

Часовой пояс сеанса

Оба драйвера работают в каждом сеансе с TimeZone=UTC, какой бы часовой пояс ни был задан по умолчанию у сервера, базы или роли: от пояса сеанса зависят приведения даты и времени, CURRENT_DATE и значения по умолчанию Now(), а hare хранит моменты времени в UTC. Если в server_settings указан другой TimeZone (UTC, Etc/UTC, GMT и подобные допускаются), будет ConfigurationError. Пояс, в котором значения показываются приложению, задаёт Hare.init(timezone=...).

Готовый SQL на двух драйверах

Оба драйвера возвращают из готового SQL одинаковые значения Python: numeric — точный Decimal (с любым числом цифр), json/jsonb — их текст, interval — timedelta (месяц считается за 30 дней, год — за 365), inet — ipaddress.ip_address/ip_interface, cidr — ipaddress.ip_network, oid/xid — int, типы reg* — имена, tsvector, citext, ltree и hstore (и в массиве, в том числе пустое значение) — текст, который печатает PostgreSQL, ROW(...) — кортеж. Эти четыре типа asyncpg читает через бинарные кодеки hare, которые соединение получает при открытии и снова после того, как hare выполнит скрипт, создающий или удаляющий расширение: собственное текстовое чтение asyncpg роняет процесс на пустом значении в массиве. Пул, открытый до того, как расширение создали в обход hare, читает его типы так, пока его соединения не откроются заново (await Connections.reconnect()). Время 24:00:00, которое не помещается в datetime.time, вызывает ошибку, а не читается как полночь. Значение типа, который rust_pg не умеет разбирать (point, pg_lsn и другие), он возвращает текстом — так, как его печатает PostgreSQL; в параметрах он принимает timedelta и объекты ipaddress. Для остальных типов результат готового SQL зависит от драйвера: диапазон — это Range из asyncpg или hare.dialects.postgresql.fields.Range, money — текст у asyncpg ('$1.00') или Decimal у rust_pg, бесконечный timestamptz — datetime.max/min без пояса или в UTC. Поля моделей (RangeField, DatetimeField и другие) читают всё это одинаково на обоих драйверах. Параметр, тип которого не подходит колонке, даёт OperationalError, а не преобразуется. Ошибка базы сохраняет в сообщении DETAIL и HINT сервера, а исключение драйвера, из которого она получена, — sqlstate, constraint_name, table_name и другие поля.

Значения готовой команды передаются драйверу как есть, поэтому видно, что с ними делают asyncpg и rust_pg. hare согласует их там, где может:

  • Неверное число параметров даёт OperationalError на обоих: asyncpg отказывает до отправки, для rust_pg отказывает сервер; подключение остаётся рабочим.
  • Значение, которого нет у типа Python, — interval 'infinity' или интервал в миллионы лет, '24:00:00'::time, дата до 1 года или после 9999-го — даёт OperationalError в запросе hare. interval читается на обоих так же, как в asyncpg: год — 365 дней, месяц — 30.
  • rust_pg раскодирует строку, когда её читают: строки, которые возвращает connection.execute(...), дают собственный rust.native.pg.ConversionError драйвера для такого значения при чтении значения, уже вне вызова.
  • asyncpg записывает Decimal с более чем 131072 цифрами до запятой как 0 — это предел его кодека; rust_pg такое значение отклоняет. Фильтр по DecimalField сравнивает такое число как первое за пределом колонки, поэтому до драйвера оно не доходит.
  • Вход без пароля даёт ConfigurationError, как и с неверным паролем, на обоих — rust_pg отказывает до запроса пароля сервером.