Skip to content

fix(redirects): check redirect rules are current before the route cache stores a page - #3997

Merged
danielmlr merged 2 commits into
emdash-cms:mainfrom
bimsonz:fix/redirect-rules-before-cache-store
Oct 8, 2026
Merged

danielmlr merged 2 commits into
emdash-cms:mainfrom
bimsonz:fix/redirect-rules-before-cache-store

Conversation

@bimsonz

@bimsonz bimsonz commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

This is the second half of the fix for #3978, next to #3982. On #3982 this part was called out as a separate problem, so it is its own PR.

#3982 purges the cached page at a redirect's source when the redirect is saved. On a real deploy, the request that refills that entry often lands on a Worker isolate that hasn't reloaded its redirect rules. It renders the old page, and the route cache stores it again. An isolate revalidates its rules 30 seconds after loading them, and an idle isolate answers its first request after that with the rules it had, so this lasts longer than 30 seconds: on the test site some fills still rendered the old page 78 seconds after the save.

Before the route cache stores a response, the redirect middleware now checks that its rules are current (one read of _emdash_redirect_state). If they changed, it reloads them, and if the path now matches a redirect it answers with that instead of the page. A redirect response that routeRules makes cacheable is checked the same way before it is returned, so an isolate that still has a deleted redirect doesn't cache it. A redirect that isn't published yet, such as a slug-change auto-redirect (published in after()), is covered too: the revision is ahead of the generation, so the reload falls back to the rules table.

The page render that a late redirect replaces is left to finish in after() instead of being cancelled. My first version cancelled it, and on the test site that hung later renders until the 100 second limit: the cancelled render had started the isolate-wide taxonomy definitions query (loadTaxonomyDefRows() shares one in-flight promise between requests), the query died with its request, and every later render on that isolate waited for it. I think a client disconnecting mid-render can hang that cache the same way without this PR, but I haven't reproduced that on its own yet, so I'll report it separately once I have.

Query cost: only a response the route cache is going to store pays the read. Astro stores a response once a max age or a tag is set, so the middleware reads context.cache.options after the render. Edge hits, routes that don't call cache.set(), 404s and sites without a route cache keep the zero-query path. The query-count snapshots don't change, because the perf fixture has no route cache, so they don't cover this case. On a cache-enabled site it is one primary-key read per cache fill.

I didn't use routeCacheFill as the trigger: the redirect middleware runs before request-context, and that flag is only set when an object cache is configured as well.

Pattern redirects: #3982 doesn't purge for them, since the paths a pattern matches can't be listed, and this PR doesn't either. With it, every fill applies a pattern redirect at once, but a page already in the cache is served until it expires. A proposal, if you want one: the middleware sees every response the cache stores, so it could add one constant tag to them, and a pattern write could purge that tag. That empties the route cache, but pattern writes are rare. I haven't built it, as that's a decision for you.

Closes #3978

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 (if applicable). Do not include messages.po changes except in translation PRs - a workflow extracts catalogs on merge to main. (n/a: no admin UI change)
  • I have added and reviewed the user-facing changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/... (n/a: bug fix)
  • I have included screenshots below if this PR changes the UI (n/a: no UI change)

AI-generated code disclosure

  • This PR includes AI-generated code - model/tool: Claude Code (Claude Opus 5.5)

Screenshots / test output

Not applicable for screenshots (no UI change).

Tests: 9 new, 3 in tests/unit/redirects/cache.test.ts and 6 in tests/unit/astro/middleware-redirect.test.ts. With the source put back to main, 8 fail. The ninth checks that a response the route cache won't store makes no database read; it passes on main by design and fails if the check runs on every render. The test that the replaced render runs to completion also fails if the render is cancelled instead. pnpm build, pnpm lint (0 diagnostics), pnpm format:check and pnpm typecheck pass. Core: 9565 passed, 11 skipped. scripts/query-counts.mjs matches its snapshots for sqlite and d1.

Fresh install, as in #3978: create-emdash 1.2.0 blog template on Cloudflare with cache: { provider: cacheCloudflare() } and routeRules: { "/": { maxAge: 300, swr: 86400 } }. The same script ran against each build on the same site: make the change, then for about 75 seconds request the plain URL and four cache-busted URLs every few seconds. Each cache-busted request is a cache fill on whichever isolate gets it.

Each cell: when the plain URL first gave the expected status, then how many fills gave something else.

Scenario Published 1.2.0 #3982 alone #3982 + this PR
Create a redirect for a cached post never 301; 25 of 44 fills rendered the page never 301; 28 of 44 301 at the first request, about 1 s after the save, and every request after; 0 of 44
Unslashed source, slashed URL cached never 301; 21 of 36 never 301; 28 of 36 301 from 1 s; 0 of 36
Update moves the source to another cached post new source never 301; 21 of 44 never 301; 14 of 44 301 from 1 s; 0 of 44
Redirect at /, which routeRules caches never 301; 2 of 40 never 301; 1 of 40 301 from 1 s; 0 of 40
Delete the redirect at / 200 from 1 s; 16 of 36 fills still 301 200 from 1 s; 21 of 36 200 from 1 s; 0 of 36
Pattern /pages/[slug] over a cached page never 301; 23 of 36 never 301; 28 of 36 never 301 (not purged); 0 of 36
Slug change on a published post 301 from 3 s; 26 of 44 fills 302 to /404 301 from 3 s; 28 of 44 301 from 3 s; 31 of 44

No response stalled in the last run, and a second run of 8 save-and-delete rounds with 30 concurrent fills each (1440 requests) had none either.

After a delete or a move, an isolate that still has the old rule answers the old source with a 301 until it reloads its rules, as on 1.2.0. Nothing caches that response unless routeRules applies, which is the / row. In the slug-change row, the fills that miss are stale isolates answering the old slug with the template's redirect to /404, which nothing caches either.

…he stores a page

The redirect middleware serves each Worker isolate's cached rules and
revalidates them in the background after 30 seconds, and an idle isolate
answers its first request after that with the rules it had. Behind the
route cache, a render made under stale rules is stored and served to every
visitor: purging the page at a new redirect's source is followed by a fill
that renders and caches the old page again.

Before the route cache stores a response, the middleware now checks that
its rules are current (one read of the redirect state row), reloads them
when they are not, and answers with the redirect if the path now matches
one. A stored redirect for a rule that has since been removed is checked
the same way. Responses the route cache does not store, and sites without
a route cache, keep the zero-query path.

The render a late redirect replaces is left to finish in the background
rather than cancelled. A render cut off with its request never settles an
isolate-wide fetch it started (the taxonomy definitions cache shares one
in-flight query between requests), and every later render on that isolate
waiting for it hangs until the platform's time limit.
@changeset-bot

changeset-bot Bot commented Oct 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 193825b

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

@pkg-pr-new

pkg-pr-new Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-test

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-loader

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

@emdash-cms/registry-moderation

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

@emdash-cms/registry-verification

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

upgrade-emdash

npm i https://pkg.pr.new/upgrade-emdash@3997

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 4cd7384

@danielmlr danielmlr self-assigned this Oct 8, 2026
@danielmlr danielmlr added bot:review Trigger an emdashbot code review on this PR ci:run Approve and re-run CI on the PR's latest commit labels Oct 8, 2026
@github-actions github-actions Bot removed the ci:run Approve and re-run CI on the PR's latest commit label Oct 8, 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.

This is the right follow-up to #3982. The underlying problem is real: purging the route cache is not enough when a stale Worker isolate renders the old page and re-fills the cache, so verifying the redirect rules before anything is stored is a sound fix. The implementation reuses the existing artifact/state machinery and keeps the new cost gated to responses Astro will actually cache.

I checked the changed middleware, the verify path in loadCachedRedirects, the RedirectSource implementation, the middleware ordering, the new tests, and the changeset. The logic for added/deleted/slug-change auto-redirects holds up, and the decision to drain the replaced render in after() rather than cancel it is well justified (it avoids hanging the isolate-wide taxonomy fetch the cancelled render started).

The query-count tradeoff is explicitly justified in the PR description and changeset: only cache-fill responses pay the read, while edge hits, 404s, and sites without a route cache keep the zero-query path. The tests cover the new behavior, including the zero-query non-cache case and the drain-to-completion path.

One minor style note: the codebase separates type-only imports with import type; the diff mixes inline type modifiers into a value import.

@emdashbot emdashbot Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed bot:review Trigger an emdashbot code review on this PR review/needs-review No maintainer or bot review yet labels Oct 8, 2026
Co-authored-by: emdashbot[bot] <273199577+emdashbot[bot]@users.noreply.github.com>
@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 Oct 8, 2026

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

Thanks @bimsonz, this closes the rest of emdash-cms/emdash#3978. I applied the bot's import suggestion and changed Refs #3978 to Closes #3978 in the description, since this and emdash-cms/emdash#3982 together fix what the issue reports.

@danielmlr
danielmlr merged commit 2ccc645 into emdash-cms:main Oct 8, 2026
44 checks passed
@emdashbot

emdashbot Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Thanks for contributing to EmDash! 🎉

Join the EmDash Discord, then run /link to connect your GitHub account and claim the Contributor role.

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.

A new redirect is not applied to a page in Workers Cache

2 participants