Skip to content

[EXPERIMENTAL] c1z sync replay#1002

Open
kans wants to merge 7 commits into
mainfrom
kans/c1z-sync-replay
Open

[EXPERIMENTAL] c1z sync replay#1002
kans wants to merge 7 commits into
mainfrom
kans/c1z-sync-replay

Conversation

@kans

@kans kans commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

POCs:
ConductorOne/baton-okta#182
ConductorOne/baton-github#177
https://github.com/ConductorOne/baton-microsoft-entra/pull/164

Source-cache replay: skip refetching data upstream says is unchanged (Pebble-only)

Connectors talking to providers with cheap change detection — HTTP conditional requests (GitHub), delta queries (Microsoft Graph) — can now tell the SDK to replay a scope's rows from the previous sync's c1z instead of re-fetching, re-parsing, and re-writing them. On a warm sync, unchanged pages cost one conditional request (a 304 is free against GitHub's rate limit) and a bulk row copy, instead of full fetch + parse + write.

Wire contract (annotation_source_cache.proto)

  • SourceCacheCapability{MODE_READ_WRITE} on the Validate response opts a connector in.
  • Per cacheable upstream request ("scope" — a page URL, a delta collection), the connector calls the SDK-provided lookup (SyncOpAttrs.SourceCache) for the previous sync's validator (etag / delta token), revalidates upstream, and either:
    • 304 / unchanged → SourceCacheReplay{scope_hash, etag} with an empty page: the SDK bulk-copies the previous sync's rows for that scope;
    • changed → fresh rows + SourceCacheScope{scope_hash, etag}: rows are stamped with the scope and the validator is persisted.
  • Delta shapes are fully supported: overlay: true replays the base then upserts changed rows on top; every page can carry tombstones (deleted_ids for canonical IDs, deleted_principal_ids for bare object IDs applied scope-relatively) so multi-page delta rounds never buffer; validators rotate per round.
  • Safety invariant: a connector may only replay a scope whose validator came from this sync's lookup — where "this sync" spans actions: a planning call may batch-resolve validators and hand verdicts to sibling cursors via SpawnCursors page tokens (blessed in the contract text; what's forbidden is validators that outlive a sync). Setup failures degrade to a no-op lookup: the connector never gets a hit and never replays. A replay annotation arriving anyway is a hard sync error.
  • Subprocess mode gets a dedicated BatonSourceCacheService on the existing control-plane listener (shared conn with the session service).

Lookup continuation (ask/answer): lookups over single-shot transports

Production runs connectors in environments that cannot call back to the syncer mid-request (gRPC-over-Lambda: one invoke, one response, no ingress to the worker). The lookup for those topologies is a continuation protocol on the existing tunnel — the response is the request:

  • The syncer attaches SourceCacheLookupOffer to list requests when it can answer (capability declared + warm previous sync). The connector's lookup defers: it records the scopes it needs and the RPC answers with SourceCacheLookupAsk instead of rows. The syncer resolves the queries against its local previous c1z (the same file replay copies from — lookup and replay cannot disagree) and re-invokes the same request with SourceCacheLookupAnswers. The connector code is unchanged across topologies: direct lookup in-process, loopback gRPC in subprocess, deferred ask on Lambda.
  • sourcecache.LookupMany is the topology-uniform batch API: a loop of point-reads on direct lookups, ONE ask for the whole batch when deferring. First-class planner shape: batch-resolve a page's scopes, embed verdicts in SpawnCursors tokens, spawned cursors never ask (Entra: ~5k scopes resolved in ~90 bounces instead of ~5k; Okta/GitHub equivalents tested).
  • Misuse fails loudly, in-process where possible: a swallowed ErrLookupDeferred (must propagate, %w-wrapping fine — matched via errors.Is) is caught by connectorbuilder before failure metrics; an ask carrying rows/tokens/scope/replay/SpawnCursors is a hard error; an ask without an offer is a hard error; a bounce cap (4 per REQUEST — page-token advances reset it, so multi-page planners asking once per page are fine) stops non-progressing connectors.
  • Version skew degrades to cold, never to misreads: connectors only ask when offered, old workers never offer, and the tunnel's annotation filtering strips the protocol types from peers that predate them.
  • Answer payloads are budgeted (2MiB per re-invoke, found-etags only; not-found answers always complete; dropped answers are absent/re-askable, never false misses). Known consumers use 3% of budget.
  • Observability: per-sync counters in a sync-complete log line — requests bounced, bounces per op kind, scopes asked, answers found/not-found, truncation re-asks, cap failures.

Design + prod topology analysis: docs/tasks/source-cache-lambda-lookup.md.

Storage (Pebble engine only; SQLite is untouched and silently degrades)

  • source_scope_hash on v3 Resource/Entitlement/Grant records with partial by_source_scope index families whose tails are identity tuples — replay derives every primary key from the index key and copies raw values with no proto unmarshal on the hot path.
  • A typeSourceCache keyspace holds one (row_kind, scope_hash) → etag manifest entry per scope (zero-row scopes included). Empty for every connector that doesn't opt in.
  • Fold compaction carries source-cache state with its rows (new bucket plans), so folded files remain valid replay sources; k-way-compacted files yield clean misses. Replay verifies each copied row's value-level stamp, so stale index entries can never inject wrong rows.
  • Scoped deletion machinery: DeleteGrantsByPrincipalsInScope / DeleteResourcesByIDsInScope (one index scan per tombstone-carrying page, zero value reads), DeleteGrantsByExternalIDsInScope, and a bounded canonical-ID delete that never falls back to the O(all-grants) scan.
  • Replay is idempotent by construction (pure upserts): re-executing a replayed page — e.g. a resumed sync whose checkpoint predates the page's commit — converges instead of duplicating (pinned by test).

Output stability (hard requirement)

A warm sync must reproduce what a cold sync would produce — same grants, same IDs. Replayed rows are verbatim copies; expander-written Sources (classified by self-source, mirroring RollbackExpansion) are stripped at copy time so expansion recomputes provenance from current state, while connector-set Sources survive byte-for-byte. Replay also re-arms grant expansion via needs_expansion reporting — a sync where every expandable page replays still expands (pinned by test).

SpawnCursors: connector-spawned sibling cursors (annotation_type_scoped_grants.proto)

A ListGrants response may enqueue additional independent cursors; each token becomes its own action — scheduled by the worker pool, rate-limited, and checkpointed like any other pagination. Honored on both call shapes:

  • Type-scoped planning: the first call computes shard assignments (e.g. one 50-id delta filter chunk per cursor) and spawns one cursor per shard, parallelizing cold enumeration.
  • Per-resource parallel warm revalidation (page-numbered APIs): on page one of a collection the connector already knows every other page's URL and stored validator, so it answers page one and spawns pages 2..N; the worker pool revalidates siblings concurrently instead of paying one round trip per page serially.

Spawned cursors are ORDINARY pages enqueued eagerly — a spawned page may replay, fetch cold (boundary shifted), chain via NextPageToken (how fresh tail pages get discovered), or itself spawn. Progress accounting counts a resource once, when its origin action's chain ends — actions carry a Spawned marker (checkpoint-compatible) so fan-out never trips the "more grant resources than resources" anomaly warning.

Type-scoped grants (annotation_type_scoped_grants.proto)

Grants-phase analogue of StaticEntitlements, for providers whose natural enumeration unit is a collection rather than a resource (e.g. one Graph delta stream covering 50 groups). A resource type annotated TypeScopedGrants is excluded from the per-resource ListGrants fan-out; its syncer implements GrantsForResourceType. Source-cache scopes/replay/tombstones work unchanged on these pages; scope-stamped and unstamped fresh pages coexist in one cursor's stream; scoped pages are authoritative upsert streams (per-sync resource dedupe bypassed — required for overlay correctness). Progress is count-only for these types.

Plumbing

  • --previous-sync-c1z flag for local/one-shot syncs; service mode reuses the existing spare-c1z path (WithKeepPreviousSyncC1Z). Both flags are hidden unless the connector declares replay capability (connectorrunner.DeclaresPreviousSyncCapability).
  • field.WithConnectorDefault: connectors can declare first-class defaults for shared SDK fields (e.g. a per-connector --workers default); explicit user values win.
  • SyncOpAttrs gains SourceCache (never nil — no-op when disabled); session store is likewise nil-safe.
  • Lookup RPC etag cap is 64KB (Graph delta tokens run long).

Testing

  • Engine: entry CRUD, cross-file replay for all three row kinds, replay idempotency (double-execution converges), stale-index defense, sources-strip both classifications, scoped deletes, bounded delete.
  • Syncer e2e (mock connectors): GitHub-shape 304 replay across a three-sync chain; Graph-shape delta with overlay + canonical and bare-principal tombstones + token rotation; all-pages-replayed sync still runs expansion; replay-while-degraded fails loudly; chunked type-scoped delta; mixed stamped/unstamped pages in one cursor.
  • Continuation protocol: builder-level (defer→ask conversion, errors.Is matching through wrapping, answers served to unchanged connector code, swallowed deferral caught in-process, direct lookup wins over offer) and syncer-level (warm replay via ask/answer across a chained multi-sync run, phase-2 SpawnCursors with verdict-carrying tokens and zero sibling asks, multi-page planner exceeding the per-request cap with per-page asks, cap/ask-with-rows/ask-with-spawn/ask-without-offer all fail loudly, TypeScopedGrants marker survives re-invokes), at workers 0 and 4.
  • Parallel sync + replay: every replay scenario runs at workers 0 and 4; per-resource spawn harness (fan-out, fresh-tail discovery, vanished-tail probes, once-per-resource progress); seeded churn soak holds warm-vs-control grant-set equivalence every round.
  • Crash/resume: a warm sync killed mid-fan-out resumes without dropping or duplicating (crashed sibling re-executes, siblings survive, file is grant-for-grant identical to cold control); pending spawned actions round-trip the sync token with markers intact.
  • Full make lint + make test green.

Real-provider validation (baton-github conditional requests, baton-entra delta, baton-okta timestamp validators) is in flight on connector branches; this PR is the SDK surface they build against. The production (Temporal + Lambda) rollout additionally needs a c1-side change to materialize the previous completed c1z into the sync activity — tracked in the design doc; no c1 protocol code or infra is required beyond the SDK bump.

@kans kans requested a review from a team July 9, 2026 20:12
@pulumi

pulumi Bot commented Jul 12, 2026

Copy link
Copy Markdown

⚠️ Pulumi could not deploy preview(s) for this pull request because GitHub reports it is not mergeable (mergeable state: dirty). This usually means the branch has merge conflicts with its base branch. Resolve the conflicts and push a new commit to retry.

@kans kans force-pushed the kans/c1z-sync-replay branch from 2c28a48 to ae0ccd4 Compare July 13, 2026 15:05
Comment on lines +696 to +718
if err := batch.Set(priKey, val, nil); err != nil {
closer.Close()
return err
}
closer.Close()
if err := batch.Set(iter.Key(), nil, nil); err != nil {
return err
}
res.Rows++
rowsInBatch++
if rowsInBatch >= replayBatchRows {
if err := batch.Commit(opts); err != nil {
return err
}
_ = batch.Close()
batch = e.db.NewBatch()
rowsInBatch = 0
}
}
if err := iter.Error(); err != nil {
return err
}
return batch.Commit(opts)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Bug: ReplaySourceCacheEntitlements mutates the entitlement primary keyspace (batch.Set(priKey, val, ...)) but never calls e.noteEntitlementKeyspaceWrite(). Every other entitlement writer bumps that generation counter (entitlements.go:98, :159, if_newer.go:219, cleanup.go:101, bulk_import.go:690, id_index_migration.go:104); lookup.go:74 documents "call after ANY mutation of the entitlement primary keyspace." Within a source-cache sync, once the lazy entIDLookup map has been built, replayed entitlements won't appear in it, so the deleted_ids tombstone path (DeleteEntitlementRecordresolveEntitlementIdentityByExternalID, plus grant candidate resolution via entitlementIdentitiesForExternalID) silently misses them and the delete no-ops — a row upstream reported deleted survives. Fix: call e.noteEntitlementKeyspaceWrite() after a successful replay (inside withWrite, after the final commit). (Confidence: medium.)

Comment thread pkg/cli/commands.go

if v.GetString(field.PreviousSyncC1ZField.GetName()) != "" {
previousSyncC1ZPath := v.GetString(field.PreviousSyncC1ZField.GetName())
_, err := os.Open(previousSyncC1ZPath)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: os.Open returns a *os.File that is assigned to _ and never closed, leaking a file descriptor on every invocation. Since this is only an existence check, use os.Stat instead (which also avoids opening the file). Confidence: high.

Suggested change
_, err := os.Open(previousSyncC1ZPath)
if _, err := os.Stat(previousSyncC1ZPath); err != nil {
return fmt.Errorf("the specified previous sync c1z file does not exist: %s", previousSyncC1ZPath)
}

Comment thread pkg/cli/commands.go
Comment on lines +476 to +479
_, err := os.Open(previousSyncC1ZPath)
if err != nil {
return fmt.Errorf("the specified previous sync c1z file does not exist: %s", previousSyncC1ZPath)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion (supersedes my prior single-line comment above): os.Open returns a *os.File assigned to _ and never closed, leaking a file descriptor. Since this is only an existence check, use os.Stat. Confidence: high.

Suggested change
_, err := os.Open(previousSyncC1ZPath)
if err != nil {
return fmt.Errorf("the specified previous sync c1z file does not exist: %s", previousSyncC1ZPath)
}
if _, err := os.Stat(previousSyncC1ZPath); err != nil {
return fmt.Errorf("the specified previous sync c1z file does not exist: %s", previousSyncC1ZPath)
}

//
// Complexity: O(scope size) tuple-walks per call regardless of tombstone
// count — callers batch a page's tombstones into one call.
func (e *Engine) DeleteGrantsByPrincipalsInScope(ctx context.Context, scopeHash string, principalIDs map[string]struct{}) (int64, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: unlike the ReplaySourceCache* functions (which cap batch growth at replayBatchRows with intermediate commits), the three scoped-delete functions (DeleteGrantsByPrincipalsInScope here, plus DeleteGrantsByExternalIDsInScope at line 207 and DeleteResourcesByIDsInScope at line 294) accumulate every matched deletion into a single unbounded pebble.Batch committed once at the end. A delta round tombstoning a large fraction of a big scope could grow the batch to a large in-memory size. Consider the same periodic-commit bound used by replay. Confidence: medium.

@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

General PR Review: [EXPERIMENTAL] c1z sync replay

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

Review Summary

Full PR diff scanned for security and correctness, with extra focus on the SDK compatibility surfaces called out in the repo-local criteria (proto wire fields, serialized pagination/replay state, exported interfaces, defaults, and go.mod). This is a large experimental change (~12k additions) adding Pebble-only source-cache replay. The proto additions are wire-safe: new fields use fresh numbers (ResourceRecord.source_scope_hash=9, EntitlementRecord=11, GrantRecord=10) with no renumbering/reuse, and generated pb/.../*.pb.go is regenerated to match. Exported API changes are additive: TypeScopedGrantsSyncer is a new opt-in interface reached via type assertion (not a method added to ResourceSyncer/ConnectorBuilder), SyncOpAttrs gains a field with only named-field construction, and new options/flags default to prior behavior (replay flags hidden and gated on the connector capability). No blocking security or correctness issues found; four defense-in-depth / hygiene suggestions below.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/sync/syncer.go:2148-2160SpawnCursors.page_tokens fan-out has no per-response or per-sync/depth cap (contrast SourceCacheLookupAsk.queries max_items: 4096); a buggy connector could push unbounded actions onto the checkpointed state.
  • pkg/sync/source_cache.go:244-245 — tombstone merge appends into the proto getter's slice, which can mutate the source proto in place if the backing array has spare capacity; copy explicitly.
  • pkg/sourcecache/continuation.go:222-232AnswersFromProto skips the row-kind/scope-hash validation and size bounds that QueriesFromProto applies to the symmetric path.
  • pkg/cli/commands.go:476 — existence check uses os.Open and discards/never closes the handle (leaks one FD; same shape as the pre-existing line 467); use os.Stat.
Prompt for AI agents

```
Verify each finding against the current code and only fix it if needed.

Suggestions

In `pkg/sync/syncer.go`:

  • Around line 2148-2160: The loop over spawn.GetPageTokens() enqueues one Action per token with no upper bound, and each spawned cursor's response can spawn again with no depth limit, all persisted in the checkpointed state token. Add a cap on the number of page tokens accepted per response and/or a per-sync total-spawned-cursor ceiling, erroring loudly when exceeded (mirroring the source-cache lookup bounce cap). Consider also adding a max_items validate rule to SpawnCursors.page_tokens in proto/c1/connector/v2/annotation_type_scoped_grants.proto and regenerating.

In `pkg/sync/source_cache.go`:

  • Around line 244-245: page.deletedIDs/page.deletedPrincipalIDs are built via append(replay.GetDeletedIds(), scope.GetDeletedIds()...), which can mutate the replay proto's backing slice in place when it has spare capacity. Build a fresh slice instead, e.g. append(append([]string{}, replay.GetDeletedIds()...), scope.GetDeletedIds()...), for both fields.

In `pkg/sourcecache/continuation.go`:

  • Around line 222-232: AnswersFromProto trusts RowKind, ScopeHash, and ETag without validation, unlike QueriesFromProto (lines 192-204). Validate the row kind and scope hash per answer and enforce a max answer count and max etag length, returning an error on violation.

In `pkg/cli/commands.go`:

  • Around line 476: The previous-sync-c1z existence check calls os.Open and discards the *os.File, leaking an FD. Replace with os.Stat(previousSyncC1ZPath) since only existence is being checked (the external-resource-c1z check at line 467 has the same issue and can be fixed the same way).
    ```

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found. 2 non-blocking suggestions posted inline; see the summary comment for details.

@kans kans force-pushed the kans/c1z-sync-replay branch from 5fd87b4 to b00db4e Compare July 13, 2026 17:53
Comment thread pkg/sync/syncer.go
Comment on lines +2148 to +2160
if hasSpawn {
for _, tok := range spawn.GetPageTokens() {
if tok == "" {
continue
}
spawned = append(spawned, Action{
Op: SyncGrantsOp,
ResourceTypeID: action.ResourceTypeID,
ResourceID: action.ResourceID, // empty on type-scoped actions
PageToken: tok,
Spawned: true,
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: spawn.GetPageTokens() is iterated with no cap on count, and each spawned cursor's own response can spawn again with no depth ceiling, so a buggy connector could push an unbounded number of actions onto the (checkpointed) state stack. Note SourceCacheLookupAsk.queries is bounded with max_items: 4096 in the proto, but SpawnCursors.page_tokens has no validate.rules bound — consider a per-response cap and/or a per-sync total-spawned ceiling that errors loudly, mirroring the bounce cap. (confidence: medium)

Comment thread pkg/sync/source_cache.go
Comment on lines +244 to +245
page.deletedIDs = append(replay.GetDeletedIds(), scope.GetDeletedIds()...)
page.deletedPrincipalIDs = append(replay.GetDeletedPrincipalIds(), scope.GetDeletedPrincipalIds()...)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: append(replay.GetDeletedIds(), scope.GetDeletedIds()...) appends into the slice returned by the proto getter; if that backing array has spare capacity the append mutates the proto message in place (and page.deletedIDs aliases it). Freshly-unmarshaled repeated fields usually have len == cap so this rarely bites, but it's safer to copy explicitly, e.g. append(append([]string{}, replay.GetDeletedIds()...), scope.GetDeletedIds()...). (confidence: low)

Comment on lines +222 to +232
func AnswersFromProto(msg *v2.SourceCacheLookupAnswers) []Answer {
out := make([]Answer, 0, len(msg.GetAnswers()))
for _, a := range msg.GetAnswers() {
out = append(out, Answer{
Query: Query{RowKind: RowKind(a.GetRowKind()), ScopeHash: a.GetScopeHash()},
Found: a.GetFound(),
ETag: a.GetEtag(),
})
}
return out
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: AnswersFromProto trusts RowKind, ScopeHash, and an unbounded ETag without validation, unlike the sibling QueriesFromProto (lines 192-204) which validates row kind and scope hash. These parent-supplied answers are the point where lookup verdicts enter the connector; validating row kind / scope hash and bounding answer count + etag length here would add defense-in-depth against a malformed or oversized answer set. (confidence: medium)

Comment thread pkg/cli/commands.go

if v.GetString(field.PreviousSyncC1ZField.GetName()) != "" {
previousSyncC1ZPath := v.GetString(field.PreviousSyncC1ZField.GetName())
_, err := os.Open(previousSyncC1ZPath)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: this existence check uses os.Open but discards the returned *os.File and never closes it, leaking one FD for the process lifetime (the pre-existing external-resource-c1z check at line 467 has the same shape). Since only existence is being checked, prefer os.Stat(previousSyncC1ZPath). (confidence: high)

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant