Skip to content

Grant Hash Digest Index + leaf only tree#977

Open
mj-palanker wants to merge 12 commits into
mainfrom
mjp/grant-digest
Open

Grant Hash Digest Index + leaf only tree#977
mj-palanker wants to merge 12 commits into
mainfrom
mjp/grant-digest

Conversation

@mj-palanker

Copy link
Copy Markdown
Contributor

Introduces grant content hashing.

Adds a new grant index where the key sorts grants into buckets based on the hash of the principal ID such that each entitlement's grants are now sorted into roughly equal sized buckets. The value is the hash of the content of the grant, such that it genuinely should describe it's state.

each "bucket" is actually a set number of bits into the 2 byte hash s.t. we can dynamically "bucket" at higher / lower resolution without re-indexing.

any two c1zs could compare any given bucket for grant equality by just scanning this index.

We also add new objects which stores an XOR rollup of the hashes on an entitlement.

for a given bit count k, we store 2^k leaf nodes where the value is the XOR of all hashes in their bucket.
we also store a root note which is the XOR of ALL grants on the entitlement (equivalent to the rollup of the leaf nodes)

@mj-palanker
mj-palanker requested review from a team, btipling, ggreer and kans June 19, 2026 02:21
Comment thread pkg/dotc1z/engine/pebble/if_newer.go Outdated
// IfNewer is by definition a post-seal mutation path, so the
// grant digests (cloned along with the sync's keyspace) are
// kept in step incrementally.
mm := newGrantDigestMutator(e)

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-low confidence): Unlike PutGrantRecords, this path has no dedup pre-pass. If the same external_id appears twice in one call and both pass the freshness check, e.db.Get(key) returns the same stored value both times (it doesn't see in-batch writes), so mm.removeGrant(old) runs twice while addGrant runs for each duplicate. The repeated remove folds the old content hash into the root XOR an even number of times (cancelling it) while the count delta stays consistent — so applyPartition's negative-count guard won't trip and the corrupted digest persists silently. The digest does stay consistent with the equally-orphaned hash index, so end-to-end diff impact is bounded, but consider adding the same last-occurrence dedup pass PutGrantRecords uses (or documenting that callers must pre-dedup).

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 (confidence: medium): The grant digest index defaults to on, so every connector that upgrades the SDK starts writing an extra per-grant index row (+8-byte value) and building per-entitlement digests at seal — a default-behavior change that grows every .c1z and adds seal-time work, even for files that are never diffed. The repo's compatibility criteria prefer new behavior to default to the old behavior and be opt-in where possible. This appears deliberate (the diff feature needs the index present by default), so non-blocking — but pkg/sdk/version.go is unchanged (v0.15.5); consider a minor bump / migration note so downstreams notice the new default write/size cost.

return nil, err
}

if !okA || !okB {

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 (confidence: medium): When a digest root is absent on either side this falls back to computeBucketDigest, which folds the by_entitlement_principal_hash index. For a file with no hash index — a read-only open of a pre-migration file (applyIndexMigrations is skipped when readOnly), or one written with WithGrantDigestIndex(false) — that fold yields count 0, so a future diff consumer comparing against a populated file would silently read it as "entitlement has zero grants." options.go already documents that the diff driver must detect index absence (via the manifest) and rebuild-or-error; that guard isn't in this PR. Worth ensuring the consumer enforces it before this path ships, so an absent index can't be mistaken for an empty entitlement.

@github-actions

Copy link
Copy Markdown
Contributor

@-

@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 commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

General PR Review: Grant Hash Digest Index + leaf only tree

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

Review Summary

Full-diff security and correctness pass over the whole PR (Pebble engine grant hash index + XOR set digests, plus the connectorstore reader capability and compactor/cleanup wiring). This is an additive, backward-compatible change: the new WithGrantDigestIndex option defaults on, the digest keyspace (typeDigest 0x0A) and index (idxGrantByEntitlementPrincipalHash 0x08) are new — no field/type reuse — and files that predate the digest simply have no root, which readers treat as "missing to recalculate" rather than a wrong answer. Failure handling (drop-all on partial seal), invalidation on out-of-sync mutation (DeleteGrantRecord drops the partition's digest + hash-index entry), atomic swap via IngestAndExcise, and compaction (MergeInto drops both keyspaces up front) are all handled and covered by tests. The renamed raw-scan helpers (scanEntitlementRefRawBytes / scanPrincipalRefRawBytes) have no stale callers, and the byte-view hash variants are pinned to independent reference implementations in grant_digest_hash_test.go, protecting the on-disk ABI. No security or correctness issues found.

Note (by design, not a finding): sqlite-to-pebble conversions (BulkSyncImport/ToPebble) and compacted/folded outputs do not carry digests until a subsequent normal sync seal — the reader fallback keeps this safe, and MergeInto drops them explicitly.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/dotc1z/engine/pebble/grants.go:580-585 — the hash-index delete in deleteGrantIndexesScratch allocates a fresh key instead of reusing the scratch buffer like every other delete in that variant; appendGrantByEntPrincHashIndexKeyBytes already exists as the append form. Minor perf nit.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/dotc1z/engine/pebble/grants.go`:
- Around line 580-585: In deleteGrantIndexesScratch, the by_entitlement_principal_hash
  delete calls encodeGrantByEntPrincHashIndexKey, which allocates a new byte slice per
  call, breaking the buffer-reuse contract that the rest of this "Scratch" variant
  follows (every other delete uses scratch = append(scratch[:0], ...)). Rewrite it to
  build the key into the reused scratch buffer using the existing byte-view helper
  appendGrantByEntPrincHashIndexKeyBytes (converting entID/principalRT/principalID/
  externalID/bucketHash to []byte views), then batch.Delete(scratch, nil), and return
  the grown scratch. This is a performance-only change; behavior must stay identical.

@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/options.go Outdated
// Set false on files that will never be grant-diffed (e.g. local CLI
// syncs, connector development) to skip the per-grant index write and
// the seal-time digest fold. NOTE: a file written with this off has no
// hash index, so a future cross-file diff MUST detect its absence

@btipling btipling Jun 22, 2026

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.

grantDigestIndex is in memory only, so how does a "future cross-file diff" detect its absence? What is written to the disk that can be detected?

// index on an existing file requires a fresh sync or explicit rebuild,
// not an Open-time backfill.
func (e *Engine) backfillGrantHashIndexAndDigests(ctx context.Context) error {
if !e.opts.grantDigestIndex {

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.

when indexMigrations are applied and they return nil we still record the index version with writeAppliedIndexVersion in applyIndexMigrations, so in the case here where we return nil when grantDigestInex is nil, I'm not sure if it is possible to back fill later if grantDigestIndex is enabled

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

Before merge:

  1. We should profile this. There is possibly a way to do the work in a single pass or way more efficiently.
  2. We need to run the benchmarks for expansion and compaction to see what it does to perf.
  3. We need to run the benchmark for the whale C1z to see what happens to perf, there.

Then, we can decide if this tradeoff is a net savings.

Name: "grant_by_entitlement_principal_hash",
Version: 2,
Apply: func(ctx context.Context, e *Engine) error {
return e.backfillGrantHashIndexAndDigests(ctx)

@kans kans Jun 26, 2026

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.

Historically, grant migrations cause incidents. For pebble, do we record if we have run a migration? If not, we probably should. That info could potentally go in the c1z header, too. A differetn strategy would be to not run the migration at all, and opt into the new behavior on the c1 side if the c1z supports it.

Comment thread pkg/dotc1z/engine/pebble/bulk_import.go Outdated
// back to an on-demand index fold, and the on-Open migration
// backfills the stored digests when the file is next opened
// writable. Skipped entirely when the digest index is disabled.
if s.b.e.opts.grantDigestIndex {

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.

Stepping through 4 objects violates Code Complete.

// consumer onto the on-demand index fold, and the on-Open migration
// backfills it next time the file opens writable. Skipped when the
// digest index is disabled — there is no hash index to fold over.
if a.engine.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.

This will load every single grant after we sync, but we already walked through all of them in the process of syncing. Can we do this work incrementally?

Comment thread pkg/dotc1z/engine/pebble/grants.go Outdated
// not extract (sources) — unmarshal only on this
// non-fresh path.
old := &v3.GrantRecord{}
if err := unmarshalRecord(oldVal, old); err != nil {

@kans kans Jun 26, 2026

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.

This should be a shallow unmarshal for the fields we need (probably just the digest), so that we don't allocate a ton of pointers on the heap on the hot path. Can we get the other fields out of new record or whatever index is handy?

Comment thread pkg/dotc1z/engine/pebble/grants.go Outdated
closer.Close()
return fmt.Errorf("PutGrantRecords: unmarshal old %q: %w", r.GetExternalId(), err)
}
if err := mm.removeGrant(old); 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.

mm or removeGrant should have a more descriptive name.

Comment thread pkg/dotc1z/engine/pebble/digest.go Outdated
return nil
}

func (m *digestMutator) applyPartition(batch *pebble.Batch, mp *mutatorPartition) error {

@kans kans Jun 26, 2026

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.

This function looks like it is doing too much work.

Comment thread pkg/dotc1z/engine/pebble/digest.go Outdated
curCount int64
curDigest [hashLen]byte
)
val, closer, err := m.e.db.Get(n.key)

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.

This is doing a random get in a loop per grant. This is probably bad.

Comment thread pkg/dotc1z/engine/pebble/if_newer.go Outdated
// which folds fields the raw index scan does not extract
// (sources).
old := &v3.GrantRecord{}
if err := unmarshalRecord(oldVal, old); 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.

Same story here, we try very hard in this function to unmarshal any protos.

Comment thread pkg/dotc1z/engine/pebble/if_newer.go Outdated
// IfNewer is by definition a post-seal mutation path, so the
// grant digests (cloned along with the sync's keyspace) are
// kept in step incrementally.
mm := newGrantDigestMutator(e)

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: The digest mutator is created here unconditionally, unlike PutGrantRecords/PutExpandedGrantRecords, which gate it on e.opts.grantDigestIndex. With the index disabled, writeGrantIndexes (line 86) skips writing the hash-index entry, but mm still runs. For a file synced with the index on and later reopened with WithGrantDigestIndex(false), the stored roots are present, so mm updates the digests while the hash index goes unwritten — leaving the digest diverged from its index until the next rebuild. Consider gating mm (and the removeGrantRaw/addGrant/apply calls) on e.opts.grantDigestIndex to match the other write paths. Narrow edge case (documented as requiring a fresh sync), hence non-blocking.

@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/adapter.go Outdated
// buildPartitionDigest can read the count from the stored root and
// skip its countKeysInRange pass. Non-fatal for the same reason as
// the digest build itself.
if err := a.engine.FlushFreshRoots(); 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 (medium confidence): FlushFreshRoots is documented "Must be called within writeMu", but EndSync calls it holding only a.mu, not the engine's writeMu. Unlike PersistFreshRoots (which wraps persistFreshRoots in withWrite) and unlike Close (which calls writeWG.Wait() first), this path mutates the freshRoots map with no serialization against in-flight PutGrantRecords/PutExpandedGrantRecords writers (which mutate the same map under writeMu). If any grant write is concurrent with EndSync, this races the map. Consider having FlushFreshRoots wrap the flush in withWrite like PersistFreshRoots, or document/enforce the "no writes in flight at EndSync" sequencing.

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

Comment on lines +580 to +585
if entID != "" && principalRT != "" && principalID != "" {
bh := principalBucketHash(principalRT, principalID)
if err := batch.Delete(encodeGrantByEntPrincHashIndexKey(entID, bh, principalRT, principalID, externalID), nil); err != nil {
return scratch, err
}
}

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: Every other delete in this ...Scratch variant reuses the scratch buffer (append(scratch[:0], ...)), but this hash-index delete allocates a fresh key via encodeGrantByEntPrincHashIndexKey. Since appendGrantByEntPrincHashIndexKeyBytes already exists as the byte-view/append form, you could keep the buffer-reuse contract here to avoid a per-mutation allocation. Minor perf nit, not correctness. (confidence: high this diverges from the pattern; low that it matters in practice)

@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

Copy link
Copy Markdown
Contributor Author

Benchmarks run with and without the Feature, adds ~1 second at 10M grants (from 7s to 8s)

goos: darwin
goarch: arm64
pkg: github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble
cpu: Apple M4
BenchmarkRegisteredPebbleWritePack/grants=10000000-10                  1        33246971041 ns/op             8262 ms/EndSync        19694 ms/PutGrants 17276227616 B/op        70217760 allocs/op
BenchmarkRegisteredPebbleWritePack/grants=10000000-10                  1        32720522541 ns/op             8237 ms/EndSync        19435 ms/PutGrants 17157200984 B/op        70210882 allocs/op
BenchmarkRegisteredPebbleWritePack/grants=10000000-10                  1        34873743666 ns/op             8348 ms/EndSync        21436 ms/PutGrants 17137948992 B/op        70210371 allocs/op
BenchmarkRegisteredPebbleWritePack/grants=10000000-10                  1        35549664292 ns/op             8205 ms/EndSync        22288 ms/PutGrants 17154485096 B/op        70210820 allocs/op
BenchmarkRegisteredPebbleWritePack/grants=10000000-10                  1        34943085750 ns/op             8195 ms/EndSync        21765 ms/PutGrants 17244106352 B/op        70213758 allocs/op
BenchmarkRegisteredPebbleWritePack/grants=10000000-10                  1        34301921666 ns/op             8199 ms/EndSync        20812 ms/PutGrants 16943747552 B/op        70205628 allocs/op
PASS
ok      github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble      233.487s
goos: darwin
goarch: arm64
pkg: github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble
cpu: Apple M4
BenchmarkRegisteredPebbleWritePack_NoDigestIndex/grants=10000000-10                    1        33889880458 ns/op             7135 ms/EndSync        21917 ms/PutGrants 16823701896 B/op        70195957 allocs/op
BenchmarkRegisteredPebbleWritePack_NoDigestIndex/grants=10000000-10                    1        33256215584 ns/op             7072 ms/EndSync        21228 ms/PutGrants 16844674776 B/op        70191977 allocs/op
BenchmarkRegisteredPebbleWritePack_NoDigestIndex/grants=10000000-10                    1        34725127125 ns/op             7107 ms/EndSync        22654 ms/PutGrants 16831746496 B/op        70192288 allocs/op
BenchmarkRegisteredPebbleWritePack_NoDigestIndex/grants=10000000-10                    1        34117817333 ns/op             7078 ms/EndSync        22076 ms/PutGrants 16914976696 B/op        70193591 allocs/op
BenchmarkRegisteredPebbleWritePack_NoDigestIndex/grants=10000000-10                    1        33318628875 ns/op             7190 ms/EndSync        21272 ms/PutGrants 16770682784 B/op        70190711 allocs/op
BenchmarkRegisteredPebbleWritePack_NoDigestIndex/grants=10000000-10                    1        33588608750 ns/op             7199 ms/EndSync        21690 ms/PutGrants 16993501960 B/op        70196186 allocs/op
PASS
ok      github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble      228.640s

@kans

kans commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

I just merged my other branch. Apologies for the conflicts. Fable and I talked about this branch a bit, and this is what it suggests another agent do:

Task: Port the grant-digest system from mjp/grant-digest onto main (post-injective-keys)

Situation

Branch mjp/grant-digest (6 commits, ~3,000 added lines) implements per-entitlement
grant digests for the Pebble c1z engine: bucketed XOR set-digests that answer "does
this entitlement hold exactly the same grants as in some other sync/file?" with a
single key read, and when the answer is no, localize the difference to principal-hash
buckets so a diff driver loads only the differing grants.

The branch was cut from v0.15.6. Since then, main merged the large "injective keys"
branch (PR #993, commit 393077d), which rewrote the substrate the digest branch sits
on. A naive merge conflicts in 6 files, and the textual conflicts understate the
breakage:

  1. Grant identity changed. The branch keys and hashes grants by external_id.
    On main, grant primary keys are the injective identity tuple
    v3|typeGrant|0x00|ent_rt|ent_rid|ent_flag|ent_tail|p_rt|p_id (see
    pkg/dotc1z/engine/pebble/keys.go, identity.go).
  2. The write paths the branch hooks were rewritten. PutGrantRecords,
    BulkGrantShard.AddGrants, UnsafePutUniqueGrantRecordsgrants.go changed
    ~980 lines on main.
  3. EndSync already has the pass we need. Main's Adapter.EndSync runs one sealed
    O(grants) scan — BuildDeferredGrantIndexes in deferred_index.go — that builds
    the by_principal index via spill sorters → SST → IngestAndExcise, and fuses sync
    stats. The branch's BuildAllGrantDigests is a second full pass; we don't want it.

Decision (already made — do not relitigate)

Keep the hash index (idxGrantByEntitlementPrincipalHash, 0x08), but build it ONLY
in the deferred EndSync pass. No inline maintenance anywhere.

Rationale: primary grant keys sort by meaningful identity, not hash, so digest buckets
are NOT contiguous in the primary keyspace. The index is what makes "scan one dirty
bucket" O(bucket) instead of O(entitlement) — essential for the motivating case
(whale entitlement, tiny diff). What we drop from the branch is the inline write-path
emission, not the index itself.

Index redesign (verified against branch encoder + main's machinery)

New key layout — same shape as the branch's encodeGrantByEntPrincHashIndexKey, with
the entitlement identity substituted for entitlement_id and external_id dropped:

key   = v3 | typeIndex | idxGrantByEntitlementPrincipalHash | 0x00 |
        ent_rt | 0x00 | ent_rid | 0x00 | ent_flag | 0x00 | ent_tail | 0x00 |
        <raw bucket hash: digestBucketHashLen = 2 bytes> |
        p_rt | 0x00 | p_id
value = 8-byte grant content hash (xxHash64)

Verified properties:

  • The digestIndexSpec shape contract (partition prefix | raw fixed-width hash |
    tail) is preserved, so bucketBounds's byte arithmetic works unchanged. Tuple
    escaping (no bare 0x00 in segments) + the load-bearing trailing separator keep
    partition prefixes prefix-free, so whole-key byte order = (entitlement, hash,
    principal). Keep the branch's positional-decoder discipline: raw hash bytes can be
    0x00, so generic tuple walkers must never cross the hash region (see the branch's
    decodeEntPrincHashTail and its doc comment).
  • Uniqueness: (ent identity, principal identity) IS the grant primary identity, so
    one index row per grant by construction — no duplicate-key failures in the merge
    (mergeSortedSpillChunksToSST treats duplicates as corruption).
  • The tail carries the full grant identity, so cross-file bucket comparison is a pure
    merge scan of two index ranges with ZERO point Gets (an improvement over the
    branch, which needed a Get per index entry). The primary key for a differing grant
    is reconstructable from the index key by removing the hash region — one Get per
    actually-changed grant, done by byte splice, not decode+re-encode.
  • Keep the branch's 2-byte stored bucket hash (commit 19a1598) —
    digestBucketHashLen = 2 covers digestMaxWidthBits = 16.

Build: fuse into the deferred pass

In buildDeferredGrantIndexesLocked (deferred_index.go):

  1. Add a second spill-sorter family for the hash index, alongside the existing
    by_principal sorter. The machinery already supports valued entries
    (spillSorter.add(key, val); kvView.valEnd; the chunk reader and
    bulkSSTWriter.add round-trip values) — the by_principal family just happens to
    use nil values.
  2. Per scanned grant, compute:
    • the index key by SPLICING segments out of the primary key (same trick as
      appendGrantByPrincipalKeyFromPrimary — segments are escaped, raw byte splice
      is byte-identical to decode+re-encode);
    • the bucket hash from the spliced principal segments;
    • the content hash = xxHash64(primary-key tail ‖ sorted source-entitlement ids).
      Source ids come from a raw protobuf value scan (sources are field 9; follow the
      raw_records.go helper pattern, e.g. scanGrantEntitlementResourceTypeRaw).
      No proto unmarshal anywhere on this path.
  3. Ingest the merged index SST with its own IngestAndExcise call, excise span =
    the branch's GrantByEntPrincHashLowerBound/UpperBound.
  4. Fold the digests during the sorter's final merge. Entries emit in
    (entitlement identity, bucket hash) byte order. Accumulate (xor, count) at max
    width — 2^16-bucket scratch, ~1.5MB — and fold down to chooseDigestWidth's pick
    at each entitlement boundary (the total count isn't known until then; XOR
    split-independence makes the fold-down exact).
  5. Digest nodes do NOT ride the index SST. typeDigest (0x0A) is separated from
    the index keyspace by typeCounter (0x08) and typeSession (0x09); a single excise
    span covering both would wipe counters and sessions. Write digest nodes via
    normal sealed-path batch Sets during the merge (they're tiny: one root + sparse
    leaves per entitlement), or a second SST with its own excise span limited to the
    digest keyspace.
  6. Zero-grant entitlements must still get a {count: 0} root node (the branch's
    "empty vs. never built" distinction). After the grant work, do one pass over the
    entitlement primary keyspace and write roots for entitlements not seen.
    Entitlements are by far the smallest record type — don't bother fusing this.

What ports from the branch vs. what to write fresh

Ports nearly unchanged:

  • digest.go + digest_test.go (~900 + ~1,200 lines): the generic digest core —
    node framing, chooseDigestWidth, dirtyPartitionBuckets cross-width merge
    comparison, digestMutator. One generalization needed:
    digestIndexSpec.partitionPrefix and encodeDigestPartitionPrefix take the
    partition as a string (was entitlement_id). Re-key digest partitions by the
    entitlement's encoded primary-key tail bytes (spliced, not re-encoded).
  • The typeDigest (0x0A) keyspace + digest node key encoders from the branch's
    keys.go; DigestLowerBound/UpperBound; the synccompactor bucket_plans.go
    additions (digest keyspace AND the hash-index keyspace must be in the
    cleanup/clone/compaction plans).
  • pkg/connectorstore/connectorstore.go: GrantDigest, GrantDigestNode,
    GrantDigestBucket, EntitlementGrantDigestReader. Note its methods take
    entitlementID string — align with how main's reader addresses entitlements
    under the identity scheme.
  • c1file.go option + engine_registry.go plumbing (WithGrantDigestIndex).
  • Reader plumbing from the branch's adapter_reader.go additions; the bucket grant
    loader now scans the index range and reconstructs primary keys by splice (no
    per-entry point Get except for materializing changed grants).

Write fresh:

  • grant_digest.go: content hash, bucket hash, index-key splice helpers (above).
  • The deferred-pass integration (above).

Post-seal mutations: v1 = invalidate

Do NOT port the incremental digestMutator wiring into the rewritten Put/Delete
paths. On any post-seal grant mutation, DeleteRange the affected entitlement's digest
partition (encodeDigestPartitionPrefix — the branch already has this
inconsistency-drop path) AND its hash-index range (the index is stale too). Readers
treat a missing digest root as "fall back to on-demand fold over the primary
keyspace". Leave the mutator code in digest.go for a follow-up.

Explicitly drop from the branch

  • All inline write-path emission of the index: the grants.go, bulk_import.go
    (BulkGrantShard.AddGrants), and if_newer.go hooks.
  • BuildAllGrantDigests (the second EndSync pass) and its Adapter.EndSync hook.
  • The branch's index_migrations.go backfill.
  • external_id anywhere in the index key or content hash.

ABI / migration notes

  • Main's index-migration registry is currently EMPTY ("none yet, because we have no
    existing data"), so the content-hash definition can be set freely now. Once
    shipped it is on-disk ABI: document the field set ("the same grant" = identity
    tuple + sorted source ids; excludes sync-relative/transient state — discovered_at,
    needs_expansion, expansion, annotations) with a comment block like the branch's,
    and note that changing it requires a migration bump.
  • Digest node framing, digestBucketHashLen, digestLeafPrefixLen, the XOR
    combiner, and leaf left-alignment are ABI — keep as the branch defines them.
  • The digest/index build must be non-fatal at EndSync (warn + continue), matching
    the stats sidecar's treatment of seal-time extras.

Verification

  • Port digest_test.go; keep the cross-width comparison and split-independence pins;
    adapt grant-instantiation tests to identity keys.
  • Pin test: content hash / index key computed from raw key+value bytes equals the
    same computed from a decoded record (mirrors
    TestAppendGrantByPrincipalKeyFromPrimary).
  • End-to-end: two syncs with a known grant delta → digest roots differ, dirty buckets
    localize the delta, bucket scan yields exactly the changed grants; identical
    syncs → roots equal, dirtyPartitionBuckets returns nil.
  • Post-seal mutation → digest partition gone → reader falls back to on-demand fold
    and still answers correctly.
  • Whale-shaped bench: deferred-pass runtime with vs. without the second sorter
    family. It roughly doubles sorter volume, and the 128MiB
    deferredIndexSpillChunkBytes sizing rationale assumed ONE family — revisit the
    memory-budget comment (two sorters share the sort semaphore and freelist sizing).
  • make lint and go test ./pkg/dotc1z/... clean.

Key references

  • Branch mjp/grant-digest: digest.go, grant_digest.go, digest_test.go,
    keys.go additions (incl. encodeGrantByEntPrincHashIndexKey and its raw-hash
    doc comment), connectorstore.go API.
  • Main: pkg/dotc1z/engine/pebble/deferred_index.go (the pass to fuse into;
    appendGrantByPrincipalKeyFromPrimary for the splice pattern; the
    finalize → mergeSortedSpillChunksToSSTIngestAndExcise sequence),
    bulk_import.go (spillSorter, valued entries, merge), keys.go (identity
    layout), raw_records.go (raw value scans), sync_stats_sidecar.go (precedent
    for fused, non-fatal seal-time work).

@kans

kans commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

@mj-palanker Can we close this PR?

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.

3 participants