Skip to main content

linera_chain/proof/
checkpoints.rs

1// Copyright (c) Zefchain Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Event streams across a checkpoint boundary.
5
6use crate::manager::proof::model::SequentialChainState;
7
8/// **Invariant (A stream's readable floor is its first index since the last checkpoint).** For
9/// every event stream of a chain, [`StreamCounts::first_index`] is the index of the first event
10/// published to that stream since the most recent checkpoint, and the indices from there up to
11/// [`StreamCounts::next_index`] are contiguous. Events below the floor may have been pruned and
12/// are unavailable to a node that bootstrapped from that checkpoint; events at or above it are
13/// untouched by checkpointing.
14///
15/// This is the sense in which pre-checkpoint events are *summarized* rather than merely dropped:
16/// what a checkpoint discards is bounded, and the boundary is a published number rather than
17/// something a reader has to infer.
18///
19/// *Proof.* [`ChainStateView`]'s `process_emitted_events` maintains both fields whenever a block is
20/// processed — executed or merely preprocessed — and branches on whether the block records a
21/// predecessor for the stream in [`previous_event_blocks`], the per-stream map holding the hash
22/// and height of the last block that emitted to it. That map is built during execution and is
23/// covered by the block hash through [`previous_event_blocks_hash`], so every node branches the
24/// same way on the same block.
25///
26/// * *A predecessor is recorded.* The stream has published since the last checkpoint, so its
27///   events must continue contiguously. If they do not — `lo != counts.next_index` — the tracker
28///   is left untouched, so a gap never advances `next_index` over a missing index and never moves
29///   the floor.
30/// * *No predecessor, and `lo >= first_index`.* This block is the first to emit to the stream
31///   since a checkpoint. `lo` becomes the new floor and `next_index` advances to `max(next_index,
32///   hi + 1)`.
33/// * *No predecessor, and `lo < first_index`.* An earlier checkpoint era, already superseded by a
34///   later one this chain has recorded. It is ignored, so the floor does not move backwards. ∎
35///
36/// **The floor only ever rises**, which is what makes it safe to publish. The `>=` guard in the
37/// second branch exists for checkpoints seen out of order — an earlier one preprocessed after a
38/// later one — and without it a stale checkpoint would lower a floor that readers had already
39/// relied on.
40///
41/// **What a reader may conclude.** A cross-chain read of an event through
42/// [`OracleResponse::Event`] is guaranteed to find it only at an index at or above that stream's
43/// floor. Below the floor, availability depends on some node not having pruned, which no statement
44/// here provides.
45///
46/// [`StreamCounts::first_index`]: crate::StreamCounts::first_index
47/// [`StreamCounts::next_index`]: crate::StreamCounts::next_index
48/// [`ChainStateView`]: crate::ChainStateView
49/// [`previous_event_blocks`]: crate::block::BlockBody::previous_event_blocks
50/// [`previous_event_blocks_hash`]: crate::block::BlockHeader::previous_event_blocks_hash
51/// [`OracleResponse::Event`]: linera_base::data_types::OracleResponse::Event
52pub trait EventFloorTracksCheckpoints: SequentialChainState {}
53
54/// **Lemma (A checkpoint summarizes every user stream that published since the previous one).**
55/// At a checkpoint, each application holding an event stream that has published since the previous
56/// checkpoint is given the chance to replace that stream's history with a summary, and no system
57/// stream is ever in that position.
58///
59/// *Proof.* `ExecutionStateActor::summarize_events_at_checkpoint` takes as its work list exactly
60/// the user streams in [`previous_event_blocks`]. That map is cleared by every checkpoint, so an
61/// entry means the stream has published since the previous one. Each owning application is run
62/// through `UserAction::SummarizeEvents` with a `StreamUpdate` whose `previous_index` is `0` and
63/// whose `first_index` and `next_index` are both the stream's current count: a summary is an
64/// absolute-state snapshot, so the application is handed no incremental range to fold in. The
65/// summary it emits lands at `next_index`, which is a fresh index with no predecessor recorded, so
66/// by [`EventFloorTracksCheckpoints`] it becomes the stream's readable floor.
67///
68/// The map is then cleared, dropping every pre-checkpoint anchor, so no later block links back to
69/// blocks whose events are no longer guaranteed to be readable.
70///
71/// Only user streams can appear. A chain that has *published* to a system stream cannot checkpoint
72/// at all — `ExecutionStateView::prepare_checkpoint` scans [`previous_event_blocks`] and refuses,
73/// because system streams have no application to summarize them — and a chain that has *consumed*
74/// system events is refused separately by [`ChainStateView`]'s `check_checkpoint_preconditions`,
75/// which scans the reader-side trackers and fails with
76/// [`ChainError::CheckpointPreconditionFailed`]. The admin chain's epoch streams are the case both
77/// guards exist for. ∎
78///
79/// **A silent application closes its stream.** Summarization is an opportunity, not an obligation.
80/// A stream whose application emits nothing when summarized loses its anchor along with every
81/// other, and is not summarized again unless it publishes something new. Nothing distinguishes a
82/// stream deliberately closed from one whose application neglected to summarize, and in both cases
83/// the events below the floor are gone.
84///
85/// [`previous_event_blocks`]: crate::block::BlockBody::previous_event_blocks
86/// [`ChainError::CheckpointPreconditionFailed`]: crate::ChainError::CheckpointPreconditionFailed
87/// [`ChainStateView`]: crate::ChainStateView
88pub trait CheckpointSummarizesUserStreams: EventFloorTracksCheckpoints {}
89
90/// **Lemma (A checkpoint moves the consumption boundary and nothing else about messages).**
91/// Restoring a chain from a checkpoint changes which incoming bundles are still *retained*, never
92/// which have been consumed. Bundles below the restored cursor are already reflected in the
93/// restored execution state and are ignored on arrival and on consumption; bundles at or above it
94/// are queued, delivered and consumed exactly as they would have been with no checkpoint at all.
95///
96/// *Proof.* Three parts — what the checkpoint records, what a restore does, and what the guards
97/// then absorb.
98///
99/// *Recorded.* `PreparedCheckpoint::inbox_cursors` carries the cursor of **every** inbox with a
100/// non-default `next_cursor_to_remove`, not only those the checkpoint acknowledges. A bootstrapping
101/// node therefore learns the consumption position of every origin the chain had consumed from,
102/// including origins it will never hear from again.
103///
104/// *Restored.* `Inbox::restore_from_checkpoint` sets `restored_cursor` to that cursor, raises
105/// `next_cursor_to_add` to it if it lagged, sets `next_cursor_to_remove` to it, drops
106/// `added_bundles` below it, and clears `removed_bundles` — those anticipated removals came from
107/// pre-restore blocks the rollback has invalidated. It refuses to move backwards: restoring at a
108/// cursor below the current `restored_cursor` is an error, so a checkpoint dispatched out of order
109/// cannot undo a later one.
110///
111/// *Absorbed.* Below `restored_cursor`, `Inbox::add_bundle` returns without queueing and
112/// `Inbox::remove_bundle` returns immediately, reporting the bundle as already known and
113/// deliberately *not* recording it in `removed_bundles` — otherwise that queue would fill with
114/// anticipations no sender will ever satisfy. So a sender that has not yet seen the matching
115/// acknowledgement and re-pushes an already-consumed bundle causes a silent no-op, not a duplicate
116/// consumption. At or above the cursor the guards are the ordinary ones, and
117/// `linera_core::proof::availability::BundleConsumedAtMostOnce` applies unchanged. ∎
118///
119/// **The acknowledgement is what lets a sender forget.** A checkpoint emits
120/// `SystemMessage::CheckpointAck` to each origin in `PreparedCheckpoint::origin_cursors`, carrying
121/// the position past the last bundle from that origin this chain has consumed. The recipients are
122/// `pending_checkpoint_ack_targets`: the chains that have sent this one a message which was not
123/// itself a `CheckpointAck`, so an acknowledgement never obliges an acknowledgement in return.
124/// What the origin then does with it, and why dropping those bundles is safe, is
125/// [`AcknowledgedMessagesMayBeForgotten`].
126///
127/// **What the outboxes still reference is certified, not merely named.**
128/// `PreparedCheckpoint::outbox_block_hashes` lists every block this chain's outboxes still refer
129/// to, captured before the checkpoint block runs, and travels in the checkpoint's oracle response.
130/// The checkpoint block's certificate therefore *re-certifies* those older blocks, which is what
131/// keeps them acceptable after the committee that signed them has been removed —
132/// [`CheckpointRecertifiesReferencedBlocks`].
133pub trait CheckpointPreservesConsumptionBoundary: SequentialChainState {}
134
135/// **Lemma (A checkpoint leaves blob availability unchanged).** Checkpointing neither strands a
136/// blob the chain can still reach nor silently requires one a bootstrapping node cannot obtain.
137///
138/// *Proof.* Two directions.
139///
140/// *Nothing is stranded.* Blobs live in storage shared across chains, addressed by content and
141/// owned by no chain's view. What a checkpoint prunes is this chain's *history* — its older blocks
142/// and the events they published — which is not where blobs are kept. So no blob becomes
143/// unreachable by checkpointing, and the retention obligation is exactly what it was before.
144///
145/// *Nothing is silently required.* `ExecutionStateView::apply_checkpoint` records an
146/// [`OracleResponse::Checkpoint`] carrying `used_blobs`, read from the system state's `used_blobs`
147/// set: every blob the chain references at that moment. An oracle response is part of the block's
148/// outcome and covered by the block hash, so the list is certified rather than advisory, and a
149/// bootstrapping node knows precisely which blobs it must hold in shared storage before applying
150/// the checkpoint — otherwise a later operation could read blob content it does not have. ∎
151///
152/// **The state dump is itself a blob, on the ordinary terms.** The execution state is split at the
153/// current epoch's `maximum_blob_size` and published through `add_created_blob`, so a checkpoint's
154/// snapshot is published by its block exactly as any other blob is: priced by the block that
155/// publishes it, bounded in count and size, and retained on the same footing. A checkpoint buys a
156/// node the right to skip replaying history; it does not buy free storage.
157///
158/// [`OracleResponse::Checkpoint`]: linera_base::data_types::OracleResponse::Checkpoint
159pub trait CheckpointPreservesBlobAvailability: SequentialChainState {}
160
161/// **Lemma (A checkpoint restores exactly the execution state it captured).** Applying a
162/// checkpoint's blobs reproduces the chain's execution state as it stood immediately before the
163/// checkpoint block, in full.
164///
165/// *Proof.* Four parts.
166///
167/// *The dump is total.* [`ExecutionStateView`] has exactly one field: an inner view holding the
168/// system state, the user applications' key-value stores, and the two previous-block maps.
169/// Everything the outer view exposes is reached by dereferencing into it, and `dump_content`
170/// serializes that inner view's persisted content whole. No part of the execution state can be
171/// left out of a checkpoint by oversight — totality here is structural, not an inventory someone
172/// has to keep current as fields are added.
173///
174/// *The dump is quiescent.* `dump_content` reads from storage and refuses to run while the view
175/// holds pending in-memory changes, failing with `ViewError::HasPendingChanges`.
176/// `prepare_checkpoint` is therefore a *pre-block* operation, run before block-level setup mutates
177/// the chain. The captured bytes are the committed pre-block state — exactly what a bootstrapping
178/// node restores before re-applying the certified checkpoint block.
179///
180/// *The bytes are pinned by the certificate.* The dump is chunked at the epoch's
181/// `maximum_blob_size` and published as created blobs of the checkpoint block, their ids listed in
182/// that block's `OracleResponse::Checkpoint` as `execution_state_blobs`. Blobs are content
183/// addressed, so a node fetching them cannot be handed different bytes; and the id list is part of
184/// the certified outcome, so it cannot be pointed at a different dump. Integrity here is free, in
185/// the way integrity of any blob is free — availability is the separate question, and is
186/// `linera_core::proof::availability::CertifiedBlockIsAvailable`'s.
187///
188/// *The hash agrees.* `ExecutionStateView::crypto_hash_mut` derives the state hash from the inner
189/// view's historical hash, and `restore_from_content` records the hash of the restored bytes as the
190/// new stored hash. A node that restores and then re-applies the certified checkpoint block
191/// computes the `state_hash` that block certifies, so a restore that went wrong does not go
192/// unnoticed. ∎
193///
194/// **This is the execution state, not the chain.** The two totality arguments point opposite ways
195/// and it is worth being exact about which applies. [`ExecutionStateView`] has one field, so the
196/// dump covers all of it. [`ChainStateView`] has sixteen, of which the blob covers exactly one —
197/// `execution_state`. Inboxes, outboxes, the tip, the chain manager, the block-hash index and the
198/// event trackers are all outside it.
199///
200/// Two of those are restored by named mechanisms rather than by the blob, and a checkpoint would be
201/// unusable without them:
202///
203/// * *Inboxes.* Each inbox's `restored_cursor` is seeded from `PreparedCheckpoint::inbox_cursors`,
204///   carried in the certified oracle response rather than the dump
205///   ([`CheckpointPreservesConsumptionBoundary`]).
206/// * *Outboxes.* `outboxes`, `outbox_counters` and `nonempty_outboxes` are rebuilt by
207///   [`ChainStateView`]'s `restore_outboxes_from_unfinalized`, run once after
208///   `restore_from_content`, from the on-chain `unfinalized_message_blocks`
209///   ([`AcknowledgedMessagesMayBeForgotten`]). Off-chain outbox state is not certified, so without
210///   this a bootstrapped node would go quiet on cross-chain delivery while looking healthy.
211///
212/// So a checkpoint is not a snapshot of a chain. It is a certified snapshot of the chain's
213/// *execution state*, plus enough certified bookkeeping to reconstruct the message-passing state
214/// around it. What the remaining chain-state fields hold after a bootstrap is outside this lemma.
215///
216/// **Residual obligation.** `restore_from_content` leaves the in-memory view stale: its
217/// documentation requires the caller to reload afterwards, and nothing in the type enforces it. A
218/// caller that skipped the reload would continue against a view that no longer describes storage.
219/// This is the same shape as [`SafetyStateRecovery`] — a correctness condition discharged by
220/// convention at the call site rather than by construction.
221///
222/// [`ChainStateView`]: crate::ChainStateView
223/// [`SafetyStateRecovery`]: crate::manager::proof::locking::SafetyStateRecovery
224/// [`ExecutionStateView`]: linera_execution::ExecutionStateView
225pub trait CheckpointRestoresExecutionState: SequentialChainState {}
226
227/// **Lemma (A sender may forget messages its recipient has checkpointed).** A chain's checkpoint
228/// dump names only those of its blocks that still carry outgoing bundles no recipient has
229/// acknowledged consuming. Acknowledged bundles are dropped, and no future incarnation of any
230/// recipient can ask for them again.
231///
232/// Without this a chain could never forget anything it had ever sent: `outbox_block_hashes` would
233/// name every block with an outgoing message for the life of the chain, and each checkpoint would
234/// be larger than the last.
235///
236/// *Code correspondence.* Three transitions, alternating between the two chains.
237///
238/// **The sender records an outstanding bundle.**
239///
240/// | | |
241/// |---|---|
242/// | transition | the per-recipient loop in `ChainStateView::execute_block_inner` |
243/// | reads | `BlockTracker::non_checkpoint_ack_tx_indices`, and the current entry for that recipient |
244/// | writes | `system.unfinalized_message_blocks[recipient]`, adding `Cursor { height, index }` for each kept transaction index |
245/// | precondition | the recipient has at least one message in this block that is not a `CheckpointAck`; otherwise the entry is not touched |
246///
247/// **The recipient acknowledges, at its own checkpoint.**
248///
249/// | | |
250/// |---|---|
251/// | transition | `ExecutionStateView::apply_checkpoint`, over `PreparedCheckpoint::origin_cursors` built before the block by `ChainStateView::collect_inbox_cursors` |
252/// | reads | `system.pending_checkpoint_ack_targets`, and `next_cursor_to_remove` of each named inbox |
253/// | writes | one `SystemMessage::CheckpointAck { latest_received_cursor }` per target through `TransactionTracker::add_outgoing_message`, then clears `pending_checkpoint_ack_targets` |
254/// | precondition | `prepare_checkpoint` and `check_checkpoint_preconditions` both passed ([`CheckpointSummarizesUserStreams`]) |
255///
256/// **The sender trims.**
257///
258/// | | |
259/// |---|---|
260/// | transition | the `CheckpointAck` arm of `SystemExecutionStateView::execute_message` |
261/// | reads | `system.unfinalized_message_blocks[context.origin]` |
262/// | writes | the same entry, replaced by `cursors.split_off(&latest_received_cursor)`, or removed when that is empty |
263/// | precondition | none — an acknowledgement naming an origin with no entry is a no-op |
264///
265/// *Proof.* Four steps.
266///
267/// *What gets recorded.* `non_checkpoint_ack_tx_indices` walks the block's outgoing messages and
268/// keeps, per destination, the indices of transactions holding at least one message for which
269/// `Message::is_checkpoint_ack` is false. `execute_block_inner` inserts a [`Cursor`] for each. So a
270/// block whose only traffic to a recipient is an acknowledgement adds nothing to track — which is
271/// the first of the two exclusions below.
272///
273/// *What the dump names.* `unfinalized_message_blocks` is held in the *system execution state*, not
274/// in the off-chain outbox, so it is identical across validators and may feed a certified oracle
275/// response: `PreparedCheckpoint::outbox_block_hashes` is the unique heights across all its
276/// cursors, resolved to hashes through `block_hashes` by the pre-block hook — the cursors are
277/// written mid-execution, when the block's own hash is not yet known. Cursors rather than bare
278/// heights is what lets an acknowledgement landing part-way through a block evict a recipient
279/// outright, which matters for a high-fanout chain whose recipients each interact with it once.
280///
281/// *What clears an entry.* On `CheckpointAck { latest_received_cursor }`, `split_off` retains the
282/// cursors at or above it and discards the strict prefix below; an emptied set removes the
283/// recipient entirely. Since the dump is derived from what remains, those blocks stop being named.
284///
285/// *Why that is safe.* `latest_received_cursor` is the recipient's `next_cursor_to_remove` at its
286/// checkpoint, so it has consumed everything below it. The same checkpoint records that position in
287/// `PreparedCheckpoint::inbox_cursors`, so any node bootstrapping the recipient seeds
288/// `restored_cursor` at least that high; and by [`CheckpointPreservesConsumptionBoundary`] a bundle
289/// below `restored_cursor` is dropped by `Inbox::add_bundle` on arrival and reported as already
290/// known by `Inbox::remove_bundle` on consumption. No incarnation of the recipient, present or
291/// future, can therefore ask for a bundle the sender has dropped. ∎
292///
293/// **What is kept is exactly what a bootstrapped node needs.** The same map has a second reader:
294/// `ChainStateView::restore_outboxes_from_unfinalized`, called once after
295/// `restore_from_content` when a node bootstraps from a checkpoint. Off-chain outbox state —
296/// `outboxes`, `outbox_counters`, `nonempty_outboxes` — is not part of the certified blob, so
297/// without this rebuild a bootstrapped node would silently stop pushing pending messages onward.
298/// Retention and resumption are therefore the same set: a sender keeps a block precisely while some
299/// recipient might still need it delivered.
300///
301/// **Why the exchange terminates.** An acknowledgement is itself a message, so two exclusions stop
302/// two chains acknowledging each other forever, and they act at different points. A bundle whose
303/// only messages to a recipient were `CheckpointAck` never enters `unfinalized_message_blocks`
304/// (`non_checkpoint_ack_tx_indices`), so it never becomes something to acknowledge; and a received
305/// `CheckpointAck` never enters its origin into `pending_checkpoint_ack_targets` (the
306/// `!posted_message.message.is_checkpoint_ack()` guard in `BlockTracker`), so it creates no debt to
307/// answer. `apply_checkpoint` then clears that set, so only a fresh non-acknowledgement message
308/// re-enters a chain for the next round.
309///
310/// **A sender can forget only as fast as its recipients checkpoint.** Nothing obliges a recipient
311/// to checkpoint, and until it does it sends no acknowledgement, so the sender keeps naming those
312/// blocks. A chain whose recipients never checkpoint therefore has an ever-growing dump however
313/// often it checkpoints itself — its own frequency does not help. This is the outbox-side face of
314/// [issue #6693](https://github.com/linera-io/linera-protocol/issues/6693): with nothing scheduling
315/// checkpoints anywhere, the bound this lemma provides rests on behaviour no rule requires.
316///
317/// [`Cursor`]: linera_base::data_types::Cursor
318pub trait AcknowledgedMessagesMayBeForgotten: CheckpointPreservesConsumptionBoundary {}
319
320/// **Lemma (A checkpoint re-certifies the blocks its outboxes still reference).** The older blocks
321/// a chain still owes delivery from remain acceptable to a node that has never seen them, even
322/// after the committee that signed them has been removed.
323///
324/// *Why this needs saying.* Removing a committee revokes the standing of everything it signed:
325/// [`AdminOperation::RemoveCommittee`] states that such blocks "will only be accepted once they
326/// have been followed (hence re-certified) by a block certified by a recent committee". A chain's
327/// unacknowledged outgoing blocks are exactly the ones most likely to be old, so without a route
328/// back to a current committee they would become undeliverable precisely when a node most needs
329/// them — on bootstrap.
330///
331/// *Code correspondence.*
332///
333/// | | |
334/// |---|---|
335/// | transition | `ChainWorkerState::process_confirmed_block`, checkpoint-restore path |
336/// | reads | `outbox_block_hashes` from the checkpoint's `OracleResponse::Checkpoint`; `Storage::contains_certificate` for each |
337/// | writes | `pre_checkpoint_block_trust`, one entry per hash not yet in storage; the entry is removed when that block later arrives |
338/// | precondition | the checkpoint certificate itself verifies against the committee for *its* epoch |
339///
340/// *Proof.* The checkpoint block's certificate is signed by a current committee, and
341/// `outbox_block_hashes` travels inside its oracle response, so the list is covered by the block
342/// hash and inherits that certificate's standing ([`AcknowledgedMessagesMayBeForgotten`] fixes what
343/// the list contains). Naming a block by hash is therefore a current committee vouching for it.
344///
345/// The worker turns that into an admission rule. Before touching any chain state it checks each
346/// named hash against storage; every one missing is recorded in `pre_checkpoint_block_trust` and
347/// the push fails with `WorkerError::BlocksNotFound`, so a half-restored chain whose outboxes
348/// reference unknown blocks is never exposed. The client then uploads each missing certificate, and
349/// the `in_trust_set` test at the top of `process_confirmed_block` both removes the mark and lets
350/// the block past the already-processed guard — the condition is `!in_trust_set &&
351/// tip.next_block_height > height`. When the set is empty the restoration runs end to end. ∎
352///
353/// **What is relaxed and what is not.** The vouching substitutes for the block's *epoch* being
354/// current, not for its certificate being valid: `certificate.check` still runs against the
355/// committee for the block's declared epoch. So a re-certified block is one whose own quorum signed
356/// it and whose continued relevance a later quorum attests — never one accepted on a hash alone.
357/// [`TipAdvancesOnlyOnValidCertificate`] says the same from the other side: what trust-marking
358/// bypasses is the re-execution of ancestors, not their certification.
359///
360/// **This is one instance of a general mechanism.** Re-certification also runs along prev-hash
361/// chains, without any checkpoint: `ChainWorkerState::select_message_bundles` accepts a
362/// revoked-epoch bundle when a later bundle in the same batch is in a still-trusted epoch, since
363/// that bundle's certificate transitively covers the earlier ones. The
364/// [`previous_message_blocks`](linera_execution::ExecutionStateView) and `previous_event_blocks`
365/// maps exist to keep such chains intact for recipients and streams that are addressed only
366/// occasionally. The general form belongs with committee reconfiguration rather than here; what is
367/// specific to checkpoints is that pruning *severs* those chains — a checkpoint clears
368/// `previous_event_blocks` ([`CheckpointSummarizesUserStreams`]) — which is why the blocks the
369/// outboxes still need must be named explicitly instead.
370///
371/// [`AdminOperation::RemoveCommittee`]: linera_execution::system::AdminOperation::RemoveCommittee
372/// [`TipAdvancesOnlyOnValidCertificate`]: crate::manager::proof::commit::TipAdvancesOnlyOnValidCertificate
373pub trait CheckpointRecertifiesReferencedBlocks:
374    AcknowledgedMessagesMayBeForgotten + CheckpointSummarizesUserStreams
375{
376}