pub trait BlockOutputsArePersisted:
CommittedBlock
+ StorageAtomicity
+ CorrectValidator { }Expand description
Lemma (A block’s outputs are persisted before it counts as processed). When a correct
validator’s ChainTipState::next_block_height passes a height, the outputs of the block at
that height are already in storage: the blobs it publishes, the events it emits, and the
certificate itself. A crash before that point costs nothing but repeated work — the tip is what
marks a block processed, so the block is handled again on restart and the writes are redone.
Code correspondence.
| transition | ChainWorkerState::process_confirmed_block |
| writes | write_blobs_and_certificate, then write_events, then maybe_write_blob_states, and only then the tip |
| re-entry guard | tip.next_block_height > height, returning BlockOutcome::Skipped |
Proof. Three parts.
Ordering. None of these writes is atomic with any other — not even within a call, since
write_blobs_and_certificate fans out to one write per storage partition
(StorageAtomicity). process_confirmed_block issues the three writes and only afterwards
dispatches to execute_contiguous_block (or execute_block_with_checkpoint_restore), which is
where tip_state is set and save() runs. The writes are three separate awaited calls, not
one batch, so a crash can land between them; what the argument needs is only that all of them
precede the tip.
The tip is the guard. On restart the certificate is offered again, and the early return
if !in_trust_set && tip.next_block_height > height decides whether the block is skipped. That
test reads persisted chain state, which by StorageAtomicity is consistent, so a crash
before save() leaves the block unprocessed and every write is reissued.
The repeats are byte-identical. The events come from block.body.events and the blobs from
get_required_blobs over the block’s required_blob_ids and created_blobs — all fields of an
already-certified block rather than products of execution. Nothing is recomputed, so the
argument needs no appeal to DeterministicExecution. ∎
The order is load-bearing, not incidental. The outputs and the tip go to different key
spaces of the same backing store, so one batch could in principle span them; none does. The
ordering is what stands in for that atomicity, and only one order works. Outputs first costs at
most repeated work, because the block is reprocessed. The tip first would be unrecoverable: the
guard would classify the block as already processed and return BlockOutcome::Skipped, so the
outputs would be missing permanently with nothing left to notice it.
What becomes visible early. Certificates, blobs and events are written to storage shared
across chains — the channel by which chains observe each other at all — while the tip and the
inboxes are per-chain state no other worker reads (SequentialChainState). Between the two
writes, then, a block’s outputs are globally readable while the producing chain has not yet
recorded the block locally. That is harmless because certificate.check precedes every one of
these writes: what becomes visible early is content a quorum has already certified, and by
CommitAgreement no conflicting block can ever be certified at that height. An uncertified
block reaches none of these stores.
Preprocessing persists outputs with no tip to record them. In Preprocess mode, or Auto
with an unbridgeable gap, preprocess_certified_block updates outboxes and event streams and
deliberately does not advance the tip. The outputs of such a block are in storage, but the
guard above will not short-circuit a later offer of the same certificate, so the writes are
simply redone.
What this does not cover. It places a block’s outputs in the producing validator’s
storage. That the resulting bundles reach the recipient chain’s inbox is
EffectsSurviveRestart; that the outbox is ever drained is stated nowhere.