Postgres backend: TCP keepalive on pooled connections (detect half-open LISTEN after failover) — 0.2.7 - #28
Merged
Merged
Conversation
…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
omer9564
approved these changes
Aug 20, 2026
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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
LISTENconnection is idle by nature: it sends nothing and waits forNOTIFYs. 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 socketESTABLISHEDforever, and the listener is deaf with no error and no event to react to. Publishers in the same process notice on their nextpg_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_KEEPALIVEfor free (keepalives=1is 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 withdocker network disconnect(the address goes silent, like a failover — unlikedocker stop/kill, which send FIN/RST and were always detected in ~1 s):ESTABLISHEDat +10 min)?keepalives_idle=5&keepalives_interval=2&keepalives_count=3Tests (
tests/test_postgres_keepalive.py, 11 tests): URL parsing/stripping/defaults/keepalives=0/non-integer →ValueError;apply_tcp_keepaliveon a real TCP socket (assertsgetsockopt), never raises on a bad socket; end to end against the test Postgres: pooled connections carry the options by default, honour URL params, andkeepalives=0leaves the socket alone. Ran locally on Python 3.11 againstpostgres:16, plustest_memory/test_postgresfrom the existing suite.Notes for reviewers
getsockopt(SO_KEEPALIVE)returns8on macOS and1on Linux — tests assert non-zero.PostgresBackend._poolsaround 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.7→publish.ymlbuilds and uploads (scripts/publish, needs thePYPI_TOKENsecret; 0.2.6 appears to have been uploaded by hand in Dec 2024 — worth confirming the secret exists before relying on the workflow). Then bumppermit-broadcaster[postgres,redis,kafka]==0.2.7in OPAL.🤖 Generated with Claude Code
https://claude.ai/code/session_01Nxg7e1Jtk5qykAdYoYENdw