fix(EN-1501): bound live query checkpoints with a configurable, Raft-replicated limit - #1660
fix(EN-1501): bound live query checkpoints with a configurable, Raft-replicated limit#1660Azorlogh wants to merge 5 commits into
Conversation
Query checkpoints were unbounded: any client or the cron scheduler could create them indefinitely, growing disk usage and List payloads without limit. Enforce a fixed cap of 10 live query checkpoints, with no eviction. Creation past the cap fails with a typed CHECKPOINT_LIMIT_REACHED (ResourceExhausted / HTTP 429); an operator must delete one to free a slot. - The live count is a deterministic, replicated set of checkpoint IDs on FSMState, rehydrated at recovery from the stored rows and enforced in the FSM apply path (processCreateQueryCheckpoint) via a lazy per-proposal WriteSet overlay -- no Pebble reads on the hot path, and not the eventually-consistent usagebuilder projection. - DeleteQueryCheckpoint is now existence-aware: a non-live id returns CHECKPOINT_NOT_FOUND and emits no log, keeping created-minus-deleted equal to the live count. - The scheduler recognizes the cap, logs it once and stays armed so creation resumes automatically once a checkpoint is deleted. - New checker pass compareQueryCheckpoints re-derives the live set from the audit chain (baseline-seeded under archiving) and flags any unjustified stored row (CHECK_STORE_ERROR_TYPE_QUERY_CHECKPOINT_MISMATCH), closing a pre-existing invariant #8 gap. - Docs and CLI text updated; the "naturally bounded" claim is replaced by the fixed cap.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🛑 Changes requested — automated reviewThe new checker pass still cannot detect deletion of an explicitly persisted limit when its audited value equals the default. |
NumaryBot
left a comment
There was a problem hiding this comment.
NumaryBot posted 1 new inline finding.
Summary: #1660 (comment)
Codecov Report❌ Patch coverage is ❌ Your patch check has failed because the patch coverage (71.55%) is below the target coverage (80.00%). You can increase the patch coverage or adjust the target coverage. Additional details and impacted files@@ Coverage Diff @@
## release/v3.0 #1660 +/- ##
================================================
+ Coverage 74.39% 74.73% +0.33%
================================================
Files 447 447
Lines 47566 47695 +129
================================================
+ Hits 35389 35645 +256
+ Misses 8984 8841 -143
- Partials 3193 3209 +16
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
flemzord
left a comment
There was a problem hiding this comment.
Revalidated current head 3163e7fc. I confirm the existing NumaryBot finding as a correctness/integrity blocker: recovery, cap enforcement, and the new checker derive the live set from embedded checkpoint IDs without validating them against the persisted Pebble key IDs. A mismatched row can therefore under-count live rows and evade the projection check. I am not duplicating the inline evidence. The NumaryBot and Codecov failures are not the basis of this review.
|
One additional correctness/integrity blocker on current head |
…al checker Addresses PR #1660 review (NumaryBot + flemzord). - ReadLiveQueryCheckpointIDs now derives the live-set IDs from the Pebble key instead of the payload checkpoint_id. A corrupted/hand-repaired row whose key and embedded id diverge can no longer under-count the cap at recovery or hide a phantom key from the checker. - compareQueryCheckpoints now verifies BOTH directions: a stored row with no create (or a later delete) AND an audit-live checkpoint with no stored row are both flagged. To keep that sound, the audit-rebuild path now recreates the checkpoint metadata rows from the CreatedQueryCheckpoint / DeletedQueryCheckpoint logs (rebuild.go), so a missing row is corruption, not a restore artifact. The physical files still cannot be rebuilt, so a rebuilt checkpoint reads Unavailable until deleted (the existing EN-1460 state). - Exported state.SaveQueryCheckpoint / DeleteQueryCheckpointFromBatch for the rebuild path. Added key-authoritative + rebuild-replays-checkpoints tests; updated docs and invariant #8.
|
@flemzord addressed in 4fdc99064:
|
NumaryBot
left a comment
There was a problem hiding this comment.
NumaryBot posted 2 new inline findings.
Summary: #1660 (comment)
Addresses PR #1660 review (NumaryBot blocker on rebuild.go). Round 2 made RebuildDelta recreate the checkpoint rows but left SubGlobNextQueryCheckpointID unset, so after a full audit rebuild the counter defaulted to 1 and the next create could reissue a used id and overwrite a restored row. Track the max checkpoint id across all CreatedQueryCheckpoint logs (deleted ones included — the counter is monotonic and must never rewind) and persist max+1 as SubGlobNextQueryCheckpointID after the replay. Exported state.StoreNextQueryCheckpointID for the rebuild path. Extended the rebuild test to assert the counter lands at max(created)+1, not max(surviving)+1.
NumaryBot
left a comment
There was a problem hiding this comment.
NumaryBot posted 2 new inline findings.
Summary: #1660 (comment)
paul-nicolas
left a comment
There was a problem hiding this comment.
Multi-model review — Claude + Codex
Reviewed at head 5e2601ed1 from a dedicated worktree pinned to that SHA. GOROOT= go build ./... is green and all changed packages pass (internal/domain/processing, internal/infra/state, internal/application/check, internal/query, internal/infra/backup, internal/infra/attributes, internal/domain).
Findings: 3 posted inline — 2 Major, 1 Medium. Both models converged on the same cluster of issues; each was then verified against the real code before posting, and everything below is mechanically confirmed rather than inferred.
The three findings share one root: the set of live checkpoints is now rigorously audited, but the contents of each checkpoint row and the filesystem behind it are not. max_sequence is audit-backed yet uncompared, created_at is not audited at all, and the new existence-aware delete silently drops the only cleanup path for orphaned checkpoint directories.
What I checked and found sound
Worth stating explicitly, since these are the parts most likely to hide a determinism or consistency bug:
- Live-set consistency across every reconstruction path.
Recovery.RecoverStateis the single path that rebuildsFSMState, and it is called on both restart and follower sync (recovery.go:49), so the newLiveQueryCheckpointIDsrehydration is reached on every boot path.FSMStateis not proto-serialized into the Raft snapshot — the snapshot is the Pebble checkpoint — so Pebble rehydration is the correct and only mechanism; there is no snapshot field left to update. - Concurrency. The map is touched only in
fsmstate.go,write_set.go, and tests — never by the query path, the scheduler, or a health path. No unsynchronised map read/write. - Overlay semantics. The lazy copy makes rollback correct (
Resetnils it, so an aborted proposal leavesFSMStateuntouched), and the cap check precedesIncrementNextQueryCheckpointID, so a rejected create consumes no ID. No Pebble read is added on the hot path (invariant #3), and the gate reads onlylen/lookups, so there is no map-iteration determinism hazard (invariant #2). - Rebuild counter restore.
RebuildDeltais incremental on top of a restored Pebble checkpoint that already carriesSubGlobNextQueryCheckpointID, so the "delta has no create logs" case correctly leaves the counter alone, and when the delta does have creates,max+1is exactly right. No ID-reuse or row-overwrite window — this addresses the concern the head commit was written for. - No checker false positive on restore. The bidirectional check is safe because restore carries the rows and
RebuildDeltapatches the delta; theinDerived && !inStoreddirection cannot fire on a legitimate restore. - Error wiring. Both reasons are complete end to end — the string→enum conversion is generic (
ErrorReason_value["ERROR_REASON_"+…]), so no per-reason registration is missing, andKindForReasonhandles both. Freezability is correct:CHECKPOINT_LIMIT_REACHED→ResourceExhaustedis not freezable (IsFreezableFailure,errors.go:151), sidestepping the idempotency-freeze hazard as intended, whileCHECKPOINT_NOT_FOUND→NotFoundis freezable, which is right since IDs are monotonic and never reused. - Proto and generated code are consistent and sequentially numbered; mocks for the two new
Scopemethods are properly regenerated. - Docs are accurate.
query-checkpoints.md,cli.md,api-comparison.md, and the invariant-#8 pass list inAGENTS.mdall correctly describe the bidirectional pass. No REST/openapi surface is affected (gRPC ClusterService only), so that claim holds too. - E2E test is properly isolated — a dedicated single node on its own ports (9224/8224), so the global cap cannot leak across specs, and it asserts the typed reasons rather than just "an error occurred". Good test.
Note on the PR description
The description still says the checker pass is "One-directional (stored ⊆ derived): the reverse is intentionally not flagged because checkpoint rows are deliberately not rebuilt on restore." The code, the docs, and AGENTS.md are all bidirectional, and rows are rebuilt on restore — the last commit changed this. Worth updating the body so it does not contradict the merged design.
Minor / Nit (not posted inline)
internal/infra/state/query_checkpoint_scheduler.go:131— thelimitReachedstate machine itself (log-once, stay-armed, reset-on-success) has no test; only theisCheckpointLimitReachedclassifier is covered. "Stays armed so creation resumes after a delete" is the behavioural claim in the PR body and the docs, and nothing asserts it at any level. Consider a small loop-level test drivingproposeFnthrough limit → limit → success.internal/infra/state/write_set.go:1745—QueryCheckpointCount/QueryCheckpointExistscallensureLiveQueryCheckpoints(), so even a rejected create (cap full) or a read-only existence check allocates a full copy of the set and makesMergeswap in a content-identical map. Harmless, but the copy could be deferred to the first mutation by having the read paths fall back tob.fsm.State.LiveQueryCheckpointIDswhen the overlay is nil.
| return nil, domain.ErrCheckpointIDRequired | ||
| } | ||
|
|
||
| if !ctx.Scope.QueryCheckpointExists(order.GetCheckpointId()) { |
There was a problem hiding this comment.
[Major] Existence-aware delete removes the only path that reclaimed stray checkpoint directories
Returning CHECKPOINT_NOT_FOUND before emitting DeletedQueryCheckpointLog also suppresses every downstream cleanup, because all of it is log-driven:
Machine.deleteQueryCheckpointFilesruns post-commit frompb.checkpointDeletes(internal/infra/state/machine.go:889-891), which is populated fromr.QueryCheckpointDeleted(machine.go:789-793) — a signal derived from the delete log.- the read-index cleanup is gated the same way (
internal/application/indexbuilder/process_logs.go:159).
I checked for any other reclamation path and there is none: DeleteQueryCheckpointFiles (internal/storage/dal/store.go:955) has exactly one caller, queryCheckpointsDir is referenced only by the create/delete/path helpers in store.go, and cleanupOldCheckpoints reaps checkpointsDir (Pebble backup checkpoints), not query-checkpoints/. process_logs.go:252 states outright that there is no reconciler.
Reachable case: a follower that falls far enough behind to be caught up by a snapshot install receives the leader's Pebble state (row already absent) and never executes the per-entry post-commit hook, so its local query-checkpoints/<id>/ survives. Before this PR an operator could reclaim it by re-issuing delete <id> — the log was emitted unconditionally and every node deleted its files. Now that call returns NotFound and does nothing, so the directory leaks permanently with no API or boot-time sweep to recover it. Each one is a full db.Checkpoint() whose hard-linked SSTs keep pinning disk as the live store compacts — the same unbounded-disk failure mode EN-1501 is closing, reintroduced through a different door.
Fix: keep the typed NotFound for the caller (it is the right contract and the live-count exactness argument holds), but do not let it be the only signal. Either emit a file-cleanup-only side effect on the not-found branch, or add a boot-time sweep that reconciles query-checkpoints/* against LiveQueryCheckpointIDs (which recovery already loads) and removes directories with no live row — the analogue of purgeOrphanVersions for the read index.
There was a problem hiding this comment.
Fixed: RecoverState now sweeps query-checkpoints/* against LiveQueryCheckpointIDs and reclaims row-less dirs (best-effort). 858e115c1
| // so a missing row is never a legitimate restore artifact — it is corruption. | ||
| // IDs come from the Pebble key (ReadLiveQueryCheckpointIDs), not the payload. | ||
| func (c *Checker) compareQueryCheckpoints(reader dal.PebbleReader, derived map[uint64]struct{}, callback func(*servicepb.CheckStoreEvent)) error { | ||
| stored, err := query.ReadLiveQueryCheckpointIDs(reader) |
There was a problem hiding this comment.
[Major] Checker pass verifies only checkpoint IDs, leaving the audit-backed max_sequence unverified
compareQueryCheckpoints reads ReadLiveQueryCheckpointIDs, which decodes IDs from the Pebble key and discards the payload. So the pass verifies set membership only. QueryCheckpointState.max_sequence is a persisted field of this projection, and it is audit-backed — CreatedQueryCheckpointLog.max_sequence (field 2, misc/proto/common.proto:559) carries it, and the rebuild path already reads it back (rebuild.go:412).
Why it matters: invariant #8 requires the checker to verify every projection it persists, and no other pass touches SubGlobQueryCheckpoint (grep: only baseline.go, query_checkpoint.go, batch.go, and this file). A store whose max_sequence is edited in place therefore passes Check() clean, and the tampered value is served to clients by ListQueryCheckpoints / GetQueryCheckpointInfo (internal/adapter/grpc/server_cluster.go:473 and :577) and printed by ledgerctl query-checkpoint list/info. It does not change what the read index serves (the indexbuilder materializes from the log's value inline), so this is an integrity/reporting gap rather than a query-correctness one — but it is exactly the "projection the checker does not verify is a tampering vector" case, and the PR adds this pass precisely to close that gap.
Fix: make derived a map[uint64]*commonpb.CreatedQueryCheckpointLog (or a small struct carrying maxSequence) instead of map[uint64]struct{}, load the full stored rows with query.ListQueryCheckpoints, and compare max_sequence per ID, emitting CHECK_STORE_ERROR_TYPE_QUERY_CHECKPOINT_MISMATCH on divergence. The baseline seed can carry the stored max_sequence the same way it carries the ID today.
There was a problem hiding this comment.
Fixed: compareQueryCheckpoints now verifies max_sequence (and created_at) per row, both directions. 858e115c1
| // already defines. The row keeps the projection audit-consistent so | ||
| // the cap and compareQueryCheckpoints stay correct after a rebuild. | ||
| if cp := p.CreatedQueryCheckpoint; cp != nil { | ||
| if err := state.SaveQueryCheckpoint(batch, &raftcmdpb.QueryCheckpointState{ |
There was a problem hiding this comment.
[Medium] Rebuild silently drops created_at, and the field has no audit backing at all
The rebuilt row is constructed with only CheckpointId and MaxSequence, so QueryCheckpointState.created_at (misc/proto/raft_cmd.proto:217) comes back as nil after any restore + RebuildDelta. That value is user-visible: it is returned by ListQueryCheckpoints / GetQueryCheckpointInfo (internal/adapter/grpc/server_cluster.go:473, :577) and rendered by ledgerctl query-checkpoint list / info, so every live checkpoint reports an empty creation time post-restore.
The root cause is that CreatedQueryCheckpointLog only carries checkpoint_id and max_sequence (misc/proto/common.proto:557-560) — the timestamp is never audited, so it is not merely dropped here, it is unrecoverable and permanently unverifiable by any checker pass. Combined with the finding on compareQueryCheckpoints, created_at is a persisted projection field with zero audit binding, which is the invariant-#8 gap in its strongest form.
Fix: add common.Timestamp created_at = 3; to CreatedQueryCheckpointLog, populate it in processCreateQueryCheckpoint from the same proposal date the row already uses, run just generate-proto, set it on the rebuilt row here, and extend the checker comparison to cover it. v3 is unreleased, so there is no wire-compat cost. Worth a rebuild regression test asserting CreatedAt survives — rebuild_test.go currently only asserts the ID set.
There was a problem hiding this comment.
Fixed: created_at added to CreatedQueryCheckpointLog, populated at create, restored on rebuild, verified in the checker. 858e115c1
|
Blocking design concern: EN-1501 establishes why query-checkpoint cardinality must be bounded, but neither the ticket nor this PR provides a legitimate basis for the value 10. The fact that This should not be “fixed” by simply reusing If the limit is configurable, it needs cluster-wide replicated semantics: the value must be committed through Raft, take effect at a precise applied index on every node, and have explicit compatibility/validation rules for rolling upgrades. If it remains a protocol constant, the chosen value needs a documented product/operational rationale rather than being copied from an unrelated default. Please document both the derivation of the limit and the safe configuration/rollout model before merging. |
…le setting + review fixes Addresses PR #1660 review (paul-nicolas + gfyrag/Geoffrey). Configurable limit (gfyrag): the max live query checkpoints is no longer a hard-coded constant. It is a cluster-wide value committed through Raft via a SetQueryCheckpointLimit order and applied deterministically in the FSM at a precise applied index, so every node enforces the same value — never a node-local flag (which would let replicas apply the same Raft entry differently). Defaults to query.DefaultQueryCheckpointLimit (10) when unset; zero is rejected (INVALID_QUERY_CHECKPOINT_LIMIT). New ledgerctl query-checkpoint set-limit / get-limit, GetQueryCheckpointLimit RPC, FSMState.QueryCheckpointLimit, SubGlobQueryCheckpointLimit key. Mirrors the query-checkpoint schedule mechanism; a set earlier in a proposal is visible to later creates in the same bulk. Not placed on ClusterConfig (flag-reconciled at leadership → would revert runtime changes). Orphaned-directory reclamation (paul P1): existence-aware delete no longer leaves a snapshot-installed follower with a row-less query-checkpoints/<id>/ directory. RecoverState sweeps query-checkpoints/* against the restored LiveQueryCheckpointIDs and removes orphans (best-effort), reclaiming the hard-linked Pebble checkpoint that would otherwise pin disk. Checker completeness (paul P2/P3): compareQueryCheckpoints verifies both directions, and for each present row compares max_sequence, created_at, and the key-vs-payload checkpoint_id. created_at is now carried on CreatedQueryCheckpointLog so a full audit rebuild reconstructs it; RebuildDelta recreates the rows and restores the monotonic NextQueryCheckpointID counter. Live-set ids are read from the Pebble key, not the payload. Docs (cli.md, api-comparison.md, query-checkpoints subsystem page) and the invariant #8 pass list updated. golangci-lint clean on both modules.
NumaryBot
left a comment
There was a problem hiding this comment.
NumaryBot posted 1 new inline finding.
Summary: #1660 (comment)
|
Reworked to a cluster-wide, Raft-committed configurable limit (default 10), applied deterministically in the FSM at a precise applied index — not a node-local flag. |
Addresses PR #1660 NumaryBot blocker. The limit gates a replicated command, so a corrupted SubGlobQueryCheckpointLimit on one replica would make it accept or reject creations differently from peers. Add compareQueryCheckpointLimit (invariant #8): compare the stored limit against the value re-derived from the latest SetQueryCheckpointLimit log (baseline-seeded under archiving, default when unset), emitting CHECK_STORE_ERROR_TYPE_QUERY_CHECKPOINT_LIMIT_MISMATCH on divergence. Copy the limit key into the checker baseline snapshot.
NumaryBot
left a comment
There was a problem hiding this comment.
NumaryBot posted 1 new inline finding.
Summary: #1660 (comment)
| // when never set). The limit gates a replicated command, so a tampered value | ||
| // would make one replica accept/reject creations differently from its peers — | ||
| // hence it is a verified projection (invariant #8), not merely rebuildable. | ||
| func (c *Checker) compareQueryCheckpointLimit(reader dal.PebbleReader, derived uint64, callback func(*servicepb.CheckStoreEvent)) error { |
There was a problem hiding this comment.
🔴 [blocker] Verify the persisted checkpoint limit against the audit chain
When the audited limit was explicitly set to the default value 10, deleting SubGlobQueryCheckpointLimit is invisible here because ReadQueryCheckpointLimit substitutes the same default for a missing key. The checker therefore accepts a lost persisted projection instead of verifying its presence; track whether the audit contains a set operation and distinguish an absent row accordingly, as required by AGENTS.md:21.
|
I think the limit should be enforced at admission time rather than by the FSM. The important rolling-upgrade property is that once a Proposed changes:
This makes the limit an operational safeguard rather than a Raft protocol invariant, which is the intended boundary here. |
EN-1501: bound the number of live query checkpoints
Query checkpoints were unbounded — a scheduler or client loop grows disk (hard-linked SST checkpoints) and
ListQueryCheckpointspayloads without limit. This caps the number of live query checkpoints.The limit
Cluster-wide, Raft-committed, runtime-configurable (default 10). Set/read via
ledgerctl query-checkpoint set-limit <n>/get-limit; enforced in the FSM at a precise applied index so every node agrees — deliberately not a node-local flag (that would break FSM determinism under config drift / rolling upgrade). Creation fails at the limit withCHECKPOINT_LIMIT_REACHED(ResourceExhausted/ 429); no eviction — delete one or raise the limit. Zero is rejected (INVALID_QUERY_CHECKPOINT_LIMIT).How
FSMState.QueryCheckpointLimit+ a live-ID set onFSMState, both rehydrated at recovery; the cap gate reads them in the apply path (no Pebble reads on the hot path, invariant Add basic script system #3).CHECKPOINT_NOT_FOUND; only a real delete emits a log, keeping created−deleted == live.query-checkpoints/*against the live set, reclaiming a row-less directory a snapshot-installed follower would otherwise leak.compareQueryCheckpointsverifies stored rows against the audit chain both ways, includingmax_sequence/created_at/ key-vs-payload id;created_atis carried onCreatedQueryCheckpointLog;RebuildDeltarecreates the rows and restores the monotonic next-ID counter.ClusterConfig, which is flag-reconciled at leadership and would revert runtime changes).Tests
Unit (processor cap/limit/delete,
WriteSetoverlay + intra-bulk, checker, recovery sweep, rebuild) + e2e (cap, delete-not-found, set/get-limit → enforce new cap → reject-0 → raise). golangci-lint clean on both modules.