Recover PRISM watchdog restarts in ~1s instead of 60s+ - #111
Merged
Conversation
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9f4c710a0c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 9f4c710. Configure here.
A heartbeat-prefixed session token promises a guarded heartbeat session, but if the dedicated PostgreSQL connection dropped between ledger construction and serve(), _start_ledger_lease_heartbeat silently skipped both the heartbeat and its monitor. A replacement could then adopt the released guard after one silence window while this coordinator kept serving as a fenced zombie. Distinguish ledgers that never required a guard from a required guard that has been lost, and hard-exit in the latter case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the lease heartbeat or monitor thread arms _watchdog_hard_exit, the release worker runs _stop_ledger_lease_heartbeat, which joined those same threads while they sat blocked joining the release worker. The circular wait consumed the heartbeat exit budget, so the fresh-connection lease release never ran on this path and recovery always fell back to silence adoption. Record the thread driving the exit and treat it as already stopped: it has set the failure event and can never renew again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The external lease fence and the periodic heartbeat serialize on the guard session's query lock, while the fence join budget equaled the guard statement timeout. A fence arriving during an in-flight heartbeat renewal could burn its whole budget queued on the lock and hard-exit a coordinator whose session was still healthy. The guard now reports when the serialized query slot is acquired, and the fence grants the queue wait the heartbeat failure budget - past which the monitor is already declaring the session dead - before starting the execution budget. Renewal doubles without the callback keep the previous single-budget behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
djh58
pushed a commit
that referenced
this pull request
Aug 12, 2026
…ence (1.x.x) (#114) * Stop PRISM self-fencing during accepted-block persistence Commit c374129 (#111) renewed the writer lease on the dedicated advisory-guard connection (statement_timeout=500ms). persist_accepted_block opens its CTE by updating the same qbit_ledger_writer_lease tuple and holds that row lock for the whole transaction, so any accepted block that takes longer than the timeout killed the heartbeat with SQLSTATE 57014, the coordinator hard-exited mid-finalization, and the Docker replacement adopted the lease and died the same way (block 39416 looped eight times at ~4m20s). Make the guarded-session heartbeat a non-locking liveness check: the guard connection now proves the session answers, still holds the advisory lock in pg_locks, and the committed lease row still names the exact session - it never touches the tuple fenced writes lock. The external-side-effect fence uses the same verification, so submitblock/CTV preflights no longer contend with in-flight persistence either. Lease TTL freshness is unchanged: every fenced write and each idle daemon pass still renews through the fenced path. Because updated_at can now legitimately look stale during a long fenced transaction, adoption silence is measured from two edges and the later one wins: the lease row's updated_at age and the successor's own advisory-guard acquisition. A successor therefore always grants its predecessor a full silence interval to self-fence after guard loss, no matter how old the row already is. The exact-session CAS and fail-closed handling of a broken or fenced-out guard session are unchanged. Also repairs pre-existing test fakes on 1.x.x whose SimpleNamespace pending shares lacked job_issued_at_ms/accepted_at_ms, which broke two shutdown tests and hung the vardiff suite in an infinite append retry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Renew the PRISM lease TTL from the heartbeat without lock waits The non-locking guarded-session heartbeat stopped advancing lease_expires_at entirely, so an idle coordinator (no fenced writes for a lease TTL, CTV broadcaster disabled) let the singleton row expire while alive; a different writer identity could then seize it through the expiry CAS because its advisory-lock key differs from the incumbent's. Fold an exact-identity TTL renewal into the verification statement using FOR NO KEY UPDATE SKIP LOCKED: the heartbeat renews whenever the lease tuple is uncontended and skips without queueing while a fenced transaction (persist_accepted_block) holds the row lock, preserving the no-self-fencing guarantee this branch introduced. Addresses the Codex P1 review finding on PR #114. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

Incident being solved
Mainnet, 2026-08-11 01:55 UTC. The PRISM coordinator's liveness watchdog fired and hard-exited:
Docker restarted the container in ~130 ms, but the replacement process could not open its Stratum listeners for ~60 seconds, because the dead process's Postgres ledger-writer lease was still held. Startup logged this on a 15 s poll until TTL expiry:
That 60 s window was a paging production outage: public Stratum subscribe timeouts plus sidecar
readyz503s. Runtime config at the time:PRISM_LEDGER_LEASE_TTL_SECONDS=60,PRISM_LEDGER_WRITER_EPOCH=1,PRISM_WRITER_QUIESCENCE_TIMEOUT_SECONDS=15.Root causes
watchdog_loophad threeos._exit(1)call sites (liveness overdue-heartbeats, publication-progresscoordination, andpublication). All three bypassed the graceful lease-release machinery that already existed for the SIGTERM path (claim_lease_release/finish_lease_release/lease_release_withheld).Net effect of this PR: watchdog-exit downtime goes from ~TTL (60 s) + poll jitter (up to 15 s) down to container restart + O(1 s).
What changed
1. Bounded lease release on watchdog exit
All three
os._exit(1)sites now route throughPrismCoordinator._watchdog_hard_exit(reason), which spawns a fresh daemon thread to attempt release under a hard ~5 s wall-clock deadline (DEFAULT_PRISM_WATCHDOG_LEASE_RELEASE_TIMEOUT_SECONDS) and thenos._exit(1)s from afinallyblock regardless of outcome. Nothing — timeout logging, thread-start failure, a blocked log pipe — can extend or suppress the terminal action.The watchdog fires precisely because other threads are wedged, so the release path is careful about what it touches:
shutdown(), which takesself.lock— potentially held by the very subsystem that stopped making progress. It closes writer admission via the shutdown controller and uses the controller's tracked-writer barrier instead, which preserves the graceful path'slease_release_withheldinvariant (never release a lease while writer operations are still in flight, or a successor could interleave with an unfinished write) without that lock.release_writer_lease_fresh_connection(), a one-shotpsqlfork, rather than the shared native connection pool, which may be exhausted or wedged.wait_for_writer_quiescence()now accepts an explicit timeout so the watchdog can bound it by its own deadline rather than the 15 s config value._watchdog_exit_diagnostic), so a full container log pipe cannot park the process before the release attempt.2. Same-identity lease adoption at startup
When the observed holder has our writer id and our epoch but a different session token, the replacement fences and adopts immediately instead of waiting out the TTL. The safety analysis is the bulk of the work here, because the dangerous case is a live twin (old container still running through a compose recreate) which will keep renewing.
The chosen invariant is a three-layer proof, documented in comments at
_writer_lease_adoption_wait_secondsand_try_adopt_writer_lease:pg_try_advisory_lock(hash(writer_id, epoch))on a dedicated, never-transparently-replaced connection (_NativePostgresLeaseGuard). A live twin still holds that lock, so a replacement blocks at guard acquisition and never even reaches lease polling. Losing the connection loses the lock, which is what makes it a real liveness signal rather than a timestamp guess.lease_adoption_silence_seconds(default 1.0 s) before any takeover — giving a holder whose guard connection dropped time to notice and self-exit._try_adopt_writer_leaseupdates only where(writer_id, writer_epoch, writer_session_token, updated_at)all match what was observed. Postgres row-lock ordering makes this the final fence: a predecessor renewal that wins first changesupdated_atand the CAS affects zero rows; if the CAS wins first, every later predecessor mutation is fenced out. A CAS loss re-observes the owner and requires a fresh full silence interval rather than permanently blacklisting the token (permanent refusal would recreate the original TTL outage if that renewal was the holder's dying act).Different writer id or different epoch is never adopted — those keep ordinary TTL fencing.
Fast adoption is opt-in per session via the
heartbeat-v1:session-token prefix, which only the coordinator assigns. psql-only deployments cannot hold a session advisory lock; they downgrade the token before publishing it, logprism ledger writer fast adoption disabled: ..., and retain TTL fencing. Other ledger users are never treated as fast-adoptable merely because they share an identity.3. Lease heartbeat + liveness monitor
For the silence proof to be sound, a healthy coordinator must be visibly live.
ledger_lease_heartbeat_looprenews on the guard's isolated session every 0.25 s — four renewals inside the 1 s silence window.ledger_lease_heartbeat_monitor_looppolls every 0.05 s and hard-exits if the last success is older than 0.75 s, so a coordinator whose heartbeat wedges is gone before Postgres could ever observe a full second of silence. Startup blocks until the first heartbeat lands, and hard-exits if it doesn't arrive inside the same budget.4. Per-RPC exact-session fence before external side effects
The periodic heartbeat is an early process-liveness detector, not an authorization oracle:
CLOCK_MONOTONICmay not advance across host suspend, and a Postgres connection object doesn't necessarily know its server session died until the next I/O. So_require_fresh_ledger_lease_for_external_side_effect(component)performs a synchronous, bounded (0.5 s) exact-session renewal on the guard connection before every external mutation —submitblock, and the CTV broadcaster'sgetnewaddress/signrawtransactionwithwallet/submitpackage/sendrawtransaction(wired through the newbefore_external_side_effecthook onCtvFanoutBroadcaster). It runs on a daemon worker so a dead network path is bounded even when the driver's server-side statement timeout can't help. Failure or timeout hard-exits and raisesShutdownInProgress— fail closed.Relatedly,
JsonRpc._callno longer applies its transparent one-shot transport retry to mutating methods (_QBIT_RPC_NO_TRANSPORT_RETRY_METHODS). The lease fence applies to the outer call, so an invisible second POST could otherwise land after the lease was lost. Read-only calls keep the retry. Durable block/CTV workflows retry later as a new, fully fenced operation.5. Exit-code discrepancy (forensics)
The incident report showed Docker recording
ExitCode=0despiteos._exit(1). Investigation found no code swallowing the status: the PRISM image has no entrypoint or init wrapper, its exec-formCMDstartspython3 -m lab.prism.prism_coordinatoras container PID 1, and neither compose file overrides it — so status 1 reaches Docker unchanged.The
ExitCode=0reading was an artifact of method:docker inspectreports the container's current execution state, not an invocation history, and shows 0 once the replacement is already running.docs/mainnet-deployment.mdnow documents this, along with the correctdocker events --filter event=dieincantation to capture the failing invocation's realexitCode, and a Dockerfile comment warning that any future wrapper mustexecthe coordinator or explicitly propagate its status.Two additional fixes found in review
Serve-path
AttributeError(commit4f695ad)._serve_with_listener_stackcalls_start_ledger_lease_heartbeat()as its first statement, and that method dereferencedself.ledgerdirectly. Coordinators built by test fixtures viaPrismCoordinator.__new__(bypassing__init__, which is what setsself.ledger) enteredserve()without the attribute and crashed withAttributeError: 'PrismCoordinator' object has no attribute 'ledger'— breakingtests/test_prism_stratum_restart_bind.pyand therefore CI'spython -m unittest discover. Fixed in production code, not the test: a missingledgeris now treated exactly like a non-fast-adoption-capable ledger (getattr(self, "ledger", None)), returningNoneand starting no threads. The same defensive pattern was applied to the other fixture-reachable paths added by this branch (ledger_lease_heartbeat_loop,_require_fresh_ledger_lease_for_external_side_effect,release_ledger_lease); paths unreachable without a ledger were left alone. No behavior change whenself.ledgeris present.Freshness-stamp regression (commit
9f4c710). Two paths wrote_ledger_lease_heartbeat_last_success_monotonicwith plain assignment of their own call-start time: the heartbeat thread, and the per-RPC fence (which writes on the calling thread after joining a worker with up to a 0.5 s budget). Neither guarded against regression, so a slower writer could overwrite a fresher stamp with an older one. Concrete failure: fence starts at T0, its worker renew succeeds at T0+0.45, the heartbeat stamps T0+0.25 and T0+0.5, then the fence caller is descheduled (GIL convoy, throttled cgroup) and at T0+0.75 writes T0 — the 0.05 s monitor sees age ≥ 0.75 s and hard-exits a coordinator whose heartbeat and advisory guard are perfectly healthy. Availability-only (the stamp is a liveness signal, not an authorization check; both writes are legitimate successful renews — only the ordering was wrong).Fixed by funneling both writers through
_record_ledger_lease_heartbeat_success(), which takes a dedicated lock (deliberately not_ledger_lease_heartbeat_failure_lock) and applies a monotonicmax(). The initial arm-time assignment in_start_ledger_lease_heartbeatis left as-is. Conservative call-start stamping is preserved at both sites — a delayed response must never look fresher than what the database actually proved. Fencing decisions and hard-exit-on-failure semantics are otherwise unchanged.Testing
python3 -m unittest tests.test_prism_coordinator_shutdown tests.test_prism_share_ledger tests.test_prism_stratum_restart_bind tests.test_prism_coordinator_vardiff tests.test_prism_progress_health tests.test_ctv_broadcaster tests.test_ctv_broadcaster_daemon→ 594 tests, OK.New coverage (~1000 lines across 5 test files) includes:
_exits when the DB hangs (blocking fake), and never runs on the watchdog thread itself._start_ledger_lease_heartbeat()returnsNoneand starts no threads on a coordinator with noledgerattribute.python3 -m unittest discover -s tests -p 'test_*.py'shows no new failures. The pre-existing macOS/Python 3.14 failures (5 splice/spool tests intest_prism_coordinator_job_cache) reproduce identically onorigin/1.x.xand are out of scope.Open risks
docs/mainnet-deployment.md.PRISM_POSTGRES_NATIVE_CLIENT=autoor1). psql-only deployments log the downgrade and keep TTL fencing — correct, just not fast.Commits
619a7325f07ce1fcaa24befe51c94f695ad9f4c710🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Note
High Risk
Touches security-critical writer-lease fencing, dual-writer prevention, and mutating block/CTV RPC paths. A fencing bug could allow concurrent writers or block external side effects incorrectly.
Overview
Cuts PRISM watchdog-restart downtime from ~lease TTL (60s+) to ~1s by releasing the writer lease on hard exit and allowing safe same-identity takeover at startup.
Watchdog lease release. All watchdog
os._exit(1)paths now go through_watchdog_hard_exit, which best-effort releases the lease on a fresh daemon thread under a ~5s deadline, then exits unconditionally. Release uses a one-shot psql connection and the shutdown controller's writer barrier (notshutdown()/self.lock) so a wedged subsystem cannot block termination.Fast same-identity adoption. Replacements can adopt a silent same-writer/same-epoch predecessor after acquiring a dedicated PostgreSQL advisory guard, observing ~1s of silence, and winning an exact-session CAS. Live twins keep the guard and cannot be fenced out. psql-only deployments downgrade to TTL fencing.
Heartbeat + external fences. A dedicated heartbeat/monitor keeps guarded sessions visibly live and hard-exits before silence can be observed. Mutating RPCs (
submitblock, CTV wallet/broadcast calls) now do a bounded exact-session renewal first, andJsonRpcno longer transparently retries those methods.Also documents watchdog exit-code forensics and the residual preflight window between Postgres verification and qbitd/wallet RPC.
Reviewed by Cursor Bugbot for commit 5ef0b15. Bugbot is set up for automated code reviews on this repo. Configure here.