-
Notifications
You must be signed in to change notification settings - Fork 1.1k
fix: prefer uncached Hyperdrive after content writes #2280
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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). |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -42,4 +42,18 @@ describe("hyperdrive()", () => { | |||||||||||||||||||||||||||||||||||||||||
| cachedBinding: "HYPERDRIVE_CACHED", | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| it("passes through preferUncachedAfterWriteMs", () => { | ||||||||||||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||||||||||||||||||||||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion] This test passes
Suggested change
Or simply remove this block, since the routing tests cover the custom value behavior. |
||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"; | ||
|
|
@@ -346,6 +347,7 @@ async function runOutsideRequest<T>( | |
| ): Promise<T> { | ||
| const runtime = await getRuntime(config); | ||
|
|
||
| const lastContentWriteAt = await getLastContentWriteAt(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This one can just be dropped: |
||
| const scoped = createRequestScopedDb({ | ||
| config: config.database?.config, | ||
| isAuthenticated: false, | ||
|
|
@@ -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 | ||
|
|
@@ -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(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Gate the fetch on the adapter actually wanting it e.g. a flag on |
||
| 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(); | ||
|
|
@@ -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 () => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -199,6 +199,7 @@ export { EmDashStorageError } from "./storage/types.js"; | |||||||||||||||||||||||||||||||||
| // Object cache (distributed read-through query cache) | ||||||||||||||||||||||||||||||||||
| export { | ||||||||||||||||||||||||||||||||||
| cachedQuery, | ||||||||||||||||||||||||||||||||||
| getLastContentWriteAt, | ||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion]
Suggested change
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
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. Takingnowinoptswould make it pure and make the window tests deterministic instead of wall-clock-relative.