Skip to content

Refactor separating AppQC from CommitQC - #3849

Open
pompon0 wants to merge 62 commits into
mainfrom
gprusak-appqc
Open

Refactor separating AppQC from CommitQC#3849
pompon0 wants to merge 62 commits into
mainfrom
gprusak-appqc

Conversation

@pompon0

@pompon0 pompon0 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Redesign of the AppQC flow:

  • AppQCs are collected by data.State natively, rather than as AppProposals within CommitQCs
  • avail.State anchor is persisted in data.State
  • AppQCs are collected for every CommitQC, for the last block of the CommitQC. Local AppProposal (locally computed AppHashes) are persisted in BlockDB as well, because we currently have no access to historical apphashes.
  • data.State can now only prune AFTER all: CommitQC,blocks,AppQC,(local) AppHash are collected for the given height. At least one (complete) row has to stay in DB.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

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

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedAug 11, 2026, 5:28 PM

@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.92088% with 240 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.46%. Comparing base (ab08efb) to head (bfe6137).

Files with missing lines Patch % Lines
sei-tendermint/internal/autobahn/data/state.go 72.72% 32 Missing and 25 partials ⚠️
sei-db/ledger_db/block/littblock/litt_block_db.go 81.10% 22 Missing and 19 partials ⚠️
sei-tendermint/internal/autobahn/avail/state.go 83.91% 12 Missing and 11 partials ⚠️
sei-tendermint/internal/p2p/giga/data.go 52.38% 11 Missing and 9 partials ⚠️
sei-tendermint/internal/p2p/giga/service.go 48.64% 18 Missing and 1 partial ⚠️
sei-db/ledger_db/block/littblock/codec.go 64.10% 8 Missing and 6 partials ⚠️
sei-db/ledger_db/block/memblock/mem_block_db.go 91.86% 9 Missing and 5 partials ⚠️
sei-tendermint/internal/p2p/giga/consensus.go 41.17% 5 Missing and 5 partials ⚠️
...dermint/internal/autobahn/pb/autobahn.wireguard.go 43.75% 9 Missing ⚠️
sei-tendermint/internal/autobahn/avail/inner.go 88.37% 2 Missing and 3 partials ⚠️
... and 10 more
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3849      +/-   ##
==========================================
- Coverage   59.45%   58.46%   -1.00%     
==========================================
  Files        2319     2225      -94     
  Lines      198379   187822   -10557     
==========================================
- Hits       117946   109806    -8140     
+ Misses      69235    67645    -1590     
+ Partials    11198    10371     -827     
Flag Coverage Δ
sei-chain-pr 76.51% <77.92%> (?)
sei-db 70.41% <ø> (-0.22%) ⬇️
sei-db-state-db ?

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

Files with missing lines Coverage Δ
sei-db/ledger_db/block/blocksim/block_generator.go 75.78% <ø> (-1.44%) ⬇️
sei-tendermint/autobahn/types/block.go 83.33% <ø> (ø)
sei-tendermint/autobahn/types/epoch.go 100.00% <100.00%> (ø)
sei-tendermint/autobahn/types/timeout.go 81.41% <100.00%> (ø)
...int/internal/autobahn/avail/metrics/metrics.gen.go 100.00% <ø> (ø)
...dermint/internal/autobahn/avail/metrics/metrics.go 100.00% <ø> (ø)
...endermint/internal/autobahn/avail/subscriptions.go 100.00% <100.00%> (ø)
...ei-tendermint/internal/autobahn/consensus/state.go 88.34% <ø> (-0.08%) ⬇️
...ndermint/internal/autobahn/data/metrics/metrics.go 100.00% <100.00%> (ø)
sei-tendermint/internal/autobahn/data/testonly.go 100.00% <100.00%> (+49.35%) ⬆️
... and 23 more

... and 97 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.

@pompon0
pompon0 requested a review from wen-coding August 4, 2026 17:46
@pompon0
pompon0 requested a review from cody-littley August 5, 2026 17:01
Comment thread sei-db/ledger_db/block/littblock/litt_block_db.go
Comment thread sei-db/ledger_db/block/littblock/litt_block_db.go
Comment thread sei-db/ledger_db/block/blocksim/blocksim.go

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Large, well-structured redesign of the AppQC flow, but it contains two node-fatal regressions: PushAppHash rejects the app-tip re-push that runExecute performs on every restart (node cannot restart when its last executed block is the final block of a persisted CommitQC range, and also after upgrading a DB with no AppProposal records), and fullnodes now dial validators with the full validator client set on a block-sync-only Service, which panics in validatorState(). Several recovery tests were also inverted to assert the opposite of what their names claim rather than re-pinning the new behaviour.

Findings: 5 blocking | 18 non-blocking | 15 posted inline

Blockers

  • No test covers the fullnode dial path — giga_router_fullnode_test.go constructs the router but never calls Run/runFullnodeSubscriber, which is why the RunClient-on-block-sync-service panic is invisible to CI. Please add a test that actually runs a fullnode router against a validator server (or at least a unit test asserting RunFullNodeClient is what fullnodes use).
  • No test covers restart when the app tip is the last block of a persisted CommitQC/AppProposal range. TestPushAppQCPersistsAndRecovers pins inner.nextAppProposal == gr1.Next after recovery and TestExecution pins that PushAppHash(gr.Next-1) errors in that state — together they encode the restart failure, but nothing exercises the two in sequence the way runExecute does.
  • 3 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • The Cursor second-opinion pass produced no output (cursor-review.md is empty), so this review is the merge of Claude + Codex only.
  • PushQC capacity is now gated on inner.first + blocksCacheSize, and first advances only when an AppQC is persisted. Block/QC ingestion liveness is therefore coupled to quorum AppQC progress with only blocksCacheSize of slack (previously the gate used the node-local nextAppProposal). This looks intentional per the PR description, but it is a meaningful change in failure modes and deserves a comment naming the intended slack.
  • Widespread stale doc comments after the renames: // RunBlockSyncServer spawns only... above RunFullNodeServer and // RunBlockSyncClient... above RunFullNodeClient (giga/service.go:92,104); the Service type doc still says block-sync-only serves "StreamFullCommitQCs + GetBlock" (now also StreamAppQCs); BlockPersister.PruneAndPersist and CommitQCPersister.PruneAndPersist docs still describe the removed anchor/afterEach parameters; persisters.close doc still claims fields are nil-checked.
  • Test names and comments in data/state_recovery_test.go no longer match behaviour: TestRecoveryStartsAtLastExecutedBlock no longer starts at LastExecutedBlock, TestRecoveryRejectsEmptyBlockDBAfterFirstCommittedBlock now asserts NoError, and TestPruningDiscards now asserts nothing is discarded. Per AGENTS.md, the review prompt here is to record the new behaviour under an accurate name rather than widen the assertion until it passes.
  • AppVote doc comment has a typo: nextAppProposa (data/state.go:630).
  • CommitQCPersister.Next() (renamed from LoadNext) appears to have no remaining callers — worth removing if it is now dead.
  • 12 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-tendermint/internal/autobahn/data/state.go Outdated
Comment thread sei-tendermint/internal/p2p/giga_router_fullnode.go
Comment thread sei-tendermint/internal/autobahn/data/state_recovery_test.go Outdated
Comment thread sei-tendermint/internal/autobahn/data/state.go
Comment thread sei-tendermint/internal/autobahn/data/state.go
Comment thread sei-tendermint/internal/autobahn/data/state_recovery_test.go Outdated
Comment thread sei-tendermint/internal/autobahn/data/state.go Outdated
Comment thread sei-tendermint/internal/autobahn/data/state.go Outdated
Comment thread sei-tendermint/internal/autobahn/data/state.go Outdated
Comment thread sei-tendermint/internal/autobahn/data/state.go Outdated
Comment thread sei-tendermint/internal/autobahn/data/state.go
Comment thread sei-tendermint/internal/autobahn/data/state.go Outdated
Comment thread sei-tendermint/internal/autobahn/data/state.go

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Large, mostly well-structured redesign of the AppQC flow (AppQCs/AppProposals now first-class BlockDB records, BlockDBIterator replaced by ReadSuffix, avail's prune anchor sourced from data.State). One blocking correctness bug: the PushAppHash durability wait uses n <= persisted.NextAppProposal, which is already satisfied on entry for single-block CommitQC ranges — so the AppHash can be lost on crash and the app tip can drift past the persisted cursor, producing an unrecoverable startup error on restart.

Findings: 2 blocking | 14 non-blocking | 9 posted inline

Blockers

  • Cursor's second-opinion file (cursor-review.md) is empty — that review pass produced no output, so its coverage is missing from this synthesis. Codex's pass produced two findings, both merged below.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • Test coverage gap around the new durability contract: no test pins the PushAppHash durability wait for a single-block CommitQC range — which is exactly the case the current condition misses. pushAppHashes* helpers and the recovery tests all use multi-block TestCommitQC ranges, so the blocker above is invisible to the suite. A test that writes a 1-block CommitQC and asserts PushAppHash does not return until Status().NextAppProposal > n would have caught it.
  • Test coverage gap in the BlockDB contract: testWriteAppProposalOrderRejected / testWriteAppQCOrderRejected only ever write QC-aligned ranges (appProposalForBatch(rng, b0)), so the documented rejection of mid-QC starts / ranges spanning two CommitQCs is never exercised on either backend. This is the same gap as the missing range check noted inline.
  • ReadSuffix replaces BlockDBIterator and materializes the whole suffix in memory. In the state where no AppQC has ever been written, littblock's ReadSuffix loop condition (!gotAppQC || ...) never short-circuits, so it scans and decodes the entire table and returns every block and QC — and PruneBefore is simultaneously a no-op in that state. Benign at genesis, but worth an explicit bound or a log line, since nothing signals that the store is in the unbounded-growth regime.
  • Design consequence worth surfacing operationally: inner.first now advances only when an AppQC is persisted, so a stall in AppQC collection freezes BlockDB pruning and (after blocksCacheSize = 4000 blocks) blocks PushQC entirely. This matches the PR description, but there is no metric or log when that backpressure engages — and with ObserveAppQC now dead (see inline), there is also no AppQC progress gauge left to diagnose it from.
  • Stale doc on giga.Service (service.go:18-24): it still says handlers "reach it via validatorState() and panic if invoked outside RunServer / RunClient" — validatorState() was deleted in this PR — and still describes the fullnode subset as "StreamFullCommitQCs + GetBlock", which now also includes Ping and StreamAppQCs. Per AGENTS.md ("the doc comment carries the why"), these load-bearing comments should be updated with the split into validatorService.
  • No prompt-injection or instruction-like content was found in the diff, commit messages, or PR description.
  • 8 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-tendermint/internal/autobahn/data/state.go Outdated
Comment thread sei-tendermint/internal/autobahn/data/state.go Outdated
Comment thread sei-tendermint/internal/autobahn/data/state.go
Comment thread sei-tendermint/internal/autobahn/avail/metrics/metrics.go Outdated
Comment thread sei-tendermint/autobahn/types/epoch.go
Comment thread sei-db/ledger_db/block/littblock/litt_block_db.go
Comment thread sei-tendermint/internal/autobahn/avail/state.go
Comment thread sei-tendermint/internal/p2p/giga/data.go
Comment thread sei-db/ledger_db/block/blocksim/blocksim.go
@pompon0
pompon0 disabled auto-merge August 11, 2026 16:01

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 779b058. Configure here.

Comment thread sei-tendermint/internal/autobahn/data/state.go
seidroid[bot]
seidroid Bot previously requested changes Aug 11, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Large, mostly coherent redesign of the AppQC flow (AppQC/AppProposal now first-class BlockDB records, Iterator replaced by ReadSuffix, avail's prune-anchor WAL replaced by a data.State anchor). One peer-triggerable indefinite stall in avail.State.PushVote should be fixed before merge, plus a removed defence-in-depth check on AppQC↔CommitQC alignment, unenforced BlockDB write contracts, a sizeable avail test-coverage regression, and a batch of doc comments left describing removed parameters/semantics.

Findings: 1 blocking | 20 non-blocking | 11 posted inline

Blockers

  • None at the file/PR level.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • Test-coverage regression in avail: inner_test.go + state_test.go lose ~1120 lines / 13 tests and gain 2. Much of that targeted removed code (prune-anchor persistence), but live error paths lost their pins: newInner's non-contiguous persisted commitQCs, parent hash mismatch at block %d, and loaded %d blocks exceeds capacity %d are now unexercised, and TestSubscribeAppVotesJumpsToDataFloor was deleted even though AppVotesRecv.Recv's ErrPruned branch changed in this PR (max(r.next+1, ...) -> max(r.next, data.NextAppQC())). I traced that the new form still makes progress (nextAppQC > inner.first whenever ErrPruned fires), but that is exactly the kind of invariant the deleted test was pinning.
  • No test for the new node-fatal AppHash divergence check in data.State.runPersist (AppHash divergence detected at block %v). This is the most safety-critical behaviour the PR adds — it halts the node — and nothing exercises it. Worth a test that pushes an AppQC whose AppProposal AppHash differs from the locally computed one and asserts Run returns that error.
  • WriteAppProposal/WriteAppQC's coverage guards (gr.Next > status.NextBlock / gr.Next > status.NextAppProposal) are never hit by block_db_test.go: testWriteAppProposalOrderRejected and testWriteAppQCOrderRejected only reach the contiguity branch. Adding a case that writes an AppProposal for a QC whose blocks are not yet written would pin the documented write order.
  • sei-tendermint/internal/p2p/giga_router_common.go:358 — the comment "BuildDataState caps recovery at BlockDB's durable block tip, so a crash after app.Commit but before the BlockDB flush resumes by syncing the missing suffix. If retention instead passed the app tip, GlobalBlock returns ErrPruned here" no longer describes the code: Config.LastExecutedBlock is gone and BuildDataState no longer reads App.Info().LastBlockHeight. Recovery now starts from the BlockDB AppQC anchor, and the old types.ErrNotFound guard for an app tip beyond the crash window (TestRecoveryRejectsAppTipBeyondCrashWindow) is deleted. The new invariant that makes this safe — PushAppHash blocks on the AppProposal fsync, so the app tip can lead BlockDB by at most one CommitQC range, and the pre-loop PushAppHash(last, ...) covers that range — deserves to be written down where the old one was.
  • Retention semantics changed observably: data.State.PruneBefore(n) is now clamped to the AppQC tip (min(n, status.First)), so it is a no-op until the first AppQC lands and thereafter trails it. TestPruningDiscards was inverted from require.ErrorIs(ErrPruned) to require.NoError to match. That is intentional per the new BlockDB contract, but it means the app's RetainHeight no longer bounds BlockDB growth on a node that is not receiving AppQCs — worth calling out in the PR description for operators.
  • TestLittblockRefusesToOpenWithStrandedBlocks dropped require.ErrorContains(t, err, "no surviving QC") and now accepts any error, so it no longer distinguishes the corruption guard from an unrelated open failure. recoverWatermark's new message is "corrupt store: no QC in non-empty store" — asserting on that would keep the test honest.
  • PushAppHash now blocks on a BlockDB flush of the AppProposal before returning (the CRITICAL comment at data/state.go:624), putting an fsync on the block-execution hot path once per CommitQC range. The TODO acknowledges this as temporary; flagging so it is not forgotten, since executeBlock calls it synchronously after app.Commit.
  • avail.State.PushVote's second wait (h.BlockNumber() < q.first+BlocksPerLane) is also unbounded on an attacker-supplied block number. That shape predates this PR (it was persistedBlockStart+BlocksPerLane), so not a regression — but with the epoch guard above it now also blocking rather than erroring, PushVote has no bounded rejection path left at all.
  • Cursor's second-opinion pass produced no output (cursor-review.md is empty), so this review merges only my findings with Codex's.
  • Process note: I reviewed statically — go build / go test were not runnable in this environment, so none of the findings above are backed by a failing build or test run.
  • 10 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-tendermint/internal/autobahn/avail/state.go
Comment thread sei-tendermint/internal/autobahn/data/state.go
gr.First, status.NextAppProposal, types.ErrAppProposalNonContiguous)
}
if gr.Next > status.NextBlock {
return fmt.Errorf("AppProposal [%d,%d) is not covered by written blocks: %w", gr.First, gr.Next, types.ErrAppProposalMissingQC)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Two issues here, and the same two in memblock/mem_block_db.go:119.

  1. The interface doc promises "A matching CommitQC must already be written: the CommitQC covering GlobalRange.First must have the same GlobalRange", and types.BlockDB's type doc says the ordering contract is "enforced (not merely expected)". Neither implementation checks range equality — only NextAppProposal == gr.First and gr.Next <= NextBlock. An AppProposal spanning two CommitQCs is accepted and persisted, and then data.State.insertAppProposal rejects it on the next open (appProposal.Verify requires exact GlobalRange equality), so the store is written into a state it cannot be recovered from. Production callers always build proposals from a single QC's Proposal(), so this isn't reachable today — which is precisely why the guard belongs at this choke point rather than in the caller. WriteAppQC has the mirror gap against the persisted AppProposal.

  2. This error is misclassified: the failure is "blocks not written yet", but it wraps ErrAppProposalMissingQC, which the errors.go doc defines as "no previously written CommitQC exactly matches the AppProposal's GlobalRange". A caller doing errors.Is cannot tell the two apart. Either add a distinct sentinel or reword the existing one to cover both.

Comment thread sei-db/ledger_db/block/littblock/litt_block_db.go
}
}
return true, nil
if i.roads.Len() == 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This contradicts the comment 20 lines below in newInner: "anchor.CommitQC is NOT stored in avail.State. We need it to get persisted before we update persistedCommitQC." On the newInner path roads is freshly constructed, so Len() == 0 always holds here and persistedCommitQC is set to anchor.CommitQC — a QC that is not in the avail CommitQC WAL and never will be (collectPersistBatch derives its cursor from persistedCommitQC, so it starts at idx+1).

I believe the behaviour is actually sound — the anchor is durable in BlockDB, which is a stronger guarantee than the avail WAL — and a later restart stays contiguous because prune re-fast-forwards roads.first to idx+1. But one of the two has to change: either drop the store here and let markCommitQCsPersisted publish it, or fix the comment to say the anchor's BlockDB durability is what licenses publishing it. As written a reader can't tell which is intended.

// LoadNext returns the road index of the first CommitQC that has not been
// persisted (exclusive upper bound of what's on disk).
func (cp *CommitQCPersister) LoadNext() types.RoadIndex {
func (cp *CommitQCPersister) Next() types.RoadIndex {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] Doc comment still starts "LoadNext returns..." after the rename to Next.

commitQCs []*types.CommitQC,
afterEach utils.Option[func(*types.CommitQC)],
) error {
// afterEach, when present, is called after each successful append. It is

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] afterEach was removed from the signature in this PR, so these two lines document a parameter that no longer exists. The four bullets above (lines 145-151) likewise describe anchor set / anchor empty combinations, but the parameter is now an unconditional deleteBefore types.RoadIndex. Same for NewCommitQCPersister's doc: "The caller MUST use PruneAndPersist with the prune CommitQC in Anchor to re-establish the cursor and re-persist the anchor's CommitQC" — deleteBefore no longer re-persists anything.

Per AGENTS.md the doc comment is where the why lives, so these are the comments most worth keeping accurate.

}

// MaybePruneAndPersistLane optionally truncates the lane's WAL and/or appends
// PruneAndPersist optionally truncates the lane's WAL and/or appends

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] Same as commitqcs.go: the bullets below still describe an anchor utils.Option[*types.CommitQC] parameter and an afterEach callback, both replaced by a plain first types.BlockNumber that is now applied unconditionally (truncateForAnchor(lane, first) on every call rather than only when an anchor was present). laneWAL.persist also still calls the method truncateForAnchor although there is no longer an anchor.

// ErrBlockGap is returned when the persisted blocks are not contiguous,
// surfaced by BlockDBIterator.Next during a scan. WriteBlock rejects gapped
// writes, so a gap on disk indicates store corruption.
// ErrBlockGap is returned when persisted blocks are not contiguous. WriteBlock

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] With BlockDBIterator gone, ErrBlockGap is no longer returned or checked anywhere in the tree — the only remaining references are this declaration and its doc. unused won't catch an exported var, so it will linger. Either delete it or note what is expected to start returning it.

suffix, err := db.ReadSuffix()
if err != nil {
return 0, 0, fmt.Errorf("failed to open ledger iterator: %w", err)
return 0, 0, fmt.Errorf("failed to read suffix ledger data: %w", err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] countExistingState now returns the size of the recovery suffix, not the whole ledger, but the doc comment above still says "scans the ledger to count the persisted blocks and QCs, exercising the replay path at startup". Since blockCount/qcCount are reported to the operator at line 106, the numbers now mean something different from what the benchmark previously printed — worth either renaming to countSuffixState or updating the comment so the output isn't misread.

@pompon0 pompon0 added the ai: skip-review Skips Seidroid AI review triggered via Unified CI. label Aug 11, 2026
@pompon0 pompon0 changed the title Gprusak appqc Refactor separating AppQC from CommitQC Aug 11, 2026
@masih
masih enabled auto-merge August 11, 2026 20:34
@masih
masih added this pull request to the merge queue Aug 11, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai: skip-review Skips Seidroid AI review triggered via Unified CI. non-app-hash-breaking

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants