Skip to content

settler, aggregate-prover: resume unsettled bundles after a restart - #148

Draft
biryukovmaxim wants to merge 18 commits into
storage-write-drainfrom
settle-resume
Draft

biryukovmaxim wants to merge 18 commits into
storage-write-drainfrom
settle-resume

Conversation

@biryukovmaxim

Copy link
Copy Markdown
Collaborator

The incidents

Two tn10 production wedges with the same signature: the settler submits a settlement, the tx is mined and accepted within ~1 s, but the settlement watch never advances and the single-flight settler waits forever ("unconfirmed after Ns"). Nothing settles afterward while execution and proving continue; the node looks healthy from the L2 side. After a prover restart the lane stayed idle (TN5). Root cause (investigated on the live systems): the bridge's Full-verbosity get_virtual_chain_from_block_v2 catch-up fetch over a dust-storm window exceeded the RPC client's 60 s request timeout and entered a retry livelock, freezing the bridge tip below the settlement's accepting block; the notification-only confirm wait turned that transport stall into a permanent settler wedge. The same 60 s livelock is the long-known slow-link catch-up defect; the transport fix (chunked/reduced-verbosity catch-up) is separate and NOT part of this branch.

What this branch does

Settlements resume after a prover restart, receipts are reused rather than re-proved (only the cheap aggregate re-runs), a competitor that settles during downtime is handled without forking, and the settler can no longer wedge on a mined-but-unobserved settlement.

  • Settlement journal (state/settlement-journal, new): proved-but-unsettled bundles are journaled at artifact publication (end_index, from_block, block_prove_to, seq_commit) over a new StateSpace::SettlementJournal column family; entries compact away against the covenant tip.
  • Startup resume: unsettled journal entries are re-loaded from the persisted aggregate receipts and re-fed to the settler ahead of new work; settled prefixes compact; a straddled entry is split at the competitor boundary and its suffix re-aggregated from cached per-batch receipts. The resume gate never parks: with no settlement observation available yet it re-feeds the tail unchanged and lets proving proceed.
  • Multi-pass resume: a settlement advance re-runs the resume pass scoped to the pre-restart tail, so a competitor settlement that lands during downtime splits the straddler once the bridge observes it, not only at startup.
  • Committed-gap coverage: batches committed but never bundled before the kill (the TN5 restarted-idle signature) are re-formed into a bundle at startup from cached per-batch receipts, bounded at the contiguously-durable prefix.
  • Chain-derived settled-ness: before every submission the settler probes the covenant outpoint's on-chain liveness; a spent outpoint is superseded without submitting, so the chain may already hold the tx we would re-submit and we adapt instead of forking.
  • Confirm-wait backstop: the warn tick already polls the covenant address UTXOs; a spent outpoint now resolves the confirm wait from the submission's own data instead of deferring to a watch that may be frozen (Landed requires our own continuation UTXO live on chain, distinguishing our landing from a competitor's Superseded). The settler never waits forever on a tx the chain already holds. Riding along: the old resubmit-on-drop probe matched a typed not-found error variant the wRPC client never delivers, so it had been inert against real nodes; the probe now accepts the actual error shape.
  • Bridge observability: one INFO line per settlement tx at chain-block ingestion (txid + block + daa), so a future watch freeze is visible at ingestion in minutes.
  • Durability premise: builds on the write-worker shutdown drain from the base of this stack (storage: drain write worker on shutdown; bridge: adaptive-filter disable option #147) — a write accepted before shutdown commits and opens its confirmation latch; the restart resume's receipt and metadata durability depends on it.

Tests

two_provers_contend grows from 7 to 13 integration tests (dev-mode proofs, simnet L1): warm restart settles the pending tail; competitor sweep during downtime; split at the competitor boundary with no new carriers; own settlement landing during downtime (no fork, no double submission, settles past the landed tip); committed-but-unjournaled gap coverage (red-verified against the pinned wedge); blind-watch confirm via the chain poll.

Out of scope (open)

  • Fee-bump and resubmit-on-mempool-eviction remain open.
  • The transport livelock itself (60 s VCC timeout; chunked catch-up) is the known slow-link catch-up defect, tracked separately.
  • Reorgs during downtime inherit the existing single-miner / low-reorg ceiling.

Related

…nant tip

Config gains journal: Option<Arc<dyn SettlementJournal>>, wired only in the
proving node; exec and test construction sites stay None, enforced by a spawn
assert requiring the queue and the watch. The worker records each published
bundle's geometry and deletes entries a settlement watch advance covers.

Review riders from the journal crate: drop the unused vprogs-core-types
dependency, assert that recording at the same start replaces the prior entry,
and align the corrupted-key expect message.
Run 1 quiesces the pipeline (every straggler settlement landed, every
in-flight bundle submitted) before mining the tail carriers, so no batch
commits past the journal tail across the kill: such a gap leaves the warm
store's state chain past the last settlement with no journal entry
covering it, and the resumed prover then skips every bundle forever
(recorded as a production follow-up). Bundle minimum 1 keeps a lone real
carrier batch from stranding below the bundle minimum when a carrier
block comes out empty. The restarted settler supersedes the re-fed tail
without a second submission and settles new work past the landed tip.
A kill between a batch's commit and its bundle's journal record wedges
the covenant: the committed range above the journal tail has no entry,
the scheduler never re-schedules committed batches, so every resumed
bundle proves from a state root the covenant never took and the settler
skips it forever (the restarted-prover-idle signature).

The startup resume now follows a committed-gap pass: with tail_end the
pre-pass journal snapshot's max end and committed_tip the highest
committed checkpoint, the range (tail_end, committed_tip] is re-formed
from persisted batch metadata and cached per-batch receipts via the
split_straddler recipe, recorded as one journal entry, and fed after
the pending tail. A settlement boundary inside the range splits it like
the straddled entry; empty batches (lane tip carried unchanged) prove
no receipt and compose nothing, matching the live bundle filter. The
journal gate no longer skips the gap check when the journal is empty:
the whole committed span is then the gap, gated on the bridge's first
tip publication with an escape on the first scheduled batch so a
settlement-free covenant is never parked.

committed_tip reaches the worker as a reverse BatchMetadata seek on the
settlement journal (new trait method alongside the full-metadata read
the gap pass needs for block hashes, seq commits, and lane tips).
…d gap

Run 1 lands settlements, mines tail carriers under a bundle minimum of
4, and kills at a polled deterministic point: the carriers' batch
metadata and per-batch receipts are durable while the sub-minimum park
keeps their bundle from journaling (a leftover prefix that does reach
the minimum is detected as journal growth and the round retries). Run 2
must re-form the committed range, settle it, and settle new work past
it; without the startup gap pass it wedges with the pinned signature
(bundles proved, every submission skipped, chain stuck), which the
pre-fix tree was re-verified to reproduce.

The spawn helper splits so a test can hand the node a caller-opened
store handle and keep a clone for live column-family reads (journal,
batch metadata, receipt presence) without contending the RocksDB lock.
open_store_retrying's final resume_unwind now names the store path and
the caught failure: resume_unwind bypasses the restored panic hook and
the original panic ran under the silent hook, so a real lock timeout
previously failed the test with no message at all.
Stale-boundary healing: the advance-pass scope extends to this run's
gap entry (its end exceeds the pre-restart snapshot), so a settlement
boundary the gap pass could not yet observe (the empty-journal escape
preceding a catch-up burst's watch publication, or the non-empty
branch's pre-downtime baseline) re-splits that entry in process
instead of wedging until the next restart; the unmapped-boundary case
keeps the re-feed-unchanged residual, now worded as such.

split_straddler gains the emptiness predicate the gap pass established:
empty batches prove no receipt and compose nothing, so a straddled
suffix spanning one is skipped instead of dropping the entry uncovered.

The gap bundle now covers the contiguously-durable prefix of the range:
a receipt still in flight for a freshly committed batch bounds the
bundle instead of aborting the whole pass, the entry's end fields derive
from the last covered batch's own metadata, and only the range above a
miss logs at error and stays uncovered (an in-flight batch journals
live; a genuinely lost one remains the loud documented ceiling).

Docs: the empty-journal wait names its park behavior (no tip and no
batch parks until activity, matching pre-fix behavior); committed_tip's
density premise is stated as a rollback ceiling rather than a
guarantee.
… blind

The confirm-wait warn tick's probe now distinguishes a spent covenant
outpoint from a node drop: our own continuation UTXO live on chain
confirms the settlement from the submission's own data (txid, block
DAA score) without the settlement watch, a spent outpoint without it
supersedes the bundle, and any probe error leaves the wait running
exactly as before. The settler can no longer wait forever on a
settlement the chain already holds.

The mempool read also accepts the wRPC subsystem's plain not-found
message alongside the typed variant: over wRPC the node's not-found
arrives only as that message, so the old drop probe never fell through
to its chain read against a real node.
One INFO line per settlement observed while processing a chain block
(txid + block + daa), so a future settlement miss is visible at
ingestion immediately: a fetch-stalled bridge shows VCC retry warnings
with no settlement-block lines, while an extraction miss would show the
block processed with no settlement line.
Reproduces the production wedge deterministically: the settler runs
with a settlement watch nobody writes (the bridge-frozen incident
shape), the settlement mines and accepts on the simnet L1 anyway, and
the settler must confirm through the warn tick's chain probe, keep the
covenant a single chain, and settle subsequent bundles. Without the
backstop the single-flight settler parks forever at chain length 1.
The non-empty-journal startup gate waited for the bridge's first tip
publication with no inbox escape, unlike the empty-journal branch. On a
covenant whose first-ever settlement never landed (a fresh lane, or a
TN5-shaped eviction striking bundle one) the bridge has no last
settlement to republish, so the gate parked the whole worker until
shutdown: the tail never re-fed and proving was held.

Mirror the empty branch's inbox escape: the gate now breaks with no tip
on the first scheduled batch and resume_pending takes the no-tip path,
re-feeding the scoped tail unchanged (the sibling of the unmapped-boundary
path). A settlement racing the escape reaches the main loop's advance
pass, which re-runs the resume with the real tip.
Review nits and a comment-convention sweep over the branch diff:

- The committed-gap miss log now says "covering nothing" when the miss
  is the first gap index, instead of claiming coverage through an index
  the loop never reached.
- The resume split's suffix drops its unused checkpoint-index tuple
  slot; the receipts vector is built directly.
- covenant_spent's doc states what it returns (spent, not spendable)
  and names the error case consistently.
- Comment wording: a journal-free run (not journal), and the test's
  wallet-sizing rationale no longer references the debugging session
  that observed it.
The warm-restart drive loops assign settled on every iteration and the
four adapted sites reassign it unconditionally before the next read, so
the 0 initializer never survives to a read; unused_assignments rejects
it under -D warnings. The two sites that read settled straight out of
the loop keep the initializer, as the loop may not run.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant