Skip to content

Entitlement Grant Hash Index & Entitlement Grant Digest Tree#999

Merged
mj-palanker merged 12 commits into
mainfrom
mjp/grant-digest-v2
Jul 14, 2026
Merged

Entitlement Grant Hash Index & Entitlement Grant Digest Tree#999
mj-palanker merged 12 commits into
mainfrom
mjp/grant-digest-v2

Conversation

@mj-palanker

@mj-palanker mj-palanker commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Adds a new "hash index" which stores a descriptive hash of the content of a grant on a new index which is shaped exactly like the normal grant key but with a new 2 bytes of the hash of the principal keys. This distributes all grants for an entitlement randomly across the keyspace when considering only the hash. A "bucket" is just a prefix scan of those 2 bytes, meaning you can chop the grant space into as many as 2^16 even buckets.

Also introduces the concept of a grant digest which is the XOR of all grant hashes for a given bucket (plus the # of grants in said bucket). By default we always create a root digest storing info on all the entitlement's grants, plus a set of "leaf nodes" for some desired "depth" into the tree.

This desired depth is calculates such that (if distributed evenly) we have no more than 512 grants per bucket.
16 bits lets this hold until we hit 33.5 Million Grants on a single entitlement.

since this desired depth calculated value isn't assumed ever, we could change this in the sdk without breaking anything

Any stored depth can be rolled up into any less granular depth simply by XOR-ing the hashes of nodes, and adding the counts, meaning you can trivially compare grant digests stored at different resolutions. We may want to leverage this to store a fixed max resolution in c1.

Derived from #977 via robot onto main

perf tests show we doubled the End Sync time, but that's from ~25ms to ~50ms

goos: darwin
goarch: arm64
pkg: github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble
cpu: Apple M4
BenchmarkRegisteredPebbleWritePack/grants=10000000-10                  1        2549355708 ns/op                41.00 ms/EndSync              2345 ms/PutGrants 5171366088 B/op    46525 allocs/op
BenchmarkRegisteredPebbleWritePack/grants=10000000-10                  1        2218060458 ns/op                54.00 ms/EndSync              2027 ms/PutGrants 5170921376 B/op    42772 allocs/op
BenchmarkRegisteredPebbleWritePack/grants=10000000-10                  1        2210106875 ns/op                68.00 ms/EndSync              2005 ms/PutGrants 5170957704 B/op    42766 allocs/op
BenchmarkRegisteredPebbleWritePack/grants=10000000-10                  1        2272597000 ns/op                48.00 ms/EndSync              2084 ms/PutGrants 5170922776 B/op    42777 allocs/op
BenchmarkRegisteredPebbleWritePack/grants=10000000-10                  1        2172931042 ns/op                50.00 ms/EndSync              1986 ms/PutGrants 5170920936 B/op    42764 allocs/op
BenchmarkRegisteredPebbleWritePack/grants=10000000-10                  1        2144806791 ns/op                46.00 ms/EndSync              1958 ms/PutGrants 5170960672 B/op    42774 allocs/op
PASS
ok      github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble      41.450s
goos: darwin
goarch: arm64
pkg: github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble
cpu: Apple M4
BenchmarkRegisteredPebbleWritePack_NoDigestIndex/grants=10000000-10                    1        3560330167 ns/op                28.00 ms/EndSync              3365 ms/PutGrants 5031893760 B/op    45705 allocs/op
BenchmarkRegisteredPebbleWritePack_NoDigestIndex/grants=10000000-10                    1        2149813833 ns/op                27.00 ms/EndSync              1985 ms/PutGrants 5031500976 B/op    42041 allocs/op
BenchmarkRegisteredPebbleWritePack_NoDigestIndex/grants=10000000-10                    1        2136727709 ns/op                27.00 ms/EndSync              1977 ms/PutGrants 5031444792 B/op    42030 allocs/op
BenchmarkRegisteredPebbleWritePack_NoDigestIndex/grants=10000000-10                    1        2142267459 ns/op                26.00 ms/EndSync              1979 ms/PutGrants 5031456872 B/op    42053 allocs/op
BenchmarkRegisteredPebbleWritePack_NoDigestIndex/grants=10000000-10                    1        2156140416 ns/op                26.00 ms/EndSync              1992 ms/PutGrants 5031494040 B/op    42037 allocs/op
BenchmarkRegisteredPebbleWritePack_NoDigestIndex/grants=10000000-10                    1        2119374666 ns/op                26.00 ms/EndSync              1956 ms/PutGrants 5031441216 B/op    42027 allocs/op
PASS
ok      github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble      41.383s

@mj-palanker
mj-palanker requested review from a team, ggreer and kans July 8, 2026 03:25
return &Options{
durability: DurabilitySync,
slowQueryThreshold: 5 * time.Second,
grantDigestIndex: true,

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.

🟡 Suggestion: Defaulting grantDigestIndex to true means every Pebble seal now runs an extra full O(grants) scan + spill-sort + ingest + digest fold — including the common PutGrantRecords path that previously ran no deferred pass at all (endSyncFinalize now calls BuildGrantDigests on that branch). This adds seal-time latency and disk I/O for all existing downstream callers, most of which never grant-diff. The repo's own compat guidance prefers new behavior to default to the old behavior and be opt-in. It's mitigated by WithGrantDigestIndex(false) and is documented, so this is non-blocking — but consider defaulting off (or at least calling out the added seal cost in the PR/rollout notes). (medium confidence)

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

General PR Review: Entitlement Grant Hash Index & Entitlement Grant Digest Tree

Blocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base a6c9f63c2310.
Review mode: full
View review run

Review Summary

Scanned the full PR diff for security and correctness, with focus on the SDK/wire-compatibility surfaces this repo demands: the new digest core (digest.go, grant_digest.go, grant_digest_build.go, grant_digest_repair.go), key encoders (keys.go), the fold/merge compaction paths, and the proto changes. The proto changes to c1/c1z/v3/manifest.proto are purely additive (fields 41–43, new GrantDigestRoot message, PebbleIdIndexFormat enum) with matching regenerated pb/.../*.pb.go; no field renumbering/reuse, reserved 3,4 preserved, no go.mod/go.sum changes. The prior review's medium-confidence finding (fold path could ship stale digests when the digest index is disabled) is addressed: compactPebbleFold now calls DropAllGrantDigestState on touched partitions when !GrantDigestIndexEnabled() (compactor_pebble.go:625-645), preserving the present-means-exact contract. The present-means-exact invariant is consistently upheld across the invalidation, repair, interrupted-build, and global-root recomputation paths. No new issues found.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

@mj-palanker
mj-palanker force-pushed the mjp/grant-digest-v2 branch from 37b586c to 9a0012a Compare July 8, 2026 22:45

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

Comment thread pkg/dotc1z/engine/pebble/grants.go Outdated
}
}
if e.grantDigestsPresent.Load() {
if id, err := grantIdentityFromRecord(r); err == nil {

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.

🟡 Suggestion (low confidence): this swallows the error from grantIdentityFromRecord(r) — on the err != nil branch the touched entitlement's digest is silently not invalidated, which would leave a stale-but-present digest and violate the present-means-exact contract. In practice a record that reaches writeGrantIndexes already had its identity derived to build the primary key, so this is likely unreachable; but the sibling paths (writeGrantIndexesForIdentityScratch, deleteGrantIndexesScratch) can't fail-open here. Consider propagating the error instead of dropping it.

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

@kans

kans commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Final brief:

@mj-palanker do we really want to exclude sources from grant hashes? Thats user facing data, and maybe goes into ticket processing?

@alan-lee-12 you possibly know best ^

Brief: grant-digest review fixes (mjp/grant-digest-v2)

Findings from a correctness/error-recovery review of the branch. One
must-fix soundness hole, a few small hardening items, one decision to
sign off. No changes to the digest core design or the hash ABI.

Fix 1 (must-fix): standalone build crash window breaks "root present ⇒ index present"

buildGrantDigestsFromSpill commits digest nodes during the merge
(closePartition flushes batches every 4 MiB) and again at
fold.finish() — including the global root — before the hash-index
IngestAndExcise. The fused seal pass is protected by the durable
deferredIdxPending marker (a crash reruns the full build); the
standalone BuildGrantDigests path has no equivalent.

Failure sequence: inline-only sync (deferred marker never armed) →
EndSync → BuildGrantDigests → process killed mid-merge or between
fold.finish() and the ingest. Committed WAL writes survive a process
crash, so partial (or all) digest roots — possibly the global root —
are durable while the index ingest never ran. On resume,
RepairMissingGrantDigests either fast-paths on the surviving global
root or repairs only partitions missing a root, trusting every
crashed-build root that committed. The file seals with correct-looking
digests over an empty hash index: ScanEntitlementGrantBucket
yields zero grants for dirty buckets and
GetEntitlementGrantDigestNodes at a finer-than-stored level scans the
empty index and reports "no grants". That is the silent
skip-entitlements-at-uplift failure mode.

Fix options (pick one):

  • Preferred — durable build-pending marker, mirroring
    deferredIdxPending: arm (fsync'd) before the standalone build's
    first node commit, clear after writeMissingEntitlementDigestRoots
    completes. Open and RepairMissingGrantDigests seeing the marker →
    dropAllGrantDigestState first. Converts every crash into "digests
    absent", which the design already treats as safe.
  • Alternative — reorder publication: stream digest nodes to their own
    SST (they are emitted in ascending key order) and ingest it after
    the index SST, so a crash can only leave index-without-digests
    (reads as missing → safe).

Verification: a test that kills the build between the digest-node
commit and the index ingest (inject a failpoint or split the function),
reopens, reruns EndSync, and asserts either no digest state at all or
digest state whose hash index matches a from-scratch build byte-for-
byte. Cover both the mid-merge and post-finish() windows.

Fix 2: writeGrantIndexes swallows identity errors — DONE

Fixed on origin/mjp/grant-digest-v2 ("fixup for error swallow"):
writeGrantIndexes now propagates the grantIdentityFromRecord error
instead of silently skipping invalidation. Nothing further to do once
that commit is in your local stack.

Fix 3: repair path drops malformed grant keys silently

repairOneGrantDigestPartitionLocked skips keys that fail
splitGrantPrimaryKey with a bare continue. The build paths count and
Error-log the same condition ("rows NOT represented in the digests").
Add the same counter + log to the repair path.

Fix 4: doc contradiction on the ComputeEntitlementBucketDigest fallback

GetEntitlementDigestRoot / getPartitionDigestRoot doc comments
invite falling back to ComputeEntitlementBucketDigest when no root
exists — but the index only exists alongside built digests, so on a
never-built file that fold returns {0, 0} ("zero grants"): the exact
trap dirtyPartitionBuckets' own comment warns against. No in-repo
caller misuses it; fix the comments so the exported API doesn't
recommend the unsafe pattern.

Fix 5 (scale, not correctness): bound repair memory

repairOneGrantDigestPartitionLocked buffers one partition's full row
set in memory, one allocation per row. A fold repair touching an
"everyone"-style entitlement (millions of grants) is multi-GB. Add a
row-count/byte threshold above which the repair falls back to the
spill-sorter machinery (or degrade to BuildGrantDigests).

Decision (sign-off, no code): content-hash field exclusions

grantContentHash64 deliberately excludes grant annotations,
source-record VALUES, external_id, and expansion flags. Any change in
only those fields fingerprints as "no change" and gets skipped by a
digest-driven diff. Confirm with the uplift consumer that nothing it
needs rides on grant annotations (e.g. immutability/system markers).
If something does, that is an ABI change — bump
grantDigestABIVersion — and far cheaper to decide now than after
files ship.

Noted, no action required

  • Bucket-plan Compactor.Compact (synccompactor/pebble) replaces
    grants and digest buckets in separate ingests; a crash mid-loop
    leaves new grants under old, present digests. Recovery contract is
    "re-run Compact" and there is no in-repo production caller — but if
    the API is live in c1, consider dropping the digest/hash buckets
    first and replacing them last.
  • Orphan partitions (grants without an entitlement record) invalidated
    post-seal are never repaired and are excluded from the recomputed
    global root. All consequences are conservative (false-dirty, never
    false-clean); documented in RepairMissingGrantDigests.

@mj-palanker
mj-palanker force-pushed the mjp/grant-digest-v2 branch from 51bf06c to 278309f Compare July 10, 2026 19:14

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

// partition turns out to be missing. Orphan grant-bearing entitlements
// without a record are not covered — see RepairMissingGrantDigests's
// doc comment.
func (e *Engine) findMissingGrantDigestPartitionsLocked(ctx context.Context) ([]string, error) {

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.

🟡 Suggestion (low confidence): findMissingGrantDigestPartitionsLocked walks only entitlement records, so an orphan partition (grants with no entitlement record) that was invalidated by InvalidateGrantDigestPartitions during a fold is never rediscovered or rebuilt here. recomputeGrantDigestGlobalRootLocked then folds only the roots that remain stored, so the whole-file grant_digest_root written after a fold excludes a touched orphan — whereas a seal-time grantDigestFold includes orphan grants in the global root. The manifest whole-file root can therefore represent the same grant set differently depending on whether it came from a seal vs. a fold+repair, which weakens (and in contrived symmetric cases could falsely-match) the header-only "did anything change?" check. Orphans are an acknowledged edge case, but consider either rebuilding invalidated partitions that still hold grants regardless of an entitlement record, or documenting that the global root is orphan-lossy after a targeted repair.

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

mj-palanker and others added 11 commits July 13, 2026 14:30
Compacted outputs previously shipped with no grant-digest state in
every merge mode (fold/k-way/overlay), defeating the digest feature
for exactly the files downstream diff consumers ingest most. After
each mode's merge finishes writing the final grant keyspace, the
compactor now calls the existing standalone Engine.BuildGrantDigests
(the same lean build EndSync runs when the deferred index pass didn't
fire), giving compacted files digests indistinguishable from a
seal-time build without re-deriving by_principal/the primary grant
keyspace, and with the same fail-safe (drop-and-continue) semantics.

Also adds a whole-file grant digest root to the v3 envelope manifest
(GrantDigestRoot: xor_digest/count/abi_version) so a consumer can
answer "did anything change?" from a header read. It's a running
(xor, count) accumulator threaded through the same build that writes
per-entitlement digest nodes, and is dropped by the same invalidation
paths whenever any partition's digest goes stale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@mj-palanker
mj-palanker force-pushed the mjp/grant-digest-v2 branch from 4b032ea to f516a82 Compare July 13, 2026 22:31
Comment thread pkg/synccompactor/compactor_pebble.go Outdated
// (expansion NOT skipped) pays for both the targeted repair AND the
// subsequent full rebuild. See
// TestCompactPebbleFoldWithExpansionRebuildsFullyRegardless.
if len(foldStats.TouchedGrantPartitions) > 0 && destEng.GrantDigestIndexEnabled() {

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.

🟡 Suggestion (medium confidence): the touched-partition invalidation is gated on destEng.GrantDigestIndexEnabled(), but the fold's initial byte copy (see bucket_plans.go's digest / grant_by_entitlement_principal_hash entries) copies the base's digest + hash-index keyspaces unconditionally. If the dest engine is opened with WithGrantDigestIndex(false) while the base was sealed with digests, this branch is skipped, rebuildCompactedGrantDigests is not called for fold, and no EndSync rebuild runs on the skip-expansion path — so the copied digests for touched entitlements stay present-but-stale, violating present-means-exact for any downstream reader (readers trust stored digests regardless of the writer's flag). Consider dropping all digest state in the else branch when grants were touched but the index is disabled (or skipping the digest bucket-plan copy when disabled).

@github-actions github-actions Bot left a comment

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.

No blocking issues found. One medium-confidence suggestion on the fold compaction path (stale grant digests when the index is disabled) — see the inline comment and review summary.

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

@mj-palanker
mj-palanker merged commit 604dae0 into main Jul 14, 2026
10 checks passed
@mj-palanker
mj-palanker deleted the mjp/grant-digest-v2 branch July 14, 2026 00:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants