Skip to content

Fix stale route-cache fills with object cache - #3488

Merged
ascorbic merged 3 commits into
mainfrom
codex/object-cache-generation-fence
Sep 26, 2026
Merged

ascorbic merged 3 commits into
mainfrom
codex/object-cache-generation-fence

Conversation

@ascorbic

Copy link
Copy Markdown
Member

What does this PR do?

Fixes the cache-layer freshness gap reported in #3257 and reduces the redundant epoch reads reported in #3258.

When Astro route caching and the EmDash object cache are both configured, any anonymous render that reaches the Worker is treated as a potential route-cache fill. Object-cache reads are bypassed for that render, so the publish path is:

  1. Publishing invalidates the object-cache epochs and globally purges the affected route-cache tags.
  2. The next plain-URL request is a route-cache MISS and reaches the Worker.
  3. Content loaders read the database rather than a potentially stale KV snapshot.
  4. The fresh response fills the route cache, and subsequent edge HITs avoid the Worker and database.

Current content readers also stop fetching the legacy content:<collection> epoch, reducing a typical content query's epoch reads from four to three. Writers continue to bump both the versioned and legacy epochs so older readers remain safe during rolling deployments. This deliberately does not combine all epochs into one KV document: KV has no compare-and-swap update for that document, concurrent updates can overwrite one another, and same-key writes are limited.

Query-count snapshots are unchanged: this adds no query shape or round trip. A route-cache fill that would previously have hit the object cache now runs its existing database loader queries. That is a conservative increase in database work on cold, expired, or purged fills in exchange for preventing stale HTML from being stored as fresh. Edge hits still execute neither the Worker nor the database. In a cache-enabled app, an anonymous route that reaches the Worker but is not ultimately stored by the route cache is conservatively fenced too.

Closes #3257

Addresses #3258

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (not applicable: no admin UI strings changed). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added and reviewed the user-facing changeset (if this PR changes a published package)
  • New features link to an approved Discussion: not applicable; this is a bug fix.
  • I have included screenshots below if this PR changes the UI: not applicable; there is no UI change.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Codex (gpt-5.6-sol)

Screenshots / test output

Not applicable; this PR does not change the UI.

Validation completed:

  • pnpm format
  • pnpm build
  • pnpm typecheck
  • pnpm lint:quick
  • pnpm --silent lint:json | jq '.diagnostics | length' returned 0
  • pnpm --dir docs build
  • Focused object-cache, content-cache, route-cache, integration, and adapter suites: 8 files and 80 tests passed
  • Final direct regression rerun: 3 files and 64 tests passed
  • SQLite query-count harness matched both committed snapshots
  • Fresh-isolate D1 query-count harness matched both committed snapshots
  • git diff --check

@changeset-bot

changeset-bot Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cca7a75

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/plugin-test Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
create-emdash Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
docs f4dc91a Sep 26 2026, 03:59 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://codex-object-cache-generation-fence.try.emdashcms.com, https://codex-object-cache-generation-fence-emdash-playground.emdash-cms.workers.dev (commit cca7a75)

This URL reflects your latest Preview deployment

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://4056face.try.emdashcms.com, https://4056face-emdash-playground.emdash-cms.workers.dev cca7a75 2026-09-26T18:25:08.287Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://aac429dc.try.emdashcms.com, https://aac429dc-emdash-playground.emdash-cms.workers.dev f4dc91a 2026-09-26T15:59:50.123Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://ded3674b.try.emdashcms.com, https://ded3674b-emdash-playground.emdash-cms.workers.dev 826ff2c 2026-09-26T13:34:06.370Z Visit the dashboard ↗

@emdashbot emdashbot 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.

The approach is sound and directly addresses the stale route-cache fill problem. Treating any anonymous Worker render with route caching enabled as a potential fill, setting routeCacheFill in request context, and having shouldBypass() load from the database prevents a purged page from being rebuilt from a stale KV snapshot. Keeping the legacy epoch on the write path while current readers stop reading it preserves rolling-deploy compatibility and reduces KV reads.

I checked the changed core files, tests, changeset, and docs. The implementation is consistent: runtime middleware already provides an ALS context (with metrics), request-context re-wraps only when an object cache is configured and route caching might store the response, and all content write paths still call invalidateCollectionCache() which bumps both the versioned and legacy namespaces.

The main finding is a stale docstring: shouldBypass() now has a fourth bypass condition (ctx.routeCacheFill === true) but the comment above it only lists the first three. I left one line-anchored suggestion to update it.


Findings

  • [suggestion] packages/core/src/object-cache/index.ts:401-404

    shouldBypass() now returns true when ctx.routeCacheFill === true, but the docstring above it only lists edit mode, preview tokens, and isolated databases. An incomplete list here is misleading for future readers deciding whether a new bypass condition belongs in this predicate.

    /**
     * Requests that must always read live data and never populate the cache:
     * visual edit mode, preview tokens, isolated databases (playground / DO
     * preview, whose schema and content diverge from the configured site),
     * and Astro route-cache fills (which must not rebuild a purged page from a
     * stale object-cache snapshot).
     */
    

@pkg-pr-new

pkg-pr-new Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@3488

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@3488

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@3488

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@3488

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@3488

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@3488

emdash

npm i https://pkg.pr.new/emdash@3488

create-emdash

npm i https://pkg.pr.new/create-emdash@3488

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@3488

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@3488

@emdash-cms/plugin-test

npm i https://pkg.pr.new/@emdash-cms/plugin-test@3488

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@3488

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@3488

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@3488

@emdash-cms/registry-loader

npm i https://pkg.pr.new/@emdash-cms/registry-loader@3488

@emdash-cms/registry-moderation

npm i https://pkg.pr.new/@emdash-cms/registry-moderation@3488

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@3488

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@3488

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@3488

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@3488

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@3488

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@3488

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@3488

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@3488

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@3488

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@3488

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@3488

commit: cca7a75

@github-actions github-actions Bot added area/core area/docs size/L review/awaiting-author Reviewed; waiting on the author to respond labels Sep 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Sep 26, 2026
@github-actions github-actions Bot added review/needs-rereview New commits since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Sep 26, 2026

@emdashbot emdashbot 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.

The approach remains sound: treating a Worker-side render with route caching active as a potential route-cache fill, marking it with routeCacheFill, and having cachedQuery bypass the object cache prevents a purged HTML response from being rebuilt from a stale KV snapshot. Reducing current readers to the versioned content epoch while writers continue bumping the legacy one preserves rolling-deploy compatibility and trims KV reads.

This is a re-review; the stale shouldBypass() docstring finding from the previous pass has been addressed. I re-read the core middleware, object-cache, virtual-config, tests, changeset, and docs. The implementation is consistent across the stack: the integration serializes objectCacheEnabled, the middleware sets routeCacheFill only when an object cache is configured, and shouldBypass() now lists all four bypass reasons.

Tests directly exercise the new behavior: they prove that anonymous cache fills bypass the object cache and rebuild from the loader, that the fast path is preserved when route caching is disabled or object cache is absent, and that current readers no longer react to legacy-epoch invalidation. The docs and changeset accurately describe the new semantics.

The one remaining risk is the gate that decides whether a request is a route-cache fill: the code relies on context.cache?.enabled. If the deployed Astro/adapter cache handle does not expose enabled, the fence silently never fires and the reported bug persists. The unit-test mock is shaped like the production assumption, so it won't catch this. I'd prefer the guard fall back to the presence of context.cache itself, which is the conservative signal the PR description describes.

const response = await next();
return toolbarMode === "client" ? injectBootstrap(response) : response;
};
if (virtualConfig?.objectCacheEnabled && context.cache?.enabled) {

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] The fence gates on context.cache?.enabled, which assumes Astro's cache handle exposes an enabled property. If the target Astro/adapter version doesn't provide it, the fix silently becomes a no-op and purged routes will still rebuild from stale KV snapshots. The unit-test mock matches this shape, so it won't reveal a runtime mismatch.

Because a request that reaches rendering with a cache handle present is already a fill candidate, fall back to the presence of the handle when enabled is missing:

Suggested change
if (virtualConfig?.objectCacheEnabled && context.cache?.enabled) {
const routeCacheMayStore = context.cache?.enabled ?? context.cache !== undefined;
if (virtualConfig?.objectCacheEnabled && routeCacheMayStore) {

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 26, 2026
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview New commits since the last review labels Sep 26, 2026
@ascorbic

Copy link
Copy Markdown
Member Author

I checked this concern against the exact Astro runtime pinned by the branch (astro@7.3.2). Its public CacheLike contract requires readonly enabled: boolean, APIContext.cache is always present, and the core AstroCache implementation exposes enabled. This is Astro core request state, not an adapter-specific optional handle.\n\nFalling back to context.cache presence would be incorrect here: presence is unconditional, including when caching is unavailable/disabled, so object-cache-enabled sites without active route caching would bypass object-cache reads on every anonymous render. That would remove the fast path the focused test protects and add database work on the logged-out hot path. I am keeping the context.cache?.enabled gate.\n\n~ 🤖 Codex (gpt-5.6-sol)

@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Sep 26, 2026

@emdashbot emdashbot 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.

The approach is sound and directly addresses the stale route-cache fill problem described in #3257: when a Worker-side render with route caching active is a potential fill, marking it routeCacheFill and having cachedQuery bypass the object cache prevents a purged HTML response from being rebuilt from a stale KV snapshot. Trimming current readers to the versioned content epoch while writers continue bumping the legacy one preserves rolling-deploy compatibility and reduces KV reads, matching the reasoning in #3258.

I re-read the middleware, the object-cache read/invalidation split, the runtime-config serialization, the tests, the docs, and the changeset. The implementation is consistent across the stack, the new regression tests exercise the bypass and fast-path behavior, and the docs/changeset accurately describe the new semantics. I verified that contentCacheNamespaces is now read-only versioned, contentInvalidationNamespaces still bumps both epochs on writes, and contentNamespaces / query.ts use the correct read set.

The only outstanding item is the defensive cache-handle gate carried over from the previous review: the code still gates the fence on context.cache?.enabled, which assumes every adapter/runtime exposes that property. The typecheck passing on the targeted Astro range reduces the risk, but the PR description itself frames the signal as "a request that reaches rendering with a cache handle present," so falling back to the presence of the handle is the more conservative match for the stated intent and protects against adapter/runtime drift.

const response = await next();
return toolbarMode === "client" ? injectBootstrap(response) : response;
};
if (virtualConfig?.objectCacheEnabled && context.cache?.enabled) {

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] The route-cache fill fence still gates on context.cache?.enabled. If the deployed Astro/adapter version exposes the handle but not an enabled property, the fix silently becomes a no-op and purged routes will continue rebuilding from stale KV snapshots. The unit-test mock is shaped like this assumption, so it won't reveal a runtime mismatch.

Because the PR describes the signal as "a request that reaches rendering with a cache handle present," fall back to the presence of the handle when enabled is missing:

Suggested change
if (virtualConfig?.objectCacheEnabled && context.cache?.enabled) {
const routeCacheMayStore = context.cache?.enabled ?? context.cache !== undefined;
if (virtualConfig?.objectCacheEnabled && routeCacheMayStore) {

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 26, 2026
@ascorbic
ascorbic merged commit 54ef82f into main Sep 26, 2026
51 checks passed
@ascorbic
ascorbic deleted the codex/object-cache-generation-fence branch September 26, 2026 19:23
@emdashbot emdashbot Bot mentioned this pull request Sep 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Object cache on KV: raising revalidate lets a page purged by a publish be rebuilt from the pre-publish list and cached stale

1 participant