Skip to content

perf(engine): hold prune's reachable and check's verified sets compactly - #499

Merged
rmanibus merged 2 commits into
mainfrom
perf/objkey-compact-sets
Aug 11, 2026
Merged

perf(engine): hold prune's reachable and check's verified sets compactly#499
rmanibus merged 2 commits into
mainfrom
perf/objkey-compact-sets

Conversation

@rmanibus

@rmanibus rmanibus commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add internal/objkey: the compact in-memory form of an object key (Key — a
    namespace byte plus a decoded SHA-256), the canonical-lowercase hex decoder,
    and the Set built on them. A key is <namespace>/<64 hex> — 73 bytes of text
    plus, as a map key, an interior pointer the garbage collector traces, all to
    carry 32 bytes of hash.
  • Use it for the two structures sized by the repository rather than by the work
    in front of them: prune's reachable set (internal/engine/prune.go) and
    check's verified set (internal/engine/check.go), both previously
    map[string]bool.
  • Rewire packCatalog and KeyCacheStore onto the same package. This bit
    packing was already written twice in internal/storelayer; prune and check
    would have been a third and a fourth copy, which is how such a scheme drifts.
  • Encoding is total, and for prune that is correctness, not polish.
    docs/compatibility.md forbids a garbage collector from reading "cannot
    represent" as "not referenced", and a key missing from reachable is an object
    the sweep deletes. A key that does not fit the compact shape is kept verbatim in
    a string-keyed fallback. Encode is a pure function of the key, so Add and
    Has always consult the same map; and it is injective, so a compact entry can
    only ever be reported for the key that added it.
  • Fix a latent bug found on the way: packCatalog decoded with encoding/hex,
    which accepts uppercase. An uppercase key therefore encoded, and re-encoded
    lowercase — so it round-tripped through Each under a different name than it
    went in with
    , and prune's sweep would have been handed the name of an object
    that is not there. Only canonical lowercase decodes now.

Related issues

None.

Repository compatibility

No repository format change. A Key is in-process only — it is never written to
a store, and every caller (Set, packCatalog.Each, packCatalog.Keys) hands
back full key strings.

The change nonetheless touches a garbage collector, so the compatibility argument
is the point of the PR rather than a formality. docs/compatibility.md: "Never
read 'cannot decode' as 'empty'"
and "never let a destructive operation proceed
on incomplete information"
. A representation that silently dropped a key it
could not encode would violate both from the inside — prune would mark fewer
objects than it reached, and delete the difference, with nothing to signal it.

So objkey.Set is total over strings rather than over well-shaped keys, and the
packCatalog fallback that already existed is preserved with the same intent.
This is covered by TestPruneManager_KeepsObjectsWhoseKeysDoNotFitTheCompactForm,
which walks a real repository whose chunk refs are short, legacy-named, and
uppercase-hex, and requires all of them to survive a prune that does delete real
garbage in the same run; by TestSet_KeepsUnshapedKeys and
TestEncode_IsInjectiveAcrossHexCase; and by
TestPackCatalog_PreservesNonCanonicalHexExactly. The uppercase-hex fix strictly
narrows what encodes, so keys that previously round-tripped compactly still do.

Verification

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

Both pass; lint reports 0 issues. The prune guards
(TestPruneManager_AbortsWhenSnapshotsVanishButObjectsRemain,
TestPruneManager_AbortsWhenSweepListingFails) are unaffected.

What an entry costs

BenchmarkSetBytesPerEntry / _Map, 200,000 keys, heap measured across the fill
rather than read off B/op (Apple M3 Max, go1.25):

representation B/entry
map[string]bool 132.4
objkey.Set 67.3

BenchmarkSetHas is 0 allocs/op, and BenchmarkKeyCacheExists stays at 0
allocs/op after the decoder moved packages.

What bench.sh can see: nothing, and that is the honest answer

SAMPLES=1 SIZES="5000 20000 50000" BACKENDS=local scripts/benchmark/bench.sh

SAMPLES=1 deliberately: bench.sh regenerates the tree once per size and the
churn steps mutate it in place, so only sample 1 is comparable between runs. Two
independent repetitions were run per binary, which gives a same-binary spread to
judge the difference against.

op scale before r1/r2 after r1/r2 same-binary spread Δ medians
prune peak RSS (MB) 50000 411.4 / 374.2 361.8 / 396.7 37.2 −13.5
check peak RSS (MB) 50000 352.4 / 336.9 334.6 / 321.9 15.5 −16.4
prune alloc (MB) 50000 9304.9 / 11088.4 8688.1 / 9000.7 1783.5 −1352.2
check alloc (MB) 50000 10893.3 / 10052.2 11404.6 / 9704.1 841.1 +81.6
restore peak RSS (MB) 50000 888.9 / 971.4 986.4 / 922.1 82.5 +24.1
backup peak RSS (MB) 50000 493.1 / 480.2 511.8 / 533.0 12.9 +35.8

Every delta is inside the spread of the same binary against itself. The last two
rows are the control: restore and backup touch neither set and cannot have
changed, yet they moved as much as or more than the operations that did. Reading
any of the first four rows as a result would be reading noise.

alloc_mb also cannot show this change, and may show it inverted. The key
strings are allocated either way — they arrive from a store listing or a JSON
decode — so what changes is whether the set retains them; meanwhile the compact
map's buckets are wider (33-byte keys against 16-byte string headers), which adds
allocation volume. Residency down, allocation flat-to-slightly-up is the expected
shape, and alloc_mb only sees the second half of it.

The structural argument

The benchmark's largest cell is too small for 65 B/entry to clear the noise
floor. Measured with prune -dry-run on a 50,000-file source tree: 109,999
objects
. 65.1 B × 110,000 ≈ 7 MB, against a prune peak RSS of ~380 MB — under
2%, where the same-binary spread at that scale is 37 MB.

What matters is that this is the term that grows with the repository while the
rest of prune's and check's footprint does not. At 1M objects it is 65 MB; at
10M, 650 MB — the difference between an operation that opens a large repository
and one that does not. Peak RSS answers "does this grow with the repository",
and the per-entry number is the only place that question is visible at a scale
CI can run.

Documentation

AGENTS.md gains an internal/objkey/ entry in the package layout, stating what
it is, why it is not in internal/core (a Key is never written down), and that
the encoding is total. No exported API changed, so no update to the Cloudstic
docs repository is needed.

Summary by CodeRabbit

  • Improvements
    • Reduced memory usage during repository verification and pruning.
    • Improved handling of legacy, uppercase, and non-canonical object keys.
    • Standardized object-key encoding and decoding across repository operations.
  • Bug Fixes
    • Ensured pruning preserves all live objects, including keys that cannot use the compact representation.
    • Preserved non-canonical object keys exactly during pack catalog operations.
  • Documentation
    • Documented object-key storage behavior and limitations.

prune's mark phase and check's walk each hold one map entry per object in
the repository. Both were map[string]bool keyed by "<prefix>/<64 hex>" —
73 bytes of text plus, as a map key, an interior pointer the garbage
collector must trace, all to carry 32 bytes of hash.

internal/storelayer already solved this for the pack catalog, and
internal/storelayer/keycache.go a second time for its digest sets. Rather
than add a third hand-rolled copy of the same bit packing, this factors it
into internal/objkey: a Key (namespace byte plus decoded SHA-256), the
canonical-lowercase hex decoder both existing copies needed, and the Set
that prune and check now use. packCatalog and KeyCacheStore are rewired
onto it, so there is one encoding rather than three drifting ones.

Measured at 67 B/entry against 132 for map[string]bool over 200,000 keys
(BenchmarkSetBytesPerEntry), so ~65 bytes and one traced pointer per live
object, on the two structures whose size is the repository's rather than
the work in front of them.

The encoding is total, which for prune is correctness rather than polish:
docs/compatibility.md forbids a garbage collector from reading "cannot
represent" as "not referenced", and a key missing from the reachable set is
an object the sweep deletes. A key that does not fit the compact shape is
kept verbatim in a string-keyed fallback, and
TestPruneManager_KeepsObjectsWhoseKeysDoNotFitTheCompactForm walks a
repository whose chunk refs take those shapes and requires them to survive.

Only canonical lowercase hex decodes, so encoding is injective. That fixes
a latent bug in packCatalog, which decoded with encoding/hex: uppercase hex
decoded there and re-encoded lowercase, so such a key round-tripped through
Each under a different name than it went in with.
@rmanibus rmanibus added area/core Core backup engine, repository model, and restore semantics perf Performance, memory, and scaling work labels Aug 11, 2026
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.42553% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/engine/check.go 66.66% 0 Missing and 5 partials ⚠️
internal/engine/prune.go 73.33% 1 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 50d3ab69-d12d-48ec-8157-a4a3be0e6b6f

📥 Commits

Reviewing files that changed from the base of the PR and between 73e352b and 38f9eb3.

📒 Files selected for processing (3)
  • internal/objkey/objkey.go
  • internal/objkey/objkey_test.go
  • internal/storelayer/packcatalog_test.go

📝 Walkthrough

Walkthrough

The PR adds compact object-key encoding and fallback storage. It applies objkey.Set to verification and pruning, shares digest decoding with storage components, updates pack-catalog key handling, and adds regression tests and benchmarks.

Changes

Object-key foundation

Layer / File(s) Summary
Object-key encoding, set, and validation
internal/objkey/*, AGENTS.md
Adds compact namespaced digest keys, canonical decoding, fallback storage for unencodable keys, tests, benchmarks, and package documentation.
Storage-layer integration
internal/storelayer/keycache.go, internal/storelayer/keycache_test.go, internal/storelayer/packcatalog.go, internal/storelayer/packcatalog_test.go
Uses shared digest decoding and compact key types in key-cache and pack-catalog operations. Tests preserve noncanonical keys and validate namespace coverage.
Verification and pruning integration
internal/engine/check.go, internal/engine/prune.go, internal/engine/prune_guard_test.go
Replaces string-keyed verification and reachability maps with objkey.Set. The prune regression test verifies that live unrepresentable keys remain and unrelated garbage is removed.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested labels: bug

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise, follows Conventional Commit format, and accurately describes the compact reachable and verified sets.
Description check ✅ Passed The description covers the required summary, issue status, compatibility, verification results, benchmarks, and documentation changes.
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
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/objkey-compact-sets

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.

🧹 Nitpick comments (1)
internal/objkey/objkey.go (1)

40-40: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Keep the namespace encoding table immutable.

Line 40 exposes the namespace table as a mutable slice. A caller can reorder, truncate, or modify it after a Key is stored. Encode, Key.String, and Set membership can then use different encodings. Prune can fail to recognize a reachable key.

Make the table private. Expose a copy or an iterator for tests and package consumers.

🤖 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/objkey/objkey.go` at line 40, Make the namespace table backing
Namespaces private and prevent callers from mutating the encoding order used by
Encode, Key.String, and Set. Update internal references to use the private
table, and provide a copy-returning accessor or iterator for tests and package
consumers that need to inspect the namespaces without altering them.
🤖 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.

Nitpick comments:
In `@internal/objkey/objkey.go`:
- Line 40: Make the namespace table backing Namespaces private and prevent
callers from mutating the encoding order used by Encode, Key.String, and Set.
Update internal references to use the private table, and provide a
copy-returning accessor or iterator for tests and package consumers that need to
inspect the namespaces without altering them.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 81ad65a4-3581-410a-9e38-1deeeae85914

📥 Commits

Reviewing files that changed from the base of the PR and between 1e809bd and 73e352b.

📒 Files selected for processing (12)
  • AGENTS.md
  • internal/engine/check.go
  • internal/engine/prune.go
  • internal/engine/prune_guard_test.go
  • internal/objkey/objkey.go
  • internal/objkey/objkey_test.go
  • internal/objkey/set.go
  • internal/objkey/set_bench_test.go
  • internal/storelayer/keycache.go
  • internal/storelayer/keycache_test.go
  • internal/storelayer/packcatalog.go
  • internal/storelayer/packcatalog_test.go

The table's index is the namespace byte, so reordering it desynchronises
Encode from Key.String and from every Key already in a Set — a reachable
key prune would then fail to recognise. The doc comment asked callers to
treat it as append-only; an exported slice left that advisory, and
mutable at run time between one Encode and the next.

Unexport it and return a copy from Namespaces(), so the invariant cannot
be broken from outside the package at all. Raised by CodeRabbit on #499.
@rmanibus
rmanibus merged commit 86fbfaa into main Aug 11, 2026
20 of 21 checks passed
@rmanibus
rmanibus deleted the perf/objkey-compact-sets branch August 11, 2026 13:01
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 perf Performance, memory, and scaling work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant