Skip to content

Postgres backend: TCP keepalive on pooled connections (detect half-open LISTEN after failover) — 0.2.7 - #28

Merged
Zivxx merged 1 commit into
masterfrom
ziv/postgres-tcp-keepalive
Aug 20, 2026
Merged

Postgres backend: TCP keepalive on pooled connections (detect half-open LISTEN after failover) — 0.2.7#28
Zivxx merged 1 commit into
masterfrom
ziv/postgres-tcp-keepalive

Conversation

@Zivxx

@Zivxx Zivxx commented Aug 19, 2026

Copy link
Copy Markdown

What

The Postgres backend now enables TCP keepalive on every pooled asyncpg connection, on by default (idle 30 s / interval 10 s / count 3 → a silently vanished server is detected within ~60 s), tunable or disableable with the libpq parameter names in the URL:

postgres://user:pw@host:5432/db?keepalives=1&keepalives_idle=30&keepalives_interval=10&keepalives_count=3
postgres://user:pw@host:5432/db?keepalives=0   # off

The parameters are stripped before the DSN reaches asyncpg (asyncpg forwards unknown query params to the server as session settings, which would fail the connection). Version bumped to 0.2.7.

Why

A LISTEN connection is idle by nature: it sends nothing and waits for NOTIFYs. When the server's address goes silent without closing the socket — an RDS Multi-AZ failover, a network partition, a dead node — the client never receives a FIN/RST, the kernel keeps the socket ESTABLISHED forever, and the listener is deaf with no error and no event to react to. Publishers in the same process notice on their next pg_notify, reconnect by DNS name and land on the new primary — so the process ends up notifying the new primary while still listening on the old one. This is exactly what happened to opal-server on an RDS failover (details in the internal incident report).

libpq-based clients get SO_KEEPALIVE for free (keepalives=1 is the libpq default); asyncpg exposes no keepalive option at all — MagicStack/asyncpg#606 (open since 2020; the maintainer's suggested workaround is exactly this: set it on the transport socket) and #519. AWS's own guidance for Postgres failover is to turn TCP keepalives on, aggressively (Fast failover with Aurora PostgreSQL).

Scope: this only makes the dead connection error; reconnecting is, as before, the caller's job (the subscriber gets Unsubscribed, the termination listener fires). It does not cover a peer whose kernel is alive but whose Postgres is mute — TCP keepalive cannot see that; an application-level heartbeat is the tool for that case.

Verification

Isolated lab: postgres:16 + this backend on one docker bridge network, the server cut with docker network disconnect (the address goes silent, like a failover — unlike docker stop/kill, which send FIN/RST and were always detected in ~1 s):

backend subscriber learns the connection died after
0.2.6 never (socket still ESTABLISHED at +10 min)
this PR, defaults 30/10/3 61.8 s
this PR, ?keepalives_idle=5&keepalives_interval=2&keepalives_count=3 12.3 s

Tests (tests/test_postgres_keepalive.py, 11 tests): URL parsing/stripping/defaults/keepalives=0/non-integer → ValueError; apply_tcp_keepalive on a real TCP socket (asserts getsockopt), never raises on a bad socket; end to end against the test Postgres: pooled connections carry the options by default, honour URL params, and keepalives=0 leaves the socket alone. Ran locally on Python 3.11 against postgres:16, plus test_memory/test_postgres from the existing suite.

Notes for reviewers

  • getsockopt(SO_KEEPALIVE) returns 8 on macOS and 1 on Linux — tests assert non-zero.
  • The tests reset PostgresBackend._pools around each test: the class-level pool cache is bound to the event loop that created it and pytest-asyncio gives each test its own loop (pre-existing; surfaced by having more than one Postgres test).
  • .venv/ added to .gitignore.

Release

After merge: tag 0.2.7publish.yml builds and uploads (scripts/publish, needs the PYPI_TOKEN secret; 0.2.6 appears to have been uploaded by hand in Dec 2024 — worth confirming the secret exists before relying on the workflow). Then bump permit-broadcaster[postgres,redis,kafka]==0.2.7 in OPAL.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Nxg7e1Jtk5qykAdYoYENdw

…2.7)

A LISTEN connection is idle by nature. When the server's address goes silent
without closing the socket — an RDS Multi-AZ failover, a network partition, a
dead node — the client never receives a FIN/RST, the kernel keeps the socket
ESTABLISHED forever and the listener is deaf with no error. Publishers on the
same process notice on their next write and reconnect by DNS name, so the
process ends up notifying the new primary while listening on the old one.

libpq clients get SO_KEEPALIVE for free (keepalives=1 is the libpq default);
asyncpg exposes no such option (MagicStack/asyncpg#606, open since 2020), so the
backend sets it on the socket via the pool's `init` hook. Defaults: on, 30 s
idle, 10 s between probes, 3 lost probes -> detected within ~60 s. Tunable with
the libpq parameter names in the URL (keepalives, keepalives_idle,
keepalives_interval, keepalives_count); the parameters are stripped before the
DSN reaches asyncpg, which would otherwise forward them to the server as
session settings.

Verified in an isolated lab (postgres:16 + this backend on one docker network,
server cut with `docker network disconnect`): before, the subscriber never
learns the connection died (socket still ESTABLISHED after 10 min); after,
Unsubscribed is raised at 61.8 s with the defaults and 12.3 s with 5/2/3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nxg7e1Jtk5qykAdYoYENdw
Zivxx added a commit to permitio/opal that referenced this pull request Aug 19, 2026
…roadcaster 0.2.7 sets it on the connection; keep the silence watchdog

TCP keepalive belongs in the library that owns the socket, not in OPAL.
permitio/broadcaster#28 (permit-broadcaster 0.2.7) sets SO_KEEPALIVE /
TCP_KEEPIDLE / TCP_KEEPINTVL / TCP_KEEPCNT on every pooled asyncpg
connection via the pool's init hook — on by default (30 s / 10 s / 3, so an
unreachable peer is detected within ~60 s), tunable with the libpq-style
keepalives, keepalives_idle, keepalives_interval and keepalives_count
query parameters of the broadcast URI. That makes OPAL's
broadcaster_keepalive.py (which reached the same socket by re-binding the
asyncpg name inside broadcaster._backends.postgres to inject an init=) and
the BROADCAST_TCP_KEEPALIVE_* keys redundant; removed, with their tests and
docs.

What stays is the part the library cannot do: the backend-agnostic reader
silence watchdog (BROADCAST_KEEPALIVE_INTERVAL 60 heartbeat,
BROADCAST_READER_SILENCE_TIMEOUT 180, heard-once latch, first-message grace,
trip floor, broadcaster_reader_silent / broadcaster_silence_trips metrics),
which also catches the case TCP keepalive cannot see — a peer whose kernel
still answers probes while its backbone delivers nothing — and the hard
terminate() of the dead listening connection before release, so the
reconnect path never runs UNLISTEN/RESET on a half-open socket.

Tests: 342 pass (348 before; the 6 removed are the keepalive-hook tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nxg7e1Jtk5qykAdYoYENdw
@Zivxx
Zivxx merged commit a358d1f into master Aug 20, 2026
4 checks passed
Zivxx added a commit to permitio/opal that referenced this pull request Aug 23, 2026
…on the Postgres backbone)

0.2.7 enables TCP keepalive on every pooled asyncpg connection of the
broadcaster's Postgres backend (on by default: idle 30 s, interval 10 s,
count 3; tunable with libpq-style keepalives_* parameters in the broadcast
URL). Without it, a LISTEN connection whose server address goes silent — an
RDS Multi-AZ failover — stays ESTABLISHED forever: the worker's broadcaster
reader is deaf while its publisher has long reconnected to the new primary.
With it, the dead connection errors within ~60 s and the existing reconnect
loop recovers.

Verified end to end on the staging scale bed (rc.2 + 0.2.7 overlay image,
forced Multi-AZ failover): every worker detected the dead listener in ~30 s,
reconnected and resynced in 2-5 s, and a fleet-wide publish converged with
zero intervention. See permitio/broadcaster#28.

Full test suite passes against the published 0.2.7 (342 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nxg7e1Jtk5qykAdYoYENdw
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants