fix(opal-server): broadcaster reader liveness — silence watchdog for a half-open LISTEN after a DB failover - #946
Draft
Zivxx wants to merge 16 commits into
Draft
fix(opal-server): broadcaster reader liveness — silence watchdog for a half-open LISTEN after a DB failover#946Zivxx wants to merge 16 commits into
Zivxx wants to merge 16 commits into
Conversation
…ctions A listening (LISTEN) connection never writes, so a peer that vanishes without FIN/RST (database failover moving the endpoint to another host, a silent packet drop) leaves the socket ESTABLISHED and the reader deaf forever. SO_KEEPALIVE with idle 30 s / interval 10 s / count 3 lets the kernel declare such a peer dead in ~60 s; asyncpg then reports the connection closed and the existing reconnect + resync path runs. Applied to every pooled connection via an init hook on the broadcaster backend's create_pool (patched by name inside that one module only — asyncpg exposes no client-side keepalive parameter and the backend builds the pool itself), and re-applied to the reader's own listening connection on connect. Fail-open: unsupported platforms log and carry on.
…onnection is a gap, not a healthy wait ReconnectingBroadcaster now records the monotonic time of the last backbone message of any kind and, when reader_silence_timeout > 0, wakes every silence_poll_seconds while reading; silence longer than the timeout (with a floor between trips) raises BackboneSilent, which the reader loop treats like any failed session: gap generation bumped, the dead listening connection TERMINATED (asyncpg terminate — so the pool cannot hand the half-open socket back to the reconnect), reconnect with backoff, gap recovery (replay + client resync) on re-subscribe. is_reader_healthy() reports False from the trip until re-subscribe, so /healthcheck and the reader-health gauge reflect a deaf worker instead of 200/1. The listening connection also gets TCP keepalive on connect when configured. Observed live: after a Multi-AZ failover of the broadcaster DB the reader's LISTEN socket to the old primary stayed ESTABLISHED; one client received 4 updates in 9 h instead of ~1/min and nothing alerted.
…L 3600->60, BROADCAST_READER_SILENCE_TIMEOUT 180, BROADCAST_TCP_KEEPALIVE_* The keepalive is now the heartbeat the silence watchdog listens for, so it is published every 60 s instead of hourly. effective_reader_silence_timeout() keeps the two coupled: disabled when the keepalive is off (a quiet channel would look dead), raised to 2x the keepalive interval if set lower (one late keepalive can never trip it). TCP keepalive is installed on the Postgres pool when the URI is postgres and BROADCAST_TCP_KEEPALIVE_ENABLED (default on).
…s, unhealthy while tripped, short silence/traffic/disabled/pre-subscribe never trip, trip floor, terminate-before-release, keepalive on listener and pool hook, config coupling
…TERVAL (60), BROADCAST_READER_SILENCE_TIMEOUT and BROADCAST_TCP_KEEPALIVE_*
… false (no heartbeat from this server)
…ike the library's broadcaster._base.Subscriber.__aiter__ is an async generator: cancelling one in-flight __anext__ (the asyncio.wait_for pattern) throws CancelledError into it and closes it for good. The previous fake tolerated that cancellation, so a watchdog that polled by cancelling the iterator looked fine under test and reconnected on every poll against the real library. The fake now has the same trap; the 44 existing reconnect tests are unchanged and still pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nxg7e1Jtk5qykAdYoYENdw
…p after the handler; arm at first message; no health flip
Review round 1 (C1, H2, H3, M4):
- C1: poll the subscriber through a _WatchedIterator that keeps ONE pending
__anext__ future across polls (asyncio.wait with a timeout) and cancels it
only at a real trip, when the session is being ended anyway. The old
wait_for-per-poll closed the library's async-generator subscriber on every
poll, which read as 'subscriber ended' and reconnected every second. Repro
against the real broadcaster memory backend: connects=1 over 3 s (was a
reconnect storm), and a real silent trip reconnects exactly once and resumes
delivery.
- H2: the last-message stamp is taken before AND after _handle_broadcast_event,
so a fan-out slower than the timeout is not silence.
- H3: a trip does not flip is_reader_healthy (mid-reconnect is the same state as
a peer-announced disconnect; readiness must keep the worker so clients can
still fetch). Observability instead: gauge opal_server.broadcaster_reader_silent
{pid} and counter opal_server.broadcaster_silence_trips {pid}.
- M4 (clock): the silence clock arms at the first backbone message of each
subscription, so a backbone that is down from boot is the connect-retry path's
problem, not a counted silence trip.
- L6: one INFO line when the backend exposes no Postgres connection to apply
TCP keepalive to.
Tests: 19 in broadcaster_reader_liveness_test.py; each of these mutations is
caught by at least one test: watchdog never trips, stamp only before handler,
clock armed at subscribe, health flips on silence, no terminate before pool
release, cancelling the pending __anext__ on poll timeout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nxg7e1Jtk5qykAdYoYENdw
… the leader The keepalive is the backbone heartbeat every worker's silence watchdog listens for; on a large scopes fleet load_scopes can outlast the watchdog timeout. Still inside the leadership lock, still one publisher per pod; the wait_until_done branch (no policy watcher) is unchanged. Guarded by a test that asserts the ordering. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nxg7e1Jtk5qykAdYoYENdw
…tall), explicit None checks, per-connection docstring L7/L8/L9 from review round 1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nxg7e1Jtk5qykAdYoYENdw
…cs; clock arming; Postgres-only keepalive Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nxg7e1Jtk5qykAdYoYENdw
…ibe — no half-open blind spot before a session's first message Review round 2, H-A. Arming only at the FIRST MESSAGE of each subscription left a permanent blind spot: a subscription that succeeds and then goes half-open before its first message (a post-failover reconnect landing on a stale endpoint — this PR's own scenario one step earlier) was never detected, with no trip, no log line and health True. On Postgres the TCP keepalive covers it; on Redis/Kafka, with keepalive disabled, or where the socket option fail-opens, the watchdog covered nothing while the INFO line and the docs said it did. Now: until this process has heard the backbone at least once (_ever_heard_backbone) the clock stays unarmed — the boot property is kept. After that, every subscription is armed at subscribe time with a first-message grace of max(timeout, 2 x keepalive) (pubsub.py silence_first_message_grace), which ends at the session's first message. The INFO line is now accurate (watchdog covers a session once the worker has heard the backbone at least once; before that only TCP keepalive could). Also L-1 (unread 'done' in the timeout-None branch removed) and L-2 (CancelledError catch in _WatchedIterator.close explained; redundant StopAsyncIteration dropped). Tests: +4 (later session armed at subscribe, trips once after the grace; never-heard process never trips across resubscribes; grace ends with the first message; grace helper >= 2 x keepalive); the first test now asserts a later session that speaks within the grace does not re-trip. Mutations caught: latch never set; arm every subscribe regardless of latch; grace ignored; grace never ends. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nxg7e1Jtk5qykAdYoYENdw
… for a steady baseline The gauge is edge-triggered inside the reader (L-3); the probe now emits it next to broadcaster_reader_healthy on every call so it reads 0 between trips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nxg7e1Jtk5qykAdYoYENdw
…; honest wording for non-Postgres backbones Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nxg7e1Jtk5qykAdYoYENdw
…fore the first heard message
✅ Deploy Preview for opal-docs ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
…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
marked this pull request as draft
August 20, 2026 11:21
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.
fix(opal-server): broadcaster reader liveness — a half-open backbone connection must not leave a worker deaf
What happened (the finding)
During a staging campaign the broadcaster Postgres (AWS RDS, Multi-AZ) was failed over. The failover itself took ~40 s and the database name moved to the standby host. Every worker's broadcaster reader — the long-lived
LISTENconnection through which it hears what other workers publish — kept its TCP socket to the old primary in stateESTABLISHED: the old host was taken down without a FIN/RST, and a listening socket never writes, so nothing on the client side ever noticed. Result, for hours, on every worker whose reader had been connected at that moment:is_reader_healthy()stayed True (the task was alive and pending),/healthcheck200;The existing reconnect + resync path handles announced disconnects (the peer closes, the read errors) — a DB stop/start, a pod restart. It had no way to notice a silent death. Two independent detectors close that: TCP keepalive at the socket (library, 0.2.7 — catches an unreachable peer within ~60 s) and, in this PR, an application-level silence watchdog (catches any backbone that stops delivering, whatever the reason, on any backend).
How
ReconnectingBroadcaster). The reader records the monotonic time of the last backbone message of any kind — own keepalives included, they prove the pipe — stamped before and after the event is handled (fan-out time is not silence). Arming: the clock is unarmed until this process has heard the backbone at least once (a slow boot, a single pod whose leader has not published yet, a keepalive publisher still starting can never trip it); from then on every subscription is armed at subscribe time, with a first-message grace of max(timeout, 2×keepalive) — so a session that goes half-open before its first message (a post-failover reconnect landing on a stale endpoint, i.e. this PR's own scenario one step earlier) is caught too, not a blind spot. While reading, the subscriber is polled through a small_WatchedIteratorthat keeps one pending__anext__across polls and wakes every second to compare againstBROADCAST_READER_SILENCE_TIMEOUT(default 180 s) — it never cancels the in-flight__anext__on a poll, because thebroadcasterlibrary's subscriber is an async generator and cancelling it closes it for good (that was the round-1 bug:wait_forper poll reconnected every second against the real library; the fake now has the same trap and a real-backend repro pins it). Silence beyond the timeout (with a 60 s floor between trips) raisesBackboneSilent, which the reader loop treats like any failed session: gap generation bumped, the dead listening connection terminated (asyncpgterminate()— so the pool cannot hand the half-open socket back to the very next connect, and the reconnect path never runsUNLISTEN *; RESET ALL;on a socket that will never answer), backoff, reconnect, and on re-subscribe the normal gap recovery (replay buffer + client resync). The keepalive publish interval goes from 3600 s to 60 s so a healthy fleet is never silent for long, and the leader starts publishing it beforeload_scopes(which can outlast the timeout on a big fleet);effective_reader_silence_timeout()keeps the two coupled — watchdog off when the keepalive is off or the publisher is disabled (a quiet channel is not a dead one), and the timeout is raised to 2× the keepalive interval if set lower so one late keepalive can never trip it./healthcheck. While the reader re-subscribes it is a pending task mid-reconnect — exactly the state a peer-announced disconnect leaves it in — and readiness must keep the worker so its clients can reconnect and fetch bundles/data (which need no backbone); the escalation for a backbone that never comes back is the existing give-up → graceful-shutdown path. Instead the state is visible: gaugeopal_server.broadcaster_reader_silent{pid}(1 while tripped and not yet re-subscribed) and counteropal_server.broadcaster_silence_trips{pid}. The gauge is re-emitted from/healthchecknext tobroadcaster_reader_healthyso it has a steady 0 baseline. A dashboard/alert on the counter is the follow-up in the deployments repo. Pre-existing gap property, unchanged: a message that arrives in the same tick as a trip is dropped with the session and reconciled by the resync that follows the reconnect.Will this trip on the tiniest hiccup?
No — the watchdog fires only on a peer that has stopped delivering for minutes, and a trip costs exactly what today's clean-disconnect path costs (one reconnect, one client resync, with the existing settle + jittered staggering):
A real backbone outage of any length already takes the reconnect + resync path today; this only adds the case where the outage is silent.
Config (conservative defaults)
OPAL_BROADCAST_KEEPALIVE_INTERVAL0disables bothOPAL_BROADCAST_READER_SILENCE_TIMEOUT0disablesTCP keepalive itself is configured on the broadcast URI (library, 0.2.7):
postgres://…/db?keepalives=1&keepalives_idle=30&keepalives_interval=10&keepalives_count=3— on by default with those values,keepalives=0to disable.Tests
opal_server/tests/broadcaster_reader_liveness_test.py(17 tests, in-memory backbone, no Postgres): a silent backbone trips the watchdog and reconnects exactly once (gap generation +1, task still pending, healthy throughout,broadcaster_reader_silent1→0, trips counter +1, no re-trip while the new session is unarmed); no trip before the first message however long the backbone is quiet, across resubscribes too; a later session (after the process has heard the backbone) is armed at subscribe and a silent one trips exactly once after the first-message grace; the grace ends with the session's first message; the grace helper is ≥ 2× keepalive; a short silence / steady traffic / watchdog disabled / backbone-down-from-boot never trip; a handler slower than the timeout is not silence; the trip floor prevents a storm; the dead listening connection is terminated before release and NOT on a clean close; the leader starts the keepalive publisher beforeload_scopes; config defaults and the keepalive/timeout coupling (incl. publisher disabled). The shared_FakeSubscriberis now a real async generator (same cancellation trap as the library's); the 44 pre-existing reconnect tests are unchanged and pass.Suite 324 → 342, all green (the 6 keepalive-hook tests left with the hook). Mutation-checked against the liveness tests — each of these edits is caught: watchdog never trips; stamp only before the handler; clock armed at subscribe instead of first message; health flips on silence (must not); no terminate before pool release; cancelling the pending
__anext__on a poll timeout (the round-1 bug); removing the heard-once latch; arming every subscribe regardless of the latch; ignoring the first-message grace; never ending the grace. Three repros against the realbroadcastermemory backend (not the fake): 3 s of a healthy-but-quiet backbone with a 30 s timeout →connects=1, gap_generation=0; a real silent trip →trips=1, reconnect, delivery resumes,healthy=True; an event whose fan-out takes 1.5 s then 2.2 s of genuine silence against a 1 s timeout → exactly one trip. pre-commit (black/isort/codespell/docformatter at the pinned versions) clean.Not in this PR / follow-ups
permit-broadcasterpin bump 0.2.6 → 0.2.7 (one line) once the library release is on PyPI.docker network disconnectthe DB without stopping it, assert recovery within ~2 min and that a 10 s blip does not resync). The bed's existing postgres-bounce test covers the announced-disconnect case only.Release note
opal-server: broadcaster reader liveness — a silence watchdog on the backbone reader (keepalive heartbeat every 60 s, 180 s of silence → terminate the dead listener, reconnect + resync); new metrics
broadcaster_reader_silent/broadcaster_silence_trips;/healthcheckis unchanged. Together withpermit-broadcaster0.2.7 (TCP keepalive on the Postgres connections) this fixes workers silently missing fleet updates after a broadcaster database failover.🤖 Generated with Claude Code
https://claude.ai/code/session_01Nxg7e1Jtk5qykAdYoYENdw