Skip to content

consensus: persist AppQC, blocks, and CommitQCs with async persistence#2896

Merged
wen-coding merged 3 commits intomainfrom
wen/persist_appqc_and_blocks
Mar 6, 2026
Merged

consensus: persist AppQC, blocks, and CommitQCs with async persistence#2896
wen-coding merged 3 commits intomainfrom
wen/persist_appqc_and_blocks

Conversation

@wen-coding
Copy link
Contributor

@wen-coding wen-coding commented Feb 16, 2026

Summary

Crash-safe persistence for availability state (AppQC, signed lane proposals, and CommitQCs). All I/O is fully asynchronous — no disk operations on the critical path or under locks.

Ref: sei-protocol/sei-v3#512

Persist layer (consensus/persist/)

  • Generic Persister[T proto.Message] interface with crash-safe A/B file strategy (abPersister[T]). No-op implementation for test/disabled paths. A/B suffixes unexported; WriteRawFile helper for corruption tests.
  • BlockPersister (persist/blocks.go): Each signed lane proposal stored as <lane_hex>_<blocknum>.pb. On load, returns all valid files sorted per lane. DeleteBefore removes pruned blocks and orphaned lanes from previous committees.
  • CommitQCPersister (persist/commitqcs.go): Each CommitQC stored as <roadindex>.pb. On load, returns all valid files sorted. ResetNext method allows the consumer to realign the persist cursor after filtering. DeleteBefore removes old CommitQC files.
  • Both loaders skip corrupt files (logged at WARN) and return all valid entries. The consumer (loadPersistedState + newInner) enforces contiguity and returns errors on gaps.

Prune anchor (PersistedAvailPruneAnchor)

  • Atomic AppQC + CommitQC pair persisted via A/B files as the crash-recovery pruning watermark. The proto is always written complete (both fields) or not at all — preventing incomplete anchor state on restart.
  • PruneAnchor Go type + PruneAnchorConv (protoutils.Conv) for encoding/decoding, with utils.Option for nullable fields.
  • The anchor is the single source of truth for crash recovery: on restart, all queues are positioned relative to it via prune(). The embedded CommitQC ensures lane range information is always available even if the corresponding CommitQC file hasn't been written yet (crash between anchor write and file write).

Availability state (avail/)

  • Single persist goroutine (always spawned — real or no-op) watches inner state directly. collectPersistBatch acquires the lock, waits for new data, reads persistence cursors directly from inner state, clamps past pruned entries, and collects the batch. I/O runs with no lock held. No channel, no backpressure.
  • Write order: prune anchor → CommitQCs → blocks → delete old. The anchor is persisted first as the crash-recovery watermark. CommitQCs follow, then latestCommitQC is published immediately so consensus can advance without waiting for block writes. Blocks are persisted last. Old data is deleted at the end.
  • Blocks persisted one at a time with nextBlockToPersist updated after each write, so vote latency equals single-block write time regardless of batch size.
  • Gate consensus on CommitQC persistence: The persist goroutine publishes latestCommitQC after writing to disk (or immediately for no-op). PushCommitQC no longer publishes directly — consensus subscribes to LastCommitQC() and won't advance until the QC is durable.
  • Gate voting on block persistence: RecvBatch only yields blocks below the nextBlockToPersist watermark, so votes are only signed for durably written blocks.
  • Gate block admission on persisted anchor (persistedBlockStart): PushBlock, ProduceBlock, WaitForCapacity, and PushVote use persistedBlockStart + BlocksPerLane as the capacity limit, where persistedBlockStart is derived from the last durably persisted prune anchor. This ensures we never admit more blocks than can be recovered after a crash.
  • No-op persister pattern: When persistence is disabled, no-op implementations for all three persisters. The persist goroutine always runs (same code path as production) — it just skips disk I/O and immediately bumps cursors. Eliminates all nil checks.

Restart / state restoration

  • Pre-filtering in loadPersistedState (avail/state.go): Decodes the prune anchor and filters out stale commitQCs (below the anchor's road index) and blocks (below the anchor's per-lane range) before passing data to newInner. This keeps domain filtering close to the I/O layer and simplifies newInner.
  • Prune-before-load in newInner (avail/inner.go): Applies prune() first when a prune anchor is present — this positions all queues (commitQCs, blocks, votes) at the correct indices so that subsequent pushBack calls insert at the right position without needing reset(). CommitQCs already pushed by prune() are skipped during loading via lqc.Index < commitQCs.next.
  • Strict contiguity enforcement: newInner returns errors (not warnings) for non-contiguous commitQCs, non-contiguous blocks, parent-hash mismatches, and blocks exceeding BlocksPerLane capacity. Since the anchor is always persisted first and data is written sequentially, any of these indicate corruption or a bug.
  • Startup file cleanup: After loading, NewState calls DeleteBefore on both block and commitQC persisters to immediately remove stale files that were filtered out, rather than waiting for the first persist cycle.
  • ResetNext cursor fix: After filtering, NewState calls pers.commitQCs.ResetNext(inner.commitQCs.next) to realign the persister's cursor. Without this, orphaned files would inflate cp.next beyond what was actually loaded, causing PersistCommitQC to reject valid new QCs on restart.
  • prune() advances nextBlockToPersist: When prune fast-forwards a lane's blocks.first past the persist cursor, the cursor is bumped to prevent busy-looping in the persist goroutine.
  • persistedBlockStart initialization: On restart, initialized from the prune anchor's CommitQC lane ranges, establishing the capacity limit from the first batch iteration.
  • Removed queue.reset(): No longer needed — prune() handles all queue positioning on startup.

Proposal verification hardening (types/proposal.go)

  • NewProposal rejects callers who aren't the view leader.
  • FullProposal.Verify now checks the proposer's signature, verifies the proposal's lane structure against the committee, and validates LaneQC header hash matches the lane range's LastHash.
  • Proposal.Verify validates that every present lane range belongs to the committee.

Other changes

  • data/state.go: Fix off-by-one in PushBlock wait condition (n <= nextQCn < nextQC). Cap block insertion loop at inner.nextQC to avoid accessing unverified QC entries.
  • data/testonly.go: Use actual leader key for test proposals (required by the new NewProposal leader check).
  • Derive CommitQC persist cursor from latestCommitQC: Removed nextCommitQCToPersist field — the cursor is derived from what's already published, which is safe because latestCommitQC is only advanced after disk write.
  • Use t.Context() instead of context.Background() in tests.
  • Replace zerolog with seilog in avail/inner.go, consensus/persist/blocks.go, and consensus/persist/commitqcs.go (aligns with Remove dependency to zerolog in favour of slog #3024).

Test plan

  • persist/blocks_test.go: load/store, gap returns all files, DeleteBefore, orphaned lane cleanup, header mismatch, corrupt files
  • persist/commitqcs_test.go: load/store, gap returns all files, DeleteBefore, corrupt files, mismatched index, ResetNext
  • persist/persist_test.go: A/B file crash safety, seq management, corrupt fallback, OS error propagation, generic typed API
  • avail/inner_test.go: newInner fresh start, loaded blocks (contiguous, gap error, parent hash mismatch error, over-capacity error, multiple lanes, empty, unknown lane), loaded CommitQCs (no anchor, with AppQC anchor, gap error, gap with anchor error, stale QCs below anchor skipped, all before AppQC pruned, gap after anchor error), anchor with no CommitQC files (crash recovery), anchor prunes block queues, anchor CommitQC used for prune, loaded all three, prune mismatched indices, prune advances nextBlockToPersist, incomplete prune anchor error
  • avail/state_test.go: fresh start, load AppQC, load blocks, load both, load commitQCs, load commitQCs with AppQC, non-contiguous commitQC files error, corrupt data error, PushBlock rejects bad parent hash, PushBlock rejects wrong signer, PushAppQC mismatch
  • avail/state_test.go (TestStateWithPersistence): end-to-end persist + prune race regression (5 iterations with disk persistence)
  • avail/state_test.go (TestStateRestartFromPersisted): end-to-end persist → stop → restart from same directory, verifies AppQC/CommitQC/block state restored correctly
  • avail/queue_test.go: newQueue, pushBack, prune, stale prune, prune past next
  • consensus/inner_test.go: consensus inner persistence round-trip, persist error propagation via newState injection
  • data/state_test.go: data state tests
  • types/proposal_test.go: proposal verification hardening tests

@github-actions
Copy link

github-actions bot commented Feb 16, 2026

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedMar 6, 2026, 4:04 PM

@codecov
Copy link

codecov bot commented Feb 16, 2026

Codecov Report

❌ Patch coverage is 80.04338% with 92 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.48%. Comparing base (42971a4) to head (ffcff96).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
sei-tendermint/internal/autobahn/avail/state.go 78.48% 19 Missing and 15 partials ⚠️
...mint/internal/autobahn/consensus/persist/blocks.go 77.14% 12 Missing and 12 partials ⚠️
...t/internal/autobahn/consensus/persist/commitqcs.go 78.72% 10 Missing and 10 partials ⚠️
...int/internal/autobahn/consensus/persist/persist.go 80.55% 5 Missing and 2 partials ⚠️
...ei-tendermint/internal/autobahn/consensus/state.go 58.33% 4 Missing and 1 partial ⚠️
sei-tendermint/internal/autobahn/avail/inner.go 96.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #2896      +/-   ##
==========================================
+ Coverage   58.27%   58.48%   +0.21%     
==========================================
  Files        2077     2113      +36     
  Lines      171308   175414    +4106     
==========================================
+ Hits        99823   102595    +2772     
- Misses      62590    63755    +1165     
- Partials     8895     9064     +169     
Flag Coverage Δ
sei-chain-pr 78.95% <80.04%> (?)
sei-db 70.41% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...endermint/internal/autobahn/avail/subscriptions.go 93.33% <100.00%> (+6.96%) ⬆️
...ei-tendermint/internal/autobahn/consensus/inner.go 63.21% <100.00%> (ø)
sei-tendermint/internal/autobahn/avail/inner.go 97.36% <96.00%> (+5.93%) ⬆️
...ei-tendermint/internal/autobahn/consensus/state.go 83.03% <58.33%> (+0.10%) ⬆️
...int/internal/autobahn/consensus/persist/persist.go 80.16% <80.55%> (ø)
...t/internal/autobahn/consensus/persist/commitqcs.go 78.72% <78.72%> (ø)
...mint/internal/autobahn/consensus/persist/blocks.go 77.14% <77.14%> (ø)
sei-tendermint/internal/autobahn/avail/state.go 75.68% <78.48%> (+2.04%) ⬆️

... and 71 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@wen-coding wen-coding force-pushed the wen/persist_appqc_and_blocks branch from ebf93df to f4a9c1e Compare February 17, 2026 04:50
@wen-coding wen-coding changed the title Port sei-v3 PR #512: persist AppQC and blocks to disk consensus: persist AppQC and blocks to disk Feb 18, 2026
@wen-coding wen-coding changed the title consensus: persist AppQC and blocks to disk consensus: persist AppQC and blocks, async block fsync Feb 18, 2026
@wen-coding wen-coding changed the title consensus: persist AppQC and blocks, async block fsync consensus: persist AppQC and blocks in avail Feb 18, 2026
@wen-coding wen-coding force-pushed the wen/persist_appqc_and_blocks branch from 05beddb to 2f0bbad Compare February 20, 2026 18:46
@wen-coding wen-coding changed the title consensus: persist AppQC and blocks in avail consensus: persist AppQC and blocks with async block persistence Feb 20, 2026
@pompon0 pompon0 self-requested a review February 23, 2026 12:31
}

// Queue enqueues a block for async persistence. Blocks if the queue is full
// until space is available or ctx is cancelled. We must not drop blocks because
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be easy to check here that blocks are received in order and return an error if that's not the case (holes are possible, because inner state can skip forward)

Comment on lines +671 to +675
for lane, q := range inner.blocks {
if inner.nextBlockToPersist[lane] < q.next {
return true
}
}

Check warning

Code scanning / CodeQL

Iteration over map Warning

Iteration over map may be a possible source of non-determinism
Comment on lines +681 to +687
for lane, q := range inner.blocks {
start := max(inner.nextBlockToPersist[lane], q.first)
for n := start; n < q.next; n++ {
b.blocks = append(b.blocks, q.q[n])
}
b.laneFirsts[lane] = q.first
}

Check warning

Code scanning / CodeQL

Iteration over map Warning

Iteration over map may be a possible source of non-determinism
Comment on lines +195 to +202
for lane, bs := range raw {
sorted := slices.Sorted(maps.Keys(bs))
blocks := make([]LoadedBlock, 0, len(sorted))
for _, n := range sorted {
blocks = append(blocks, LoadedBlock{Number: n, Proposal: bs[n]})
}
result[lane] = blocks
}

Check warning

Code scanning / CodeQL

Iteration over map Warning

Iteration over map may be a possible source of non-determinism
Comment on lines +646 to +651
for lane := range inner.blocks {
start := commitQC.LaneRange(lane).First()
if start > inner.persistedBlockStart[lane] {
inner.persistedBlockStart[lane] = start
}
}

Check warning

Code scanning / CodeQL

Iteration over map Warning

Iteration over map may be a possible source of non-determinism
Comment on lines +155 to +157
for lane, q := range inner.blocks {
laneFirsts[lane] = q.first
}

Check warning

Code scanning / CodeQL

Iteration over map Warning

Iteration over map may be a possible source of non-determinism
if err := pers.commitQCs.DeleteBefore(batch.commitQCFirst); err != nil {
return fmt.Errorf("commitqc deleteBefore: %w", err)
}
s.markCommitQCsPersisted(batch.commitQCs[len(batch.commitQCs)-1])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

markCommitQCsPersisted can be called directly after PersistCommitQC loop.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

// innerFile is the A/B file prefix for avail inner state persistence.
const innerFile = "avail_inner"

func decodePruneAnchor(a *pb.PersistedAvailPruneAnchor) (*types.AppQC, *types.CommitQC, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: you might want to add Anchor type and define a full proto.Conv for it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

return i, nil
}

if l.pruneAppQC != nil {
Copy link
Contributor

@pompon0 pompon0 Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Option[Anchor]

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

i.commitQCs.pushBack(l.commitQCs[0].QC)
for _, lqc := range l.commitQCs[1:] {
if lqc.Index != i.commitQCs.next {
log.Warn().
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that we persist anchor first now, this should never happen.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i.e. return error instead of logging

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

var lastHash types.BlockHeaderHash
for j, b := range bs {
if q.Len() >= BlocksPerLane {
log.Warn().
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should never happen

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed

Msg("capping loaded blocks at lane capacity")
break
}
if b.Number != q.next {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should never happen

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed

Msg("skipping non-contiguous persisted blocks (orphans will be cleaned up)")
break
}
if j > 0 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed

if err := pers.pruneAnchor.Persist(anchor); err != nil {
return fmt.Errorf("persist prune anchor: %w", err)
}
commitQC, err := types.CommitQCConv.Decode(anchor.CommitQc)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you shouldn't need to re-decode it. It shouldn't be encoded within the batch.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed

// collectPersistBatch is in the same goroutine and reads it directly.
nextBlockToPersist map[types.LaneID]types.BlockNumber

// persistedBlockStart is the per-lane block number derived from the last
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this is fine (as we discussed yesterday), but without persistedBlockStart it would work as well, which now I see that it would be strictly less logic. This is just observation, no changes requested.

// Restore persisted CommitQCs into the queue. Stale entries below the
// prune anchor have already been filtered by loadPersistedState.
if len(l.commitQCs) > 0 {
i.commitQCs.reset(l.commitQCs[0].Index)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: single i.prune(anchor) call will move all the lane/vote and commitqc queues to the right position (no resets needed) and imo it would clarify the logic here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed

var wantAppQCIdx types.RoadIndex
var wantNextBlocks map[types.LaneID]types.BlockNumber

require.NoError(t, scope.Run(context.Background(), func(ctx context.Context, s scope.Scope) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

t.Context()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Comment on lines +87 to +89
for lane := range i.blocks {
i.persistedBlockStart[lane] = anchor.CommitQC.LaneRange(lane).First()
}

Check warning

Code scanning / CodeQL

Iteration over map Warning

Iteration over map may be a possible source of non-determinism
Comment on lines +110 to +134
for lane, bs := range l.blocks {
q, ok := i.blocks[lane]
if !ok || len(bs) == 0 {
continue
}
var lastHash types.BlockHeaderHash
for j, b := range bs {
if q.Len() >= BlocksPerLane {
return nil, fmt.Errorf("lane %s: loaded %d blocks exceeds capacity %d", lane, len(bs), BlocksPerLane)
}
if b.Number != q.next {
return nil, fmt.Errorf("lane %s: non-contiguous persisted blocks: expected %d, got %d", lane, q.next, b.Number)
}
if j > 0 {
if got := b.Proposal.Msg().Block().Header().ParentHash(); got != lastHash {
return nil, fmt.Errorf("lane %s: parent hash mismatch at block %d", lane, b.Number)
}
}
lastHash = b.Proposal.Msg().Block().Header().Hash()
q.pushBack(b.Proposal)
}
if q.next > q.first {
i.nextBlockToPersist[lane] = q.next
}
}

Check warning

Code scanning / CodeQL

Iteration over map Warning

Iteration over map may be a possible source of non-determinism
Comment on lines +138 to +147
for lane, bs := range blocks {
first := anchor.CommitQC.LaneRange(lane).First()
j := 0
for j < len(bs) && bs[j].Number < first {
j++
}
if j > 0 {
loaded.blocks[lane] = bs[j:]
}
}

Check warning

Code scanning / CodeQL

Iteration over map Warning

Iteration over map may be a possible source of non-determinism
@wen-coding wen-coding requested a review from stevenlanders March 4, 2026 23:17
@wen-coding wen-coding force-pushed the wen/persist_appqc_and_blocks branch from 528a35b to da8050a Compare March 5, 2026 19:33
@wen-coding wen-coding requested a review from arajasek March 5, 2026 19:34
@wen-coding wen-coding force-pushed the wen/persist_appqc_and_blocks branch from da8050a to 58893a4 Compare March 5, 2026 20:04
- Fix doc comment: stateDir -> PersistentStateDir
- Remove len(batch.commitQCs)>0 guard so commitQC DeleteBefore always runs
- Clarify empty laneFirsts no-op semantics in BlockPersister.DeleteBefore
- Fix WriteRawFile comment: "one of the A/B files" -> "the A file"

Made-with: Cursor
@wen-coding wen-coding merged commit 40146a1 into main Mar 6, 2026
40 checks passed
@wen-coding wen-coding deleted the wen/persist_appqc_and_blocks branch March 6, 2026 16:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants