Skip to content

sync: omit the entitlement graph from checkpoint tokens#1019

Open
arreyder wants to merge 2 commits into
mainfrom
chrhodes/omit-entitlement-graph-from-checkpoints
Open

sync: omit the entitlement graph from checkpoint tokens#1019
arreyder wants to merge 2 commits into
mainfrom
chrhodes/omit-entitlement-graph-from-checkpoints

Conversation

@arreyder

@arreyder arreyder commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Problem

state.Marshal serializes the entire entitlement graph into every checkpoint token (every ≥10s during a sync, including once per page while the graph is being loaded). For large tenants this is what has been OOM-killing be-temporal-sync in prod — twice in the last day, first at a 24 G limit (2026-07-17 23:06Z) and again at 30 G after the limit was raised (2026-07-18 02:04Z, image 121645).

The last pre-OOM heap profile (22s before the second kill) shows the marshal path owning roughly 11 GB of a 13.5 GB heap for a live graph of only ~0.9 GB:

state.Marshal                     6.4 GB cum (47%)
 ├ json mapEncoder.encode         5.8 GB   (the graph's maps; key-sorting churn)
 └ bytes.growSlice                5.3 GB   (the output buffer + doubling history)
snappy.Encode                     2.0 GB   (the blob compressed into the store)
pebble manual.New / rawalloc      3.5 GB   (the blob as a pebble large batch →
                                            its own flushable memtable, bypassing
                                            MemTableSize entirely)
EntitlementGraph.AddEdge          0.9 GB   (the actual live graph)

Every copy is O(graph) and several are live simultaneously, so one sufficiently large tenant checkpointing is enough to kill a worker — and since the OOM lands mid-checkpoint, the sync crash-loops: the checkpoint meant to preserve progress is the thing preventing completion.

Change

Checkpoints no longer carry the graph. It is a projection of data already in the store — loadEntitlementGraph rebuilds it from PendingExpansionPage with no connector calls, and PrepareExpansionReplayToken already relies on whole expansions being re-runnable from the store on finished syncs. This extends that same replay property to interim checkpoints: a crash mid-expansion re-runs the load + expansion phases on resume instead of resuming them from the token.

Checkpoint memory becomes O(actions + stats) — effectively KBs — independent of tenant size.

Resume-safety detail

A resumed reader gets no graph, so an in-flight SyncGrantExpansionOp action's PageToken is blanked in the serialized copy (live state is untouched): resuming that pagination against a fresh graph would silently drop the edges from earlier pages. Doing this at marshal time keeps graph-less tokens safe for older readers too (version rollback mid-sync ⇒ the old SDK sees a fresh graph + empty page token ⇒ clean full reload, not silent corruption). Unmarshal additionally normalizes orphan page tokens defensively.

Note the load phase leaves the final page token on the action even after Loaded=true, so the blanking applies uniformly across phases.

Compatibility

writer → reader behavior
old token (graph inline) → new SDK decodes + resumes exactly as before (field kept for decode; covered by test)
new token → new SDK fresh graph, load restarts from page one
new token → old SDK (rollback) same clean restart — this is why blanking happens at marshal time

Nothing outside pkg/sync consumes the token's graph: ClearEntitlementGraph already stripped it from every finished sync's token, and c1 passes SyncToken through opaquely.

Trade-off

Crash/restart mid-expansion now redoes the graph load + expansion (store-local reads and idempotent StoreExpandedGrants upserts, no connector traffic) — minutes of local recompute in the rare-restart case, versus a guaranteed OOM-loop for large tenants today. Small tenants redo little; large tenants are exactly the ones that couldn't get through a checkpoint at all.

Stated plainly: cross-restart expansion progress is now zero. A tenant whose entire expansion exceeds one worker/activity lifetime would livelock on repeated restarts, where before it could inch forward page by page (though for exactly those tenants the checkpoint itself was what OOM'd the worker, so the old "progress" was moot in practice). If that livelock is ever observed, the follow-up is a durable expansion progress marker persisted in the store — not re-inflating the token.

Testing

  • TestSyncerTokenOmitsEntitlementGraph — token carries neither graph nor in-flight expansion pagination; live state unmutated; resumed state restarts the load; non-expansion actions keep their pagination and no map entries are dropped
  • TestSyncerTokenLegacyInlineGraphStillDecodes — old-style tokens restore the graph and keep their pagination; the next checkpoint after a legacy resume writes the new format (graph omitted, expansion pagination blanked) while the live state is unaffected
  • TestSyncerTokenUnmarshalBlanksOrphanExpansionPageToken — defensive normalization (mixed action map)
  • TestSyncerTokenV0OrphanExpansionPageTokenBlanked — the defensive blanking also covers tokens decoded through the V0 fallback
  • Full ./pkg/sync/... suite green (incl. expand's topological-merge interrupt/resume tests, pebble fastpath, sqlite parity); token tests pass with -race

A syncer-level "interrupt exactly mid-expansion, resume, assert grant parity" e2e would need a new test hook (the existing interruption harness is connector-failure-based, and expansion makes no connector calls) — happy to add one as a follow-up if you want the hook.

🤖 Generated with Claude Code

@arreyder
arreyder requested a review from a team July 18, 2026 02:48
@github-actions

github-actions Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

General PR Review: sync: omit the entitlement graph from checkpoint tokens

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

Review Summary

Scanned the full PR diff (only pkg/sync/state.go and pkg/sync/state_test.go changed; no go.mod/go.sum, proto, or generated files) for security and correctness. The change stops serializing the entitlement graph and blanks any in-flight SyncGrantExpansionOp page token in the serialized copy, relying on the store-backed replay property that PrepareExpansionReplayToken already depends on. Marshal copies the actions map only when blanking is needed and never mutates live state; Unmarshal defensively blanks orphan expansion page tokens on graph-less V1 and V0 tokens while preserving non-expansion pagination and legacy inline-graph resume. Tests cover new-format omission, legacy inline-graph decode, and both V1/V0 orphan-token normalization. No new issues found; the earlier low-confidence note about signaling this serialized-state change via pkg/sdk/version.go (still v0.19.0) or a migration note remains applicable but was already raised in the prior review.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

Chris Rhodes and others added 2 commits July 21, 2026 11:35
state.Marshal serialized the entire entitlement graph into every
checkpoint. For large tenants that JSON encoding multiplied the
checkpoint's memory footprint several times over — the json map
encoding, the string copy, and the store's record/compression/batch
copies are each O(graph) and live at once. In production this
OOM-killed sync workers mid-checkpoint (be-temporal-sync, twice:
2026-07-17 at a 24G limit and 2026-07-18 at 30G; the final pre-OOM
heap profile shows state.Marshal at 6.4GB cum of a 13.5GB heap, with
a live graph of only ~0.9GB).

The graph is a projection of data already in the store:
loadEntitlementGraph rebuilds it from PendingExpansionPage with no
connector calls, and PrepareExpansionReplayToken already relies on
whole expansions being re-runnable from the store. So checkpoints now
omit it, and a crash mid-expansion re-runs the load and expansion
phases on resume instead of resuming them from the token.

Because a resumed reader gets no graph, an in-flight
SyncGrantExpansionOp action's PageToken is blanked in the serialized
copy — resuming that pagination against a fresh graph would silently
drop the edges from earlier pages. Blanking at marshal time keeps
graph-less tokens safe for older readers too (rollback mid-sync);
Unmarshal additionally normalizes orphan page tokens from writers
that didn't. Tokens written by older SDKs still carry the graph
inline and decode + resume exactly as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-ups: exercise both blanking paths with a mixed action map
(non-expansion pagination must survive, no entries dropped), pin the
upgrade chain (legacy inline-graph token in -> next checkpoint out must
be graph-less with the expansion page token blanked), and cover orphan
blanking through the V0 fallback decode path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@arreyder
arreyder force-pushed the chrhodes/omit-entitlement-graph-from-checkpoints branch from 994cdaf to 4afa50c Compare July 21, 2026 16:40

@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