Skip to content

fix(store): a read plan tracks its keys, not a tally (RFC 0025) - #503

Open
rmanibus wants to merge 3 commits into
mainfrom
fix/plan-tracks-declared-keys
Open

fix(store): a read plan tracks its keys, not a tally (RFC 0025)#503
rmanibus wants to merge 3 commits into
mainfrom
fix/plan-tracks-declared-keys

Conversation

@rmanibus

@rmanibus rmanibus commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

  • packGroupPlan held a count per pack, and consume ran on every serve from a packed location — so a read nobody declared decremented a declared pack's budget. Packs mix five namespaces, so this is the ordinary case: a traversal declares a batch of filemeta/ refs and then reads, undeclared, the content/ objects those refs name, the node/ objects it discovers mid-walk, and whatever path resolution needs — a good share of them out of the packs it just declared against.
  • The plan now names its keys. PlanReads already materialises every key to sort it, so the set costs a map slot apiece over the caller's own strings — bounded by the batch (8,192 refs for every streaming traversal), never by the catalog. consume spends only a declared key, and fetchWhole is asked per key rather than per pack.
  • consume now also runs on the miss path. Counting only cache hits left every named pack claiming one object outstanding — the one whose miss brought the body in — so a plan never reached the zero that retires it.
  • aging.sh gains ls, find and diff; backup and prune move to a separate FINAL_OPS because they change the repository under the curve; and ATTACH=1/KEEP_STORE=1 let two builds be measured against one repository.
  • RFC 0025 updated with the re-run this unblocks, including two of its own predictions that the measurement refuted.

What the re-run found

This is the precondition RFC 0025 set on re-running the planned-versus-unplanned count in "The estimator is load-bearing". Counted at 82 backups against MinIO, per key prefix:

command planned unplanned what the unplanned are
restore 345 473 node/ 472, snapshot/ 1
check 60 696 node/ 577, content/ 118
ls 66 473 node/ 472
find 0 656 node/ 632, filemeta/ 24
backup 64 473 node/ 473
prune 275 4,556 node/ 4,523

96–100% of every command's unplanned misses are node/. restore, ls, backup and diff have 472 or 473 node reads and one snapshot left, and nothing else. A HAMT descent cannot declare its next read — which node comes next is a field of the node currently being decoded. So packAdmission's remaining job is tree descent, not traversal at large, which turns "can we delete it" into one well-posed question instead of six.

The RFC's stated reason for expecting this to matter was wrong, and is recorded as such. It predicted the count would move once the accounting was honest. Measured against a build differing only in that, it moves by 0–3 out of ~500 — a traversal's undeclared reads are node/ and its declared ones are filemeta/, so the two populations barely overlap. The defect is real and a unit test pins it; its effect on the number was nil. The breakdown, not the fix, is what changed the conclusion.

One open decision for the reviewer

Making the plan name its keys is two changes, and only one is free. Three builds, one repository, two passes each — base, mid (key-aware consume only), fix (also fetchWhole per key):

requests, 82 packs base mid fix
restore 851, 849 854, 861 851, 855
check 712, 712 712, 712 761, 761
ls 547, 547 547, 547 547, 547
find 679, 679 679, 679 679, 679
diff 539, 539 539, 538 539, 539

The accounting fix costs nothing. Asking per key costs check 7% and nothing else. A synchronous counter and a -debug trace of the same process agree prefix by prefix on why: eight whole-pack transfers stop happening that base made on behalf of content/ objects check never declared, each of which had been populating the body cache and serving ~6 later content reads as hits. That was a subsidy, not a decision — the plan answering for reads it was never told about, the same conflation that let an undeclared read spend a declared budget.

Keeping it also closes the one-plan-per-PackStore hazard: a cat of one file inheriting another operation's eighty-object whole-fetch decision transfers 8 MB to return a few hundred bytes. If 49 requests on check is the wrong trade, say so and I will ship mid instead. A third option is recorded in the RFC and not built here: let an undeclared read ride a whole fetch the plan is going to make anyway, while still reporting unplanned and spending nothing.

Related issues

None — RFC 0025 has no tracking issue. Part of RFC 0025.

Repository compatibility

No repository format change. This changes only how PackStore decides between a ranged read and a whole-pack transfer; the objects read and returned are identical either way, and nothing here is written to a store.

Verification

env GOCACHE=/tmp/cloudstic-gocache go test -race -count=1 ./internal/storelayer ./internal/engine ./pkg/store
env GOCACHE=/tmp/cloudstic-gocache GOLANGCI_LINT_CACHE=/tmp/cloudstic-golangci-lint golangci-lint run ./internal/... ./pkg/...

Both pass; golangci-lint reports 0 issues. Full go test -race -count=1 ./... also passes.

Two regression tests, both confirmed failing on the pre-change code:

  • TestPackGroupPlan_UndeclaredReadKeepsDeclaredBudget — before: filemeta/1 is no longer planned after 4 undeclared reads of packs/a.
  • TestPackStore_UndeclaredReadDoesNotSpendAPlannedPacksBudget — before: 7 ranged reads serving 40 declared keys, want 0.

Request counts are from scripts/benchmark/aging.sh against MinIO at 80 backups / 5,000 files / 82 packs. Not from bench.sh, which builds a fresh repository per cell and structurally cannot see aged-repository behaviour, and whose check cell has a documented 70% run-to-run spread. Holding one repository fixed and attaching each build collapses that spread to 0% — the six read rows above reproduce to the digit — which is what the ATTACH change is for. prune is reported from a single sample and its 65% spread stands; it is not treated as an A/B.

Documentation

No documentation change required. No exported API changes: packGroupPlan is unexported and PackStore's public surface is untouched, so the separate docs repository is unaffected. RFC 0025 is updated in this PR.

Summary by CodeRabbit

  • Bug Fixes

    • Improved packed-data read accounting so unexpected reads no longer consume budgets reserved for planned content.
    • Ensured planned reads can still use efficient whole-pack transfers after unrelated reads or cache eviction.
  • Benchmarking

    • Expanded aging benchmarks with configurable read-only and state-changing operations.
    • Added support for reusable repositories, full-tree diff discovery, input validation, and clearer cleanup instructions.
  • Documentation

    • Updated traversal and packed-read guidance with revised measurements, benchmarks, and corrected conclusions.

packGroupPlan held a count per pack, and consume ran on every serve from a
packed location — so a read nobody declared decremented a declared pack's
budget. Packs mix five namespaces, so this is the ordinary case: a traversal
declares a batch of filemeta refs and then reads, undeclared, the content
objects those refs name, the nodes it discovers mid-walk, and whatever path
resolution needs, a good share of them out of the packs it just declared
against. The declared reads behind them found the budget spent and fell back to
the estimate.

The plan now names its keys. PlanReads already materialises every key to sort
it, so the set costs a map slot apiece over the caller's own strings — bounded
by the batch (8,192 refs for every streaming traversal), never by the catalog.
consume spends only a declared key, and fetchWhole is asked per key rather than
per pack: a pack being named by the plan says nothing about a read the plan does
not name, and answering a cat of one file from someone else's eighty declared
objects transfers 8 MB to return a few hundred bytes.

Two consequences worth stating. Consume now runs on the miss path too, because a
miss is a read: counting only cache hits left every named pack claiming one
object still outstanding — the one whose miss brought the body in — so a plan
never reached the zero that retires it, and a pack read entirely by ranged reads
kept stating the demand it began with. And the declared set is a multiset, since
a deduplicated content object is legitimately declared once per referencing
file; RFC 0025 records de-duplicating those as a measured mistake.

This is the precondition RFC 0025 sets on re-running the planned-versus-unplanned
count in "The estimator is load-bearing": that count was taken on this
accounting, so an unknown share of the unplanned misses it records are declared
reads whose slot was taken.
…repository (RFC 0025)

aging.sh could run restore and check. RFC 0025 counts six commands, and the
four it could not run were measured by hand each time, which is why `diff` was
invoked without the two snapshot IDs it requires through four rounds and
reported zero.

OPS gains ls, find and diff. backup and prune go in a separate FINAL_OPS,
because they change the repository: a backup adds one, which is this script's
independent variable, and a prune deletes snapshots and rewrites packs, so
either at a checkpoint silently redefines every checkpoint after it.

ATTACH=1 measures against the repository already in MinIO rather than aging a
new one, and KEEP_STORE=1 leaves the container up for it. That is what makes a
read-policy comparison mean anything here. Running the whole script twice
compares two builds against two repositories, and pack composition is not
deterministic — PackStore.Put uploads outside the lock while backup uploads
concurrently — which is where `check`'s 70% spread on identical code comes
from. Age once, attach each build in turn, and the layout is held fixed instead
of resampled.

find gets a pattern nothing matches: its cost is the walk, and matches would
add output formatting without adding traversal. diff gets oldest against
latest, the whole-tree case; adjacent snapshots differ by one churn step and
would measure the churn.
…(RFC 0025)

Re-runs "The estimator is load-bearing" on accounting that distinguishes a
declared read from one that merely shares a pack, which is the precondition
that section set for deleting anything.

The totals barely moved, and the breakdown changed the conclusion. Counted per
key prefix at 82 backups, 96-100% of every command's unplanned misses are
node/: restore, ls, backup and diff have 472 or 473 node reads and one snapshot
left, and nothing else. A HAMT descent cannot declare its next read, because
which node comes next is a field of the node currently being decoded. So the
estimator's job is permanent and it is tree descent, not traversal at large --
which makes deleting packAdmission one well-posed question rather than six
vague ones.

This section previously predicted the count would move once the accounting was
honest: an unknown share of the unplanned misses were meant to be declared
reads whose slot had been taken. Measured against a build differing only in
that, it moves by 0-3 out of ~500. The mechanism is real and a unit test pins
it; its magnitude on these traversals is nil, because a traversal's undeclared
reads are node/ and its declared ones are filemeta/, and the two populations
barely overlap. Recorded as a wrong prediction rather than quietly dropped.

Asking the plan per key rather than per pack costs check 7% and nothing else,
and both instruments agree on why: eight whole-pack transfers stop happening
that base made on behalf of content objects check never declared, each of which
had been populating the body cache and serving around six later content reads
as hits. That was a subsidy, not a decision. Whether to keep it is left open,
with a third option recorded -- ride a whole fetch the plan will make anyway,
while still reporting unplanned and spending nothing.

Also corrects §6's variance note. check's 70% spread is a property of the
protocol, not of check: each run aged its own repository, and pack composition
is not deterministic. Aging once and attaching each build to the same store
collapses it -- 712, 712 / 712, 712 / 761, 761 across three builds and two
passes, with ls, find and diff identical to the digit in all six.
@rmanibus rmanibus added bug Something isn't working area/store area/core Core backup engine, repository model, and restore semantics perf Performance, memory, and scaling work labels Aug 12, 2026
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Pack read tracking now uses per-key grouped-read plans for cache hits, misses, and whole-pack admission. Tests cover undeclared reads. RFC 0025 updates measurements. The aging benchmark supports configurable operations and retained repositories.

Changes

Key-aware pack read planning

Layer / File(s) Summary
Key-aware pack accounting and validation
internal/storelayer/pack.go, internal/storelayer/packgroup.go, internal/storelayer/packgroup_test.go
Plans retain declared keys. Only declared reads consume plan state. Whole-pack decisions use the requested key. Tests cover undeclared reads, plan retirement, and whole-pack transfers.
RFC measurements and conclusions
rfcs/0025-traversal-order-and-pack-contiguous-reads.md
RFC 0025 documents per-key accounting, controlled repository attachment, key-prefix miss measurements, and updated estimator conclusions.
Configurable aging benchmark
scripts/benchmark/aging.sh
The benchmark supports configurable read and final operations, attach mode, retained stores, ls/find/diff checkpoints, snapshot validation, and cleanup controls.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ReadPath
  participant PackGroupPlan
  participant Cache
  participant PackStore
  ReadPath->>PackGroupPlan: Declare packed object keys
  PackStore->>PackGroupPlan: Check whole-pack admission by key
  PackStore->>Cache: Read cached pack or object
  PackStore->>PackGroupPlan: Consume matching declared key
  PackStore->>ReadPath: Return object data
Loading

Possibly related PRs

  • Cloudstic/cli#487: Adds the PackStore demand-declaration mechanism extended by this change.
  • Cloudstic/cli#496: Introduces grouped pack-read behavior extended with per-key accounting.
  • Cloudstic/cli#500: Evaluates related declared and undeclared restore reads.

Suggested labels: benchmark, rfc, test

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description check ✅ Passed The description covers the required sections, explains the key changes, documents compatibility, and lists detailed verification results.
Title check ✅ Passed The title is concise, follows the required Conventional Commit format, and accurately identifies the key-aware read-plan fix.
Docstring Coverage ✅ Passed Docstring coverage is 87.50% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch fix/plan-tracks-declared-keys
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/plan-tracks-declared-keys

Comment @coderabbitai help to get the list of available commands.

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rfcs/0025-traversal-order-and-pack-contiguous-reads.md (1)

1182-1185: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Describe the K=1 rule as a future policy.

Lines 299-304 state that current ungrouped reads use the estimator. Lines 995-1068 state that the heuristics remain required for unplanned node/ reads. This text instead says ungrouped reads “need no heuristic at all.”

State that K=1 ranged reads are a proposed replacement policy. Do not describe it as current behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rfcs/0025-traversal-order-and-pack-contiguous-reads.md` around lines 1182 -
1185, Update the “Grouping covers only the keys a caller hands over” discussion
to describe the K=1 arithmetic ranged-read rule as a proposed future policy, not
current behavior. Preserve the examples of ungrouped reads and acknowledge that
existing estimator heuristics remain required for unplanned node/ reads.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/storelayer/pack.go`:
- Around line 396-409: Preserve one plan instance throughout each Get flow by
capturing the current plan before the cache-miss path calls resolveFromPack.
Update resolveFromPack and the subsequent consume call to use that captured plan
rather than rereading s.plan, ensuring admission and consumption stay tied to
the same declaration; add a regression test that replaces the plan while a
ranged read is blocked.

In `@scripts/benchmark/aging.sh`:
- Around line 313-315: Update the prune handling in the benchmark script so it
runs a full check successfully immediately before invoking prune, regardless of
whether OPS includes check. Apply this consistently to both attach-mode and
normal-mode execution paths, and abort without pruning when validation fails.

---

Outside diff comments:
In `@rfcs/0025-traversal-order-and-pack-contiguous-reads.md`:
- Around line 1182-1185: Update the “Grouping covers only the keys a caller
hands over” discussion to describe the K=1 arithmetic ranged-read rule as a
proposed future policy, not current behavior. Preserve the examples of ungrouped
reads and acknowledge that existing estimator heuristics remain required for
unplanned node/ reads.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3170adb6-ff5c-42d7-b561-6bfbb144e0bb

📥 Commits

Reviewing files that changed from the base of the PR and between d523676 and 58e3d84.

📒 Files selected for processing (5)
  • internal/storelayer/pack.go
  • internal/storelayer/packgroup.go
  • internal/storelayer/packgroup_test.go
  • rfcs/0025-traversal-order-and-pack-contiguous-reads.md
  • scripts/benchmark/aging.sh

Comment on lines +396 to +409
s.groupPlan().consume(key, entry.PackRef)
return data, nil
}

return s.resolveFromPack(ctx, key, entry)
// A miss is a read too. Counting only the cache hits left the plan claiming
// one object still outstanding on every pack it named — the one whose miss
// brought the body in — so `count` never reached the zero that retires it,
// and a pack read entirely by ranged reads kept stating the demand it began
// with rather than what was left of it.
data, err := s.resolveFromPack(ctx, key, entry)
if err != nil {
return nil, err
}
s.groupPlan().consume(key, entry.PackRef)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Keep one plan instance for each Get call.

Line 405 reads admission state from the current plan. Line 409 consumes the current plan again after external I/O. If PlanReads replaces s.plan while the read is blocked, an old read can consume the declaration from the new plan for the same key. The next declared read then falls back to admission probing.

Capture the plan before resolving the pack. Pass that same plan to resolveFromPack and consume. Add a regression test that replaces the plan while a ranged read is blocked.

Proposed fix
+	plan := s.groupPlan()
 	if packData, ok := s.packCache.Get(entry.PackRef); ok {
 		// ...
-		s.groupPlan().consume(key, entry.PackRef)
+		plan.consume(key, entry.PackRef)
 		return data, nil
 	}

-	data, err := s.resolveFromPack(ctx, key, entry)
+	data, err := s.resolveFromPack(ctx, key, entry, plan)
 	if err != nil {
 		return nil, err
 	}
-	s.groupPlan().consume(key, entry.PackRef)
+	plan.consume(key, entry.PackRef)
 	return data, nil
 }

-func (s *PackStore) resolveFromPack(ctx context.Context, key string, entry PackEntry) ([]byte, error) {
+func (s *PackStore) resolveFromPack(ctx context.Context, key string, entry PackEntry, plan *packGroupPlan) ([]byte, error) {
 	// ...
-	fetchWhole, planned := s.groupPlan().fetchWhole(key, entry.PackRef)
+	fetchWhole, planned := plan.fetchWhole(key, entry.PackRef)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
s.groupPlan().consume(key, entry.PackRef)
return data, nil
}
return s.resolveFromPack(ctx, key, entry)
// A miss is a read too. Counting only the cache hits left the plan claiming
// one object still outstanding on every pack it named — the one whose miss
// brought the body in — so `count` never reached the zero that retires it,
// and a pack read entirely by ranged reads kept stating the demand it began
// with rather than what was left of it.
data, err := s.resolveFromPack(ctx, key, entry)
if err != nil {
return nil, err
}
s.groupPlan().consume(key, entry.PackRef)
plan := s.groupPlan()
if packData, ok := s.packCache.Get(entry.PackRef); ok {
// ...
plan.consume(key, entry.PackRef)
return data, nil
}
// A miss is a read too. Counting only the cache hits left the plan claiming
// one object still outstanding on every pack it named — the one whose miss
// brought the body in — so `count` never reached the zero that retires it,
// and a pack read entirely by ranged reads kept stating the demand it began
// with rather than what was left of it.
data, err := s.resolveFromPack(ctx, key, entry, plan)
if err != nil {
return nil, err
}
plan.consume(key, entry.PackRef)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/storelayer/pack.go` around lines 396 - 409, Preserve one plan
instance throughout each Get flow by capturing the current plan before the
cache-miss path calls resolveFromPack. Update resolveFromPack and the subsequent
consume call to use that captured plan rather than rereading s.plan, ensuring
admission and consumption stay tied to the same declaration; add a regression
test that replaces the plan while a ranged read is blocked.

Comment on lines +313 to +315
prune)
measure_op prune "$backups" "$CLOUDSTIC_BIN" prune $flags -quiet
;;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate the repository before prune.

OPS="" is supported at Line 71. With FINAL_OPS=prune, this path can repack and delete repository data without a preceding full read validation. This affects both attach mode at Line 355 and normal mode at Line 409.

Run a successful full check immediately before prune, even when OPS omits check. Stop when validation fails. As per coding guidelines, “Never allow prune, forget, or repacking to proceed when repository data could not be fully read.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/benchmark/aging.sh` around lines 313 - 315, Update the prune handling
in the benchmark script so it runs a full check successfully immediately before
invoking prune, regardless of whether OPS includes check. Apply this
consistently to both attach-mode and normal-mode execution paths, and abort
without pruning when validation fails.

Source: Coding guidelines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core Core backup engine, repository model, and restore semantics area/store bug Something isn't working perf Performance, memory, and scaling work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant