From cacc2269b519a98bb58c17ec88b024badbeb5f56 Mon Sep 17 00:00:00 2001 From: MJ Palanker Date: Tue, 7 Jul 2026 19:29:13 -0700 Subject: [PATCH 01/12] port grant digests post key change --- pkg/connectorstore/connectorstore.go | 95 ++ pkg/dotc1z/c1file.go | 21 + pkg/dotc1z/engine/pebble/adapter.go | 18 +- pkg/dotc1z/engine/pebble/adapter_reader.go | 129 ++ pkg/dotc1z/engine/pebble/bulk_import.go | 5 +- pkg/dotc1z/engine/pebble/cleanup.go | 5 + pkg/dotc1z/engine/pebble/deferred_index.go | 129 +- pkg/dotc1z/engine/pebble/digest.go | 729 ++++++++ pkg/dotc1z/engine/pebble/digest_test.go | 1477 +++++++++++++++++ pkg/dotc1z/engine/pebble/engine.go | 18 + pkg/dotc1z/engine/pebble/grant_digest.go | 418 +++++ .../engine/pebble/grant_digest_build.go | 622 +++++++ .../engine/pebble/grant_digest_hash_test.go | 144 ++ .../pebble/grant_write_scale_bench_test.go | 14 +- pkg/dotc1z/engine/pebble/grants.go | 25 +- pkg/dotc1z/engine/pebble/if_newer.go | 6 + pkg/dotc1z/engine/pebble/index_migrations.go | 14 +- pkg/dotc1z/engine/pebble/keys.go | 129 ++ pkg/dotc1z/engine/pebble/options.go | 23 + .../engine/pebble/production_bench_test.go | 27 +- pkg/dotc1z/engine/pebble/raw_records.go | 62 + pkg/dotc1z/engine_registry.go | 36 +- pkg/dotc1z/pebble_store.go | 6 +- pkg/sync/expand/expand_benchmark_test.go | 16 +- pkg/synccompactor/pebble/bucket_plans.go | 14 + pkg/synccompactor/pebble/merge.go | 10 + 26 files changed, 4125 insertions(+), 67 deletions(-) create mode 100644 pkg/dotc1z/engine/pebble/digest.go create mode 100644 pkg/dotc1z/engine/pebble/digest_test.go create mode 100644 pkg/dotc1z/engine/pebble/grant_digest.go create mode 100644 pkg/dotc1z/engine/pebble/grant_digest_build.go create mode 100644 pkg/dotc1z/engine/pebble/grant_digest_hash_test.go diff --git a/pkg/connectorstore/connectorstore.go b/pkg/connectorstore/connectorstore.go index fa1f03feb..5ac681f74 100644 --- a/pkg/connectorstore/connectorstore.go +++ b/pkg/connectorstore/connectorstore.go @@ -105,6 +105,101 @@ type LatestFinishedSyncIDFetcher interface { LatestFinishedSyncID(ctx context.Context, syncType SyncType) (string, error) } +// GrantDigest is the root of an entitlement's grant digest: a +// content-defined hash over all of the entitlement's grants plus the +// grant count. Two entitlements (in the same or different c1z files) +// with the same Count and Hash hold the same set of grants. +type GrantDigest struct { + // Hash is the digest of every grant under the entitlement. Opaque + // bytes — compare for equality, don't interpret. Empty when Count + // is 0. + Hash []byte + // Count is the number of grants under the entitlement. + Count int64 + // Level is the digest's native (stored) rollup level: the leaf + // granularity it was built at, expressed as a count of + // principal-hash bits, so the leaf level holds up to 2^Level + // buckets. Level 0 means only the root is stored (small or empty + // entitlements). It is the finest level GetEntitlementGrantDigestNodes + // can serve cheaply (by folding the stored leaves); a finer level + // would require a full grant-index scan. + Level int +} + +// GrantDigestNode is one bucket of an entitlement's grant digest at a +// requested rollup level: a content hash + grant count over the grants +// whose principal-hash falls in that bucket. +type GrantDigestNode struct { + // Index is the bucket index, in [0, 2^level). For level >= 1 nodes + // are returned sparsely and in ascending Index — only non-empty + // buckets appear, so a returned leaf node always has Count > 0. (At + // level 0 the single root node is always returned, even with + // Count 0.) + Index uint32 + // Hash is the digest over the bucket's grants. + Hash []byte + // Count is the number of grants in the bucket. + Count int64 +} + +// GrantDigestBucket addresses one rollup bucket of an entitlement's +// grant digest: the grants whose principal-hash falls under Index at the +// given Level (2^Level buckets; Level 0 = the whole entitlement, Index +// ignored). Index is exactly a GrantDigestNode.Index returned by +// GetEntitlementGrantDigestNodes at the same Level — list nodes to find +// the buckets, then scan the ones you want. +type GrantDigestBucket struct { + // Level is the bucket width in principal-hash bits (2^Level buckets); + // 0 = the whole entitlement. + Level int + // Index is the bucket index in [0, 2^Level); ignored when Level == 0. + Index uint32 +} + +// EntitlementGrantDigestReader is an optional Reader capability that +// returns the precomputed grant digest for an entitlement. It is +// engine-specific: only the Pebble engine maintains the digest, so +// callers must type-assert and fall back when the assertion fails or +// found is false. +// +// found is false when the reader has no digest built for the +// entitlement (it was never synced, or the file predates / opted out of +// the digest index — see WithGrantDigestIndex). A nil error with +// found=false means "no digest", not "error". +type EntitlementGrantDigestReader interface { + // GetEntitlementGrantDigest returns the entitlement's digest root — + // the whole-entitlement hash + grant count, plus the digest's native + // rollup Level. One key read. + GetEntitlementGrantDigest(ctx context.Context, entitlementID string) (digest GrantDigest, found bool, err error) + + // GetEntitlementGrantDigestNodes lists the digest's rollup nodes for + // entitlementID at `level`: 2^level principal-hash buckets, with + // level 0 returning a single node (the root) covering the whole + // entitlement. + // + // For 0 <= level <= the native Level (GrantDigest.Level) this folds + // the stored leaves — one contiguous scan of the digest keyspace, no + // grant-index scan. For a finer level it falls back to scanning the + // grant index directly (O(grants)) — slower, but it never errors on a + // "too deep" level. The principal-hash carries a bounded number of + // bits, so a level beyond that resolution is served at the maximum + // (you may get fewer than 2^level distinct buckets). found is false + // when no digest exists. + GetEntitlementGrantDigestNodes(ctx context.Context, entitlementID string, level int) (nodes []GrantDigestNode, found bool, err error) + + // ScanEntitlementGrantBucket yields every grant in one digest bucket + // of entitlementID (see GrantDigestBucket) as a v2.Grant, stopping + // early if yield returns false. Bucket Level 0 scans the whole + // entitlement; a Level finer than the bucket-hash resolution is + // clamped (matching GetEntitlementGrantDigestNodes). It reads the + // grant hash index, which exists only on files whose digest was + // built (they are derived together at seal): callers must check + // GetEntitlementGrantDigest first and treat found=false as "scan + // unavailable — read the grants directly", not as "no grants". It + // yields nothing when there is no active sync or no matching grants. + ScanEntitlementGrantBucket(ctx context.Context, entitlementID string, bucket GrantDigestBucket, yield func(grant *v2.Grant) bool) error +} + // DBSizeProvider is an optional capability for a store that can report its // current uncompressed working-set size (e.g. dotc1z.C1File stat'ing its // sqlite file). Consumed by the syncer's ProgressLog to include diff --git a/pkg/dotc1z/c1file.go b/pkg/dotc1z/c1file.go index 2845ae7f3..a9feeec68 100644 --- a/pkg/dotc1z/c1file.go +++ b/pkg/dotc1z/c1file.go @@ -340,6 +340,12 @@ type c1zOptions struct { v2GrantsWriter bool bulkLoad bool + // disableGrantDigestIndex turns off the Pebble engine's seal-time + // build of the by_entitlement_principal_hash index + grant digests. + // Inverted so the zero value keeps the build on (current behavior). + // See WithGrantDigestIndex. + disableGrantDigestIndex bool + // engine is the storage engine to use for newly created files. // Reads dispatch on magic byte regardless. Default EngineSQLite. engine c1zstore.Engine @@ -445,6 +451,21 @@ func WithV2GrantsWriter(enabled bool) C1ZOption { } } +// WithGrantDigestIndex toggles the Pebble engine's seal-time build of +// the by_entitlement_principal_hash index and per-entitlement grant +// digests (the substrate for cross-file grant diffing). Default true. +// +// Pass false for files that will never be grant-diffed (local CLI syncs, +// connector development) to skip the seal-time derivation pass. Safe to +// toggle: a file sealed with this off stores no digest roots, which +// readers treat as "missing — recalculate", never as "no grants". No +// effect on the SQLite engine. +func WithGrantDigestIndex(enabled bool) C1ZOption { + return func(o *c1zOptions) { + o.disableGrantDigestIndex = !enabled + } +} + // WithBulkLoad enables deferred secondary-index creation for a // freshly-created destination c1z. See WithC1FBulkLoad for the full contract: // it is opt-in and never the default; indexes are deferred only on EMPTY diff --git a/pkg/dotc1z/engine/pebble/adapter.go b/pkg/dotc1z/engine/pebble/adapter.go index c18ef1b54..512de090b 100644 --- a/pkg/dotc1z/engine/pebble/adapter.go +++ b/pkg/dotc1z/engine/pebble/adapter.go @@ -75,9 +75,10 @@ func NewAdapter(e *Engine) *Adapter { // Compile-time checks for the full Writer interface and the optional // connectorstore capabilities that SQLite's *C1File also exposes. var ( - _ connectorstore.Writer = (*Adapter)(nil) - _ connectorstore.LatestFinishedSyncIDFetcher = (*Adapter)(nil) - _ connectorstore.DBSizeProvider = (*Adapter)(nil) + _ connectorstore.Writer = (*Adapter)(nil) + _ connectorstore.LatestFinishedSyncIDFetcher = (*Adapter)(nil) + _ connectorstore.DBSizeProvider = (*Adapter)(nil) + _ connectorstore.EntitlementGrantDigestReader = (*Adapter)(nil) ) // === sync lifecycle === @@ -344,6 +345,17 @@ func (e *Engine) endSyncFinalize(ctx context.Context, existing *v3.SyncRunRecord if err := e.clearDeferredIdxPending(); err != nil { return fmt.Errorf("EndSync: clear deferred index marker: %w", err) } + } else if e.GrantDigestIndexEnabled() { + // The deferred pass didn't run (no grant went through the + // deferred index paths — inline-index writes like PutGrantRecords + // never arm the marker), but digests are built at every seal: + // run the standalone build, which shares the fused build's + // merge/fold/ingest machinery over its own grant scan. Build + // failures are downgraded to a loud digest-state drop inside; + // an error surfacing here (cancellation, drop failure) is fatal. + if err := e.BuildGrantDigests(ctx); err != nil { + return fmt.Errorf("EndSync: build grant digests: %w", err) + } } updated := v3.SyncRunRecord_builder{ SyncId: existing.GetSyncId(), diff --git a/pkg/dotc1z/engine/pebble/adapter_reader.go b/pkg/dotc1z/engine/pebble/adapter_reader.go index 624a9ecf0..7239867aa 100644 --- a/pkg/dotc1z/engine/pebble/adapter_reader.go +++ b/pkg/dotc1z/engine/pebble/adapter_reader.go @@ -647,3 +647,132 @@ func (a *Adapter) InitCurrentSync(ctx context.Context) error { } return nil } + +// resolveDigestEntitlementIdentity maps a public entitlement id string +// to the structural identity the digest keyspace is addressed by. +// Resolution goes through the grant-scan resolver (entitlement records +// first, grant-keyspace probes for ids the store never saw a record +// for — grants can carry such ids, and their digests exist too). ok is +// false when the id matches nothing; an AMBIGUOUS id stays an error — +// a lossy string must never guess which digest to answer with. +func (a *Adapter) resolveDigestEntitlementIdentity(ctx context.Context, entitlementID string) (entitlementIdentity, bool, error) { + id, err := a.engine.resolveGrantScanEntitlementIdentity(ctx, entitlementID) + if err != nil { + if errors.Is(err, pebble.ErrNotFound) { + return entitlementIdentity{}, false, nil + } + return entitlementIdentity{}, false, err + } + return id, true, nil +} + +// GetEntitlementGrantDigest implements connectorstore.EntitlementGrantDigestReader. +// It returns the stored grant-digest root (content hash + grant count) +// for entitlementID under the reader's active sync. found is false when +// no digest exists — either no active sync, an unknown entitlement, or +// no digest was built for it (e.g. WithGrantDigestIndex(false), a file +// that predates the digest, or a post-seal mutation invalidated it). A +// nil error with found=false means "no digest". +func (a *Adapter) GetEntitlementGrantDigest(ctx context.Context, entitlementID string) (connectorstore.GrantDigest, bool, error) { + syncID, err := a.resolveActiveSyncForReader(ctx, nil) + if err != nil { + return connectorstore.GrantDigest{}, false, err + } + if syncID == "" { + return connectorstore.GrantDigest{}, false, nil + } + id, ok, err := a.resolveDigestEntitlementIdentity(ctx, entitlementID) + if err != nil || !ok { + return connectorstore.GrantDigest{}, false, err + } + root, ok, err := a.engine.GetEntitlementDigestRoot(ctx, id) + if err != nil || !ok { + return connectorstore.GrantDigest{}, false, err + } + return connectorstore.GrantDigest{Hash: root.Hash, Count: root.Count, Level: root.Bits}, true, nil +} + +// GetEntitlementGrantDigestNodes implements +// connectorstore.EntitlementGrantDigestReader. It lists the entitlement's +// grant-digest rollup nodes at the requested level (2^level buckets; +// level 0 = the root). For 0 <= level <= the digest's native level it +// folds the stored leaves — one scan of the digest keyspace. For a finer +// level it scans the grant index directly (O(grants)) instead of +// erroring; the level is clamped to the bucket-hash resolution +// (digestMaxWidthBits). +func (a *Adapter) GetEntitlementGrantDigestNodes(ctx context.Context, entitlementID string, level int) ([]connectorstore.GrantDigestNode, bool, error) { + if level < 0 { + return nil, false, fmt.Errorf("pebble: negative grant-digest level %d", level) + } + syncID, err := a.resolveActiveSyncForReader(ctx, nil) + if err != nil { + return nil, false, err + } + if syncID == "" { + return nil, false, nil + } + id, ok, err := a.resolveDigestEntitlementIdentity(ctx, entitlementID) + if err != nil || !ok { + return nil, false, err + } + root, ok, err := a.engine.GetEntitlementDigestRoot(ctx, id) + if err != nil || !ok { + return nil, false, err + } + // Level 0 is the whole-entitlement root — return it directly (covers + // the root-only digest, which has no stored leaves to fold). + if level == 0 { + return []connectorstore.GrantDigestNode{{Index: 0, Hash: root.Hash, Count: root.Count}}, true, nil + } + // The bucket hash carries at most digestMaxWidthBits of resolution; + // a finer level can't address more buckets, so clamp. + bits := min(level, digestMaxWidthBits) + // At or below the stored width, fold the digest leaves (cheap). Finer + // than what we stored, scan the grant index to compute the rollup. + partition := digestPartitionForEntitlement(id) + var folded []foldedBucket + if bits <= root.Bits { + folded, err = a.engine.foldedLeafBuckets(ctx, grantDigestSpec, partition, bits) + } else { + folded, err = a.engine.computeBucketsAtWidth(ctx, grantDigestSpec, partition, bits) + } + if err != nil { + return nil, false, err + } + nodes := make([]connectorstore.GrantDigestNode, len(folded)) + for i := range folded { + nodes[i] = connectorstore.GrantDigestNode{ + Index: folded[i].idx, + Hash: append([]byte(nil), folded[i].digest[:]...), + Count: folded[i].count, + } + } + return nodes, true, nil +} + +// ScanEntitlementGrantBucket implements +// connectorstore.EntitlementGrantDigestReader. It yields every grant in +// the given digest bucket of entitlementID, translated to v2.Grant. +// Bucket Level 0 scans the whole entitlement; a finer Level is clamped +// to the bucket-hash resolution. Yields nothing when there is no active +// sync or the entitlement is unknown. +func (a *Adapter) ScanEntitlementGrantBucket(ctx context.Context, entitlementID string, bucket connectorstore.GrantDigestBucket, yield func(*v2.Grant) bool) error { + if bucket.Level < 0 { + return fmt.Errorf("pebble: negative grant-digest level %d", bucket.Level) + } + syncID, err := a.resolveActiveSyncForReader(ctx, nil) + if err != nil { + return err + } + if syncID == "" { + return nil + } + id, ok, err := a.resolveDigestEntitlementIdentity(ctx, entitlementID) + if err != nil || !ok { + return err + } + bits := min(bucket.Level, digestMaxWidthBits) + return a.engine.IterateGrantsByEntitlementBucket(ctx, id, DigestBucket{Index: bucket.Index, Bits: bits}, func(r *v3.GrantRecord) bool { + return yield(V3GrantToV2(r)) + }) +} diff --git a/pkg/dotc1z/engine/pebble/bulk_import.go b/pkg/dotc1z/engine/pebble/bulk_import.go index 621779a31..ce280cebb 100644 --- a/pkg/dotc1z/engine/pebble/bulk_import.go +++ b/pkg/dotc1z/engine/pebble/bulk_import.go @@ -50,7 +50,10 @@ const bulkSpillKeyChunkBytes = 8 << 20 const bulkSpillBufferSize = 1 << 20 // grantIndexFamilies are the index-discriminator bytes AddGrants can -// emit (see grantIndexKeys). +// emit (see grantIndexKeys). The by_entitlement_principal_hash index +// is deliberately absent: it and the grant digests are derived from +// the primaries by the fused deferred pass at seal time +// (BuildDeferredGrantIndexes), never written inline. var grantIndexFamilies = []byte{ idxGrantByPrincipal, idxGrantByNeedsExpansion, diff --git a/pkg/dotc1z/engine/pebble/cleanup.go b/pkg/dotc1z/engine/pebble/cleanup.go index a132572d6..ceff3ad98 100644 --- a/pkg/dotc1z/engine/pebble/cleanup.go +++ b/pkg/dotc1z/engine/pebble/cleanup.go @@ -35,6 +35,8 @@ func scopedRanges() [][2][]byte { {GrantByPrincipalLowerBound(), GrantByPrincipalUpperBound()}, {GrantByPrincipalResourceTypeLowerBound(), GrantByPrincipalResourceTypeUpperBound()}, {GrantByNeedsExpansionLowerBound(), GrantByNeedsExpansionUpperBound()}, + {GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()}, + {DigestLowerBound(), DigestUpperBound()}, {encodeAssetPrefix(), upperBoundOf(encodeAssetPrefix())}, // Stats sidecar — single key; the half-open range shape // contains exactly that one key. @@ -94,6 +96,9 @@ func (e *Engine) ResetForNewSync(ctx context.Context) error { return fmt.Errorf("ResetForNewSync: excise [%x, %x): %w", span.Start, span.End, err) } } + // The record-type span above covers typeDigest and the hash + // index too; disarm the mutation-path digest invalidation. + e.grantDigestsPresent.Store(false) e.noteEntitlementKeyspaceWrite() return nil }) diff --git a/pkg/dotc1z/engine/pebble/deferred_index.go b/pkg/dotc1z/engine/pebble/deferred_index.go index 6fe511c9c..e6d44d63e 100644 --- a/pkg/dotc1z/engine/pebble/deferred_index.go +++ b/pkg/dotc1z/engine/pebble/deferred_index.go @@ -19,15 +19,25 @@ import ( ) // deferredIndexSpillChunkBytes is the spill-chunk arena size for the deferred -// by_principal index build. The shared bulkSpillKeyChunkBytes (8MiB) is sized +// index build's sorters. The shared bulkSpillKeyChunkBytes (8MiB) is sized // for the bulk import, where lanes × index-families sorters are alive at once // and small arenas bound aggregate memory. The deferred build is the opposite -// shape — one producer, one sorter, nothing else running — and with 8MiB -// chunks a whale (57M+ index keys ≈ 6.4GB) produced an 801-way final merge: -// ~10 heap comparisons per entry plus 801 open chunk files with 1MiB readers -// (~800MB of buffers). 128MiB chunks cut that to ~50 runs. Peak memory is the -// active arena plus up to `sorters` chunks being sorted in background -// (~640MB), in the same ballpark as what the wide merge's read buffers used. +// shape — one producer, at most two sorter families, nothing else running — +// and with 8MiB chunks a whale (57M+ index keys ≈ 6.4GB) produced an 801-way +// final merge: ~10 heap comparisons per entry plus 801 open chunk files with +// 1MiB readers (~800MB of buffers). 128MiB chunks cut that to ~50 runs. +// +// Memory budget (revised for the second family): with the grant digest +// index enabled the scan feeds TWO sorter families — by_principal +// (key-only) and by_entitlement_principal_hash (8-byte values) — which +// share the sort semaphore, so the peak is two filling arenas plus up +// to `sorters` (≤4) arenas in background sorts: ≈ 6 × 128MiB = 768MiB, +// roughly double the single-family budget. That stays acceptable +// because the build runs alone at EndSync (the sync's write pipeline +// and its arenas are already drained) and each family's merge width +// stays ~50 for whale-scale inputs. Each family gets its own bounded +// freelist sized to the sort concurrency, so retained (idle) arenas +// are capped at the same order. const deferredIndexSpillChunkBytes = 128 << 20 // appendGrantByPrincipalKeyFromPrimary builds the by_principal index key @@ -338,6 +348,18 @@ func (e *Engine) buildDeferredGrantIndexesLocked(ctx context.Context) error { // no-op once finalize has drained the sorter. defer principal.abort() + // Second sorter family: the by_entitlement_principal_hash rows the + // grant digests fold over (valued entries — the 8-byte content + // hash). Shares the sort semaphore with by_principal; see the + // deferredIndexSpillChunkBytes budget note. Nil when the digest + // index is disabled. + var hashIdx *spillSorter + if e.opts.grantDigestIndex { + hashIdx = newSpillSorter(dir, fmt.Sprintf("index-%02x", idxGrantByEntitlementPrincipalHash), sem, deferredIndexSpillChunkBytes) + hashIdx.free = newSpillArenaFreeList(deferredIndexSpillChunkBytes, sorters+1) + defer hashIdx.abort() + } + iter, err := e.db.NewIter(&pebble.IterOptions{ LowerBound: GrantLowerBound(), UpperBound: GrantUpperBound(), @@ -372,6 +394,12 @@ func (e *Engine) buildDeferredGrantIndexesLocked(ctx context.Context) error { var scanned, droppedMalformedKeys int64 var idxKeyScratch []byte + // Hash-index row emission is non-fatal, mirroring the rebuild tee: + // remember the first error, stop emitting, keep scanning — + // by_principal and the stats stash don't depend on the digests, and + // the seal treats a failed digest build as "drop and re-read". + var hashScanErr error + var hashScratch grantHashRowScratch lastLog := start for iter.First(); iter.Valid(); iter.Next() { totalKeys++ @@ -401,14 +429,20 @@ func (e *Engine) buildDeferredGrantIndexesLocked(ctx context.Context) error { // Only possible on key-layout drift or corruption: every grant // write path derives its key from the same 6-segment encoder. // The row still rides the primary rebuild (totalKeys counts it), - // but it cannot be represented in by_principal — count and warn - // below so a principal silently losing grants is observable. + // but it cannot be represented in by_principal (nor in the hash + // index/digests) — count and warn below so a principal silently + // losing grants is observable. droppedMalformedKeys++ continue } if err := principal.add(idxKey, nil); err != nil { return err } + if hashIdx != nil && hashScanErr == nil { + if err := appendGrantHashIndexRow(hashIdx, iter.Key(), iter.Value(), &hashScratch); err != nil { + hashScanErr = err + } + } scanned++ // Throttle per-row bookkeeping; ctx.Err and time.Now are measurable // at 57M+ rows. @@ -516,33 +550,60 @@ func (e *Engine) buildDeferredGrantIndexesLocked(ctx context.Context) error { zap.Int64("index_keys_added", scanned), zap.Duration("total", time.Since(start)), ) - return nil - } - l.Info("deferred grant index build: merging sorted chunks", - zap.Int("chunks", len(chunks)), - zap.Int64("index_keys_added", scanned), - zap.Duration("scan", scanDone.Sub(start)), - ) - sstPath := filepath.Join(dir, fmt.Sprintf("index-%02x.sst", idxGrantByPrincipal)) - if err := mergeSortedSpillChunksToSST(ctx, sstPath, fmt.Sprintf("index-%02x", idxGrantByPrincipal), chunks); err != nil { - return err - } - mergeDone := time.Now() + } else { + l.Info("deferred grant index build: merging sorted chunks", + zap.Int("chunks", len(chunks)), + zap.Int64("index_keys_added", scanned), + zap.Duration("scan", scanDone.Sub(start)), + ) + sstPath := filepath.Join(dir, fmt.Sprintf("index-%02x.sst", idxGrantByPrincipal)) + if err := mergeSortedSpillChunksToSST(ctx, sstPath, fmt.Sprintf("index-%02x", idxGrantByPrincipal), chunks); err != nil { + return err + } + mergeDone := time.Now() + + _, err = e.db.IngestAndExcise(ctx, []string{sstPath}, nil, nil, pebble.KeyRange{ + Start: GrantByPrincipalLowerBound(), + End: GrantByPrincipalUpperBound(), + }) + if err != nil { + return fmt.Errorf("BuildDeferredGrantIndexes: ingest/excise: %w", err) + } - _, err = e.db.IngestAndExcise(ctx, []string{sstPath}, nil, nil, pebble.KeyRange{ - Start: GrantByPrincipalLowerBound(), - End: GrantByPrincipalUpperBound(), - }) - if err != nil { - return fmt.Errorf("BuildDeferredGrantIndexes: ingest/excise: %w", err) + l.Info("deferred grant index build complete", + zap.Int64("index_keys_scanned", scanned), + zap.Duration("scan", scanDone.Sub(start)), + zap.Duration("merge", mergeDone.Sub(scanDone)), + zap.Duration("ingest", time.Since(mergeDone)), + zap.Duration("total", time.Since(start)), + ) } - ctxzap.Extract(ctx).Info("deferred grant index build complete", - zap.Int64("index_keys_scanned", scanned), - zap.Duration("scan", scanDone.Sub(start)), - zap.Duration("merge", mergeDone.Sub(scanDone)), - zap.Duration("ingest", time.Since(mergeDone)), - zap.Duration("total", time.Since(start)), - ) + // Grant digests + hash index, fused onto the same scan. Non-fatal, + // like the stats sidecar's treatment of seal-time extras: on ANY + // failure — a row emission error remembered mid-scan, a merge or + // ingest error here — every digest node and hash-index row is + // dropped, because a partially built digest that LOOKS present + // would violate the present-means-exact contract (digest.go). + // Absent digests just mean a diff consumer re-reads the grants; the + // next successful seal recalculates them. Context cancellation + // stays fatal — the whole EndSync is being abandoned. + if hashIdx != nil { + digestErr := hashScanErr + if digestErr == nil { + digestErr = e.buildGrantDigestsFromSpill(ctx, dir, hashIdx) + } + if digestErr != nil { + if ctx.Err() != nil { + return digestErr + } + l.Error("deferred grant index build: grant digest/hash-index build failed; dropping digest state — grant-diff callers must re-read grants until the next successful seal", + zap.Error(digestErr), + ) + if dropErr := e.dropAllGrantDigestStateLocked(); dropErr != nil { + return fmt.Errorf("BuildDeferredGrantIndexes: drop grant digest state after failed build: %w", dropErr) + } + } + } return nil } diff --git a/pkg/dotc1z/engine/pebble/digest.go b/pkg/dotc1z/engine/pebble/digest.go new file mode 100644 index 000000000..dc6448192 --- /dev/null +++ b/pkg/dotc1z/engine/pebble/digest.go @@ -0,0 +1,729 @@ +package pebble + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + + "github.com/cockroachdb/pebble/v2" +) + +// Bucketed XOR set digests over bucket-hash indexes. +// +// Goal: answer "does this partition hold exactly the same records as +// some other sync/file?" with a single key read, and when the answer is +// no, identify which hash buckets differ so a caller can load only +// those records instead of re-reading the whole partition. +// +// The digest is generic over any secondary index matching the shape +// described on digestIndexSpec — a partition prefix followed by a raw +// fixed-width bucket hash, with a per-record content hash as the +// value. Two properties make such an index the right substrate: +// +// - hash-major order is identical across two files that hold the same +// records, so a streamed fold produces the same digest; and +// - a bucket is a contiguous bit-range of the hash, which is a +// contiguous range of the index key, so "all records in bucket P" +// is a single range scan. +// +// The grant instantiation (partition = entitlement identity tail, +// bucket hash = hash(principal identity)) lives in grant_digest.go. +// +// Combiner. A node's digest is the XOR of every content hash beneath +// it (the content hashes themselves are whatever the index writer +// chose — only the combiner is XOR). XOR is homomorphic (parent = XOR +// of children) and order-independent, which buys two things: +// +// - split-independence: a bucket's digest depends only on the records +// in its hash range, never on how the range is subdivided, so +// buckets from digests of different widths compare directly (a +// width-w bucket is the XOR of its two width-(w+1) halves); +// - the empty digest is all-zero (the XOR identity), so an absent +// leaf reads as {count: 0, digest: 0}. +// +// Lifecycle: PRESENT means EXACT. Digest nodes (and the index they +// fold over) are written only by the seal-time build (the deferred +// EndSync pass, see deferred_index.go) — never maintained incrementally +// on the record write paths. Starting a new sync excises the digest +// keyspace along with the rest of the data keyspace, and the mutation +// paths that can run against a file holding built digests drop the +// touched partition's nodes (and its index range) instead of updating +// them. A reader that finds a root may therefore trust it +// unconditionally; a missing root means "not built (or invalidated) — +// recalculate", and comparison treats the whole partition as dirty +// rather than deriving a digest from an index whose presence it cannot +// verify. +// +// Every node also stores its record COUNT. Comparison always checks the +// (count, digest) pair, so a non-empty node whose hashes happened to XOR +// to zero can never be conflated with an empty/absent one. XOR +// set-hashing is not adversarially collision-resistant +// (Bellare–Micciancio); a digest is an optimization, not a trust +// boundary — see RFC 0003 §9. Index writers should fold every +// key-distinguishing field into the content hash so duplicate leaves +// (and thus in-partition self-cancellation) are impossible by +// construction. +// +// Shape. A flat hash table, not a multi-level radix tree: one root plus +// a single leaf level of 2^width buckets, where width — in BITS of the +// bucket hash, 0..digestMaxWidthBits — is chosen per partition so the +// average bucket holds at most digestTargetBucketSize records +// (chooseDigestWidth). Width 0 means root-only (small and empty +// partitions cost exactly one stored node). Growing capacity one bit at +// a time keeps realized bucket occupancy within a 2x band of the +// target; a byte-per-level radix could only pick capacities of 256^k, +// so a partition just past a boundary would store up to ~256x more +// nodes than needed. +// +// Interior levels are deliberately absent: hierarchical pruning only +// pays when the leaf level is too large to scan, and at <= 2^16 leaves +// a contiguous range scan beats a level-by-level descent. Comparison is +// a single merge scan of both sides' leaf levels (see +// dirtyPartitionBuckets); cross-width comparison folds the finer side's +// leaves down to the coarser width on the fly, which split-independence +// makes exact. For the same reason digestTargetBucketSize is a soft +// tuning knob, not an ABI constant: digests built with different +// targets still compare correctly. +// +// Leaves are stored sparsely — a leaf is materialized iff its bucket +// holds >=1 record. The root is always materialized (it is the "digest +// was built" marker — absence of the root means "never built", never +// "empty"). +// +// On-disk ABI. The bucket-hash width, each index's content-hash +// definition, the combiner, the leaf-prefix encoding, and the node +// value framing are all part of the stored format: changing +// digestBucketHashLen, an index's content-hash field set, the combiner, +// or the framing requires an index-migration version bump (see +// index_migrations.go). digestTargetBucketSize is NOT part of the ABI +// (see above). + +const ( + // digestBucketHashLen is the width, in bytes, of the raw bucket + // hash embedded in a digested index's key. It is exactly the bytes + // digestMaxWidthBits can address (16 bits = 2 bytes); storing more + // would be dead weight on every index row, since only the top + // digestMaxWidthBits ever select a bucket. Collisions in the bucket + // address are harmless (they only co-locate records — the index + // key's tail still distinguishes rows). ABI. + digestBucketHashLen = 2 + + // digestTargetBucketSize is the record count a single leaf bucket + // aims to hold. The width is grown until 2^width buckets bring the + // average bucket under this. Tunable without a migration: the width + // is read from the stored root and cross-width comparison folds to + // the coarser side. + digestTargetBucketSize = 512 + + // digestMaxWidthBits caps the leaf-level width. 2^16 buckets keeps + // the comparison's full leaf scan trivially cheap; past the cap + // (digestTargetBucketSize << 16 records) buckets simply grow beyond + // the target. + digestMaxWidthBits = 16 + + // digestLeafPrefixLen is the stored byte width of a leaf key's + // bucket prefix: the bucket index LEFT-ALIGNED in 16 bits, so leaf + // keys sort in bucket-hash order at every width and folding to a + // coarser width is "take the top bits". ABI. + digestLeafPrefixLen = 2 +) + +// Node-key levels: the root is level 0 (empty prefix); the single leaf +// level is 1 (digestLeafPrefixLen-byte prefix). See encodeDigestNodeKey. +const ( + digestLevelRoot byte = 0 + digestLevelLeaf byte = 1 +) + +// hashLen is the width of a content hash (index value) and of a node +// digest. +const hashLen = 8 + +// zeroDigest is the XOR identity — the digest of an empty/absent node. +var zeroDigest [hashLen]byte + +// xorInto XORs src into dst in place, over min(len(dst), len(src)). +func xorInto(dst, src []byte) { + for i := range min(len(dst), len(src)) { + dst[i] ^= src[i] + } +} + +// digestIndexSpec describes one bucket-hash index the digest core can +// fold. The index must have the shape +// +// index key = partitionPrefix(partition) | | +// index value = content hash (hashLen bytes) +// +// The content hash defines record identity for the diff and must fold +// every key-distinguishing field (see the package comment); the bucket +// hash must be derived from fields that are stable across syncs. +type digestIndexSpec struct { + // indexID discriminates this index's nodes inside the typeDigest + // keyspace. Conventionally the digested index's own idx* byte. ABI. + indexID byte + + // partitionPrefix returns the index-key prefix covering one + // partition's entries, ending immediately before the raw bucket + // hash (trailing separator included). + partitionPrefix func(partition string) []byte +} + +// DigestBucket addresses one hash bucket of a partition: the records +// whose bucket hash starts with the top Bits bits of Index. The zero +// value (Bits 0) addresses the whole partition. +type DigestBucket struct { + Index uint32 + Bits int +} + +// leafKeyPrefix returns the stored digestLeafPrefixLen-byte node-key +// prefix for a leaf bucket: the index left-aligned in 16 bits. +// Requires 1 <= Bits <= digestMaxWidthBits. +func (b DigestBucket) leafKeyPrefix() []byte { + out := make([]byte, digestLeafPrefixLen) + binary.BigEndian.PutUint16(out, uint16(b.Index)<<(16-b.Bits)) //nolint:gosec // Index < 2^Bits <= 2^16 by construction + return out +} + +// bucketOfHash returns the width-`bits` bucket holding bucket hash bh. +// Requires 1 <= bits <= digestMaxWidthBits. +func bucketOfHash(bh []byte, bits int) DigestBucket { + u := binary.BigEndian.Uint16(bh[:digestLeafPrefixLen]) + return DigestBucket{Index: uint32(u >> (16 - bits)), Bits: bits} +} + +// bucketBounds returns the index key range [lower, upper) covering a +// bucket's records in one partition. The raw bucket hash is a clean +// byte region of the index key, so a bit-granular bucket maps to plain +// uint64 arithmetic on that region. +func (s digestIndexSpec) bucketBounds(partition string, b DigestBucket) ([]byte, []byte) { + prefix := s.partitionPrefix(partition) + if b.Bits == 0 { + return prefix, upperBoundOf(prefix) + } + boundAt := func(hash uint64) []byte { + // hash carries its bucket bits in the top digestMaxWidthBits, so + // the bound is the top digestBucketHashLen bytes of the big-endian + // uint64 appended to the partition prefix. + var full [8]byte + binary.BigEndian.PutUint64(full[:], hash) + out := make([]byte, 0, len(prefix)+digestBucketHashLen) + out = append(out, prefix...) + return append(out, full[:digestBucketHashLen]...) + } + // Signed shift count (Go >= 1.13): b.Bits is in [1, digestMaxWidthBits] + // here (the Bits==0 case returned above), so shift is in [48, 63] — + // no uint conversion, so no overflow-checker warning to suppress. + shift := 64 - b.Bits + lower := boundAt(uint64(b.Index) << shift) + if uint64(b.Index)+1 == uint64(1)< digestMaxWidthBits { + return 0, 0, nil, false + } + widthBits := int(val[0]) + count := int64(binary.BigEndian.Uint64(val[1:9])) //nolint:gosec // count is a non-negative row count + return widthBits, count, val[9:], true +} + +func packDigestLeaf(count int64, digest []byte) []byte { + buf := make([]byte, 0, 8+len(digest)) + var n [8]byte + binary.BigEndian.PutUint64(n[:], uint64(count)) //nolint:gosec // non-negative row count + buf = append(buf, n[:]...) + return append(buf, digest...) +} + +// unpackDigestLeaf returns (count, digest, ok) for a leaf node body. +func unpackDigestLeaf(val []byte) (int64, []byte, bool) { + if len(val) != 8+hashLen { + return 0, nil, false + } + count := int64(binary.BigEndian.Uint64(val[:8])) //nolint:gosec // non-negative count + return count, val[8:], true +} + +// digestLeafNode is one materialized leaf produced by a partition +// fold: the stored left-aligned bucket prefix and the packed +// count||digest value. +type digestLeafNode struct { + prefix [digestLeafPrefixLen]byte + val []byte +} + +// foldPartitionNodes folds one partition's index range into its digest +// nodes: the packed root value plus every non-empty leaf, in ascending +// bucket order (the order the sorted index scan produces them). +// Read-only — callers decide persistence: a batch for the standalone +// single-partition rebuild (buildPartitionDigestAtWidth); the seal-time +// bulk build streams its own fold instead (see deferred_index.go). +// Memory is O(stored leaves), bounded by 2^digestMaxWidthBits nodes. +// +// Sorted index order means each bucket's entries are contiguous, so the +// single "open" leaf closes exactly when its prefix changes. Only +// non-empty leaves are ever opened, so sparsity is automatic, not a +// prune pass. +// +// Callers must hold writeMu (all do): folding outside the writer mutex +// could interleave with a record write and persist a digest that +// matches neither the before nor the after state. +func (e *Engine) foldPartitionNodes(ctx context.Context, spec digestIndexSpec, partition string, widthBits int) ([]byte, []digestLeafNode, error) { + prefix := spec.partitionPrefix(partition) + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: prefix, UpperBound: upperBoundOf(prefix)}) + if err != nil { + return nil, nil, err + } + defer iter.Close() + + // lowMask clears the sub-bucket bits of a left-aligned 16-bit + // prefix, leaving the leaf's stored key prefix value. + var lowMask uint16 + if widthBits > 0 { + lowMask = ^uint16(0) >> widthBits + } + + var ( + leaves []digestLeafNode + leafOpen bool + leafLV uint16 // left-aligned stored prefix of the open leaf + leafDigest [hashLen]byte + leafCount int64 + rootDigest [hashLen]byte + total int64 + ) + flushLeaf := func() { + if !leafOpen { + return + } + var n digestLeafNode + binary.BigEndian.PutUint16(n.prefix[:], leafLV) + n.val = packDigestLeaf(leafCount, leafDigest[:]) + leaves = append(leaves, n) + } + + for iter.First(); iter.Valid(); iter.Next() { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + key := iter.Key() + if len(key) < len(prefix)+digestBucketHashLen { + continue // malformed; skip defensively + } + val := iter.Value() // per-record content hash + if len(val) != hashLen { + // Index writers always emit exactly hashLen bytes; a wrong + // length is a corrupt or mis-encoded entry. xorInto would + // fold only a prefix and quietly corrupt the digest, so + // reject it. At seal the caller downgrades a build error to + // "no digest" (drop + re-read), so this fails safe. + return nil, nil, fmt.Errorf("foldPartitionNodes: content hash for %q is %d bytes, want %d", partition, len(val), hashLen) + } + if widthBits > 0 { + lv := binary.BigEndian.Uint16(key[len(prefix):]) &^ lowMask + if !leafOpen || lv != leafLV { + flushLeaf() + leafLV = lv + leafDigest = [hashLen]byte{} + leafCount = 0 + leafOpen = true + } + xorInto(leafDigest[:], val) + leafCount++ + } + xorInto(rootDigest[:], val) + total++ + } + if err := iter.Error(); err != nil { + return nil, nil, err + } + flushLeaf() + + // The root is produced unconditionally — even at count 0 — as the + // "digest was built" marker. + return packDigestRoot(widthBits, total, rootDigest[:]), leaves, nil +} + +// buildPartitionDigestAtWidth rebuilds ONE partition's digest in place +// through a write batch. This is the standalone/repair path (tests, +// explicit rebuilds); the seal-time bulk build streams every +// partition's nodes during the deferred-pass merge instead (see +// deferred_index.go). +// +// The batch starts by range-deleting the partition's whole node +// keyspace: the build only ever Sets nodes, so without the clear a +// rebuild that changes width or empties a bucket would leave stale +// leaves that the comparison merge scan (which enumerates leaves from +// the node keyspace) would read — and a stale digest that happens to +// match the peer prunes a real diff. Old and new framings are +// byte-length identical, so stale nodes are not detectable by +// inspection. +// +// The width is taken as a parameter rather than derived so the +// width-selection seam can be exercised directly: tests force a width +// that the natural count→width mapping would only produce at a very +// large record count, which is how the cross-width comparison path gets +// covered without seeding hundreds of thousands of records. +func (e *Engine) buildPartitionDigestAtWidth(ctx context.Context, spec digestIndexSpec, partition string, widthBits int) error { + nodeLower := encodeDigestPartitionPrefix(spec.indexID, partition) + nodeUpper := upperBoundOf(nodeLower) + + return e.withWrite(func() error { + rootVal, leaves, err := e.foldPartitionNodes(ctx, spec, partition, widthBits) + if err != nil { + return err + } + + batch := e.db.NewBatch() + defer batch.Close() + + // Clear any prior build (see function comment). In-batch + // ordering makes this safe: the Sets below land after the + // tombstone and survive it. + if err := batch.DeleteRange(nodeLower, nodeUpper, nil); err != nil { + return err + } + rootKey := encodeDigestNodeKey(spec.indexID, partition, digestLevelRoot, nil) + if err := batch.Set(rootKey, rootVal, nil); err != nil { + return err + } + for i := range leaves { + key := encodeDigestNodeKey(spec.indexID, partition, digestLevelLeaf, leaves[i].prefix[:]) + if err := batch.Set(key, leaves[i].val, nil); err != nil { + return err + } + } + + opts := writeOpts(e.opts.durability) + if e.IsFreshSync() { + opts = pebble.NoSync + } + if err := batch.Commit(opts); err != nil { + return err + } + e.grantDigestsPresent.Store(true) + return nil + }) +} + +// DigestRoot is a partition's stored root digest. +type DigestRoot struct { + Hash []byte + Bits int // leaf-level width in bits; 0 = root-only digest + Count int64 +} + +// getPartitionDigestRoot returns the stored root for a partition. ok is +// false when no digest has been built for it (the caller can fall back +// to computeBucketDigest, which derives the same digest from the index +// on demand). +func (e *Engine) getPartitionDigestRoot(spec digestIndexSpec, partition string) (DigestRoot, bool, error) { + val, closer, err := e.db.Get(encodeDigestNodeKey(spec.indexID, partition, digestLevelRoot, nil)) + if err != nil { + if errors.Is(err, pebble.ErrNotFound) { + return DigestRoot{}, false, nil + } + return DigestRoot{}, false, err + } + defer closer.Close() + widthBits, count, h, valid := unpackDigestRoot(val) + if !valid { + return DigestRoot{}, false, fmt.Errorf("getPartitionDigestRoot: malformed root for %q", partition) + } + out := make([]byte, len(h)) + copy(out, h) + return DigestRoot{Hash: out, Bits: widthBits, Count: count}, true, nil +} + +// getDigestLeaf reads one stored leaf by its key prefix. An absent leaf +// returns (0, zero digest, present=false, nil) — the XOR identity. +func (e *Engine) getDigestLeaf(spec digestIndexSpec, partition string, leafPrefix []byte) (int64, []byte, bool, error) { + val, closer, err := e.db.Get(encodeDigestNodeKey(spec.indexID, partition, digestLevelLeaf, leafPrefix)) + if err != nil { + if errors.Is(err, pebble.ErrNotFound) { + return 0, zeroDigest[:], false, nil + } + return 0, nil, false, err + } + defer closer.Close() + count, digest, ok := unpackDigestLeaf(val) + if !ok { + return 0, nil, false, fmt.Errorf("getDigestLeaf: malformed leaf for %q", partition) + } + out := make([]byte, hashLen) + copy(out, digest) + return count, out, true, nil +} + +// foldedBucket is one entry of a folded leaf scan: the (XOR, count) +// aggregate of the consecutive stored leaves sharing the top `bits` +// bits of their bucket index. +type foldedBucket struct { + idx uint32 + count int64 + digest [hashLen]byte +} + +// foldedLeafBuckets scans a partition's stored leaf level and folds it +// to width foldBits (which must be <= the width the digest was built +// at), returning the non-empty buckets in index order. One contiguous +// range scan; folding is exact because leaf prefixes are left-aligned +// (so keys sort in bucket-hash order at any width) and XOR digests are +// split-independent. +func (e *Engine) foldedLeafBuckets(ctx context.Context, spec digestIndexSpec, partition string, foldBits int) ([]foldedBucket, error) { + stem := encodeDigestNodeKey(spec.indexID, partition, digestLevelLeaf, nil) + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: stem, UpperBound: upperBoundOf(stem)}) + if err != nil { + return nil, err + } + defer iter.Close() + var out []foldedBucket + for iter.First(); iter.Valid(); iter.Next() { + if err := ctx.Err(); err != nil { + return nil, err + } + key := iter.Key() + if len(key) != len(stem)+digestLeafPrefixLen { + continue // malformed; skip defensively + } + lv := binary.BigEndian.Uint16(key[len(stem):]) + idx := uint32(lv >> (16 - foldBits)) + count, digest, ok := unpackDigestLeaf(iter.Value()) + if !ok { + return nil, fmt.Errorf("foldedLeafBuckets: malformed leaf for %q", partition) + } + if n := len(out); n > 0 && out[n-1].idx == idx { + out[n-1].count += count + xorInto(out[n-1].digest[:], digest) + continue + } + fb := foldedBucket{idx: idx, count: count} + copy(fb.digest[:], digest) + out = append(out, fb) + } + return out, iter.Error() +} + +// computeBucketDigest folds the index over a single bucket (the zero +// bucket = whole partition = the root) and returns the content-defined +// XOR digest plus the record count. This is the authoritative +// definition of a node; stored nodes are a cache of it. +// Split-independent: the digest depends only on the records in the +// bucket's hash range, not on any digest's width. +func (e *Engine) computeBucketDigest(ctx context.Context, spec digestIndexSpec, partition string, bucket DigestBucket) ([]byte, int64, error) { + lower, upper := spec.bucketBounds(partition, bucket) + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: lower, UpperBound: upper}) + if err != nil { + return nil, 0, err + } + defer iter.Close() + digest := make([]byte, hashLen) + var count int64 + for iter.First(); iter.Valid(); iter.Next() { + if err := ctx.Err(); err != nil { + return nil, 0, err + } + val := iter.Value() + if len(val) != hashLen { + // See buildPartitionDigestAtWidth: a mis-length index value is + // corruption; reject it rather than silently fold a prefix. + return nil, 0, fmt.Errorf("computeBucketDigest: content hash for %q is %d bytes, want %d", partition, len(val), hashLen) + } + xorInto(digest, val) + count++ + } + if err := iter.Error(); err != nil { + return nil, 0, err + } + return digest, count, nil +} + +// computeBucketsAtWidth scans the index for one partition and rolls it +// up into width-`bits` buckets directly — O(records), one contiguous +// index scan, O(1) memory. It is the fallback for +// GetEntitlementGrantDigestNodes when the requested level is finer than +// the stored digest's width (foldedLeafBuckets only goes as fine as what +// was built). bits must be in [1, digestMaxWidthBits]; the caller clamps +// to the bucket-hash resolution. Returns the non-empty buckets in index +// order — index entries are bucket-hash-major, so each bucket's records +// are contiguous and close when the top-`bits` prefix changes. +func (e *Engine) computeBucketsAtWidth(ctx context.Context, spec digestIndexSpec, partition string, bits int) ([]foldedBucket, error) { + prefix := spec.partitionPrefix(partition) + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: prefix, UpperBound: upperBoundOf(prefix)}) + if err != nil { + return nil, err + } + defer iter.Close() + var out []foldedBucket + for iter.First(); iter.Valid(); iter.Next() { + if err := ctx.Err(); err != nil { + return nil, err + } + key := iter.Key() + if len(key) < len(prefix)+digestBucketHashLen { + continue // malformed; skip defensively + } + // The bucket hash is the digestBucketHashLen raw bytes right after + // the partition prefix; its top `bits` bits select the bucket. + idx := uint32(binary.BigEndian.Uint16(key[len(prefix):]) >> (16 - bits)) + val := iter.Value() + if len(val) != hashLen { + // See buildPartitionDigestAtWidth: a mis-length index value is + // corruption; reject it rather than fold a prefix. + return nil, fmt.Errorf("computeBucketsAtWidth: content hash for %q is %d bytes, want %d", partition, len(val), hashLen) + } + if len(out) == 0 || out[len(out)-1].idx != idx { + out = append(out, foldedBucket{idx: idx}) + } + i := len(out) - 1 + xorInto(out[i].digest[:], val) + out[i].count++ + } + return out, iter.Error() +} + +// dirtyPartitionBuckets compares this engine's partition against +// other's and returns the buckets whose records differ. A single zero +// bucket (Bits 0) means "the whole partition differs" (used when the +// comparison granularity is the root, e.g. small partitions). A nil +// (empty) result means the two are identical. +// +// The fast path is a single root read per side. On mismatch both sides' +// leaf levels are folded to compareBits — the narrower digest's width, +// where both sides have directly comparable buckets (XOR digests are +// split-independent) — and merge-compared in one pass. Each side's fold +// is a single contiguous range scan of its stored leaves, so cost is +// O(leaves), bounded by count/digestTargetBucketSize per side. +// +// A missing root means the digest was never built (or was invalidated) +// on that side — NOT that the partition is empty. There is no safe +// cheaper answer in that state: deriving a digest from the index would +// trust an index whose presence this side can't verify (a file written +// with the index disabled folds to "zero records" and two such files +// would compare falsely clean). So the whole partition is reported +// dirty; the caller re-reads it, or rebuilds the digest first. +func (e *Engine) dirtyPartitionBuckets(ctx context.Context, spec digestIndexSpec, other *Engine, partition string) ([]DigestBucket, error) { + rootA, okA, err := e.getPartitionDigestRoot(spec, partition) + if err != nil { + return nil, err + } + rootB, okB, err := other.getPartitionDigestRoot(spec, partition) + if err != nil { + return nil, err + } + + if !okA || !okB { + return []DigestBucket{{}}, nil + } + + if rootA.Count == rootB.Count && bytes.Equal(rootA.Hash, rootB.Hash) { + return nil, nil + } + + // Roots differ. The comparison granularity is the narrower digest's + // width; at width 0 there is nothing below the root. + compareBits := min(rootA.Bits, rootB.Bits) + if compareBits == 0 { + return []DigestBucket{{}}, nil + } + + fa, err := e.foldedLeafBuckets(ctx, spec, partition, compareBits) + if err != nil { + return nil, err + } + fb, err := other.foldedLeafBuckets(ctx, spec, partition, compareBits) + if err != nil { + return nil, err + } + + // Merge the two sorted folded-bucket streams. A bucket present on + // only one side is dirty by construction (stored leaves are never + // empty); a shared bucket is dirty iff its (count, digest) differs. + var dirty []DigestBucket + i, j := 0, 0 + for i < len(fa) || j < len(fb) { + switch { + case j == len(fb) || (i < len(fa) && fa[i].idx < fb[j].idx): + dirty = append(dirty, DigestBucket{Index: fa[i].idx, Bits: compareBits}) + i++ + case i == len(fa) || fb[j].idx < fa[i].idx: + dirty = append(dirty, DigestBucket{Index: fb[j].idx, Bits: compareBits}) + j++ + default: + if fa[i].count != fb[j].count || fa[i].digest != fb[j].digest { + dirty = append(dirty, DigestBucket{Index: fa[i].idx, Bits: compareBits}) + } + i++ + j++ + } + } + + // Roots differed but the merge found nothing: with consistent + // digests that's impossible (the root is the XOR of the leaves), so + // a stored node is stale or corrupt. Fail safe — whole partition + // dirty; the next rebuild heals the digest. + if len(dirty) == 0 { + return []DigestBucket{{}}, nil + } + return dirty, nil +} + +// --- Invalidation --- + +// dropPartitionDigest stages a DeleteRange over one partition's digest +// nodes (root included). Record mutations that can run against a file +// holding a built digest use this instead of updating nodes in place: +// under the present-means-exact contract, a mutated partition's digest +// is simply MISSING until the next seal-time build recalculates it. +func dropPartitionDigest(batch *pebble.Batch, spec digestIndexSpec, partition string) error { + lo := encodeDigestPartitionPrefix(spec.indexID, partition) + return batch.DeleteRange(lo, upperBoundOf(lo), nil) +} diff --git a/pkg/dotc1z/engine/pebble/digest_test.go b/pkg/dotc1z/engine/pebble/digest_test.go new file mode 100644 index 000000000..862d5144c --- /dev/null +++ b/pkg/dotc1z/engine/pebble/digest_test.go @@ -0,0 +1,1477 @@ +package pebble + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + "testing" + + "github.com/cockroachdb/pebble/v2" + "github.com/segmentio/ksuid" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3" + "github.com/conductorone/baton-sdk/pkg/connectorstore" +) + +// putEnt writes an entitlement record whose external_id is the +// canonical SDK shape ("app:github:"+entID) — the same string grants +// reference via EntitlementRef.EntitlementId (see makeGrant), so the +// entitlement record and its grants share one structural identity and +// therefore one digest partition. +func putEnt(t testing.TB, e *Engine, ctx context.Context, entID string) { + t.Helper() + rec := v3.EntitlementRecord_builder{ + ExternalId: canonicalTestEntID(entID), + Resource: v3.ResourceRef_builder{ + ResourceTypeId: "app", + ResourceId: "github", + }.Build(), + }.Build() + if err := e.PutEntitlementRecord(ctx, rec); err != nil { + t.Fatalf("PutEntitlementRecord: %v", err) + } +} + +// testEntIdentity is the structural identity the digest keyspace is +// addressed by for a makeGrant/putEnt-shaped entitlement. +func testEntIdentity(entID string) entitlementIdentity { + return entitlementIdentityFromParts("app", "github", canonicalTestEntID(entID)) +} + +// testEntPartition is the digest partition for a test entitlement. +func testEntPartition(entID string) string { + return digestPartitionForEntitlement(testEntIdentity(entID)) +} + +// makeGrantWithSources is makeGrant plus an optional source-entitlement +// set, which the content hash folds in — so two grants with the same +// identity but different sources produce different content hashes while +// keeping the SAME index key. +func makeGrantWithSources(syncID, externalID, entID, principalID string, sources ...string) *v3.GrantRecord { + g := makeGrant(syncID, externalID, entID, principalID) + if len(sources) > 0 { + m := make(map[string]*v3.GrantSourceRecord, len(sources)) + for _, s := range sources { + m[s] = v3.GrantSourceRecord_builder{}.Build() + } + g.SetSources(m) + } + return g +} + +// sealGrantDigests runs the seal-time deferred pass (whose fused digest +// build is the production writer of the hash index + digests). +func sealGrantDigests(t testing.TB, e *Engine) { + t.Helper() + if err := e.BuildDeferredGrantIndexes(context.Background()); err != nil { + t.Fatalf("BuildDeferredGrantIndexes: %v", err) + } +} + +// digestNodeCount counts stored digest nodes (across all partitions and +// digested indexes). +func digestNodeCount(t testing.TB, e *Engine) int { + t.Helper() + return countKeyRangeTest(t, e, DigestLowerBound(), DigestUpperBound()) +} + +// rawLeafPrefixes returns the stored 2-byte leaf key prefixes for one +// entitlement's grant digest, in key order. +func rawLeafPrefixes(t testing.TB, e *Engine, entID string) [][]byte { + t.Helper() + stem := encodeDigestNodeKey(grantDigestSpec.indexID, testEntPartition(entID), digestLevelLeaf, nil) + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: stem, UpperBound: upperBoundOf(stem)}) + if err != nil { + t.Fatalf("NewIter: %v", err) + } + defer iter.Close() + var out [][]byte + for iter.First(); iter.Valid(); iter.Next() { + key := iter.Key() + if len(key) != len(stem)+digestLeafPrefixLen { + t.Fatalf("leaf key with prefix length %d, want %d", len(key)-len(stem), digestLeafPrefixLen) + } + out = append(out, append([]byte(nil), key[len(stem):]...)) + } + if err := iter.Error(); err != nil { + t.Fatalf("iter: %v", err) + } + return out +} + +// countKeyRangeTest counts stored keys in [lo, hi). +func countKeyRangeTest(t testing.TB, e *Engine, lo, hi []byte) int { + t.Helper() + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: lo, UpperBound: hi}) + if err != nil { + t.Fatalf("NewIter: %v", err) + } + defer iter.Close() + n := 0 + for iter.First(); iter.Valid(); iter.Next() { + n++ + } + if err := iter.Error(); err != nil { + t.Fatalf("iter: %v", err) + } + return n +} + +// entHashIndexRowCount counts hash-index rows under one entitlement's +// partition. +func entHashIndexRowCount(t testing.TB, e *Engine, entID string) int { + t.Helper() + prefix := encodeGrantByEntPrincHashEntPrefix(testEntPartition(entID)) + return countKeyRangeTest(t, e, prefix, upperBoundOf(prefix)) +} + +// TestGrantDigestIndexSealOnly verifies the seal-only lifecycle: grant +// writes never produce hash-index rows or digest nodes inline; the +// adapter's EndSync produces both when the digest index is enabled and +// neither when it is disabled. The grants here go through the INLINE +// index path (PutGrantRecords), so this also covers the standalone +// EndSync digest build (deferredIdxPending never arms). +func TestGrantDigestIndexSealOnly(t *testing.T) { + ctx := context.Background() + grants := []*v3.GrantRecord{ + makeGrant("", "g1", "ent-A", "alice"), + makeGrant("", "g2", "ent-A", "bob"), + } + write := func(e *Engine) *Adapter { + t.Helper() + a := NewAdapter(e) + if _, err := a.StartNewSync(ctx, connectorstore.SyncTypeFull, ""); err != nil { + t.Fatalf("StartNewSync: %v", err) + } + putEnt(t, e, ctx, "ent-A") + if err := e.PutGrantRecords(ctx, grants...); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + return a + } + + // Default (index on): nothing inline; seal derives rows + digests. + on, _ := newTestEngine(t) + a := write(on) + if got := countKeyRangeTest(t, on, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()); got != 0 { + t.Fatalf("pre-seal: hash index rows = %d, want 0 (never written inline)", got) + } + if got := digestNodeCount(t, on); got != 0 { + t.Fatalf("pre-seal: digest nodes = %d, want 0 (never written inline)", got) + } + if err := a.EndSync(ctx); err != nil { + t.Fatalf("EndSync: %v", err) + } + if got := countKeyRangeTest(t, on, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()); got != len(grants) { + t.Fatalf("sealed: hash index rows = %d, want %d", got, len(grants)) + } + if got := digestNodeCount(t, on); got == 0 { + t.Fatal("sealed: no digest nodes built") + } + + // Disabled: seal skips the derivation entirely. + off, _ := newTestEngine(t, WithGrantDigestIndex(false)) + aOff := write(off) + if err := aOff.EndSync(ctx); err != nil { + t.Fatalf("EndSync: %v", err) + } + if got := countKeyRangeTest(t, off, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()); got != 0 { + t.Fatalf("digest index off: hash index rows = %d, want 0", got) + } + if got := digestNodeCount(t, off); got != 0 { + t.Fatalf("digest index off: digest nodes = %d, want 0", got) + } + // The other grant indexes are still maintained. + if got := countKeyRangeTest(t, off, GrantByPrincipalLowerBound(), GrantByPrincipalUpperBound()); got != len(grants) { + t.Fatalf("digest index off: by_principal rows = %d, want %d", got, len(grants)) + } +} + +// TestGrantDigestIncludesExpandedGrants guards the seal-time +// derivation end-to-end through the FUSED path: expanded grants go +// through the deferred index write path, arming the marker, so EndSync +// runs BuildDeferredGrantIndexes and the digest build fuses onto its +// scan — covering directly-synced and expanded grants alike, because +// both are derived from the grant primaries. +func TestGrantDigestIncludesExpandedGrants(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + a := NewAdapter(e) + if _, err := a.StartNewSync(ctx, connectorstore.SyncTypeFull, ""); err != nil { + t.Fatalf("StartNewSync: %v", err) + } + putEnt(t, e, ctx, "ent-A") + + // One directly-synced grant. + if err := e.PutGrantRecords(ctx, makeGrant("", "g-direct", "ent-A", "alice")); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + // One expanded grant (with a source), via the expansion write path — + // this arms the deferred-index marker. + exp := makeGrantWithSources("", "g-expanded", "ent-A", "bob", "src-ent") + if err := e.PutExpandedGrantRecords(ctx, []*v3.GrantRecord{exp}); err != nil { + t.Fatalf("PutExpandedGrantRecords: %v", err) + } + if !e.deferredIdxPending.Load() { + t.Fatal("expected the expansion write to arm the deferred-index marker") + } + if err := a.EndSync(ctx); err != nil { + t.Fatalf("EndSync: %v", err) + } + + if got := countKeyRangeTest(t, e, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()); got != 2 { + t.Fatalf("hash index rows = %d, want 2 (direct + expanded)", got) + } + root, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-A")) + if err != nil || !ok { + t.Fatalf("root: ok=%v err=%v", ok, err) + } + if root.Count != 2 { + t.Fatalf("digest root count = %d, want 2 (expanded grant must be in the digest)", root.Count) + } +} + +// TestAdapterGetEntitlementGrantDigest exercises the reader capability +// (connectorstore.EntitlementGrantDigestReader) end-to-end through the +// Adapter: a sealed sync resolves and returns the root hash + count, an +// unknown entitlement reports not-found, and an engine with the digest +// index disabled reports not-found even for a real entitlement. +func TestAdapterGetEntitlementGrantDigest(t *testing.T) { + ctx := context.Background() + + seal := func(e *Engine) *Adapter { + t.Helper() + a := NewAdapter(e) + if _, err := a.StartNewSync(ctx, connectorstore.SyncTypeFull, ""); err != nil { + t.Fatalf("StartNewSync: %v", err) + } + putEnt(t, e, ctx, "ent-A") + if err := e.PutGrantRecords(ctx, + makeGrant("", "g1", "ent-A", "alice"), + makeGrant("", "g2", "ent-A", "bob"), + ); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + if err := a.EndSync(ctx); err != nil { + t.Fatalf("EndSync: %v", err) + } + return a + } + + // Digest index on: the entitlement resolves with its grant count. + on, _ := newTestEngine(t) + a := seal(on) + d, found, err := a.GetEntitlementGrantDigest(ctx, canonicalTestEntID("ent-A")) + if err != nil { + t.Fatalf("GetEntitlementGrantDigest: %v", err) + } + if !found { + t.Fatal("expected found for ent-A") + } + if d.Count != 2 { + t.Fatalf("count = %d, want 2", d.Count) + } + if len(d.Hash) == 0 { + t.Fatal("expected non-empty hash") + } + + // Unknown entitlement: not found, no error. + if _, found, err := a.GetEntitlementGrantDigest(ctx, canonicalTestEntID("ent-missing")); err != nil || found { + t.Fatalf("unknown entitlement: found=%v err=%v, want found=false err=nil", found, err) + } + + // Disabled: a real entitlement reports not-found (no digest built). + off, _ := newTestEngine(t, WithGrantDigestIndex(false)) + aOff := seal(off) + if _, found, err := aOff.GetEntitlementGrantDigest(ctx, canonicalTestEntID("ent-A")); err != nil || found { + t.Fatalf("digest off: found=%v err=%v, want found=false err=nil", found, err) + } +} + +// TestAdapterGrantDigestNodes exercises the rollup-node reader: the +// native level is exposed, level 0 returns the root, level == native +// returns the stored leaves (whose counts sum to the total and whose +// hashes XOR to the root), and a finer level falls back to an index +// scan without error. +func TestAdapterGrantDigestNodes(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + a := NewAdapter(e) + if _, err := a.StartNewSync(ctx, connectorstore.SyncTypeFull, ""); err != nil { + t.Fatalf("StartNewSync: %v", err) + } + putEnt(t, e, ctx, "ent-A") + const n = 600 // > digestTargetBucketSize, so the native level is >= 1 + grants := make([]*v3.GrantRecord, 0, n) + for range n { + grants = append(grants, makeGrant("", ksuid.New().String(), "ent-A", ksuid.New().String())) + } + if err := e.PutGrantRecords(ctx, grants...); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + if err := a.EndSync(ctx); err != nil { + t.Fatalf("EndSync: %v", err) + } + + entID := canonicalTestEntID("ent-A") + d, found, err := a.GetEntitlementGrantDigest(ctx, entID) + if err != nil || !found { + t.Fatalf("digest: found=%v err=%v", found, err) + } + if d.Count != n { + t.Fatalf("count = %d, want %d", d.Count, n) + } + if d.Level < 1 { + t.Fatalf("native level = %d, want >= 1 for %d grants", d.Level, n) + } + + // Level 0 → a single root node matching the digest root. + roots, found, err := a.GetEntitlementGrantDigestNodes(ctx, entID, 0) + if err != nil || !found { + t.Fatalf("nodes(0): found=%v err=%v", found, err) + } + if len(roots) != 1 || roots[0].Count != n || !bytes.Equal(roots[0].Hash, d.Hash) { + t.Fatalf("level 0 = %+v, want one root node with count %d and the root hash", roots, n) + } + + // Level == native → the stored leaves: sparse (no zero-count nodes), + // counts sum to the total, hashes XOR to the root. + leaves, found, err := a.GetEntitlementGrantDigestNodes(ctx, entID, d.Level) + if err != nil || !found { + t.Fatalf("nodes(native): found=%v err=%v", found, err) + } + var sum int64 + xor := make([]byte, len(d.Hash)) + for _, nd := range leaves { + if nd.Count == 0 { + t.Fatal("leaf node with count 0 returned; leaves must be sparse") + } + sum += nd.Count + for j := range xor { + xor[j] ^= nd.Hash[j] + } + } + if sum != n { + t.Fatalf("leaf counts sum = %d, want %d", sum, n) + } + if !bytes.Equal(xor, d.Hash) { + t.Fatal("XOR of leaf hashes != root hash") + } + + // Finer than native → index-scan fallback (no error). Same totals: + // counts sum to n and hashes XOR to the root. + finer, found, err := a.GetEntitlementGrantDigestNodes(ctx, entID, d.Level+1) + if err != nil || !found { + t.Fatalf("nodes(native+1): found=%v err=%v, want a scanned result", found, err) + } + sum, xor = 0, make([]byte, len(d.Hash)) + for _, nd := range finer { + sum += nd.Count + for j := range xor { + xor[j] ^= nd.Hash[j] + } + } + if sum != n || !bytes.Equal(xor, d.Hash) { + t.Fatalf("finer-level scan: sum=%d xor-matches-root=%v, want sum %d and matching root", sum, bytes.Equal(xor, d.Hash), n) + } + + // Absurdly fine level → clamped to the hash resolution, still no error. + if _, found, err := a.GetEntitlementGrantDigestNodes(ctx, entID, 999); err != nil || !found { + t.Fatalf("nodes(999): found=%v err=%v, want clamped scan with no error", found, err) + } +} + +// TestAdapterScanGrantBucket exercises the bucket grant scan: level 0 +// scans the whole entitlement, per-bucket scans at the native level +// agree with the rollup node counts and partition the grants, yield can +// stop early, and an unknown entitlement scans nothing without error. +func TestAdapterScanGrantBucket(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + a := NewAdapter(e) + if _, err := a.StartNewSync(ctx, connectorstore.SyncTypeFull, ""); err != nil { + t.Fatalf("StartNewSync: %v", err) + } + putEnt(t, e, ctx, "ent-A") + const n = 600 // > digestTargetBucketSize so the native level is >= 1 + grants := make([]*v3.GrantRecord, 0, n) + for range n { + grants = append(grants, makeGrant("", ksuid.New().String(), "ent-A", ksuid.New().String())) + } + if err := e.PutGrantRecords(ctx, grants...); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + if err := a.EndSync(ctx); err != nil { + t.Fatalf("EndSync: %v", err) + } + + entID := canonicalTestEntID("ent-A") + // Level 0 = whole entitlement → every grant. + total := 0 + if err := a.ScanEntitlementGrantBucket(ctx, entID, connectorstore.GrantDigestBucket{Level: 0}, func(*v2.Grant) bool { + total++ + return true + }); err != nil { + t.Fatalf("scan level 0: %v", err) + } + if total != n { + t.Fatalf("level-0 scan yielded %d grants, want %d", total, n) + } + + // Per native-level bucket: each scan's count matches the rollup node, + // and the buckets partition all the grants. + d, _, err := a.GetEntitlementGrantDigest(ctx, entID) + if err != nil { + t.Fatalf("digest: %v", err) + } + nodes, _, err := a.GetEntitlementGrantDigestNodes(ctx, entID, d.Level) + if err != nil { + t.Fatalf("nodes: %v", err) + } + sum := 0 + for _, nd := range nodes { + c := int64(0) + if err := a.ScanEntitlementGrantBucket(ctx, entID, connectorstore.GrantDigestBucket{Level: d.Level, Index: nd.Index}, func(*v2.Grant) bool { + c++ + return true + }); err != nil { + t.Fatalf("scan bucket %d: %v", nd.Index, err) + } + if c != nd.Count { + t.Fatalf("bucket %d: scanned %d grants, node count says %d", nd.Index, c, nd.Count) + } + sum += int(c) + } + if sum != n { + t.Fatalf("per-bucket scans covered %d grants, want %d", sum, n) + } + + // yield can stop early. + calls := 0 + if err := a.ScanEntitlementGrantBucket(ctx, entID, connectorstore.GrantDigestBucket{Level: 0}, func(*v2.Grant) bool { + calls++ + return false + }); err != nil { + t.Fatalf("scan early-stop: %v", err) + } + if calls != 1 { + t.Fatalf("early stop: yield called %d times, want 1", calls) + } + + // Unknown entitlement → nothing, no error. + missing := 0 + if err := a.ScanEntitlementGrantBucket(ctx, canonicalTestEntID("ent-missing"), connectorstore.GrantDigestBucket{Level: 0}, func(*v2.Grant) bool { + missing++ + return true + }); err != nil || missing != 0 { + t.Fatalf("scan unknown entitlement: count=%d err=%v, want 0/nil", missing, err) + } +} + +// seedEntitlement writes the entitlement record + grants and runs the +// seal-time build (hash index + digests), returning the syncID. +func seedEntitlement(t testing.TB, e *Engine, entID string, grants []*v3.GrantRecord) string { + t.Helper() + ctx := context.Background() + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + putEnt(t, e, ctx, entID) + if err := e.PutGrantRecords(ctx, grants...); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + sealGrantDigests(t, e) + return syncID +} + +// rebuildDigestAtWidth re-derives the hash index + digests from the +// primaries (the seal-style rebuild) and then rebuilds one +// entitlement's digest at a forced leaf-level width, so a test can +// build two digests of different widths over a small grant set. +func rebuildDigestAtWidth(t testing.TB, e *Engine, entID string, widthBits int) { + t.Helper() + sealGrantDigests(t, e) + if err := e.buildPartitionDigestAtWidth(context.Background(), grantDigestSpec, testEntPartition(entID), widthBits); err != nil { + t.Fatalf("buildPartitionDigestAtWidth: %v", err) + } +} + +// seedEntitlementAtWidth is seedEntitlement but forces a specific +// leaf-level width instead of deriving it from the grant count. +func seedEntitlementAtWidth(t testing.TB, e *Engine, entID string, grants []*v3.GrantRecord, widthBits int) string { + t.Helper() + ctx := context.Background() + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + putEnt(t, e, ctx, entID) + if err := e.PutGrantRecords(ctx, grants...); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + rebuildDigestAtWidth(t, e, entID, widthBits) + return syncID +} + +// TestDigestDifferentWidthsComparison builds two digests of different +// widths (4 vs 8 bits) over the SAME entitlement and exercises +// DirtyEntitlementBuckets across them. It validates two things the +// equal-width tests cannot: +// +// - split-independence: identical grant content yields the same root +// hash regardless of digest width, and compares as zero dirty +// buckets; +// - the cross-width merge: after one principal's grant changes, the +// comparison (at compareBits = min(4,8) = 4) localizes the change to +// that principal's width-4 bucket — the finer (width-8) side's +// leaves fold down to width 4 during the scan — and leaves a known +// principal in a different bucket clean. +func TestDigestDifferentWidthsComparison(t *testing.T) { + ctx := context.Background() + + const nPrincipals = 40 + principals := make([]string, nPrincipals) + for i := range principals { + principals[i] = fmt.Sprintf("user-%03d", i) + } + mkGrants := func() []*v3.GrantRecord { + gs := make([]*v3.GrantRecord, 0, nPrincipals) + for i, p := range principals { + gs = append(gs, makeGrant("", fmt.Sprintf("g-%03d", i), "ent-A", p)) + } + return gs + } + + // bucket4 returns the width-4 bucket index for a principal, matching + // how grants are keyed (principal type "user" per makeGrant). + bucket4 := func(principalID string) uint32 { + return bucketOfHash(principalBucketHash("user", principalID), 4).Index + } + + ea, _ := newTestEngine(t) + eb, _ := newTestEngine(t) + seedEntitlementAtWidth(t, ea, "ent-A", mkGrants(), 4) + seedEntitlementAtWidth(t, eb, "ent-A", mkGrants(), 8) + entA := testEntIdentity("ent-A") + + ra, okA, err := ea.GetEntitlementDigestRoot(ctx, entA) + if err != nil || !okA { + t.Fatalf("root A: ok=%v err=%v", okA, err) + } + rb, okB, err := eb.GetEntitlementDigestRoot(ctx, entA) + if err != nil || !okB { + t.Fatalf("root B: ok=%v err=%v", okB, err) + } + if ra.Bits != 4 || rb.Bits != 8 { + t.Fatalf("widths = %d, %d; want 4, 8", ra.Bits, rb.Bits) + } + // Split-independence: identical content -> identical root despite + // different digest widths. + if !bytes.Equal(ra.Hash, rb.Hash) { + t.Fatalf("different-width digests over identical content disagree on root:\n A(w4)=%x\n B(w8)=%x", ra.Hash, rb.Hash) + } + dirty, err := ea.DirtyEntitlementBuckets(ctx, eb, entA) + if err != nil { + t.Fatalf("DirtyEntitlementBuckets (identical): %v", err) + } + if len(dirty) != 0 { + t.Fatalf("identical content across widths: dirty=%d, want 0", len(dirty)) + } + + // Pick the principal to change and a "clean" principal known to sit + // in a different width-4 bucket. + changed := principals[0] + changedBucket := bucket4(changed) + cleanP := "" + for _, p := range principals[1:] { + if bucket4(p) != changedBucket { + cleanP = p + break + } + } + if cleanP == "" { + t.Skip("no principal landed in a different width-4 bucket from the changed one; can't assert localization") + } + + // Mutate the changed principal's grant in B (same identity -> same + // index key, new content hash via an added source), then re-derive + // B's index + digest at width 8 — the seal-style rebuild, since + // mutations never maintain either inline. + g := makeGrantWithSources("", "g-000", "ent-A", changed, "src-ent") + if err := eb.PutGrantRecord(ctx, g); err != nil { + t.Fatalf("PutGrantRecord (mutate): %v", err) + } + rebuildDigestAtWidth(t, eb, "ent-A", 8) + + rb2, _, _ := eb.GetEntitlementDigestRoot(ctx, entA) + if bytes.Equal(ra.Hash, rb2.Hash) { + t.Fatal("mutation did not change B's root") + } + + dirty, err = ea.DirtyEntitlementBuckets(ctx, eb, entA) + if err != nil { + t.Fatalf("DirtyEntitlementBuckets (changed): %v", err) + } + if len(dirty) == 0 { + t.Fatal("changed principal across widths produced no dirty buckets") + } + // Localization: every dirty entry is a width-4 bucket (not the + // whole-entitlement zero bucket). + for _, b := range dirty { + if b.Bits != 4 { + t.Fatalf("dirty bucket bits = %d, want 4 (compareBits); got whole-entitlement or wrong-width bucket", b.Bits) + } + } + // Loading the dirty buckets in B surfaces the changed principal and + // excludes the known-clean principal. + loaded := map[string]bool{} + for _, b := range dirty { + if err := eb.IterateGrantsByEntitlementBucket(ctx, entA, b, func(g *v3.GrantRecord) bool { + loaded[g.GetPrincipal().GetResourceId()] = true + return true + }); err != nil { + t.Fatalf("IterateGrantsByEntitlementBucket: %v", err) + } + } + if !loaded[changed] { + t.Fatalf("dirty buckets did not include the changed principal %q; loaded=%v", changed, loaded) + } + if loaded[cleanP] { + t.Fatalf("dirty buckets wrongly included clean principal %q (different bucket); change was not localized", cleanP) + } +} + +func TestDigestEmptyEntitlementSingleRoot(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + seedEntitlement(t, e, "ent-empty", nil) + + if got := digestNodeCount(t, e); got != 1 { + t.Fatalf("empty entitlement: digest node count = %d, want 1 (root only)", got) + } + root, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-empty")) + if err != nil || !ok { + t.Fatalf("GetEntitlementDigestRoot: ok=%v err=%v", ok, err) + } + if root.Bits != 0 { + t.Fatalf("empty entitlement width = %d, want 0", root.Bits) + } + if root.Count != 0 { + t.Fatalf("empty entitlement count = %d, want 0", root.Count) + } +} + +// TestGrantDigestZeroGrantRootsAtEndSync covers the standalone EndSync +// build with a mix: an entitlement with inline-written grants (the +// deferred marker never arms) and a zero-grant entitlement. Both get +// digests at seal — the zero-grant one a {count: 0} root. +func TestGrantDigestZeroGrantRootsAtEndSync(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + a := NewAdapter(e) + if _, err := a.StartNewSync(ctx, connectorstore.SyncTypeFull, ""); err != nil { + t.Fatalf("StartNewSync: %v", err) + } + putEnt(t, e, ctx, "ent-with") + putEnt(t, e, ctx, "ent-zero") + if err := e.PutGrantRecords(ctx, makeGrant("", "g1", "ent-with", "alice")); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + if e.deferredIdxPending.Load() { + t.Fatal("inline grant writes must not arm the deferred marker (precondition for this test)") + } + if err := a.EndSync(ctx); err != nil { + t.Fatalf("EndSync: %v", err) + } + + d, found, err := a.GetEntitlementGrantDigest(ctx, canonicalTestEntID("ent-with")) + if err != nil || !found { + t.Fatalf("ent-with digest: found=%v err=%v", found, err) + } + if d.Count != 1 { + t.Fatalf("ent-with count = %d, want 1", d.Count) + } + dz, found, err := a.GetEntitlementGrantDigest(ctx, canonicalTestEntID("ent-zero")) + if err != nil || !found { + t.Fatalf("ent-zero digest: found=%v err=%v, want a {count: 0} root (empty, not never-built)", found, err) + } + if dz.Count != 0 || dz.Level != 0 { + t.Fatalf("ent-zero root = count %d level %d, want 0/0", dz.Count, dz.Level) + } +} + +func TestDigestIdenticalGrantsSameRoot(t *testing.T) { + ctx := context.Background() + mk := func() []*v3.GrantRecord { + return []*v3.GrantRecord{ + makeGrant("", "g1", "ent-A", "alice"), + makeGrant("", "g2", "ent-A", "bob"), + makeGrant("", "g3", "ent-A", "carol"), + } + } + ea, _ := newTestEngine(t) + eb, _ := newTestEngine(t) + seedEntitlement(t, ea, "ent-A", mk()) + seedEntitlement(t, eb, "ent-A", mk()) + entA := testEntIdentity("ent-A") + + ra, okA, err := ea.GetEntitlementDigestRoot(ctx, entA) + if err != nil || !okA { + t.Fatalf("root A: ok=%v err=%v", okA, err) + } + rb, okB, err := eb.GetEntitlementDigestRoot(ctx, entA) + if err != nil || !okB { + t.Fatalf("root B: ok=%v err=%v", okB, err) + } + if !bytes.Equal(ra.Hash, rb.Hash) { + t.Fatalf("identical grants produced different roots:\n A=%x\n B=%x", ra.Hash, rb.Hash) + } + dirty, err := ea.DirtyEntitlementBuckets(ctx, eb, entA) + if err != nil { + t.Fatalf("DirtyEntitlementBuckets: %v", err) + } + if len(dirty) != 0 { + t.Fatalf("identical grants: dirty buckets = %d, want 0", len(dirty)) + } +} + +func TestDigestContentChangeDirtyBucket(t *testing.T) { + ctx := context.Background() + // Base set: same in both engines except bob's grant gains a source + // in B. The identity is unchanged, so the index KEY is identical and + // only the content hash (and thus bob's bucket) differs. + baseA := []*v3.GrantRecord{ + makeGrant("", "g1", "ent-A", "alice"), + makeGrant("", "g2", "ent-A", "bob"), + makeGrant("", "g3", "ent-A", "carol"), + } + baseB := []*v3.GrantRecord{ + makeGrant("", "g1", "ent-A", "alice"), + makeGrantWithSources("", "g2", "ent-A", "bob", "src-ent"), + makeGrant("", "g3", "ent-A", "carol"), + } + ea, _ := newTestEngine(t) + eb, _ := newTestEngine(t) + seedEntitlement(t, ea, "ent-A", baseA) + seedEntitlement(t, eb, "ent-A", baseB) + entA := testEntIdentity("ent-A") + + ra, _, _ := ea.GetEntitlementDigestRoot(ctx, entA) + rb, _, _ := eb.GetEntitlementDigestRoot(ctx, entA) + if bytes.Equal(ra.Hash, rb.Hash) { + t.Fatal("content change did not change the root hash") + } + + dirty, err := ea.DirtyEntitlementBuckets(ctx, eb, entA) + if err != nil { + t.Fatalf("DirtyEntitlementBuckets: %v", err) + } + if len(dirty) == 0 { + t.Fatal("content change produced no dirty buckets") + } + + // Loading the dirty buckets in B must surface bob (the changed + // principal). + found := map[string]bool{} + for _, b := range dirty { + if err := eb.IterateGrantsByEntitlementBucket(ctx, entA, b, func(g *v3.GrantRecord) bool { + found[g.GetPrincipal().GetResourceId()] = true + return true + }); err != nil { + t.Fatalf("IterateGrantsByEntitlementBucket: %v", err) + } + } + if !found["bob"] { + t.Fatalf("dirty buckets did not include the changed principal bob; found=%v", found) + } +} + +func TestDigestAddedGrantDirtyBucket(t *testing.T) { + ctx := context.Background() + baseA := []*v3.GrantRecord{ + makeGrant("", "g1", "ent-A", "alice"), + makeGrant("", "g2", "ent-A", "bob"), + } + baseB := []*v3.GrantRecord{ + makeGrant("", "g1", "ent-A", "alice"), + makeGrant("", "g2", "ent-A", "bob"), + makeGrant("", "g3", "ent-A", "dave"), // added in B + } + ea, _ := newTestEngine(t) + eb, _ := newTestEngine(t) + seedEntitlement(t, ea, "ent-A", baseA) + seedEntitlement(t, eb, "ent-A", baseB) + entA := testEntIdentity("ent-A") + + dirty, err := ea.DirtyEntitlementBuckets(ctx, eb, entA) + if err != nil { + t.Fatalf("DirtyEntitlementBuckets: %v", err) + } + if len(dirty) == 0 { + t.Fatal("added grant produced no dirty buckets") + } + found := map[string]bool{} + for _, b := range dirty { + if err := eb.IterateGrantsByEntitlementBucket(ctx, entA, b, func(g *v3.GrantRecord) bool { + found[g.GetPrincipal().GetResourceId()] = true + return true + }); err != nil { + t.Fatalf("IterateGrantsByEntitlementBucket: %v", err) + } + } + if !found["dave"] { + t.Fatalf("dirty buckets did not include the added principal dave; found=%v", found) + } +} + +func TestDigestVariableWidth(t *testing.T) { + ctx := context.Background() + + // Small entitlement: under one target bucket -> width 0, single node. + small := make([]*v3.GrantRecord, 0, 10) + for range 10 { + small = append(small, makeGrant("", ksuid.New().String(), "ent-small", ksuid.New().String())) + } + es, _ := newTestEngine(t) + seedEntitlement(t, es, "ent-small", small) + rootS, ok, err := es.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-small")) + if err != nil || !ok { + t.Fatalf("small root: ok=%v err=%v", ok, err) + } + if rootS.Bits != 0 { + t.Fatalf("small entitlement width = %d, want 0", rootS.Bits) + } + if rootS.Count != 10 { + t.Fatalf("small entitlement count = %d, want 10", rootS.Count) + } + if got := digestNodeCount(t, es); got != 1 { + t.Fatalf("small entitlement node count = %d, want 1", got) + } + + // Large entitlement: well over the target bucket size -> the width + // grows one bit at a time, and the digest gains leaf nodes beyond + // the root. + const n = digestTargetBucketSize*3 + 7 + large := make([]*v3.GrantRecord, 0, n) + for range n { + large = append(large, makeGrant("", ksuid.New().String(), "ent-large", ksuid.New().String())) + } + el, _ := newTestEngine(t) + seedEntitlement(t, el, "ent-large", large) + rootL, ok, err := el.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-large")) + if err != nil || !ok { + t.Fatalf("large root: ok=%v err=%v", ok, err) + } + if want := chooseDigestWidth(n); rootL.Bits != want { + t.Fatalf("large entitlement width = %d, want %d", rootL.Bits, want) + } + if rootL.Count != int64(n) { + t.Fatalf("large entitlement count = %d, want %d", rootL.Count, n) + } + // root + at least 2 leaves (width>=1 over n grants spreads across + // multiple buckets). + if got := digestNodeCount(t, el); got < 3 { + t.Fatalf("large entitlement node count = %d, want >= 3 (root + leaves)", got) + } + // Capacity invariant: 2^width buckets at the target size must cover + // the count, and width-1 must not (else the width is too large). + if int64(1)< 0 && int64(1)<<(rootL.Bits-1)*digestTargetBucketSize >= n { + t.Fatalf("width %d is one bit wider than the count %d needs", rootL.Bits, n) + } +} + +// TestHashIndexIsHashOrdered verifies the index iterates in +// hash(principal) order: the embedded bucket-hash region is +// non-decreasing across the entitlement's index range. +func TestHashIndexIsHashOrdered(t *testing.T) { + e, _ := newTestEngine(t) + grants := make([]*v3.GrantRecord, 0, 200) + for range 200 { + grants = append(grants, makeGrant("", ksuid.New().String(), "ent-A", ksuid.New().String())) + } + seedEntitlement(t, e, "ent-A", grants) + + entPrefix := encodeGrantByEntPrincHashEntPrefix(testEntPartition("ent-A")) + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: entPrefix, UpperBound: upperBoundOf(entPrefix)}) + if err != nil { + t.Fatal(err) + } + defer iter.Close() + var prev uint16 + havePrev := false + count := 0 + for iter.First(); iter.Valid(); iter.Next() { + _, bucket, ok := splitGrantHashIndexKey(iter.Key()) + if !ok { + t.Fatal("failed to split index key") + } + if havePrev && bucket < prev { + t.Fatalf("index not hash-ordered: %x < %x", bucket, prev) + } + prev, havePrev = bucket, true + count++ + } + if count != 200 { + t.Fatalf("hash index entry count = %d, want 200", count) + } +} + +// dumpDigestNodes snapshots every digest node key/value. Used to +// byte-compare independently built digests. +func dumpDigestNodes(t testing.TB, e *Engine) map[string][]byte { + t.Helper() + iter, err := e.db.NewIter(&pebble.IterOptions{ + LowerBound: DigestLowerBound(), + UpperBound: DigestUpperBound(), + }) + if err != nil { + t.Fatalf("NewIter: %v", err) + } + defer iter.Close() + out := map[string][]byte{} + for iter.First(); iter.Valid(); iter.Next() { + out[string(iter.Key())] = append([]byte(nil), iter.Value()...) + } + if err := iter.Error(); err != nil { + t.Fatalf("iter: %v", err) + } + return out +} + +// requireSameDigestNodes fails with a per-key diff when two node +// snapshots differ. +func requireSameDigestNodes(t *testing.T, got, want map[string][]byte) { + t.Helper() + for k, wv := range want { + gv, ok := got[k] + if !ok { + t.Errorf("missing node %x (want %x)", k, wv) + continue + } + if !bytes.Equal(gv, wv) { + t.Errorf("node %x differs:\n got %x\nwant %x", k, gv, wv) + } + } + for k, gv := range got { + if _, ok := want[k]; !ok { + t.Errorf("extra node %x = %x", k, gv) + } + } +} + +// TestFusedFoldMatchesPartitionRebuild pins the seal-time streaming +// fold (grantDigestFold, which accumulates at max width and folds down +// at each partition boundary) against the independent single-partition +// rebuild (buildPartitionDigestAtWidth → foldPartitionNodes, a direct +// index-range fold): both must produce byte-identical nodes for every +// partition, including the zero-grant root. +func TestFusedFoldMatchesPartitionRebuild(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + counts := map[string]int64{"ent-big": 600, "ent-small": 10, "ent-zero": 0} + for entID, n := range counts { + putEnt(t, e, ctx, entID) + grants := make([]*v3.GrantRecord, 0, n) + for range n { + grants = append(grants, makeGrant("", ksuid.New().String(), entID, ksuid.New().String())) + } + if err := e.PutGrantRecords(ctx, grants...); err != nil { + t.Fatalf("PutGrantRecords(%s): %v", entID, err) + } + } + sealGrantDigests(t, e) + fused := dumpDigestNodes(t, e) + + for entID, n := range counts { + if err := e.buildPartitionDigestAtWidth(ctx, grantDigestSpec, testEntPartition(entID), chooseDigestWidth(n)); err != nil { + t.Fatalf("buildPartitionDigestAtWidth(%s): %v", entID, err) + } + } + requireSameDigestNodes(t, dumpDigestNodes(t, e), fused) +} + +// TestDigestLeafFoldConsistent verifies the leaf-level build and the +// fold machinery the comparison rests on: every stored leaf is +// non-empty, the root is exactly the XOR (and count-sum) of the leaves, +// no nodes exist beyond root + leaves, folding the leaf level to a +// coarser width matches a manual regrouping, and a stored leaf +// byte-matches the authoritative on-demand fold of its bucket. +func TestDigestLeafFoldConsistent(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + const n = 60 + grants := make([]*v3.GrantRecord, 0, n) + for i := range n { + grants = append(grants, makeGrant("", fmt.Sprintf("g-%03d", i), "ent-A", fmt.Sprintf("user-%03d", i))) + } + seedEntitlementAtWidth(t, e, "ent-A", grants, 8) + entA := testEntIdentity("ent-A") + partition := testEntPartition("ent-A") + + root, ok, err := e.GetEntitlementDigestRoot(ctx, entA) + if err != nil || !ok { + t.Fatalf("root: ok=%v err=%v", ok, err) + } + if root.Bits != 8 || root.Count != n { + t.Fatalf("root width=%d count=%d, want 8, %d", root.Bits, root.Count, n) + } + + // Folding at the build width returns the stored leaves one-to-one. + leaves, err := e.foldedLeafBuckets(ctx, grantDigestSpec, partition, 8) + if err != nil { + t.Fatal(err) + } + if len(leaves) == 0 { + t.Fatal("no leaf nodes stored") + } + var ( + rootXor [hashLen]byte + rootCount int64 + ) + for _, l := range leaves { + if l.count < 1 { + t.Fatalf("leaf %d stored with count %d; empty leaves must not be materialized", l.idx, l.count) + } + xorInto(rootXor[:], l.digest[:]) + rootCount += l.count + } + if rootCount != root.Count || !bytes.Equal(rootXor[:], root.Hash) { + t.Fatalf("root != fold of leaves: count %d vs %d", root.Count, rootCount) + } + + // Exactly root + leaves — nothing else in the keyspace. + if got, want := digestNodeCount(t, e), 1+len(leaves); got != want { + t.Fatalf("total node count = %d, want %d (root + leaves only)", got, want) + } + + // Folding to a coarser width matches a manual regroup of the + // build-width leaves. + leaves4, err := e.foldedLeafBuckets(ctx, grantDigestSpec, partition, 4) + if err != nil { + t.Fatal(err) + } + manual := map[uint32]*foldedBucket{} + var order []uint32 + for _, l := range leaves { + idx := l.idx >> 4 + fb, ok := manual[idx] + if !ok { + fb = &foldedBucket{idx: idx} + manual[idx] = fb + order = append(order, idx) + } + fb.count += l.count + xorInto(fb.digest[:], l.digest[:]) + } + if len(leaves4) != len(order) { + t.Fatalf("fold to width 4: %d buckets, want %d", len(leaves4), len(order)) + } + for i, idx := range order { + got, want := leaves4[i], manual[idx] + if got.idx != want.idx || got.count != want.count || got.digest != want.digest { + t.Fatalf("folded bucket %d mismatch: got {%d %d %x}, want {%d %d %x}", + i, got.idx, got.count, got.digest, want.idx, want.count, want.digest) + } + } + + // A stored leaf is a cache of the authoritative fold. + b := DigestBucket{Index: leaves[0].idx, Bits: 8} + h, c, err := e.ComputeEntitlementBucketDigest(ctx, entA, b) + if err != nil { + t.Fatal(err) + } + lc, ld, present, err := e.getDigestLeaf(grantDigestSpec, partition, b.leafKeyPrefix()) + if err != nil || !present { + t.Fatalf("leaf %d: present=%v err=%v", b.Index, present, err) + } + if c != lc || !bytes.Equal(h, ld) { + t.Fatalf("stored leaf disagrees with ComputeEntitlementBucketDigest: count %d vs %d", lc, c) + } +} + +// TestDigestRebuildClearsStaleNodes verifies the leading DeleteRange in +// the build: a rebuild at a narrower width must remove the prior +// build's finer-grained leaves, or the comparison merge scan would read +// them. +func TestDigestRebuildClearsStaleNodes(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + grants := make([]*v3.GrantRecord, 0, 40) + for i := range 40 { + grants = append(grants, makeGrant("", fmt.Sprintf("g-%03d", i), "ent-A", fmt.Sprintf("user-%03d", i))) + } + seedEntitlementAtWidth(t, e, "ent-A", grants, 8) + + before := rawLeafPrefixes(t, e, "ent-A") + if len(before) == 0 { + t.Fatal("width-8 build produced no leaves") + } + rootBefore, _, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-A")) + if err != nil { + t.Fatal(err) + } + + if err := e.buildPartitionDigestAtWidth(ctx, grantDigestSpec, testEntPartition("ent-A"), 4); err != nil { + t.Fatalf("rebuild at width 4: %v", err) + } + + rootAfter, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-A")) + if err != nil || !ok { + t.Fatalf("root after rebuild: ok=%v err=%v", ok, err) + } + if rootAfter.Bits != 4 { + t.Fatalf("root width after rebuild = %d, want 4", rootAfter.Bits) + } + // Split-independence: same content, same root digest. + if !bytes.Equal(rootBefore.Hash, rootAfter.Hash) || rootBefore.Count != rootAfter.Count { + t.Fatal("rebuild at different width changed the root digest/count over identical content") + } + // Every surviving leaf prefix must be width-4 aligned (low 12 bits + // of the left-aligned prefix zero) — a width-8 leaf that escaped the + // range-clear would fail this. + after := rawLeafPrefixes(t, e, "ent-A") + if len(after) == 0 || len(after) > 16 { + t.Fatalf("width-4 rebuild stored %d leaves, want 1..16", len(after)) + } + for _, p := range after { + if lv := binary.BigEndian.Uint16(p); lv&0x0FFF != 0 { + t.Fatalf("stale leaf prefix %x survived the width-4 rebuild", p) + } + } +} + +// TestDigestDeleteInvalidatesAndResealRecalculates pins the +// present-means-exact lifecycle around post-seal mutation: +// DeleteGrantRecord drops the touched entitlement's digest partition +// AND its hash-index range (both are seal-derived and equally stale), +// so the digest reads as "missing — recalculate"; a seal-style rebuild +// then byte-matches a from-scratch build over the surviving grants. +func TestDigestDeleteInvalidatesAndResealRecalculates(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + grants := make([]*v3.GrantRecord, 0, 30) + for i := range 30 { + grants = append(grants, makeGrant("", fmt.Sprintf("g-%03d", i), "ent-A", fmt.Sprintf("user-%03d", i))) + } + seedEntitlement(t, e, "ent-A", grants) + entA := testEntIdentity("ent-A") + + if _, ok, err := e.GetEntitlementDigestRoot(ctx, entA); err != nil || !ok { + t.Fatalf("sealed root: ok=%v err=%v", ok, err) + } + if rows := entHashIndexRowCount(t, e, "ent-A"); rows != 30 { + t.Fatalf("sealed hash index rows = %d, want 30", rows) + } + + // Post-seal delete: the digest must be dropped, not silently stale. + // v1 invalidation drops the whole entitlement's hash-index range too. + if err := e.DeleteGrantRecord(ctx, "g-008"); err != nil { + t.Fatalf("DeleteGrantRecord: %v", err) + } + if _, ok, err := e.GetEntitlementDigestRoot(ctx, entA); err != nil || ok { + t.Fatalf("root after delete: ok=%v err=%v, want missing (invalidated)", ok, err) + } + if got := digestNodeCount(t, e); got != 0 { + t.Fatalf("digest nodes after delete = %d, want 0 (partition dropped)", got) + } + if got := entHashIndexRowCount(t, e, "ent-A"); got != 0 { + t.Fatalf("hash index rows after delete = %d, want 0 (stale index range dropped)", got) + } + + // Reseal: the digest is recalculated from the surviving primaries + // and byte-matches an independent from-scratch build. + sealGrantDigests(t, e) + root, ok, err := e.GetEntitlementDigestRoot(ctx, entA) + if err != nil || !ok { + t.Fatalf("root after reseal: ok=%v err=%v", ok, err) + } + if root.Count != 29 { + t.Fatalf("resealed root count = %d, want 29", root.Count) + } + resealed := dumpDigestNodes(t, e) + + fresh, _ := newTestEngine(t) + survivors := make([]*v3.GrantRecord, 0, 29) + for i := range 30 { + if i == 8 { + continue + } + survivors = append(survivors, makeGrant("", fmt.Sprintf("g-%03d", i), "ent-A", fmt.Sprintf("user-%03d", i))) + } + seedEntitlement(t, fresh, "ent-A", survivors) + requireSameDigestNodes(t, resealed, dumpDigestNodes(t, fresh)) +} + +// TestDigestPutInvalidatesOnlyTouchedPartition: a post-seal grant WRITE +// (not just delete) invalidates the touched entitlement's digest state +// while other entitlements' digests survive intact. +func TestDigestPutInvalidatesOnlyTouchedPartition(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + putEnt(t, e, ctx, "ent-A") + putEnt(t, e, ctx, "ent-B") + if err := e.PutGrantRecords(ctx, + makeGrant("", "ga1", "ent-A", "alice"), + makeGrant("", "gb1", "ent-B", "bob"), + ); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + sealGrantDigests(t, e) + + if err := e.PutGrantRecord(ctx, makeGrant("", "ga2", "ent-A", "dave")); err != nil { + t.Fatalf("PutGrantRecord (post-seal): %v", err) + } + if _, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-A")); err != nil || ok { + t.Fatalf("ent-A root after post-seal write: ok=%v err=%v, want missing", ok, err) + } + if got := entHashIndexRowCount(t, e, "ent-A"); got != 0 { + t.Fatalf("ent-A hash index rows after post-seal write = %d, want 0", got) + } + rootB, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-B")) + if err != nil || !ok { + t.Fatalf("ent-B root: ok=%v err=%v, want intact", ok, err) + } + if rootB.Count != 1 { + t.Fatalf("ent-B root count = %d, want 1", rootB.Count) + } + if got := entHashIndexRowCount(t, e, "ent-B"); got != 1 { + t.Fatalf("ent-B hash index rows = %d, want 1 (untouched)", got) + } +} + +// TestSealRebuildDropsStaleIndexRows verifies the seal-time index +// derivation is a full re-derivation, not an append: rows for grants +// that changed principal (or were removed) since the last seal do not +// survive a reseal, because the build excises the whole index range. +func TestSealRebuildDropsStaleIndexRows(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + seedEntitlement(t, e, "ent-A", []*v3.GrantRecord{ + makeGrant("", "g1", "ent-A", "alice"), + makeGrant("", "g2", "ent-A", "bob"), + }) + + // Move g2 to a different principal (a new identity row + a delete of + // the old one, addressed by refs — the identity scheme's exact + // delete) and reseal. The old (bob) row must be gone and the digest + // must reflect the new content. + if err := e.PutGrantRecord(ctx, makeGrant("", "g2", "ent-A", "carol")); err != nil { + t.Fatalf("PutGrantRecord: %v", err) + } + if err := e.DeleteGrantByIdentityRefs(ctx, makeGrant("", "g2", "ent-A", "bob")); err != nil { + t.Fatalf("DeleteGrantByIdentityRefs: %v", err) + } + sealGrantDigests(t, e) + + entPrefix := encodeGrantByEntPrincHashEntPrefix(testEntPartition("ent-A")) + principals := map[string]bool{} + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: entPrefix, UpperBound: upperBoundOf(entPrefix)}) + if err != nil { + t.Fatal(err) + } + defer iter.Close() + for iter.First(); iter.Valid(); iter.Next() { + priKey, ok := grantPrimaryKeyFromHashIndexKey(nil, iter.Key()) + if !ok { + t.Fatal("failed to reconstruct primary key from index key") + } + id, ok := decodeGrantIdentityKey(priKey) + if !ok { + t.Fatal("failed to decode reconstructed primary key") + } + principals[id.principalID] = true + } + if principals["bob"] || !principals["carol"] || !principals["alice"] || len(principals) != 2 { + t.Fatalf("index principals after reseal = %v, want {alice, carol}", principals) + } + root, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-A")) + if err != nil || !ok { + t.Fatalf("root: ok=%v err=%v", ok, err) + } + if root.Count != 2 { + t.Fatalf("root count = %d, want 2", root.Count) + } +} + +// TestGrantDigestSpillMerge forces the digest build through the +// multi-run spill path: a tiny chunk size makes every few rows cut a +// sorted run file, so the index SST and the digest fold come out of the +// k-way merge rather than a single in-memory chunk. The merged result +// must byte-equal a single-chunk build — same rows, same hash-major +// order (bulkSSTWriter rejects ordering violations outright) — and the +// digests folded over it must match too. +func TestGrantDigestSpillMerge(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + // One big entitlement that will span many tiny runs, plus small + // ones that each fit inside a single run. + putEnt(t, e, ctx, "ent-big") + putEnt(t, e, ctx, "ent-small-1") + putEnt(t, e, ctx, "ent-small-2") + var grants []*v3.GrantRecord + for i := range 300 { + grants = append(grants, makeGrant("", fmt.Sprintf("g-big-%03d", i), "ent-big", fmt.Sprintf("user-%03d", i))) + } + grants = append(grants, + makeGrant("", "g-s1", "ent-small-1", "alice"), + makeGrant("", "g-s2", "ent-small-2", "bob"), + ) + if err := e.PutGrantRecords(ctx, grants...); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + + dumpIndex := func() map[string][]byte { + t.Helper() + out := map[string][]byte{} + iter, err := e.db.NewIter(&pebble.IterOptions{ + LowerBound: GrantByEntPrincHashLowerBound(), + UpperBound: GrantByEntPrincHashUpperBound(), + }) + if err != nil { + t.Fatal(err) + } + defer iter.Close() + for iter.First(); iter.Valid(); iter.Next() { + out[string(iter.Key())] = append([]byte(nil), iter.Value()...) + } + if err := iter.Error(); err != nil { + t.Fatal(err) + } + return out + } + + // buildChunked runs the scan + spill + merge/fold/ingest with a + // forced chunk size, through the same buildGrantDigestsFromSpill the + // production seal uses. + buildChunked := func(chunkBytes int) { + t.Helper() + dir := t.TempDir() + sem := make(chan struct{}, 2) + sorter := newSpillSorter(dir, "hash-chunked", sem, chunkBytes) + if err := e.withWrite(func() error { + iter, err := e.db.NewIter(&pebble.IterOptions{ + LowerBound: GrantLowerBound(), + UpperBound: GrantUpperBound(), + }) + if err != nil { + return err + } + var scratch grantHashRowScratch + for iter.First(); iter.Valid(); iter.Next() { + if err := appendGrantHashIndexRow(sorter, iter.Key(), iter.Value(), &scratch); err != nil { + _ = iter.Close() + return err + } + } + if err := iter.Close(); err != nil { + return err + } + return e.buildGrantDigestsFromSpill(ctx, dir, sorter) + }); err != nil { + t.Fatalf("chunked digest build (chunk=%d): %v", chunkBytes, err) + } + } + + // Tiny 512-byte chunks: ~5 rows per run, so ent-big spans dozens of + // runs and reassembles during the merge. + buildChunked(512) + spilled := dumpIndex() + spilledNodes := dumpDigestNodes(t, e) + + // Single-chunk build (1MiB holds all ~300 rows comfortably). + buildChunked(1 << 20) + inMemory := dumpIndex() + memNodes := dumpDigestNodes(t, e) + + if len(spilled) != len(grants) { + t.Fatalf("spilled index rows = %d, want %d", len(spilled), len(grants)) + } + for k, v := range inMemory { + sv, ok := spilled[k] + if !ok { + t.Fatalf("row missing from spilled index: %x", k) + } + if !bytes.Equal(sv, v) { + t.Fatalf("row %x differs: spilled %x, in-memory %x", k, sv, v) + } + } + if len(spilled) != len(inMemory) { + t.Fatalf("row counts differ: spilled %d, in-memory %d", len(spilled), len(inMemory)) + } + requireSameDigestNodes(t, spilledNodes, memNodes) + + root, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-big")) + if err != nil || !ok { + t.Fatalf("root: ok=%v err=%v", ok, err) + } + if root.Count != 300 { + t.Fatalf("ent-big root count = %d, want 300", root.Count) + } +} + +// TestDigestMissingRootWholeDirty: a missing root means "digest never +// built (or invalidated)" — never "no grants", and never something to +// silently derive from an index whose presence can't be verified. The +// comparison must report the whole entitlement dirty, even when the +// underlying content is identical: correctness comes from re-reading, +// not from trusting an unverifiable shortcut. +func TestDigestMissingRootWholeDirty(t *testing.T) { + ctx := context.Background() + mk := func() []*v3.GrantRecord { + return []*v3.GrantRecord{ + makeGrant("", "g1", "ent-A", "alice"), + makeGrant("", "g2", "ent-A", "bob"), + makeGrant("", "g3", "ent-A", "carol"), + } + } + ea, _ := newTestEngine(t) + seedEntitlement(t, ea, "ent-A", mk()) + entA := testEntIdentity("ent-A") + + // B holds the same grants but never builds a digest. + eb, _ := newTestEngine(t) + syncB := ksuid.New().String() + if err := eb.SetCurrentSync(syncB); err != nil { + t.Fatal(err) + } + putEnt(t, eb, ctx, "ent-A") + for _, g := range mk() { + if err := eb.PutGrantRecord(ctx, g); err != nil { + t.Fatal(err) + } + } + if _, ok, err := eb.GetEntitlementDigestRoot(ctx, entA); err != nil || ok { + t.Fatalf("B unexpectedly has a root: ok=%v err=%v", ok, err) + } + + for name, dirtyFn := range map[string]func() ([]DigestBucket, error){ + "A vs B": func() ([]DigestBucket, error) { return ea.DirtyEntitlementBuckets(ctx, eb, entA) }, + "B vs A": func() ([]DigestBucket, error) { return eb.DirtyEntitlementBuckets(ctx, ea, entA) }, + } { + dirty, err := dirtyFn() + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if len(dirty) != 1 || dirty[0].Bits != 0 { + t.Fatalf("%s: unbuilt digest on one side: dirty=%v, want one whole-entitlement bucket", name, dirty) + } + } +} diff --git a/pkg/dotc1z/engine/pebble/engine.go b/pkg/dotc1z/engine/pebble/engine.go index fccb73db3..757a0dfe0 100644 --- a/pkg/dotc1z/engine/pebble/engine.go +++ b/pkg/dotc1z/engine/pebble/engine.go @@ -84,6 +84,13 @@ type Engine struct { // as one sorted SST at EndSync — see BuildDeferredGrantIndexes). deferredIdxPending atomic.Bool + // grantDigestsPresent reports whether the digest keyspace holds any + // nodes — i.e. whether a grant mutation must invalidate the touched + // entitlement's digest + hash-index ranges + // (stageGrantDigestInvalidation). Probed once at Open, set by the + // seal-time build, cleared by ResetForNewSync and the Drop* paths. + grantDigestsPresent atomic.Bool + // synthLayer is the open wave-scoped layer session, if any (see // BeginSynthesizedGrantLayer). Single producer: the expansion driver // opens/adds/finishes sessions strictly sequentially. synthLayerMu @@ -212,6 +219,12 @@ func Open(ctx context.Context, dir string, opts ...Option) (*Engine, error) { _ = e.Close() return nil, err } + // Arm the mutation-path digest invalidation iff the file actually + // holds digest nodes (one bounded seek; see grant_digest.go). + if err := e.probeGrantDigestsPresent(); err != nil { + _ = e.Close() + return nil, err + } // Run secondary-index migrations before returning. Migrations // are skipped for read-only opens (the on-disk file is // immutable, so we'd error out trying to backfill). @@ -389,6 +402,11 @@ func (e *Engine) IsFreshSync() bool { return e.freshSync } +// GrantDigestIndexEnabled reports whether the seal-time deferred pass +// builds the by_entitlement_principal_hash index and grant digests. +// See WithGrantDigestIndex. +func (e *Engine) GrantDigestIndexEnabled() bool { return e.opts.grantDigestIndex } + // takeFreshGrantsEmpty / takeFreshResourcesEmpty return true // exactly once per fresh sync, for the first PutXxxRecords call // of that type after diff --git a/pkg/dotc1z/engine/pebble/grant_digest.go b/pkg/dotc1z/engine/pebble/grant_digest.go new file mode 100644 index 000000000..8be63ecfa --- /dev/null +++ b/pkg/dotc1z/engine/pebble/grant_digest.go @@ -0,0 +1,418 @@ +package pebble + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + + "github.com/cespare/xxhash/v2" + "github.com/cockroachdb/pebble/v2" + + v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3" + "github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/codec" +) + +// Grant instantiation of the digest core (digest.go): the per- +// entitlement grant digest, folded over the +// by_entitlement_principal_hash index. +// +// partition = the entitlement's encoded primary-key tail +// (see the partition convention in keys.go) +// bucket hash = grantPrincipalBucketHash (identity of the principal) +// content hash = grantContentHash64 (the membership edge) +// +// This answers "does this entitlement have exactly the same grants as +// some other sync/file?" with a single root read, and localizes any +// difference to principal-hash buckets so a diff driver loads only +// those grants. +// +// Everything on this path works on the ENCODED key bytes: the index +// key is spliced out of the grant primary key, and both hashes are +// computed over encoded segment bytes — no decode, no re-encode, no +// proto unmarshal. The encoded tuple form is injective and its escape +// is order-preserving, so hashing encoded bytes is exactly as +// collision-free as hashing the decoded fields with a canonical +// framing, while letting the seal-time build run allocation-free. + +// grantDigestSpec wires the grant hash index into the digest core. The +// index's key layout (see encodeGrantByEntPrincHashEntPrefix) satisfies +// the digestIndexSpec shape contract: partition prefix, then the raw +// digestBucketHashLen-byte bucket hash, then the principal tail; the +// value is the grant content hash. +var grantDigestSpec = digestIndexSpec{ + indexID: idxGrantByEntitlementPrincipalHash, + partitionPrefix: encodeGrantByEntPrincHashEntPrefix, +} + +// digestPartitionForEntitlement returns the digest partition for an +// entitlement identity: its encoded primary-key tail as a string (see +// the partition convention in keys.go). +func digestPartitionForEntitlement(id entitlementIdentity) string { + return string(appendEntitlementIdentityTail(make([]byte, 0, 96), id)) +} + +// --- Hashes (on-disk ABI) --- +// +// ABI: the two hash definitions below are part of the stored format. +// Two SDK builds must hash identical grants identically or the digest +// comparison reads "everything differs"; changing either input framing +// requires an index-migration bump (index_migrations.go). + +// grantPrincipalBucketHash64 is the bucket address for a principal: +// xxHash64 over the ENCODED principal segments +// +// esc(principal_rt) | 0x00 | esc(principal_id) +// +// — byte-identical to the principal region of the grant's primary key, +// which is what the seal-time build actually hashes (a raw sub-slice, +// no re-encode). Identity only — never the principal's full object — +// so the address is stable across syncs even when the principal's +// attributes change. Only the top digestBucketHashLen bytes of the +// big-endian hash are stored in index keys (the bucket-selecting +// bits). ABI. +func grantPrincipalBucketHash64(encodedPrincipalSegments []byte) uint64 { + return xxhash.Sum64(encodedPrincipalSegments) +} + +// principalBucketHash is the from-identity form of the bucket hash: +// the stored digestBucketHashLen key bytes for a principal given its +// decoded identity. Encodes the segments exactly as the primary grant +// key does, then hashes — so it MUST agree with hashing the spliced +// key region (pinned by TestGrantDigestSpliceMatchesEncode). Returns a +// fresh slice. +func principalBucketHash(principalRT, principalID string) []byte { + enc := codec.AppendTupleStrings(make([]byte, 0, 64), principalRT, principalID) + var full [8]byte + binary.BigEndian.PutUint64(full[:], grantPrincipalBucketHash64(enc)) + out := make([]byte, digestBucketHashLen) + copy(out, full[:]) + return out +} + +// grantContentHash64 is the canonical content hash of a grant — the +// value stored in the hash index and the unit the grant digest folds. +// +// ABI: "the same grant" is defined as +// +// xxHash64( primaryKeyTail ‖ ( 0x00 ‖ esc(source_id) )* ) +// +// where primaryKeyTail is the grant's encoded primary-key tail (the +// 6-segment identity tuple ent_rt|ent_rid|ent_flag|ent_tail|p_rt|p_id, +// escaped and separator-delimited exactly as stored) and the source +// ids — the keys of the grant's sources map, its expansion +// provenance — are appended as additional tuple segments in ascending +// byte order (sortedSourceKeys must already be sorted; the escape is +// order-preserving so raw order == encoded order). +// +// The field set deliberately covers the membership EDGE (the identity +// tuple) plus the grant's source-entitlement set, and deliberately +// EXCLUDES everything sync-relative or transient — external_id (not +// identity under the injective-key scheme; the same edge keeps its +// hash when a connector changes its id grammar), discovered_at, +// needs_expansion, expansion state, and annotations — none of which +// change "which principal holds which entitlement". The source map +// VALUES (GrantSourceRecord) are not folded in v1 — only the set of +// source ids, which is the membership-composition signal. +// +// This is a hand-rolled framing, NOT proto marshal: deterministic-proto +// output is not canonical across protobuf library versions, which +// would make two files written by different SDK builds hash identical +// grants differently. The tuple framing is injective: every segment is +// escaped and separator-delimited, and the identity is a fixed six +// segments, so no source list can alias a different identity split (a +// naive 0x00-joined concatenation would collide e.g. sources +// ["a","b"] vs ["a\x00b"]). +// +// tuple is a caller-reused scratch buffer, returned grown for reuse. +func grantContentHash64(tuple, primaryKeyTail []byte, sortedSourceKeys [][]byte) (uint64, []byte) { + if len(sortedSourceKeys) == 0 { + // Common case: no sources — hash the tail bytes in place. + return xxhash.Sum64(primaryKeyTail), tuple + } + tuple = append(tuple[:0], primaryKeyTail...) + for _, k := range sortedSourceKeys { + tuple = codec.AppendTupleSeparator(tuple) + tuple = codec.AppendTupleBytes(tuple, k) + } + return xxhash.Sum64(tuple), tuple +} + +// grantContentHashForRecord is the from-record form of the content +// hash: encodes the grant's identity tuple and sorts its source keys, +// then delegates to grantContentHash64. The seal-time build never uses +// this (it splices key bytes and raw-scans the value); it exists for +// readers, tests, and any future repair path, and is pinned against +// the splice form by TestGrantDigestSpliceMatchesEncode. +func grantContentHashForRecord(r *v3.GrantRecord) ([]byte, error) { + id, err := grantIdentityFromRecord(r) + if err != nil { + return nil, err + } + key := encodeGrantIdentityKey(id) + srcs := make([][]byte, 0, len(r.GetSources())) + for k := range r.GetSources() { + srcs = append(srcs, []byte(k)) + } + sortByteSlices(srcs) + h, _ := grantContentHash64(nil, key[grantPrimaryKeyPrefixLen:], srcs) + out := make([]byte, hashLen) + binary.BigEndian.PutUint64(out, h) + return out, nil +} + +// sortByteSlices sorts byte slices ascending (bytes.Compare order). +func sortByteSlices(s [][]byte) { + // Small-n insertion sort: source sets are tiny (usually 0–4). + for i := 1; i < len(s); i++ { + for j := i; j > 0 && bytes.Compare(s[j], s[j-1]) < 0; j-- { + s[j], s[j-1] = s[j-1], s[j] + } + } +} + +// --- Key splices --- + +// grantPrimaryKeyPrefixLen is the byte length of the grant primary-key +// header: versionV3 | typeGrant | separator. +const grantPrimaryKeyPrefixLen = 3 + +// grantHashIndexKeyPrefixLen is the byte length of the hash-index key +// header: versionV3 | typeIndex | idxGrantByEntitlementPrincipalHash | +// separator. +const grantHashIndexKeyPrefixLen = 4 + +// splitGrantPrimaryKey locates the partition/principal boundary of a +// grant primary key: the byte offset of the 4th tuple separator in the +// key (the one that ends the entitlement's 4 identity segments). It +// also validates the overall 6-segment shape, exactly like +// appendGrantByPrincipalKeyFromPrimary. Returns ok=false for keys that +// are not well-formed 6-segment grant identities. +// +// With the returned sep4 in hand every region is a plain sub-slice: +// +// partition = key[grantPrimaryKeyPrefixLen:sep4] +// principal segments = key[sep4+1:] +func splitGrantPrimaryKey(primaryKey []byte) (int, bool) { + if len(primaryKey) < grantPrimaryKeyPrefixLen || + primaryKey[0] != versionV3 || primaryKey[1] != typeGrant || primaryKey[2] != 0 { + return 0, false + } + sep4 := 0 + off := grantPrimaryKeyPrefixLen + for i := range 5 { + sep := bytes.IndexByte(primaryKey[off:], 0) + if sep < 0 { + return 0, false + } + off += sep + if i == 3 { + sep4 = off + } + off++ + } + if bytes.IndexByte(primaryKey[off:], 0) >= 0 { + return 0, false + } + return sep4, true +} + +// appendGrantHashIndexKeyFromPrimary builds the +// by_entitlement_principal_hash index key by SPLICING a grant primary +// key around the raw bucket hash, into dst: +// +// primary = v3|typeGrant|0x00| ent_rt|0|ent_rid|0|ent_flag|0|ent_tail |0| p_rt|0|p_id +// index = v3|typeIndex|idx |0x00| ent_rt|0|ent_rid|0|ent_flag|0|ent_tail |0| bh[0:2] | p_rt|0|p_id +// +// The segments are already escaped and the tuple encoding is +// canonical, so the raw byte splice is byte-identical to +// decode + re-encode (same trick as appendGrantByPrincipalKeyFromPrimary, +// pinned by TestGrantDigestSpliceMatchesEncode). sep4 must come from +// splitGrantPrimaryKey on the same key. +func appendGrantHashIndexKeyFromPrimary(dst, primaryKey []byte, sep4 int, bucketHash64 uint64) []byte { + var bh [8]byte + binary.BigEndian.PutUint64(bh[:], bucketHash64) + dst = append(dst, versionV3, typeIndex, idxGrantByEntitlementPrincipalHash, 0) + dst = append(dst, primaryKey[grantPrimaryKeyPrefixLen:sep4+1]...) // partition + its trailing separator + dst = append(dst, bh[:digestBucketHashLen]...) + return append(dst, primaryKey[sep4+1:]...) // principal segments +} + +// grantPrimaryKeyFromHashIndexKey reconstructs the grant primary key +// from a hash-index key by removing the raw hash region — a byte +// splice, no decode. Returns ok=false for keys that don't parse as +// hash-index entries. The hash bytes may contain 0x00, so the 4th +// separator is found by counting separators from the LEFT (the walk +// never crosses the hash region — see the positional-decoding note on +// encodeGrantByEntPrincHashEntPrefix). +func grantPrimaryKeyFromHashIndexKey(dst, idxKey []byte) ([]byte, bool) { + if len(idxKey) < grantHashIndexKeyPrefixLen || + idxKey[0] != versionV3 || idxKey[1] != typeIndex || + idxKey[2] != idxGrantByEntitlementPrincipalHash || idxKey[3] != 0 { + return dst, false + } + off := grantHashIndexKeyPrefixLen + for range 4 { + sep := bytes.IndexByte(idxKey[off:], 0) + if sep < 0 { + return dst, false + } + off += sep + 1 + } + // off now points at the raw bucket hash. + if len(idxKey) < off+digestBucketHashLen { + return dst, false + } + dst = append(dst, versionV3, typeGrant, 0) + dst = append(dst, idxKey[grantHashIndexKeyPrefixLen:off]...) // partition + trailing separator + return append(dst, idxKey[off+digestBucketHashLen:]...), true +} + +// --- Engine API --- + +// GetEntitlementDigestRoot returns the stored grant-digest root for an +// entitlement. ok is false when no digest has been built for it (the +// caller can fall back to ComputeEntitlementBucketDigest, which derives +// the same digest from the index on demand). +func (e *Engine) GetEntitlementDigestRoot(ctx context.Context, id entitlementIdentity) (DigestRoot, bool, error) { + return e.getPartitionDigestRoot(grantDigestSpec, digestPartitionForEntitlement(id)) +} + +// ComputeEntitlementBucketDigest folds the grant hash index over a +// single bucket of an entitlement (the zero bucket = the whole +// entitlement) — the authoritative on-demand counterpart of the stored +// digest nodes. +func (e *Engine) ComputeEntitlementBucketDigest(ctx context.Context, id entitlementIdentity, bucket DigestBucket) ([]byte, int64, error) { + return e.computeBucketDigest(ctx, grantDigestSpec, digestPartitionForEntitlement(id), bucket) +} + +// DirtyEntitlementBuckets compares this engine's entitlement against +// other's and returns the buckets whose grants differ — see +// dirtyPartitionBuckets for the comparison contract (zero bucket = +// whole entitlement; nil = identical). +func (e *Engine) DirtyEntitlementBuckets(ctx context.Context, other *Engine, id entitlementIdentity) ([]DigestBucket, error) { + return e.dirtyPartitionBuckets(ctx, grantDigestSpec, other, digestPartitionForEntitlement(id)) +} + +// IterateGrantsByEntitlementBucket yields the grants in one +// principal-hash bucket of an entitlement (the zero bucket = the whole +// entitlement). This is the dirty-bucket loader: after a digest +// comparison flags a bucket, the caller materializes only those grants. +// The primary key is reconstructed from each index key by byte splice +// (no decode); the point Get per entry is the cost of MATERIALIZING a +// changed grant, not of finding it. Orphan index entries are skipped. +func (e *Engine) IterateGrantsByEntitlementBucket(ctx context.Context, id entitlementIdentity, bucket DigestBucket, yield func(*v3.GrantRecord) bool) error { + lower, upper := grantDigestSpec.bucketBounds(digestPartitionForEntitlement(id), bucket) + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: lower, UpperBound: upper}) + if err != nil { + return err + } + defer iter.Close() + var keyScratch []byte + for iter.First(); iter.Valid(); iter.Next() { + if err := ctx.Err(); err != nil { + return err + } + priKey, ok := grantPrimaryKeyFromHashIndexKey(keyScratch[:0], iter.Key()) + keyScratch = priKey + if !ok { + continue + } + val, closer, getErr := e.db.Get(priKey) + if getErr != nil { + if errors.Is(getErr, pebble.ErrNotFound) { + continue + } + return getErr + } + r := &v3.GrantRecord{} + uErr := unmarshalRecord(val, r) + closer.Close() + if uErr != nil { + return fmt.Errorf("IterateGrantsByEntitlementBucket: unmarshal: %w", uErr) + } + if !yield(r) { + return nil + } + } + return iter.Error() +} + +// DropAllGrantDigests removes every stored digest node. Called when a +// seal-time build fails partway: a partially built digest that LOOKS +// present would violate the present-means-exact contract, whereas +// absent digests just make readers re-read the grants until the next +// successful seal recalculates them. +func (e *Engine) DropAllGrantDigests(ctx context.Context) error { + return e.withWrite(func() error { + e.grantDigestsPresent.Store(false) + return e.db.DeleteRange(DigestLowerBound(), DigestUpperBound(), writeOpts(e.opts.durability)) + }) +} + +// DropAllGrantDigestState removes every stored digest node AND the +// whole by_entitlement_principal_hash index, and clears the engine's +// digests-present flag. For callers that are about to mutate grants +// through paths that bypass the engine's index maintenance entirely +// (the synccompactor's fold merge): after this, digests read as +// "missing — recalculate", never as stale-but-present, and subsequent +// engine-path writes skip per-entitlement invalidation tombstones. +// Two range tombstones: the digest keyspace and the hash-index +// keyspace are NOT adjacent (typeCounter/typeSession sit between +// them), so this must never be collapsed into one span. +func (e *Engine) DropAllGrantDigestState(ctx context.Context) error { + return e.withWrite(func() error { + e.grantDigestsPresent.Store(false) + opts := writeOpts(e.opts.durability) + if err := e.db.DeleteRange(DigestLowerBound(), DigestUpperBound(), opts); err != nil { + return err + } + return e.db.DeleteRange(GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound(), opts) + }) +} + +// stageGrantDigestInvalidation stages the post-seal invalidation for +// one entitlement into batch: DeleteRange over the entitlement's +// digest partition (present-means-exact — a mutated partition's digest +// must read as missing, see digest.go) AND over its hash-index range +// (the index is derived at seal and is equally stale after a +// mutation). v1 never updates either in place. +// +// Gated on grantDigestsPresent so the ordinary sync paths pay nothing: +// during a fresh sync both keyspaces are empty by construction +// (ResetForNewSync wiped them; only the seal build writes them), and a +// resumed unfinished sync never built them — emitting two range +// tombstones per record there (e.g. PutExpandedGrantRecords over +// millions of grants) would bloat the LSM for no reason. The flag is +// true only when digests actually exist: probed once at Open, set by +// the seal build, cleared by ResetForNewSync and the Drop* paths. +func (e *Engine) stageGrantDigestInvalidation(batch *pebble.Batch, id entitlementIdentity) error { + if !e.grantDigestsPresent.Load() { + return nil + } + partition := digestPartitionForEntitlement(id) + if err := dropPartitionDigest(batch, grantDigestSpec, partition); err != nil { + return err + } + lo := encodeGrantByEntPrincHashEntPrefix(partition) + return batch.DeleteRange(lo, upperBoundOf(lo), nil) +} + +// probeGrantDigestsPresent initializes the digests-present flag at +// Open: one bounded seek over the digest keyspace. Present digests on +// a reopened sealed file arm the mutation-path invalidation +// (stageGrantDigestInvalidation); absent digests keep those paths +// tombstone-free. +func (e *Engine) probeGrantDigestsPresent() error { + iter, err := e.db.NewIter(&pebble.IterOptions{ + LowerBound: DigestLowerBound(), + UpperBound: DigestUpperBound(), + }) + if err != nil { + return err + } + defer iter.Close() + e.grantDigestsPresent.Store(iter.First()) + return iter.Error() +} diff --git a/pkg/dotc1z/engine/pebble/grant_digest_build.go b/pkg/dotc1z/engine/pebble/grant_digest_build.go new file mode 100644 index 000000000..3abf5fc24 --- /dev/null +++ b/pkg/dotc1z/engine/pebble/grant_digest_build.go @@ -0,0 +1,622 @@ +package pebble + +import ( + "bufio" + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "time" + + "github.com/cockroachdb/pebble/v2" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" +) + +// Seal-time construction of the by_entitlement_principal_hash index and +// the per-entitlement grant digests, fused into the deferred EndSync +// pass (BuildDeferredGrantIndexes). This file is the ONLY writer of +// either keyspace. +// +// The deferred pass's primary-grant scan emits one hash-index row per +// grant into a second spill-sorter family (appendGrantHashIndexRow). +// The sorted rows come back in whole-key byte order, which by the index +// layout is (entitlement partition, bucket hash, principal) — exactly +// digest fold order. The final k-way merge therefore does double duty: +// it streams the rows into the index SST (ingested over the excised +// index range, like by_principal) AND folds the digests on the fly +// (grantDigestFold), accumulating each partition's buckets at max width +// and folding down to chooseDigestWidth's pick when the partition +// closes — the total count isn't known until then, and XOR +// split-independence makes the fold-down exact. +// +// Digest nodes do NOT ride the index SST: typeDigest is separated from +// the index keyspace by typeCounter/typeSession, and an excise span +// covering all three would wipe counters and sessions. The nodes are +// tiny (one root + sparse leaves per entitlement), so they go through +// ordinary batch Sets, committed in bounded chunks during the merge. + +// grantHashRowScratch is the reusable per-row scratch for +// appendGrantHashIndexRow: the deferred scan calls it once per grant +// and none of it may allocate per row. +type grantHashRowScratch struct { + keyBuf []byte + tupleBuf []byte + srcKeys [][]byte +} + +// appendGrantHashIndexRow derives one hash-index row from a raw +// primary-grant (key, value) and adds it to the sorter: +// +// - the index key is spliced out of the primary key +// (appendGrantHashIndexKeyFromPrimary — no decode); +// - the bucket hash is xxHash64 of the primary key's principal +// region (grantPrincipalBucketHash64 — a raw sub-slice); +// - the content hash covers the primary-key tail plus the grant's +// sorted source-entitlement ids, pulled from the value with a raw +// protobuf field scan (scanGrantSourceKeysRawBytes — no proto +// unmarshal anywhere on this path). +// +// key/value are only borrowed (the sorter copies before returning). +func appendGrantHashIndexRow(sorter *spillSorter, primaryKey, value []byte, s *grantHashRowScratch) error { + sep4, ok := splitGrantPrimaryKey(primaryKey) + if !ok { + // The caller skips rows that already failed the by_principal + // splice; reaching here means the two splitters disagree. + return fmt.Errorf("grant hash index: primary key %x did not split as a 6-segment identity", primaryKey) + } + srcs, err := scanGrantSourceKeysRawBytes(value, s.srcKeys[:0]) + if err != nil { + return fmt.Errorf("grant hash index: scan sources: %w", err) + } + s.srcKeys = srcs + if len(srcs) > 1 { + sortByteSlices(srcs) + } + ch64, tuple := grantContentHash64(s.tupleBuf, primaryKey[grantPrimaryKeyPrefixLen:], srcs) + s.tupleBuf = tuple + bh64 := grantPrincipalBucketHash64(primaryKey[sep4+1:]) + s.keyBuf = appendGrantHashIndexKeyFromPrimary(s.keyBuf[:0], primaryKey, sep4, bh64) + var chb [hashLen]byte + binary.BigEndian.PutUint64(chb[:], ch64) + return sorter.add(s.keyBuf, chb[:]) +} + +// digestNodeBatchFlushBytes bounds how much digest-node data a single +// batch accumulates before committing mid-merge. +const digestNodeBatchFlushBytes = 4 << 20 + +// grantDigestFold streams the merge's (index key, content hash) rows +// into digest nodes. Rows arrive in (partition, bucket hash) order, so +// each partition is a contiguous run and its buckets are touched in +// ascending order; the fold keeps a max-width (2^16) scratch — ~1.5MiB +// of counts+xors, reused across all partitions — plus the list of +// touched buckets, so per-partition close and reset are O(touched), +// never O(2^16). +type grantDigestFold struct { + e *Engine + opts *pebble.WriteOptions + + counts []int64 // 1< 0 { + // Fold the touched max-width buckets (ascending) down to the + // chosen width: consecutive buckets sharing the top `width` + // bits merge into one leaf. Exact by XOR split-independence. + shift := digestMaxWidthBits - width + for i := 0; i < len(f.touched); { + leafIdx := f.touched[i] >> shift + var digest [hashLen]byte + var count int64 + for i < len(f.touched) && f.touched[i]>>shift == leafIdx { + b := f.touched[i] + xorInto(digest[:], f.xors[b][:]) + count += f.counts[b] + i++ + } + var prefix [digestLeafPrefixLen]byte + binary.BigEndian.PutUint16(prefix[:], leafIdx<= digestNodeBatchFlushBytes { + if err := f.batch.Commit(f.opts); err != nil { + return err + } + f.batch.Close() + f.batch = f.e.db.NewBatch() + } + return nil +} + +// finish closes the last partition and commits the tail batch. +func (f *grantDigestFold) finish() error { + if err := f.closePartition(); err != nil { + return err + } + err := f.batch.Commit(f.opts) + f.batch.Close() + f.batch = nil + return err +} + +// abort discards the un-committed tail batch. Safe after finish (no-op). +func (f *grantDigestFold) abort() { + if f.batch != nil { + f.batch.Close() + f.batch = nil + } +} + +// splitGrantHashIndexKey locates the partition and raw-hash regions of +// a hash-index key: partition = key[grantHashIndexKeyPrefixLen:sepEnd], +// bucket hash at [sepEnd+1, sepEnd+1+digestBucketHashLen). Counts the +// four partition separators from the LEFT so the walk never crosses +// the raw hash region (whose bytes may be 0x00). +func splitGrantHashIndexKey(key []byte) ([]byte, uint16, bool) { + if len(key) < grantHashIndexKeyPrefixLen || + key[0] != versionV3 || key[1] != typeIndex || + key[2] != idxGrantByEntitlementPrincipalHash || key[3] != 0 { + return nil, 0, false + } + off := grantHashIndexKeyPrefixLen + for range 4 { + sep := bytes.IndexByte(key[off:], 0) + if sep < 0 { + return nil, 0, false + } + off += sep + 1 + } + if len(key) < off+digestBucketHashLen { + return nil, 0, false + } + return key[grantHashIndexKeyPrefixLen : off-1], binary.BigEndian.Uint16(key[off : off+digestBucketHashLen]), true +} + +// mergeGrantHashChunksToSST heap-merges the hash-index sorter's chunk +// files into one SST while streaming every row through the digest +// fold. Same merge shape as mergeSortedSpillChunksToSST; duplicate +// keys are corruption here too — (entitlement, principal) is the grant +// primary identity, so the sorter sees exactly one row per grant. +func mergeGrantHashChunksToSST(ctx context.Context, sstPath, name string, chunks []string, fold *grantDigestFold) error { + start := time.Now() + l := ctxzap.Extract(ctx) + readers := make([]*os.File, 0, len(chunks)) + defer func() { + for _, r := range readers { + _ = r.Close() + } + }() + bufReaders := make([]*bufio.Reader, len(chunks)) + keyBufs := make([][]byte, len(chunks)) + valBufs := make([][]byte, len(chunks)) + h := &spillChunkHeap{} + var lenBuf [4]byte + for i, chunk := range chunks { + f, err := os.Open(chunk) // #nosec G304 - staged under the build's MkdirTemp dir. + if err != nil { + return err + } + readers = append(readers, f) + bufReaders[i] = bufio.NewReaderSize(f, bulkSpillBufferSize) + ok, err := readSpillEntry(bufReaders[i], &keyBufs[i], &valBufs[i], &lenBuf) + if err != nil { + return err + } + if ok { + h.push(spillChunkItem{chunkIdx: i, key: keyBufs[i], val: valBufs[i]}) + } + } + + writer, err := newBulkSSTWriter(filepath.Dir(sstPath), name) + if err != nil { + return err + } + success := false + defer func() { + _ = writer.finish() + if !success { + _ = os.Remove(sstPath) + } + }() + var last []byte + var written int64 + lastLog := start + for len(*h) > 0 { + item := h.pop() + if bytes.Equal(item.key, last) { + return fmt.Errorf("%w: bucket %s key %x", errBulkImportDuplicateKey, name, item.key) + } + partition, bucket, ok := splitGrantHashIndexKey(item.key) + if !ok { + // Impossible for keys this build emitted; corruption. + return fmt.Errorf("grant hash index merge: malformed index key %x", item.key) + } + if err := fold.add(partition, bucket, item.val); err != nil { + return err + } + if err := writer.add(item.key, item.val); err != nil { + return err + } + written++ + // Throttled bookkeeping, same rationale as the primary merge. + if written&0xFFFF == 0 { + if err := ctx.Err(); err != nil { + return err + } + if now := time.Now(); now.Sub(lastLog) >= 15*time.Second { + l.Info("grant digest build: merging hash-index chunks", + zap.Int("chunks", len(chunks)), + zap.Int64("entries_written", written), + zap.Int64("partitions_folded", fold.partitions), + zap.Duration("elapsed", now.Sub(start)), + ) + lastLog = now + } + } + last = append(last[:0], item.key...) + ok, err := readSpillEntry(bufReaders[item.chunkIdx], &keyBufs[item.chunkIdx], &valBufs[item.chunkIdx], &lenBuf) + if err != nil { + return err + } + if ok { + h.push(spillChunkItem{chunkIdx: item.chunkIdx, key: keyBufs[item.chunkIdx], val: valBufs[item.chunkIdx]}) + } + } + if err := writer.finish(); err != nil { + return err + } + l.Info("grant digest build: hash-index merge complete", + zap.Int("chunks", len(chunks)), + zap.Int64("entries_written", written), + zap.Int64("partitions_folded", fold.partitions), + zap.Duration("elapsed", time.Since(start)), + ) + success = true + return nil +} + +// buildGrantDigestsFromSpill finalizes the deferred pass's hash-index +// sorter into the ingested index SST and the digest nodes, then +// backfills zero-grant entitlement roots. Runs under the engine write +// barrier (the deferred pass holds it). Any error leaves partially +// written digest nodes behind — the caller MUST drop the digest state +// on failure (dropAllGrantDigestStateLocked) so no half-built digest +// survives looking present. +func (e *Engine) buildGrantDigestsFromSpill(ctx context.Context, dir string, hashIdx *spillSorter) error { + start := time.Now() + l := ctxzap.Extract(ctx) + chunks, err := hashIdx.finalize() + if err != nil { + return err + } + opts := writeOpts(e.opts.durability) + if e.IsFreshSync() { + opts = pebble.NoSync + } + if len(chunks) == 0 { + // No grants at all — pebble rejects an empty SST, so clear any + // stale state directly, then give every entitlement its + // {count: 0} root (the "empty vs. never built" distinction). + if err := e.db.DeleteRange(DigestLowerBound(), DigestUpperBound(), opts); err != nil { + return err + } + if err := e.db.DeleteRange(GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound(), opts); err != nil { + return err + } + if err := e.writeMissingEntitlementDigestRoots(ctx, opts); err != nil { + return err + } + e.grantDigestsPresent.Store(true) + return nil + } + + fold, err := newGrantDigestFold(e) + if err != nil { + return err + } + defer fold.abort() + sstPath := filepath.Join(dir, fmt.Sprintf("index-%02x.sst", idxGrantByEntitlementPrincipalHash)) + if err := mergeGrantHashChunksToSST(ctx, sstPath, fmt.Sprintf("index-%02x", idxGrantByEntitlementPrincipalHash), chunks, fold); err != nil { + return err + } + if err := fold.finish(); err != nil { + return err + } + mergeDone := time.Now() + + // Atomically replace the whole hash-index range with the merged SST. + if _, err := e.db.IngestAndExcise(ctx, []string{sstPath}, nil, nil, pebble.KeyRange{ + Start: GrantByEntPrincHashLowerBound(), + End: GrantByEntPrincHashUpperBound(), + }); err != nil { + return fmt.Errorf("grant digest build: ingest/excise: %w", err) + } + + // Grant-bearing partitions got their digests from the fold; + // entitlements with zero grants still need a root. + if err := e.writeMissingEntitlementDigestRoots(ctx, opts); err != nil { + return err + } + e.grantDigestsPresent.Store(true) + + l.Info("grant digest build complete", + zap.Int64("index_rows", hashIdx.count), + zap.Int64("partitions", fold.partitions), + zap.Int64("digest_nodes", fold.nodes), + zap.Duration("merge", mergeDone.Sub(start)), + zap.Duration("total", time.Since(start)), + ) + return nil +} + +// writeMissingEntitlementDigestRoots gives every entitlement RECORD +// with no stored digest root a {width: 0, count: 0} root. Grant-bearing +// partitions were covered by the fold (including orphans that have +// grants but no entitlement record), so a missing root here means zero +// grants by construction; this pass covers entitlements the sync saw +// but granted nothing under. One pass over the entitlement primary +// keyspace — by far the smallest record type — with a point Get per +// entitlement; deliberately not fused into the grant scan. +func (e *Engine) writeMissingEntitlementDigestRoots(ctx context.Context, opts *pebble.WriteOptions) error { + iter, err := e.db.NewIter(&pebble.IterOptions{ + LowerBound: EntitlementLowerBound(), + UpperBound: EntitlementUpperBound(), + }) + if err != nil { + return err + } + defer iter.Close() + batch := e.db.NewBatch() + // Closure, not a bare defer: the loop rotates `batch` on flush and a + // receiver-bound defer would double-Close the first batch. + defer func() { batch.Close() }() + emptyRoot := packDigestRoot(0, 0, zeroDigest[:]) + var scanned int64 + for iter.First(); iter.Valid(); iter.Next() { + scanned++ + if scanned&0x3FF == 0 { + if err := ctx.Err(); err != nil { + return err + } + } + key := iter.Key() + if len(key) <= grantPrimaryKeyPrefixLen { + continue + } + // The entitlement primary tail IS the digest partition (see the + // partition convention in keys.go) — a raw splice, no decode. + partition := string(key[grantPrimaryKeyPrefixLen:]) + rootKey := encodeDigestNodeKey(grantDigestSpec.indexID, partition, digestLevelRoot, nil) + if _, closer, err := e.db.Get(rootKey); err == nil { + closer.Close() + continue + } else if !errors.Is(err, pebble.ErrNotFound) { + return err + } + if err := batch.Set(rootKey, emptyRoot, nil); err != nil { + return err + } + if batch.Len() >= digestNodeBatchFlushBytes { + if err := batch.Commit(opts); err != nil { + return err + } + batch.Close() + batch = e.db.NewBatch() + } + } + if err := iter.Error(); err != nil { + return err + } + return batch.Commit(opts) +} + +// BuildGrantDigests is the standalone digest build for an EndSync whose +// deferred pass never ran: grants written through PutGrantRecords / +// UnsafePutUniqueGrantRecords / the bulk importer maintain by_principal +// inline and never arm the deferred-index marker, so a sync without +// expansion-path writes reaches EndSync with deferredIdxPending false — +// and possibly millions of grants. Digests are built at EVERY seal, so +// this runs its own primary-grant scan feeding the same spill-sorter → +// merge+fold → ingest machinery the fused pass uses +// (buildGrantDigestsFromSpill). When the deferred pass DOES run, the +// fused build supersedes this (same output, one shared scan). +// +// Failure semantics match the fused pass: any build error (except +// context cancellation, which stays fatal) downgrades to a loud drop of +// the digest state — absent digests are safe, half-built ones are not. +func (e *Engine) BuildGrantDigests(ctx context.Context) error { + return e.withWriteAllowSealed(func() error { + err := e.buildGrantDigestsStandaloneLocked(ctx) + if err == nil { + return nil + } + if ctx.Err() != nil { + return err + } + ctxzap.Extract(ctx).Error("grant digest build failed; dropping digest state — grant-diff callers must re-read grants until the next successful seal", + zap.Error(err), + ) + if dropErr := e.dropAllGrantDigestStateLocked(); dropErr != nil { + return fmt.Errorf("BuildGrantDigests: drop grant digest state after failed build: %w", dropErr) + } + return nil + }) +} + +func (e *Engine) buildGrantDigestsStandaloneLocked(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + dir, err := os.MkdirTemp("", "pebble-grant-digest-") + if err != nil { + return fmt.Errorf("BuildGrantDigests: mkdir temp: %w", err) + } + defer os.RemoveAll(dir) + + sorters := min(4, max(2, runtime.GOMAXPROCS(0)/2)) + sem := make(chan struct{}, sorters) + hashIdx := newSpillSorter(dir, fmt.Sprintf("index-%02x", idxGrantByEntitlementPrincipalHash), sem, deferredIndexSpillChunkBytes) + hashIdx.free = newSpillArenaFreeList(deferredIndexSpillChunkBytes, sorters+1) + // Wait out background chunk sorts before the deferred RemoveAll + // deletes the directory they write into; no-op after finalize. + defer hashIdx.abort() + + iter, err := e.db.NewIter(&pebble.IterOptions{ + LowerBound: GrantLowerBound(), + UpperBound: GrantUpperBound(), + }) + if err != nil { + return fmt.Errorf("BuildGrantDigests: iter: %w", err) + } + iterClosed := false + defer func() { + if !iterClosed { + _ = iter.Close() + } + }() + var scanned, droppedMalformedKeys int64 + var scratch grantHashRowScratch + for iter.First(); iter.Valid(); iter.Next() { + if _, ok := splitGrantPrimaryKey(iter.Key()); !ok { + // Same key-layout-drift/corruption case the deferred pass + // counts; such rows cannot be represented in the digests. + droppedMalformedKeys++ + continue + } + if err := appendGrantHashIndexRow(hashIdx, iter.Key(), iter.Value(), &scratch); err != nil { + return err + } + scanned++ + if scanned&0xFFFF == 0 { + if err := ctx.Err(); err != nil { + return err + } + } + } + if err := iter.Error(); err != nil { + return fmt.Errorf("BuildGrantDigests: iter error: %w", err) + } + if droppedMalformedKeys > 0 { + ctxzap.Extract(ctx).Error("grant digest build: grant primary keys did not decode as 6-segment identities; their rows are NOT represented in the digests", + zap.Int64("dropped", droppedMalformedKeys), + ) + } + // Release the iterator's pinned version before the build's + // IngestAndExcise replaces the hash-index range. + iterClosed = true + if err := iter.Close(); err != nil { + return fmt.Errorf("BuildGrantDigests: close iter: %w", err) + } + return e.buildGrantDigestsFromSpill(ctx, dir, hashIdx) +} + +// dropAllGrantDigestStateLocked is DropAllGrantDigestState for callers +// already holding the engine write barrier (the deferred pass's +// non-fatal error handler). +func (e *Engine) dropAllGrantDigestStateLocked() error { + e.grantDigestsPresent.Store(false) + opts := writeOpts(e.opts.durability) + if e.IsFreshSync() { + opts = pebble.NoSync + } + if err := e.db.DeleteRange(DigestLowerBound(), DigestUpperBound(), opts); err != nil { + return err + } + return e.db.DeleteRange(GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound(), opts) +} diff --git a/pkg/dotc1z/engine/pebble/grant_digest_hash_test.go b/pkg/dotc1z/engine/pebble/grant_digest_hash_test.go new file mode 100644 index 000000000..0ef370d9c --- /dev/null +++ b/pkg/dotc1z/engine/pebble/grant_digest_hash_test.go @@ -0,0 +1,144 @@ +package pebble + +import ( + "bytes" + "encoding/binary" + "testing" + + "github.com/cespare/xxhash/v2" + "github.com/stretchr/testify/require" + + v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3" + "github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/codec" +) + +// The seal-time digest build never decodes anything: the hash-index key +// is spliced out of the grant primary key, the bucket hash is computed +// over a raw sub-slice of it, the content hash over the primary tail +// plus raw-scanned source keys. All of that is on-disk ABI, so each +// splice/byte form is pinned here against the from-decoded-record +// reference path (encode identity → hash), the way +// TestAppendGrantByPrincipalKeyFromPrimary pins the by_principal +// splice. A divergence would make two SDK builds hash identical grants +// differently, which the digest comparison reads as "everything +// differs". +func TestGrantDigestSpliceMatchesEncode(t *testing.T) { + cases := []struct { + name string + entRT, entRID, entID string + prt, pid, ext string + srcs []string + }{ + {name: "plain opaque ent id", entRT: "app", entRID: "github", entID: "ent-1", prt: "user", pid: "user-42", ext: "grant-1"}, + {name: "stripped ent id", entRT: "app", entRID: "github", entID: "app:github:member", prt: "user", pid: "user-42", ext: "app:github:member:user:user-42"}, + {name: "sources", entRT: "app", entRID: "github", entID: "ent-1", prt: "user", pid: "user-42", ext: "g", srcs: []string{"c-src", "a-src", "b-src"}}, + {name: "embedded NUL", entRT: "a\x00pp", entRID: "git\x00hub", entID: "ent\x00x", prt: "us\x00er", pid: "id\x00", ext: "g", srcs: []string{"s\x00rc", "\x00"}}, + {name: "escape byte", entRT: "a\x01pp", entRID: "hub", entID: "ent\x01x", prt: "us\x01er", pid: "\x01id", ext: "g", srcs: []string{"\x01", "\x00"}}, + {name: "unicode", entRT: "приложение", entRID: "гитхаб", entID: "entitlé", prt: "usér", pid: "ид-42", ext: "грант"}, + {name: "duplicate-source keys impossible but sorted singleton", entRT: "app", entRID: "gh", entID: "e", prt: "u", pid: "p", ext: "", srcs: []string{"only"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := v3.GrantRecord_builder{ + ExternalId: tc.ext, + Entitlement: v3.EntitlementRef_builder{ + ResourceTypeId: tc.entRT, + ResourceId: tc.entRID, + EntitlementId: tc.entID, + }.Build(), + Principal: v3.PrincipalRef_builder{ + ResourceTypeId: tc.prt, + ResourceId: tc.pid, + }.Build(), + }.Build() + if len(tc.srcs) > 0 { + m := make(map[string]*v3.GrantSourceRecord, len(tc.srcs)) + for _, s := range tc.srcs { + m[s] = v3.GrantSourceRecord_builder{}.Build() + } + rec.SetSources(m) + } + + id, err := grantIdentityFromRecord(rec) + require.NoError(t, err) + priKey := encodeGrantIdentityKey(id) + val, err := marshalRecord(rec) + require.NoError(t, err) + + sep4, ok := splitGrantPrimaryKey(priKey) + require.True(t, ok, "splitGrantPrimaryKey") + + // The partition region of the primary key is exactly the + // encoded entitlement identity tail. + partition := appendEntitlementIdentityTail(nil, id.entitlement) + require.Equal(t, partition, priKey[grantPrimaryKeyPrefixLen:sep4], "partition region") + require.Equal(t, string(partition), digestPartitionForEntitlement(id.entitlement)) + + // Bucket hash over the spliced principal region == bucket hash + // over freshly encoded principal segments. + wantBH64 := xxhash.Sum64(codec.AppendTupleStrings(nil, tc.prt, tc.pid)) + require.Equal(t, wantBH64, grantPrincipalBucketHash64(priKey[sep4+1:]), "bucket hash from key splice") + var full [8]byte + binary.BigEndian.PutUint64(full[:], wantBH64) + require.Equal(t, full[:digestBucketHashLen], principalBucketHash(tc.prt, tc.pid), "principalBucketHash top bytes") + + // Content hash from raw key+value bytes == content hash from + // the decoded record. + srcs, err := scanGrantSourceKeysRawBytes(val, nil) + require.NoError(t, err) + sortByteSlices(srcs) + ch64, _ := grantContentHash64(nil, priKey[grantPrimaryKeyPrefixLen:], srcs) + fromRecord, err := grantContentHashForRecord(rec) + require.NoError(t, err) + require.Equal(t, fromRecord, binary.BigEndian.AppendUint64(nil, ch64), "content hash: raw scan vs decoded record") + + // Index key splice == reference built entirely from encoders. + idxKey := appendGrantHashIndexKeyFromPrimary(nil, priKey, sep4, wantBH64) + ref := encodeGrantByEntPrincHashEntPrefix(string(partition)) + ref = append(ref, principalBucketHash(tc.prt, tc.pid)...) + ref = codec.AppendTupleStrings(ref, tc.prt, tc.pid) + require.Equal(t, ref, idxKey, "index key: splice vs encode") + + // Round trip: removing the hash region reconstructs the + // primary key byte-exactly. + back, ok := grantPrimaryKeyFromHashIndexKey(nil, idxKey) + require.True(t, ok, "grantPrimaryKeyFromHashIndexKey") + require.Equal(t, priKey, back, "primary key round trip") + + // The merge-side splitter agrees on the partition and bucket. + gotPartition, bucket, ok := splitGrantHashIndexKey(idxKey) + require.True(t, ok, "splitGrantHashIndexKey") + require.Equal(t, partition, gotPartition) + require.Equal(t, binary.BigEndian.Uint16(full[:digestBucketHashLen]), bucket) + }) + } +} + +// TestGrantDigestPartitionPrefixFree pins the property bucketBounds and +// the partition-contiguity of the index rest on: no partition's index +// prefix is a byte-prefix of another's, even for entitlements whose +// tails extend each other or contain separator-adjacent escapes. +func TestGrantDigestPartitionPrefixFree(t *testing.T) { + ids := []entitlementIdentity{ + entitlementIdentityFromParts("app", "github", "ent"), + entitlementIdentityFromParts("app", "github", "ent-1"), + entitlementIdentityFromParts("app", "github", "app:github:ent"), + entitlementIdentityFromParts("app", "github", "ent\x00"), + entitlementIdentityFromParts("app", "github", "ent\x01"), + entitlementIdentityFromParts("app", "gith", "ub:ent"), + entitlementIdentityFromParts("ap", "pgithub", "ent"), + } + prefixes := make([][]byte, len(ids)) + for i, id := range ids { + prefixes[i] = encodeGrantByEntPrincHashEntPrefix(digestPartitionForEntitlement(id)) + } + for i := range prefixes { + for j := range prefixes { + if i == j { + continue + } + require.False(t, bytes.HasPrefix(prefixes[i], prefixes[j]), + "partition prefix %d (%x) extends %d (%x)", i, prefixes[i], j, prefixes[j]) + } + } +} diff --git a/pkg/dotc1z/engine/pebble/grant_write_scale_bench_test.go b/pkg/dotc1z/engine/pebble/grant_write_scale_bench_test.go index 2e61d6f44..a838202f2 100644 --- a/pkg/dotc1z/engine/pebble/grant_write_scale_bench_test.go +++ b/pkg/dotc1z/engine/pebble/grant_write_scale_bench_test.go @@ -71,7 +71,7 @@ func makeGrantRecordBatch(syncID string, offset, count int) []*v3.GrantRecord { return out } -func benchmarkGrantWriteScale(b *testing.B, putUnique bool) { +func benchmarkGrantWriteScale(b *testing.B, putUnique, grantIndex bool) { n := benchGrantCount(b) ctx := context.Background() const batchSize = 10000 @@ -79,7 +79,7 @@ func benchmarkGrantWriteScale(b *testing.B, putUnique bool) { b.ReportMetric(float64(n), "grants") b.ResetTimer() for i := 0; i < b.N; i++ { - e, err := Open(ctx, b.TempDir()) + e, err := Open(ctx, b.TempDir(), WithGrantDigestIndex(grantIndex)) require.NoError(b, err) require.NoError(b, e.MarkFreshSync(benchGrantSyncID)) @@ -103,7 +103,9 @@ func benchmarkGrantWriteScale(b *testing.B, putUnique bool) { } } -func BenchmarkGrantWriteScaleBaseline(b *testing.B) { benchmarkGrantWriteScale(b, false) } -func BenchmarkGrantWriteScaleUnsafePutUnique(b *testing.B) { - benchmarkGrantWriteScale(b, true) -} +// The digest index is built at seal time, never inline, so the write +// path has no per-grant digest cost to isolate; the flag is kept in the +// matrix only to confirm that. +func BenchmarkGrantWriteScale_NoDigestIndex(b *testing.B) { benchmarkGrantWriteScale(b, false, false) } +func BenchmarkGrantWriteScale(b *testing.B) { benchmarkGrantWriteScale(b, false, true) } +func BenchmarkGrantWriteScaleUnsafePutUnique(b *testing.B) { benchmarkGrantWriteScale(b, true, true) } diff --git a/pkg/dotc1z/engine/pebble/grants.go b/pkg/dotc1z/engine/pebble/grants.go index e004584f4..f46209a13 100644 --- a/pkg/dotc1z/engine/pebble/grants.go +++ b/pkg/dotc1z/engine/pebble/grants.go @@ -994,13 +994,26 @@ func grantIndexKeys(r *v3.GrantRecord) [][]byte { return keys } -// writeGrantIndexes adds index entries for r to batch. +// writeGrantIndexes adds index entries for r to batch. The +// by_entitlement_principal_hash index and the digests over it are +// deliberately NOT written here: both are derived from the primaries in +// the fused deferred pass at seal time (BuildDeferredGrantIndexes), +// never maintained inline. A write landing on a file whose digests are +// already built (post-seal mutation) instead invalidates the touched +// entitlement's digest state. func (e *Engine) writeGrantIndexes(batch *pebble.Batch, r *v3.GrantRecord) error { for _, k := range grantIndexKeys(r) { if err := batch.Set(k, nil, nil); err != nil { return err } } + if e.grantDigestsPresent.Load() { + if id, err := grantIdentityFromRecord(r); err == nil { + if err := e.stageGrantDigestInvalidation(batch, id.entitlement); err != nil { + return err + } + } + } return nil } @@ -1068,6 +1081,11 @@ func (e *Engine) writeGrantIndexesForIdentityScratch(batch *pebble.Batch, id gra return scratch, err } } + // Post-seal mutation invalidation; no-op (one atomic load) during + // ordinary syncs — see stageGrantDigestInvalidation. + if err := e.stageGrantDigestInvalidation(batch, id.entitlement); err != nil { + return scratch, err + } return scratch, nil } @@ -1096,6 +1114,11 @@ func (e *Engine) deleteGrantIndexesScratch(batch *pebble.Batch, externalID strin if err := batch.Delete(scratch, nil); err != nil { return scratch, err } + // Post-seal mutation invalidation; no-op (one atomic load) during + // ordinary syncs — see stageGrantDigestInvalidation. + if err := e.stageGrantDigestInvalidation(batch, id.entitlement); err != nil { + return scratch, err + } return scratch, nil } diff --git a/pkg/dotc1z/engine/pebble/if_newer.go b/pkg/dotc1z/engine/pebble/if_newer.go index 347091a09..1b20dcc81 100644 --- a/pkg/dotc1z/engine/pebble/if_newer.go +++ b/pkg/dotc1z/engine/pebble/if_newer.go @@ -40,6 +40,12 @@ func (e *Engine) PutGrantRecordsIfNewer(ctx context.Context, records ...*v3.Gran } batch := e.db.NewBatch() defer batch.Close() + // No inline hash-index or digest maintenance here: both are + // derived at seal time (the fused deferred pass). But IfNewer is + // the partial-sync path — it mutates a CLONED sealed file whose + // digests are built — so the index write/delete helpers below + // stage the touched entitlements' digest invalidation + // (stageGrantDigestInvalidation) whenever digests are present. written := 0 for _, r := range records { if r == nil { diff --git a/pkg/dotc1z/engine/pebble/index_migrations.go b/pkg/dotc1z/engine/pebble/index_migrations.go index cd7098b95..e1cc9007a 100644 --- a/pkg/dotc1z/engine/pebble/index_migrations.go +++ b/pkg/dotc1z/engine/pebble/index_migrations.go @@ -62,9 +62,17 @@ type indexMigration struct { // adding an entry here means the next engine Open will backfill // the corresponding index for any existing c1z that doesn't have // it yet. -var indexMigrations = []indexMigration{ - // none yet, because we have no existing data. -} +// +// Deliberately empty today. The by_entitlement_principal_hash index + +// grant digests are NOT backfilled at Open: they are rebuilt from the +// primaries at every seal (the fused deferred pass / BuildGrantDigests), +// so a file that predates them simply has no digests — readers see +// "missing, recalculate" (never a wrong answer) and the file's next +// sync seals them in. Open-time grant backfills have historically been incident +// bait (unbounded latency and memory at Open on large files); prefer +// seal-time derivation or explicit rebuild commands over registering +// one here. +var indexMigrations []indexMigration // applyIndexMigrations runs on engine Open (writable opens only — // read-only files are immutable on disk). For each registered diff --git a/pkg/dotc1z/engine/pebble/keys.go b/pkg/dotc1z/engine/pebble/keys.go index f1484d08d..440793d5e 100644 --- a/pkg/dotc1z/engine/pebble/keys.go +++ b/pkg/dotc1z/engine/pebble/keys.go @@ -63,6 +63,7 @@ const ( typeIndex byte = 0x07 typeCounter byte = 0x08 typeSession byte = 0x09 + typeDigest byte = 0x0A typeEngineMeta byte = 0xFF ) @@ -78,6 +79,13 @@ const ( idxGrantByNeedsExpansion byte = 0x05 idxGrantByPrincipalResourceType byte = 0x06 // retired: served by idxGrantByPrincipal prefix scans. idxGrantByEntitlementResource byte = 0x07 // retired: served by grant primary entitlement-resource prefix scans. + // idxGrantByEntitlementPrincipalHash sorts grants by + // (entitlement identity, hash(principal identity)). Unlike every + // other grant index its entries carry a VALUE (the grant content + // hash). It is the substrate the per-entitlement grant digest + // (typeDigest) folds over; built ONLY by the seal-time deferred + // pass, never maintained inline. See digest.go and grant_digest.go. + idxGrantByEntitlementPrincipalHash byte = 0x08 ) // --- Grant --- @@ -270,6 +278,127 @@ func encodeGrantByPrincipalResourceTypeIdentityPrefix(principalRT string) []byte return codec.AppendTupleSeparator(buf) } +// --- Grant by (entitlement, principal-hash) + digest nodes --- +// +// PARTITION CONVENTION. Both keyspaces below are addressed by a digest +// "partition": the raw encoded tail of the entitlement's PRIMARY key — +// the already-escaped, separator-delimited 4-segment tuple +// +// ent_rt | 0x00 | ent_rid | 0x00 | ent_flag | 0x00 | ent_tail +// +// exactly as appendEntitlementIdentityKey produces it (minus the 3-byte +// header), and exactly as it appears inside every grant primary key. +// Using the encoded bytes (spliced, never decode+re-encoded) keeps the +// seal-time build allocation-free and makes index/digest partition +// order byte-identical to primary entitlement key order. Segments are +// escaped and the segment COUNT is fixed at four, so partitions are +// mutually prefix-free even though they contain bare 0x00 separators. +// appendEntitlementIdentityTail is the from-identity constructor. + +// appendEntitlementIdentityTail appends the digest-partition bytes for +// an entitlement identity: the 4-segment encoded tuple tail of its +// primary key (no header, no trailing separator). MUST stay in +// byte-lockstep with appendEntitlementIdentityKey's tail. +func appendEntitlementIdentityTail(dst []byte, id entitlementIdentity) []byte { + return codec.AppendTupleStrings(dst, id.resourceTypeID, id.resourceID, id.flagComponent(), id.tail) +} + +// encodeGrantByEntPrincHashEntPrefix is the by-value prefix for "all +// hash-index rows under this entitlement partition", in principal-hash +// order. partition is the raw encoded entitlement tail (see the +// partition convention above) and is appended RAW — it is already +// tuple-encoded. The trailing separator is load-bearing (see the +// keys.go convention doc); the raw bucket hash follows it. Its output +// length is also the offset decoders use to locate the raw hash region. +// +// The full index key shape (see grant_digest.go for the hash and value +// definitions): +// +// v3 | typeIndex | idxGrantByEntitlementPrincipalHash | 0x00 | +// ent_rt | 0x00 | ent_rid | 0x00 | ent_flag | 0x00 | ent_tail | 0x00 | +// | +// principal_rt | 0x00 | principal_id +// -> value: grant content hash (xxHash64, 8 bytes) +// +// (entitlement identity, principal identity) is the grant PRIMARY +// identity, so the index holds exactly one row per grant by +// construction. Because the bucket hash is raw it can contain 0x00, so +// generic tuple walkers must NOT be pointed past the partition prefix — +// their walk would derail on hash bytes. Decode positionally: the hash +// occupies exactly digestBucketHashLen bytes after the prefix. +func encodeGrantByEntPrincHashEntPrefix(partition string) []byte { + buf := make([]byte, 0, 6+len(partition)) + buf = append(buf, versionV3, typeIndex, idxGrantByEntitlementPrincipalHash) + buf = codec.AppendTupleSeparator(buf) + buf = append(buf, partition...) + return codec.AppendTupleSeparator(buf) +} + +// GrantByEntPrincHashLowerBound / UpperBound bound the entire +// by_entitlement_principal_hash index. Exported for the +// cleanup/clone/compaction keyspace plans. +func GrantByEntPrincHashLowerBound() []byte { + return []byte{versionV3, typeIndex, idxGrantByEntitlementPrincipalHash} +} + +func GrantByEntPrincHashUpperBound() []byte { + return upperBoundOf(GrantByEntPrincHashLowerBound()) +} + +// Digest node keys. +// +// v3 | typeDigest | index_id(1 byte) | 0x00 | esc(partition) | 0x00 | level(1 byte) | bucket_prefix +// +// index_id discriminates WHICH digested index the node belongs to (the +// digested index's own idx* byte, see digestIndexSpec) — it leads the +// tail so all of a file's digests for every index are still a single +// contiguous range for the cleanup/clone plans. The partition (which +// for the grant digest contains bare 0x00 separators — see the +// partition convention above) is tuple-ESCAPED here, unlike in the +// index key: node keys carry a level byte and a raw bucket prefix after +// it, so the partition must parse as a single ordinary tuple element. +// The tuple escape is order-preserving, so node keys still sort in raw +// partition order. level 0 is the root (bucket_prefix empty); level 1 +// is the single leaf level, one node per non-empty bucket, whose +// bucket_prefix is the bucket index LEFT-ALIGNED in 2 raw bytes +// (digestLeafPrefixLen). The left alignment makes leaf keys sort in +// bucket-hash order at every digest width, so the comparison's +// fold-to-coarser-width merge is a single contiguous scan of this +// range. See digest.go for the node value framing. +func encodeDigestNodeKey(indexID byte, partition string, level byte, bucketPrefix []byte) []byte { + buf := encodeDigestPartitionPrefix(indexID, partition) + buf = append(buf, level) + return append(buf, bucketPrefix...) +} + +// encodeDigestPartitionPrefix is the prefix of every digest node key +// for one (index, partition) — the range a rebuild clears before +// writing (the build only Sets nodes; without the leading DeleteRange a +// width change or an emptied bucket would leave stale nodes for the +// comparison merge scan to read) and the range the invalidation paths +// remove when a record mutation invalidates the partition. +func encodeDigestPartitionPrefix(indexID byte, partition string) []byte { + buf := make([]byte, 0, 7+len(partition)) + buf = append(buf, versionV3, typeDigest) + buf = append(buf, indexID) + buf = codec.AppendTupleSeparator(buf) + buf = codec.AppendTupleStrings(buf, partition) + return codec.AppendTupleSeparator(buf) +} + +// DigestLowerBound / UpperBound bound the entire digest keyspace (all +// digested indexes). Exported for the cleanup/clone/compaction keyspace +// plans. typeDigest is NOT adjacent to typeIndex — typeCounter and +// typeSession sit between them — so an excise span must never be +// widened to cover both the hash index and the digests in one range. +func DigestLowerBound() []byte { + return []byte{versionV3, typeDigest} +} + +func DigestUpperBound() []byte { + return upperBoundOf(DigestLowerBound()) +} + // --- ResourceType --- // encodeResourceTypeKey returns the primary key for a resource_type: diff --git a/pkg/dotc1z/engine/pebble/options.go b/pkg/dotc1z/engine/pebble/options.go index 343a30bba..479521083 100644 --- a/pkg/dotc1z/engine/pebble/options.go +++ b/pkg/dotc1z/engine/pebble/options.go @@ -42,6 +42,14 @@ type Options struct { // readOnly opens the engine without write permission. Save is // disallowed in this mode. readOnly bool + + // grantDigestIndex controls whether the seal-time deferred pass + // (BuildDeferredGrantIndexes) also constructs the + // by_entitlement_principal_hash index and the per-entitlement grant + // digests — the substrate for cross-file grant diffing. Default on. + // The write paths never maintain either inline, so this gates only + // the fused derivation at EndSync. See WithGrantDigestIndex. + grantDigestIndex bool } // Option is a functional option passed to Open. @@ -65,6 +73,20 @@ func WithDurability(d Durability) Option { return func(o *Options) { o.durabilit // WithReadOnly opens the engine in read-only mode. Save is disallowed. func WithReadOnly(readOnly bool) Option { return func(o *Options) { o.readOnly = readOnly } } +// WithGrantDigestIndex toggles the seal-time construction of the +// by_entitlement_principal_hash index and the per-entitlement grant +// digests. Default true. +// +// Set false on files that will never be grant-diffed (e.g. local CLI +// syncs, connector development) to skip the derivation work in the +// EndSync deferred pass. Safe to toggle per Open: a file sealed with +// this off simply stores no digest roots, which readers and the +// cross-file comparison treat as "missing — recalculate / +// whole-entitlement dirty", never as "no grants". +func WithGrantDigestIndex(enabled bool) Option { + return func(o *Options) { o.grantDigestIndex = enabled } +} + // WithSlowQueryThreshold overrides the default 5 s threshold for // slow-iterator logging. func WithSlowQueryThreshold(d time.Duration) Option { @@ -140,6 +162,7 @@ func defaultOptions() *Options { return &Options{ durability: DurabilitySync, slowQueryThreshold: 5 * time.Second, + grantDigestIndex: true, } } diff --git a/pkg/dotc1z/engine/pebble/production_bench_test.go b/pkg/dotc1z/engine/pebble/production_bench_test.go index 52ffc0fc5..c98704e6b 100644 --- a/pkg/dotc1z/engine/pebble/production_bench_test.go +++ b/pkg/dotc1z/engine/pebble/production_bench_test.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" "testing" + "time" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/connectorstore" @@ -51,32 +52,44 @@ func prepareRegisteredC1Z(b *testing.B, n int, engine c1zstore.Engine) (string, return path, syncID } -func benchmarkRegisteredWritePack(b *testing.B, engine c1zstore.Engine, n int) { +func benchmarkRegisteredWritePack(b *testing.B, engine c1zstore.Engine, n int, storeOpts ...dotc1z.C1ZOption) { ctx := context.Background() root := b.TempDir() grants := benchmarkGrants(n) b.ReportAllocs() b.ReportMetric(float64(n), "grants/op") b.ResetTimer() + + var totalPutGrants, totalEndSync int64 for i := 0; i < b.N; i++ { path := fmt.Sprintf("%s/%s-sync-%06d.c1z", root, engine, i) - store, err := dotc1z.NewStore(ctx, path, dotc1z.WithEngine(engine)) + openOpts := append([]dotc1z.C1ZOption{dotc1z.WithEngine(engine)}, storeOpts...) + store, err := dotc1z.NewStore(ctx, path, openOpts...) if err != nil { b.Fatalf("NewStore: %v", err) } if _, err := store.StartNewSync(ctx, connectorstore.SyncTypeFull, ""); err != nil { b.Fatalf("StartNewSync: %v", err) } + + t0 := time.Now() if err := store.PutGrants(ctx, grants...); err != nil { b.Fatalf("PutGrants: %v", err) } + totalPutGrants += time.Since(t0).Milliseconds() + + t1 := time.Now() if err := store.EndSync(ctx); err != nil { b.Fatalf("EndSync: %v", err) } + totalEndSync += time.Since(t1).Milliseconds() + if err := store.Close(ctx); err != nil { b.Fatalf("Close: %v", err) } } + b.ReportMetric(float64(totalPutGrants)/float64(b.N), "ms/PutGrants") + b.ReportMetric(float64(totalEndSync)/float64(b.N), "ms/EndSync") } func benchmarkRegisteredUnpackReadGrants(b *testing.B, engine c1zstore.Engine, n int) { @@ -223,6 +236,16 @@ func BenchmarkRegisteredPebbleWritePack(b *testing.B) { } } +func BenchmarkRegisteredPebbleWritePack_NoDigestIndex(b *testing.B) { + for _, n := range grantsScales() { + b.Run(fmt.Sprintf("grants=%d", n), func(b *testing.B) { + benchmarkRegisteredWritePack(b, c1zstore.EnginePebble, n, + dotc1z.WithGrantDigestIndex(false), + ) + }) + } +} + func BenchmarkRegisteredSQLiteWritePack(b *testing.B) { for _, n := range grantsScales() { b.Run(fmt.Sprintf("grants=%d", n), func(b *testing.B) { diff --git a/pkg/dotc1z/engine/pebble/raw_records.go b/pkg/dotc1z/engine/pebble/raw_records.go index 89ddfd37f..b56c4c439 100644 --- a/pkg/dotc1z/engine/pebble/raw_records.go +++ b/pkg/dotc1z/engine/pebble/raw_records.go @@ -136,6 +136,12 @@ func (e *Engine) deleteGrantIndexesRaw(batch *pebble.Batch, externalID string, v if err := batch.Delete(encodeGrantByPrincipalIdentityIndexKey(id), nil); err != nil { return err } + // Post-seal mutation of a grant invalidates the touched entitlement's + // digest + hash-index state (no-op unless digests exist — see + // stageGrantDigestInvalidation). + if err := e.stageGrantDigestInvalidation(batch, id.entitlement); err != nil { + return err + } return batch.Delete(encodeGrantByNeedsExpansionIdentityIndexKey(id), nil) } @@ -212,6 +218,62 @@ func scanGrantEntitlementResourceTypeRaw(value []byte) ([]byte, error) { return entRT, nil } +// scanGrantSourceKeysRawBytes extracts the source-entitlement ID keys +// from a marshaled GrantRecord without a full unmarshal. Sources are +// field 9 (map), encoded as repeated embedded +// messages each with sub-field 1 = key string. The keys are views +// borrowed from value (valid only while value's backing bytes are), +// appended to keys — pass a recycled keys[:0] to reuse its backing +// array across calls. The seal-time grant digest build calls this once +// per grant (see appendGrantHashIndexRow). +func scanGrantSourceKeysRawBytes(value []byte, keys [][]byte) ([][]byte, error) { + for len(value) > 0 { + num, typ, n := protowire.ConsumeTag(value) + if n < 0 { + return nil, protowire.ParseError(n) + } + value = value[n:] + if num != 9 { + n = protowire.ConsumeFieldValue(num, typ, value) + if n < 0 { + return nil, protowire.ParseError(n) + } + value = value[n:] + continue + } + if typ != protowire.BytesType { + return nil, fmt.Errorf("raw record: grant sources entry has wire type %v", typ) + } + entry, n := protowire.ConsumeBytes(value) + if n < 0 { + return nil, protowire.ParseError(n) + } + value = value[n:] + for len(entry) > 0 { + eNum, eTyp, en := protowire.ConsumeTag(entry) + if en < 0 { + return nil, protowire.ParseError(en) + } + entry = entry[en:] + if eNum == 1 && eTyp == protowire.BytesType { + k, kn := protowire.ConsumeBytes(entry) + if kn < 0 { + return nil, protowire.ParseError(kn) + } + keys = append(keys, k) + entry = entry[kn:] + } else { + en = protowire.ConsumeFieldValue(eNum, eTyp, entry) + if en < 0 { + return nil, protowire.ParseError(en) + } + entry = entry[en:] + } + } + } + return keys, nil +} + // scanEntitlementResourceTypeRaw extracts only the entitlement's // resource_type_id (its own resource's type) from a marshaled // EntitlementRecord, borrowing the bytes from value. The stats grouping diff --git a/pkg/dotc1z/engine_registry.go b/pkg/dotc1z/engine_registry.go index 403f1683e..7d110f122 100644 --- a/pkg/dotc1z/engine_registry.go +++ b/pkg/dotc1z/engine_registry.go @@ -32,7 +32,13 @@ type StoreOptions struct { SyncLimit int SkipCleanup bool V2GrantsWriter bool - Engine c1zstore.Engine + + // DisableGrantDigestIndex turns off the Pebble engine's seal-time + // build of the by_entitlement_principal_hash index + grant digests. + // Inverted so the zero value keeps the build on (current behavior). + DisableGrantDigestIndex bool + + Engine c1zstore.Engine // PayloadEncoding selects the v3 envelope payload framing for // engines that produce a v3 envelope (currently Pebble). Zero @@ -180,18 +186,19 @@ func storeOptionsFromC1ZOptions(options *c1zOptions) StoreOptions { maxDecodedPayloadBytes, _ := explicitMaxDecodedSizeForDecoderOptions(options.decoderOptions...) maxDecoderMemoryBytes, _ := explicitMaxMemorySizeForDecoderOptions(options.decoderOptions...) out := StoreOptions{ - TmpDir: options.tmpDir, - DecoderOptions: append([]DecoderOption(nil), options.decoderOptions...), - ReadOnly: options.readOnly, - EncoderConcurrency: options.encoderConcurrency, - SyncLimit: options.syncLimit, - SkipCleanup: options.skipCleanup, - V2GrantsWriter: options.v2GrantsWriter, - Engine: options.engine, - PayloadEncoding: options.payloadEncoding, - DecoderPool: options.decoderPool, - MaxDecodedPayloadBytes: maxDecodedPayloadBytes, - MaxDecoderMemoryBytes: maxDecoderMemoryBytes, + TmpDir: options.tmpDir, + DecoderOptions: append([]DecoderOption(nil), options.decoderOptions...), + ReadOnly: options.readOnly, + EncoderConcurrency: options.encoderConcurrency, + SyncLimit: options.syncLimit, + SkipCleanup: options.skipCleanup, + V2GrantsWriter: options.v2GrantsWriter, + DisableGrantDigestIndex: options.disableGrantDigestIndex, + Engine: options.engine, + PayloadEncoding: options.payloadEncoding, + DecoderPool: options.decoderPool, + MaxDecodedPayloadBytes: maxDecodedPayloadBytes, + MaxDecoderMemoryBytes: maxDecoderMemoryBytes, } if out.Engine == "" { out.Engine = c1zstore.EngineSQLite @@ -351,5 +358,8 @@ func (sqliteDriver) OpenStore(ctx context.Context, outputFilePath string, opts S if opts.V2GrantsWriter { c1zOpts = append(c1zOpts, WithV2GrantsWriter(true)) } + if opts.DisableGrantDigestIndex { + c1zOpts = append(c1zOpts, WithGrantDigestIndex(false)) + } return NewC1ZFile(ctx, outputFilePath, c1zOpts...) } diff --git a/pkg/dotc1z/pebble_store.go b/pkg/dotc1z/pebble_store.go index f30f25b08..9714d4281 100644 --- a/pkg/dotc1z/pebble_store.go +++ b/pkg/dotc1z/pebble_store.go @@ -98,7 +98,11 @@ func (pebbleDriver) OpenStore(ctx context.Context, outputFilePath string, opts S } } - e, err := pebble.Open(ctx, dbDir, pebble.WithReadOnly(opts.ReadOnly)) + engineOpts := []pebble.Option{pebble.WithReadOnly(opts.ReadOnly)} + if opts.DisableGrantDigestIndex { + engineOpts = append(engineOpts, pebble.WithGrantDigestIndex(false)) + } + e, err := pebble.Open(ctx, dbDir, engineOpts...) if err != nil { return nil, cleanupOnError(err) } diff --git a/pkg/sync/expand/expand_benchmark_test.go b/pkg/sync/expand/expand_benchmark_test.go index d8c342448..33527ea7f 100644 --- a/pkg/sync/expand/expand_benchmark_test.go +++ b/pkg/sync/expand/expand_benchmark_test.go @@ -400,6 +400,16 @@ func BenchmarkExpandWhalePebble(b *testing.B) { benchmarkExpandPebbleAtPath(b, pebblePath, false) } +func BenchmarkExpandWhalePebble_NoDigestIndex(b *testing.B) { + pebblePath := os.Getenv("BATON_BENCH_PEBBLE_PATH") + if pebblePath == "" { + b.Skip("BATON_BENCH_PEBBLE_PATH not set") + } + benchmarkExpandPebbleAtPath(b, pebblePath, false, + dotc1z.WithGrantDigestIndex(false), + ) +} + func benchmarkExpandPebble(b *testing.B, syncID string, perStep bool) { pebblePath := getPebbleTestdataPath(syncID) if _, err := os.Stat(pebblePath); os.IsNotExist(err) { @@ -408,13 +418,13 @@ func benchmarkExpandPebble(b *testing.B, syncID string, perStep bool) { benchmarkExpandPebbleAtPath(b, pebblePath, perStep) } -func benchmarkExpandPebbleAtPath(b *testing.B, pebblePath string, perStep bool) { +func benchmarkExpandPebbleAtPath(b *testing.B, pebblePath string, perStep bool, storeOpts ...dotc1z.C1ZOption) { if _, err := os.Stat(pebblePath); os.IsNotExist(err) { // #nosec G703 -- pebblePath is a test-controlled fixture/env path in this benchmark. b.Skipf("Pebble file not found: %s", pebblePath) } ctx := context.Background() - storeWriter, err := dotc1z.NewStore(ctx, pebblePath) + storeWriter, err := dotc1z.NewStore(ctx, pebblePath, storeOpts...) require.NoError(b, err) latest, err := storeWriter.SyncMeta().LatestFullSync(ctx) require.NoError(b, err) @@ -466,7 +476,7 @@ func benchmarkExpandPebbleAtPath(b *testing.B, pebblePath string, perStep bool) require.NoError(b, err) require.NoError(b, os.WriteFile(tmpPath, srcData, 0600)) // #nosec G703 -- tmpPath is generated by os.CreateTemp in this benchmark. - storeWriter, err := dotc1z.NewStore(ctx, tmpPath) + storeWriter, err := dotc1z.NewStore(ctx, tmpPath, storeOpts...) require.NoError(b, err) _, started, err := storeWriter.StartOrResumeSync(ctx, connectorstore.SyncTypeFull, destSyncID) require.NoError(b, err) diff --git a/pkg/synccompactor/pebble/bucket_plans.go b/pkg/synccompactor/pebble/bucket_plans.go index 5b5b1d943..33d9be541 100644 --- a/pkg/synccompactor/pebble/bucket_plans.go +++ b/pkg/synccompactor/pebble/bucket_plans.go @@ -60,6 +60,20 @@ func buildBucketPlans() []bucketPlan { lower: enginepkg.GrantByNeedsExpansionLowerBound(), upper: enginepkg.GrantByNeedsExpansionUpperBound(), }, + { + name: "grant_by_entitlement_principal_hash", + lower: enginepkg.GrantByEntPrincHashLowerBound(), + upper: enginepkg.GrantByEntPrincHashUpperBound(), + }, + { + // Digest nodes (all digested indexes, e.g. the per-entitlement + // grant digest). Copied byte-for-byte; because the records move + // verbatim during Compact, the digests stay valid in the + // destination without a rebuild. + name: "digest", + lower: enginepkg.DigestLowerBound(), + upper: enginepkg.DigestUpperBound(), + }, { name: "asset", lower: enginepkg.AssetLowerBound(), diff --git a/pkg/synccompactor/pebble/merge.go b/pkg/synccompactor/pebble/merge.go index 52257b46a..6f2c8275b 100644 --- a/pkg/synccompactor/pebble/merge.go +++ b/pkg/synccompactor/pebble/merge.go @@ -107,6 +107,16 @@ func MergeInto(ctx context.Context, dest *enginepkg.Engine, sources []SourceSync if err := dest.SetCurrentSync(destSyncID); err != nil { return stats, fmt.Errorf("synccompactor/pebble.MergeInto: bind dest sync: %w", err) } + // The fold dest starts as a byte copy of a SEALED base, which carries + // the grant hash index and digests. This merge mutates grants through + // the raw DB handle without maintaining either (nothing does, outside + // the seal-time build), so under the present-means-exact contract both + // must be dropped up front — a folded output's digests read as + // "missing, recalculate", never as stale-but-present. The rebuild + // strategies' fresh dests are in the same state by construction. + if err := dest.DropAllGrantDigestState(ctx); err != nil { + return stats, fmt.Errorf("synccompactor/pebble.MergeInto: drop grant digest state: %w", err) + } // Folds write the dest keyspace through the raw DB handle; invalidate // the engine's bare-id lookup state on the way out (even on error — // earlier sources may already have been folded in). From 87033633d56cedc0203e9ed467e1014d6e4d8230 Mon Sep 17 00:00:00 2001 From: MJ Palanker Date: Tue, 7 Jul 2026 20:04:18 -0700 Subject: [PATCH 02/12] Unify API on entitlement stub for lookup --- pkg/connectorstore/connectorstore.go | 28 ++++++--- pkg/dotc1z/engine/pebble/adapter_reader.go | 39 ++++++------ pkg/dotc1z/engine/pebble/digest_test.go | 73 ++++++++++++++++------ 3 files changed, 93 insertions(+), 47 deletions(-) diff --git a/pkg/connectorstore/connectorstore.go b/pkg/connectorstore/connectorstore.go index 5ac681f74..d393a7bec 100644 --- a/pkg/connectorstore/connectorstore.go +++ b/pkg/connectorstore/connectorstore.go @@ -162,18 +162,30 @@ type GrantDigestBucket struct { // callers must type-assert and fall back when the assertion fails or // found is false. // +// Digests are keyed by the entitlement's STRUCTURAL identity — +// (resource_type_id, resource_id, entitlement id) — so every method +// takes a *v2.Entitlement stub, the same addressing the grants-for- +// entitlement readers use. Pass the resource ref when you have it (any +// caller iterating entitlement records does): identity then derives +// exactly from the structured parts, with no lookup and no ambiguity. +// A stub carrying only the Id falls back to bare-id resolution under +// the exactly-one rule; an id that matches entitlements on more than +// one resource is an error, never a guess. A nil stub or empty Id is +// an error. +// // found is false when the reader has no digest built for the -// entitlement (it was never synced, or the file predates / opted out of -// the digest index — see WithGrantDigestIndex). A nil error with -// found=false means "no digest", not "error". +// entitlement (it was never synced, the file predates / opted out of +// the digest index — see WithGrantDigestIndex — or a bare id resolved +// to nothing). A nil error with found=false means "no digest", not +// "error". type EntitlementGrantDigestReader interface { // GetEntitlementGrantDigest returns the entitlement's digest root — // the whole-entitlement hash + grant count, plus the digest's native // rollup Level. One key read. - GetEntitlementGrantDigest(ctx context.Context, entitlementID string) (digest GrantDigest, found bool, err error) + GetEntitlementGrantDigest(ctx context.Context, entitlement *v2.Entitlement) (digest GrantDigest, found bool, err error) // GetEntitlementGrantDigestNodes lists the digest's rollup nodes for - // entitlementID at `level`: 2^level principal-hash buckets, with + // the entitlement at `level`: 2^level principal-hash buckets, with // level 0 returning a single node (the root) covering the whole // entitlement. // @@ -185,10 +197,10 @@ type EntitlementGrantDigestReader interface { // bits, so a level beyond that resolution is served at the maximum // (you may get fewer than 2^level distinct buckets). found is false // when no digest exists. - GetEntitlementGrantDigestNodes(ctx context.Context, entitlementID string, level int) (nodes []GrantDigestNode, found bool, err error) + GetEntitlementGrantDigestNodes(ctx context.Context, entitlement *v2.Entitlement, level int) (nodes []GrantDigestNode, found bool, err error) // ScanEntitlementGrantBucket yields every grant in one digest bucket - // of entitlementID (see GrantDigestBucket) as a v2.Grant, stopping + // of the entitlement (see GrantDigestBucket) as a v2.Grant, stopping // early if yield returns false. Bucket Level 0 scans the whole // entitlement; a Level finer than the bucket-hash resolution is // clamped (matching GetEntitlementGrantDigestNodes). It reads the @@ -197,7 +209,7 @@ type EntitlementGrantDigestReader interface { // GetEntitlementGrantDigest first and treat found=false as "scan // unavailable — read the grants directly", not as "no grants". It // yields nothing when there is no active sync or no matching grants. - ScanEntitlementGrantBucket(ctx context.Context, entitlementID string, bucket GrantDigestBucket, yield func(grant *v2.Grant) bool) error + ScanEntitlementGrantBucket(ctx context.Context, entitlement *v2.Entitlement, bucket GrantDigestBucket, yield func(grant *v2.Grant) bool) error } // DBSizeProvider is an optional capability for a store that can report its diff --git a/pkg/dotc1z/engine/pebble/adapter_reader.go b/pkg/dotc1z/engine/pebble/adapter_reader.go index 7239867aa..8dd4ca6bc 100644 --- a/pkg/dotc1z/engine/pebble/adapter_reader.go +++ b/pkg/dotc1z/engine/pebble/adapter_reader.go @@ -648,15 +648,16 @@ func (a *Adapter) InitCurrentSync(ctx context.Context) error { return nil } -// resolveDigestEntitlementIdentity maps a public entitlement id string -// to the structural identity the digest keyspace is addressed by. -// Resolution goes through the grant-scan resolver (entitlement records -// first, grant-keyspace probes for ids the store never saw a record -// for — grants can carry such ids, and their digests exist too). ok is -// false when the id matches nothing; an AMBIGUOUS id stays an error — -// a lossy string must never guess which digest to answer with. -func (a *Adapter) resolveDigestEntitlementIdentity(ctx context.Context, entitlementID string) (entitlementIdentity, bool, error) { - id, err := a.engine.resolveGrantScanEntitlementIdentity(ctx, entitlementID) +// digestEntitlementIdentity maps a request-supplied entitlement stub to +// the structural identity the digest keyspace is addressed by, via the +// same entitlementIdentityForRequest the grants-for-entitlement readers +// use: exact derivation from the resource ref when present, bare-id +// resolution (exactly-one rule) otherwise. ok is false when a bare id +// matches nothing; an AMBIGUOUS id stays an error — a lossy string must +// never guess which digest to answer with. A nil stub or empty Id is an +// error, matching ListGrantsForEntitlement. +func (a *Adapter) digestEntitlementIdentity(ctx context.Context, ent *v2.Entitlement) (entitlementIdentity, bool, error) { + id, err := a.entitlementIdentityForRequest(ctx, ent) if err != nil { if errors.Is(err, pebble.ErrNotFound) { return entitlementIdentity{}, false, nil @@ -668,12 +669,12 @@ func (a *Adapter) resolveDigestEntitlementIdentity(ctx context.Context, entitlem // GetEntitlementGrantDigest implements connectorstore.EntitlementGrantDigestReader. // It returns the stored grant-digest root (content hash + grant count) -// for entitlementID under the reader's active sync. found is false when -// no digest exists — either no active sync, an unknown entitlement, or +// for the entitlement under the reader's active sync. found is false +// when no digest exists — either no active sync, an unknown bare id, or // no digest was built for it (e.g. WithGrantDigestIndex(false), a file // that predates the digest, or a post-seal mutation invalidated it). A // nil error with found=false means "no digest". -func (a *Adapter) GetEntitlementGrantDigest(ctx context.Context, entitlementID string) (connectorstore.GrantDigest, bool, error) { +func (a *Adapter) GetEntitlementGrantDigest(ctx context.Context, ent *v2.Entitlement) (connectorstore.GrantDigest, bool, error) { syncID, err := a.resolveActiveSyncForReader(ctx, nil) if err != nil { return connectorstore.GrantDigest{}, false, err @@ -681,7 +682,7 @@ func (a *Adapter) GetEntitlementGrantDigest(ctx context.Context, entitlementID s if syncID == "" { return connectorstore.GrantDigest{}, false, nil } - id, ok, err := a.resolveDigestEntitlementIdentity(ctx, entitlementID) + id, ok, err := a.digestEntitlementIdentity(ctx, ent) if err != nil || !ok { return connectorstore.GrantDigest{}, false, err } @@ -700,7 +701,7 @@ func (a *Adapter) GetEntitlementGrantDigest(ctx context.Context, entitlementID s // level it scans the grant index directly (O(grants)) instead of // erroring; the level is clamped to the bucket-hash resolution // (digestMaxWidthBits). -func (a *Adapter) GetEntitlementGrantDigestNodes(ctx context.Context, entitlementID string, level int) ([]connectorstore.GrantDigestNode, bool, error) { +func (a *Adapter) GetEntitlementGrantDigestNodes(ctx context.Context, ent *v2.Entitlement, level int) ([]connectorstore.GrantDigestNode, bool, error) { if level < 0 { return nil, false, fmt.Errorf("pebble: negative grant-digest level %d", level) } @@ -711,7 +712,7 @@ func (a *Adapter) GetEntitlementGrantDigestNodes(ctx context.Context, entitlemen if syncID == "" { return nil, false, nil } - id, ok, err := a.resolveDigestEntitlementIdentity(ctx, entitlementID) + id, ok, err := a.digestEntitlementIdentity(ctx, ent) if err != nil || !ok { return nil, false, err } @@ -752,11 +753,11 @@ func (a *Adapter) GetEntitlementGrantDigestNodes(ctx context.Context, entitlemen // ScanEntitlementGrantBucket implements // connectorstore.EntitlementGrantDigestReader. It yields every grant in -// the given digest bucket of entitlementID, translated to v2.Grant. +// the given digest bucket of the entitlement, translated to v2.Grant. // Bucket Level 0 scans the whole entitlement; a finer Level is clamped // to the bucket-hash resolution. Yields nothing when there is no active -// sync or the entitlement is unknown. -func (a *Adapter) ScanEntitlementGrantBucket(ctx context.Context, entitlementID string, bucket connectorstore.GrantDigestBucket, yield func(*v2.Grant) bool) error { +// sync or a bare entitlement id resolves to nothing. +func (a *Adapter) ScanEntitlementGrantBucket(ctx context.Context, ent *v2.Entitlement, bucket connectorstore.GrantDigestBucket, yield func(*v2.Grant) bool) error { if bucket.Level < 0 { return fmt.Errorf("pebble: negative grant-digest level %d", bucket.Level) } @@ -767,7 +768,7 @@ func (a *Adapter) ScanEntitlementGrantBucket(ctx context.Context, entitlementID if syncID == "" { return nil } - id, ok, err := a.resolveDigestEntitlementIdentity(ctx, entitlementID) + id, ok, err := a.digestEntitlementIdentity(ctx, ent) if err != nil || !ok { return err } diff --git a/pkg/dotc1z/engine/pebble/digest_test.go b/pkg/dotc1z/engine/pebble/digest_test.go index 862d5144c..8cb4c25a4 100644 --- a/pkg/dotc1z/engine/pebble/digest_test.go +++ b/pkg/dotc1z/engine/pebble/digest_test.go @@ -45,6 +45,22 @@ func testEntPartition(entID string) string { return digestPartitionForEntitlement(testEntIdentity(entID)) } +// testV2Ent is the full entitlement stub (id + resource ref) the digest +// reader methods take — the same shape ListGrantsForEntitlement +// requests carry, so identity derives exactly from the structured +// parts with no bare-id resolution. +func testV2Ent(entID string) *v2.Entitlement { + return v2.Entitlement_builder{ + Id: canonicalTestEntID(entID), + Resource: v2.Resource_builder{ + Id: v2.ResourceId_builder{ + ResourceType: "app", + Resource: "github", + }.Build(), + }.Build(), + }.Build() +} + // makeGrantWithSources is makeGrant plus an optional source-entitlement // set, which the content hash folds in — so two grants with the same // identity but different sources produce different content hashes while @@ -263,7 +279,7 @@ func TestAdapterGetEntitlementGrantDigest(t *testing.T) { // Digest index on: the entitlement resolves with its grant count. on, _ := newTestEngine(t) a := seal(on) - d, found, err := a.GetEntitlementGrantDigest(ctx, canonicalTestEntID("ent-A")) + d, found, err := a.GetEntitlementGrantDigest(ctx, testV2Ent("ent-A")) if err != nil { t.Fatalf("GetEntitlementGrantDigest: %v", err) } @@ -277,15 +293,32 @@ func TestAdapterGetEntitlementGrantDigest(t *testing.T) { t.Fatal("expected non-empty hash") } - // Unknown entitlement: not found, no error. - if _, found, err := a.GetEntitlementGrantDigest(ctx, canonicalTestEntID("ent-missing")); err != nil || found { - t.Fatalf("unknown entitlement: found=%v err=%v, want found=false err=nil", found, err) + // An id-only stub (no resource ref) resolves through the bare-id + // fallback to the same digest. + bare := v2.Entitlement_builder{Id: canonicalTestEntID("ent-A")}.Build() + dBare, found, err := a.GetEntitlementGrantDigest(ctx, bare) + if err != nil || !found { + t.Fatalf("bare-id stub: found=%v err=%v", found, err) + } + if dBare.Count != d.Count || !bytes.Equal(dBare.Hash, d.Hash) { + t.Fatal("bare-id stub resolved to a different digest than the full stub") + } + + // Unknown entitlement: not found, no error — both as a full stub + // (identity derives, no root exists) and as a bare id (resolution + // misses). + if _, found, err := a.GetEntitlementGrantDigest(ctx, testV2Ent("ent-missing")); err != nil || found { + t.Fatalf("unknown entitlement (full stub): found=%v err=%v, want found=false err=nil", found, err) + } + bareMissing := v2.Entitlement_builder{Id: canonicalTestEntID("ent-missing")}.Build() + if _, found, err := a.GetEntitlementGrantDigest(ctx, bareMissing); err != nil || found { + t.Fatalf("unknown entitlement (bare id): found=%v err=%v, want found=false err=nil", found, err) } // Disabled: a real entitlement reports not-found (no digest built). off, _ := newTestEngine(t, WithGrantDigestIndex(false)) aOff := seal(off) - if _, found, err := aOff.GetEntitlementGrantDigest(ctx, canonicalTestEntID("ent-A")); err != nil || found { + if _, found, err := aOff.GetEntitlementGrantDigest(ctx, testV2Ent("ent-A")); err != nil || found { t.Fatalf("digest off: found=%v err=%v, want found=false err=nil", found, err) } } @@ -315,8 +348,8 @@ func TestAdapterGrantDigestNodes(t *testing.T) { t.Fatalf("EndSync: %v", err) } - entID := canonicalTestEntID("ent-A") - d, found, err := a.GetEntitlementGrantDigest(ctx, entID) + ent := testV2Ent("ent-A") + d, found, err := a.GetEntitlementGrantDigest(ctx, ent) if err != nil || !found { t.Fatalf("digest: found=%v err=%v", found, err) } @@ -328,7 +361,7 @@ func TestAdapterGrantDigestNodes(t *testing.T) { } // Level 0 → a single root node matching the digest root. - roots, found, err := a.GetEntitlementGrantDigestNodes(ctx, entID, 0) + roots, found, err := a.GetEntitlementGrantDigestNodes(ctx, ent, 0) if err != nil || !found { t.Fatalf("nodes(0): found=%v err=%v", found, err) } @@ -338,7 +371,7 @@ func TestAdapterGrantDigestNodes(t *testing.T) { // Level == native → the stored leaves: sparse (no zero-count nodes), // counts sum to the total, hashes XOR to the root. - leaves, found, err := a.GetEntitlementGrantDigestNodes(ctx, entID, d.Level) + leaves, found, err := a.GetEntitlementGrantDigestNodes(ctx, ent, d.Level) if err != nil || !found { t.Fatalf("nodes(native): found=%v err=%v", found, err) } @@ -362,7 +395,7 @@ func TestAdapterGrantDigestNodes(t *testing.T) { // Finer than native → index-scan fallback (no error). Same totals: // counts sum to n and hashes XOR to the root. - finer, found, err := a.GetEntitlementGrantDigestNodes(ctx, entID, d.Level+1) + finer, found, err := a.GetEntitlementGrantDigestNodes(ctx, ent, d.Level+1) if err != nil || !found { t.Fatalf("nodes(native+1): found=%v err=%v, want a scanned result", found, err) } @@ -378,7 +411,7 @@ func TestAdapterGrantDigestNodes(t *testing.T) { } // Absurdly fine level → clamped to the hash resolution, still no error. - if _, found, err := a.GetEntitlementGrantDigestNodes(ctx, entID, 999); err != nil || !found { + if _, found, err := a.GetEntitlementGrantDigestNodes(ctx, ent, 999); err != nil || !found { t.Fatalf("nodes(999): found=%v err=%v, want clamped scan with no error", found, err) } } @@ -407,10 +440,10 @@ func TestAdapterScanGrantBucket(t *testing.T) { t.Fatalf("EndSync: %v", err) } - entID := canonicalTestEntID("ent-A") + ent := testV2Ent("ent-A") // Level 0 = whole entitlement → every grant. total := 0 - if err := a.ScanEntitlementGrantBucket(ctx, entID, connectorstore.GrantDigestBucket{Level: 0}, func(*v2.Grant) bool { + if err := a.ScanEntitlementGrantBucket(ctx, ent, connectorstore.GrantDigestBucket{Level: 0}, func(*v2.Grant) bool { total++ return true }); err != nil { @@ -422,18 +455,18 @@ func TestAdapterScanGrantBucket(t *testing.T) { // Per native-level bucket: each scan's count matches the rollup node, // and the buckets partition all the grants. - d, _, err := a.GetEntitlementGrantDigest(ctx, entID) + d, _, err := a.GetEntitlementGrantDigest(ctx, ent) if err != nil { t.Fatalf("digest: %v", err) } - nodes, _, err := a.GetEntitlementGrantDigestNodes(ctx, entID, d.Level) + nodes, _, err := a.GetEntitlementGrantDigestNodes(ctx, ent, d.Level) if err != nil { t.Fatalf("nodes: %v", err) } sum := 0 for _, nd := range nodes { c := int64(0) - if err := a.ScanEntitlementGrantBucket(ctx, entID, connectorstore.GrantDigestBucket{Level: d.Level, Index: nd.Index}, func(*v2.Grant) bool { + if err := a.ScanEntitlementGrantBucket(ctx, ent, connectorstore.GrantDigestBucket{Level: d.Level, Index: nd.Index}, func(*v2.Grant) bool { c++ return true }); err != nil { @@ -450,7 +483,7 @@ func TestAdapterScanGrantBucket(t *testing.T) { // yield can stop early. calls := 0 - if err := a.ScanEntitlementGrantBucket(ctx, entID, connectorstore.GrantDigestBucket{Level: 0}, func(*v2.Grant) bool { + if err := a.ScanEntitlementGrantBucket(ctx, ent, connectorstore.GrantDigestBucket{Level: 0}, func(*v2.Grant) bool { calls++ return false }); err != nil { @@ -462,7 +495,7 @@ func TestAdapterScanGrantBucket(t *testing.T) { // Unknown entitlement → nothing, no error. missing := 0 - if err := a.ScanEntitlementGrantBucket(ctx, canonicalTestEntID("ent-missing"), connectorstore.GrantDigestBucket{Level: 0}, func(*v2.Grant) bool { + if err := a.ScanEntitlementGrantBucket(ctx, testV2Ent("ent-missing"), connectorstore.GrantDigestBucket{Level: 0}, func(*v2.Grant) bool { missing++ return true }); err != nil || missing != 0 { @@ -687,14 +720,14 @@ func TestGrantDigestZeroGrantRootsAtEndSync(t *testing.T) { t.Fatalf("EndSync: %v", err) } - d, found, err := a.GetEntitlementGrantDigest(ctx, canonicalTestEntID("ent-with")) + d, found, err := a.GetEntitlementGrantDigest(ctx, testV2Ent("ent-with")) if err != nil || !found { t.Fatalf("ent-with digest: found=%v err=%v", found, err) } if d.Count != 1 { t.Fatalf("ent-with count = %d, want 1", d.Count) } - dz, found, err := a.GetEntitlementGrantDigest(ctx, canonicalTestEntID("ent-zero")) + dz, found, err := a.GetEntitlementGrantDigest(ctx, testV2Ent("ent-zero")) if err != nil || !found { t.Fatalf("ent-zero digest: found=%v err=%v, want a {count: 0} root (empty, not never-built)", found, err) } From 41d244ec8e7efe4ff4014e675f10eb85fc492a15 Mon Sep 17 00:00:00 2001 From: MJ Palanker Date: Wed, 8 Jul 2026 13:14:14 -0700 Subject: [PATCH 03/12] Build grant digests for compaction outputs; add whole-file digest root 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 --- pb/c1/c1z/v3/manifest.pb.go | 238 +++++++++++++++--- pb/c1/c1z/v3/manifest.pb.validate.go | 135 ++++++++++ pb/c1/c1z/v3/manifest_protoopaque.pb.go | 218 +++++++++++++--- pkg/dotc1z/engine/pebble/digest_test.go | 19 +- pkg/dotc1z/engine/pebble/grant_digest.go | 64 +++++ .../engine/pebble/grant_digest_build.go | 30 ++- .../pebble/grant_digest_global_root_test.go | 191 ++++++++++++++ pkg/dotc1z/engine/pebble/manifest.go | 9 + pkg/dotc1z/format/v3/envelope.go | 19 +- .../compactor_grant_digest_test.go | 133 ++++++++++ pkg/synccompactor/compactor_pebble.go | 56 +++++ pkg/synccompactor/pebble/kway_bench_test.go | 66 ++++- proto/c1/c1z/v3/manifest.proto | 28 +++ 13 files changed, 1117 insertions(+), 89 deletions(-) create mode 100644 pkg/dotc1z/engine/pebble/grant_digest_global_root_test.go create mode 100644 pkg/synccompactor/compactor_grant_digest_test.go diff --git a/pb/c1/c1z/v3/manifest.pb.go b/pb/c1/c1z/v3/manifest.pb.go index 487a1b678..dcda1a7e0 100644 --- a/pb/c1/c1z/v3/manifest.pb.go +++ b/pb/c1/c1z/v3/manifest.pb.go @@ -195,8 +195,19 @@ type C1ZManifestV3 struct { // tooling inspect legacy-vs-structured index state without extracting the // Pebble payload. PebbleIdIndexFormat PebbleIdIndexFormat `protobuf:"varint,42,opt,name=pebble_id_index_format,json=pebbleIdIndexFormat,proto3,enum=c1.c1z.v3.PebbleIdIndexFormat" json:"pebble_id_index_format,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Whole-file grant digest root: the XOR fold of every per-entitlement + // grant-digest root's content-hash XOR in the file's single sync (i.e. + // the fold of every entitlement's digest, not just entitlements with + // grants), plus the total grant count. Lets a consumer answer "did + // anything change?" from a header read alone, without unpacking the + // Pebble payload. Absent means "no digest built for this file" (older + // writers, or a file whose digest state was invalidated after the + // last save) — never "zero grants"; see + // c1/dotc1z/engine/pebble/digest.go's present-means-exact contract. + // Additive field: old readers ignore it, old files simply lack it. + GrantDigestRoot *GrantDigestRoot `protobuf:"bytes,43,opt,name=grant_digest_root,json=grantDigestRoot,proto3" json:"grant_digest_root,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *C1ZManifestV3) Reset() { @@ -287,6 +298,13 @@ func (x *C1ZManifestV3) GetPebbleIdIndexFormat() PebbleIdIndexFormat { return PebbleIdIndexFormat_PEBBLE_ID_INDEX_FORMAT_UNSPECIFIED } +func (x *C1ZManifestV3) GetGrantDigestRoot() *GrantDigestRoot { + if x != nil { + return x.GrantDigestRoot + } + return nil +} + func (x *C1ZManifestV3) SetEngine(v string) { x.Engine = v } @@ -323,6 +341,10 @@ func (x *C1ZManifestV3) SetPebbleIdIndexFormat(v PebbleIdIndexFormat) { x.PebbleIdIndexFormat = v } +func (x *C1ZManifestV3) SetGrantDigestRoot(v *GrantDigestRoot) { + x.GrantDigestRoot = v +} + func (x *C1ZManifestV3) HasEngineConfig() bool { if x == nil { return false @@ -337,6 +359,13 @@ func (x *C1ZManifestV3) HasDescriptors() bool { return x.Descriptors != nil } +func (x *C1ZManifestV3) HasGrantDigestRoot() bool { + if x == nil { + return false + } + return x.GrantDigestRoot != nil +} + func (x *C1ZManifestV3) ClearEngineConfig() { x.EngineConfig = nil } @@ -345,6 +374,10 @@ func (x *C1ZManifestV3) ClearDescriptors() { x.Descriptors = nil } +func (x *C1ZManifestV3) ClearGrantDigestRoot() { + x.GrantDigestRoot = nil +} + type C1ZManifestV3_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. @@ -394,6 +427,17 @@ type C1ZManifestV3_builder struct { // tooling inspect legacy-vs-structured index state without extracting the // Pebble payload. PebbleIdIndexFormat PebbleIdIndexFormat + // Whole-file grant digest root: the XOR fold of every per-entitlement + // grant-digest root's content-hash XOR in the file's single sync (i.e. + // the fold of every entitlement's digest, not just entitlements with + // grants), plus the total grant count. Lets a consumer answer "did + // anything change?" from a header read alone, without unpacking the + // Pebble payload. Absent means "no digest built for this file" (older + // writers, or a file whose digest state was invalidated after the + // last save) — never "zero grants"; see + // c1/dotc1z/engine/pebble/digest.go's present-means-exact contract. + // Additive field: old readers ignore it, old files simply lack it. + GrantDigestRoot *GrantDigestRoot } func (b0 C1ZManifestV3_builder) Build() *C1ZManifestV3 { @@ -409,6 +453,111 @@ func (b0 C1ZManifestV3_builder) Build() *C1ZManifestV3 { x.SyncRuns = b.SyncRuns x.FoldDeadBytes = b.FoldDeadBytes x.PebbleIdIndexFormat = b.PebbleIdIndexFormat + x.GrantDigestRoot = b.GrantDigestRoot + return m0 +} + +// GrantDigestRoot is the whole-file grant digest: present iff the +// engine successfully built grant digests for the file's sync (seal +// time, or a compaction that ran the same build). See +// c1.c1z.v3.C1ZManifestV3.grant_digest_root. +type GrantDigestRoot struct { + state protoimpl.MessageState `protogen:"hybrid.v1"` + // XOR of every grant's content hash in the sync. hashLen (8) bytes. + XorDigest []byte `protobuf:"bytes,1,opt,name=xor_digest,json=xorDigest,proto3" json:"xor_digest,omitempty"` + // Total grant count the digest was built over. + Count int64 `protobuf:"varint,2,opt,name=count,proto3" json:"count,omitempty"` + // Version of the grant content-hash / bucket-hash ABI (grant_digest.go) + // this root was computed under. A future ABI bump changes this, which + // makes roots computed under different versions incomparable by + // construction instead of silently comparing unrelated hash schemes. + AbiVersion uint32 `protobuf:"varint,3,opt,name=abi_version,json=abiVersion,proto3" json:"abi_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GrantDigestRoot) Reset() { + *x = GrantDigestRoot{} + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GrantDigestRoot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GrantDigestRoot) ProtoMessage() {} + +func (x *GrantDigestRoot) ProtoReflect() protoreflect.Message { + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *GrantDigestRoot) GetXorDigest() []byte { + if x != nil { + return x.XorDigest + } + return nil +} + +func (x *GrantDigestRoot) GetCount() int64 { + if x != nil { + return x.Count + } + return 0 +} + +func (x *GrantDigestRoot) GetAbiVersion() uint32 { + if x != nil { + return x.AbiVersion + } + return 0 +} + +func (x *GrantDigestRoot) SetXorDigest(v []byte) { + if v == nil { + v = []byte{} + } + x.XorDigest = v +} + +func (x *GrantDigestRoot) SetCount(v int64) { + x.Count = v +} + +func (x *GrantDigestRoot) SetAbiVersion(v uint32) { + x.AbiVersion = v +} + +type GrantDigestRoot_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + // XOR of every grant's content hash in the sync. hashLen (8) bytes. + XorDigest []byte + // Total grant count the digest was built over. + Count int64 + // Version of the grant content-hash / bucket-hash ABI (grant_digest.go) + // this root was computed under. A future ABI bump changes this, which + // makes roots computed under different versions incomparable by + // construction instead of silently comparing unrelated hash schemes. + AbiVersion uint32 +} + +func (b0 GrantDigestRoot_builder) Build() *GrantDigestRoot { + m0 := &GrantDigestRoot{} + b, x := &b0, m0 + _, _ = b, x + x.XorDigest = b.XorDigest + x.Count = b.Count + x.AbiVersion = b.AbiVersion return m0 } @@ -457,7 +606,7 @@ type IndexedFrameIndex struct { func (x *IndexedFrameIndex) Reset() { *x = IndexedFrameIndex{} - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[1] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -469,7 +618,7 @@ func (x *IndexedFrameIndex) String() string { func (*IndexedFrameIndex) ProtoMessage() {} func (x *IndexedFrameIndex) ProtoReflect() protoreflect.Message { - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[1] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -587,7 +736,7 @@ type IndexedFrameEntry struct { func (x *IndexedFrameEntry) Reset() { *x = IndexedFrameEntry{} - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[2] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -599,7 +748,7 @@ func (x *IndexedFrameEntry) String() string { func (*IndexedFrameEntry) ProtoMessage() {} func (x *IndexedFrameEntry) ProtoReflect() protoreflect.Message { - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[2] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -737,7 +886,7 @@ type RecordTypeInfo struct { func (x *RecordTypeInfo) Reset() { *x = RecordTypeInfo{} - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[3] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -749,7 +898,7 @@ func (x *RecordTypeInfo) String() string { func (*RecordTypeInfo) ProtoMessage() {} func (x *RecordTypeInfo) ProtoReflect() protoreflect.Message { - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[3] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -835,7 +984,7 @@ type SyncRunSummary struct { func (x *SyncRunSummary) Reset() { *x = SyncRunSummary{} - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[4] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -847,7 +996,7 @@ func (x *SyncRunSummary) String() string { func (*SyncRunSummary) ProtoMessage() {} func (x *SyncRunSummary) ProtoReflect() protoreflect.Message { - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[4] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1001,7 +1150,7 @@ type PebbleEngineConfig struct { func (x *PebbleEngineConfig) Reset() { *x = PebbleEngineConfig{} - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[5] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1013,7 +1162,7 @@ func (x *PebbleEngineConfig) String() string { func (*PebbleEngineConfig) ProtoMessage() {} func (x *PebbleEngineConfig) ProtoReflect() protoreflect.Message { - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[5] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1069,7 +1218,7 @@ var File_c1_c1z_v3_manifest_proto protoreflect.FileDescriptor const file_c1_c1z_v3_manifest_proto_rawDesc = "" + "\n" + - "\x18c1/c1z/v3/manifest.proto\x12\tc1.c1z.v3\x1a\x1bc1/storage/v3/records.proto\x1a\x19google/protobuf/any.proto\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x96\x04\n" + + "\x18c1/c1z/v3/manifest.proto\x12\tc1.c1z.v3\x1a\x1bc1/storage/v3/records.proto\x1a\x19google/protobuf/any.proto\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xde\x04\n" + "\rC1ZManifestV3\x12\x16\n" + "\x06engine\x18\x01 \x01(\tR\x06engine\x122\n" + "\x15engine_schema_version\x18\x02 \x01(\rR\x13engineSchemaVersion\x129\n" + @@ -1080,7 +1229,14 @@ const file_c1_c1z_v3_manifest_proto_rawDesc = "" + "\frecord_types\x18\v \x03(\v2\x19.c1.c1z.v3.RecordTypeInfoR\vrecordTypes\x126\n" + "\tsync_runs\x18( \x03(\v2\x19.c1.c1z.v3.SyncRunSummaryR\bsyncRuns\x12&\n" + "\x0ffold_dead_bytes\x18) \x01(\x03R\rfoldDeadBytes\x12S\n" + - "\x16pebble_id_index_format\x18* \x01(\x0e2\x1e.c1.c1z.v3.PebbleIdIndexFormatR\x13pebbleIdIndexFormat\"\xcc\x01\n" + + "\x16pebble_id_index_format\x18* \x01(\x0e2\x1e.c1.c1z.v3.PebbleIdIndexFormatR\x13pebbleIdIndexFormat\x12F\n" + + "\x11grant_digest_root\x18+ \x01(\v2\x1a.c1.c1z.v3.GrantDigestRootR\x0fgrantDigestRoot\"g\n" + + "\x0fGrantDigestRoot\x12\x1d\n" + + "\n" + + "xor_digest\x18\x01 \x01(\fR\txorDigest\x12\x14\n" + + "\x05count\x18\x02 \x01(\x03R\x05count\x12\x1f\n" + + "\vabi_version\x18\x03 \x01(\rR\n" + + "abiVersion\"\xcc\x01\n" + "\x11IndexedFrameIndex\x126\n" + "\aentries\x18\x01 \x03(\v2\x1c.c1.c1z.v3.IndexedFrameEntryR\aentries\x12$\n" + "\x0etotal_raw_size\x18\x02 \x01(\x03R\ftotalRawSize\x122\n" + @@ -1120,39 +1276,41 @@ const file_c1_c1z_v3_manifest_proto_rawDesc = "" + "\x1dPAYLOAD_ENCODING_INDEXED_ZSTD\x10\x05\"\x04\b\x03\x10\x03\"\x04\b\x04\x10\x04B0Z.github.com/conductorone/baton-sdk/pb/c1/c1z/v3b\x06proto3" var file_c1_c1z_v3_manifest_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_c1_c1z_v3_manifest_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_c1_c1z_v3_manifest_proto_msgTypes = make([]protoimpl.MessageInfo, 7) var file_c1_c1z_v3_manifest_proto_goTypes = []any{ (PebbleIdIndexFormat)(0), // 0: c1.c1z.v3.PebbleIdIndexFormat (PayloadEncoding)(0), // 1: c1.c1z.v3.PayloadEncoding (*C1ZManifestV3)(nil), // 2: c1.c1z.v3.C1ZManifestV3 - (*IndexedFrameIndex)(nil), // 3: c1.c1z.v3.IndexedFrameIndex - (*IndexedFrameEntry)(nil), // 4: c1.c1z.v3.IndexedFrameEntry - (*RecordTypeInfo)(nil), // 5: c1.c1z.v3.RecordTypeInfo - (*SyncRunSummary)(nil), // 6: c1.c1z.v3.SyncRunSummary - (*PebbleEngineConfig)(nil), // 7: c1.c1z.v3.PebbleEngineConfig - (*anypb.Any)(nil), // 8: google.protobuf.Any - (*descriptorpb.FileDescriptorSet)(nil), // 9: google.protobuf.FileDescriptorSet - (v3.SyncType)(0), // 10: c1.storage.v3.SyncType - (*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp - (*v3.SyncStatsRecord)(nil), // 12: c1.storage.v3.SyncStatsRecord + (*GrantDigestRoot)(nil), // 3: c1.c1z.v3.GrantDigestRoot + (*IndexedFrameIndex)(nil), // 4: c1.c1z.v3.IndexedFrameIndex + (*IndexedFrameEntry)(nil), // 5: c1.c1z.v3.IndexedFrameEntry + (*RecordTypeInfo)(nil), // 6: c1.c1z.v3.RecordTypeInfo + (*SyncRunSummary)(nil), // 7: c1.c1z.v3.SyncRunSummary + (*PebbleEngineConfig)(nil), // 8: c1.c1z.v3.PebbleEngineConfig + (*anypb.Any)(nil), // 9: google.protobuf.Any + (*descriptorpb.FileDescriptorSet)(nil), // 10: google.protobuf.FileDescriptorSet + (v3.SyncType)(0), // 11: c1.storage.v3.SyncType + (*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp + (*v3.SyncStatsRecord)(nil), // 13: c1.storage.v3.SyncStatsRecord } var file_c1_c1z_v3_manifest_proto_depIdxs = []int32{ - 8, // 0: c1.c1z.v3.C1ZManifestV3.engine_config:type_name -> google.protobuf.Any + 9, // 0: c1.c1z.v3.C1ZManifestV3.engine_config:type_name -> google.protobuf.Any 1, // 1: c1.c1z.v3.C1ZManifestV3.payload_encoding:type_name -> c1.c1z.v3.PayloadEncoding - 9, // 2: c1.c1z.v3.C1ZManifestV3.descriptors:type_name -> google.protobuf.FileDescriptorSet - 5, // 3: c1.c1z.v3.C1ZManifestV3.record_types:type_name -> c1.c1z.v3.RecordTypeInfo - 6, // 4: c1.c1z.v3.C1ZManifestV3.sync_runs:type_name -> c1.c1z.v3.SyncRunSummary + 10, // 2: c1.c1z.v3.C1ZManifestV3.descriptors:type_name -> google.protobuf.FileDescriptorSet + 6, // 3: c1.c1z.v3.C1ZManifestV3.record_types:type_name -> c1.c1z.v3.RecordTypeInfo + 7, // 4: c1.c1z.v3.C1ZManifestV3.sync_runs:type_name -> c1.c1z.v3.SyncRunSummary 0, // 5: c1.c1z.v3.C1ZManifestV3.pebble_id_index_format:type_name -> c1.c1z.v3.PebbleIdIndexFormat - 4, // 6: c1.c1z.v3.IndexedFrameIndex.entries:type_name -> c1.c1z.v3.IndexedFrameEntry - 10, // 7: c1.c1z.v3.SyncRunSummary.type:type_name -> c1.storage.v3.SyncType - 11, // 8: c1.c1z.v3.SyncRunSummary.started_at:type_name -> google.protobuf.Timestamp - 11, // 9: c1.c1z.v3.SyncRunSummary.ended_at:type_name -> google.protobuf.Timestamp - 12, // 10: c1.c1z.v3.SyncRunSummary.stats:type_name -> c1.storage.v3.SyncStatsRecord - 11, // [11:11] is the sub-list for method output_type - 11, // [11:11] is the sub-list for method input_type - 11, // [11:11] is the sub-list for extension type_name - 11, // [11:11] is the sub-list for extension extendee - 0, // [0:11] is the sub-list for field type_name + 3, // 6: c1.c1z.v3.C1ZManifestV3.grant_digest_root:type_name -> c1.c1z.v3.GrantDigestRoot + 5, // 7: c1.c1z.v3.IndexedFrameIndex.entries:type_name -> c1.c1z.v3.IndexedFrameEntry + 11, // 8: c1.c1z.v3.SyncRunSummary.type:type_name -> c1.storage.v3.SyncType + 12, // 9: c1.c1z.v3.SyncRunSummary.started_at:type_name -> google.protobuf.Timestamp + 12, // 10: c1.c1z.v3.SyncRunSummary.ended_at:type_name -> google.protobuf.Timestamp + 13, // 11: c1.c1z.v3.SyncRunSummary.stats:type_name -> c1.storage.v3.SyncStatsRecord + 12, // [12:12] is the sub-list for method output_type + 12, // [12:12] is the sub-list for method input_type + 12, // [12:12] is the sub-list for extension type_name + 12, // [12:12] is the sub-list for extension extendee + 0, // [0:12] is the sub-list for field type_name } func init() { file_c1_c1z_v3_manifest_proto_init() } @@ -1166,7 +1324,7 @@ func file_c1_c1z_v3_manifest_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_c1_c1z_v3_manifest_proto_rawDesc), len(file_c1_c1z_v3_manifest_proto_rawDesc)), NumEnums: 2, - NumMessages: 6, + NumMessages: 7, NumExtensions: 0, NumServices: 0, }, diff --git a/pb/c1/c1z/v3/manifest.pb.validate.go b/pb/c1/c1z/v3/manifest.pb.validate.go index a69d20d1f..432a659f5 100644 --- a/pb/c1/c1z/v3/manifest.pb.validate.go +++ b/pb/c1/c1z/v3/manifest.pb.validate.go @@ -197,6 +197,35 @@ func (m *C1ZManifestV3) validate(all bool) error { // no validation rules for PebbleIdIndexFormat + if all { + switch v := interface{}(m.GetGrantDigestRoot()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, C1ZManifestV3ValidationError{ + field: "GrantDigestRoot", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, C1ZManifestV3ValidationError{ + field: "GrantDigestRoot", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetGrantDigestRoot()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return C1ZManifestV3ValidationError{ + field: "GrantDigestRoot", + reason: "embedded message failed validation", + cause: err, + } + } + } + if len(errors) > 0 { return C1ZManifestV3MultiError(errors) } @@ -275,6 +304,112 @@ var _ interface { ErrorName() string } = C1ZManifestV3ValidationError{} +// Validate checks the field values on GrantDigestRoot with the rules defined +// in the proto definition for this message. If any rules are violated, the +// first error encountered is returned, or nil if there are no violations. +func (m *GrantDigestRoot) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on GrantDigestRoot with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// GrantDigestRootMultiError, or nil if none found. +func (m *GrantDigestRoot) ValidateAll() error { + return m.validate(true) +} + +func (m *GrantDigestRoot) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for XorDigest + + // no validation rules for Count + + // no validation rules for AbiVersion + + if len(errors) > 0 { + return GrantDigestRootMultiError(errors) + } + + return nil +} + +// GrantDigestRootMultiError is an error wrapping multiple validation errors +// returned by GrantDigestRoot.ValidateAll() if the designated constraints +// aren't met. +type GrantDigestRootMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m GrantDigestRootMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m GrantDigestRootMultiError) AllErrors() []error { return m } + +// GrantDigestRootValidationError is the validation error returned by +// GrantDigestRoot.Validate if the designated constraints aren't met. +type GrantDigestRootValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e GrantDigestRootValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e GrantDigestRootValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e GrantDigestRootValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e GrantDigestRootValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e GrantDigestRootValidationError) ErrorName() string { return "GrantDigestRootValidationError" } + +// Error satisfies the builtin error interface +func (e GrantDigestRootValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sGrantDigestRoot.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = GrantDigestRootValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = GrantDigestRootValidationError{} + // Validate checks the field values on IndexedFrameIndex with the rules defined // in the proto definition for this message. If any rules are violated, the // first error encountered is returned, or nil if there are no violations. diff --git a/pb/c1/c1z/v3/manifest_protoopaque.pb.go b/pb/c1/c1z/v3/manifest_protoopaque.pb.go index a5ab7cbf1..1732c6d2f 100644 --- a/pb/c1/c1z/v3/manifest_protoopaque.pb.go +++ b/pb/c1/c1z/v3/manifest_protoopaque.pb.go @@ -158,6 +158,7 @@ type C1ZManifestV3 struct { xxx_hidden_SyncRuns *[]*SyncRunSummary `protobuf:"bytes,40,rep,name=sync_runs,json=syncRuns,proto3"` xxx_hidden_FoldDeadBytes int64 `protobuf:"varint,41,opt,name=fold_dead_bytes,json=foldDeadBytes,proto3"` xxx_hidden_PebbleIdIndexFormat PebbleIdIndexFormat `protobuf:"varint,42,opt,name=pebble_id_index_format,json=pebbleIdIndexFormat,proto3,enum=c1.c1z.v3.PebbleIdIndexFormat"` + xxx_hidden_GrantDigestRoot *GrantDigestRoot `protobuf:"bytes,43,opt,name=grant_digest_root,json=grantDigestRoot,proto3"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -254,6 +255,13 @@ func (x *C1ZManifestV3) GetPebbleIdIndexFormat() PebbleIdIndexFormat { return PebbleIdIndexFormat_PEBBLE_ID_INDEX_FORMAT_UNSPECIFIED } +func (x *C1ZManifestV3) GetGrantDigestRoot() *GrantDigestRoot { + if x != nil { + return x.xxx_hidden_GrantDigestRoot + } + return nil +} + func (x *C1ZManifestV3) SetEngine(v string) { x.xxx_hidden_Engine = v } @@ -290,6 +298,10 @@ func (x *C1ZManifestV3) SetPebbleIdIndexFormat(v PebbleIdIndexFormat) { x.xxx_hidden_PebbleIdIndexFormat = v } +func (x *C1ZManifestV3) SetGrantDigestRoot(v *GrantDigestRoot) { + x.xxx_hidden_GrantDigestRoot = v +} + func (x *C1ZManifestV3) HasEngineConfig() bool { if x == nil { return false @@ -304,6 +316,13 @@ func (x *C1ZManifestV3) HasDescriptors() bool { return x.xxx_hidden_Descriptors != nil } +func (x *C1ZManifestV3) HasGrantDigestRoot() bool { + if x == nil { + return false + } + return x.xxx_hidden_GrantDigestRoot != nil +} + func (x *C1ZManifestV3) ClearEngineConfig() { x.xxx_hidden_EngineConfig = nil } @@ -312,6 +331,10 @@ func (x *C1ZManifestV3) ClearDescriptors() { x.xxx_hidden_Descriptors = nil } +func (x *C1ZManifestV3) ClearGrantDigestRoot() { + x.xxx_hidden_GrantDigestRoot = nil +} + type C1ZManifestV3_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. @@ -361,6 +384,17 @@ type C1ZManifestV3_builder struct { // tooling inspect legacy-vs-structured index state without extracting the // Pebble payload. PebbleIdIndexFormat PebbleIdIndexFormat + // Whole-file grant digest root: the XOR fold of every per-entitlement + // grant-digest root's content-hash XOR in the file's single sync (i.e. + // the fold of every entitlement's digest, not just entitlements with + // grants), plus the total grant count. Lets a consumer answer "did + // anything change?" from a header read alone, without unpacking the + // Pebble payload. Absent means "no digest built for this file" (older + // writers, or a file whose digest state was invalidated after the + // last save) — never "zero grants"; see + // c1/dotc1z/engine/pebble/digest.go's present-means-exact contract. + // Additive field: old readers ignore it, old files simply lack it. + GrantDigestRoot *GrantDigestRoot } func (b0 C1ZManifestV3_builder) Build() *C1ZManifestV3 { @@ -376,6 +410,105 @@ func (b0 C1ZManifestV3_builder) Build() *C1ZManifestV3 { x.xxx_hidden_SyncRuns = &b.SyncRuns x.xxx_hidden_FoldDeadBytes = b.FoldDeadBytes x.xxx_hidden_PebbleIdIndexFormat = b.PebbleIdIndexFormat + x.xxx_hidden_GrantDigestRoot = b.GrantDigestRoot + return m0 +} + +// GrantDigestRoot is the whole-file grant digest: present iff the +// engine successfully built grant digests for the file's sync (seal +// time, or a compaction that ran the same build). See +// c1.c1z.v3.C1ZManifestV3.grant_digest_root. +type GrantDigestRoot struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_XorDigest []byte `protobuf:"bytes,1,opt,name=xor_digest,json=xorDigest,proto3"` + xxx_hidden_Count int64 `protobuf:"varint,2,opt,name=count,proto3"` + xxx_hidden_AbiVersion uint32 `protobuf:"varint,3,opt,name=abi_version,json=abiVersion,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GrantDigestRoot) Reset() { + *x = GrantDigestRoot{} + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GrantDigestRoot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GrantDigestRoot) ProtoMessage() {} + +func (x *GrantDigestRoot) ProtoReflect() protoreflect.Message { + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *GrantDigestRoot) GetXorDigest() []byte { + if x != nil { + return x.xxx_hidden_XorDigest + } + return nil +} + +func (x *GrantDigestRoot) GetCount() int64 { + if x != nil { + return x.xxx_hidden_Count + } + return 0 +} + +func (x *GrantDigestRoot) GetAbiVersion() uint32 { + if x != nil { + return x.xxx_hidden_AbiVersion + } + return 0 +} + +func (x *GrantDigestRoot) SetXorDigest(v []byte) { + if v == nil { + v = []byte{} + } + x.xxx_hidden_XorDigest = v +} + +func (x *GrantDigestRoot) SetCount(v int64) { + x.xxx_hidden_Count = v +} + +func (x *GrantDigestRoot) SetAbiVersion(v uint32) { + x.xxx_hidden_AbiVersion = v +} + +type GrantDigestRoot_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + // XOR of every grant's content hash in the sync. hashLen (8) bytes. + XorDigest []byte + // Total grant count the digest was built over. + Count int64 + // Version of the grant content-hash / bucket-hash ABI (grant_digest.go) + // this root was computed under. A future ABI bump changes this, which + // makes roots computed under different versions incomparable by + // construction instead of silently comparing unrelated hash schemes. + AbiVersion uint32 +} + +func (b0 GrantDigestRoot_builder) Build() *GrantDigestRoot { + m0 := &GrantDigestRoot{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_XorDigest = b.XorDigest + x.xxx_hidden_Count = b.Count + x.xxx_hidden_AbiVersion = b.AbiVersion return m0 } @@ -413,7 +546,7 @@ type IndexedFrameIndex struct { func (x *IndexedFrameIndex) Reset() { *x = IndexedFrameIndex{} - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[1] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -425,7 +558,7 @@ func (x *IndexedFrameIndex) String() string { func (*IndexedFrameIndex) ProtoMessage() {} func (x *IndexedFrameIndex) ProtoReflect() protoreflect.Message { - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[1] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -527,7 +660,7 @@ type IndexedFrameEntry struct { func (x *IndexedFrameEntry) Reset() { *x = IndexedFrameEntry{} - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[2] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -539,7 +672,7 @@ func (x *IndexedFrameEntry) String() string { func (*IndexedFrameEntry) ProtoMessage() {} func (x *IndexedFrameEntry) ProtoReflect() protoreflect.Message { - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[2] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -672,7 +805,7 @@ type RecordTypeInfo struct { func (x *RecordTypeInfo) Reset() { *x = RecordTypeInfo{} - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[3] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -684,7 +817,7 @@ func (x *RecordTypeInfo) String() string { func (*RecordTypeInfo) ProtoMessage() {} func (x *RecordTypeInfo) ProtoReflect() protoreflect.Message { - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[3] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -765,7 +898,7 @@ type SyncRunSummary struct { func (x *SyncRunSummary) Reset() { *x = SyncRunSummary{} - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[4] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -777,7 +910,7 @@ func (x *SyncRunSummary) String() string { func (*SyncRunSummary) ProtoMessage() {} func (x *SyncRunSummary) ProtoReflect() protoreflect.Message { - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[4] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -928,7 +1061,7 @@ type PebbleEngineConfig struct { func (x *PebbleEngineConfig) Reset() { *x = PebbleEngineConfig{} - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[5] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -940,7 +1073,7 @@ func (x *PebbleEngineConfig) String() string { func (*PebbleEngineConfig) ProtoMessage() {} func (x *PebbleEngineConfig) ProtoReflect() protoreflect.Message { - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[5] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -996,7 +1129,7 @@ var File_c1_c1z_v3_manifest_proto protoreflect.FileDescriptor const file_c1_c1z_v3_manifest_proto_rawDesc = "" + "\n" + - "\x18c1/c1z/v3/manifest.proto\x12\tc1.c1z.v3\x1a\x1bc1/storage/v3/records.proto\x1a\x19google/protobuf/any.proto\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x96\x04\n" + + "\x18c1/c1z/v3/manifest.proto\x12\tc1.c1z.v3\x1a\x1bc1/storage/v3/records.proto\x1a\x19google/protobuf/any.proto\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xde\x04\n" + "\rC1ZManifestV3\x12\x16\n" + "\x06engine\x18\x01 \x01(\tR\x06engine\x122\n" + "\x15engine_schema_version\x18\x02 \x01(\rR\x13engineSchemaVersion\x129\n" + @@ -1007,7 +1140,14 @@ const file_c1_c1z_v3_manifest_proto_rawDesc = "" + "\frecord_types\x18\v \x03(\v2\x19.c1.c1z.v3.RecordTypeInfoR\vrecordTypes\x126\n" + "\tsync_runs\x18( \x03(\v2\x19.c1.c1z.v3.SyncRunSummaryR\bsyncRuns\x12&\n" + "\x0ffold_dead_bytes\x18) \x01(\x03R\rfoldDeadBytes\x12S\n" + - "\x16pebble_id_index_format\x18* \x01(\x0e2\x1e.c1.c1z.v3.PebbleIdIndexFormatR\x13pebbleIdIndexFormat\"\xcc\x01\n" + + "\x16pebble_id_index_format\x18* \x01(\x0e2\x1e.c1.c1z.v3.PebbleIdIndexFormatR\x13pebbleIdIndexFormat\x12F\n" + + "\x11grant_digest_root\x18+ \x01(\v2\x1a.c1.c1z.v3.GrantDigestRootR\x0fgrantDigestRoot\"g\n" + + "\x0fGrantDigestRoot\x12\x1d\n" + + "\n" + + "xor_digest\x18\x01 \x01(\fR\txorDigest\x12\x14\n" + + "\x05count\x18\x02 \x01(\x03R\x05count\x12\x1f\n" + + "\vabi_version\x18\x03 \x01(\rR\n" + + "abiVersion\"\xcc\x01\n" + "\x11IndexedFrameIndex\x126\n" + "\aentries\x18\x01 \x03(\v2\x1c.c1.c1z.v3.IndexedFrameEntryR\aentries\x12$\n" + "\x0etotal_raw_size\x18\x02 \x01(\x03R\ftotalRawSize\x122\n" + @@ -1047,39 +1187,41 @@ const file_c1_c1z_v3_manifest_proto_rawDesc = "" + "\x1dPAYLOAD_ENCODING_INDEXED_ZSTD\x10\x05\"\x04\b\x03\x10\x03\"\x04\b\x04\x10\x04B0Z.github.com/conductorone/baton-sdk/pb/c1/c1z/v3b\x06proto3" var file_c1_c1z_v3_manifest_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_c1_c1z_v3_manifest_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_c1_c1z_v3_manifest_proto_msgTypes = make([]protoimpl.MessageInfo, 7) var file_c1_c1z_v3_manifest_proto_goTypes = []any{ (PebbleIdIndexFormat)(0), // 0: c1.c1z.v3.PebbleIdIndexFormat (PayloadEncoding)(0), // 1: c1.c1z.v3.PayloadEncoding (*C1ZManifestV3)(nil), // 2: c1.c1z.v3.C1ZManifestV3 - (*IndexedFrameIndex)(nil), // 3: c1.c1z.v3.IndexedFrameIndex - (*IndexedFrameEntry)(nil), // 4: c1.c1z.v3.IndexedFrameEntry - (*RecordTypeInfo)(nil), // 5: c1.c1z.v3.RecordTypeInfo - (*SyncRunSummary)(nil), // 6: c1.c1z.v3.SyncRunSummary - (*PebbleEngineConfig)(nil), // 7: c1.c1z.v3.PebbleEngineConfig - (*anypb.Any)(nil), // 8: google.protobuf.Any - (*descriptorpb.FileDescriptorSet)(nil), // 9: google.protobuf.FileDescriptorSet - (v3.SyncType)(0), // 10: c1.storage.v3.SyncType - (*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp - (*v3.SyncStatsRecord)(nil), // 12: c1.storage.v3.SyncStatsRecord + (*GrantDigestRoot)(nil), // 3: c1.c1z.v3.GrantDigestRoot + (*IndexedFrameIndex)(nil), // 4: c1.c1z.v3.IndexedFrameIndex + (*IndexedFrameEntry)(nil), // 5: c1.c1z.v3.IndexedFrameEntry + (*RecordTypeInfo)(nil), // 6: c1.c1z.v3.RecordTypeInfo + (*SyncRunSummary)(nil), // 7: c1.c1z.v3.SyncRunSummary + (*PebbleEngineConfig)(nil), // 8: c1.c1z.v3.PebbleEngineConfig + (*anypb.Any)(nil), // 9: google.protobuf.Any + (*descriptorpb.FileDescriptorSet)(nil), // 10: google.protobuf.FileDescriptorSet + (v3.SyncType)(0), // 11: c1.storage.v3.SyncType + (*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp + (*v3.SyncStatsRecord)(nil), // 13: c1.storage.v3.SyncStatsRecord } var file_c1_c1z_v3_manifest_proto_depIdxs = []int32{ - 8, // 0: c1.c1z.v3.C1ZManifestV3.engine_config:type_name -> google.protobuf.Any + 9, // 0: c1.c1z.v3.C1ZManifestV3.engine_config:type_name -> google.protobuf.Any 1, // 1: c1.c1z.v3.C1ZManifestV3.payload_encoding:type_name -> c1.c1z.v3.PayloadEncoding - 9, // 2: c1.c1z.v3.C1ZManifestV3.descriptors:type_name -> google.protobuf.FileDescriptorSet - 5, // 3: c1.c1z.v3.C1ZManifestV3.record_types:type_name -> c1.c1z.v3.RecordTypeInfo - 6, // 4: c1.c1z.v3.C1ZManifestV3.sync_runs:type_name -> c1.c1z.v3.SyncRunSummary + 10, // 2: c1.c1z.v3.C1ZManifestV3.descriptors:type_name -> google.protobuf.FileDescriptorSet + 6, // 3: c1.c1z.v3.C1ZManifestV3.record_types:type_name -> c1.c1z.v3.RecordTypeInfo + 7, // 4: c1.c1z.v3.C1ZManifestV3.sync_runs:type_name -> c1.c1z.v3.SyncRunSummary 0, // 5: c1.c1z.v3.C1ZManifestV3.pebble_id_index_format:type_name -> c1.c1z.v3.PebbleIdIndexFormat - 4, // 6: c1.c1z.v3.IndexedFrameIndex.entries:type_name -> c1.c1z.v3.IndexedFrameEntry - 10, // 7: c1.c1z.v3.SyncRunSummary.type:type_name -> c1.storage.v3.SyncType - 11, // 8: c1.c1z.v3.SyncRunSummary.started_at:type_name -> google.protobuf.Timestamp - 11, // 9: c1.c1z.v3.SyncRunSummary.ended_at:type_name -> google.protobuf.Timestamp - 12, // 10: c1.c1z.v3.SyncRunSummary.stats:type_name -> c1.storage.v3.SyncStatsRecord - 11, // [11:11] is the sub-list for method output_type - 11, // [11:11] is the sub-list for method input_type - 11, // [11:11] is the sub-list for extension type_name - 11, // [11:11] is the sub-list for extension extendee - 0, // [0:11] is the sub-list for field type_name + 3, // 6: c1.c1z.v3.C1ZManifestV3.grant_digest_root:type_name -> c1.c1z.v3.GrantDigestRoot + 5, // 7: c1.c1z.v3.IndexedFrameIndex.entries:type_name -> c1.c1z.v3.IndexedFrameEntry + 11, // 8: c1.c1z.v3.SyncRunSummary.type:type_name -> c1.storage.v3.SyncType + 12, // 9: c1.c1z.v3.SyncRunSummary.started_at:type_name -> google.protobuf.Timestamp + 12, // 10: c1.c1z.v3.SyncRunSummary.ended_at:type_name -> google.protobuf.Timestamp + 13, // 11: c1.c1z.v3.SyncRunSummary.stats:type_name -> c1.storage.v3.SyncStatsRecord + 12, // [12:12] is the sub-list for method output_type + 12, // [12:12] is the sub-list for method input_type + 12, // [12:12] is the sub-list for extension type_name + 12, // [12:12] is the sub-list for extension extendee + 0, // [0:12] is the sub-list for field type_name } func init() { file_c1_c1z_v3_manifest_proto_init() } @@ -1093,7 +1235,7 @@ func file_c1_c1z_v3_manifest_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_c1_c1z_v3_manifest_proto_rawDesc), len(file_c1_c1z_v3_manifest_proto_rawDesc)), NumEnums: 2, - NumMessages: 6, + NumMessages: 7, NumExtensions: 0, NumServices: 0, }, diff --git a/pkg/dotc1z/engine/pebble/digest_test.go b/pkg/dotc1z/engine/pebble/digest_test.go index 8cb4c25a4..882076bde 100644 --- a/pkg/dotc1z/engine/pebble/digest_test.go +++ b/pkg/dotc1z/engine/pebble/digest_test.go @@ -86,11 +86,24 @@ func sealGrantDigests(t testing.TB, e *Engine) { } } -// digestNodeCount counts stored digest nodes (across all partitions and -// digested indexes). +// digestNodeCount counts stored PER-PARTITION digest nodes (across all +// partitions and digested indexes) — i.e. everything callers of this +// helper actually assert shapes about (root-only, root+leaves, ...). +// It excludes the whole-file grant-digest global root (see +// globalGrantDigestNodeKey): that node lives in the same typeDigest +// keyspace but is a single fold-of-everything summary the seal build +// writes once per file, not a per-partition node, so counting it here +// would throw off every existing "N nodes for this one entitlement" +// assertion by a constant +1. func digestNodeCount(t testing.TB, e *Engine) int { t.Helper() - return countKeyRangeTest(t, e, DigestLowerBound(), DigestUpperBound()) + n := countKeyRangeTest(t, e, DigestLowerBound(), DigestUpperBound()) + if _, ok, err := e.GetGrantDigestGlobalRoot(context.Background()); err != nil { + t.Fatalf("GetGrantDigestGlobalRoot: %v", err) + } else if ok { + n-- + } + return n } // rawLeafPrefixes returns the stored 2-byte leaf key prefixes for one diff --git a/pkg/dotc1z/engine/pebble/grant_digest.go b/pkg/dotc1z/engine/pebble/grant_digest.go index 8be63ecfa..0ba3cef96 100644 --- a/pkg/dotc1z/engine/pebble/grant_digest.go +++ b/pkg/dotc1z/engine/pebble/grant_digest.go @@ -46,6 +46,36 @@ var grantDigestSpec = digestIndexSpec{ partitionPrefix: encodeGrantByEntPrincHashEntPrefix, } +// grantDigestABIVersion is the version of the content-hash / bucket-hash +// definitions below (grantContentHash64, grantPrincipalBucketHash64). +// Stamped into the manifest's GrantDigestRoot.abi_version so a future +// ABI bump (a change to either hash's input framing) makes stored +// manifest roots computed under different versions incomparable by +// construction, rather than silently comparing unrelated hash schemes. +// Bump alongside any index-migration version bump that touches these +// hashes (see index_migrations.go). +const grantDigestABIVersion uint32 = 1 + +// digestLevelGlobalRoot is the node-key level for the whole-file grant +// digest root (see globalGrantDigestNodeKey): the XOR fold of every +// per-entitlement root's digest, plus the total grant count, across +// the whole sync. It is NOT part of the generic digest.go core (which +// only knows about per-partition roots/leaves at digestLevelRoot / +// digestLevelLeaf) — the "whole index" summary is a grant-digest- +// specific manifest need, so a level value the core never produces +// keeps the global node's key disjoint from every per-partition node +// regardless of what any entitlement's partition bytes happen to be. +const digestLevelGlobalRoot byte = 2 + +// globalGrantDigestNodeKey returns the storage key for the whole-file +// grant digest root: a level-digestLevelGlobalRoot node under an empty +// partition. Its value is packed exactly like a leaf (packDigestLeaf / +// unpackDigestLeaf: count(8 BE) || digest(hashLen)) — there is no +// bucket-width concept for a whole-sync aggregate. +func globalGrantDigestNodeKey() []byte { + return encodeDigestNodeKey(grantDigestSpec.indexID, "", digestLevelGlobalRoot, nil) +} + // digestPartitionForEntitlement returns the digest partition for an // entitlement identity: its encoded primary-key tail as a string (see // the partition convention in keys.go). @@ -279,6 +309,33 @@ func (e *Engine) GetEntitlementDigestRoot(ctx context.Context, id entitlementIde return e.getPartitionDigestRoot(grantDigestSpec, digestPartitionForEntitlement(id)) } +// GetGrantDigestGlobalRoot returns the whole-file grant digest root — +// the XOR fold of every entitlement's grant-digest root's content-hash +// plus the total grant count, across the sync's single sync. ok is +// false when no digest has been built for this file (present-means- +// exact: absence means "recalculate", never "zero grants"). Written +// only alongside a full grant-digest build (the seal-time build, or a +// compaction that runs BuildGrantDigests) and dropped by the same +// invalidation paths that drop any per-entitlement root — see +// stageGrantDigestInvalidation and the Drop* functions below. +func (e *Engine) GetGrantDigestGlobalRoot(ctx context.Context) (DigestRoot, bool, error) { + val, closer, err := e.db.Get(globalGrantDigestNodeKey()) + if err != nil { + if errors.Is(err, pebble.ErrNotFound) { + return DigestRoot{}, false, nil + } + return DigestRoot{}, false, err + } + defer closer.Close() + count, digest, ok := unpackDigestLeaf(val) + if !ok { + return DigestRoot{}, false, fmt.Errorf("GetGrantDigestGlobalRoot: malformed global root") + } + out := make([]byte, len(digest)) + copy(out, digest) + return DigestRoot{Hash: out, Count: count}, true, nil +} + // ComputeEntitlementBucketDigest folds the grant hash index over a // single bucket of an entitlement (the zero bucket = the whole // entitlement) — the authoritative on-demand counterpart of the stored @@ -395,6 +452,13 @@ func (e *Engine) stageGrantDigestInvalidation(batch *pebble.Batch, id entitlemen if err := dropPartitionDigest(batch, grantDigestSpec, partition); err != nil { return err } + // The whole-file root is the fold of every partition's root; once + // one partition's digest is invalidated the aggregate is stale too, + // so it must drop alongside it rather than linger looking present + // (present-means-exact — digest.go). + if err := batch.Delete(globalGrantDigestNodeKey(), nil); err != nil { + return err + } lo := encodeGrantByEntPrincHashEntPrefix(partition) return batch.DeleteRange(lo, upperBoundOf(lo), nil) } diff --git a/pkg/dotc1z/engine/pebble/grant_digest_build.go b/pkg/dotc1z/engine/pebble/grant_digest_build.go index 3abf5fc24..6e852f3d2 100644 --- a/pkg/dotc1z/engine/pebble/grant_digest_build.go +++ b/pkg/dotc1z/engine/pebble/grant_digest_build.go @@ -110,6 +110,16 @@ type grantDigestFold struct { rootXor [hashLen]byte total int64 + // globalXor/globalTotal accumulate across EVERY partition the fold + // sees (never reset by closePartition, unlike rootXor/total): the + // whole-file grant digest root written once at finish(). XOR is + // associative/commutative, so folding it here in partition-close + // order is exact regardless of how partitions or their buckets were + // subdivided — same split-independence property the per-partition + // digest relies on (digest.go). + globalXor [hashLen]byte + globalTotal int64 + batch *pebble.Batch partitions int64 nodes int64 @@ -162,6 +172,8 @@ func (f *grantDigestFold) add(partitionBytes []byte, bucket uint16, contentHash xorInto(f.xors[bucket][:], contentHash) xorInto(f.rootXor[:], contentHash) f.total++ + xorInto(f.globalXor[:], contentHash) + f.globalTotal++ return nil } @@ -224,11 +236,21 @@ func (f *grantDigestFold) closePartition() error { return nil } -// finish closes the last partition and commits the tail batch. +// finish closes the last partition, writes the whole-file global root +// (the fold of every partition this build touched — see globalXor/ +// globalTotal), and commits the tail batch. The global root lands in +// the same final batch as the last partition's nodes, so it is never +// visible without them: a crash between batches can only leave the +// global root ABSENT, never present ahead of a partition it should +// have folded in. func (f *grantDigestFold) finish() error { if err := f.closePartition(); err != nil { return err } + if err := f.batch.Set(globalGrantDigestNodeKey(), packDigestLeaf(f.globalTotal, f.globalXor[:]), nil); err != nil { + return err + } + f.nodes++ err := f.batch.Commit(f.opts) f.batch.Close() f.batch = nil @@ -402,6 +424,12 @@ func (e *Engine) buildGrantDigestsFromSpill(ctx context.Context, dir string, has if err := e.writeMissingEntitlementDigestRoots(ctx, opts); err != nil { return err } + // Zero grants still means the digest WAS built (present-means- + // exact — an absent global root would tell a manifest reader to + // recalculate instead of trusting "nothing to diff"). + if err := e.db.Set(globalGrantDigestNodeKey(), packDigestLeaf(0, zeroDigest[:]), opts); err != nil { + return err + } e.grantDigestsPresent.Store(true) return nil } diff --git a/pkg/dotc1z/engine/pebble/grant_digest_global_root_test.go b/pkg/dotc1z/engine/pebble/grant_digest_global_root_test.go new file mode 100644 index 000000000..fd6c7493f --- /dev/null +++ b/pkg/dotc1z/engine/pebble/grant_digest_global_root_test.go @@ -0,0 +1,191 @@ +package pebble + +import ( + "bytes" + "context" + "testing" + + "github.com/segmentio/ksuid" + + v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3" + "github.com/conductorone/baton-sdk/pkg/dotc1z/c1zstore" +) + +// TestGrantDigestGlobalRootMatchesFold verifies the whole-file grant +// digest root (the manifest-level summary) equals the XOR/count fold +// of every entitlement's own digest root, independently recomputed via +// the authoritative on-demand fold (ComputeEntitlementBucketDigest) — +// not via the same fold code path that produced the global root. +func TestGrantDigestGlobalRootMatchesFold(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + counts := map[string]int64{"ent-a": 5, "ent-b": 3, "ent-zero": 0} + for entID, n := range counts { + putEnt(t, e, ctx, entID) + grants := make([]*v3.GrantRecord, 0, n) + for range n { + grants = append(grants, makeGrant("", ksuid.New().String(), entID, ksuid.New().String())) + } + if len(grants) > 0 { + if err := e.PutGrantRecords(ctx, grants...); err != nil { + t.Fatalf("PutGrantRecords(%s): %v", entID, err) + } + } + } + sealGrantDigests(t, e) + + var wantXor [hashLen]byte + var wantCount int64 + for entID := range counts { + digest, count, err := e.ComputeEntitlementBucketDigest(ctx, testEntIdentity(entID), DigestBucket{}) + if err != nil { + t.Fatalf("ComputeEntitlementBucketDigest(%s): %v", entID, err) + } + xorInto(wantXor[:], digest) + wantCount += count + } + + got, ok, err := e.GetGrantDigestGlobalRoot(ctx) + if err != nil { + t.Fatalf("GetGrantDigestGlobalRoot: %v", err) + } + if !ok { + t.Fatal("expected global root present after seal") + } + if got.Count != wantCount { + t.Fatalf("global root count = %d, want %d", got.Count, wantCount) + } + if !bytes.Equal(got.Hash, wantXor[:]) { + t.Fatalf("global root digest = %x, want %x", got.Hash, wantXor[:]) + } +} + +// TestGrantDigestGlobalRootEmptySync verifies a sync with no grants at +// all still gets a PRESENT (count 0, zero digest) global root — the +// "digest was built" marker, distinguishing "nothing to diff" from +// "never built" (present-means-exact, digest.go). +func TestGrantDigestGlobalRootEmptySync(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + sealGrantDigests(t, e) + + root, ok, err := e.GetGrantDigestGlobalRoot(ctx) + if err != nil { + t.Fatalf("GetGrantDigestGlobalRoot: %v", err) + } + if !ok { + t.Fatal("expected global root present even with zero grants") + } + if root.Count != 0 { + t.Fatalf("global root count = %d, want 0", root.Count) + } + if !bytes.Equal(root.Hash, zeroDigest[:]) { + t.Fatalf("global root digest = %x, want zero", root.Hash) + } +} + +// TestGrantDigestGlobalRootDroppedOnInvalidation verifies that +// invalidating any single entitlement's digest (a post-seal +// DeleteGrantRecord) also drops the whole-file global root: the +// aggregate is a fold over every partition, so it goes stale the +// moment any partition changes and must read as "missing — +// recalculate" rather than a silently wrong cached value. +func TestGrantDigestGlobalRootDroppedOnInvalidation(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + seedEntitlement(t, e, "ent-A", []*v3.GrantRecord{ + makeGrant("", "g1", "ent-A", "alice"), + makeGrant("", "g2", "ent-A", "bob"), + }) + + if _, ok, err := e.GetGrantDigestGlobalRoot(ctx); err != nil || !ok { + t.Fatalf("global root before delete: ok=%v err=%v", ok, err) + } + + if err := e.DeleteGrantRecord(ctx, "g1"); err != nil { + t.Fatalf("DeleteGrantRecord: %v", err) + } + if _, ok, err := e.GetGrantDigestGlobalRoot(ctx); err != nil || ok { + t.Fatalf("global root after delete: ok=%v err=%v, want missing (invalidated)", ok, err) + } + + // Reseal recalculates it from the surviving primaries. + sealGrantDigests(t, e) + root, ok, err := e.GetGrantDigestGlobalRoot(ctx) + if err != nil || !ok { + t.Fatalf("global root after reseal: ok=%v err=%v", ok, err) + } + if root.Count != 1 { + t.Fatalf("resealed global root count = %d, want 1", root.Count) + } +} + +// TestManifestGrantDigestRoot verifies BuildManifestWithSyncRuns stamps +// the manifest's GrantDigestRoot from the engine's stored global root, +// byte-identical to reading it directly, with the current ABI version. +func TestManifestGrantDigestRoot(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + seedEntitlement(t, e, "ent-A", []*v3.GrantRecord{ + makeGrant("", "g1", "ent-A", "alice"), + }) + + m, err := BuildManifestWithSyncRuns(ctx, e, c1zstore.PayloadEncodingUnspecified) + if err != nil { + t.Fatalf("BuildManifestWithSyncRuns: %v", err) + } + root := m.GetGrantDigestRoot() + if root == nil { + t.Fatal("expected manifest GrantDigestRoot to be present") + } + if root.GetAbiVersion() != grantDigestABIVersion { + t.Fatalf("manifest abi_version = %d, want %d", root.GetAbiVersion(), grantDigestABIVersion) + } + want, ok, err := e.GetGrantDigestGlobalRoot(ctx) + if err != nil || !ok { + t.Fatalf("GetGrantDigestGlobalRoot: ok=%v err=%v", ok, err) + } + if root.GetCount() != want.Count { + t.Fatalf("manifest count = %d, want %d", root.GetCount(), want.Count) + } + if !bytes.Equal(root.GetXorDigest(), want.Hash) { + t.Fatalf("manifest xor_digest = %x, want %x", root.GetXorDigest(), want.Hash) + } +} + +// TestManifestGrantDigestRootAbsentWithoutDigests verifies that a file +// sealed with the digest index disabled — never having built any +// digest state — has no GrantDigestRoot in its manifest: absent means +// "no digest, full read", never "zero grants" (see the manifest proto +// doc comment). +func TestManifestGrantDigestRootAbsentWithoutDigests(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t, WithGrantDigestIndex(false)) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + putEnt(t, e, ctx, "ent-A") + if err := e.PutGrantRecords(ctx, makeGrant("", "g1", "ent-A", "alice")); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + if err := e.BuildDeferredGrantIndexes(ctx); err != nil { + t.Fatalf("BuildDeferredGrantIndexes: %v", err) + } + + m, err := BuildManifestWithSyncRuns(ctx, e, c1zstore.PayloadEncodingUnspecified) + if err != nil { + t.Fatalf("BuildManifestWithSyncRuns: %v", err) + } + if m.GetGrantDigestRoot() != nil { + t.Fatalf("expected absent GrantDigestRoot, got %v", m.GetGrantDigestRoot()) + } +} diff --git a/pkg/dotc1z/engine/pebble/manifest.go b/pkg/dotc1z/engine/pebble/manifest.go index 9a7cdabd9..65b44a2af 100644 --- a/pkg/dotc1z/engine/pebble/manifest.go +++ b/pkg/dotc1z/engine/pebble/manifest.go @@ -50,6 +50,15 @@ func BuildManifestWithSyncRuns(ctx context.Context, e *Engine, encoding c1zstore } m.SetSyncRuns(syncRuns) m.SetPebbleIdIndexFormat(e.manifestIDIndexFormat()) + if root, ok, err := e.GetGrantDigestGlobalRoot(ctx); err != nil { + return nil, fmt.Errorf("pebble: manifest grant digest root: %w", err) + } else if ok { + m.SetGrantDigestRoot(c1zv3.GrantDigestRoot_builder{ + XorDigest: root.Hash, + Count: root.Count, + AbiVersion: grantDigestABIVersion, + }.Build()) + } return m, nil } diff --git a/pkg/dotc1z/format/v3/envelope.go b/pkg/dotc1z/format/v3/envelope.go index 042577c56..0bf308615 100644 --- a/pkg/dotc1z/format/v3/envelope.go +++ b/pkg/dotc1z/format/v3/envelope.go @@ -576,8 +576,9 @@ func readEnvelope(r io.Reader, headerOnly bool, pool *DecoderPool) (*Envelope, e // unmarshalManifestHeader decodes the cheap manifest fields by hand: // engine (1), engine_schema_version (2), payload_encoding (4), the -// sync_runs projection (40), and fold_dead_bytes (41). The descriptor -// closure (field 10) — by far +// sync_runs projection (40), fold_dead_bytes (41), +// pebble_id_index_format (42), and grant_digest_root (43). The +// descriptor closure (field 10) — by far // the largest field — is skipped, which is what makes header reads // cheap enough for engine dispatch on every open. Sync run summaries // are small and few (bounded by the sync retention limit), so decoding @@ -664,6 +665,20 @@ func unmarshalManifestHeader(b []byte) (*c1zv3.C1ZManifestV3, error) { } out.SetPebbleIdIndexFormat(c1zv3.PebbleIdIndexFormat(v)) b = b[n:] + case 43: + if typ != protowire.BytesType { + return nil, fmt.Errorf("c1z v3: manifest grant_digest_root has wire type %v", typ) + } + v, n := protowire.ConsumeBytes(b) + if n < 0 { + return nil, protowire.ParseError(n) + } + root := &c1zv3.GrantDigestRoot{} + if err := proto.Unmarshal(v, root); err != nil { + return nil, fmt.Errorf("%w: grant_digest_root: %w", ErrManifestInvalid, err) + } + out.SetGrantDigestRoot(root) + b = b[n:] default: n := protowire.ConsumeFieldValue(num, typ, b) if n < 0 { diff --git a/pkg/synccompactor/compactor_grant_digest_test.go b/pkg/synccompactor/compactor_grant_digest_test.go new file mode 100644 index 000000000..7d62057d6 --- /dev/null +++ b/pkg/synccompactor/compactor_grant_digest_test.go @@ -0,0 +1,133 @@ +package synccompactor + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/connectorstore" + "github.com/conductorone/baton-sdk/pkg/dotc1z" + enginepkg "github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble" + formatv3 "github.com/conductorone/baton-sdk/pkg/dotc1z/format/v3" +) + +// buildOracleFromCompacted copies every record the compacted output at +// out actually holds into a brand-new Pebble store (via the public v2 +// reader/writer surface — no shared state, no internal helpers) and +// seals it there. Comparing the oracle's independently-built grant +// digest against the compacted output's is a from-scratch parity +// check: if compaction's digest build were wrong (built over stale or +// partial data), it would not byte-match a digest built from a fresh +// engine over the same final records. +func buildOracleFromCompacted(t testing.TB, ctx context.Context, out *CompactableSync, path string) string { + t.Helper() + src, err := dotc1z.NewStore(ctx, out.FilePath, dotc1z.WithReadOnly(true), dotc1z.WithTmpDir(t.TempDir())) + require.NoError(t, err) + defer src.Close(ctx) + require.NoError(t, src.SetCurrentSync(ctx, out.SyncID)) + + dst, err := dotc1z.NewStore(ctx, path, dotc1z.WithEngine(dotc1z.EnginePebble), dotc1z.WithTmpDir(t.TempDir())) + require.NoError(t, err) + syncID, err := dst.StartNewSync(ctx, connectorstore.SyncTypeFull, "") + require.NoError(t, err) + + rtResp, err := src.ListResourceTypes(ctx, v2.ResourceTypesServiceListResourceTypesRequest_builder{PageSize: 1000}.Build()) + require.NoError(t, err) + require.NoError(t, dst.PutResourceTypes(ctx, rtResp.GetList()...)) + + rResp, err := src.ListResources(ctx, v2.ResourcesServiceListResourcesRequest_builder{PageSize: 1000}.Build()) + require.NoError(t, err) + require.NoError(t, dst.PutResources(ctx, rResp.GetList()...)) + + eResp, err := src.ListEntitlements(ctx, v2.EntitlementsServiceListEntitlementsRequest_builder{PageSize: 1000}.Build()) + require.NoError(t, err) + require.NoError(t, dst.PutEntitlements(ctx, eResp.GetList()...)) + + gResp, err := src.ListGrants(ctx, v2.GrantsServiceListGrantsRequest_builder{PageSize: 1000}.Build()) + require.NoError(t, err) + require.NoError(t, dst.PutGrants(ctx, gResp.GetList()...)) + + require.NoError(t, dst.EndSync(ctx)) + require.NoError(t, dst.Close(ctx)) + return syncID +} + +// TestCompactPebbleGrantDigestsBuiltPerMode pins Deliverable 1 (RFC +// 0003): a compacted output — under every merge strategy — must carry +// grant-digest state (the by_entitlement_principal_hash index + digest +// nodes) matching a from-scratch build over its final grant set, and +// the saved envelope's manifest must carry a matching whole-file +// GrantDigestRoot. Before this, compaction shipped digest-free outputs +// in every mode, defeating the digest feature for exactly the files +// downstream diff consumers ingest most. +func TestCompactPebbleGrantDigestsBuiltPerMode(t *testing.T) { + for _, mode := range []PebbleCompactorMode{PebbleCompactorModeFold, PebbleCompactorModeKWay, PebbleCompactorModeOverlay} { + t.Run(string(mode), func(t *testing.T) { + ctx := context.Background() + inDir := t.TempDir() + outDir := t.TempDir() + + p1 := filepath.Join(inDir, "in1.c1z") + p2 := filepath.Join(inDir, "in2.c1z") + s1 := buildPebbleInput(t, ctx, p1, connectorstore.SyncTypeFull, "g-shared", "g-only1") + s2 := buildPebbleInput(t, ctx, p2, connectorstore.SyncTypePartial, "g-shared", "g-only2") + + entries := []*CompactableSync{{FilePath: p1, SyncID: s1}, {FilePath: p2, SyncID: s2}} + c, cleanup, err := NewCompactor(ctx, outDir, entries, + WithTmpDir(t.TempDir()), WithEngine(dotc1z.EnginePebble), WithPebbleCompactorMode(mode)) + require.NoError(t, err) + defer func() { _ = cleanup() }() + + out, err := c.Compact(ctx) + require.NoError(t, err) + require.NotNil(t, out) + + store, err := dotc1z.NewStore(ctx, out.FilePath, dotc1z.WithReadOnly(true), dotc1z.WithTmpDir(t.TempDir())) + require.NoError(t, err) + defer store.Close(ctx) + eng, ok := enginepkg.AsEngine(store) + require.True(t, ok, "compacted output must be a pebble engine") + require.NoError(t, store.SetCurrentSync(ctx, out.SyncID)) + + gotRoot, ok, err := eng.GetGrantDigestGlobalRoot(ctx) + require.NoError(t, err) + require.True(t, ok, "compacted output must carry a built grant digest") + require.EqualValues(t, 3, gotRoot.Count, "digest built over the deduped union of 3 grants") + + // Oracle: an independent from-scratch build over the same + // final records. + oraclePath := filepath.Join(outDir, "oracle-"+string(mode)+".c1z") + oracleSyncID := buildOracleFromCompacted(t, ctx, out, oraclePath) + oracleStore, err := dotc1z.NewStore(ctx, oraclePath, dotc1z.WithReadOnly(true), dotc1z.WithTmpDir(t.TempDir())) + require.NoError(t, err) + defer oracleStore.Close(ctx) + oracleEng, ok := enginepkg.AsEngine(oracleStore) + require.True(t, ok) + require.NoError(t, oracleStore.SetCurrentSync(ctx, oracleSyncID)) + + wantRoot, ok, err := oracleEng.GetGrantDigestGlobalRoot(ctx) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, wantRoot.Count, gotRoot.Count, "compacted vs. from-scratch grant count") + require.True(t, bytes.Equal(wantRoot.Hash, gotRoot.Hash), + "compacted digest %x must byte-equal a from-scratch build over the same final grants %x", gotRoot.Hash, wantRoot.Hash) + + // The saved manifest header carries the same root, readable + // without unpacking the payload. + f, err := os.Open(out.FilePath) + require.NoError(t, err) + defer f.Close() + m, err := formatv3.ReadManifestHeader(f) + require.NoError(t, err) + mRoot := m.GetGrantDigestRoot() + require.NotNil(t, mRoot, "manifest must carry the grant digest root") + require.Equal(t, gotRoot.Count, mRoot.GetCount()) + require.True(t, bytes.Equal(gotRoot.Hash, mRoot.GetXorDigest())) + }) + } +} diff --git a/pkg/synccompactor/compactor_pebble.go b/pkg/synccompactor/compactor_pebble.go index 5b4f7b83b..5b26497fd 100644 --- a/pkg/synccompactor/compactor_pebble.go +++ b/pkg/synccompactor/compactor_pebble.go @@ -571,6 +571,10 @@ func (c *Compactor) compactPebbleFold(ctx context.Context) (string, error) { } } + if err := rebuildCompactedGrantDigests(ctx, destEng); err != nil { + return "", fmt.Errorf("compactPebbleFold: %w", err) + } + // Optionally compact the folded LSM before save. Off by default: // a compaction rewrites the SSTs that overlap the partials' writes // — for scattered overrides that is most of the base — which both @@ -659,6 +663,54 @@ func (c *Compactor) compactPebbleFold(ctx context.Context) (string, error) { return newSyncID, nil } +// rebuildCompactedGrantDigests builds the by_entitlement_principal_hash +// index and per-entitlement grant digests (plus the whole-file root, +// see manifest.go) for a compacted output, once the merge has finished +// writing its final grant keyspace — so a compacted file carries built +// digests indistinguishable from a seal-time build, instead of shipping +// with none (every merge strategy writes the final grant records +// through its own path — raw keep-newer for fold, run-file/SST +// materialization for k-way and overlay — and none of them maintains +// the hash index or digests inline). +// +// This reuses Engine.BuildGrantDigests verbatim: the SAME standalone +// build EndSync runs when a sync's deferred index pass never fired +// (grants already have a correct by_principal / by_needs_expansion, +// nothing to rebuild there). It scans the primary grants exactly once — +// it does NOT re-derive by_principal or rewrite the primary grant +// keyspace, so it does not repeat the O(grants) rebuild work the merge +// strategies already did. Because every hash-index row and digest node +// is recomputed from scratch off the FINAL winner records, there is no +// verbatim-copy path that could carry forward a superseded row's stale +// content hash — unlike a fused emit-during-merge design, this build +// has no notion of "copy an index entry from a source", so the classic +// hazard (two sources sharing a grant identity but different source +// sets, whose content hash covers the source set) cannot arise: every +// row is derived from the one winner value that survived the merge. +// +// Failure policy matches the seal build exactly (same function): an +// error other than context cancellation drops all digest state and +// logs, and returns nil — a compacted file with no digests is safe +// (present-means-exact), so a digest-build hiccup must never fail the +// compaction. Interrupt/restart safety is free: this runs once, after +// the merge's writes are already durably committed, so a crash or +// cancellation before this call simply leaves the output with no +// digest state at all (never a partial one) — the conservative design +// the digest package's present-means-exact contract calls for. +// +// No-op when the destination engine was opened with the digest index +// disabled (WithGrantDigestIndex(false)), matching EndSync's own gate +// (GrantDigestIndexEnabled). +func rebuildCompactedGrantDigests(ctx context.Context, destEng *enginepkg.Engine) error { + if !destEng.GrantDigestIndexEnabled() { + return nil + } + if err := destEng.BuildGrantDigests(ctx); err != nil { + return fmt.Errorf("build grant digests: %w", err) + } + return nil +} + // runPebbleRebuild is the standard (non-fold) Pebble compaction: start // an empty partial sync on the dest and merge every input into it. // compactPebble updates the sync's type to the union of the input @@ -1038,6 +1090,10 @@ func (c *Compactor) compactPebble(ctx context.Context, newSyncId string) error { return fmt.Errorf("compactPebble: merge: %w", err) } + if err := rebuildCompactedGrantDigests(ctx, destEng); err != nil { + return fmt.Errorf("compactPebble: %w", err) + } + // Set the compacted sync_run's type + ended_at to the union / max // across the inputs so downstream gating (e.g. grant expansion) // behaves identically to the sqlite path, then recompute stats. diff --git a/pkg/synccompactor/pebble/kway_bench_test.go b/pkg/synccompactor/pebble/kway_bench_test.go index f6494a10f..855ef02a5 100644 --- a/pkg/synccompactor/pebble/kway_bench_test.go +++ b/pkg/synccompactor/pebble/kway_bench_test.go @@ -18,10 +18,22 @@ import ( enginepkg "github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble" ) -func BenchmarkMergeFilesIntoKWay(b *testing.B) { +// kwayBenchSourceCount / kwayBenchGrantsPerSource fix the "syncs=50" +// shape referenced by RFC 0003 Deliverable 1's verification +// requirement: BenchmarkMergeFilesIntoKWay is the merge-only baseline, +// BenchmarkMergeFilesIntoKWayWithGrantDigests adds the post-merge +// digest build compaction now performs, and the delta between the two +// is the wall-clock cost that build adds at this scale. +const ( + kwayBenchSourceCount = 50 + kwayBenchGrantsPerSource = 2500 +) + +func buildKWayBenchSources(b *testing.B) (string, []SourceFile) { + b.Helper() ctx := context.Background() - const sourceCount = 50 - const grantsPerSource = 2500 + const sourceCount = kwayBenchSourceCount + const grantsPerSource = kwayBenchGrantsPerSource fixtureDir := b.TempDir() sources := make([]SourceFile, 0, sourceCount) @@ -86,10 +98,18 @@ func BenchmarkMergeFilesIntoKWay(b *testing.B) { } sources = append(sources, SourceFile{Path: path, SyncID: syncID}) } + return fixtureDir, sources +} + +// BenchmarkMergeFilesIntoKWay is the merge-only baseline: no grant +// digest build, matching pre-Deliverable-1 compaction output. +func BenchmarkMergeFilesIntoKWay(b *testing.B) { + ctx := context.Background() + fixtureDir, sources := buildKWayBenchSources(b) b.ReportAllocs() - b.ReportMetric(sourceCount, "sources/op") - b.ReportMetric(grantsPerSource, "grants_per_source") + b.ReportMetric(kwayBenchSourceCount, "sources/op") + b.ReportMetric(kwayBenchGrantsPerSource, "grants_per_source") b.ResetTimer() for i := 0; i < b.N; i++ { dest, _ := benchNewEngine(b, fmt.Sprintf("dest-%d", i)) @@ -106,3 +126,39 @@ func BenchmarkMergeFilesIntoKWay(b *testing.B) { } } } + +// BenchmarkMergeFilesIntoKWayWithGrantDigests is +// BenchmarkMergeFilesIntoKWay plus the post-merge BuildGrantDigests +// call the compactor now runs on every compacted output (RFC 0003 +// Deliverable 1, compactor_pebble.go's rebuildCompactedGrantDigests). +// The delta against BenchmarkMergeFilesIntoKWay is the wall-clock cost +// that build adds at the syncs=50 / grants=125000 scale. +func BenchmarkMergeFilesIntoKWayWithGrantDigests(b *testing.B) { + ctx := context.Background() + fixtureDir, sources := buildKWayBenchSources(b) + + b.ReportAllocs() + b.ReportMetric(kwayBenchSourceCount, "sources/op") + b.ReportMetric(kwayBenchGrantsPerSource, "grants_per_source") + b.ResetTimer() + for i := 0; i < b.N; i++ { + dest, _ := benchNewEngine(b, fmt.Sprintf("dest-%d", i)) + destSyncID := ksuid.New().String() + tmpDir := filepath.Join(fixtureDir, fmt.Sprintf("tmp-%d", i)) + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + b.Fatal(err) + } + if err := dest.SetCurrentSync(destSyncID); err != nil { + b.Fatal(err) + } + if _, err := MergeFilesInto(ctx, dest, sources, destSyncID, tmpDir); err != nil { + b.Fatal(err) + } + if err := dest.BuildGrantDigests(ctx); err != nil { + b.Fatal(err) + } + if err := dest.Close(); err != nil { + b.Fatal(err) + } + } +} diff --git a/proto/c1/c1z/v3/manifest.proto b/proto/c1/c1z/v3/manifest.proto index 837dd3eeb..fbda58999 100644 --- a/proto/c1/c1z/v3/manifest.proto +++ b/proto/c1/c1z/v3/manifest.proto @@ -75,6 +75,34 @@ message C1ZManifestV3 { // tooling inspect legacy-vs-structured index state without extracting the // Pebble payload. PebbleIdIndexFormat pebble_id_index_format = 42; + + // Whole-file grant digest root: the XOR fold of every per-entitlement + // grant-digest root's content-hash XOR in the file's single sync (i.e. + // the fold of every entitlement's digest, not just entitlements with + // grants), plus the total grant count. Lets a consumer answer "did + // anything change?" from a header read alone, without unpacking the + // Pebble payload. Absent means "no digest built for this file" (older + // writers, or a file whose digest state was invalidated after the + // last save) — never "zero grants"; see + // c1/dotc1z/engine/pebble/digest.go's present-means-exact contract. + // Additive field: old readers ignore it, old files simply lack it. + GrantDigestRoot grant_digest_root = 43; +} + +// GrantDigestRoot is the whole-file grant digest: present iff the +// engine successfully built grant digests for the file's sync (seal +// time, or a compaction that ran the same build). See +// c1.c1z.v3.C1ZManifestV3.grant_digest_root. +message GrantDigestRoot { + // XOR of every grant's content hash in the sync. hashLen (8) bytes. + bytes xor_digest = 1; + // Total grant count the digest was built over. + int64 count = 2; + // Version of the grant content-hash / bucket-hash ABI (grant_digest.go) + // this root was computed under. A future ABI bump changes this, which + // makes roots computed under different versions incomparable by + // construction instead of silently comparing unrelated hash schemes. + uint32 abi_version = 3; } enum PebbleIdIndexFormat { From 015af42e90a2b0f65e34bfc5a166cf99965e140b Mon Sep 17 00:00:00 2001 From: MJ Palanker Date: Wed, 8 Jul 2026 15:33:47 -0700 Subject: [PATCH 04/12] repair digests when possible instead of a full rebuild --- pkg/dotc1z/engine/pebble/adapter.go | 22 +- pkg/dotc1z/engine/pebble/digest.go | 9 + .../engine/pebble/endsync_repair_test.go | 184 +++++++ .../engine/pebble/grant_digest_build.go | 29 +- .../engine/pebble/grant_digest_repair.go | 484 ++++++++++++++++++ .../engine/pebble/grant_digest_repair_test.go | 214 ++++++++ .../compactor_grant_digest_test.go | 228 ++++++++- pkg/synccompactor/compactor_pebble.go | 66 ++- pkg/synccompactor/pebble/merge.go | 59 ++- pkg/synccompactor/pebble/merge_test.go | 49 ++ 10 files changed, 1313 insertions(+), 31 deletions(-) create mode 100644 pkg/dotc1z/engine/pebble/endsync_repair_test.go create mode 100644 pkg/dotc1z/engine/pebble/grant_digest_repair.go create mode 100644 pkg/dotc1z/engine/pebble/grant_digest_repair_test.go diff --git a/pkg/dotc1z/engine/pebble/adapter.go b/pkg/dotc1z/engine/pebble/adapter.go index 512de090b..7eec0b8cc 100644 --- a/pkg/dotc1z/engine/pebble/adapter.go +++ b/pkg/dotc1z/engine/pebble/adapter.go @@ -348,13 +348,21 @@ func (e *Engine) endSyncFinalize(ctx context.Context, existing *v3.SyncRunRecord } else if e.GrantDigestIndexEnabled() { // The deferred pass didn't run (no grant went through the // deferred index paths — inline-index writes like PutGrantRecords - // never arm the marker), but digests are built at every seal: - // run the standalone build, which shares the fused build's - // merge/fold/ingest machinery over its own grant scan. Build - // failures are downgraded to a loud digest-state drop inside; - // an error surfacing here (cancellation, drop failure) is fatal. - if err := e.BuildGrantDigests(ctx); err != nil { - return fmt.Errorf("EndSync: build grant digests: %w", err) + // never arm the marker). RepairMissingGrantDigests reduces to a + // full BuildGrantDigests-equivalent scan for the common case (a + // brand-new sync always has grantDigestsPresent false, since + // ResetForNewSync excised the digest keyspace at StartNewSync) — + // no behavior change there. It only does LESS work when this + // EndSync is a second call on an already-digested sync that was + // rebound via SetCurrentSync rather than started fresh (grant + // expansion's own follow-up sync is exactly this shape): trusting + // a still-valid whole-file root outright, or rebuilding only the + // entitlements invalidated since the prior seal, instead of + // rescanning every grant in the file again. Build failures are + // downgraded to a loud digest-state drop inside; an error + // surfacing here (cancellation, drop failure) is fatal. + if err := e.RepairMissingGrantDigests(ctx); err != nil { + return fmt.Errorf("EndSync: repair grant digests: %w", err) } } updated := v3.SyncRunRecord_builder{ diff --git a/pkg/dotc1z/engine/pebble/digest.go b/pkg/dotc1z/engine/pebble/digest.go index dc6448192..e17653bea 100644 --- a/pkg/dotc1z/engine/pebble/digest.go +++ b/pkg/dotc1z/engine/pebble/digest.go @@ -412,6 +412,15 @@ func (e *Engine) foldPartitionNodes(ctx context.Context, spec digestIndexSpec, p // that the natural count→width mapping would only produce at a very // large record count, which is how the cross-width comparison path gets // covered without seeding hundreds of thousands of records. +// +// Test-only today (no production caller). If a future production path +// reuses this for the grant digest specifically, it MUST also either +// invalidate or recompute the whole-file global root +// (globalGrantDigestNodeKey) alongside it: +// RepairMissingGrantDigests's fast path trusts the global root's mere +// presence to mean every partition is present and correct, and this +// function rewrites one partition's content without touching that +// root at all. func (e *Engine) buildPartitionDigestAtWidth(ctx context.Context, spec digestIndexSpec, partition string, widthBits int) error { nodeLower := encodeDigestPartitionPrefix(spec.indexID, partition) nodeUpper := upperBoundOf(nodeLower) diff --git a/pkg/dotc1z/engine/pebble/endsync_repair_test.go b/pkg/dotc1z/engine/pebble/endsync_repair_test.go new file mode 100644 index 000000000..e50cf44b6 --- /dev/null +++ b/pkg/dotc1z/engine/pebble/endsync_repair_test.go @@ -0,0 +1,184 @@ +package pebble + +import ( + "bytes" + "context" + "sync" + "testing" + + "github.com/segmentio/ksuid" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + + v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3" + "github.com/conductorone/baton-sdk/pkg/connectorstore" +) + +// capturingCore/newCapturingLogger let a test observe the zap messages +// EndSync's finalize logs, without hooking any production code — +// used here to prove a SECOND EndSync on an already-digested, +// rebound sync takes the targeted repair path (RepairMissingGrantDigests) +// rather than a full from-scratch rescan (BuildGrantDigests). +type capturingCore struct { + zapcore.LevelEnabler + mu *sync.Mutex + messages *[]string +} + +func newCapturingLogger() (*zap.Logger, func() []string) { + mu := &sync.Mutex{} + messages := &[]string{} + core := capturingCore{LevelEnabler: zapcore.DebugLevel, mu: mu, messages: messages} + return zap.New(core), func() []string { + mu.Lock() + defer mu.Unlock() + out := make([]string, len(*messages)) + copy(out, *messages) + return out + } +} + +func (c capturingCore) With([]zapcore.Field) zapcore.Core { return c } +func (c capturingCore) Check(e zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry { + if c.Enabled(e.Level) { + return ce.AddCore(e, c) + } + return ce +} +func (c capturingCore) Write(e zapcore.Entry, _ []zapcore.Field) error { + c.mu.Lock() + defer c.mu.Unlock() + *c.messages = append(*c.messages, e.Message) + return nil +} +func (c capturingCore) Sync() error { return nil } + +// TestEndSyncSecondCallTakesTargetedRepairPath is the core pin for +// wiring RepairMissingGrantDigests into Adapter.endSyncFinalize: a +// SECOND EndSync on a sync that was already fully digested and is +// REBOUND (SetCurrentSync, not StartNewSync — the shape grant +// expansion's own follow-up sync produces) must take the targeted +// repair path for the one entitlement a post-seal write touched, +// leaving every other entitlement's digest nodes byte-identical to +// what the first EndSync built — not a full rescan of every grant in +// the file. +func TestEndSyncSecondCallTakesTargetedRepairPath(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + a := NewAdapter(e) + + syncID, err := a.StartNewSync(ctx, connectorstore.SyncTypeFull, "") + if err != nil { + t.Fatalf("StartNewSync: %v", err) + } + putEnt(t, e, ctx, "ent-A") + putEnt(t, e, ctx, "ent-B") + grantsA := make([]*v3.GrantRecord, 0, 20) + for range 20 { + id := ksuid.New().String() + grantsA = append(grantsA, makeGrant("", id, "ent-A", id)) + } + if err := e.PutGrantRecords(ctx, grantsA...); err != nil { + t.Fatalf("PutGrantRecords(ent-A): %v", err) + } + if err := e.PutGrantRecords(ctx, makeGrant("", "gb1", "ent-B", "bob")); err != nil { + t.Fatalf("PutGrantRecords(ent-B): %v", err) + } + + // First EndSync: fresh sync, grantDigestsPresent starts false, so + // RepairMissingGrantDigests must fall back to the full build — + // exactly today's behavior, no regression. + if err := a.EndSync(ctx); err != nil { + t.Fatalf("first EndSync: %v", err) + } + afterFirstSeal := dumpDigestNodes(t, e) + if len(afterFirstSeal) == 0 { + t.Fatal("expected digest nodes after first EndSync") + } + + // Rebind (NOT StartNewSync — that would excise the digest keyspace + // and defeat the whole point) and write one more grant under ent-A + // only. This is the shape grant expansion's follow-up sync takes: + // resume an already-sealed sync, write some grants, end it again. + if err := a.SetCurrentSync(ctx, syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + if err := e.PutGrantRecord(ctx, makeGrant("", "ga-extra", "ent-A", "extra-user")); err != nil { + t.Fatalf("PutGrantRecord (post-seal): %v", err) + } + + // Sanity: only ent-A (and the global root) should be invalidated by + // that write. + if _, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-A")); err != nil || ok { + t.Fatalf("ent-A root after post-seal write: ok=%v err=%v, want missing", ok, err) + } + rootB, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-B")) + if err != nil || !ok { + t.Fatalf("ent-B root after post-seal write: ok=%v err=%v, want intact", ok, err) + } + if rootB.Count != 1 { + t.Fatalf("ent-B root count = %d, want 1 (untouched)", rootB.Count) + } + + logger, capture := newCapturingLogger() + endSyncCtx := ctxzap.ToContext(ctx, logger) + if err := a.EndSync(endSyncCtx); err != nil { + t.Fatalf("second EndSync: %v", err) + } + + var sawTargetedRepair, sawFullBuild bool + for _, msg := range capture() { + switch msg { + case "grant digest repair: rebuilt missing entitlement partitions": + sawTargetedRepair = true + case "grant digest build complete": + sawFullBuild = true + } + } + if !sawTargetedRepair { + t.Error("expected the second EndSync to log a targeted repair") + } + if sawFullBuild { + t.Error("second EndSync ran a full from-scratch rebuild instead of the targeted repair") + } + + // ent-B's digest nodes must be byte-identical to what the FIRST + // seal produced — proof the targeted repair never touched it. + afterSecondSeal := dumpDigestNodes(t, e) + entBPrefix := encodeDigestPartitionPrefix(grantDigestSpec.indexID, testEntPartition("ent-B")) + for k, v := range afterFirstSeal { + if !bytesHasPrefix(k, entBPrefix) { + continue + } + got, ok := afterSecondSeal[k] + if !ok { + t.Fatalf("ent-B node %x missing after targeted repair", k) + } + if !bytes.Equal(got, v) { + t.Fatalf("ent-B node %x changed by targeted repair: got %x, want %x", k, got, v) + } + } + + // ent-A must be correctly repaired (21 grants now), and the global + // root must be recomputed and correct. + rootA, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-A")) + if err != nil || !ok { + t.Fatalf("ent-A root after repair: ok=%v err=%v", ok, err) + } + if rootA.Count != 21 { + t.Fatalf("ent-A root count = %d, want 21", rootA.Count) + } + globalRoot, ok, err := e.GetGrantDigestGlobalRoot(ctx) + if err != nil || !ok { + t.Fatalf("global root after repair: ok=%v err=%v", ok, err) + } + if globalRoot.Count != 22 { + t.Fatalf("global root count = %d, want 22 (21 ent-A + 1 ent-B)", globalRoot.Count) + } +} + +func bytesHasPrefix(k string, prefix []byte) bool { + return len(k) >= len(prefix) && k[:len(prefix)] == string(prefix) +} diff --git a/pkg/dotc1z/engine/pebble/grant_digest_build.go b/pkg/dotc1z/engine/pebble/grant_digest_build.go index 6e852f3d2..7a8251c02 100644 --- a/pkg/dotc1z/engine/pebble/grant_digest_build.go +++ b/pkg/dotc1z/engine/pebble/grant_digest_build.go @@ -534,16 +534,25 @@ func (e *Engine) writeMissingEntitlementDigestRoots(ctx context.Context, opts *p return batch.Commit(opts) } -// BuildGrantDigests is the standalone digest build for an EndSync whose -// deferred pass never ran: grants written through PutGrantRecords / -// UnsafePutUniqueGrantRecords / the bulk importer maintain by_principal -// inline and never arm the deferred-index marker, so a sync without -// expansion-path writes reaches EndSync with deferredIdxPending false — -// and possibly millions of grants. Digests are built at EVERY seal, so -// this runs its own primary-grant scan feeding the same spill-sorter → -// merge+fold → ingest machinery the fused pass uses -// (buildGrantDigestsFromSpill). When the deferred pass DOES run, the -// fused build supersedes this (same output, one shared scan). +// BuildGrantDigests is the full-file standalone digest build: grants +// written through PutGrantRecords / UnsafePutUniqueGrantRecords / the +// bulk importer maintain by_principal inline and never arm the +// deferred-index marker, so a sync without expansion-path writes +// reaches EndSync with deferredIdxPending false — and possibly +// millions of grants. This runs its own primary-grant scan feeding the +// same spill-sorter → merge+fold → ingest machinery the fused pass +// uses (buildGrantDigestsFromSpill). When the deferred pass DOES run, +// the fused build supersedes this (same output, one shared scan). +// +// Callers: Adapter.endSyncFinalize calls RepairMissingGrantDigests +// rather than this directly — RepairMissingGrantDigests falls back to +// this exact function whenever no digest exists yet at all +// (grantDigestsPresent false), which is always true for a brand-new +// sync (ResetForNewSync excises the digest keyspace at StartNewSync), +// so the common case is unchanged. This function stays exported and +// self-sufficient for direct callers (tests, explicit repair) that +// want an unconditional from-scratch rebuild regardless of what +// already exists. // // Failure semantics match the fused pass: any build error (except // context cancellation, which stays fatal) downgrades to a loud drop of diff --git a/pkg/dotc1z/engine/pebble/grant_digest_repair.go b/pkg/dotc1z/engine/pebble/grant_digest_repair.go new file mode 100644 index 000000000..345c906e0 --- /dev/null +++ b/pkg/dotc1z/engine/pebble/grant_digest_repair.go @@ -0,0 +1,484 @@ +package pebble + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "sort" + + "github.com/cockroachdb/pebble/v2" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" +) + +// Targeted repair of the grant hash index + digests: heal exactly the +// entitlement partitions missing a digest root, trusting every +// partition that already has one, instead of the seal-time build's +// unconditional full-file rebuild. This is the load-bearing primitive +// behind compaction's fold strategy carrying built digests without +// paying an O(base) rebuild on every fold (see +// InvalidateGrantDigestPartitions and RepairMissingGrantDigests below, +// and compactPebbleFold in pkg/synccompactor). +// +// Contract: present-means-exact (digest.go) still holds throughout — +// nothing here ever leaves a partition looking present with wrong +// data. A partition is either fully correct (untouched, or freshly +// rebuilt from its own primaries) or absent (never repaired, never +// half-built); the whole-file global root is recomputed whenever +// anything changes, from the digest keyspace itself (cheap — bounded +// by entitlement count, not grant count), so it can never drift from +// the sum of what's actually stored. + +// GrantPartitionFromPrimaryKey returns the entitlement partition — the +// same string digestPartitionForEntitlement (and this file's repair +// functions) key off — that a raw grant primary key belongs to, by +// splicing the key rather than decoding it. For callers that mutate +// grants through the raw DB handle and need to report which +// partitions they touched without ever constructing an +// entitlementIdentity (the fold compactor's raw merge — see +// InvalidateGrantDigestPartitions). ok is false for a key that does +// not parse as a 6-segment grant identity. +func GrantPartitionFromPrimaryKey(key []byte) (string, bool) { + sep4, ok := splitGrantPrimaryKey(key) + if !ok { + return "", false + } + return string(key[grantPrimaryKeyPrefixLen:sep4]), true +} + +// grantPrimaryEntitlementBoundsFromPartition returns the primary grant +// key range for one entitlement partition, derived directly from the +// raw partition bytes. Identical to what encodeGrantPrimaryEntitlementPrefix +// produces from a decoded identity, since partition IS that identity's +// encoded tail (digestPartitionForEntitlement) — this just skips +// needing an entitlementIdentity value to get there. +func grantPrimaryEntitlementBoundsFromPartition(partition string) ([]byte, []byte) { + lower := make([]byte, 0, grantPrimaryKeyPrefixLen+len(partition)+1) + lower = append(lower, versionV3, typeGrant, 0) + lower = append(lower, partition...) + lower = append(lower, 0) + return lower, upperBoundOf(lower) +} + +// isGrantDigestRootKey reports whether key is a per-entitlement grant- +// digest ROOT node (level digestLevelRoot) for the grant digest index +// specifically — the unit recomputeGrantDigestGlobalRootLocked folds +// into the whole-file root. The global root's own key uses +// digestLevelGlobalRoot, a different level value, so it never matches +// here by construction (globalGrantDigestNodeKey). Finding the level +// byte by scanning for the next bare 0x00 after indexID is safe +// because the partition region is TUPLE-ESCAPED (encodeDigestPartitionPrefix), +// unlike the hash-index key's raw splice — a bare 0x00 there can only +// be the separator ending it, never embedded partition data. +func isGrantDigestRootKey(key []byte) bool { + const headerLen = 3 // versionV3, typeDigest, indexID + if len(key) < headerLen+1 || key[0] != versionV3 || key[1] != typeDigest || key[2] != grantDigestSpec.indexID { + return false + } + if key[headerLen] != 0 { + return false + } + _, afterSep, found := bytes.Cut(key[headerLen+1:], []byte{0}) + if !found { + return false + } + return len(afterSep) == 1 && afterSep[0] == digestLevelRoot +} + +// InvalidateGrantDigestPartitions drops the digest + hash-index state +// for exactly the given entitlement partitions, plus the whole-file +// global root — which folds over every partition and goes stale the +// moment any one of them does. Every other partition's state is left +// completely untouched. +// +// For callers that mutate the primary grant keyspace directly, +// bypassing the engine's own per-record invalidation path +// (stageGrantDigestInvalidation) — the fold compactor's raw merge — to +// report exactly what they changed. RepairMissingGrantDigests +// recomputes exactly what this drops (and only that), so the pair +// gives compaction per-entitlement-scoped digest maintenance without +// an O(file) rebuild. No-op when partitions is empty or no digest has +// ever been built for this file. +func (e *Engine) InvalidateGrantDigestPartitions(ctx context.Context, partitions []string) error { + if len(partitions) == 0 || !e.grantDigestsPresent.Load() { + return nil + } + return e.withWrite(func() error { + batch := e.db.NewBatch() + defer batch.Close() + for _, partition := range partitions { + if err := dropPartitionDigest(batch, grantDigestSpec, partition); err != nil { + return err + } + lo := encodeGrantByEntPrincHashEntPrefix(partition) + if err := batch.DeleteRange(lo, upperBoundOf(lo), nil); err != nil { + return err + } + } + if err := batch.Delete(globalGrantDigestNodeKey(), nil); err != nil { + return err + } + opts := writeOpts(e.opts.durability) + if e.IsFreshSync() { + opts = pebble.NoSync + } + return batch.Commit(opts) + }) +} + +// RepairMissingGrantDigests rebuilds the by_entitlement_principal_hash +// rows and digest nodes for exactly the entitlements missing a digest +// root — trusting every entitlement that already has one — then +// recomputes the whole-file global root if anything changed. The +// whole operation holds the engine's write lock for its entire +// duration, like BuildGrantDigests/BuildDeferredGrantIndexes, so no +// concurrent grant write can interleave with a partition mid-repair +// and leave it silently wrong. +// +// When digests were never built for this file at all (or were dropped +// wholesale — grantDigestsPresent false), a full BuildGrantDigests is +// strictly cheaper than a point-Get per entitlement that would all +// miss anyway, so this delegates to it instead. +// +// Failure policy: a repair error for one partition is logged and that +// partition is left missing (safe — present-means-exact — and +// self-healing on the next repair or seal) rather than failing the +// whole call; only context cancellation propagates. Unlike a fused +// full rebuild, a partial failure here does not require dropping +// already-repaired partitions — each one commits its own correct, +// complete state independently. +// +// Orphan grants (an entitlement identity with grants but no +// entitlement RECORD) are not discovered by the missing-partition scan +// below, which walks entitlement records — matching the scope of +// writeMissingEntitlementDigestRoots's own backfill. An orphan that +// already has a valid root from an earlier full build is still +// correctly folded into the recomputed global root, since that step +// scans the digest keyspace itself, not entitlement records. +// +// Called from Adapter.endSyncFinalize in place of a bare +// BuildGrantDigests whenever the deferred (by_principal-rebuilding) +// pass didn't run: for a brand-new sync grantDigestsPresent is always +// false (ResetForNewSync excises the digest keyspace at StartNewSync), +// so this reduces to exactly BuildGrantDigests's existing full scan — +// no behavior change for the common single-EndSync-per-sync case. It +// only diverges — into the cheap fast path or the targeted repair — +// when EndSync runs a SECOND time on an already-digested, rebound sync +// (SetCurrentSync, not StartNewSync) without a fresh reset in between: +// the shape grant-expansion's own follow-up sync produces, and exactly +// the case that made a full rebuild here wasteful (see RFC 0003 / +// compactPebbleFold). +// +// Like BuildGrantDigests, a failure that isn't context cancellation is +// logged and downgraded to a full state drop rather than propagated — +// digest problems must never fail EndSync. This only covers failures +// in the outer scan/orchestration (e.g. the entitlement-keyspace scan +// itself erroring): a failure repairing one INDIVIDUAL partition is +// already handled without dropping anything (see +// repairMissingGrantDigestsLocked). +func (e *Engine) RepairMissingGrantDigests(ctx context.Context) error { + if !e.opts.grantDigestIndex { + return nil + } + if !e.grantDigestsPresent.Load() { + return e.BuildGrantDigests(ctx) + } + err := e.repairMissingGrantDigestsAttempt(ctx) + if err == nil { + return nil + } + if ctx.Err() != nil { + return err + } + ctxzap.Extract(ctx).Error("grant digest repair failed; dropping digest state — grant-diff callers must re-read grants until the next successful repair or seal", + zap.Error(err)) + if dropErr := e.withWriteAllowSealed(func() error { return e.dropAllGrantDigestStateLocked() }); dropErr != nil { + return fmt.Errorf("RepairMissingGrantDigests: drop grant digest state after failed repair: %w", dropErr) + } + return nil +} + +// repairMissingGrantDigestsAttempt is the un-wrapped repair attempt: +// the fast path (trust a present global root) plus the locked +// scan-and-repair. RepairMissingGrantDigests applies the uniform +// "downgrade to a full drop, log, never fail the caller" policy to +// whatever this returns. +func (e *Engine) repairMissingGrantDigestsAttempt(ctx context.Context) error { + // Fast path: EVERY code path that can make a single entitlement's + // digest go missing also drops the whole-file global root in the + // same commit (stageGrantDigestInvalidation, InvalidateGrantDigestPartitions, + // the Drop* family — see their doc comments), and the loop below + // only ever writes the global root back once it has verified NOTHING + // is missing (repaired everything it found, zero failures). So the + // root's mere presence certifies every entitlement's digest is + // present and correct, without walking the entitlement keyspace at + // all: one point Get instead of a scan, for what should be the + // overwhelmingly common "nothing to repair" case (e.g. a fold that + // invalidated nothing, or a periodic health-check call). + if _, ok, err := e.GetGrantDigestGlobalRoot(ctx); err != nil { + return err + } else if ok { + return nil + } + return e.withWriteAllowSealed(func() error { + return e.repairMissingGrantDigestsLocked(ctx) + }) +} + +func (e *Engine) repairMissingGrantDigestsLocked(ctx context.Context) error { + l := ctxzap.Extract(ctx) + missing, err := e.findMissingGrantDigestPartitionsLocked(ctx) + if err != nil { + return err + } + var repaired, failed int + for _, partition := range missing { + if err := ctx.Err(); err != nil { + return err + } + if err := e.repairOneGrantDigestPartitionLocked(ctx, partition); err != nil { + if ctx.Err() != nil { + return err + } + l.Error("grant digest repair: rebuilding one entitlement partition failed; it remains missing (recalculated on the next repair or seal)", + zap.Error(err)) + failed++ + continue + } + repaired++ + } + if repaired > 0 || failed > 0 { + l.Info("grant digest repair: rebuilt missing entitlement partitions", + zap.Int("repaired", repaired), + zap.Int("failed", failed), + zap.Int("candidates", len(missing)), + ) + } + if failed > 0 { + // At least one entitlement is still missing: the whole-file + // root must stay absent too. Writing it now — even folded only + // over what IS present — would make RepairMissingGrantDigests's + // own fast path trust an incomplete file as fully repaired on + // its next call. + return nil + } + if err := e.recomputeGrantDigestGlobalRootLocked(ctx); err != nil { + if ctx.Err() != nil { + return err + } + l.Error("grant digest repair: recomputing the whole-file root failed; it remains missing (recalculated on the next repair or seal)", + zap.Error(err)) + } + return nil +} + +// findMissingGrantDigestPartitionsLocked scans every entitlement +// record and returns the partitions with no stored digest root: one +// point Get per entitlement, no grant scan unless (in the caller) a +// 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) { + iter, err := e.db.NewIter(&pebble.IterOptions{ + LowerBound: EntitlementLowerBound(), + UpperBound: EntitlementUpperBound(), + }) + if err != nil { + return nil, err + } + defer iter.Close() + var missing []string + for iter.First(); iter.Valid(); iter.Next() { + if err := ctx.Err(); err != nil { + return nil, err + } + key := iter.Key() + if len(key) <= grantPrimaryKeyPrefixLen { + continue + } + // The entitlement primary tail IS the digest partition (see the + // partition convention in keys.go) — a raw splice, no decode. + partition := string(key[grantPrimaryKeyPrefixLen:]) + rootKey := encodeDigestNodeKey(grantDigestSpec.indexID, partition, digestLevelRoot, nil) + if _, closer, err := e.db.Get(rootKey); err == nil { + closer.Close() + continue + } else if !errors.Is(err, pebble.ErrNotFound) { + return nil, err + } + missing = append(missing, partition) + } + return missing, iter.Error() +} + +// repairOneGrantDigestPartitionLocked rebuilds BOTH the hash-index +// rows and the digest nodes for one entitlement partition, from a scan +// of its primary grants — it does NOT assume the hash index has +// anything usable for this partition (invalidation always drops both +// together, see InvalidateGrantDigestPartitions / +// stageGrantDigestInvalidation). Must be called with the engine's +// write lock already held (RepairMissingGrantDigests): it commits +// through the raw DB handle directly rather than nesting another +// withWrite call. +// +// One entitlement's grants are expected to fit comfortably in memory +// (this is a targeted repair, not the seal-time build sized for the +// whole file), so this sorts them directly instead of spilling to +// disk like the deferred build's spill-sorter. +func (e *Engine) repairOneGrantDigestPartitionLocked(ctx context.Context, partition string) error { + lower, upper := grantPrimaryEntitlementBoundsFromPartition(partition) + + type hashRow struct { + key []byte + val [hashLen]byte + } + var rows []hashRow + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: lower, UpperBound: upper}) + if err != nil { + return err + } + for iter.First(); iter.Valid(); iter.Next() { + if err := ctx.Err(); err != nil { + _ = iter.Close() + return err + } + key, value := iter.Key(), iter.Value() + sep4, ok := splitGrantPrimaryKey(key) + if !ok { + continue + } + srcs, serr := scanGrantSourceKeysRawBytes(value, nil) + if serr != nil { + _ = iter.Close() + return serr + } + if len(srcs) > 1 { + sortByteSlices(srcs) + } + ch64, _ := grantContentHash64(nil, key[grantPrimaryKeyPrefixLen:], srcs) + bh64 := grantPrincipalBucketHash64(key[sep4+1:]) + idxKey := appendGrantHashIndexKeyFromPrimary(nil, key, sep4, bh64) + var row hashRow + row.key = idxKey + binary.BigEndian.PutUint64(row.val[:], ch64) + rows = append(rows, row) + } + if err := iter.Error(); err != nil { + _ = iter.Close() + return err + } + if err := iter.Close(); err != nil { + return err + } + + sort.Slice(rows, func(i, j int) bool { return bytes.Compare(rows[i].key, rows[j].key) < 0 }) + + opts := writeOpts(e.opts.durability) + if e.IsFreshSync() { + opts = pebble.NoSync + } + + // Pass 1: replace the hash-index range with the freshly-derived + // rows (a clean DeleteRange first: nothing may assume what, if + // anything, was left there). + hiBatch := e.db.NewBatch() + hiLower := encodeGrantByEntPrincHashEntPrefix(partition) + if err := hiBatch.DeleteRange(hiLower, upperBoundOf(hiLower), nil); err != nil { + hiBatch.Close() + return err + } + for i := range rows { + if err := hiBatch.Set(rows[i].key, rows[i].val[:], nil); err != nil { + hiBatch.Close() + return err + } + } + if err := hiBatch.Commit(opts); err != nil { + hiBatch.Close() + return err + } + hiBatch.Close() + + // Pass 2: fold the just-committed hash index into the digest, + // reusing the exact fold logic the seal-time build runs + // (foldPartitionNodes) instead of re-deriving the same computation + // a second way from the in-memory rows. + width := chooseDigestWidth(int64(len(rows))) + rootVal, leaves, err := e.foldPartitionNodes(ctx, grantDigestSpec, partition, width) + if err != nil { + return err + } + digestBatch := e.db.NewBatch() + defer digestBatch.Close() + digestLower := encodeDigestPartitionPrefix(grantDigestSpec.indexID, partition) + if err := digestBatch.DeleteRange(digestLower, upperBoundOf(digestLower), nil); err != nil { + return err + } + rootKey := encodeDigestNodeKey(grantDigestSpec.indexID, partition, digestLevelRoot, nil) + if err := digestBatch.Set(rootKey, rootVal, nil); err != nil { + return err + } + for i := range leaves { + leafKey := encodeDigestNodeKey(grantDigestSpec.indexID, partition, digestLevelLeaf, leaves[i].prefix[:]) + if err := digestBatch.Set(leafKey, leaves[i].val, nil); err != nil { + return err + } + } + if err := digestBatch.Commit(opts); err != nil { + return err + } + e.grantDigestsPresent.Store(true) + return nil +} + +// recomputeGrantDigestGlobalRootLocked folds every CURRENTLY STORED +// per-entitlement grant-digest root into the whole-file global root +// and writes it — a scan of the (small) digest ROOT keyspace, not the +// grants, so it stays cheap even for a whale-scale file (bounded by +// entitlement count, not grant count). Must be called with the +// engine's write lock already held. +func (e *Engine) recomputeGrantDigestGlobalRootLocked(ctx context.Context) error { + var xor [hashLen]byte + var total int64 + iter, err := e.db.NewIter(&pebble.IterOptions{ + LowerBound: DigestLowerBound(), + UpperBound: DigestUpperBound(), + }) + if err != nil { + return err + } + for iter.First(); iter.Valid(); iter.Next() { + if err := ctx.Err(); err != nil { + _ = iter.Close() + return err + } + if !isGrantDigestRootKey(iter.Key()) { + continue + } + _, count, digest, ok := unpackDigestRoot(iter.Value()) + if !ok { + _ = iter.Close() + return fmt.Errorf("recomputeGrantDigestGlobalRoot: malformed root at %x", iter.Key()) + } + xorInto(xor[:], digest) + total += count + } + if err := iter.Error(); err != nil { + _ = iter.Close() + return err + } + if err := iter.Close(); err != nil { + return err + } + opts := writeOpts(e.opts.durability) + if e.IsFreshSync() { + opts = pebble.NoSync + } + if err := e.db.Set(globalGrantDigestNodeKey(), packDigestLeaf(total, xor[:]), opts); err != nil { + return err + } + e.grantDigestsPresent.Store(true) + return nil +} diff --git a/pkg/dotc1z/engine/pebble/grant_digest_repair_test.go b/pkg/dotc1z/engine/pebble/grant_digest_repair_test.go new file mode 100644 index 000000000..4098cb7cb --- /dev/null +++ b/pkg/dotc1z/engine/pebble/grant_digest_repair_test.go @@ -0,0 +1,214 @@ +package pebble + +import ( + "context" + "testing" + + "github.com/cockroachdb/pebble/v2" + "github.com/segmentio/ksuid" + + v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3" +) + +// TestRepairMissingGrantDigestsLeavesGlobalRootMissingOnPartialFailure +// pins the invariant RepairMissingGrantDigests's own fast path relies +// on: if even one invalidated entitlement fails to repair, the +// whole-file global root must stay absent rather than get recomputed +// over an incomplete file — otherwise the NEXT call's fast path +// (trusting the root's mere presence) would wrongly skip the scan and +// leave the failed entitlement missing forever. +func TestRepairMissingGrantDigestsLeavesGlobalRootMissingOnPartialFailure(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + putEnt(t, e, ctx, "ent-good") + putEnt(t, e, ctx, "ent-bad") + if err := e.PutGrantRecords(ctx, + makeGrant("", "g-good", "ent-good", "alice"), + makeGrant("", "g-bad", "ent-bad", "bob"), + ); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + sealGrantDigests(t, e) + + goodPartition := testEntPartition("ent-good") + badPartition := testEntPartition("ent-bad") + if err := e.InvalidateGrantDigestPartitions(ctx, []string{goodPartition, badPartition}); err != nil { + t.Fatalf("InvalidateGrantDigestPartitions: %v", err) + } + + // Corrupt ent-bad's grant VALUE (leaving its key, and ent-good, + // untouched) so its repair scan fails deriving the content hash. + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: GrantLowerBound(), UpperBound: GrantUpperBound()}) + if err != nil { + t.Fatalf("NewIter: %v", err) + } + var badKey []byte + for iter.First(); iter.Valid(); iter.Next() { + if p, ok := GrantPartitionFromPrimaryKey(iter.Key()); ok && p == badPartition { + badKey = append([]byte(nil), iter.Key()...) + } + } + if err := iter.Close(); err != nil { + t.Fatalf("iter close: %v", err) + } + if badKey == nil { + t.Fatal("could not find ent-bad's grant primary key") + } + if err := e.db.Set(badKey, []byte{0xFF, 0xFF, 0xFF}, pebble.NoSync); err != nil { + t.Fatalf("corrupt ent-bad grant value: %v", err) + } + + if err := e.RepairMissingGrantDigests(ctx); err != nil { + t.Fatalf("RepairMissingGrantDigests must not fail the caller: %v", err) + } + + if _, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-good")); err != nil || !ok { + t.Fatalf("ent-good root: ok=%v err=%v, want repaired", ok, err) + } + if _, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-bad")); err != nil || ok { + t.Fatalf("ent-bad root: ok=%v err=%v, want still missing (repair failed)", ok, err) + } + if _, ok, err := e.GetGrantDigestGlobalRoot(ctx); err != nil || ok { + t.Fatalf("global root: ok=%v err=%v, want still missing (repair incomplete)", ok, err) + } +} + +// TestRepairMissingGrantDigestsHealsOnlyInvalidatedPartition is the +// core pin for the targeted repair path: after invalidating ONE +// entitlement's digest + hash-index state out of several, repair must +// reproduce a byte-identical whole-file digest keyspace to the +// original fused build — proving both that the invalidated partition +// is correctly rebuilt AND that every untouched partition (and the +// recomputed whole-file root) is left exactly as it was, not +// coincidentally recomputed to the same values. +func TestRepairMissingGrantDigestsHealsOnlyInvalidatedPartition(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + counts := map[string]int64{"ent-a": 5, "ent-b": 600, "ent-c": 3} + for entID, n := range counts { + putEnt(t, e, ctx, entID) + grants := make([]*v3.GrantRecord, 0, n) + for range n { + grants = append(grants, makeGrant("", ksuid.New().String(), entID, ksuid.New().String())) + } + if err := e.PutGrantRecords(ctx, grants...); err != nil { + t.Fatalf("PutGrantRecords(%s): %v", entID, err) + } + } + sealGrantDigests(t, e) + want := dumpDigestNodes(t, e) + + partition := testEntPartition("ent-b") + if err := e.InvalidateGrantDigestPartitions(ctx, []string{partition}); err != nil { + t.Fatalf("InvalidateGrantDigestPartitions: %v", err) + } + + // Sanity: exactly ent-b and the global root went missing; ent-a and + // ent-c are untouched. + if _, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-b")); err != nil || ok { + t.Fatalf("ent-b root after invalidate: ok=%v err=%v, want missing", ok, err) + } + if _, ok, err := e.GetGrantDigestGlobalRoot(ctx); err != nil || ok { + t.Fatalf("global root after invalidate: ok=%v err=%v, want missing", ok, err) + } + for _, entID := range []string{"ent-a", "ent-c"} { + if _, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity(entID)); err != nil || !ok { + t.Fatalf("%s root after invalidate: ok=%v err=%v, want intact", entID, ok, err) + } + } + + if err := e.RepairMissingGrantDigests(ctx); err != nil { + t.Fatalf("RepairMissingGrantDigests: %v", err) + } + + requireSameDigestNodes(t, dumpDigestNodes(t, e), want) +} + +// TestRepairMissingGrantDigestsNoOpWhenNothingMissing verifies a +// repair call against a fully-built file changes nothing. +func TestRepairMissingGrantDigestsNoOpWhenNothingMissing(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + seedEntitlement(t, e, "ent-A", []*v3.GrantRecord{makeGrant("", "g1", "ent-A", "alice")}) + before := dumpDigestNodes(t, e) + + if err := e.RepairMissingGrantDigests(ctx); err != nil { + t.Fatalf("RepairMissingGrantDigests: %v", err) + } + requireSameDigestNodes(t, dumpDigestNodes(t, e), before) +} + +// TestRepairMissingGrantDigestsFallsBackWhenNeverBuilt verifies that a +// file whose digests were never built at all (grantDigestsPresent +// false) is repaired via a full BuildGrantDigests rather than a +// point-Get-per-entitlement scan that would all miss anyway. +func TestRepairMissingGrantDigestsFallsBackWhenNeverBuilt(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + putEnt(t, e, ctx, "ent-A") + if err := e.PutGrantRecords(ctx, makeGrant("", "g1", "ent-A", "alice")); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + + if err := e.RepairMissingGrantDigests(ctx); err != nil { + t.Fatalf("RepairMissingGrantDigests: %v", err) + } + + root, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-A")) + if err != nil || !ok { + t.Fatalf("ent-A root: ok=%v err=%v", ok, err) + } + if root.Count != 1 { + t.Fatalf("ent-A root count = %d, want 1", root.Count) + } + if _, ok, err := e.GetGrantDigestGlobalRoot(ctx); err != nil || !ok { + t.Fatalf("global root: ok=%v err=%v", ok, err) + } +} + +// TestRepairMissingGrantDigestsDisabledIsNoOp verifies the digest- +// index-disabled gate matches EndSync's own (GrantDigestIndexEnabled). +func TestRepairMissingGrantDigestsDisabledIsNoOp(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t, WithGrantDigestIndex(false)) + if err := e.RepairMissingGrantDigests(ctx); err != nil { + t.Fatalf("RepairMissingGrantDigests: %v", err) + } +} + +// TestGrantPartitionFromPrimaryKey pins the raw splice against the +// decoded-identity form the digest keyspace itself keys off. +func TestGrantPartitionFromPrimaryKey(t *testing.T) { + e, _ := newTestEngine(t) + seedEntitlement(t, e, "ent-A", []*v3.GrantRecord{makeGrant("", "g1", "ent-A", "alice")}) + + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: GrantLowerBound(), UpperBound: GrantUpperBound()}) + if err != nil { + t.Fatalf("NewIter: %v", err) + } + defer iter.Close() + if !iter.First() { + t.Fatal("expected at least one grant primary key") + } + key := append([]byte(nil), iter.Key()...) + + got, ok := GrantPartitionFromPrimaryKey(key) + if !ok { + t.Fatal("GrantPartitionFromPrimaryKey: ok = false") + } + if want := testEntPartition("ent-A"); got != want { + t.Fatalf("partition = %q, want %q", got, want) + } +} diff --git a/pkg/synccompactor/compactor_grant_digest_test.go b/pkg/synccompactor/compactor_grant_digest_test.go index 7d62057d6..58cdebe9c 100644 --- a/pkg/synccompactor/compactor_grant_digest_test.go +++ b/pkg/synccompactor/compactor_grant_digest_test.go @@ -5,17 +5,61 @@ import ( "context" "os" "path/filepath" + "sync" "testing" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/connectorstore" "github.com/conductorone/baton-sdk/pkg/dotc1z" + "github.com/conductorone/baton-sdk/pkg/dotc1z/c1zstore" enginepkg "github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble" formatv3 "github.com/conductorone/baton-sdk/pkg/dotc1z/format/v3" ) +// capturingCore/newCapturingLogger let a test observe the zap messages +// a compaction run logs, without hooking any production code — used +// here to prove the fold's "no grant writes, skip the rebuild" branch +// actually fired, which the resulting digest bytes alone can't show +// (a rebuild over unchanged data would produce the identical bytes). +type capturingCore struct { + zapcore.LevelEnabler + mu *sync.Mutex + messages *[]string +} + +func newCapturingLogger() (*zap.Logger, func() []string) { + mu := &sync.Mutex{} + messages := &[]string{} + core := capturingCore{LevelEnabler: zapcore.DebugLevel, mu: mu, messages: messages} + return zap.New(core), func() []string { + mu.Lock() + defer mu.Unlock() + out := make([]string, len(*messages)) + copy(out, *messages) + return out + } +} + +func (c capturingCore) With([]zapcore.Field) zapcore.Core { return c } +func (c capturingCore) Check(e zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry { + if c.Enabled(e.Level) { + return ce.AddCore(e, c) + } + return ce +} +func (c capturingCore) Write(e zapcore.Entry, _ []zapcore.Field) error { + c.mu.Lock() + defer c.mu.Unlock() + *c.messages = append(*c.messages, e.Message) + return nil +} +func (c capturingCore) Sync() error { return nil } + // buildOracleFromCompacted copies every record the compacted output at // out actually holds into a brand-new Pebble store (via the public v2 // reader/writer surface — no shared state, no internal helpers) and @@ -31,7 +75,7 @@ func buildOracleFromCompacted(t testing.TB, ctx context.Context, out *Compactabl defer src.Close(ctx) require.NoError(t, src.SetCurrentSync(ctx, out.SyncID)) - dst, err := dotc1z.NewStore(ctx, path, dotc1z.WithEngine(dotc1z.EnginePebble), dotc1z.WithTmpDir(t.TempDir())) + dst, err := dotc1z.NewStore(ctx, path, dotc1z.WithEngine(c1zstore.EnginePebble), dotc1z.WithTmpDir(t.TempDir())) require.NoError(t, err) syncID, err := dst.StartNewSync(ctx, connectorstore.SyncTypeFull, "") require.NoError(t, err) @@ -79,7 +123,7 @@ func TestCompactPebbleGrantDigestsBuiltPerMode(t *testing.T) { entries := []*CompactableSync{{FilePath: p1, SyncID: s1}, {FilePath: p2, SyncID: s2}} c, cleanup, err := NewCompactor(ctx, outDir, entries, - WithTmpDir(t.TempDir()), WithEngine(dotc1z.EnginePebble), WithPebbleCompactorMode(mode)) + WithTmpDir(t.TempDir()), WithEngine(c1zstore.EnginePebble), WithPebbleCompactorMode(mode)) require.NoError(t, err) defer func() { _ = cleanup() }() @@ -131,3 +175,183 @@ func TestCompactPebbleGrantDigestsBuiltPerMode(t *testing.T) { }) } } + +// grantDigestGlobalRootOf opens path read-only, binds syncID, and +// returns its stored whole-file grant digest root. +func grantDigestGlobalRootOf(t testing.TB, ctx context.Context, path, syncID string) enginepkg.DigestRoot { + t.Helper() + store, err := dotc1z.NewStore(ctx, path, dotc1z.WithReadOnly(true), dotc1z.WithTmpDir(t.TempDir())) + require.NoError(t, err) + defer store.Close(ctx) + eng, ok := enginepkg.AsEngine(store) + require.True(t, ok, "%s is not a pebble engine", path) + require.NoError(t, store.SetCurrentSync(ctx, syncID)) + root, ok, err := eng.GetGrantDigestGlobalRoot(ctx) + require.NoError(t, err) + require.True(t, ok, "%s has no built grant digest", path) + return root +} + +// TestCompactPebbleFoldSkipsDigestRebuildWhenGrantsUnchanged pins the +// fix for the "every fold throws away and regenerates the whole grant +// digest, even when nothing changed" gap: a fold whose partial touches +// zero grants must leave the base's already-correct digest state +// completely untouched — no drop, no rebuild — rather than paying a +// full from-scratch rebuild for zero benefit. The resulting bytes +// alone can't distinguish "left alone" from "rebuilt to the same +// answer" (both are byte-identical for unchanged data), so this +// captures the compactor's log output to confirm the skip branch +// actually fired, and separately confirms the base's own root survives +// into the compacted output unchanged. +func TestCompactPebbleFoldSkipsDigestRebuildWhenGrantsUnchanged(t *testing.T) { + logger, capture := newCapturingLogger() + ctx := ctxzap.ToContext(context.Background(), logger) + inDir := t.TempDir() + outDir := t.TempDir() + + basePath := filepath.Join(inDir, "base.c1z") + partialPath := filepath.Join(inDir, "partial-no-grants.c1z") + baseSync := buildPebbleInput(t, ctx, basePath, connectorstore.SyncTypeFull, "g1", "g2") + // A partial that shares the base's resource/entitlement shape but + // carries ZERO grants: the fold's grants-bucket scan iterates + // nothing, so FoldStats.GrantWrites is exactly zero. + partialSync := buildPebbleInput(t, ctx, partialPath, connectorstore.SyncTypePartial) + + wantRoot := grantDigestGlobalRootOf(t, ctx, basePath, baseSync) + + entries := []*CompactableSync{{FilePath: basePath, SyncID: baseSync}, {FilePath: partialPath, SyncID: partialSync}} + c, cleanup, err := NewCompactor(ctx, outDir, entries, + WithTmpDir(t.TempDir()), WithEngine(c1zstore.EnginePebble), WithPebbleCompactorMode(PebbleCompactorModeFold)) + require.NoError(t, err) + defer func() { _ = cleanup() }() + + out, err := c.Compact(ctx) + require.NoError(t, err) + require.NotNil(t, out) + + var sawSkip bool + for _, msg := range capture() { + if msg == "compactPebbleFold: no grant writes; base grant digest state left untouched" { + sawSkip = true + } + } + require.True(t, sawSkip, "expected the fold to log that it skipped the grant digest rebuild") + + gotRoot := grantDigestGlobalRootOf(t, ctx, out.FilePath, out.SyncID) + require.Equal(t, wantRoot.Count, gotRoot.Count, "grant count must be exactly the base's, unchanged") + require.True(t, bytes.Equal(wantRoot.Hash, gotRoot.Hash), + "compacted root %x must byte-equal the base's untouched root %x", gotRoot.Hash, wantRoot.Hash) +} + +// TestCompactPebbleFoldRepairsOnlyTouchedEntitlements is the +// end-to-end companion to +// TestRepairMissingGrantDigestsHealsOnlyInvalidatedPartition (which +// pins the byte-exact per-entitlement scoping at the engine level): +// a fold whose partial adds a grant under only ONE of two base +// entitlements must take the targeted invalidate+repair path (not the +// full-file rebuild, not the no-op skip), and the result must still +// match a from-scratch build over the final data. +func TestCompactPebbleFoldRepairsOnlyTouchedEntitlements(t *testing.T) { + logger, capture := newCapturingLogger() + ctx := ctxzap.ToContext(context.Background(), logger) + inDir := t.TempDir() + outDir := t.TempDir() + + basePath := filepath.Join(inDir, "base.c1z") + partialPath := filepath.Join(inDir, "partial.c1z") + baseSync := buildOverlayInput(t, ctx, basePath, overlayInputSpec{ + syncType: connectorstore.SyncTypeFull, + suffix: "base", + grants: []overlayGrantSpec{ + {id: "g-ent1-alice", principalID: "alice", entitlementID: "ent-1"}, + {id: "g-ent2-bob", principalID: "bob", entitlementID: "ent-2"}, + }, + }) + // The partial adds a NEW grant only under ent-1; ent-2 is never + // mentioned, so its partition must never be touched by the fold. + partialSync := buildOverlayInput(t, ctx, partialPath, overlayInputSpec{ + syncType: connectorstore.SyncTypePartial, + suffix: "partial", + grants: []overlayGrantSpec{ + {id: "g-ent1-bob", principalID: "bob", entitlementID: "ent-1"}, + }, + }) + + entries := []*CompactableSync{{FilePath: basePath, SyncID: baseSync}, {FilePath: partialPath, SyncID: partialSync}} + c, cleanup, err := NewCompactor(ctx, outDir, entries, + WithTmpDir(t.TempDir()), WithEngine(c1zstore.EnginePebble), WithPebbleCompactorMode(PebbleCompactorModeFold), + WithSkipGrantExpansion()) + require.NoError(t, err) + defer func() { _ = cleanup() }() + + out, err := c.Compact(ctx) + require.NoError(t, err) + require.NotNil(t, out) + + var repairedMsg bool + for _, msg := range capture() { + if msg == "compactPebbleFold: repaired grant digests for touched entitlements" { + repairedMsg = true + } + } + require.True(t, repairedMsg, "expected the fold to log a targeted repair, not a full rebuild or a skip") + + // Oracle: the compacted output's whole-file root must still match + // a from-scratch build over the same final grants, proving the + // targeted repair produced a correct result even though it only + // touched ent-1. + oraclePath := filepath.Join(outDir, "oracle.c1z") + oracleSyncID := buildOracleFromCompacted(t, ctx, out, oraclePath) + gotRoot := grantDigestGlobalRootOf(t, ctx, out.FilePath, out.SyncID) + wantRoot := grantDigestGlobalRootOf(t, ctx, oraclePath, oracleSyncID) + require.Equal(t, wantRoot.Count, gotRoot.Count) + require.True(t, bytes.Equal(wantRoot.Hash, gotRoot.Hash), + "compacted root %x must byte-equal a from-scratch build %x", gotRoot.Hash, wantRoot.Hash) +} + +// TestCompactPebbleFoldWithExpansionRebuildsFullyRegardless closes the +// loop on a real question about the targeted repair's safety: grant +// expansion (run by Compact whenever it isn't skipped — here, because +// the union sync type is Full) writes grants through its OWN paths +// (PutExpandedGrantRecords / PutSynthesizedGrantRecords / the +// layer-session ingest), never through compactPebbleFold's tracked +// merge. This does not need those paths to invalidate perfectly on +// their own: expansion's syncer.Sync always calls store.EndSync +// afterward, and EndSync's finalize always runs a full digest rebuild +// when the digest index is enabled, unconditionally superseding +// whatever the targeted repair produced. This test exercises the full +// Compact() flow (not compactPebbleFold directly) with expansion NOT +// skipped, and asserts the final output's whole-file root still +// matches a from-scratch build. +func TestCompactPebbleFoldWithExpansionRebuildsFullyRegardless(t *testing.T) { + ctx := context.Background() + inDir := t.TempDir() + outDir := t.TempDir() + + p1 := filepath.Join(inDir, "in1.c1z") + p2 := filepath.Join(inDir, "in2.c1z") + s1 := buildPebbleInput(t, ctx, p1, connectorstore.SyncTypeFull, "g-shared", "g-only1") + s2 := buildPebbleInput(t, ctx, p2, connectorstore.SyncTypePartial, "g-shared", "g-only2") + + entries := []*CompactableSync{{FilePath: p1, SyncID: s1}, {FilePath: p2, SyncID: s2}} + // No WithSkipGrantExpansion(): the base is Full, so the compacted + // sync's union type is Full too, skipExpansion is false in + // Compact(), and a real grant-expansion syncer.Sync + EndSync runs + // on the compacted output before it is saved. + c, cleanup, err := NewCompactor(ctx, outDir, entries, + WithTmpDir(t.TempDir()), WithEngine(c1zstore.EnginePebble), WithPebbleCompactorMode(PebbleCompactorModeFold)) + require.NoError(t, err) + defer func() { _ = cleanup() }() + + out, err := c.Compact(ctx) + require.NoError(t, err) + require.NotNil(t, out) + + oraclePath := filepath.Join(outDir, "oracle.c1z") + oracleSyncID := buildOracleFromCompacted(t, ctx, out, oraclePath) + gotRoot := grantDigestGlobalRootOf(t, ctx, out.FilePath, out.SyncID) + wantRoot := grantDigestGlobalRootOf(t, ctx, oraclePath, oracleSyncID) + require.Equal(t, wantRoot.Count, gotRoot.Count) + require.True(t, bytes.Equal(wantRoot.Hash, gotRoot.Hash), + "post-expansion compacted root %x must byte-equal a from-scratch build %x", gotRoot.Hash, wantRoot.Hash) +} diff --git a/pkg/synccompactor/compactor_pebble.go b/pkg/synccompactor/compactor_pebble.go index 5b26497fd..4c67bf2a1 100644 --- a/pkg/synccompactor/compactor_pebble.go +++ b/pkg/synccompactor/compactor_pebble.go @@ -571,8 +571,58 @@ func (c *Compactor) compactPebbleFold(ctx context.Context) (string, error) { } } - if err := rebuildCompactedGrantDigests(ctx, destEng); err != nil { - return "", fmt.Errorf("compactPebbleFold: %w", err) + // Only touch grant digests for the entitlement partitions this fold + // actually wrote a grant into. The dest started as a byte copy of a + // sealed base, whose digest state is already exactly correct for + // every OTHER entitlement; invalidating (and later recalling + // RepairMissingGrantDigests to rebuild) the whole file on every + // fold — even one where a single entitlement out of thousands + // changed — would reintroduce the O(base) cost fold exists to + // avoid. InvalidateGrantDigestPartitions drops exactly the touched + // partitions (+ the now-stale whole-file root); + // RepairMissingGrantDigests then rebuilds exactly what's missing, + // each from a targeted scan of just that entitlement's own grants — + // never a full-file scan — and recomputes the whole-file root from + // the (small) digest keyspace itself. A fold whose partials add + // nothing new (a common steady-state case — re-running compaction + // with no new data, or partials that only touch + // resources/entitlements) touches nothing at all. + // + // This result is FINAL only when the caller (Compact) goes on to + // skip grant expansion (WithSkipGrantExpansion, or a partial-typed + // union) — nothing else touches c.compactedC1z before Close in that + // path (GetSync is a pure read; Cleanup is a hard no-op for the + // Pebble engine). When expansion runs instead, its own grant writes + // use dedicated write paths (PutExpandedGrantRecords / + // PutSynthesizedGrantRecords / the layer-session ingest, not + // PutGrantRecords) whose invalidation correctness doesn't matter + // here: expandGrants' syncer.Sync ALWAYS calls store.EndSync + // afterward — even for a no-op expansion — and Adapter.EndSync's + // finalize unconditionally runs a FULL digest rebuild + // (BuildDeferredGrantIndexes or BuildGrantDigests) whenever the + // digest index is enabled, with no branch that skips both. So + // whatever this targeted repair produces gets unconditionally + // superseded by a full, correct rebuild the moment expansion runs — + // safe, but this optimization's actual win is scoped to + // skip-expansion compactions; a fold whose base is a full sync + // (expansion NOT skipped) pays for both the targeted repair AND the + // subsequent full rebuild. See + // TestCompactPebbleFoldWithExpansionRebuildsFullyRegardless. + if len(foldStats.TouchedGrantPartitions) > 0 && destEng.GrantDigestIndexEnabled() { + partitions := make([]string, 0, len(foldStats.TouchedGrantPartitions)) + for p := range foldStats.TouchedGrantPartitions { + partitions = append(partitions, p) + } + if err := destEng.InvalidateGrantDigestPartitions(ctx, partitions); err != nil { + return "", fmt.Errorf("compactPebbleFold: invalidate grant digest partitions: %w", err) + } + if err := destEng.RepairMissingGrantDigests(ctx); err != nil { + return "", fmt.Errorf("compactPebbleFold: repair grant digests: %w", err) + } + l.Info("compactPebbleFold: repaired grant digests for touched entitlements", + zap.Int("touched_partitions", len(partitions))) + } else { + l.Info("compactPebbleFold: no grant writes; base grant digest state left untouched") } // Optionally compact the folded LSM before save. Off by default: @@ -701,6 +751,18 @@ func (c *Compactor) compactPebbleFold(ctx context.Context) (string, error) { // No-op when the destination engine was opened with the digest index // disabled (WithGrantDigestIndex(false)), matching EndSync's own gate // (GrantDigestIndexEnabled). +// +// Callers: only k-way and overlay use this (unconditionally) — those +// modes materialize every record fresh on every run regardless of +// digests, so a full rebuild is proportional to work already +// unavoidable. Fold does NOT call this: its dest starts as a byte copy +// of a sealed base whose digest state is already correct for every +// untouched entitlement, so a full rebuild on every fold — even one +// where a handful of entitlements out of thousands changed — would +// reintroduce the O(base) cost fold exists to avoid. Fold instead +// calls Engine.InvalidateGrantDigestPartitions + +// Engine.RepairMissingGrantDigests to invalidate and rebuild EXACTLY +// the entitlements it touched (see compactPebbleFold). func rebuildCompactedGrantDigests(ctx context.Context, destEng *enginepkg.Engine) error { if !destEng.GrantDigestIndexEnabled() { return nil diff --git a/pkg/synccompactor/pebble/merge.go b/pkg/synccompactor/pebble/merge.go index 6f2c8275b..40e2e4da3 100644 --- a/pkg/synccompactor/pebble/merge.go +++ b/pkg/synccompactor/pebble/merge.go @@ -39,6 +39,23 @@ type FoldStats struct { // ReplacedByBucket counts strictly-newer overrides per bucket name. // Sums to OverriddenRecords. ReplacedByBucket map[string]int64 + // GrantWrites counts every grant record actually written to dest — + // both fresh admissions (no incumbent at the key) and strictly- + // newer overrides. Byte-identical resubmissions and not-strictly- + // newer candidates are NOT counted (mergeBucketRawIfNewer's no-op + // fast paths). The fold compactor uses this to decide whether the + // grants keyspace changed at all: when it's zero across a whole + // fold, the base's already-correct grant-digest state was never + // touched and needs no rebuild (see compactPebbleFold). + GrantWrites int64 + // TouchedGrantPartitions is the set of entitlement partitions + // (enginepkg.GrantPartitionFromPrimaryKey) that had at least one + // grant actually written to dest, under the same counting rule as + // GrantWrites. The fold compactor uses this to invalidate and + // repair EXACTLY the entitlements a fold touched + // (Engine.InvalidateGrantDigestPartitions + + // Engine.RepairMissingGrantDigests), instead of the whole file. + TouchedGrantPartitions map[string]struct{} } func (s *FoldStats) Add(o FoldStats) { @@ -50,6 +67,13 @@ func (s *FoldStats) Add(o FoldStats) { for bucket, n := range o.ReplacedByBucket { s.bumpReplaced(bucket, n) } + s.GrantWrites += o.GrantWrites + for p := range o.TouchedGrantPartitions { + if s.TouchedGrantPartitions == nil { + s.TouchedGrantPartitions = make(map[string]struct{}, len(o.TouchedGrantPartitions)) + } + s.TouchedGrantPartitions[p] = struct{}{} + } } func (s *FoldStats) bumpAdded(bucket string, n int64) { @@ -96,6 +120,22 @@ func (s *FoldStats) bumpReplaced(bucket string, n int64) { // the already-written (earlier source, or pre-existing dest) record is // kept. Callers pass sources newest-first so the tie winner matches the // SQLite fold. +// +// MergeInto does NOT touch grant-digest state itself. The fold dest +// starts as a byte copy of a SEALED base, which carries an already- +// CORRECT grant hash index and digests; this merge mutates grants +// through the raw DB handle without maintaining either (nothing does, +// outside the seal-time build), so once ANY grant write lands the +// dest's digest state is stale relative to it. Rather than drop it +// pre-emptively on every call — which would force a full from-scratch +// digest rebuild even for a fold that changes nothing, defeating +// fold's whole point of doing O(partials) work, not O(base) — callers +// inspect the returned FoldStats.GrantWrites: zero means the grants +// keyspace was never touched and the base's digest state is still +// exactly correct, left alone; nonzero means the caller must rebuild +// (Engine.BuildGrantDigests rebuilds both keyspaces atomically from +// scratch, so no separate drop is needed even then — see +// compactPebbleFold). func MergeInto(ctx context.Context, dest *enginepkg.Engine, sources []SourceSync, destSyncID string) (FoldStats, error) { var stats FoldStats if dest == nil { @@ -107,16 +147,6 @@ func MergeInto(ctx context.Context, dest *enginepkg.Engine, sources []SourceSync if err := dest.SetCurrentSync(destSyncID); err != nil { return stats, fmt.Errorf("synccompactor/pebble.MergeInto: bind dest sync: %w", err) } - // The fold dest starts as a byte copy of a SEALED base, which carries - // the grant hash index and digests. This merge mutates grants through - // the raw DB handle without maintaining either (nothing does, outside - // the seal-time build), so under the present-means-exact contract both - // must be dropped up front — a folded output's digests read as - // "missing, recalculate", never as stale-but-present. The rebuild - // strategies' fresh dests are in the same state by construction. - if err := dest.DropAllGrantDigestState(ctx); err != nil { - return stats, fmt.Errorf("synccompactor/pebble.MergeInto: drop grant digest state: %w", err) - } // Folds write the dest keyspace through the raw DB handle; invalidate // the engine's bare-id lookup state on the way out (even on error — // earlier sources may already have been folded in). @@ -262,6 +292,15 @@ func mergeBucketRawIfNewer(ctx context.Context, dest *enginepkg.Engine, src *peb if err := batch.Set(key, value, nil); err != nil { return stats, err } + if bucket.id == runBucketGrants { + stats.GrantWrites++ + if partition, ok := enginepkg.GrantPartitionFromPrimaryKey(key); ok { + if stats.TouchedGrantPartitions == nil { + stats.TouchedGrantPartitions = map[string]struct{}{} + } + stats.TouchedGrantPartitions[partition] = struct{}{} + } + } if err := forEachIndexKeyFromRaw(bucket, key, lower, value, &scratch, nil, setIndexKey); err != nil { return stats, err } diff --git a/pkg/synccompactor/pebble/merge_test.go b/pkg/synccompactor/pebble/merge_test.go index 0eace2029..c57285b46 100644 --- a/pkg/synccompactor/pebble/merge_test.go +++ b/pkg/synccompactor/pebble/merge_test.go @@ -88,6 +88,55 @@ func TestMergeIntoUnionNewerWins(t *testing.T) { assertIndexesMatchDerived(t, ctx, dst) } +// TestMergeIntoGrantWritesTracksActualChanges pins the FoldStats.GrantWrites +// signal the fold compactor uses to skip a from-scratch grant-digest +// rebuild when a fold changed nothing: it must be zero whenever no +// grant record was actually written to dest (a byte-identical +// resubmission, or a source with no grants at all), and nonzero +// whenever one was (a fresh admission or a strictly-newer override). +func TestMergeIntoGrantWritesTracksActualChanges(t *testing.T) { + ctx := context.Background() + src, _ := newEngine(t, "gw-src") + dst, _ := newEngine(t, "gw-dst") + + syncID := ksuid.New().String() + destSync := ksuid.New().String() + at := time.Unix(1000, 0).UTC() + + require.NoError(t, src.SetCurrentSync(syncID)) + require.NoError(t, src.PutGrantRecords(ctx, grantAt(syncID, "g1", at))) + + // First merge: a genuinely new grant is admitted. + stats, err := MergeInto(ctx, dst, []SourceSync{{Engine: src, SyncID: syncID}}, destSync) + require.NoError(t, err) + require.EqualValues(t, 1, stats.GrantWrites, "first merge admits one new grant") + + // Re-merging the SAME source into the SAME dest sync: the incoming + // record is now byte-identical to the incumbent, which + // mergeBucketRawIfNewer treats as a true no-op — zero grant writes, + // even though a grant record was iterated and compared. + stats2, err := MergeInto(ctx, dst, []SourceSync{{Engine: src, SyncID: syncID}}, destSync) + require.NoError(t, err) + require.Zero(t, stats2.GrantWrites, "resubmitting an identical grant must not count as a write") + + // A source with no grants at all contributes zero grant writes. + empty, _ := newEngine(t, "gw-empty-src") + emptySyncID := ksuid.New().String() + require.NoError(t, empty.SetCurrentSync(emptySyncID)) + stats3, err := MergeInto(ctx, dst, []SourceSync{{Engine: empty, SyncID: emptySyncID}}, destSync) + require.NoError(t, err) + require.Zero(t, stats3.GrantWrites, "a source with no grants contributes zero grant writes") + + // A genuinely newer record on the same identity DOES count. + newer := time.Unix(2000, 0).UTC() + src2, _ := newEngine(t, "gw-src2") + require.NoError(t, src2.SetCurrentSync(syncID)) + require.NoError(t, src2.PutGrantRecords(ctx, grantAt(syncID, "g1", newer))) + stats4, err := MergeInto(ctx, dst, []SourceSync{{Engine: src2, SyncID: syncID}}, destSync) + require.NoError(t, err) + require.EqualValues(t, 1, stats4.GrantWrites, "a strictly-newer override counts as a grant write") +} + // TestMergeIntoTieKeepsIncumbent pins the equal-discovered_at tie rule // to SQLite's strict `>`: on a tie, the earlier-applied source's record // is kept (it is the incumbent the newer write does not replace). From 83c42e70451c99b99e28c330ab2ccea8e782b1d9 Mon Sep 17 00:00:00 2001 From: MJ Palanker Date: Wed, 8 Jul 2026 17:46:05 -0700 Subject: [PATCH 05/12] fixup for error swallow --- pkg/dotc1z/engine/pebble/grants.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/pkg/dotc1z/engine/pebble/grants.go b/pkg/dotc1z/engine/pebble/grants.go index f46209a13..1abf1d3d8 100644 --- a/pkg/dotc1z/engine/pebble/grants.go +++ b/pkg/dotc1z/engine/pebble/grants.go @@ -1008,10 +1008,20 @@ func (e *Engine) writeGrantIndexes(batch *pebble.Batch, r *v3.GrantRecord) error } } if e.grantDigestsPresent.Load() { - if id, err := grantIdentityFromRecord(r); err == nil { - if err := e.stageGrantDigestInvalidation(batch, id.entitlement); err != nil { - return err - } + // Propagate a derivation failure rather than fail-open: the + // sibling paths (writeGrantIndexesForIdentityScratch, + // deleteGrantIndexesScratch) already take id as a given and + // can't silently skip invalidation either — a record that + // reaches here already had its identity derived to build the + // primary key, so this should be unreachable in practice, but + // swallowing it would leave a stale-but-present digest on the + // touched entitlement, violating present-means-exact. + id, err := grantIdentityFromRecord(r) + if err != nil { + return err + } + if err := e.stageGrantDigestInvalidation(batch, id.entitlement); err != nil { + return err } } return nil From cc9c85747330ee8c5950eeb2a0eccb59f16dd865 Mon Sep 17 00:00:00 2001 From: MJ Palanker Date: Fri, 10 Jul 2026 11:41:50 -0700 Subject: [PATCH 06/12] fix invariant failure w/ fsync digest pending marker --- pkg/dotc1z/engine/pebble/cleanup.go | 10 + pkg/dotc1z/engine/pebble/digest.go | 8 + pkg/dotc1z/engine/pebble/engine.go | 44 ++++ pkg/dotc1z/engine/pebble/grant_digest.go | 6 + .../engine/pebble/grant_digest_build.go | 105 ++++++++- .../pebble/grant_digest_build_crash_test.go | 214 ++++++++++++++++++ .../engine/pebble/grant_digest_repair.go | 12 + pkg/dotc1z/engine/pebble/id_index_format.go | 27 +++ 8 files changed, 414 insertions(+), 12 deletions(-) create mode 100644 pkg/dotc1z/engine/pebble/grant_digest_build_crash_test.go diff --git a/pkg/dotc1z/engine/pebble/cleanup.go b/pkg/dotc1z/engine/pebble/cleanup.go index ceff3ad98..66f30a06a 100644 --- a/pkg/dotc1z/engine/pebble/cleanup.go +++ b/pkg/dotc1z/engine/pebble/cleanup.go @@ -99,6 +99,16 @@ func (e *Engine) ResetForNewSync(ctx context.Context) error { // The record-type span above covers typeDigest and the hash // index too; disarm the mutation-path digest invalidation. e.grantDigestsPresent.Store(false) + // The digest-build crash marker lives in the preserved + // engine-meta range, but the excise just removed everything it + // was guarding against trusting — consume it (only reachable + // when an interrupted build's cleanup drop itself failed; + // writable Opens consume it before anything else runs). + if e.grantDigestBuildPending.Load() { + if err := e.clearGrantDigestBuildPending(); err != nil { + return err + } + } e.noteEntitlementKeyspaceWrite() return nil }) diff --git a/pkg/dotc1z/engine/pebble/digest.go b/pkg/dotc1z/engine/pebble/digest.go index e17653bea..7160290fd 100644 --- a/pkg/dotc1z/engine/pebble/digest.go +++ b/pkg/dotc1z/engine/pebble/digest.go @@ -475,6 +475,14 @@ type DigestRoot struct { // to computeBucketDigest, which derives the same digest from the index // on demand). func (e *Engine) getPartitionDigestRoot(spec digestIndexSpec, partition string) (DigestRoot, bool, error) { + if e.grantDigestBuildPending.Load() { + // An interrupted digest build's half-committed nodes may be + // durable while its hash index never ingested; until the pending + // state is consumed (a writable Open drops it; a read-only open + // cannot), no stored root may be trusted — report "never built", + // which every consumer already treats as "recalculate". + return DigestRoot{}, false, nil + } val, closer, err := e.db.Get(encodeDigestNodeKey(spec.indexID, partition, digestLevelRoot, nil)) if err != nil { if errors.Is(err, pebble.ErrNotFound) { diff --git a/pkg/dotc1z/engine/pebble/engine.go b/pkg/dotc1z/engine/pebble/engine.go index 757a0dfe0..b88507048 100644 --- a/pkg/dotc1z/engine/pebble/engine.go +++ b/pkg/dotc1z/engine/pebble/engine.go @@ -12,6 +12,7 @@ import ( "github.com/cockroachdb/pebble/v2" "github.com/cockroachdb/pebble/v2/vfs" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "google.golang.org/protobuf/proto" v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3" @@ -91,6 +92,26 @@ type Engine struct { // seal-time build, cleared by ResetForNewSync and the Drop* paths. grantDigestsPresent atomic.Bool + // grantDigestBuildPending mirrors the durable digest-build marker + // (encodeGrantDigestBuildPendingKey): true between a digest build's + // arm and its completion (or the drop that cleans up after it). A + // writable Open that finds the durable marker drops all digest state + // immediately, so on a writable engine this is only ever true while + // a build owns the write barrier or after an in-process build + // failure whose cleanup drop itself failed. A READ-ONLY Open cannot + // drop, so the flag stays set and the digest root getters + // (getPartitionDigestRoot, GetGrantDigestGlobalRoot) report "never + // built" instead of trusting nodes a crashed build half-committed. + grantDigestBuildPending atomic.Bool + + // Test seams for the digest-build crash-window tests + // (grant_digest_build_crash_test.go): a hook fired at named points + // inside buildGrantDigestsFromSpill and a node-batch flush-threshold + // override so a small test dataset exercises the mid-merge commit + // path. Nil/zero in production. + testDigestBuildHook func(stage string) error + testDigestNodeFlushBytes int + // synthLayer is the open wave-scoped layer session, if any (see // BeginSynthesizedGrantLayer). Single producer: the expansion driver // opens/adds/finishes sessions strictly sequentially. synthLayerMu @@ -219,6 +240,29 @@ func Open(ctx context.Context, dir string, opts ...Option) (*Engine, error) { _ = e.Close() return nil, err } + // Honor the durable digest-build marker (see + // encodeGrantDigestBuildPendingKey): a prior process was killed + // mid-digest-build, after some digest-node commits were durable but + // before the hash-index ingest completed. Those nodes LOOK present + // while the index beneath them is empty or stale, so nothing stored + // may be trusted: drop it all before probing presence — absent + // digests are always safe (present-means-exact, digest.go). A + // read-only open cannot drop; it keeps the flag set instead, which + // makes the digest root getters report "never built". + if _, closer, err := e.db.Get(encodeGrantDigestBuildPendingKey()); err == nil { + closer.Close() + e.grantDigestBuildPending.Store(true) + if !o.readOnly { + ctxzap.Extract(ctx).Warn("pebble: interrupted grant digest build detected at open; dropping all digest state — the next EndSync rebuilds it from scratch") + if err := e.dropAllGrantDigestStateLocked(); err != nil { + _ = e.Close() + return nil, fmt.Errorf("pebble: drop digest state left by an interrupted build: %w", err) + } + } + } else if !errors.Is(err, pebble.ErrNotFound) { + _ = e.Close() + return nil, err + } // Arm the mutation-path digest invalidation iff the file actually // holds digest nodes (one bounded seek; see grant_digest.go). if err := e.probeGrantDigestsPresent(); err != nil { diff --git a/pkg/dotc1z/engine/pebble/grant_digest.go b/pkg/dotc1z/engine/pebble/grant_digest.go index 0ba3cef96..afd2ce249 100644 --- a/pkg/dotc1z/engine/pebble/grant_digest.go +++ b/pkg/dotc1z/engine/pebble/grant_digest.go @@ -319,6 +319,12 @@ func (e *Engine) GetEntitlementDigestRoot(ctx context.Context, id entitlementIde // invalidation paths that drop any per-entitlement root — see // stageGrantDigestInvalidation and the Drop* functions below. func (e *Engine) GetGrantDigestGlobalRoot(ctx context.Context) (DigestRoot, bool, error) { + if e.grantDigestBuildPending.Load() { + // Same guard as getPartitionDigestRoot: a global root committed + // by an interrupted build must read as absent, not certify a + // hash index that was never ingested. + return DigestRoot{}, false, nil + } val, closer, err := e.db.Get(globalGrantDigestNodeKey()) if err != nil { if errors.Is(err, pebble.ErrNotFound) { diff --git a/pkg/dotc1z/engine/pebble/grant_digest_build.go b/pkg/dotc1z/engine/pebble/grant_digest_build.go index 7a8251c02..a4548a83b 100644 --- a/pkg/dotc1z/engine/pebble/grant_digest_build.go +++ b/pkg/dotc1z/engine/pebble/grant_digest_build.go @@ -90,6 +90,36 @@ func appendGrantHashIndexRow(sorter *spillSorter, primaryKey, value []byte, s *g // batch accumulates before committing mid-merge. const digestNodeBatchFlushBytes = 4 << 20 +// markGrantDigestBuildPending durably arms the digest-build marker +// (encodeGrantDigestBuildPendingKey). MUST be fsync'd (pebble.Sync, +// even during a fresh sync) and MUST precede the build's first digest +// write: the build's own node commits may ride NoSync, and a node +// batch becoming durable ahead of the marker would reopen exactly the +// trust-a-crashed-build window the marker closes. +func (e *Engine) markGrantDigestBuildPending() error { + if err := e.db.Set(encodeGrantDigestBuildPendingKey(), nil, pebble.Sync); err != nil { + return fmt.Errorf("arm grant digest build marker: %w", err) + } + e.grantDigestBuildPending.Store(true) + return nil +} + +// clearGrantDigestBuildPending drops both forms of the marker. Called +// only once the digest state is a complete, self-consistent whole: a +// finished build (buildGrantDigestsFromSpill) or a full drop +// (dropAllGrantDigestStateLocked). The delete is fsync'd deliberately — +// the WAL is prefix-durable, so a durable clear implies every +// (possibly NoSync) write that preceded it is durable too. The +// marker's absence therefore certifies COMPLETE digest state on disk, +// never a lucky partial one. +func (e *Engine) clearGrantDigestBuildPending() error { + if err := e.db.Delete(encodeGrantDigestBuildPendingKey(), pebble.Sync); err != nil { + return fmt.Errorf("clear grant digest build marker: %w", err) + } + e.grantDigestBuildPending.Store(false) + return nil +} + // grantDigestFold streams the merge's (index key, content hash) rows // into digest nodes. Rows arrive in (partition, bucket hash) order, so // each partition is a contiguous run and its buckets are touched in @@ -98,8 +128,9 @@ const digestNodeBatchFlushBytes = 4 << 20 // touched buckets, so per-partition close and reset are O(touched), // never O(2^16). type grantDigestFold struct { - e *Engine - opts *pebble.WriteOptions + e *Engine + opts *pebble.WriteOptions + flushBytes int // digestNodeBatchFlushBytes, or the engine's test override counts []int64 // 1< 0 { + flushBytes = e.testDigestNodeFlushBytes + } f := &grantDigestFold{ - e: e, - opts: opts, - counts: make([]int64, 1<= digestNodeBatchFlushBytes { + if f.batch.Len() >= f.flushBytes { if err := f.batch.Commit(f.opts); err != nil { return err } f.batch.Close() f.batch = f.e.db.NewBatch() + if f.e.testDigestBuildHook != nil { + // Crash-window seam: digest-node batches are now committed + // (durable under WAL replay) while the hash-index ingest has + // not run. See grant_digest_build_crash_test.go. + if err := f.e.testDigestBuildHook("node-batch-committed"); err != nil { + return err + } + } } return nil } @@ -400,6 +444,15 @@ func mergeGrantHashChunksToSST(ctx context.Context, sstPath, name string, chunks // written digest nodes behind — the caller MUST drop the digest state // on failure (dropAllGrantDigestStateLocked) so no half-built digest // survives looking present. +// +// An in-process error the caller can drop on is only half the failure +// surface: node batches commit DURING the merge and at fold.finish(), +// before the index ingest, and committed WAL writes survive a process +// kill. The durable build-pending marker armed here (and cleared only +// once everything below has completed, or by the drop) is what keeps a +// crash inside that window from resurrecting correct-looking digest +// roots over a never-ingested hash index — see +// encodeGrantDigestBuildPendingKey. func (e *Engine) buildGrantDigestsFromSpill(ctx context.Context, dir string, hashIdx *spillSorter) error { start := time.Now() l := ctxzap.Extract(ctx) @@ -411,6 +464,11 @@ func (e *Engine) buildGrantDigestsFromSpill(ctx context.Context, dir string, has if e.IsFreshSync() { opts = pebble.NoSync } + // Arm the durable crash marker before the first digest write on + // EITHER branch below. + if err := e.markGrantDigestBuildPending(); err != nil { + return err + } if len(chunks) == 0 { // No grants at all — pebble rejects an empty SST, so clear any // stale state directly, then give every entitlement its @@ -431,7 +489,7 @@ func (e *Engine) buildGrantDigestsFromSpill(ctx context.Context, dir string, has return err } e.grantDigestsPresent.Store(true) - return nil + return e.clearGrantDigestBuildPending() } fold, err := newGrantDigestFold(e) @@ -447,6 +505,14 @@ func (e *Engine) buildGrantDigestsFromSpill(ctx context.Context, dir string, has return err } mergeDone := time.Now() + if e.testDigestBuildHook != nil { + // Crash-window seam: every digest node INCLUDING the global root + // is committed, the ingest has not run. See + // grant_digest_build_crash_test.go. + if err := e.testDigestBuildHook("post-finish"); err != nil { + return err + } + } // Atomically replace the whole hash-index range with the merged SST. if _, err := e.db.IngestAndExcise(ctx, []string{sstPath}, nil, nil, pebble.KeyRange{ @@ -462,6 +528,13 @@ func (e *Engine) buildGrantDigestsFromSpill(ctx context.Context, dir string, has return err } e.grantDigestsPresent.Store(true) + // The digest state is complete: consume the crash marker. Its + // fsync'd delete also makes every NoSync node batch above durable + // (WAL prefix ordering), so "marker absent" always means "complete + // state on disk". + if err := e.clearGrantDigestBuildPending(); err != nil { + return err + } l.Info("grant digest build complete", zap.Int64("index_rows", hashIdx.count), @@ -644,8 +717,13 @@ func (e *Engine) buildGrantDigestsStandaloneLocked(ctx context.Context) error { } // dropAllGrantDigestStateLocked is DropAllGrantDigestState for callers -// already holding the engine write barrier (the deferred pass's -// non-fatal error handler). +// already holding the engine write barrier (the failed-build handlers, +// Open's interrupted-build recovery), plus the marker consumption those +// callers need: the drop restores the always-safe "digests absent" +// state, which is exactly what the build-pending marker demands, so it +// is cleared here in the same stroke. The clear's fsync'd delete runs +// LAST — WAL prefix ordering makes the (possibly NoSync) tombstones +// above durable before the marker's absence is. func (e *Engine) dropAllGrantDigestStateLocked() error { e.grantDigestsPresent.Store(false) opts := writeOpts(e.opts.durability) @@ -655,5 +733,8 @@ func (e *Engine) dropAllGrantDigestStateLocked() error { if err := e.db.DeleteRange(DigestLowerBound(), DigestUpperBound(), opts); err != nil { return err } - return e.db.DeleteRange(GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound(), opts) + if err := e.db.DeleteRange(GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound(), opts); err != nil { + return err + } + return e.clearGrantDigestBuildPending() } diff --git a/pkg/dotc1z/engine/pebble/grant_digest_build_crash_test.go b/pkg/dotc1z/engine/pebble/grant_digest_build_crash_test.go new file mode 100644 index 000000000..5838ce392 --- /dev/null +++ b/pkg/dotc1z/engine/pebble/grant_digest_build_crash_test.go @@ -0,0 +1,214 @@ +package pebble + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/cockroachdb/pebble/v2" + "github.com/stretchr/testify/require" + + "github.com/conductorone/baton-sdk/pkg/connectorstore" +) + +// Crash-window tests for the grant digest build's durable pending +// marker (encodeGrantDigestBuildPendingKey). The build commits digest +// nodes in bounded batches DURING the merge and again at fold.finish() +// — including the global root — before the hash-index IngestAndExcise, +// and committed WAL writes survive a process kill. Without the marker, +// a resumed EndSync's RepairMissingGrantDigests trusted every root the +// crashed build left behind (or fast-pathed on its global root) and +// sealed the file with correct-looking digests over an empty hash +// index — the silent skip-entitlements-at-uplift failure mode. +// +// The kill is simulated by driving the REAL standalone build to a +// sentinel error at a named point (testDigestBuildHook), bypassing +// BuildGrantDigests's in-process drop handler — exactly a process +// death's durable footprint (a clean Close then persists everything +// committed, the maximal state a WAL replay could resurrect). + +var errDigestBuildKilled = errors.New("test: digest build killed") + +// seedDigestCrashRecords writes a deterministic entitlement/grant set — +// identical bytes on every engine it is applied to, so the recovered +// build can be compared byte-for-byte against a from-scratch reference. +func seedDigestCrashRecords(t *testing.T, e *Engine) { + t.Helper() + ctx := context.Background() + for entID, principals := range map[string][]string{ + "ent-a": {"alice", "bob", "carol"}, + "ent-b": {"dave", "erin"}, + "ent-c": {"frank"}, + "ent-zero": nil, + } { + putEnt(t, e, ctx, entID) + for _, p := range principals { + g := makeGrant("", "g-"+entID+"-"+p, entID, p) + require.NoError(t, e.PutGrantRecords(ctx, g), "PutGrantRecords(%s/%s)", entID, p) + } + } +} + +// dumpKeyRangeTest snapshots a keyspace range as key→value bytes for +// byte-for-byte comparison between engines. +func dumpKeyRangeTest(t *testing.T, e *Engine, lo, hi []byte) map[string][]byte { + t.Helper() + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: lo, UpperBound: hi}) + require.NoError(t, err, "NewIter") + defer iter.Close() + out := map[string][]byte{} + for iter.First(); iter.Valid(); iter.Next() { + out[string(iter.Key())] = append([]byte(nil), iter.Value()...) + } + require.NoError(t, iter.Error(), "iter") + return out +} + +func rawKeyPresent(t *testing.T, e *Engine, key []byte) bool { + t.Helper() + _, closer, err := e.db.Get(key) + if err == nil { + closer.Close() + return true + } + require.ErrorIs(t, err, pebble.ErrNotFound) + return false +} + +// TestGrantDigestBuildCrashMidMerge kills the standalone build right +// after a mid-merge digest-node batch commit: some partitions' roots +// are durable, the global root and the hash-index ingest are not. +func TestGrantDigestBuildCrashMidMerge(t *testing.T) { + testGrantDigestBuildCrash(t, + func(e *Engine) { + // Commit (and fire the hook) on every partition close so a + // small dataset exercises the mid-merge commit path; die + // after the first committed batch. + e.testDigestNodeFlushBytes = 1 + fired := 0 + e.testDigestBuildHook = func(stage string) error { + if stage != "node-batch-committed" { + return nil + } + fired++ + if fired == 1 { + return errDigestBuildKilled + } + return nil + } + }, + func(t *testing.T, e *Engine) { + // Partial: at least one partition's nodes committed, but the + // global root only rides fold.finish()'s final batch. + require.NotZero(t, countKeyRangeTest(t, e, DigestLowerBound(), DigestUpperBound()), + "mid-merge kill must leave committed digest nodes behind") + require.False(t, rawKeyPresent(t, e, globalGrantDigestNodeKey()), + "the global root must not be durable before fold.finish()") + }, + ) +} + +// TestGrantDigestBuildCrashPostFinish kills the build between +// fold.finish() and the hash-index IngestAndExcise: EVERY digest node +// including the global root is durable over an index that was never +// ingested — the exact state RepairMissingGrantDigests's global-root +// fast path would have trusted wholesale. +func TestGrantDigestBuildCrashPostFinish(t *testing.T) { + testGrantDigestBuildCrash(t, + func(e *Engine) { + e.testDigestBuildHook = func(stage string) error { + if stage == "post-finish" { + return errDigestBuildKilled + } + return nil + } + }, + func(t *testing.T, e *Engine) { + require.True(t, rawKeyPresent(t, e, globalGrantDigestNodeKey()), + "post-finish kill must leave the global root durable") + }, + ) +} + +func testGrantDigestBuildCrash(t *testing.T, arm func(*Engine), assertPoisoned func(*testing.T, *Engine)) { + ctx := context.Background() + dbDir := filepath.Join(t.TempDir(), "db") + + // Process A: sync some grants (inline-index writes — the deferred + // marker is never armed, so EndSync would take the standalone + // RepairMissingGrantDigests→BuildGrantDigests path), then die inside + // the digest build. + e, err := Open(ctx, dbDir) + require.NoError(t, err) + a := NewAdapter(e) + syncID, err := a.StartNewSync(ctx, connectorstore.SyncTypeFull, "") + require.NoError(t, err) + seedDigestCrashRecords(t, e) + + arm(e) + buildErr := e.withWriteAllowSealed(func() error { return e.buildGrantDigestsStandaloneLocked(ctx) }) + require.ErrorIs(t, buildErr, errDigestBuildKilled) + + // The poisoned durable state the marker exists for: the marker and + // (some or all) digest nodes are committed, the ingest never ran. + require.True(t, rawKeyPresent(t, e, encodeGrantDigestBuildPendingKey()), + "the build-pending marker must be armed before any node commit") + require.Zero(t, countKeyRangeTest(t, e, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()), + "the hash index must be empty — the ingest never ran") + assertPoisoned(t, e) + + // Even in-process, the root getters must refuse the crashed build's + // nodes while the pending flag is set. + _, ok, gerr := e.GetGrantDigestGlobalRoot(ctx) + require.NoError(t, gerr) + require.False(t, ok, "a pending build's global root must read as absent") + + // The "crash": stop without any cleanup. A clean Close persists + // every committed batch — the maximal state WAL replay could hand a + // real crash's successor. + require.NoError(t, e.Close()) + + // Process B: reopen. The marker must be consumed by dropping ALL + // digest state — a crash always converts to "digests absent". + e2, err := Open(ctx, dbDir) + require.NoError(t, err) + defer e2.Close() + require.False(t, e2.grantDigestBuildPending.Load(), "reopen must consume the durable marker") + require.False(t, rawKeyPresent(t, e2, encodeGrantDigestBuildPendingKey())) + require.Zero(t, countKeyRangeTest(t, e2, DigestLowerBound(), DigestUpperBound()), + "reopen must drop every digest node the crashed build committed") + require.False(t, e2.grantDigestsPresent.Load()) + + // Resume the sync and rerun EndSync — the standalone build runs + // again, from scratch. + a2 := NewAdapter(e2) + require.NoError(t, a2.SetCurrentSync(ctx, syncID)) + require.NoError(t, a2.EndSync(ctx)) + require.False(t, e2.grantDigestBuildPending.Load(), "a successful build must clear the marker") + require.False(t, rawKeyPresent(t, e2, encodeGrantDigestBuildPendingKey())) + + // The recovered digest state must be byte-for-byte identical to a + // from-scratch build over the same records — digest nodes, hash + // index, and global root carry no sync-relative bytes. + ref, _ := newTestEngine(t) + aRef := NewAdapter(ref) + _, err = aRef.StartNewSync(ctx, connectorstore.SyncTypeFull, "") + require.NoError(t, err) + seedDigestCrashRecords(t, ref) + require.NoError(t, aRef.EndSync(ctx)) + + require.Equal(t, + dumpKeyRangeTest(t, ref, DigestLowerBound(), DigestUpperBound()), + dumpKeyRangeTest(t, e2, DigestLowerBound(), DigestUpperBound()), + "digest nodes after crash recovery must match a from-scratch build") + require.Equal(t, + dumpKeyRangeTest(t, ref, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()), + dumpKeyRangeTest(t, e2, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()), + "hash-index rows after crash recovery must match a from-scratch build") + + root, rootOK, err := e2.GetGrantDigestGlobalRoot(ctx) + require.NoError(t, err) + require.True(t, rootOK, "global root must be present after the recovered seal") + require.EqualValues(t, 6, root.Count) +} diff --git a/pkg/dotc1z/engine/pebble/grant_digest_repair.go b/pkg/dotc1z/engine/pebble/grant_digest_repair.go index 345c906e0..0327c29f4 100644 --- a/pkg/dotc1z/engine/pebble/grant_digest_repair.go +++ b/pkg/dotc1z/engine/pebble/grant_digest_repair.go @@ -182,6 +182,18 @@ func (e *Engine) RepairMissingGrantDigests(ctx context.Context) error { if !e.opts.grantDigestIndex { return nil } + if e.grantDigestBuildPending.Load() { + // A prior digest build died mid-commit and the drop that should + // have consumed the durable marker never completed (a writable + // Open normally does it; in-process, a failed build's own + // cleanup). Whatever nodes it left look present over a hash + // index that was never ingested, so nothing stored is + // trustworthy — restore the safe absent state first and let the + // full rebuild below recalculate from scratch. + if err := e.withWriteAllowSealed(func() error { return e.dropAllGrantDigestStateLocked() }); err != nil { + return fmt.Errorf("RepairMissingGrantDigests: drop digest state left by an interrupted build: %w", err) + } + } if !e.grantDigestsPresent.Load() { return e.BuildGrantDigests(ctx) } diff --git a/pkg/dotc1z/engine/pebble/id_index_format.go b/pkg/dotc1z/engine/pebble/id_index_format.go index 77f9fbc9a..d6b153fa7 100644 --- a/pkg/dotc1z/engine/pebble/id_index_format.go +++ b/pkg/dotc1z/engine/pebble/id_index_format.go @@ -42,6 +42,33 @@ func encodeDeferredIdxPendingKey() []byte { return codec.AppendTupleStrings(buf, "deferred_grant_idx_pending") } +// encodeGrantDigestBuildPendingKey is the durable marker that a grant +// digest build is mid-flight: armed (fsync'd) before the build's first +// digest-node commit, cleared only after the whole build — merge, hash +// index ingest, zero-grant root backfill — has completed +// (buildGrantDigestsFromSpill), and consumed by the drop that restores +// the safe "digests absent" state (dropAllGrantDigestStateLocked). +// +// It exists because the build commits digest nodes in bounded batches +// DURING the merge and again at fold.finish() — including the global +// root — before the hash-index IngestAndExcise. Committed WAL writes +// survive a process kill, so a crash inside that window leaves +// correct-looking digest roots durable over a hash index that was never +// ingested; a resumed EndSync's RepairMissingGrantDigests would trust +// every surviving root (or fast-path on the global root) and seal the +// file with digests over an empty index — the silent +// skip-entitlements-at-uplift failure mode. The fused seal pass is +// additionally protected by deferredIdxPending (a crash reruns the full +// build), but the standalone BuildGrantDigests path has no such marker, +// and this one covers both. Marker present at Open (or at repair) → +// drop all digest state first: every crash converts to "digests +// absent", which present-means-exact (digest.go) already treats as safe. +func encodeGrantDigestBuildPendingKey() []byte { + buf := make([]byte, 0, 2+len("grant_digest_build_pending")) + buf = append(buf, versionV3, typeEngineMeta) + return codec.AppendTupleStrings(buf, "grant_digest_build_pending") +} + func (e *Engine) readIDIndexFormat() (uint32, error) { val, closer, err := e.db.Get(encodeIDIndexFormatKey()) if err != nil { From b053ef07bfd4d1c8e062847dd2da8d0aa3cb6719 Mon Sep 17 00:00:00 2001 From: MJ Palanker Date: Fri, 10 Jul 2026 12:14:38 -0700 Subject: [PATCH 07/12] fixup address additional comments --- pkg/dotc1z/engine/pebble/digest.go | 19 ++- pkg/dotc1z/engine/pebble/engine.go | 12 +- pkg/dotc1z/engine/pebble/grant_digest.go | 19 ++- .../engine/pebble/grant_digest_repair.go | 107 ++++++++----- .../engine/pebble/grant_digest_repair_test.go | 148 ++++++++++++++++++ 5 files changed, 250 insertions(+), 55 deletions(-) diff --git a/pkg/dotc1z/engine/pebble/digest.go b/pkg/dotc1z/engine/pebble/digest.go index 7160290fd..c63a214db 100644 --- a/pkg/dotc1z/engine/pebble/digest.go +++ b/pkg/dotc1z/engine/pebble/digest.go @@ -470,10 +470,14 @@ type DigestRoot struct { Count int64 } -// getPartitionDigestRoot returns the stored root for a partition. ok is -// false when no digest has been built for it (the caller can fall back -// to computeBucketDigest, which derives the same digest from the index -// on demand). +// getPartitionDigestRoot returns the stored root for a partition. ok +// is false when no digest has been built for it (or it was +// invalidated) — which means RECALCULATE FROM THE PRIMARY RECORDS (or +// treat the whole partition as dirty), never "fold the index instead": +// the index is only ever written and dropped alongside the digest +// nodes, so with no root the index range is absent too and +// computeBucketDigest would read that absence as "zero records" — the +// false-clean trap dirtyPartitionBuckets' doc comment describes. func (e *Engine) getPartitionDigestRoot(spec digestIndexSpec, partition string) (DigestRoot, bool, error) { if e.grantDigestBuildPending.Load() { // An interrupted digest build's half-committed nodes may be @@ -575,6 +579,13 @@ func (e *Engine) foldedLeafBuckets(ctx context.Context, spec digestIndexSpec, pa // definition of a node; stored nodes are a cache of it. // Split-independent: the digest depends only on the records in the // bucket's hash range, not on any digest's width. +// +// PRECONDITION: only meaningful while the partition's digest is built +// (its root is stored) — the index rows this folds exist exactly as +// long as the digest nodes do. Against a never-built or invalidated +// partition the fold reads an absent index range and returns {0, 0}, +// indistinguishable from a truly empty partition; it must never be +// used as a fallback for a missing root (see getPartitionDigestRoot). func (e *Engine) computeBucketDigest(ctx context.Context, spec digestIndexSpec, partition string, bucket DigestBucket) ([]byte, int64, error) { lower, upper := spec.bucketBounds(partition, bucket) iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: lower, UpperBound: upper}) diff --git a/pkg/dotc1z/engine/pebble/engine.go b/pkg/dotc1z/engine/pebble/engine.go index b88507048..166104e0e 100644 --- a/pkg/dotc1z/engine/pebble/engine.go +++ b/pkg/dotc1z/engine/pebble/engine.go @@ -104,11 +104,13 @@ type Engine struct { // built" instead of trusting nodes a crashed build half-committed. grantDigestBuildPending atomic.Bool - // Test seams for the digest-build crash-window tests - // (grant_digest_build_crash_test.go): a hook fired at named points - // inside buildGrantDigestsFromSpill and a node-batch flush-threshold - // override so a small test dataset exercises the mid-merge commit - // path. Nil/zero in production. + // Test seams for the digest build/repair tests: a hook fired at + // named points inside buildGrantDigestsFromSpill + // (grant_digest_build_crash_test.go) and a batch flush-threshold + // override — shared by the build's fold and the streaming partition + // repair (repairOneGrantDigestPartitionLocked) — so a small test + // dataset exercises the mid-stream commit paths. Nil/zero in + // production. testDigestBuildHook func(stage string) error testDigestNodeFlushBytes int diff --git a/pkg/dotc1z/engine/pebble/grant_digest.go b/pkg/dotc1z/engine/pebble/grant_digest.go index afd2ce249..018a0e4d3 100644 --- a/pkg/dotc1z/engine/pebble/grant_digest.go +++ b/pkg/dotc1z/engine/pebble/grant_digest.go @@ -302,9 +302,14 @@ func grantPrimaryKeyFromHashIndexKey(dst, idxKey []byte) ([]byte, bool) { // --- Engine API --- // GetEntitlementDigestRoot returns the stored grant-digest root for an -// entitlement. ok is false when no digest has been built for it (the -// caller can fall back to ComputeEntitlementBucketDigest, which derives -// the same digest from the index on demand). +// entitlement. ok is false when no digest has been built for it (or it +// was invalidated) — which means the caller must re-read the +// entitlement's grants (or treat the whole entitlement as dirty), NOT +// fall back to ComputeEntitlementBucketDigest: the hash index that +// fold reads is only ever written and dropped alongside the digest +// nodes, so with no root it is absent too and the fold would report +// "zero grants" for an entitlement that may have millions — the +// false-clean trap dirtyPartitionBuckets' doc comment describes. func (e *Engine) GetEntitlementDigestRoot(ctx context.Context, id entitlementIdentity) (DigestRoot, bool, error) { return e.getPartitionDigestRoot(grantDigestSpec, digestPartitionForEntitlement(id)) } @@ -345,7 +350,13 @@ func (e *Engine) GetGrantDigestGlobalRoot(ctx context.Context) (DigestRoot, bool // ComputeEntitlementBucketDigest folds the grant hash index over a // single bucket of an entitlement (the zero bucket = the whole // entitlement) — the authoritative on-demand counterpart of the stored -// digest nodes. +// digest nodes, for verifying or subdividing a digest that EXISTS. +// Only meaningful while the entitlement's digest is built (its root is +// stored): the hash index lives and dies with the digest nodes, so +// against a never-built or invalidated entitlement this folds an +// absent index range and returns {0, 0} — "zero grants", not "unknown". +// Never use it as a fallback for a missing root; see +// GetEntitlementDigestRoot and computeBucketDigest's precondition. func (e *Engine) ComputeEntitlementBucketDigest(ctx context.Context, id entitlementIdentity, bucket DigestBucket) ([]byte, int64, error) { return e.computeBucketDigest(ctx, grantDigestSpec, digestPartitionForEntitlement(id), bucket) } diff --git a/pkg/dotc1z/engine/pebble/grant_digest_repair.go b/pkg/dotc1z/engine/pebble/grant_digest_repair.go index 0327c29f4..738e7711d 100644 --- a/pkg/dotc1z/engine/pebble/grant_digest_repair.go +++ b/pkg/dotc1z/engine/pebble/grant_digest_repair.go @@ -6,7 +6,6 @@ import ( "encoding/binary" "errors" "fmt" - "sort" "github.com/cockroachdb/pebble/v2" "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" @@ -335,22 +334,48 @@ func (e *Engine) findMissingGrantDigestPartitionsLocked(ctx context.Context) ([] // through the raw DB handle directly rather than nesting another // withWrite call. // -// One entitlement's grants are expected to fit comfortably in memory -// (this is a targeted repair, not the seal-time build sized for the -// whole file), so this sorts them directly instead of spilling to -// disk like the deferred build's spill-sorter. +// The scan STREAMS each derived row straight into the hash-index +// batch, rotated at digestNodeBatchFlushBytes, instead of buffering +// the partition's rows in memory: an "everyone"-style entitlement can +// hold millions of grants, and one allocation per row made a targeted +// repair of such a partition multi-GB. No sort is needed — batch Sets +// accept any order, (entitlement, principal) is the primary identity +// so duplicates are impossible, and the digest fold (pass 2) reads +// the committed index, which pebble returns sorted. A crash between +// rotated commits leaves hash rows without a digest root, which still +// reads as "missing"; the next repair's leading DeleteRange re-cleans +// them — the same window that already exists between the hash-index +// and digest commits. func (e *Engine) repairOneGrantDigestPartitionLocked(ctx context.Context, partition string) error { lower, upper := grantPrimaryEntitlementBoundsFromPartition(partition) - type hashRow struct { - key []byte - val [hashLen]byte + opts := writeOpts(e.opts.durability) + if e.IsFreshSync() { + opts = pebble.NoSync } - var rows []hashRow + flushBytes := digestNodeBatchFlushBytes + if e.testDigestNodeFlushBytes > 0 { + flushBytes = e.testDigestNodeFlushBytes + } + + // Pass 1: replace the hash-index range with the freshly-derived + // rows (a clean DeleteRange first: nothing may assume what, if + // anything, was left there). Closure defer, not a bound one: the + // loop rotates `hiBatch` on flush. + hiBatch := e.db.NewBatch() + defer func() { hiBatch.Close() }() + hiLower := encodeGrantByEntPrincHashEntPrefix(partition) + if err := hiBatch.DeleteRange(hiLower, upperBoundOf(hiLower), nil); err != nil { + return err + } + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: lower, UpperBound: upper}) if err != nil { return err } + var rows, droppedMalformedKeys int64 + var scratch grantHashRowScratch + var chb [hashLen]byte for iter.First(); iter.Valid(); iter.Next() { if err := ctx.Err(); err != nil { _ = iter.Close() @@ -359,23 +384,38 @@ func (e *Engine) repairOneGrantDigestPartitionLocked(ctx context.Context, partit key, value := iter.Key(), iter.Value() sep4, ok := splitGrantPrimaryKey(key) if !ok { + // Same key-layout-drift/corruption case the build paths + // count: such rows cannot be represented in the digests. + droppedMalformedKeys++ continue } - srcs, serr := scanGrantSourceKeysRawBytes(value, nil) + srcs, serr := scanGrantSourceKeysRawBytes(value, scratch.srcKeys[:0]) if serr != nil { _ = iter.Close() return serr } + scratch.srcKeys = srcs if len(srcs) > 1 { sortByteSlices(srcs) } - ch64, _ := grantContentHash64(nil, key[grantPrimaryKeyPrefixLen:], srcs) + ch64, tuple := grantContentHash64(scratch.tupleBuf, key[grantPrimaryKeyPrefixLen:], srcs) + scratch.tupleBuf = tuple bh64 := grantPrincipalBucketHash64(key[sep4+1:]) - idxKey := appendGrantHashIndexKeyFromPrimary(nil, key, sep4, bh64) - var row hashRow - row.key = idxKey - binary.BigEndian.PutUint64(row.val[:], ch64) - rows = append(rows, row) + scratch.keyBuf = appendGrantHashIndexKeyFromPrimary(scratch.keyBuf[:0], key, sep4, bh64) + binary.BigEndian.PutUint64(chb[:], ch64) + if err := hiBatch.Set(scratch.keyBuf, chb[:], nil); err != nil { + _ = iter.Close() + return err + } + rows++ + if hiBatch.Len() >= flushBytes { + if err := hiBatch.Commit(opts); err != nil { + _ = iter.Close() + return err + } + hiBatch.Close() + hiBatch = e.db.NewBatch() + } } if err := iter.Error(); err != nil { _ = iter.Close() @@ -384,40 +424,23 @@ func (e *Engine) repairOneGrantDigestPartitionLocked(ctx context.Context, partit if err := iter.Close(); err != nil { return err } - - sort.Slice(rows, func(i, j int) bool { return bytes.Compare(rows[i].key, rows[j].key) < 0 }) - - opts := writeOpts(e.opts.durability) - if e.IsFreshSync() { - opts = pebble.NoSync - } - - // Pass 1: replace the hash-index range with the freshly-derived - // rows (a clean DeleteRange first: nothing may assume what, if - // anything, was left there). - hiBatch := e.db.NewBatch() - hiLower := encodeGrantByEntPrincHashEntPrefix(partition) - if err := hiBatch.DeleteRange(hiLower, upperBoundOf(hiLower), nil); err != nil { - hiBatch.Close() - return err - } - for i := range rows { - if err := hiBatch.Set(rows[i].key, rows[i].val[:], nil); err != nil { - hiBatch.Close() - return err - } + if droppedMalformedKeys > 0 { + // Mirrors the build paths' loud skip (BuildGrantDigests / + // BuildDeferredGrantIndexes): a principal silently losing grants + // must be observable. + ctxzap.Extract(ctx).Error("grant digest repair: grant primary keys did not decode as 6-segment identities; their rows are NOT represented in the repaired digest", + zap.Int64("dropped", droppedMalformedKeys), + ) } if err := hiBatch.Commit(opts); err != nil { - hiBatch.Close() return err } - hiBatch.Close() // Pass 2: fold the just-committed hash index into the digest, // reusing the exact fold logic the seal-time build runs // (foldPartitionNodes) instead of re-deriving the same computation - // a second way from the in-memory rows. - width := chooseDigestWidth(int64(len(rows))) + // a second way from the scanned rows. + width := chooseDigestWidth(rows) rootVal, leaves, err := e.foldPartitionNodes(ctx, grantDigestSpec, partition, width) if err != nil { return err diff --git a/pkg/dotc1z/engine/pebble/grant_digest_repair_test.go b/pkg/dotc1z/engine/pebble/grant_digest_repair_test.go index 4098cb7cb..5f46f124d 100644 --- a/pkg/dotc1z/engine/pebble/grant_digest_repair_test.go +++ b/pkg/dotc1z/engine/pebble/grant_digest_repair_test.go @@ -2,10 +2,14 @@ package pebble import ( "context" + "strings" "testing" "github.com/cockroachdb/pebble/v2" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "github.com/segmentio/ksuid" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3" ) @@ -212,3 +216,147 @@ func TestGrantPartitionFromPrimaryKey(t *testing.T) { t.Fatalf("partition = %q, want %q", got, want) } } + +// repairLogCore is a minimal zapcore.Core recording entries in-memory +// so tests can assert on repair logs without zaptest/observer (not +// vendored) — same pattern as progresslog's capturingCore. +type repairLogCore struct { + zapcore.LevelEnabler + entries []repairLogEntry +} + +type repairLogEntry struct { + msg string + fields map[string]any +} + +func newRepairLogCore() *repairLogCore { + return &repairLogCore{LevelEnabler: zapcore.ErrorLevel} +} + +func (c *repairLogCore) With([]zapcore.Field) zapcore.Core { return c } +func (c *repairLogCore) Check(ent zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry { + if c.Enabled(ent.Level) { + return ce.AddCore(ent, c) + } + return ce +} + +func (c *repairLogCore) Write(ent zapcore.Entry, fields []zapcore.Field) error { + enc := zapcore.NewMapObjectEncoder() + for _, f := range fields { + f.AddTo(enc) + } + c.entries = append(c.entries, repairLogEntry{msg: ent.Message, fields: enc.Fields}) + return nil +} + +func (c *repairLogCore) Sync() error { return nil } + +// TestRepairMissingGrantDigestsCountsMalformedKeys pins the repair +// path's loud skip of grant primary keys that fail the 6-segment +// split, matching the build paths: the row cannot be represented in +// the digest, and a principal silently losing grants must be +// observable. The repair itself still succeeds, covering only the +// well-formed rows. +func TestRepairMissingGrantDigestsCountsMalformedKeys(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + putEnt(t, e, ctx, "ent-a") + if err := e.PutGrantRecords(ctx, + makeGrant("", "g1", "ent-a", "alice"), + makeGrant("", "g2", "ent-a", "bob"), + ); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + sealGrantDigests(t, e) + + // A key inside ent-a's primary range with SEVEN tuple segments — + // the key-layout-drift/corruption shape splitGrantPrimaryKey + // rejects (a well-formed grant identity has exactly six). + partition := testEntPartition("ent-a") + lower, _ := grantPrimaryEntitlementBoundsFromPartition(partition) + malformed := append(append([]byte(nil), lower...), 'p', 0, 'q', 0, 'r') + if err := e.db.Set(malformed, []byte("junk"), pebble.NoSync); err != nil { + t.Fatalf("inject malformed key: %v", err) + } + + if err := e.InvalidateGrantDigestPartitions(ctx, []string{partition}); err != nil { + t.Fatalf("InvalidateGrantDigestPartitions: %v", err) + } + + core := newRepairLogCore() + lctx := ctxzap.ToContext(ctx, zap.New(core)) + if err := e.RepairMissingGrantDigests(lctx); err != nil { + t.Fatalf("RepairMissingGrantDigests: %v", err) + } + + root, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-a")) + if err != nil || !ok { + t.Fatalf("ent-a root after repair: ok=%v err=%v, want repaired", ok, err) + } + if root.Count != 2 { + t.Fatalf("repaired root count = %d, want 2 (malformed row must not be represented)", root.Count) + } + + var logged bool + for _, entry := range core.entries { + if !strings.Contains(entry.msg, "NOT represented in the repaired digest") { + continue + } + logged = true + if dropped, _ := entry.fields["dropped"].(int64); dropped != 1 { + t.Fatalf("dropped field = %v, want 1", entry.fields["dropped"]) + } + } + if !logged { + t.Fatal("expected an Error log counting the malformed key the repair skipped") + } +} + +// TestRepairStreamsPartitionInBoundedBatches pins the repair's +// streaming pass-1: with the flush threshold forced to rotate the +// hash-index batch after every row (the smallest possible bound, i.e. +// maximum rotations — the shape a multi-million-grant "everyone" +// entitlement would produce at the real 4MiB threshold), the repaired +// hash index and digest nodes must still be byte-identical to the +// seal-time build's. +func TestRepairStreamsPartitionInBoundedBatches(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + for entID, n := range map[string]int{"ent-big": 700, "ent-small": 2} { + putEnt(t, e, ctx, entID) + grants := make([]*v3.GrantRecord, 0, n) + for range n { + grants = append(grants, makeGrant("", ksuid.New().String(), entID, ksuid.New().String())) + } + if err := e.PutGrantRecords(ctx, grants...); err != nil { + t.Fatalf("PutGrantRecords(%s): %v", entID, err) + } + } + sealGrantDigests(t, e) + wantNodes := dumpDigestNodes(t, e) + wantHash := dumpKeyRangeTest(t, e, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()) + + // Rotate the repair's hash-index batch on every Set. + e.testDigestNodeFlushBytes = 1 + + partition := testEntPartition("ent-big") + if err := e.InvalidateGrantDigestPartitions(ctx, []string{partition}); err != nil { + t.Fatalf("InvalidateGrantDigestPartitions: %v", err) + } + if err := e.RepairMissingGrantDigests(ctx); err != nil { + t.Fatalf("RepairMissingGrantDigests: %v", err) + } + + requireSameDigestNodes(t, dumpDigestNodes(t, e), wantNodes) + requireSameDigestNodes(t, dumpKeyRangeTest(t, e, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()), wantHash) +} From dd7bb7514e73507dacdcaeb603f9c450e8219f99 Mon Sep 17 00:00:00 2001 From: MJ Palanker Date: Fri, 10 Jul 2026 13:18:55 -0700 Subject: [PATCH 08/12] fixup lint errors --- .golangci.yml | 3 +++ pkg/dotc1z/engine/pebble/deferred_index.go | 2 +- pkg/dotc1z/engine/pebble/grants_synth_encode.go | 4 ++-- pkg/dotc1z/format/v3/envelope_test.go | 2 +- pkg/sync/expand/fanout_disk_benchmark_test.go | 5 ++++- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 0121ce5e5..6e6463729 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -78,6 +78,9 @@ linters: - hasher.Write - os.Setenv - os.RemoveAll + # bytes.Buffer writes are documented to never return an error. + - bytes.Buffer.Write + - bytes.Buffer.WriteByte - name: var-naming arguments: - - ID diff --git a/pkg/dotc1z/engine/pebble/deferred_index.go b/pkg/dotc1z/engine/pebble/deferred_index.go index e6d44d63e..9710d665a 100644 --- a/pkg/dotc1z/engine/pebble/deferred_index.go +++ b/pkg/dotc1z/engine/pebble/deferred_index.go @@ -68,7 +68,7 @@ func appendGrantByPrincipalKeyFromPrimary(dst, primaryKey []byte) ([]byte, bool) if sep < 0 { return dst, false } - segs[i] = rest[:sep] + segs[i] = rest[:sep] // #nosec G602 -- false positive: IndexByte returned >= 0 above, so sep < len(rest). rest = rest[sep+1:] } if bytes.IndexByte(rest, 0) >= 0 { diff --git a/pkg/dotc1z/engine/pebble/grants_synth_encode.go b/pkg/dotc1z/engine/pebble/grants_synth_encode.go index 8fa0a780d..b398712fd 100644 --- a/pkg/dotc1z/engine/pebble/grants_synth_encode.go +++ b/pkg/dotc1z/engine/pebble/grants_synth_encode.go @@ -112,14 +112,14 @@ func appendGrantSourcesWire(dst []byte, scratch batonGrant.Sources, sources bato } keyLen := len(src.EntitlementID) entryLen := 1 + protowire.SizeVarint(uint64(keyLen)) + keyLen + - 1 + protowire.SizeVarint(uint64(valLen)) + valLen + 1 + protowire.SizeVarint(uint64(valLen)) + valLen // #nosec G115 -- valLen is 0 or 2. dst = protowire.AppendVarint(dst, grantSourcesFieldTag) dst = protowire.AppendVarint(dst, uint64(entryLen)) // #nosec G115 -- entryLen is a small positive length. dst = protowire.AppendVarint(dst, grantSourceKeyTag) dst = protowire.AppendVarint(dst, uint64(keyLen)) dst = append(dst, src.EntitlementID...) dst = protowire.AppendVarint(dst, grantSourceValTag) - dst = protowire.AppendVarint(dst, uint64(valLen)) + dst = protowire.AppendVarint(dst, uint64(valLen)) // #nosec G115 -- valLen is 0 or 2. if src.IsDirect { dst = protowire.AppendVarint(dst, grantSourceDirectTag) dst = protowire.AppendVarint(dst, 1) diff --git a/pkg/dotc1z/format/v3/envelope_test.go b/pkg/dotc1z/format/v3/envelope_test.go index 51aad2bd4..905cca227 100644 --- a/pkg/dotc1z/format/v3/envelope_test.go +++ b/pkg/dotc1z/format/v3/envelope_test.go @@ -229,7 +229,7 @@ func TestPayloadBudgetForFileSize(t *testing.T) { require.Equal(t, defaultMaxDecodedPayloadBytes, payloadBudgetForFileSize(-1)) }) t.Run("large file scales with its size", func(t *testing.T) { - size := int64(1 << 30) // 1 GiB file → 100 GiB budget + const size = 1 << 30 // 1 GiB file → 100 GiB budget require.Equal(t, uint64(size)*maxPayloadCompressionRatio, payloadBudgetForFileSize(size)) }) t.Run("env var overrides scaling", func(t *testing.T) { diff --git a/pkg/sync/expand/fanout_disk_benchmark_test.go b/pkg/sync/expand/fanout_disk_benchmark_test.go index 5d6b4eb4b..f0a894e56 100644 --- a/pkg/sync/expand/fanout_disk_benchmark_test.go +++ b/pkg/sync/expand/fanout_disk_benchmark_test.go @@ -161,7 +161,10 @@ func BenchmarkExpandFanout10MDisk(b *testing.B) { _ = tmp.Close() runPath := tmp.Name() // runPath is from os.CreateTemp().Name(), not external input — benchmark-only. - if err := os.WriteFile(runPath, baseData, 0600); err != nil { //nolint:gosec // G703: path is a test-created temp file, not tainted + // #nosec G703 -- path is a test-created temp file, not tainted. (#nosec, not + // //nolint: CI's pinned gosec fires G703 here, newer local builds don't — + // nolintlint would report an unused directive on whichever side doesn't.) + if err := os.WriteFile(runPath, baseData, 0600); err != nil { b.Fatal(err) } c1f, err := dotc1z.NewC1ZFile(ctx, runPath) From ed5cc8a07c576af6d79d3998c866b64d5c2f2d84 Mon Sep 17 00:00:00 2001 From: MJ Palanker Date: Fri, 10 Jul 2026 16:30:46 -0700 Subject: [PATCH 09/12] apparenly fix a flaky test? --- pkg/dotc1z/pebble_store_registry_test.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/dotc1z/pebble_store_registry_test.go b/pkg/dotc1z/pebble_store_registry_test.go index c1012d3ca..14cba6ca0 100644 --- a/pkg/dotc1z/pebble_store_registry_test.go +++ b/pkg/dotc1z/pebble_store_registry_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/binary" + "errors" "os" "path/filepath" "testing" @@ -179,7 +180,13 @@ func TestPebbleStoreOpenHonorsDecoderMaxMemory(t *testing.T) { WithReadOnly(true), WithDecoderOptions(WithDecoderMaxMemory(1)), ) - require.ErrorIs(t, err, zstd.ErrWindowSizeExceeded, "NewStore with tiny decoder memory error = %v, want zstd.ErrWindowSizeExceeded", err) + // Depending on the frame shape, the 1-byte cap surfaces as either the + // window-size or decoded-size violation; both prove the custom cap + // reached the decoder. + require.True(t, + errors.Is(err, zstd.ErrWindowSizeExceeded) || errors.Is(err, zstd.ErrDecoderSizeExceeded), + "NewStore with tiny decoder memory error = %v, want a zstd memory-cap violation", err, + ) } func TestReadOnlyPebbleOpenMigratesLegacyIDIndexTempCopy(t *testing.T) { From 3b668827f40425fe52b3b74c6e388d1aae3e5af6 Mon Sep 17 00:00:00 2001 From: MJ Palanker Date: Mon, 13 Jul 2026 14:25:15 -0700 Subject: [PATCH 10/12] include orphans always --- .../engine/pebble/grant_digest_repair.go | 116 ++++++++++++++---- .../engine/pebble/grant_digest_repair_test.go | 77 ++++++++++++ 2 files changed, 170 insertions(+), 23 deletions(-) diff --git a/pkg/dotc1z/engine/pebble/grant_digest_repair.go b/pkg/dotc1z/engine/pebble/grant_digest_repair.go index 738e7711d..819734b16 100644 --- a/pkg/dotc1z/engine/pebble/grant_digest_repair.go +++ b/pkg/dotc1z/engine/pebble/grant_digest_repair.go @@ -150,12 +150,18 @@ func (e *Engine) InvalidateGrantDigestPartitions(ctx context.Context, partitions // complete state independently. // // Orphan grants (an entitlement identity with grants but no -// entitlement RECORD) are not discovered by the missing-partition scan -// below, which walks entitlement records — matching the scope of -// writeMissingEntitlementDigestRoots's own backfill. An orphan that -// already has a valid root from an earlier full build is still -// correctly folded into the recomputed global root, since that step -// scans the digest keyspace itself, not entitlement records. +// entitlement RECORD) are covered: the missing-partition scan +// discovers grant-bearing partitions from the grant primary keyspace +// itself, not from entitlement records, so an invalidated orphan is +// rebuilt like any other partition and the recomputed global root +// always includes it. That keeps the global root a pure function of +// the stored grant set — identical whether it was produced by a seal +// (whose fold covers orphans too) or by a fold + targeted repair — +// which the manifest's header-only "did the grants change?" +// comparison depends on. Without it, a touched orphan would go +// missing forever: the recomputed root's presence would satisfy the +// repair fast path (repairMissingGrantDigestsAttempt) on every later +// call. // // Called from Adapter.endSyncFinalize in place of a bare // BuildGrantDigests whenever the deferred (by_principal-rebuilding) @@ -285,43 +291,107 @@ func (e *Engine) repairMissingGrantDigestsLocked(ctx context.Context) error { return nil } -// findMissingGrantDigestPartitionsLocked scans every entitlement -// record and returns the partitions with no stored digest root: one -// point Get per entitlement, no grant scan unless (in the caller) a -// partition turns out to be missing. Orphan grant-bearing entitlements -// without a record are not covered — see RepairMissingGrantDigests's -// doc comment. +// findMissingGrantDigestPartitionsLocked returns the partitions with +// no stored digest root, in two passes. Pass 1 discovers every +// grant-BEARING partition from the grant primary keyspace itself — +// one boundary seek plus one root point-Get per partition, so still +// bounded by partition count, not grant count. Deriving these from +// the grants rather than the entitlement records is what makes orphan +// partitions (grants with no entitlement record) repairable: the +// seal-time fold includes orphans in the global root, so a repair +// that couldn't rediscover an invalidated orphan would recompute a +// root that represents the same grant set differently (see +// RepairMissingGrantDigests's doc comment). Pass 2 walks the +// entitlement records (one point Get each) and adds only what pass 1 +// can't see: entitlements with zero grants, whose {count: 0} root +// (writeMissingEntitlementDigestRoots) may equally have been +// invalidated — e.g. by deleting a partition's last grant. func (e *Engine) findMissingGrantDigestPartitionsLocked(ctx context.Context) ([]string, error) { - iter, err := e.db.NewIter(&pebble.IterOptions{ + var missing []string + missingSet := map[string]struct{}{} + + giter, err := e.db.NewIter(&pebble.IterOptions{ + LowerBound: GrantLowerBound(), + UpperBound: GrantUpperBound(), + }) + if err != nil { + return nil, err + } + defer giter.Close() + for valid := giter.First(); valid; { + if err := ctx.Err(); err != nil { + return nil, err + } + partition, ok := GrantPartitionFromPrimaryKey(giter.Key()) + if !ok { + // Malformed key: no partition to derive seek bounds from, so + // step one key. (One inside a well-formed partition's range + // is jumped by the seek below instead, and counted when that + // partition is repaired — repairOneGrantDigestPartitionLocked.) + valid = giter.Next() + continue + } + present, err := e.grantDigestRootPresent(partition) + if err != nil { + return nil, err + } + if !present { + missing = append(missing, partition) + missingSet[partition] = struct{}{} + } + _, partitionUpper := grantPrimaryEntitlementBoundsFromPartition(partition) + valid = giter.SeekGE(partitionUpper) + } + if err := giter.Error(); err != nil { + return nil, err + } + + eiter, err := e.db.NewIter(&pebble.IterOptions{ LowerBound: EntitlementLowerBound(), UpperBound: EntitlementUpperBound(), }) if err != nil { return nil, err } - defer iter.Close() - var missing []string - for iter.First(); iter.Valid(); iter.Next() { + defer eiter.Close() + for eiter.First(); eiter.Valid(); eiter.Next() { if err := ctx.Err(); err != nil { return nil, err } - key := iter.Key() + key := eiter.Key() if len(key) <= grantPrimaryKeyPrefixLen { continue } // The entitlement primary tail IS the digest partition (see the // partition convention in keys.go) — a raw splice, no decode. partition := string(key[grantPrimaryKeyPrefixLen:]) - rootKey := encodeDigestNodeKey(grantDigestSpec.indexID, partition, digestLevelRoot, nil) - if _, closer, err := e.db.Get(rootKey); err == nil { - closer.Close() + if _, dup := missingSet[partition]; dup { continue - } else if !errors.Is(err, pebble.ErrNotFound) { + } + present, err := e.grantDigestRootPresent(partition) + if err != nil { return nil, err } - missing = append(missing, partition) + if !present { + missing = append(missing, partition) + } + } + return missing, eiter.Error() +} + +// grantDigestRootPresent reports whether partition has a stored grant- +// digest root node: one point Get, no value read. +func (e *Engine) grantDigestRootPresent(partition string) (bool, error) { + rootKey := encodeDigestNodeKey(grantDigestSpec.indexID, partition, digestLevelRoot, nil) + _, closer, err := e.db.Get(rootKey) + if err == nil { + closer.Close() + return true, nil + } + if errors.Is(err, pebble.ErrNotFound) { + return false, nil } - return missing, iter.Error() + return false, err } // repairOneGrantDigestPartitionLocked rebuilds BOTH the hash-index diff --git a/pkg/dotc1z/engine/pebble/grant_digest_repair_test.go b/pkg/dotc1z/engine/pebble/grant_digest_repair_test.go index 5f46f124d..65eea43b7 100644 --- a/pkg/dotc1z/engine/pebble/grant_digest_repair_test.go +++ b/pkg/dotc1z/engine/pebble/grant_digest_repair_test.go @@ -136,6 +136,83 @@ func TestRepairMissingGrantDigestsHealsOnlyInvalidatedPartition(t *testing.T) { requireSameDigestNodes(t, dumpDigestNodes(t, e), want) } +// TestRepairMissingGrantDigestsRediscoversInvalidatedOrphan pins that +// the missing-partition scan discovers partitions from the grant +// primary keyspace, not entitlement records: after invalidating an +// ORPHAN partition (grants with no entitlement record) alongside a +// regular one — the shape a fold merge produces when the incoming +// sync carries a grant whose entitlement record is absent — repair +// must rebuild both and leave the whole digest keyspace (global root +// included) byte-identical to the seal-time build's. An +// entitlement-record-only scan would never rediscover the orphan, and +// the recomputed global root would silently exclude its grants — +// permanently, since the root's presence satisfies the repair fast +// path on every later call. +func TestRepairMissingGrantDigestsRediscoversInvalidatedOrphan(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + putEnt(t, e, ctx, "ent-real") + // No putEnt for ent-orphan: its grants have no entitlement record. + if err := e.PutGrantRecords(ctx, + makeGrant("", "g1", "ent-real", "alice"), + makeGrant("", "g2", "ent-orphan", "bob"), + makeGrant("", "g3", "ent-orphan", "carol"), + ); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + sealGrantDigests(t, e) + if _, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-orphan")); err != nil || !ok { + t.Fatalf("orphan root after seal: ok=%v err=%v, want present (seal covers orphans)", ok, err) + } + want := dumpDigestNodes(t, e) + + if err := e.InvalidateGrantDigestPartitions(ctx, []string{ + testEntPartition("ent-orphan"), + testEntPartition("ent-real"), + }); err != nil { + t.Fatalf("InvalidateGrantDigestPartitions: %v", err) + } + if err := e.RepairMissingGrantDigests(ctx); err != nil { + t.Fatalf("RepairMissingGrantDigests: %v", err) + } + + requireSameDigestNodes(t, dumpDigestNodes(t, e), want) +} + +// TestRepairMissingGrantDigestsRediscoversZeroGrantEntitlement pins +// the scan's second pass: a zero-grant entitlement's {count: 0} root +// is invisible to the grant-keyspace pass (it has no grant keys), so +// after invalidation it must be rediscovered from the entitlement +// records and rebuilt — again byte-identical to the seal-time build. +func TestRepairMissingGrantDigestsRediscoversZeroGrantEntitlement(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + putEnt(t, e, ctx, "ent-empty") + putEnt(t, e, ctx, "ent-a") + if err := e.PutGrantRecords(ctx, makeGrant("", "g1", "ent-a", "alice")); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + sealGrantDigests(t, e) + want := dumpDigestNodes(t, e) + + if err := e.InvalidateGrantDigestPartitions(ctx, []string{testEntPartition("ent-empty")}); err != nil { + t.Fatalf("InvalidateGrantDigestPartitions: %v", err) + } + if err := e.RepairMissingGrantDigests(ctx); err != nil { + t.Fatalf("RepairMissingGrantDigests: %v", err) + } + + requireSameDigestNodes(t, dumpDigestNodes(t, e), want) +} + // TestRepairMissingGrantDigestsNoOpWhenNothingMissing verifies a // repair call against a fully-built file changes nothing. func TestRepairMissingGrantDigestsNoOpWhenNothingMissing(t *testing.T) { From f516a829cf317fcc213a932c4faf86a68918af15 Mon Sep 17 00:00:00 2001 From: MJ Palanker Date: Mon, 13 Jul 2026 15:31:19 -0700 Subject: [PATCH 11/12] is this nolint needed? --- pkg/sync/expander_store_guard_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/sync/expander_store_guard_test.go b/pkg/sync/expander_store_guard_test.go index 6a0d6f832..47d30047d 100644 --- a/pkg/sync/expander_store_guard_test.go +++ b/pkg/sync/expander_store_guard_test.go @@ -1,4 +1,4 @@ -package sync +package sync //nolint:revive,nolintlint // we can't change the package name for backwards compatibility import ( "context" From 7528e04d48f0d6f2c9624460a21bb7260f25bcef Mon Sep 17 00:00:00 2001 From: MJ Palanker Date: Mon, 13 Jul 2026 16:45:51 -0700 Subject: [PATCH 12/12] drop indexes if we aren't maintaining indexes lol --- .../compactor_grant_digest_test.go | 94 +++++++++++++++++++ pkg/synccompactor/compactor_pebble.go | 46 ++++++--- 2 files changed, 128 insertions(+), 12 deletions(-) diff --git a/pkg/synccompactor/compactor_grant_digest_test.go b/pkg/synccompactor/compactor_grant_digest_test.go index 58cdebe9c..88bc5002c 100644 --- a/pkg/synccompactor/compactor_grant_digest_test.go +++ b/pkg/synccompactor/compactor_grant_digest_test.go @@ -8,6 +8,7 @@ import ( "sync" "testing" + "github.com/cockroachdb/pebble/v2" "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "github.com/stretchr/testify/require" "go.uber.org/zap" @@ -309,6 +310,99 @@ func TestCompactPebbleFoldRepairsOnlyTouchedEntitlements(t *testing.T) { "compacted root %x must byte-equal a from-scratch build %x", gotRoot.Hash, wantRoot.Hash) } +// requireEmptyKeyRange asserts the engine's raw keyspace holds nothing +// in [lo, hi). +func requireEmptyKeyRange(t testing.TB, eng *enginepkg.Engine, lo, hi []byte, what string) { + t.Helper() + iter, err := eng.DB().NewIter(&pebble.IterOptions{LowerBound: lo, UpperBound: hi}) + require.NoError(t, err) + defer iter.Close() + require.False(t, iter.First(), "%s keyspace must be empty, found key %x", what, iter.Key()) + require.NoError(t, iter.Error()) +} + +// TestCompactPebbleFoldDigestIndexDisabledDropsDigests pins the fold's +// disabled-digest-index escape hatch: the dest starts as a byte copy of +// the sealed base INCLUDING its digest + hash-index keyspaces, readers +// trust whatever is stored regardless of the writer's flag, and with +// WithGrantDigestIndex(false) every rebuild path (the targeted repair, +// EndSync's finalize) is gated off — so once the merge writes a grant, +// the copied digests are stale and nothing downstream can heal them. +// The fold must DROP the copied digest state instead (absent is always +// safe; present-but-stale silently corrupts grant diffs). Before this, +// the output shipped the base's untouched-but-wrong digest root. +func TestCompactPebbleFoldDigestIndexDisabledDropsDigests(t *testing.T) { + logger, capture := newCapturingLogger() + ctx := ctxzap.ToContext(context.Background(), logger) + inDir := t.TempDir() + outDir := t.TempDir() + + basePath := filepath.Join(inDir, "base.c1z") + partialPath := filepath.Join(inDir, "partial.c1z") + baseSync := buildOverlayInput(t, ctx, basePath, overlayInputSpec{ + syncType: connectorstore.SyncTypeFull, + suffix: "base", + grants: []overlayGrantSpec{ + {id: "g-ent1-alice", principalID: "alice", entitlementID: "ent-1"}, + {id: "g-ent2-bob", principalID: "bob", entitlementID: "ent-2"}, + }, + }) + // The partial adds a grant under ent-1, so the fold's merge writes + // into the grants keyspace and ent-1's copied digest goes stale. + partialSync := buildOverlayInput(t, ctx, partialPath, overlayInputSpec{ + syncType: connectorstore.SyncTypePartial, + suffix: "partial", + grants: []overlayGrantSpec{ + {id: "g-ent1-bob", principalID: "bob", entitlementID: "ent-1"}, + }, + }) + // Sanity: the sealed base carries the digest state the fold copies. + grantDigestGlobalRootOf(t, ctx, basePath, baseSync) + + entries := []*CompactableSync{{FilePath: basePath, SyncID: baseSync}, {FilePath: partialPath, SyncID: partialSync}} + c, cleanup, err := NewCompactor(ctx, outDir, entries, + WithTmpDir(t.TempDir()), WithEngine(c1zstore.EnginePebble), WithPebbleCompactorMode(PebbleCompactorModeFold), + WithSkipGrantExpansion(), + WithC1ZOptions(dotc1z.WithGrantDigestIndex(false))) + require.NoError(t, err) + defer func() { _ = cleanup() }() + + out, err := c.Compact(ctx) + require.NoError(t, err) + require.NotNil(t, out) + + var sawDrop bool + for _, msg := range capture() { + if msg == "compactPebbleFold: grant writes with digest index disabled; dropped the base's copied digest state" { + sawDrop = true + } + } + require.True(t, sawDrop, "expected the fold to log dropping the copied digest state") + + store, err := dotc1z.NewStore(ctx, out.FilePath, dotc1z.WithReadOnly(true), dotc1z.WithTmpDir(t.TempDir())) + require.NoError(t, err) + defer store.Close(ctx) + eng, ok := enginepkg.AsEngine(store) + require.True(t, ok, "compacted output must be a pebble engine") + require.NoError(t, store.SetCurrentSync(ctx, out.SyncID)) + + // A default-options reader must see digests as ABSENT (recalculate), + // never the base's stale-but-present state. + _, ok, err = eng.GetGrantDigestGlobalRoot(ctx) + require.NoError(t, err) + require.False(t, ok, "output must carry no whole-file digest root") + requireEmptyKeyRange(t, eng, enginepkg.DigestLowerBound(), enginepkg.DigestUpperBound(), "digest node") + requireEmptyKeyRange(t, eng, enginepkg.GrantByEntPrincHashLowerBound(), enginepkg.GrantByEntPrincHashUpperBound(), "grant hash index") + + // And the saved envelope header must not stamp a stale root either. + f, err := os.Open(out.FilePath) + require.NoError(t, err) + defer f.Close() + m, err := formatv3.ReadManifestHeader(f) + require.NoError(t, err) + require.Nil(t, m.GetGrantDigestRoot(), "manifest must not carry a grant digest root after the drop") +} + // TestCompactPebbleFoldWithExpansionRebuildsFullyRegardless closes the // loop on a real question about the targeted repair's safety: grant // expansion (run by Compact whenever it isn't skipped — here, because diff --git a/pkg/synccompactor/compactor_pebble.go b/pkg/synccompactor/compactor_pebble.go index 4c67bf2a1..9983ba315 100644 --- a/pkg/synccompactor/compactor_pebble.go +++ b/pkg/synccompactor/compactor_pebble.go @@ -608,19 +608,41 @@ func (c *Compactor) compactPebbleFold(ctx context.Context) (string, error) { // (expansion NOT skipped) pays for both the targeted repair AND the // subsequent full rebuild. See // TestCompactPebbleFoldWithExpansionRebuildsFullyRegardless. - if len(foldStats.TouchedGrantPartitions) > 0 && destEng.GrantDigestIndexEnabled() { - partitions := make([]string, 0, len(foldStats.TouchedGrantPartitions)) - for p := range foldStats.TouchedGrantPartitions { - partitions = append(partitions, p) - } - if err := destEng.InvalidateGrantDigestPartitions(ctx, partitions); err != nil { - return "", fmt.Errorf("compactPebbleFold: invalidate grant digest partitions: %w", err) - } - if err := destEng.RepairMissingGrantDigests(ctx); err != nil { - return "", fmt.Errorf("compactPebbleFold: repair grant digests: %w", err) + // + // When the dest engine has the digest index DISABLED, touched + // digests must be dropped instead of repaired: the byte copy + // carried the sealed base's digest state unconditionally, readers + // serve whatever is stored regardless of this writer's flag + // (grantDigestsPresent is probed from the keyspace at Open), and + // every rebuild path — this one, EndSync's finalize, + // RepairMissingGrantDigests itself — gates on the same flag, so + // stale digests would ship as present-but-wrong with nothing left + // to heal them. Absent is always safe (present-means-exact). + // Dropping only on a grant write, rather than skipping the digest + // bucket copy up front, keeps the no-grant-write fold preserving + // the base's still-exact digests for free even on a disabled-index + // engine. See TestCompactPebbleFoldDigestIndexDisabledDropsDigests. + if len(foldStats.TouchedGrantPartitions) > 0 { + if !destEng.GrantDigestIndexEnabled() { + if err := destEng.DropAllGrantDigestState(ctx); err != nil { + return "", fmt.Errorf("compactPebbleFold: drop grant digest state (digest index disabled): %w", err) + } + l.Info("compactPebbleFold: grant writes with digest index disabled; dropped the base's copied digest state", + zap.Int("touched_partitions", len(foldStats.TouchedGrantPartitions))) + } else { + partitions := make([]string, 0, len(foldStats.TouchedGrantPartitions)) + for p := range foldStats.TouchedGrantPartitions { + partitions = append(partitions, p) + } + if err := destEng.InvalidateGrantDigestPartitions(ctx, partitions); err != nil { + return "", fmt.Errorf("compactPebbleFold: invalidate grant digest partitions: %w", err) + } + if err := destEng.RepairMissingGrantDigests(ctx); err != nil { + return "", fmt.Errorf("compactPebbleFold: repair grant digests: %w", err) + } + l.Info("compactPebbleFold: repaired grant digests for touched entitlements", + zap.Int("touched_partitions", len(partitions))) } - l.Info("compactPebbleFold: repaired grant digests for touched entitlements", - zap.Int("touched_partitions", len(partitions))) } else { l.Info("compactPebbleFold: no grant writes; base grant digest state left untouched") }