fix(sync): stop Electric's stale-handle warning becoming permanent - #752
Merged
Conversation
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>
🍁 Maple PR previewWarning Preview cleanup could not be confirmed. The Alchemy teardown outcome was Final commit |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Two changes that stop
[Electric] Received stale cached response with expired shape handlefrom firing on most page loads.apps/electric-sync/src/routes/headers.ts— capmax-age,s-maxage,stale-while-revalidateandstale-if-errorat 60s and stripimmutable, on top of the existingpublic→privatedowngrade.apps/web/src/lib/collections/shape-fetch.ts— sendorg=<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.devis not a proxied record), and whileelectric.maple.devis Cloudflare-proxied at the ALB, it answerscf-cache-status: DYNAMIC— no Cache Everything rule on/v1/*.buildUpstreamSyncUrlalready forwardshandle,offset,expired_handleandcache-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 byhandle+offsetand 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:
localStorage(electric_expired_shapes, LRU-capped at 250, no TTL) and sends it asexpired_handleon every later request for that shape.Capping
max-agealone would have moved the bug rather than fixed it: a browser honouring the month ofstale-while-revalidatekeeps serving the stale chunk for that month. Hence all four directives plusimmutable, which is the one directive a cappedmax-agecannot 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
localStorageexpired-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, withVary: Authorizationthe only thing holding tenants apart.Reviewer notes
org=is not a trust surface.decodeSyncRequestreads onlyshape/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 whyVary: Authorizationand theprivatedowngrade both stay. Theheaders.tsdoc comment previously said the URL "carries NO org"; it now spells out this distinction instead.localStorageentry 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.apps/electricdeploy still invalidates every handle, because the task is adesiredCount: 1singleton on storage that dies with it and deploys runminimumHealthyPercent: 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.mainrather thanfix/pods-live-lifecycle, which was already merged and byte-identical tomain.Testing
apps/electric-sync: 79 tests pass, including three new ones covering Electric's completed-chunk header verbatim, a value already under the cap, andimmutableremoval.bun typecheck: 41/41.bun run lint: clean.apps/web: 2365 pass. One pre-existing failure,guided-setup.test.tsx:57assertingisClerkAuthEnabled === false— it reads Clerk keys from a local.env.local, unrelated to this change.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.