Submit found blocks to the node before accounting and keep the submitter alive under DB saturation (1.x.x) - #113
Merged
Merged
Conversation
Anatolie
force-pushed
the
fix/prism-submit-first-liveness
branch
from
August 12, 2026 07:15
cec4e91 to
223d2a0
Compare
Rebasing the submit-first fast lane onto the 1.x.x landing fences left the accounting decision unsynchronized with fenced predating appends: the fast lane no longer holds the fence across submitblock, so a landing could verify a pre-bump epoch while a predating row's durable commit was still in flight and persist a payout window omitting that row. Re-check the live append-invalidation epoch under the landing fence lock after a completed fast-lane offer, immediately before accounting: the fence can no longer gate the node offer, but it still gates accounting. The fallback (not-yet-offered) path keeps the base behavior of holding the fence across submitblock itself. Adapt the 1.x.x fence tests to submit-first semantics: the node offer now always goes out, so their assertions move from "submitblock must not run" to "accounting must abandon with append_epoch_stale", matching the pattern the durable-descendant fast-lane test already uses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Anatolie
force-pushed
the
fix/prism-submit-first-liveness
branch
from
August 12, 2026 17:06
223d2a0 to
c74fd00
Compare
A retryable candidate failure inside the block_accounting task (for example an attempt-marker statement timeout) slept out the exponential backoff while still holding accepted_block_handling writer admission and the disposition lease. That stalled every queued accounting task and kept an armed payout barrier blocking balance mutation for up to the 30s backoff cap — the exact convoy this branch exists to prevent, in its target saturation conditions. Record a per-hash not-before deadline instead (same escalation state) and honor it at both dequeue sites. The submitter-thread path keeps its existing heartbeating backoff wait, and replay_pending_block_candidates already short-circuits while the retained candidate occupies the retry slot, so a parked candidate adds no outbox churn during its backoff. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… the accounting queue A successful submitblock followed by a retryable failure (for example an attempt-marker statement timeout) dropped the fresh node result, so the in-process retry re-offered and read "duplicate" — classifying against the moved live tip and leaning on chain probes that may be unavailable under the same saturation, which could terminally abandon a block the node had already accepted. Retention now stashes a definitive acceptance and every offer site reuses it instead of re-asking the node, rerunning the landing tail exactly as if the first pass had continued past the failure. Ambiguous or rejected offers are never reused, and a process crash still converges through the durable replay path. Separately, the primary accounting handoff queue was constructed unbounded, so the documented result-preserving spillover ordering could never engage in production. It is now bounded (default depth 8, attribute-overridable); the overflow queue stays unbounded by design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5a563ca. Configure here.
The stash for definitive submitblock successes was written only at three retention sites, so retention paths that did not stash (the defer-accounting handoff failures among them) dropped a popped result on the floor, and even covered paths consumed the stash on a retry that then failed again — both re-opening the duplicate-reclassification hazard the stash exists to prevent. Record the entry once, in the universal post-offer hook, read it without consuming, and clear it only when the candidate reaches a terminal outcome. Every present and future retention path is covered without having to remember, and repeated retryable failures keep reusing the same known acceptance. The regression test now fails the attempt marker twice to pin survival across a consumed-and-failed retry. 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.

Summary
Found blocks were being lost because the PRISM coordinator did durable Postgres accounting before offering the block to qbitd. When production Postgres saturated, the
submitblockRPC sat behind a database write long enough for the tip to move, and the coordinator then abandoned a real block asstale-job. The same unbounded database wait wedged theblock_submitterthread entirely, tripping the liveness watchdog and restarting the process.This branch makes the node offer the fast lane — it happens before any accounting — bounds every blocking call the submitter can make, moves accounting onto its own thread so it can never convoy later block submissions, and adds per-phase heartbeats so a future watchdog firing names the exact call that stopped progressing.
Incident that motivated this (production, 2026-08-11)
01:50:52 block candidate abandoned reason=stale-job: tip moved before submit— the tip had moved ~80s earlier, meaning the submit had been stuck in the pipeline that entire time.unresponsive subsystems=['block_submitter'] timeout=300s, restarting the process and causing a paging outage.CreateNewBlockinstant.Root cause:
_submit_next_block_candidate_writercalled_mark_block_candidate_attempted(a durable Postgres write) beforesubmit_block_candidate(thesubmitblockRPC), and nothing on that path — lock acquisition, Postgres statements, or the RPC — had a timeout. A delayed submit is a lost block; a duplicate submit is harmless, since the node rejects blocks it already knows.What changed
1. Submit-first ordering (
c18a661)_submit_block_candidate_to_nodenow offers the durable candidate to qbitd before the attempt-marker write, writer admission, audit construction, or payout publication. The node result plus the same-hash disposition lease then transfer to the accounting tail.Crash-point analysis for the new ordering (duplicate submits are acceptable, double accounting is not — everything is deduped by block hash):
submitblockpendingsubmitblock, before attempt markerpendingpending+ attempt markersubmitted/abandonedsubmittedcoalesces to success,abandonedstays rejected — before any new node offer is made.The at-most-once terminal-accounting guarantee that the old mark-before-submit ordering provided is preserved via the finalize-retry registry rather than by delaying the submit. Exact share replays now return the original row as not newly inserted (
BlockCandidateIntentPersistResult), so process-local worker and vardiff counters are not credited twice.A short in-memory prospective-payout barrier is installed before qbitd can observe a candidate, so startup prewarm cannot issue child work from the stale balance base — without falsely claiming the block landed.
2. Bounded blocking calls and phase heartbeats (
c18a661)SingleWriterShareLedger.operation_timeout/statement_timeoutscopes, which set both the client-side budget andSET LOCAL statement_timeout. Expiry raisesLedgerOperationTimeout; the row stayspendingand enters the ordinary candidate backoff. Direct outbox reads/mutations additionally go through a single-flight wrapper so a driver that ignores its deadline cannot accumulate retry threads.submitblockruns under an explicit hard deadline.with self.lockon this path is replaced by a bounded acquire-loop that heartbeats in slices and periodically logs which lock is contended and the current phase._record_block_submitter_phasetags liveness with the active phase —replay-outbox-query,submitblock-rpc,fast-lane-admission,accounting-queue, and so on. A watchdog firing now reportsblock_submitter:replay-outbox-queryinstead of just the thread name, which is exactly the diagnostic the 2026-08-11 log was missing.PRISM_BLOCK_SUBMIT_STUCK_CALL_EXIT_SECONDS, the coordinator requests shutdown and exits nonzero so the supervisor replaces the poisoned process; durable outbox rows remain pending for replay.3. Priority isolation: node offers vs. accounting (
bf7bf61)Adversarial review of the first commit found that a stalled accounting tail could still convoy later block submissions. Accounting now runs on its own
block_accountingthread behind a height-prioritized queue:(node result, lease)and immediately returns to the next candidate.block_submitterandblock_accountingexpose independent phase heartbeats, so slow accounting neither delays node offers nor disguises which phase stopped._reserve_block_fast_lane_slot) keepPRISM_MAX_BLOCKS/ stop-after-block semantics correct now that admission and accounting are decoupled.submittedon later chain evidence.4. Startup replay no longer crash-loops on a slow ledger (
e082971)Review caught a P0 regression introduced by the two commits above: routing the startup outbox replay through the new 1s database deadline meant a slow-but-healthy Postgres raised
BlockSubmitterDatabaseTimeout/LedgerOperationTimeoutout ofserve()→main(), exiting nonzero ~1s after boot and crash-looping — in precisely the conditions this branch exists to survive, including after the branch's own fail-stop exits._run_startup_block_candidate_replaynow catchesTimeoutError(covering both subclasses) at the startup boundary, logs the phase and configured budget, and continues boot so listeners open and the submitter/accounting threads start. This is safe becauseblock_submit_loopalready callsreplay_pending_block_candidates()every iteration with paced backoff, and every durable candidate remainspendinginqbit_block_candidate_outbox; the pre-listener pass only gives durable candidates a head start over fresh miner wakeups.Deliberate scope limits in that fix:
ShutdownInProgresshandling and thedrain_threadsclean-stop path are unchanged.replay_recovered_sharesruns on the main thread with no timeout scope armed, so its behaviour is untouched.Configuration
New knobs, all with production-safe defaults wired through
.env.exampleandcompose.yaml:PRISM_BLOCK_SUBMIT_RPC_TIMEOUT_SECONDS1submitblockRPC.PRISM_BLOCK_SUBMIT_DB_TIMEOUT_SECONDS1PRISM_BLOCK_SUBMIT_LOCK_WAIT_LOG_SECONDS5PRISM_BLOCK_SUBMIT_STUCK_CALL_EXIT_SECONDS30docs/prism-ledger-ops.mdis updated to describe the fast lane, the accounting handoff, best-effort startup replay, the phase-tagged heartbeats, and the revised exactly-once accounting story.Testing
python3 -m unittest tests.test_prism_coordinator_vardiff tests.test_prism_share_ledger— 487 tests, OK (run locally on this branch). The 5 pre-existing failures intests.test_prism_coordinator_job_cacheare unchanged from base6c00d6eand are untouched here.Coverage added for each acceptance property:
test_submitter_offers_block_before_writer_admission_with_rpc_deadline— the RPC precedes writer admission and carries a deadline.test_sixty_second_attempt_mark_stall_does_not_delay_rpc_or_heartbeat— with a database that stalls 60s, the candidate still reaches the node RPC promptly and the submitter keeps heartbeating.test_crash_after_submit_before_attempt_mark_replays_duplicate_once— crash between submit and mark-attempted; replay resubmits and accounting lands exactly once.test_accounting_saturation_does_not_convoy_node_offers,test_replay_batch_reaches_node_while_oldest_accounting_stalls— accounting stalls do not block later node offers.test_overdue_submitter_heartbeat_names_the_stuck_phase— watchdog output includes the phase label.test_startup_block_replay_timeout_starts_submitter_and_converges,test_startup_block_replay_catches_psql_server_timeout,test_startup_block_replay_keeps_hard_database_errors_fatal,test_startup_block_replay_preserves_shutdown_stop— the boot regression and its intentional limits.test_stuck_rpc_worker_pool_requests_nonzero_restart,test_stuck_ledger_worker_pool_requests_nonzero_restart,test_one_stuck_ledger_call_does_not_restart_with_spare_capacity— poisoned-pool fail-stop only fires with no spare capacity.test_synchronous_waiter_joins_finalize_only_registry,test_restart_resubmit_coalesces_terminal_submitted_outbox,test_restart_resubmit_honors_terminal_abandoned_outbox,test_exact_share_replay_does_not_repeat_process_credit— exactly-once accounting across replay and same-hash races.test_operation_timeout_bounds_local_writer_lock_admission,test_statement_timeout_refreshes_for_each_database_step,test_subprocess_operation_timeout_sets_client_and_server_deadlines,test_native_operation_timeout_is_transaction_local, and the matching server-deadline/hard-error cases.Risks and rollout notes
pendingand converge on retry. If a deployment has a genuinely slower ledger, raisePRISM_BLOCK_SUBMIT_DB_TIMEOUT_SECONDS— note this budget also covers TCP connect + auth on a cold connection.🤖 Generated with Claude Code
Note
High Risk
Changes found-block submission ordering, crash/replay semantics, and fail-stop behavior on the critical path that lands pool blocks and drives payout accounting.
Overview
Found blocks are now offered to qbitd before any durable accounting, so a saturated Postgres can no longer delay
submitblocklong enough to lose the tip race or wedgeblock_submitter.The node result hands off to an independent height-prioritized
block_accountinglane; a full primary queue spills into a result-preserving overflow instead of turning an already-offered block back into a raw retry. Live discoveries outrank restart replay, and startup outbox recovery is best-effort so a slow ledger no longer crash-loops boot.Every submit-path RPC, DB statement, and lock wait is freshly deadline-bounded, with phase-tagged heartbeats and a poisoned-worker fail-stop. New knobs (
PRISM_BLOCK_SUBMIT_*) are wired through.env.example/compose.yaml, anddocs/prism-ledger-ops.mddocuments the fast lane, accounting isolation, and revised exactly-once replay story.Reviewed by Cursor Bugbot for commit 223d2a0. Bugbot is set up for automated code reviews on this repo. Configure here.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.