Skip to content

special error when reading below watermark#3773

Merged
cody-littley merged 2 commits into
mainfrom
cjl/blockdb-read-errs
Jul 17, 2026
Merged

special error when reading below watermark#3773
cody-littley merged 2 commits into
mainfrom
cjl/blockdb-read-errs

Conversation

@cody-littley

Copy link
Copy Markdown
Contributor

Describe your changes and provide context

Return a special error when reading a block below the watermark.

@cursor

cursor Bot commented Jul 16, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes BlockDB read and prune semantics that consensus/state layers depend on; callers must handle ErrPruned, and in-cohort prune rounding alters which heights stay readable after a partial prune.

Overview
Introduces types.ErrPruned for ReadBlockByNumber and ReadQCByBlockNumber when the height is below the retention watermark, so callers can tell pruned from never written (utils.None with nil error). The BlockDB contract and godocs are updated accordingly.

Prune behavior is tightened so a QC’s blocks change readability atomically: PruneBefore now rounds the watermark down to the covering QC’s GlobalRange().First if the requested point falls inside a cohort. littblock adds clampPruneBoundary; memblock tracks a watermark and applies the same rounding before eager deletes.

Tests are extended (including testPrunedDistinctFromNotFound and cohort round-down cases); synthetic QC helpers switch to GenFullCommitQCRange for consistent ranges.

Reviewed by Cursor Bugbot for commit c203dc6. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown

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

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedJul 17, 2026, 3:53 PM

@codecov

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.77778% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.03%. Comparing base (6ab4b85) to head (c203dc6).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
sei-db/ledger_db/block/littblock/litt_block_db.go 60.00% 4 Missing and 4 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3773      +/-   ##
==========================================
- Coverage   59.99%   59.03%   -0.97%     
==========================================
  Files        2290     2201      -89     
  Lines      190586   180317   -10269     
==========================================
- Hits       114344   106450    -7894     
+ Misses      66052    64509    -1543     
+ Partials    10190     9358     -832     
Flag Coverage Δ
sei-chain-pr 87.45% <77.77%> (?)
sei-db 70.41% <ø> (ø)
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/memblock/mem_block_db.go 98.27% <100.00%> (+0.14%) ⬆️
sei-tendermint/autobahn/types/testonly.go 95.37% <100.00%> (+0.02%) ⬆️
sei-db/ledger_db/block/littblock/litt_block_db.go 69.62% <60.00%> (-2.02%) ⬇️

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

@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.

Adds a distinct types.ErrPruned for below-watermark by-number reads and wires it consistently through both the durable (littblock) and in-memory (memblock) BlockDB implementations, with thorough test updates. Clean and well-scoped; one documentation/contract accuracy concern about the "never readable again" guarantee across restarts.

Findings: 0 blocking | 4 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor's second-opinion review (cursor-review.md) is empty — that pass produced no output.
  • The new types.ErrPruned has no production consumer yet (grep shows only the two implementations produce it; data.State does not consume BlockDB reads, and the p2p/avail layers check a separate data.ErrPruned). This PR is groundwork; a follow-up will need to translate BlockDB types.ErrPruned into the data.ErrPruned those callers already handle, otherwise the new 'stop retrying' signal never reaches them.
  • Nice consistency win: memblock now tracks a watermark and refuses sub-watermark reads even when a straddling QC physically covers the height, matching littblock's by-number gate (TestMemblockPruneStraddlingQC updated accordingly).
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-tendermint/autobahn/types/block_db.go Outdated
Comment thread sei-db/ledger_db/block/block_db_test.go Outdated
defer func() { _ = db.Close() }()
writeAll(t, db, batches)

watermark := batches[1].first

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: can you prune in the middle of a QC to make the watermark case more interesting?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This test was missing. When I added it, it exposed a real bug where we would allow the prune watermark in memory be in the middle of a QC's range. I also fixed the bug.

// ErrPruned is returned by the by-number read methods (ReadBlockByNumber and
// ReadQCByBlockNumber) when the requested GlobalBlockNumber is strictly below
// the retention watermark: the record has been pruned (or is being reclaimed)
// and will never become readable again. It is distinct from a utils.None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: we don't write the watermark onto disk, so never say never...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

godoc updated

// on its next GC pass, and only after a retention-TTL floor). The
// watermark guarantees nothing below n is removed before n is
// reached, but does NOT bound when eligible data is actually
// reclaimed — pruned entries may remain readable for a while.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

do we need to update this comment?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is still technically accurate. If there is a QC spanning blocks 100-110 and you ask to PruneBefore(105), internally it will clamp the watermark to block 100.

@claude claude 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.

LGTM - the one finding is a minor doc-wording nit, not a correctness issue.

Extended reasoning...

Overview: This PR introduces types.ErrPruned, a sentinel error returned by ReadBlockByNumber/ReadQCByBlockNumber in both littblock and memblock when a query falls strictly below the retention watermark, distinguishing pruned from never-written. memblock gains its own watermark field to match littblock's existing behavior for straddling QCs. The interface doc comment in block_db.go is updated to describe the three-way Some/ErrPruned/None outcome. Tests are updated across both implementations to assert ErrPruned where a plain None was previously expected, plus one new contract test.

Security risks: None. This is an internal storage-layer contract change with no auth, crypto, or externally-controlled input involved. The change is purely about which sentinel value/error is returned from an existing gate check that already existed (n below watermark) - no new gating logic is introduced, just a differentiated return value.

Level of scrutiny: Low-to-moderate. The change is mechanical: existing below-watermark branches that returned None with a nil error now return None with ErrPruned, plus a new watermark field added to memblock mirroring the pattern already proven out in littblock. No production caller yet consumes types.ErrPruned (grepped - only the pre-existing, unrelated data.ErrPruned is used elsewhere), so this PR only changes the contract surface, not runtime behavior of any live caller. Test coverage is thorough and updated consistently across both implementations.

Other factors: One nit-level finding was surfaced: the new doc language about a pruned record never becoming readable again is not durable across a process restart for littblock, because recoverReadWatermark re-derives the watermark from the lowest surviving QC key rather than persisting the exact PruneBefore value, which can be lower when a straddling QC survives GC. This does not violate any data-integrity invariant (a re-served block still has a readable covering QC) and is scoped to precision of the doc comment, not correctness of the gating logic itself. Given no current caller depends on the literal wording, and the underlying safety property holds, this does not rise to a level requiring a hold on approval.

Comment thread sei-db/ledger_db/block/littblock/litt_block_db.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.

Introduces a distinct types.ErrPruned for below-watermark reads across both BlockDB backends, with strong tests and docs; sound and low-risk since no production caller reads these methods yet. One latent consistency concern: littblock's prune clamp trusts the QC's embedded GlobalRange().First while WriteQC/memblock use the supplied index bounds, with no validation that they match.

Findings: 0 blocking | 5 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • littblock.clampPruneBoundary derives the rounded watermark from the QC's embedded GlobalRange().First, whereas memblock rounds using the supplied WriteQC bounds (e.lower/e.upper). The BlockDB.WriteQC contract does not require lowerBound == qc.GlobalRange().First and neither backend validates it, so a mismatched QC would make the two backends disagree and could push littblock's watermark above the requested height (even past the newest block, over-pruning). Not production-reachable today (only blocksim/tests call these), but worth hardening: validate lowerBound==GlobalRange().First / upperBound==Next in WriteQC, or clamp from the index bounds for parity with memblock. (Also raised by Codex as P1.)
  • There are now two distinct ErrPruned sentinels — the new types.ErrPruned and the pre-existing data/state.go ErrPruned (errors.New("pruned")). Harmless now but should be reconciled/mapped when BlockDB is wired into data.State.
  • cursor-review.md was empty — the Cursor second-opinion pass produced no output.
  • Codex's second-opinion review flagged only the clampPruneBoundary/WriteQC consistency issue above; no additional findings from it.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

if err != nil {
return 0, fmt.Errorf("failed to decode covering QC for %d: %w", blockHeight, err)
}
return qc.QC().GlobalRange().First, nil

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 rounds the watermark to the QC's embedded GlobalRange().First, but WriteQC indexes the QC under the separately-supplied lowerBound/upperBound and never validates they match the embedded range (nor does the interface contract require it). memblock's clamp instead uses the stored index bounds (e.lower). If a QC's embedded First ever differs from its index position, the two backends diverge and littblock can move the watermark above the requested height — potentially past the newest block, over-pruning. Consider validating lowerBound == qc.GlobalRange().First in WriteQC, or clamping from the index bounds here for parity with memblock.

@claude claude 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.

LGTM — the only finding from this run is a nit on test-only helper code (GenFullCommitQCRange) with no effect on any current code path, and the earlier round-trip/restart concerns I and other reviewers raised have already been addressed (godoc updated, watermark-rounding bug found by the new test and fixed).

Extended reasoning...

Overview

This PR adds a distinct types.ErrPruned error for ReadBlockByNumber/ReadQCByBlockNumber when a requested block/QC is below the retention watermark, replacing the previous ambiguous utils.None. It also tightens PruneBefore so the watermark always rounds down to a QC's GlobalRange().First (littblock via the new clampPruneBoundary, memblock via an equivalent in-memory scan), guaranteeing a QC's covered blocks change readability atomically. Touches littblock, memblock, the types.BlockDB interface docs, and test infrastructure (GenFullCommitQCRange replacing GenFullCommitQCN).

Security risks

None. This is internal storage/retention logic in the consensus block store, not exposed to external input or untrusted data.

Level of scrutiny

Medium — this changes read/prune semantics of a durable store consumed by consensus-critical code (data.State), so correctness of the watermark/cohort-rounding logic matters, but no callers currently consume ErrPruned yet (per the PR's own docs), which limits blast radius today. The PR has strong test coverage (both littblock and memblock exercise the same scenarios, including a new test that itself caught and fixed a real watermark-rounding bug), and prior review rounds (mine, cursor/seidroid/wen-coding, cody-littley) already surfaced and resolved the restart/permanence-of-ErrPruned concerns via doc wording and a bug fix.

Other factors

The one remaining finding this run (GenFullCommitQCRange's Next not surviving a protobuf round-trip) is confined to test-only code and has no current caller relying on it — a nit, not a blocker. The author has been responsive throughout the thread, addressing every prior reviewer comment (including my own) with either a code fix or a doc clarification.

Comment on lines +409 to +417
// GenFullCommitQCRange generates a FullCommitQC whose GlobalRange is
// [first, next) and which is internally consistent (First + len(headers) ==
// Next), for tests that index a QC by its own range. littblock does not verify
// signatures, so the QC need not be otherwise well-formed.
func GenFullCommitQCRange(rng utils.Rng, first GlobalBlockNumber, next GlobalBlockNumber) *FullCommitQC {
qc := GenCommitQC(rng)
qc.vote.Msg().Proposal().globalRange = GlobalRange{First: first, Next: next}
headers := utils.GenSliceN(rng, int(next-first), GenBlockHeader) //nolint:gosec // small test range
return NewFullCommitQC(qc, headers)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 GenFullCommitQCRange's doc comment promises the returned QC's GlobalRange stays internally consistent (First + len(headers) == Next), but that only holds for the freshly-constructed in-memory object. Because it builds the QC via GenCommitQC/ProposalAt with zero lane ranges and then pokes globalRange directly, any protobuf encode/decode round-trip (which littblock performs on every WriteQC/Read) recomputes Next as First + sum(lane-range lengths) and silently collapses it back to First. This is test-only code (testonly.go) and no current test or production code path reads a round-tripped QC's .Next off this helper, so nothing in this PR is broken today, but it's a genuine footgun in the PR's own new public test API for a future littblock/memblock test that asserts .Next after a read-back.

Extended reasoning...

GenFullCommitQCRange (sei-tendermint/autobahn/types/testonly.go:409-417) is new test-helper API added by this PR to replace GenFullCommitQCN. It builds a CommitQC via GenCommitQCCommitQCAtProposalAt, which constructs a Proposal with an empty laneRanges map, and then directly overwrites the private field qc.vote.Msg().Proposal().globalRange = GlobalRange{First: first, Next: next}. The doc comment states the returned QC "is internally consistent (First + len(headers) == Next)".

That claim is true only of the freshly-constructed, in-memory object. Proposal.GlobalRange() (proposal.go:209-211) just returns the struct field, so reading it back immediately after construction reflects the injected value. But ProposalConv (proposal.go) only ever serializes GlobalFirst on the wire — Encode never writes Next at all. Decode reconstructs the proposal via newProposal (proposal.go:175-191), which recomputes Next as First + sum(laneRange lengths). Since the underlying proposal built by ProposalAt has zero lane ranges, that sum is always 0, so any encode→decode round trip collapses Next back down to First, silently discarding the value passed into GenFullCommitQCRange.

This isn't a hypothetical serialization path — it's the exact one littblock performs on every WriteQC/read. encodeQC calls types.FullCommitQCConv.Marshal(qc) and decodeQC calls types.FullCommitQCConv.Unmarshal(...), both of which delegate down through CommitQCConvCommitVoteConvProposalConv. Three independent verifiers reproduced this concretely: calling types.GenFullCommitQCRange(rng, 100, 110) yields GlobalRange{First:100, Next:110} in memory (10 headers), but after types.FullCommitQCConv.Marshal + Unmarshal — littblock's exact round trip — it reads back as GlobalRange{First:100, Next:100}, with the 10 headers still present but the range now zero-length.

Why nothing in this PR currently observes it: clampPruneBoundary and every current caller of a decoded QC only ever read GlobalRange().First (which is serialized and survives). recoverCursors derives lastQCNext from lowerBound + len(qc.Headers()) (the header count, which survives in full), never from GlobalRange().Next. The new litt_block_stranding_test.go tests that use this helper (writeSyntheticBatches) only ever assert .First after a round trip. So the refutation is right that no code path in this PR is broken by this — I'm not claiming it is.

Why it's still worth flagging (as a nit, not a blocker): the helper is the PR's own new exported test API (types.GenFullCommitQCRange), specifically built to feed littblock (a store that always round-trips through protobuf), and its doc comment makes an unqualified consistency promise that doesn't survive that exact round trip. A future littblock/memblock test — or a reviewer extending testPrunedDistinctFromNotFound-style coverage — asserting .GlobalRange().Next on a QC obtained via ReadQCByBlockNumber built with this helper would silently get a collapsed, wrong zero-length range instead of the range requested, with no error or panic to flag the mistake. That's a real footgun in new test infrastructure even though it causes zero harm today.

Step-by-step proof:

  1. Call qc := types.GenFullCommitQCRange(rng, 100, 110).
  2. In memory: qc.QC().GlobalRange() returns {First: 100, Next: 110}, and len(qc.Headers()) == 10, matching the doc's claimed invariant.
  3. Encode: bytes := types.FullCommitQCConv.Marshal(qc) (what littblock's encodeQC calls). Internally this delegates to ProposalConv.Encode, which serializes only GlobalFirst = 100Next is never written to the wire.
  4. Decode: qc2 := types.FullCommitQCConv.Unmarshal(bytes) (what littblock's decodeQC calls). This calls newProposal, which recomputes Next = First + sum(laneRange lengths) = 100 + 0 = 100, since the proposal has no lane ranges.
  5. Result: qc2.QC().GlobalRange() is now {First: 100, Next: 100} — a zero-length range — while len(qc2.Headers()) is still 10. The invariant First + len(headers) == Next (100 + 10 == 110) that held in step 2 is now false (100 + 10 != 100).

Suggested fix: either build the proposal with real lane ranges sized to match [first, next) so Next is derived correctly and survives serialization, or add a doc caveat noting the invariant only holds for the in-memory object returned by the constructor and does not survive a protobuf round trip (e.g. through littblock).

@cody-littley
cody-littley added this pull request to the merge queue Jul 17, 2026
Merged via the queue into main with commit 7fee5c2 Jul 17, 2026
71 checks passed
@cody-littley
cody-littley deleted the cjl/blockdb-read-errs branch July 17, 2026 18:15
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.

2 participants