Skip to content

fix: multi-replica HA — converge cross-pod subscriptions (#1053) + confirm death before reaping (#1052) - #1056

Open
oeway wants to merge 2 commits into
mainfrom
fix/1053-cross-pod-subscription-convergence
Open

fix: multi-replica HA — converge cross-pod subscriptions (#1053) + confirm death before reaping (#1052)#1056
oeway wants to merge 2 commits into
mainfrom
fix/1053-cross-pod-subscription-convergence

Conversation

@oeway

@oeway oeway commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Two tightly-coupled multi-replica (N≥2 pods) HA fixes. #1052 is a direct consequence of the #1053 window, so they ship together as one coherent fix (0.21.136).

#1053 — cross-pod targeted subscriptions never converge (root cause)

RedisEventBus.subscribe_to_client_events optimistically recorded a targeted psubscribe pattern (targeted:<ws>/<client_id>:*) in _subscribed_patterns before confirming the psubscribe. On a timeout/error the pattern stayed recorded-but-unwired, and the if pattern not in self._subscribed_patterns guard turned every future attempt into a no-op → a permanent cross-pod black hole for that client's targeted messages. Masked for same-pod callers by the local short-circuit in emit.

Fix: separate the DESIRED set (_subscribed_patterns) from the CONFIRMED set (_confirmed_patterns, actually wired to the live pubsub); guard on CONFIRMED. Three convergence paths:

  1. a subsequent subscribe_to_client_events call retries the unconfirmed pattern;
  2. reconnect re-wires every desired pattern (_rewire_desired_patterns, never discards on failure) — and clears the circuit breaker before re-wiring so a stale-open breaker can't refuse to wire on a fresh healthy pubsub;
  3. a background reconciler loop (_reconcile_loop, HYPHA_SUBSCRIPTION_RECONCILE_INTERVAL, default 5s) retries desired − confirmed — required because subscribe_to_client_events is called only once per client at register.

Tests: tests/test_cross_pod_subscription_convergence.py (reproduce-before-fix: a dropped targeted psubscribe is later reconciled; the reconcile loop re-wires without a second subscribe call; reconnect re-wire preserves desired and never discards).

#1052 — orphan reaper deletes live clients after a single dropped ping (defense-in-depth)

The #15 continuous reaper deleted all of a client's services:* keys after a single failed cross-pod ping. The ping is best-effort (Redis pub/sub has no buffering), so one dropped message during the #1053 convergence window made the reaper delete a live client's registration — a silent, permanent outage until manual restart.

Fix: track per-client consecutive probe failures across reaper passes and only reap after HYPHA_ORPHAN_REAP_MIN_FAILURES (default 3) consecutive failures. Any successful probe resets the counter; a client that drops out of the candidate set is pruned. At the default 300s interval, 3 failures span ~10 min ≫ the convergence/reconcile window, so only genuinely-dead clients are reaped.

Tests: tests/test_orphan_reaper_confirmation.py (single dropped ping does not reap; N consecutive failures do reap; a recovered probe resets the counter; a disappeared candidate is pruned). The two existing single-pass reaper tests set MIN_FAILURES=1 to preserve their concurrency/startup-cleanup focus.

New env knobs

  • HYPHA_SUBSCRIPTION_RECONCILE_INTERVAL (default 5.0s)
  • HYPHA_ORPHAN_REAP_MIN_FAILURES (default 3)

Blast radius / review note

Core event-bus subscription path — affects every deployment (single- and multi-pod). Docker-free tests pass locally; multi-replica integration tests run in CI. Do not merge without maintainer (@oeway) sign-off despite green CI, given the all-deployment blast radius.

🤖 Generated with Claude Code

….136)

RedisEventBus.subscribe_to_client_events recorded a targeted psubscribe
pattern in `_subscribed_patterns` optimistically — BEFORE the psubscribe
was confirmed — and on a psubscribe timeout/error left the pattern
recorded-but-unwired. The `if pattern not in self._subscribed_patterns`
guard then turned every later subscribe into a no-op, so the pattern was a
permanent cross-pod black hole: targeted RPC delivered via the Redis
`targeted:<ws>/<client_id>:*` channel was silently dropped forever. This
was masked for same-pod callers by the local short-circuit in `emit`, and
it is the root cause of the #1052 orphan-reaper false-reap (a live client
fails a single cross-pod ping precisely because of this gap, then the
#15 reaper deletes its services:* keys).

Fix: separate the DESIRED set (`_subscribed_patterns`, what we want wired)
from the CONFIRMED set (`_confirmed_patterns`, actually wired to the live
pubsub). The membership guard is now keyed on the confirmed set, so a
desired-but-unconfirmed pattern is retried instead of no-op'd, via three
paths: (a) a subsequent subscribe call, (b) reconnect re-wire that never
discards a desired pattern on transient failure, and (c) a new background
reconciler loop (`_reconcile_loop`, HYPHA_SUBSCRIPTION_RECONCILE_INTERVAL,
default 5s) — required because subscribe_to_client_events is called only
ONCE per client at register time, so register-time-timeout convergence
cannot rely on a later subscribe call. On reconnect the circuit breaker is
cleared BEFORE re-wiring targeted patterns so a stale-open breaker cannot
refuse to wire them on the fresh healthy pubsub.

Tests (tests/test_cross_pod_subscription_convergence.py, Docker-free, two
RedisEventBus instances sharing one fakeredis = two pods on one Redis,
reproduce-before-fix): register-time psubscribe timeout is reconciled via
retry; the reconciler re-wires without a second subscribe call; a reconnect
re-wire preserves every desired pattern and never discards a transiently
failing one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added bug Something isn't working documentation Improvements or additions to documentation labels Aug 19, 2026
… a client

The #15 continuous orphan reaper deleted ALL of a client's services:* keys
after a SINGLE failed cross-pod ping. The cross-pod ping is best-effort (Redis
pub/sub has no buffering), so a single dropped message during a reconnect /
subscription-convergence window (the #1053 window) made the reaper delete a
LIVE client's registration — a silent, permanent outage until manual restart.

Defense-in-depth on top of the #1053 root-cause fix: track per-client
CONSECUTIVE probe failures across reaper passes (_orphan_probe_failures) and
only reap after HYPHA_ORPHAN_REAP_MIN_FAILURES (default 3) consecutive
failures. A client that answers any probe has its counter reset; a client that
drops out of the candidate set has its counter pruned. At the default 300s
interval, 3 failures span ~10 min — far longer than any transient
unreachability window — so only a genuinely dead client is reaped.

Tests: tests/test_orphan_reaper_confirmation.py (single dropped ping does not
reap; N consecutive failures do reap; a recovered probe resets the counter; a
disappeared candidate is pruned). Existing single-pass reaper tests set
MIN_FAILURES=1 to preserve their concurrency/startup-cleanup focus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@oeway oeway changed the title fix(event-bus): #1053 converge cross-pod targeted subscriptions fix: multi-replica HA — converge cross-pod subscriptions (#1053) + confirm death before reaping (#1052) Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant