Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/prefer-uncached-hyperdrive-after-write.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"emdash": patch
"@emdash-cms/cloudflare": patch
---

Fixes anonymous public pages reseeding edge/object caches with stale Hyperdrive query results right after content publishes. When `cachedBinding` is set, public reads prefer the uncached Hyperdrive binding for a short window after content writes (default 60s, overridable via `preferUncachedAfterWriteMs` to match your Hyperdrive max_age).
17 changes: 10 additions & 7 deletions docs/src/content/docs/deployment/database.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -307,11 +307,14 @@ wrangler hyperdrive create emdash-db \

### Configuration

| Option | Type | Default | Description |
| --------------- | -------- | ------------- | -------------------------------------------------------------------- |
| `binding` | `string` | `"HYPERDRIVE"` | Primary (caching-disabled) Hyperdrive binding name |
| `cachedBinding` | `string` | — | Optional caching-enabled binding for anonymous reads (see below) |
| `max` | `number` | `5` | Max size of the in-Worker connection pool to Hyperdrive |
| Option | Type | Default | Description |
| ------------------------------ | -------- | -------------- | ---------------------------------------------------------------------------------------------------------------- |
| `binding` | `string` | `"HYPERDRIVE"` | Primary (caching-disabled) Hyperdrive binding name |
| `cachedBinding` | `string` | — | Optional caching-enabled binding for anonymous reads (see below) |
| `preferUncachedAfterWriteMs` | `number` | `60000`\* | After a content publish, prefer `binding` for this many ms on anonymous public reads (match Hyperdrive `max_age`) |
| `max` | `number` | `5` | Max size of the in-Worker connection pool to Hyperdrive |

\*Default `60000` applies only when `cachedBinding` is set; ignored otherwise.

### Serving anonymous reads from cache

Expand Down Expand Up @@ -343,7 +346,7 @@ database: hyperdrive({ binding: "HYPERDRIVE", cachedBinding: "HYPERDRIVE_CACHED"

This is the [two-configuration pattern](https://developers.cloudflare.com/hyperdrive/configuration/query-caching/#disable-caching) Cloudflare documents for caching. EmDash decides which binding to use per request:

- **Anonymous reads of public-site paths** (`GET`/`HEAD`, no session, not under `/_emdash`) → cache-enabled `cachedBinding`.
- **Anonymous reads of public-site paths** (`GET`/`HEAD`, no session, not under `/_emdash`) → cache-enabled `cachedBinding`, **except** for a short window after a content publish (default 60s; set `preferUncachedAfterWriteMs` to your Hyperdrive `max_age`) when EmDash prefers the uncached `binding` so a rebuild cannot reseed edge/object caches from still-stale Hyperdrive results.
- **Authenticated requests** (editors, authors) → uncached `binding`.
- **Writes** (`POST`, `PUT`, `DELETE`, including anonymous ones) → uncached `binding`.
- **Any request under `/_emdash`** (admin, setup, auth, internal APIs), even an anonymous `GET` → uncached `binding`.
Expand All @@ -357,7 +360,7 @@ This is the [two-configuration pattern](https://developers.cloudflare.com/hyperd
</Aside>

<Aside type="caution">
Both configurations must point at the **same** database, or anonymous visitors see a different dataset than editors. Anonymous reads of just-published content can be up to the cache's `max_age` stale (Hyperdrive default 60s, max 1h), and this cache is independent of EmDash's own cache invalidation. Only opt in if a short public-read staleness window is acceptable; otherwise omit `cachedBinding` and keep caching disabled.
Both configurations must point at the **same** database, or anonymous visitors see a different dataset than editors. Set `preferUncachedAfterWriteMs` to your cached config's `max_age` (default 60s) so post-publish public rebuilds read fresh SQL. Outside that window, anonymous public reads can still be up to `max_age` stale relative to non-content writes. Only opt in if that trade-off is acceptable; otherwise omit `cachedBinding` and keep caching disabled.
</Aside>

<Aside>
Expand Down
39 changes: 38 additions & 1 deletion packages/cloudflare/src/db/hyperdrive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,18 @@ interface HyperdriveConfig {
* authenticated requests and all writes stay on `binding`.
*/
cachedBinding?: string;
/**
* After a content publish, prefer the primary uncached binding for this
* many ms on anonymous public reads. Defaults to 60_000 when
* `cachedBinding` is set (Hyperdrive's default `max_age`); 0 otherwise.
* Set equal to your cached Hyperdrive config's `max_age`.
*/
preferUncachedAfterWriteMs?: number;
}

/** Hyperdrive default query-cache max_age when caching is enabled. */
const DEFAULT_PREFER_UNCACHED_AFTER_WRITE_MS = 60_000;

/**
* Minimal shape of a Hyperdrive binding. Workers inject `connectionString`
* (and the discrete parts) at runtime; we only need the string for pg.
Expand Down Expand Up @@ -147,6 +157,8 @@ export interface RequestScopedDbOpts {
isWrite: boolean;
cookies: CookieJar;
url: URL;
/** ms-epoch of last content-namespace invalidation; from core object-cache. */
lastContentWriteAt?: number;
}

export interface RequestScopedDb {
Expand Down Expand Up @@ -182,6 +194,7 @@ export function createRequestScopedDb(opts: RequestScopedDbOpts): RequestScopedD
isAuthenticated: opts.isAuthenticated,
isWrite: opts.isWrite,
url: opts.url,
lastContentWriteAt: opts.lastContentWriteAt,
});
let binding = getBinding(bindingName);
// If the cached binding was selected but isn't present at runtime (e.g.
Expand Down Expand Up @@ -241,6 +254,17 @@ function isEmDashInternalPath(url: URL): boolean {
return url.pathname === EMDASH_BASE_PATH || url.pathname.startsWith(`${EMDASH_BASE_PATH}/`);
}

/**
* Effective window (ms) to prefer the primary binding after a content write.
* Defaults to Hyperdrive's 60s `max_age` when `cachedBinding` is set.
*/
function preferUncachedDurationMs(config: HyperdriveConfig): number {
if (config.preferUncachedAfterWriteMs !== undefined) {
return config.preferUncachedAfterWriteMs;
}
return config.cachedBinding ? DEFAULT_PREFER_UNCACHED_AFTER_WRITE_MS : 0;
}

/**
* Decide which binding a given request should use.
*
Expand All @@ -255,19 +279,32 @@ function isEmDashInternalPath(url: URL): boolean {
* even an anonymous GET → primary. The setup-status and login-state reads
* are anonymous GETs that must see writes made moments earlier; routing them
* to the cache would loop the setup wizard and show stale auth state.
* - anonymous public reads within `preferUncachedAfterWriteMs` of the last
* 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.

*/
export function selectBindingName(
config: HyperdriveConfig,
opts: { isAuthenticated: boolean; isWrite: boolean; url: URL },
opts: {
isAuthenticated: boolean;
isWrite: boolean;
url: URL;
lastContentWriteAt?: number;
},
): string {
if (
config.cachedBinding &&
!opts.isAuthenticated &&
!opts.isWrite &&
!isEmDashInternalPath(opts.url)
) {
const duration = preferUncachedDurationMs(config);
const lastWrite = opts.lastContentWriteAt ?? 0;
if (duration > 0 && lastWrite > 0 && Date.now() - lastWrite < duration) {
return config.binding;
}
return config.cachedBinding;
}
return config.binding;
Expand Down
40 changes: 30 additions & 10 deletions packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,11 @@ export interface HyperdriveConfig {
* check, which must observe a write made moments earlier. Migrations and the
* cold-start singleton always use `binding`.
*
* Anonymous reads of just-published content can be up to the cache's
* `max_age` stale (Hyperdrive default 60s, max 1h), and this cache is
* independent of EmDash's own cache invalidation — only opt in if a short
* public-read staleness window is acceptable. Omit it and the adapter uses
* the single primary binding as before.
* After a content publish, EmDash prefers the primary uncached binding for
* anonymous public reads for a short window (see
* {@link preferUncachedAfterWriteMs}) so edge/object caches are not reseeded
* from Hyperdrive's still-stale query results. Outside that window,
* anonymous public reads use this cache-enabled binding.
*
* Bind both configs in wrangler:
* ```jsonc
Expand All @@ -157,6 +157,17 @@ export interface HyperdriveConfig {
*/
cachedBinding?: string;

/**
* How long (ms) after a content write anonymous public reads should use the
* primary uncached `binding` instead of `cachedBinding`. Set this equal to
* your cached Hyperdrive configuration's `max_age` so the prefer-uncached
* window covers the query-cache TTL.
*
* Only applies when `cachedBinding` is set. Default: `60_000` (Hyperdrive's
* default `max_age`) when `cachedBinding` is set; ignored otherwise.
*/
preferUncachedAfterWriteMs?: number;

/**
* Maximum size of the in-Worker node-postgres connection pool.
*
Expand Down Expand Up @@ -299,12 +310,18 @@ export function d1(config: D1Config): DatabaseDescriptor {
*
* **Optional: serve anonymous reads from cache.** If a short public-read
* staleness window is acceptable, pass a second `cachedBinding` pointing at a
* caching-enabled Hyperdrive config over the same database. Anonymous read
* requests then route through the cache-enabled binding while authenticated
* requests and writes stay on the uncached `binding`, keeping read-after-write
* consistency intact:
* caching-enabled Hyperdrive config over the same database. Anonymous public
* reads then route through the cache-enabled binding while authenticated
* requests and writes stay on the uncached `binding`. For a short window after
* each content publish (default 60s; set `preferUncachedAfterWriteMs` to match
* your Hyperdrive `max_age`), anonymous public reads also use the primary so a
* rebuild cannot reseed edge caches from stale Hyperdrive results:
* ```ts
* database: hyperdrive({ binding: "HYPERDRIVE", cachedBinding: "HYPERDRIVE_CACHED" })
* database: hyperdrive({
* binding: "HYPERDRIVE",
* cachedBinding: "HYPERDRIVE_CACHED",
* // preferUncachedAfterWriteMs: 60_000, // default when cachedBinding is set
* })
* ```
*
* For best latency, pair this with a Smart Placement hint so the Worker runs in
Expand Down Expand Up @@ -338,6 +355,9 @@ export function hyperdrive(config: HyperdriveConfig = {}): DatabaseDescriptor {
binding: config.binding ?? "HYPERDRIVE",
max: config.max,
...(config.cachedBinding !== undefined ? { cachedBinding: config.cachedBinding } : {}),
...(config.preferUncachedAfterWriteMs !== undefined
? { preferUncachedAfterWriteMs: config.preferUncachedAfterWriteMs }
: {}),
},
type: "postgres",
// Each request gets a fresh pg connection that is closed afterwards —
Expand Down
89 changes: 89 additions & 0 deletions packages/cloudflare/tests/db/hyperdrive-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,82 @@ describe("selectBindingName", () => {
),
).toBe("HYPERDRIVE");
});

it("uses the primary binding for anonymous public reads soon after a content write", () => {
const name = selectBindingName(cfg, {
isAuthenticated: false,
isWrite: false,
url: publicUrl,
lastContentWriteAt: Date.now() - 1_000,
});
expect(name).toBe("HYPERDRIVE");
});

it("uses the cached binding once the prefer-uncached window has elapsed", () => {
const name = selectBindingName(cfg, {
isAuthenticated: false,
isWrite: false,
url: publicUrl,
lastContentWriteAt: Date.now() - 61_000,
});
expect(name).toBe("HYPERDRIVE_CACHED");
});

it("uses the cached binding when lastContentWriteAt is zero or missing", () => {
expect(
selectBindingName(cfg, {
isAuthenticated: false,
isWrite: false,
url: publicUrl,
lastContentWriteAt: 0,
}),
).toBe("HYPERDRIVE_CACHED");
expect(
selectBindingName(cfg, {
isAuthenticated: false,
isWrite: false,
url: publicUrl,
}),
).toBe("HYPERDRIVE_CACHED");
});

it("respects a custom preferUncachedAfterWriteMs", () => {
const custom = {
binding: "HYPERDRIVE",
cachedBinding: "HYPERDRIVE_CACHED",
preferUncachedAfterWriteMs: 5_000,
};
expect(
selectBindingName(custom, {
isAuthenticated: false,
isWrite: false,
url: publicUrl,
lastContentWriteAt: Date.now() - 2_000,
}),
).toBe("HYPERDRIVE");
expect(
selectBindingName(custom, {
isAuthenticated: false,
isWrite: false,
url: publicUrl,
lastContentWriteAt: Date.now() - 6_000,
}),
).toBe("HYPERDRIVE_CACHED");
});

it("ignores lastContentWriteAt when no cachedBinding is set", () => {
expect(
selectBindingName(
{ binding: "HYPERDRIVE" },
{
isAuthenticated: false,
isWrite: false,
url: publicUrl,
lastContentWriteAt: Date.now(),
},
),
).toBe("HYPERDRIVE");
});
});

describe("createRequestScopedDb binding routing", () => {
Expand All @@ -127,6 +203,19 @@ describe("createRequestScopedDb binding routing", () => {
expect(poolCalls[0]!.connectionString).toBe("postgres://replica/cached");
});

it("builds the pool from the primary binding when lastContentWriteAt is recent", () => {
poolCalls.length = 0;
createRequestScopedDb({
config: { binding: "HYPERDRIVE", cachedBinding: "HYPERDRIVE_CACHED" },
isAuthenticated: false,
isWrite: false,
cookies,
url,
lastContentWriteAt: Date.now() - 500,
});
expect(poolCalls[0]!.connectionString).toBe("postgres://primary/uncached");
});

it("builds the pool from the primary binding for authenticated reads", () => {
poolCalls.length = 0;
createRequestScopedDb({
Expand Down
14 changes: 14 additions & 0 deletions packages/cloudflare/tests/hyperdrive-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,18 @@ describe("hyperdrive()", () => {
cachedBinding: "HYPERDRIVE_CACHED",
});
});

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.

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,
});
});
Comment on lines +46 to +58

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.

});
7 changes: 7 additions & 0 deletions packages/core/src/astro/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
import { setI18nConfig } from "../i18n/config.js";
import type { Database, Storage } from "../index.js";
import { createPublicMediaUrlResolver } from "../media/url.js";
import { getLastContentWriteAt } from "../object-cache/index.js";
import type { SandboxRunnerFactory } from "../plugins/sandbox/types.js";
import type { ResolvedPlugin } from "../plugins/types.js";
import { invalidateUrlPatternCache } from "../query.js";
Expand Down Expand Up @@ -346,6 +347,7 @@ async function runOutsideRequest<T>(
): 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.

const scoped = createRequestScopedDb({
config: config.database?.config,
isAuthenticated: false,
Expand All @@ -354,6 +356,7 @@ async function runOutsideRequest<T>(
isWrite: true,
cookies: NOOP_COOKIE_JAR,
url: CRON_EVENT_URL,
lastContentWriteAt,
});
if (!scoped?.close) {
// Stateless adapter (or no per-request scoping): the singleton is safe
Expand Down Expand Up @@ -647,12 +650,14 @@ export const onRequest = defineMiddleware(async (context, next) => {
// 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.

const anonScoped = createRequestScopedDb({
config: config?.database?.config,
isAuthenticated: false,
isWrite: request.method !== "GET" && request.method !== "HEAD",
cookies,
url,
lastContentWriteAt,
});
const runAnon = async () => {
const t0 = performance.now();
Expand Down Expand Up @@ -855,12 +860,14 @@ export const onRequest = defineMiddleware(async (context, next) => {
// it in ALS so the runtime's db getter and loader's getDb() pick it up,
// then call commit() after next() so the adapter can persist any
// per-request state (e.g. a D1 bookmark cookie for read-your-writes).
const lastContentWriteAt = await getLastContentWriteAt();
const scoped = createRequestScopedDb({
config: config?.database?.config,
isAuthenticated: !!sessionUser || hasBearerAuth,
isWrite: request.method !== "GET" && request.method !== "HEAD",
cookies: context.cookies,
url,
lastContentWriteAt,
});

const renderAndFinalize = async () => {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export { EmDashStorageError } from "./storage/types.js";
// 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";

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.

invalidateObjectCache,
invalidateCollectionCache,
invalidateTaxonomyObjectCache,
Expand Down
Loading
Loading