diff-aware incremental grant expansion - #1013
Conversation
99c1208 to
4e46616
Compare
General PR Review: diff-aware incremental grant expansionBlocking Issues: 0 | Suggestions: 5 | Threads Resolved: 0 Reviewed at head Review SummaryScanned the full PR diff (40 files, +7587/-142) for security and correctness, with focused reads of the new incremental expander, the compactor's diff-aware path, the Pebble graph sidecar, and the fold/merge changes. Prior finding Risk triage (per Security IssuesNone found. The added Correctness IssuesNone found. The seed derivation (new edges plus fold-collected changed entitlements), the affected-closure topological order, the reverse dropped-edge check against Suggestions
Prompt for AI agents |
|
Review feedback from both me, and two Fable passes over the PR considering it in an of itself, and in regard to the big sync replay effort. Replays (eg delta queries in entra) may as well be a new flavor of online/live compaction with certified merge semantics around connector defined scopes (an etag for a page...) instead of the SDK's row level scoping, and they already solve the negative case of tomb stones. I plan on finishing off both sides after both change sets land - incremental expansion for replays, and tombstones for compaction. Review: incremental grant expansionThe design direction is right. But there are two blockers that mean the incremental path never actually executes against a real store today, two correctness divergences from full expansion that are masked by those blockers, and several soundness/lifecycle gaps. Details below, then the requested changes. BlockersB1. The incremental expander cannot read grants through the real store adapter — it always errors and silently falls back to full expansion. B2. SQLite lifecycle refuses the resume the incremental path depends on. Correctness bugs (currently masked by B1)C1. Wrong directness predicate for shallow edges. C2. Sources/provenance are never merged, and the drift compounds. C3. Edge-spec changes on an existing edge are invisible. UnsoundnessU1. The caller's base graph is mutated in place, poisoning retries. U2. Requested changes
Items 1–4 are correctness blockers; 5–8 and 10 are unsoundness/API-shape (6 and 7 are cheap now and structural later); 9 is convergence hygiene; 11–12 are cheap tests and a measurement. Notes (no action required)
Other stuff
|
4e46616 to
f996ba5
Compare
| NextPage(ctx context.Context, actionID string, pageToken string) error | ||
| EntitlementGraph(ctx context.Context) *expand.EntitlementGraph | ||
| ClearEntitlementGraph(ctx context.Context) | ||
| ClearEntitlementGraphTransientState(ctx context.Context) |
There was a problem hiding this comment.
🟡 Suggestion: This adds a method to the exported State interface, which is a breaking change for any downstream that implements it. In practice State is only implemented by the unexported *state and can't be injected into the syncer (no public constructor/setter accepts a custom State), so the real-world impact is low. Flagging only for SDK-compat awareness — no change required if State isn't intended as a downstream extension point. (low confidence)
Fixes from kans's review of the diff-aware incremental grant expansion: - B1: pass full entitlement records (not bare ids) to all grant reads so the incremental path stops erroring into a silent full-expansion fallback; compactor tests assert it actually ran. - B2: scope incremental to Pebble; degrade gracefully to full on SQLite. - C1: use isGrantDirectOnEntitlement for shallow filtering and IsDirect. - C2: merge sources into existing grants and record all contributing edges; parity tests compare full rows including sources maps. - Derive changed entitlements inside the compactor; drop the caller param. Compare edge specs (not just endpoints): widened re-expands, narrowed auto-declines via a named ErrIncrementalRevocationDecline hook. - U1: clone the base graph so a failed/declined run can't poison a retry. - U2: clear a preserved graph in PrepareExpansionReplayToken so replay works. - Converge the finish path: Cleanup/EndSync/Close on a detached ctx, fatal teardown errors vs safe fallback, run-duration bound + ctx polling, dangling-ref skip-with-warn. - Strip transient graph state before the final checkpoint. - Tests: dangling-ref + sealed-artifact lifecycle + revocation parity + shallow-directness differentials. Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
f996ba5 to
e69dac6
Compare
| return false, fmt.Errorf("incremental expansion: get sync: %w", err) | ||
| } | ||
| syncType := connectorstore.SyncType(syncResp.GetSync().GetSyncType()) | ||
| if _, _, err := c.compactedC1z.StartOrResumeSync(walkCtx, syncType, newSyncId); err != nil { |
There was a problem hiding this comment.
🟡 Suggestion: If StartOrResumeSync fails here, the store may be left partially resumed, but this returns a plain error that the caller treats as a safe fallback to full expansion (case err != nil → warn + full). Elsewhere the code deliberately marks state-ambiguous failures (restoreEndedSync, finishIncrementalExpansion) as errIncrementalFatal precisely because running full expansion against an unknown store state is unsafe. Consider whether a resume failure should also be fatal (or otherwise guaranteed to leave the store in the ended state the full path expects). Low confidence — depends on StartOrResumeSync leaving the store untouched on error.
There was a problem hiding this comment.
^ This code is scary. Actually starting a new sync would mean throwing out all existing data. Why not call ResumeSync?
Fixes from kans's review of the diff-aware incremental grant expansion: - B1: pass full entitlement records (not bare ids) to all grant reads so the incremental path stops erroring into a silent full-expansion fallback; compactor tests assert it actually ran. - B2: scope incremental to Pebble; degrade gracefully to full on SQLite. - C1: use isGrantDirectOnEntitlement for shallow filtering and IsDirect. - C2: merge sources into existing grants and record all contributing edges; parity tests compare full rows including sources maps. - Derive changed entitlements inside the compactor; drop the caller param. Compare edge specs (not just endpoints): widened re-expands, narrowed auto-declines via a named ErrIncrementalRevocationDecline hook. - U1: clone the base graph so a failed/declined run can't poison a retry. - U2: clear a preserved graph in PrepareExpansionReplayToken so replay works. - Converge the finish path: Cleanup/EndSync/Close on a detached ctx, fatal teardown errors vs safe fallback, run-duration bound + ctx polling, dangling-ref skip-with-warn. - Strip transient graph state before the final checkpoint. - Tests: dangling-ref + sealed-artifact lifecycle + revocation parity + shallow-directness differentials. Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
d1ced91 to
a0d5774
Compare
a0d5774 to
b0b83ea
Compare
|
@manojacs - review by me, Fable, and a bunch of sub-agents. Since you started work on this, we landed a major refactor to the SDK that centralizes the declaration and handling of SDK invariants (referential & business consistency) for the new write paths that I'm working towards - ie, partial sync replays. Task: Rebase
|
|
Another random thought. We are accumulating fuzzers - it would be nice to have a edit: not to swamp you, but I have been working on a rubric to drive this sort of in depth AI review (it should be in main now, so you can run it yourself). I had it reevaluate the originate feedback, and it generated this revision: Addendum to the brief above (re-review against docs/BUG_CATCHING.md)We re-evaluated this branch against the current review rubric. The original New finding — unversioned sidecar envelope (same failing-test-first Strengthened requirements:
Cross-version cell table (lives in code, not here): the graph moved from Stage 0 refresh: main has moved since the brief was written — the Fallback visibility (small, new): incremental expansion declines to full |
Fixes from kans's review of the diff-aware incremental grant expansion: - B1: pass full entitlement records (not bare ids) to all grant reads so the incremental path stops erroring into a silent full-expansion fallback; compactor tests assert it actually ran. - B2: scope incremental to Pebble; degrade gracefully to full on SQLite. - C1: use isGrantDirectOnEntitlement for shallow filtering and IsDirect. - C2: merge sources into existing grants and record all contributing edges; parity tests compare full rows including sources maps. - Derive changed entitlements inside the compactor; drop the caller param. Compare edge specs (not just endpoints): widened re-expands, narrowed auto-declines via a named ErrIncrementalRevocationDecline hook. - U1: clone the base graph so a failed/declined run can't poison a retry. - U2: clear a preserved graph in PrepareExpansionReplayToken so replay works. - Converge the finish path: Cleanup/EndSync/Close on a detached ctx, fatal teardown errors vs safe fallback, run-duration bound + ctx polling, dangling-ref skip-with-warn. - Strip transient graph state before the final checkpoint. - Tests: dangling-ref + sealed-artifact lifecycle + revocation parity + shallow-directness differentials. Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
b0b83ea to
d6814d2
Compare
Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Make incremental compaction derive affected memberships and edge changes from merged artifact data, validate and clone the preserved entitlement graph, and safely decline cycles, revocations, unsupported stores, and dense affected closures to normal full expansion. Persist versioned graph sidecars only after sealing and bind them to the artifact's whole-file grant digest. Reuse now requires matching sync ID, graph structure, digest ABI, grant count, and grant hash; stale, inherited, unbound, or mismatched graphs fail closed. Keep the default full-expansion behavior unchanged unless callers explicitly opt in. Add stable outcome reasons, safe finalization ordering, fold-sidecar invalidation, and the shared ingestion-invariant path needed by compaction. Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Add full-vs-incremental differential oracles, an independent fixed-point access model, mutation-adequacy controls, multi-generation reuse, k-way parity, edge-filter transitions, graph/grant digest mismatch, compatibility healing, and SQLite fallback coverage. Exercise artifact durability with real subprocess kills and fault injection across graph persistence, seal, verification marker, close, and publication boundaries. Add bounded performance gates that keep sparse changes incremental and decline dense closures before grant writes. Wire compatibility, crash, fuzz, soak, and performance targets into the Makefile. The full repository suite, race checks, compatibility matrix, crash/retry suite, performance gate, and extended differential fuzz runs pass. Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
…ation to Pebble Incremental expansion now routes every candidate source grant through grantContributesOverEdge — the same predicate the full expander uses — so a malformed grant with a nil principal (or nil principal id) is skipped instead of collapsing onto a shared contribution key and aborting the attempt into a full-expansion fallback. WithPreserveEntitlementGraph is now applied only on Pebble outputs: other engines always decline incremental expansion and have no graph sidecar, so preserving the graph there is pure wasted work. Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
6803353 to
273f89f
Compare
| bench-smoke: ## Run the bounded checkpoint cost benchmarks once. | ||
| go test -run '^$$' -bench 'Benchmark(CheckpointToken|SpawnedCursorAdmission)' -benchtime=1x -benchmem ./pkg/sync | ||
|
|
||
| .PHONY: incremental-performance-check |
There was a problem hiding this comment.
🟡 Suggestion: incremental-performance-check and incremental-soak are not referenced by .github/workflows/nightly.yaml (which enumerates targets explicitly: compat-check, fuzz-smoke, differential-check, interrupt-check, …) or by any other target, so TestIncrementalPerformanceGates — which is additionally skipped unless BATON_INCREMENTAL_PERF=1 — never runs automatically. docs/BUG_CATCHING.md cost-contract guidance asks for a benchmark that enforces the cost curve on grant-expansion paths; an unwired gate does not. Adding both to the nightly suite matrix would close it. (confidence: high)
- loadIncrementalBaseGraph: guard the nil run LatestFinishedSyncOfAnyType returns for an artifact with no finished sync (both engines return nil, nil), declining to full expansion instead of panicking on run.ID. - recomputeDestination: merge a principal's contribution into every existing grant row sharing the principal key, matching the full expander's per-key group merge; previously the first row consumed the contribution and later duplicate rows kept stale sources. Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
| // Changed entitlements are derived from the applied increments (their | ||
| // grants' entitlement ids), not supplied by the caller — trust the data. | ||
| changedEntitlementIDs, err := c.changedEntitlementIDs(walkCtx) | ||
| if err != nil { | ||
| if endErr := c.restoreEndedSync(ctx); endErr != nil { |
There was a problem hiding this comment.
🟡 Suggestion: a destination entitlement that was dangling during the base expansion and materializes in an increment is never seeded, so incremental expansion silently omits grants full expansion would write.
loadEntitlementGraph (pkg/sync/syncer.go:2504) calls AddEntitlementID(dstEntitlementID) unconditionally — only the source is existence-checked — so an edge src -> dst lands in the base graph even when dst has no entitlement row. The base expansion then skips dst (dangling-ref skip-with-warn). If a later increment adds the dst entitlement record but no grants on dst:
PendingExpansionstill reports the samesrc -> dstedge,baseGraphEdgefinds itinBasewith an unchanged spec → no entry innewEdges;- the fold's changed set only collects entitlement ids from the grants bucket (
pkg/synccompactor/pebble/merge.go:1116), andderiveChangedEntitlementIDslikewise only reads grants, sodstis not inchangedEntitlementIDs.
The walk therefore has no seed for dst and writes nothing, while a full expansion would now populate it. Consider also seeding graph nodes whose entitlement record newly resolves in the store (or declining when a base edge's destination was dangling at base-expansion time). (confidence: medium — the logic gap is clear; reachability depends on connectors emitting GrantExpandable targets ahead of the entitlement itself.)
There was a problem hiding this comment.
Acknowledged — this one is real. The dangling-destination precondition is common (I8's own docs note expansion drops millions of edges per week to this class, and I8 is warn-only in default mode, so a base with dangling destinations still seals verified and passes the incremental reuse gate). The uncommon step is the destination actually materializing later, but the config-gap class (resource type enabled after initial setup) makes that plausible.
The fix needs some more thought — the candidate shapes pull in different directions:
- decline-on-dangling-destination: record in the sidecar which edge destinations had no entitlement record at base-expansion time, and decline to full when any exist. Always correct, but given the volume of permanent magic-id danglings, this could decline a large fraction of real compactions and gut the fast path.
- seed-on-materialization: have the fold/derive changed-set also collect entitlement ids that newly resolve (entitlements bucket, not just grants). Precise and keeps the fast path, but touches the merge plumbing and needs its own differential coverage for the dangling→materialized sequence.
Rather than rushing either into this PR, we'll follow up with the appropriate fix in a separate change.
Same nil-return class as loadIncrementalBaseGraph: an artifact with no finished sync returns (nil, nil), and the three graph-mode helpers then panicked on run.ID. Return a named error instead so a broken harness step stays diagnosable. Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
| store, err := dotc1z.NewStore(ctx, c.entries[0].FilePath, | ||
| dotc1z.WithReadOnly(true), dotc1z.WithTmpDir(c.tmpDir)) |
There was a problem hiding this comment.
🟡 Suggestion: in fold mode this is a second full extraction of the base c1z on every compaction. copyFileForFold already byte-copies entries[0] to destFilePath and doOneCompaction opens it as c.compactedC1z, so the base envelope has already been unpacked once — and compactPebbleFold deletes the sidecar from that copy at line 697 before expandGrants runs, so the graph has to be re-read from the original. Capturing the sidecar blob (and the base's verification run) inside compactPebbleFold before DeleteEntitlementGraphSidecar would avoid re-unpacking a whale base. Note also that this open omits WithDecoderOptions(WithDecoderConcurrency(-1)) and the shared c.decoderPool that deriveChangedEntitlementIDs and doOneCompaction pass, so the extra decode also runs with default decoder settings. (confidence: medium)
There was a problem hiding this comment.
Did some napkin math on this against observed artifact sizes from the last two weeks of production telemetry: typical artifacts are ~20 MB uncompressed (~1 MB compressed), and the largest observed is ~2.5 GB uncompressed (~330 MB compressed).
The redundant second open costs roughly: read the compressed file + single-threaded zstd decompress (~500 MB/s) + temp-dir write (~250–500 MB/s):
- typical (~22 MB uncompressed): ~0.1 s per compaction — noise
- largest observed (~2.5 GB): ~10–15 s per compaction, plus a transient ~2.5 GB temp-disk spike while both extractions exist
So the cost is real but only meaningful at the far right of the size distribution, and it scales with compaction frequency, not correctness. Leaving this as a follow-up rather than expanding this PR:
- cheap half: pass
WithDecoderConcurrency(-1)+ the sharedc.decoderPoolto this open (parallel decompress, near-zero risk) - full fix: capture the sidecar blob + base verification run inside
compactPebbleFoldbeforeDeleteEntitlementGraphSidecar, which touches the fold plumbing and deserves its own change
…Compact Same LatestFinishedSyncOfAnyType (nil, nil) class as the three sites guarded in graph_modes_new.go; this was the remaining unguarded dereference. Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
kans
left a comment
There was a problem hiding this comment.
This is safe to merge and is a large effort. Lets pick up the remainder in another PR before we turn it on:
Blocking enablement
-
Dangling-destination seeding gap (r3764614368)
Destination dangling in base graph, entitlement row added by later increment
with no grant changes -> never seeded -> grants silently missing vs full
expansion. Fix: seed nodes whose entitlement record newly resolves, or
decline when a base edge's destination was dangling at base-expansion time. -
Decline must not eat the run budget (no PR comment - add issue)
Base unpack + PendingExpansion classification + restore all run before the
leftover-duration check, on parent ctx not runCtx. A decline (incl. the
guaranteed first run after enable) can fail compaction with "unable to
finish" instead of running full expansion. Fix: cheap eligibility checks
first; charge incremental work against runCtx; leftover check before fallback. -
Redundant base re-unpack per fold (r3767877771)
Every opted-in fold extracts the whole base c1z a SECOND time just to read
the sidecar the fold copy already had (and deleted at compactor_pebble.go:697)- can make fold+incremental slower than fold+full on whale bases. Fix:
capture sidecar blob + verification run inside compactPebbleFold before
DeleteEntitlementGraphSidecar; pass shared decoder pool/options.
- can make fold+incremental slower than fold+full on whale bases. Fix:
-
ResumeSync instead of StartOrResumeSync (r3600362362) - one-liner
compactor.go:678: lookup error falls through to StartNewSync ->
ResetForNewSync wipes the merged dest. ResumeSync is already on the
interface and fails closed. -
Wire incremental-performance-check / incremental-soak into nightly CI
(r3764191460) - perf gates currently never run automatically.
Non-blocking
- Cleanup: WithPreserveEntitlementGraph SQLite token fallback is dead code
(never fires without WithEntitlementGraphInCheckpoints); fix docs or
delete the branch.
Expand only the subgraph affected by an incremental change instead of rebuilding and walking the whole entitlement graph. Seeds from both new edges and changed-membership entitlements, so a new member on an existing group propagates; new edges that close a cycle fall back to full expansion. Source reads stream and writes flush in chunks to bound memory. Additions only — callers use full expansion for change sets with revocations.