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.
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
| FinishAction(ctx context.Context, action *Action) | ||
| NextPage(ctx context.Context, actionID string, pageToken string) error | ||
| EntitlementGraph(ctx context.Context) *expand.EntitlementGraph | ||
| PeekEntitlementGraph() *expand.EntitlementGraph |
There was a problem hiding this comment.
🟡 Suggestion: These two additions (PeekEntitlementGraph, ClearEntitlementGraphTransientState) expand the exported State interface. Any external type that implements sync.State would fail to compile until it defines them. In practice only the internal *state implements this, so the risk is likely nil — but per the SDK compatibility criteria, adding methods to an exported interface is a breaking change. If State is intended to be internal-only, consider documenting that. Low confidence.
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
…he token A preserved graph costs ~170-190 bytes/node in the sync token (~10MB at 50k entitlements, measured in TestGraphBlobSizeAtScale), and tokens travel through workflow state. Store it as a Pebble engine-meta sidecar (same single-key shape as the stats sidecar) instead: - WithPreserveEntitlementGraph writes the sidecar when the store supports it and keeps the token skinny; SQLite (or a failed sidecar write) keeps the graph in the token as before. - sync.GraphFromStore(ctx, store, syncID) loads it; a sync-id guard in the blob rejects a stale fold-inherited sidecar. GraphFromToken remains for legacy artifacts. - The compactor writes the post-expansion graph into the compacted artifact (updated clone on incremental success; fresh graph via preserve on the decline->full path when opted in) so the artifact self-carries its base graph for the next round. Without the opt-in, any inherited sidecar is deleted. - StartNewSync wipes the sidecar with the rest of the keyspace, so a replacement sync never inherits a prior sync's graph. Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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
| _ = store.Close(ctx) | ||
| return nil, fmt.Errorf("incremental expansion: load base verification: %w", runErr) | ||
| } | ||
| if run.ID != c.entries[0].SyncID || |
There was a problem hiding this comment.
🟠 Bug: LatestFinishedSyncOfAnyType returns (nil, nil) when the artifact has no finished sync — both engines do this explicitly (adapter_sync_meta.go:128-130 returns nil, nil for best == nil; sync_runs.go:359-361 maps sql.ErrNoRows to nil, nil). run.ID here then panics instead of returning the error that would make the caller fall back to full expansion. A base c1z whose sync was never ended (interrupted collection) crashes the compaction rather than degrading. Add a run == nil branch alongside the verification check (and close the store on that path). (confidence: high on the nil return, medium on how often such an input reaches here)
| if pc == nil { | ||
| return nil | ||
| } | ||
| delete(contrib, key) |
There was a problem hiding this comment.
🟡 Suggestion: this consumes the contribution on the first destination grant matching the principal key, so any further grant rows for the same principal on this entitlement keep stale sources. The full expander deliberately handles the N-rows-per-key shape — principalGrantGroup collects every grant with the same key and mergeContributionGroupStreams (topological_merge_streaming.go:411-418) merges into each baseGrant. Since the Pebble primary key is the grant external id (there is a separate ent+principal hash index), a connector-supplied grant with a custom id can coexist with the expander-synthesized one for the same (principal, entitlement). If that shape is genuinely impossible, a comment stating the invariant would be worth more than the divergence; otherwise merge into every matching row and only drop the entry once the destination stream is exhausted. (confidence: medium)
| 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>
| PeekEntitlementGraph() *expand.EntitlementGraph | ||
| ClearEntitlementGraph(ctx context.Context) | ||
| ClearEntitlementGraphTransientState(ctx context.Context) |
There was a problem hiding this comment.
🟡 Suggestion: State is an exported interface, and adding PeekEntitlementGraph/ClearEntitlementGraphTransientState breaks any downstream type that implements it. The repo already has a stated convention against this — see the comment on pushAction (state.go:932-934): "The scheduler uses that copy to admit spawned work without changing the exported State interface." Both new methods are only called from *syncer on the concrete *state, so they could be reached through a small unexported interface assertion (or a *state type assertion) instead of widening State. (confidence: medium — no exported injection point for a caller-supplied State exists today, so the break is compile-time-only for external implementers.)
| // 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.
| run, err := store.SyncMeta().LatestFinishedSyncOfAnyType(ctx) | ||
| if err != nil { | ||
| _ = store.Close(ctx) | ||
| return result, err | ||
| } | ||
| if graphStore, ok := store.(sdksync.EntitlementGraphStore); ok { | ||
| data, graphErr := graphStore.GetEntitlementGraphBlob(ctx) | ||
| if graphErr != nil { | ||
| _ = store.Close(ctx) | ||
| return result, graphErr | ||
| } | ||
| result.GraphPresent = len(data) > 0 | ||
| } | ||
| graph, err := sdksync.GraphFromStore(ctx, store, run.ID) |
There was a problem hiding this comment.
🟡 Suggestion: run.ID here (and at lines 128/150 and 211/236) dereferences the result of LatestFinishedSyncOfAnyType, which returns (nil, nil) on both engines when the artifact holds no finished sync. This is the same nil-return class already fixed in compactor.go:loadIncrementalBaseGraph ("Both engines return (nil, nil) ... decline to full expansion, don't panic"). Inspecting an artifact whose collection was interrupted panics the harness instead of reporting graph_reusable: false. A if run == nil { return result, fmt.Errorf(...) } guard on each of the three sites keeps the failure diagnosable. (confidence: high on the nil return, low on reachability — this file is behind the compatharness build tag and only runs under BATON_GRAPH_COMPAT.)
There was a problem hiding this comment.
Addressed in 8d52ffc — all three sites now check for a nil run after the error check and return a named error ("candidate artifact has no finished sync" / "inspected artifact has no finished sync" / "compaction input has no finished sync") instead of panicking, matching the guard added to loadIncrementalBaseGraph.
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>
| var before runtime.MemStats | ||
| runtime.ReadMemStats(&before) | ||
| compactor, cleanup, err := synccompactor.NewCompactor(ctx, filepath.Dir(outPath), | ||
| []*synccompactor.CompactableSync{{FilePath: inputPath, SyncID: run.ID}, empty}, |
There was a problem hiding this comment.
🟡 Suggestion: run comes from LatestFinishedSyncOfAnyType at line 561, which returns (nil, nil) when the artifact holds no finished sync — run.ID here panics. This is the same class the latest commit just guarded in graph_modes_new.go (lines 133, 185, 226); graphCompatFullCompact is the remaining unguarded site. Add if run == nil { return compatResult{}, fmt.Errorf("input artifact has no finished sync") } after the error check. Harness-only (compatharness build tag). (confidence: high on the nil return, low on reachability)
| 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>
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.