Skip to content

fix(sync): stop Electric's stale-handle warning becoming permanent - #752

Merged
Makisuo merged 1 commit into
mainfrom
fix/electric-stale-shape-handle
Sep 3, 2026
Merged

fix(sync): stop Electric's stale-handle warning becoming permanent#752
Makisuo merged 1 commit into
mainfrom
fix/electric-stale-shape-handle

Conversation

@Makisuo

@Makisuo Makisuo commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

What

Two changes that stop [Electric] Received stale cached response with expired shape handle from firing on most page loads.

  1. apps/electric-sync/src/routes/headers.ts — cap max-age, s-maxage, stale-while-revalidate and stale-if-error at 60s and strip immutable, on top of the existing publicprivate downgrade.
  2. apps/web/src/lib/collections/shape-fetch.ts — send org=<id> on the shape URL.

Why

The CDN the warning names is not misconfigured

Worth stating up front, because the warning sends you looking in the wrong place. Nothing shared sits in front of the electric-sync worker (electric-sync.maple.dev is not a proxied record), and while electric.maple.dev is Cloudflare-proxied at the ALB, it answers cf-cache-status: DYNAMIC — no Cache Everything rule on /v1/*. buildUpstreamSyncUrl already forwards handle, offset, expired_handle and cache-buster, so that key was complete anyway.

The cache doing the replay is the browser's own, and what made it permanent is a lifetime mismatch on our side.

The lifetime mismatch

Electric marks a completed log chunk immutable and cacheable for a week, paired with a month of stale-while-revalidate. Sound on its own terms — a chunk is addressed by handle + offset and its bytes never change. But a handle lives only as long as the shape storage that minted it, and ours is task-local storage on a singleton ECS task replaced whole on every deploy (apps/electric/alchemy.run.ts). We passed those headers straight through, so browsers held chunks for days under handles Electric forgets in minutes.

The cost is not a stale read — the client notices — it is a permanent one:

  1. On a 409 the client writes the dead handle into localStorage (electric_expired_shapes, LRU-capped at 250, no TTL) and sends it as expired_handle on every later request for that shape.
  2. A disk-cached chunk still tagged with that handle trips the stale-cache detector on every page load.
  3. The cache-buster retry that recovers clears neither side, so it recurs indefinitely.

Capping max-age alone would have moved the bug rather than fixed it: a browser honouring the month of stale-while-revalidate keeps serving the stale chunk for that month. Hence all four directives plus immutable, which is the one directive a capped max-age cannot undo.

The org-blind URL (independent, latent)

The client-facing URL carried no org, and the ShapeStream derives its internal shape key from that URL minus the cursor params. That key indexes the localStorage expired-handle map — so one entry per shape name was shared by every org a user visits, and one org's dead handle rode along on another's requests. The browser HTTP cache and any intermediary keyed on it too, with Vary: Authorization the only thing holding tenants apart.

Reviewer notes

  • org= is not a trust surface. decodeSyncRequest reads only shape/scope, and only Electric's own cursor params reach upstream, so the org still comes solely from the bearer. Because the param is client-authored and unverified it is a cache-key hint, not a credential — which is exactly why Vary: Authorization and the private downgrade both stay. The headers.ts doc comment previously said the URL "carries NO org"; it now spells out this distinction instead.
  • Existing damage is sticky. Users who already tripped this keep a no-TTL localStorage entry plus a disk-cached chunk. The cap stops new ones forming and old chunks age out within 7 days; a hard reload clears it immediately.
  • This does not stop the 409 storms, only their persistence on the client. Every apps/electric deploy still invalidates every handle, because the task is a desiredCount: 1 singleton on storage that dies with it and deploys run minimumHealthyPercent: 0 / maximumPercent: 100. Giving it durable storage is the EFS-vs-EBS call already written up in that file's comments — deliberately left for its own PR.
  • Branched from main rather than fix/pods-live-lifecycle, which was already merged and byte-identical to main.

Testing

  • apps/electric-sync: 79 tests pass, including three new ones covering Electric's completed-chunk header verbatim, a value already under the cap, and immutable removal.
  • bun typecheck: 41/41.
  • bun run lint: clean.
  • apps/web: 2365 pass. One pre-existing failure, guided-setup.test.tsx:57 asserting isClerkAuthEnabled === false — it reads Clerk keys from a local .env.local, unrelated to this change.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

The web console logged `[Electric] Received stale cached response with
expired shape handle` on most page loads. The CDN the warning names is not
misconfigured: nothing shared sits in front of the electric-sync worker, and
`electric.maple.dev` (Cloudflare-proxied at the ALB) answers
`cf-cache-status: DYNAMIC`, so the worker to Electric hop is not cached
either. The cache doing the replay is the browser's own, and what made it
permanent is a lifetime mismatch we introduced.

Electric marks a completed log chunk immutable and cacheable for a week,
paired with a month of stale-while-revalidate. That is sound on its own
terms -- a chunk is addressed by handle + offset and its bytes never change
-- but a handle lives only as long as the shape storage that minted it, and
ours is task-local storage on a singleton ECS task replaced whole on every
deploy. We passed those headers through with nothing but a public to private
downgrade, so browsers held chunks for days under handles Electric forgets
in minutes.

The cost is not a stale read, it is a permanent one. On a 409 the client
writes the dead handle into localStorage (`electric_expired_shapes`,
LRU-capped at 250, no TTL) and sends it as `expired_handle` on every later
request for that shape. A disk-cached chunk still tagged with that handle
trips the stale-cache detector on every page load, and the cache-buster
retry that recovers clears neither side.

So cap max-age, s-maxage, stale-while-revalidate and stale-if-error at 60s
and drop `immutable`. Capping max-age alone would have moved the bug rather
than fixed it: a browser honouring the month of stale-while-revalidate keeps
serving the stale chunk for that month.

Second, independent half: the client-facing URL carried no org, and the
ShapeStream derives its internal shape key from that URL minus the cursor
params. That key indexes the localStorage expired-handle map, so a single
entry per shape name was shared by every org a user visits and one org's
dead handle rode along on another's requests. The browser HTTP cache and any
intermediary keyed on it too, with `Vary: Authorization` the only thing
holding tenants apart. Send `org=<id>` so the URL differs per tenant. The
proxy reads nothing from it -- `decodeSyncRequest` looks only at
shape/scope, and only Electric's cursor params reach upstream -- so the org
still comes solely from the bearer and nothing a client can reach widens.
Because that param is client-authored and unverified, it is a cache-key
hint and not a credential, which is why Vary and private both stay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Makisuo
Makisuo merged commit 346439f into main Sep 3, 2026
33 checks passed
@Makisuo
Makisuo deleted the fix/electric-stale-shape-handle branch September 3, 2026 09:26
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

🍁 Maple PR preview

Warning

Preview cleanup could not be confirmed. The Alchemy teardown outcome was skipped.

Final commit 41e4217 · View workflow run

@Makisuo
Makisuo restored the fix/electric-stale-shape-handle branch September 3, 2026 09:32
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