Skip to content

fix: prefer uncached Hyperdrive after content writes - #2280

Open
scottbuscemi wants to merge 2 commits into
mainfrom
fix/prefer-uncached-hyperdrive-after-write
Open

fix: prefer uncached Hyperdrive after content writes#2280
scottbuscemi wants to merge 2 commits into
mainfrom
fix/prefer-uncached-hyperdrive-after-write

Conversation

@scottbuscemi

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes post-publish public staleness when anonymous reads use Hyperdrive's cached binding (HYPERDRIVE_CACHED). After live content writes, edge/object-cache correctly invalidate, but the next anonymous rebuild could re-read stale SQL from Hyperdrive (~60s max_age) and reseed KV + Workers Cache with old HTML (e.g. an updated htmlBlock / any Portable Text content in a post).

Approach:

  • Core records when live content namespaces were invalidated (getLastContentWriteAt), without hardcoding any duration.
  • @emdash-cms/cloudflare hyperdrive({ preferUncachedAfterWriteMs }) owns the window (default 60_000 only when cachedBinding is set). Sites override to match their Hyperdrive max_age.
  • After a content write, anonymous public GETs prefer the primary uncached binding while now - lastContentWriteAt < preferUncachedAfterWriteMs.
  • No cachedBinding → no behavior change.

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 (@emdash-cms/cloudflare clean; core residual errors are pre-existing missing sibling package builds in this scratch clone, unrelated to this change)
  • pnpm lint passes (pnpm lint:quick — 0 diagnostics)
  • 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 strings)
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: n/a (bug fix; maintainer PR)

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: grok-4.5 / opencode

Screenshots / test output

# packages/core
Test Files  1 passed (object-cache.test.ts)
Tests  29 passed

# packages/cloudflare
Test Files  2 passed (hyperdrive-routing, hyperdrive-config)
Tests  26 passed

# middleware-prerender
Tests  13 passed

pnpm lint:quick — 0 diagnostics

After a content publish, anonymous public reads briefly use the primary
Hyperdrive binding so edge/object caches are not reseeded from stale
query-cache results (default 60s when cachedBinding is set).
@changeset-bot

changeset-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 20b7135

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

This PR includes changesets to release 17 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash 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 Jul 29, 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
emdash-demo-cache 20b7135 Jul 29 2026, 10:21 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 29, 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
emdash-playground 20b7135 Jul 29 2026, 10:20 PM

@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Jul 29, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jul 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-verification

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 20b7135

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 29, 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
emdash-demo-do 20b7135 Jul 29 2026, 10:19 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 29, 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 d8a53c0 Jul 29 2026, 10:13 PM

@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 PR addresses a real problem: with Hyperdrive's cachedBinding, anonymous public reads right after a content publish can reseed edge/object caches from Hyperdrive's still-stale query cache. The approach—tracking the last content-namespace invalidation in the object-cache backend and routing to the primary uncached binding for a configurable window—fits EmDash's existing epoch/invalidation architecture and the split-binding design.

I read the full changed files, traced the routing tests, and checked the marker logic against the object-cache revalidation pattern. The code is generally clean and well-tested, and the changeset/docs are appropriate.

The one blocking concern is a performance regression in getLastContentWriteAt: it refuses to cache a "no writes yet" marker (0), so sites with an object-cache configured will perform an extra backend round-trip on every logged-out request until a content write occurs. That violates the "never add queries to the logged-out hot path" guidance and should be fixed. I also flagged one newly-added config-pin test as low-value.

Once the zero-marker caching is fixed, this looks good to merge.

Comment thread packages/core/src/object-cache/index.ts Outdated
Comment thread packages/cloudflare/tests/hyperdrive-config.test.ts Outdated
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Jul 29, 2026
Avoid an object-cache backend round-trip on every logged-out request when
no content write has occurred yet. Also drop a low-value config-pin test.
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review overlap and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 29, 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.

@khoinguyenpham04 khoinguyenpham04 added the bot:review Trigger an emdashbot code review on this PR label Jul 30, 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 PR is a sensible, targeted fix for a real Hyperdrive cachedBinding footgun. By tracking the last content-namespace object-cache invalidation and briefly routing anonymous public GETs to the uncached binding, it prevents stale query-cache results from reseeding edge/object caches right after a publish. The approach fits EmDash's existing epoch/invalidation architecture and keeps the window configurable.

The previous review's blocking concern—the getLastContentWriteAt marker not caching a confirmed "no writes yet" value—has been addressed. The new caches a confirmed zero marker within the revalidate window test verifies that sites without content writes no longer pay a backend round-trip on every logged-out request, which satisfies the logged-out hot-path concern.

I read the full changed files, traced the routing logic and the marker lifecycle, and checked the new tests against the behavior. The fix is clean. There are two non-blocking items left: a config-pin test that should be deleted or rewritten, and an unnecessary public export of an internal helper. Once those are addressed, this is good to merge.

Comment on lines +46 to +58
it("passes through preferUncachedAfterWriteMs", () => {
const result = hyperdrive({
binding: "HYPERDRIVE",
cachedBinding: "HYPERDRIVE_CACHED",
preferUncachedAfterWriteMs: 120_000,
});
expect(result.config).toEqual({
binding: "HYPERDRIVE",
max: undefined,
cachedBinding: "HYPERDRIVE_CACHED",
preferUncachedAfterWriteMs: 120_000,
});
});

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] This test passes preferUncachedAfterWriteMs: 120_000 into hyperdrive() and asserts the same value appears in the returned descriptor config. It restates the source code rather than testing behavior, so it can only fail when someone intentionally changes the option plumbing. Per AGENTS.md, config-pin tests inflate coverage without catching regressions and should be deleted or rewritten against observable behavior. A meaningful behavior test already exists in hyperdrive-routing.test.ts for custom windows, so this descriptor test is redundant.

Suggested change
it("passes through preferUncachedAfterWriteMs", () => {
const result = hyperdrive({
binding: "HYPERDRIVE",
cachedBinding: "HYPERDRIVE_CACHED",
preferUncachedAfterWriteMs: 120_000,
});
expect(result.config).toEqual({
binding: "HYPERDRIVE",
max: undefined,
cachedBinding: "HYPERDRIVE_CACHED",
preferUncachedAfterWriteMs: 120_000,
});
});
it("defaults preferUncachedAfterWriteMs to 60s when cachedBinding is set", () => {
const result = hyperdrive({
binding: "HYPERDRIVE",
cachedBinding: "HYPERDRIVE_CACHED",
});
expect(result.config).not.toHaveProperty("preferUncachedAfterWriteMs");
});

Or simply remove this block, since the routing tests cover the custom value behavior.

// Object cache (distributed read-through query cache)
export {
cachedQuery,
getLastContentWriteAt,

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] getLastContentWriteAt is only used inside core middleware (which passes the resolved stamp to adapters via lastContentWriteAt in RequestScopedDbOpts) and in tests; it is not consumed from the public emdash package. Exporting it from the barrel makes it part of the supported API surface without a clear external consumer.

Suggested change
getLastContentWriteAt,
export {
cachedQuery,
invalidateObjectCache,
invalidateCollectionCache,
invalidateTaxonomyObjectCache,
invalidateBylineObjectCache,
invalidateMenuObjectCache,
invalidateSchemaObjectCache,
invalidateCommentObjectCache,
isObjectCacheActive,
isObjectCacheConfigured,
contentNamespace,
contentNamespaces,
CacheNamespace,
} from "./object-cache/index.js";

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 30, 2026
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview Author pushed changes since the last review labels Jul 30, 2026
// Even on the anonymous fast path we ask the adapter for a per-request
// scoped db. For D1 with read replication this routes anonymous reads
// to the nearest replica; for other adapters it's a no-op.
const lastContentWriteAt = await getLastContentWriteAt();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This runs on every logged-out request for every adapter, but only Hyperdrive with cachedBinding ever consumes the value. On the recommended D1 + KV setup that's a blocking backend read ahead of render once per revalidate window (1s default, 2s timeout worst case) per isolate, when most sites don't need it.

Gate the fetch on the adapter actually wanting it e.g. a flag on DatabaseDescriptor so middleware doesn't need to know adapter config shapes, and skip it entirely when unset. Same applies to the call in the general request path below.

): Promise<T> {
const runtime = await getRuntime(config);

const lastContentWriteAt = await getLastContentWriteAt();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This one can just be dropped: runOutsideRequest passes isWrite: true, and selectBindingName never consults lastContentWriteAt on the write path. It's a wasted backend read on every cron tick.

// Object cache (distributed read-through query cache)
export {
cachedQuery,
getLastContentWriteAt,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agree with the open thread here but there's more: middleware imports this via a relative path, so the export has no consumers - it's permanent public API surface added without needing to. Remove it from the barrel.

});
});

it("passes through preferUncachedAfterWriteMs", () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

On the open thread about this being a config-pin test I half agree, but note the routing tests construct config objects directly and never go through the hyperdrive() builder, so this is currently the only test that would catch a typo in the conditional spread. Rather than delete it, replace it with one test through the real chain: createRequestScopedDb(hyperdrive({...}).config, { ..., lastContentWriteAt: recent }) asserting the primary pool. That tests behaviour and keeps the builder link covered.

* content-namespace invalidation → primary, so a post-publish rebuild does
* not reseed edge/object caches from Hyperdrive's still-stale query cache.
*
* Pure (no I/O) so the routing rule can be unit-tested directly.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: this doc line is no longer true: the new window check calls Date.now() inside. Taking now in opts would make it pure and make the window tests deterministic instead of wall-clock-relative.

@ascorbic

ascorbic commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

This is a good additioon, and pretty close to ready. Just a few tidiness and perf changes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants