Skip to content

Prune block_hashes at every checkpoint - #6599

Open
afck wants to merge 7 commits into
linera-io:mainfrom
afck:prune-block-hashes-at-checkpoint
Open

Prune block_hashes at every checkpoint#6599
afck wants to merge 7 commits into
linera-io:mainfrom
afck:prune-block-hashes-at-checkpoint

Conversation

@afck

@afck afck commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Motivation

A checkpoint only guarantees the availability of the blocks it vouches for in its outbox_block_hashes, so a node that bootstraps from it never learns the other pre-checkpoint blocks.

However, a node that just executes a block with a checkpoint, without bootstrapping from it, has its block_hashes collection growing indefinitely.

Proposal

Drop those from block_hashes when executing a checkpoint. Retained below the checkpoint are the vouched-for blocks, which still carry unacknowledged messages, and any block still queued in an outbox.

For the vouched-for set to cover every previous_message_blocks anchor, the checkpoint also drops the anchor of each recipient that has acknowledged everything we sent it. Such an anchor would otherwise dangle even without any pruning, breaking a bootstrapped node on its next block to that recipient.

The rest of the codebase is made to survive pruned block hashes:

  • process_outgoing_messages treats a missing block_hashes entry for the outbox's previous height as "no predecessor" instead of a corrupted chain state — the same state a bootstrapped node's rebuilt outbox is in.
  • handle_revert_confirm stops re-adding heights at the first pruned one instead of failing: acknowledgements cover a lane prefix, so everything below the pruned height is already acknowledged in the recipient's own checkpoint.
  • The simple-protocol proxy serves DownloadCertificatesByHeights directly from storage instead of going through the (now prunable) block_hashes map.

Settled lanes. When the first bundle of a cross-chain update declares no predecessor, the sender's checkpoint settled the lane below it, and the sender will never push anything below that bundle again. The receiving inbox records this in a new sender_pruned_cursor: below it, consumptions that cannot be reconciled with added_bundles are ignored instead of raising UnexpectedBundle, nothing is anticipated, and late re-deliveries are dropped. Without this, a node that replays the recipient chain without having seen the pre-checkpoint bundles would corrupt its inbox cursor. Bundles actually present in the inbox still reconcile (or get skipped) normally.

CheckpointAck liveness. Acknowledgements are the one bundle class with no availability story: checkpoints don't vouch for them, they are excluded from previous_message_blocks and unfinalized_message_blocks, and bootstrapping doesn't restore them to the outbox. A validator that bootstrapped the recipient chain from a later checkpoint can therefore never obtain a pre-checkpoint ack, and no one can make it validate a block consuming that ack. So:

  • A block in the fast round must never consume a CheckpointAck: the worker rejects such proposals, and the client routes ack-consuming blocks to a non-fast round. Otherwise a fast proposal confirmed by half the validators and unvalidatable by the other half would brick the chain.
  • If a proposal fails with MissingCrossChainUpdate for an ack bundle even after the updater has sent the validator the sender chain's information, the client retries the proposal without any ack bundles.
  • The skip is durable: the client settles the unavailable bundle in its own inbox — removing it from added_bundles and advancing sender_pruned_cursor past it — so a restart or a later linera process-inbox doesn't propose it again, while a certified block from another chain owner that does consume the ack still reconciles as a no-op.

This also fixes the issues found in #6609.

Test Plan

Tests were extended: inbox unit tests for the settled-lane marker, a two-worker test driving a full sender/receiver checkpoint-ack cycle across pruned lanes, and client tests for revert-confirm on pruned heights, the fast-round exclusion, and skipping an ack a validator cannot hold.

Release Plan

  • Nothing to do / These changes follow the usual release cycle.

Links

A checkpoint only guarantees the availability of the blocks it vouches for in
its `outbox_block_hashes`, so a node that bootstraps from it never learns the
other pre-checkpoint blocks. Drop those from `block_hashes` when executing a
checkpoint, so a node that has been following the chain all along converges on
the same set.

Retained below the checkpoint are the vouched-for blocks, which still carry
unacknowledged messages, and any block still queued in an outbox: a queue is
drained by a delivery confirmation from the recipient's validators, which is a
separate path from the acknowledgement that empties `unfinalized_message_blocks`,
so it can outlive the checkpoint's guarantee.

For the vouched-for set to cover every `previous_message_blocks` anchor, the
checkpoint also drops the anchor of each recipient that has acknowledged
everything we sent it. Such an anchor would otherwise dangle even without any
pruning, breaking a bootstrapped node on its next block to that recipient.

Finally, `process_outgoing_messages` now treats a missing `block_hashes` entry
for the outbox's previous height as "no predecessor" instead of a corrupted
chain state -- the same state a bootstrapped node's rebuilt outbox is in.
Extend the ack-cycle test past the producer's second checkpoint: reset the
producer to that checkpoint (the unit-test stand-in for a node that
bootstrapped from it) and send to the recipient again. This reproduces two
failures observed on devnet-2026-07-08 with unpruned anchors: the proposal
failed locally with CorruptedChainState because the recipient's
previous_message_blocks anchor pointed below the checkpoint, and a
full-history sender's message on the same lane was never delivered to the
checkpointed recipient's inbox.
pull Bot pushed a commit to Spencerx/linera-protocol that referenced this pull request Jul 14, 2026
## Motivation

`ChainClient::checkpoint` exists since the checkpoint feature was
merged, but there was no way to trigger a checkpoint manually: its only
callers are unit tests, and neither the CLI nor the node-service GraphQL
API exposes it.

## Proposal

Add a `linera checkpoint [CHAIN_ID]` command that publishes a checkpoint
of the given chain (defaulting to the wallet's default chain), retrying
via `apply_client_command` like the other operation commands, and
printing the height of the resulting block.

## Test Plan

CI, plus a manual end-to-end test against the `devnet-2026-07-08` devnet
(validators at 13206a2, client from this branch):

* Two wallets with faucet chains A and B; several transfers in both
directions with inbox processing.
* `linera checkpoint` on A (with a still-unreceived A→B transfer in
flight) — checkpoint block certified by the devnet validators; B then
received the pre-checkpoint message.
* Full `CheckpointAck` cycle: checkpoint on B, A received the ack in a
block and checkpointed again.
* Fresh third wallet, `linera wallet follow-chain A --sync`:
bootstrapped from the *latest* checkpoint without replaying history —
local state and balance match the owner wallet, and the local node
stores only the checkpoint block (a `blocks` GraphQL query walks back
from the tip and stops at the checkpoint block because its parent was
never downloaded).
* `change-ownership`/`assign` to the third wallet, which then
successfully proposed blocks (a transfer to a fresh chain, and another
checkpoint) on the bootstrapped chain.

The test also reproduced two known limitations on `main` (addressed by
the upcoming linera-io#6599):

* The bootstrapped wallet cannot send to a recipient whose
`previous_message_blocks` anchor predates the checkpoint (`Corrupted
chain state: missing entry in block_hashes`, locally in the proposing
client), and its local node can no longer sync the chain once another
owner does so.
* After both sender and recipient have checkpointed, a new message on
the previously-used A→B lane is never delivered to the recipient's inbox
on the validators (a transfer to a fresh recipient sent at the same time
was delivered normally).

## Release Plan

- Nothing to do / These changes follow the usual release cycle.
afck added 5 commits July 16, 2026 10:57
…ssing

Fixes for three review findings on checkpoint pruning:

* handle_revert_confirm walked the previous_message_blocks links down into
  the acknowledged prefix and hard-errored on the first pruned height,
  before re-adding anything to the outbox - so after a recipient reset,
  wiped in-flight bundles were never resent. The walk now stops at the
  first missing hash instead: acknowledgements cover a prefix of each
  lane, so everything from there down is already included in the
  recipient's own latest checkpoint and needs no retransmission.

* The simple-protocol proxy served DownloadCertificatesByHeights through
  the chain worker's pruned block_hashes map, silently under-serving
  requests for pruned heights and making honest validators look faulty to
  syncing clients. It now reads the unpruned storage index, like the gRPC
  proxy. The in-memory test validator took the same shortcut and got the
  same fix.

* The process_outgoing_messages tolerance for a missing predecessor hash
  was untested. The reachable scenario is an outbox that drained while
  ahead of the tip: it survives, pointing past its last scheduled block,
  which a later checkpoint then prunes once every recipient has
  acknowledged it. A new worker test builds that shape end-to-end with
  real certificates, and the tolerance comment now describes it.
After a checkpoint prunes a fully acknowledged lane, the next message on it
carries previous_height: None. A node that never received one of the settled
bundles — it bootstrapped the sender chain from a later checkpoint, or missed
the push — could not reconcile its consumption: once the new bundle was added,
consuming the missed one hit UnexpectedBundle (and in the reverse order, the
anticipated entry made every future push fail), permanently wedging the lane.
Recoverable only by abandoning the chain and bootstrapping it.

The inbox now records the no-predecessor point in a new sender_pruned_cursor
register: the sender's checkpoint settled everything below it, so nothing
there is re-pushable, and consumption reaches this node only through the
receiver's own certified blocks — which carry the bundle in their body.
Below the cursor, consumptions that cannot be reconciled are ignored instead
of failing, nothing is anticipated, anticipated entries are dropped when the
cursor advances, and re-deliveries are dropped.

Bundles actually present in added_bundles are unaffected: a pending bundle
below the cursor — e.g. a CheckpointAck sent by the checkpoint block itself,
which no anchor tracks — still reconciles or errors exactly as before, and
the ignored case reports the bundle as not present, so block proposals are
still rejected unless the validator really holds their bundles.
A CheckpointAck bundle has no availability guarantee: no anchor tracks it, no
checkpoint vouches for its block, and once delivered it is never resendable.
A validator that bootstrapped the sender chain from a checkpoint later than
the ack-sending block therefore cannot hold the bundle, and a proposal
consuming it is rejected by that validator (MissingCrossChainUpdate) while
others may vote for it. In the fast round the votes are confirmations, so
such a split cannot be resolved by re-proposing without the ack.

Clients now propose ack-consuming blocks in a slower round - the same rule
that already applies to blocks using oracles - and validators enforce it,
mirroring FastBlockUsingOracles.
When a validator rejects a proposal with MissingCrossChainUpdate, the updater
first tries to heal it by pushing the sender chain. That cannot work for a
CheckpointAck bundle on a validator that bootstrapped the sender chain from a
checkpoint later than the ack-sending block: the block sits below its tip, is
vouched for by nothing, and is never resent. With enough such validators (plus
ordinary unavailability), a proposal consuming the ack can never be certified,
and the client would keep re-proposing it forever.

The client now settles exactly the acknowledgement bundles named by such
rejections in its local inbox - removing them from the pending bundles and
advancing the lane's sender_pruned_cursor past them, the state a node that
never received them is in - and retries the proposal without them. Skipping
them is always legal: they are Simple, zero-grant bundles, and validators
that do hold one discard it when the lane's next bundle is consumed; if
another owner's quorum does consume it, the certified block reconciles as a
settled no-op. The settlement is part of the chain state, so it survives
client restarts and one-shot CLI commands instead of costing a failed
proposal round-trip each time. A leftover pending proposal from an
interrupted call gets the same treatment rather than wedging every future
proposal. If nothing else is pending, no block is proposed at all.
@afck
afck force-pushed the prune-block-hashes-at-checkpoint branch from 905bfaa to 534455b Compare July 16, 2026 14:38
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