github ozontech/pg_doorman v3.6.5

5 hours ago

Pool exhaustion after large-frame error (incident 2026-05-03)

A 3.5.2 instance held cl_active=40, sv_active=40, semaphore_avail=0 for 38 minutes with no in-flight queries. PG-side, all 40 backends sat in state=idle. Two coupled bugs.

After a streaming error on a large DataRow/CopyData, doorman lost the deferred frame header, so the next read parsed body bytes as a new frame and marked the backend bad. With the semaphore exhausted, no checkouts arrived to recycle that backend, and pool accounting stayed inflated until restart.

The fix keeps the deferred header until streaming returns Ok, and Object::drop now evicts bad or pending-large-frame servers in the same tick: slots.size decremented, permit returned, coordinator observers notified.

Upgrade closes the symptom; no config changes.

New gauge: oldest_active_age_ms per pool

SHOW POOLS shows a new oldest_active_age_ms column. Prometheus exports pg_doorman_pools_oldest_active_age_ms{user, database}. The value is the maximum age in milliseconds among ACTIVE servers in each pool, sampled at scrape time, and falls to 0 when no server is ACTIVE.

Add an alert when the gauge stays above your typical query duration. Sustained non-zero values mean stuck checkouts hours before they exhaust the pool.

Read buffer leak fixed (RSS regression up to 4 GB)

Per-connection reusable read buffers (Client.read_buf, Server.read_buf) retained the largest allocation each connection had ever served. After one multi-MiB simple-query INSERT, every subsequent small message split out of that allocation. Across thousands of clients in transaction mode, occasional megabyte payloads compounded into 100 MB → 4 GB pooler RSS over time.

Buffers above 256 KiB are now released after the read; the next read allocates a fresh 16 KiB buffer. The steady-state path under 256 KiB keeps the previous behavior.

Fallback resilience

Patroni-assisted fallback now races Server::startup against every alive cluster member in parallel, with strict sync_standby priority that protects write traffic during a local-backend outage.

  • Per-candidate startup deadline now covers the StartupMessage round-trip too.
  • Two-wave race: wave 1 against every sync_standby in parallel; wave 2 (replica + leader) only if every sync_standby failed.
  • Per-host cooldown with exponential backoff up to 60s; the discovery loop prunes expired entries each cycle.
  • Soft outer deadline under query_wait_timeout.
  • New metric pg_doorman_fallback_candidate_failures_total{pool, reason}.
  • Each (pool, host:port) logs at most one WARN per 10s.

See Patroni-assisted fallback. Use IP addresses, not hostnames, in member.host. A 5s DNS hang consumes the full per-candidate budget.

Full changelog: https://ozontech.github.io/pg_doorman/changelog.html


Истощение пула после ошибки на больших фреймах (инцидент 2026-05-03)

Инстанс 3.5.2 держал cl_active=40, sv_active=40, semaphore_avail=0 38 минут без in-flight запросов. На стороне PG все 40 бэкендов сидели в state=idle. Две связанные ошибки.

После ошибки стриминга большого DataRow/CopyData doorman терял отложенный заголовок фрейма, поэтому следующий read парсил байты тела как новый фрейм и помечал бэкенд плохим. При истощённой семафоре checkout-ов не приходило, чтобы переиспользовать этот бэкенд, и учёт пула оставался завышенным до рестарта.

Фикс держит отложенный заголовок до успешного стриминга, и Object::drop теперь вытесняет плохие серверы и серверы с отложенным large-frame заголовком в том же тике: декрементирует slots.size, возвращает permit, нотифицирует coordinator-наблюдателей.

Апгрейд закрывает симптом; конфиг менять не нужно.

Новая метрика: oldest_active_age_ms по пулу

SHOW POOLS показывает новую колонку oldest_active_age_ms. Prometheus экспортирует pg_doorman_pools_oldest_active_age_ms{user, database}. Значение — максимальный возраст в миллисекундах среди ACTIVE-серверов пула, на момент scrape-а; падает в 0, если активных нет.

Добавьте алерт, когда метрика держится выше вашей типичной длительности запроса. Устойчивые ненулевые значения означают застрявшие checkout-ы за часы до истощения пула.

Утечка read-буфера исправлена (регрессия RSS до 4 ГБ)

Переиспользуемые per-connection read-буферы (Client.read_buf, Server.read_buf) удерживали самый большой allocation, который коннект когда-либо обслуживал. После одного мульти-MiB simple-query INSERT-а каждое последующее мелкое сообщение откусывалось от этого allocation. По тысячам клиентов в transaction-режиме случайные мегабайтные payload-ы складывались в RSS пулера 100 МБ → 4 ГБ со временем.

Буферы выше 256 KiB теперь освобождаются после read; следующий read аллоцирует свежие 16 KiB. Steady-state путь под 256 KiB сохраняет прежнее поведение.

Устойчивость fallback-а

Patroni-assisted fallback теперь гоняет Server::startup против каждого живого члена кластера параллельно, со строгим приоритетом sync_standby, защищающим write-трафик при отказе локального бэкенда.

  • Per-candidate стартап-дедлайн теперь покрывает и StartupMessage round-trip.
  • Двухволновая гонка: волна 1 против всех sync_standby параллельно; волна 2 (replica + leader) только если все sync_standby упали.
  • Per-host cooldown с экспоненциальным backoff до 60s; discovery-цикл чистит просроченные записи каждый раз.
  • Мягкий внешний дедлайн под query_wait_timeout.
  • Новая метрика pg_doorman_fallback_candidate_failures_total{pool, reason}.
  • Каждая пара (pool, host:port) пишет не более одного WARN в 10s.

См. Patroni-assisted fallback. Используйте IP-адреса, не hostnames, в member.host. 5-секундный DNS hang съедает весь per-candidate бюджет.

Полный changelog: https://ozontech.github.io/pg_doorman/changelog.html

Don't miss a new pg_doorman release

NewReleases is sending notifications on new releases.