Don't treat a block the storage never persisted as committed - #535
Draft
claude[bot] wants to merge 2 commits into
Draft
Don't treat a block the storage never persisted as committed#535claude[bot] wants to merge 2 commits into
claude[bot] wants to merge 2 commits into
Conversation
EpochAwareStorage/CallbackStorage.Index returned nil - success - for a block it deliberately did not persist (a Telock, which only extends the dying epoch until the sealing block finalizes). A nil error from Storage.Index is the engine's only signal that a block is durable at Storage.NumBlocks()-1, so Epoch.indexFinalization treated the skip as a commit: it set e.lastBlock to the block and advanced e.round via progressRoundsDueToCommit, while nextSeqToCommit() - which is Storage.NumBlocks() - stayed behind. The two cursors can never reconcile afterwards. The node builds proposals on a phantom parent that honest nodes reject, advertises the never-persisted block to replicating peers as its latest finalized block, and rejects the proposal that does belong at that sequence. It happens without an attacker in the dying epoch, since a Telock legitimately collects a finalization whenever the sealing finalization lags, and it can be induced in a freshly transitioned node by replaying the old-epoch Telock block and its finalization QC in a ReplicationResponse. - Storage.Index now documents its contract and CallbackStorage reports the skip with the new common.ErrBlockNotIndexed instead of nil. The block is deliberately left in the CachedStorage cache, as a Telock must stay retrievable by digest while its epoch is being extended. - indexFinalization reports whether the block was committed and leaves lastBlock and the round untouched when it was not, both for a reported skip and - defensively - for a storage that reports success without advancing NumBlocks. Its callers stop the commit loop and, on the replication path, restore the rounds map so the block that does belong at that round can still be finalized. - The non-validator likewise no longer halts, nor treats the sequence as accepted, when the storage declines to persist a block. Old-epoch artifacts also reached the commit path with no epoch binding: VerifyQC only attests that the signers form a quorum of the current validator set, which a quorum of the previous epoch still satisfies when the sets overlap. A replayed Telock chains onto the last block of its epoch, so it claims the round and sequence the first block of the new epoch belongs to. Blocks and finalizations of an earlier epoch are now rejected in verifyQuorumRound and handleFinalizationMessage, and a sealed epoch stops accepting finalization messages, since nothing beyond its sealing block belongs to it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpZGbLPZnb9sV7EVcYi3dB
Refusing to commit a block the storage did not persist is not enough on its own: whatever the engine still holds for that sequence has to go too, or it takes the place of the block that does belong there. - The non-validator left the refused block in incompleteSequences, which only a real commit ever clears. handleBlock then dropped the block that belongs at that sequence as a duplicate, and handleFinalization treated its finalization as conflicting and set haltedError - a permanent halt where before the fix the state was cleared as a side effect of the false commit. Drop the entry and re-request the sequence. - indexFinalizations kept the round, which let the refused block be served to replicating peers as a finalized quorum round for its sequence, be used as the parent of our next proposal, and make storeProposal refuse the real block for that round. Drop the round, as the replication path already did. - The replication path now re-requests the sequence as well, since the caller removed it from the replication state before handing it over. Also note in the Storage contract that NumBlocks() must reflect Index synchronously, and stop the epoch-binding comments from reading as though they were the boundary rather than a layer above it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpZGbLPZnb9sV7EVcYi3dB
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.
Problem
CallbackStorage.Indexreturnednil— success — for a block it deliberately did not persist: a Telock, the block built after the sealing block purely to extend the dying epoch until that sealing block finalizes. Telocks are never indexed.A
nilerror fromStorage.Indexis the consensus engine's only signal that a block is durable atStorage.NumBlocks()-1, soEpoch.indexFinalizationtreated the skip as a real commit: it sete.lastBlockto the block and advancede.roundviaprogressRoundsDueToCommit, whilenextSeqToCommit()— which isStorage.NumBlocks()— stayed behind.The two cursors can never reconcile afterwards. The node:
metadata()preferse.lastBlock), which honest nodes reject;handleReplicationRequest, which is exempt from post-seal send suppression);Two ways in:
indexFinalizationsloop commits the sealing block and then phantom-commits the Telock in the same iteration.VerifyQConly attests that the signers form a quorum of the current validator set, which a quorum of the previous epoch still satisfies whenever the sets overlap, and the replication verification path usesOnlyVMVerifyOpt, which accepts a Telock vacuously (itsInnerBlockis nil). A replayed Telock chains onto the last block of its epoch, so it claims the very round and sequence the first block of the new epoch belongs to. Its finalization also occupiesrounds[R+1], which blocks the real finalization for that round (storeFinalization: "already has a finalization") and so blocks the healing path.If a quorum is poisoned during the transition window, no real block at seq s+1 is ever finalized and
indexFinalizationscan never commit again — a halt that survives restarts, since no node holds a committable block at the stuck sequence.Fix
The skip is no longer reported as success
common.ErrBlockNotIndexedis added and theStorage.Indexcontract is documented: anilerror means the block is stored at its sequence; an implementation that intentionally does not persist a block must say so.CallbackStorage.Indexreturns it for a Telock. The block is deliberately left in theCachedStoragecache — a Telock must stay retrievable by digest while its epoch is being extended.The engine no longer commits state for a block that is not in storage
indexFinalizationreports whether the block was committed, and leavese.lastBlockand the round untouched when it was not — both for a reported skip and, defensively, for a storage that reports success without advancingNumBlocks().indexFinalizationsstops the commit loop and leaves the round in place, so the block that does belong at that sequence can still be committed.Old-epoch artifacts are rejected before they reach the commit path
verifyQuorumRoundrejects quorum rounds whose block, or whose finalization, belongs to an earlier epoch;handleFinalizationMessagerejects such finalizations too. Every sequence an epoch still has to commit belongs to that epoch or a later one, so this cannot reject anything a lagging node needs — a node catches up epoch by epoch, sealing and transitioning as it commits each sealing block.Testing
go test -race ./...passes, with no newERR/WARNoutput.New regression tests, each verified to fail against the unpatched code:
TestCallbackStorageReportsTelockSkip— the adapter reports the skip rather than returningnil, does not persist the Telock, does not run the post-index callback, and keeps the Telock retrievable by digest.TestEpochDoesNotCommitBlockTheStorageDeclinedToIndex— with a storage that declines the seq-2 block, both when it reports the skip and when it hides it behind anilerror: nothing at or after that sequence is committed, the round does not advance past it, and a replicating peer is told the last committed block is the latest finalized sequence. (The nil-error case reproduces the original desync: the round advanced past an uncommitted block.)TestEpochRejectsFinalizationsFromPreviousEpochs— replaying a previous-epoch block that claims the current epoch's first sequence, through a notarized quorum round, a finalized quorum round, a proposal, and a finalization message, leaves storage, the commit cursor and the round unchanged.Follow-up after adversarial review
A fresh-context review found that refusing to commit is not enough on its own — whatever the engine still holds for that sequence has to go too, or it takes the place of the block that does belong there:
newFinalizedBlockTask, skippingremoveOldSequencesAndEpochs— the only thing that ever clears an uncommitted entry fromincompleteSequences.handleBlockthen drops the real block for that sequence as a duplicate, andhandleFinalizationtreats its finalization as conflicting and setshaltedError. Ironically the pre-fix false commit cleared that state as a side effect, so the first cut turned a self-healing state into a halt. It now drops the entry and re-requests the sequence.indexFinalizationskept the round. That let the refused block be served to replicating peers as a finalized quorum round for its sequence (locateQuorumRecord), be used as the parent of the next proposal (metadataviagetHighestRound), and makestoreProposalrefuse the real block for that round. It now drops the round, as the replication path already did — and the test asserts the replication response never carries it.Two review points I checked and did not act on:
createNotarizedBlockVerificationTaskalso verifies withOnlyVMVerifyOpt, so the check is load-bearing and stays.setMetadataFromRecordscan lowere.Epochback to the previous epoch when a WAL segment holding post-sealing-block rounds survives garbage collection, which would disable the epoch checks after a crash-restart-after-seal. That is a separate pre-existing bug (it also mis-derivese.roundand restores stale rounds, so such a node already rejects every real proposal); the storage-level guard still holds there, which is why it is the boundary and the epoch checks are documented as a layer above it. Flagged rather than half-fixed here.New tests, each verified to fail without its corresponding fix:
TestNonValidatorRecoversFromBlockTheStorageDeclinedToIndex— the refused block is dropped rather than held, and the block that belongs at that sequence is still committed without halting.TestEpochDoesNotCommitBlockTheStorageDeclinedToIndexnow also asserts that no replication response serves the refused block as finalized.🤖 Generated with Claude Code
https://claude.ai/code/session_01GpZGbLPZnb9sV7EVcYi3dB
Generated by Claude Code