fix: prefer uncached Hyperdrive after content writes - #2280
Conversation
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 detectedLatest commit: 20b7135 The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 packages
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 |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | 20b7135 | Jul 29 2026, 10:21 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | 20b7135 | Jul 29 2026, 10:20 PM |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-do | 20b7135 | Jul 29 2026, 10:19 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | d8a53c0 | Jul 29 2026, 10:13 PM |
There was a problem hiding this comment.
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.
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.
Overlapping PRsThis PR modifies files that are also changed by other open PRs: This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
There was a problem hiding this comment.
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.
| 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, | ||
| }); | ||
| }); |
There was a problem hiding this comment.
[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.
| 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, |
There was a problem hiding this comment.
[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.
| getLastContentWriteAt, | |
| export { | |
| cachedQuery, | |
| invalidateObjectCache, | |
| invalidateCollectionCache, | |
| invalidateTaxonomyObjectCache, | |
| invalidateBylineObjectCache, | |
| invalidateMenuObjectCache, | |
| invalidateSchemaObjectCache, | |
| invalidateCommentObjectCache, | |
| isObjectCacheActive, | |
| isObjectCacheConfigured, | |
| contentNamespace, | |
| contentNamespaces, | |
| CacheNamespace, | |
| } from "./object-cache/index.js"; |
| // 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(); |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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", () => { |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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.
|
This is a good additioon, and pretty close to ready. Just a few tidiness and perf changes |
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 (~60smax_age) and reseed KV + Workers Cache with old HTML (e.g. an updatedhtmlBlock/ any Portable Text content in a post).Approach:
getLastContentWriteAt), without hardcoding any duration.@emdash-cms/cloudflarehyperdrive({ preferUncachedAfterWriteMs })owns the window (default 60_000 only whencachedBindingis set). Sites override to match their Hyperdrivemax_age.now - lastContentWriteAt < preferUncachedAfterWriteMs.cachedBinding→ no behavior change.Type of change
Checklist
pnpm typecheckpasses (@emdash-cms/cloudflareclean; core residual errors are pre-existing missing sibling package builds in this scratch clone, unrelated to this change)pnpm lintpasses (pnpm lint:quick— 0 diagnostics)pnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain. (n/a — no admin UI strings)AI-generated code disclosure
Screenshots / test output