Skip to content

feat(core): optional distributed object cache for query results#1378

Merged
ascorbic merged 17 commits into
mainfrom
feat/object-cache
Jun 22, 2026
Merged

feat(core): optional distributed object cache for query results#1378
ascorbic merged 17 commits into
mainfrom
feat/object-cache

Conversation

@scottbuscemi

@scottbuscemi scottbuscemi commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Adds an optional, opt-in distributed object cache for query results. Content reads (getEmDashCollection, getEmDashEntry, resolveEmDashPath) and chrome reads (site settings, menus, taxonomies, per-entry terms, collection info, public comments) can be served from a fast key/value store instead of hitting the database on every request. It sits beneath the per-request cache and above the database, dramatically reducing read pressure on D1/SQLite — especially on Cloudflare, where KV absorbs far more requests than D1.

The cache is off by default and fully opt-in. Configure a backend in astro.config.mjs:

import { kvCache } from "@emdash-cms/cloudflare"; // Workers KV (distributed)
import { memoryCache } from "emdash/astro";       // in-isolate (Node / local dev)

emdash({
	database: d1({ binding: "DB" }),
	objectCache: kvCache({ binding: "CACHE" }),
});

Invalidation is epoch-based and automatic: content, byline, taxonomy, menu, and settings writes bump a per-namespace version, instantly orphaning stale entries (no key enumeration). Value and epoch are fetched in one parallel round-trip via a stable-key envelope, so there are no orphaned keys to accumulate. Preview and visual-edit requests bypass the cache, so editors previewing see live content; other reads are served from the cache, which only ever stores published content. After an edit, anonymous visitors may see stale content until isolates pick up the bumped epoch — immediate on the memory backend, and on KV bounded by KV's edge-cache propagation (eventually consistent, up to ~60s) plus the revalidate window (default 1s, configurable). Backend reads are bounded by a timeout so a slow/unavailable cache can never hang a render.

Existing sites are unaffected until they opt in.

Closes #

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) — object-cache unit/content/comments/schema/entry-terms + cloudflare kv-timeout suites
  • 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). 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: https://github.com/emdash-cms/emdash/discussions/... Draft — Discussion to be linked before review.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.8 (opencode)

Screenshots / test output

object-cache.test.ts / object-cache-content.test.ts / object-cache-comments-schema.test.ts / entry-terms-object-cache.test.ts — 25 passed
cloudflare kv-timeout.test.ts — 2 passed
pnpm lint — 0 diagnostics; pnpm typecheck — clean

Try this PR

Open a fresh playground →

A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.

Tracks feat/object-cache. Updated automatically when the playground redeploys.

Add an opt-in read-through cache that sits beneath the per-request cache
and above the database, so content and chrome (settings, menus,
taxonomies) reads can be served from a fast key/value store instead of
hitting D1/SQLite on every request.

- New ObjectCache abstraction (interface + descriptor + virtual module
  + per-isolate backend), mirroring the storage adapter pattern. Off by
  default: when unconfigured, cachedQuery is a transparent passthrough.
- Backends: in-isolate memory (emdash/object-cache/memory via
  memoryCache() from emdash/astro) and Cloudflare KV
  (@emdash-cms/cloudflare/cache/kv via kvCache()).
- JSON codec preserves Date instances; content entries are snapshotted
  (dropping the .edit proxy, capturing the CURSOR_RAW_VALUES symbol) and
  rebuilt on read.
- Epoch-based invalidation at the repository chokepoint (content, seo,
  byline, taxonomy, menu) and settings; content reads fold in shared
  bylines/taxonomies epochs so author/term renames invalidate correctly.
- Auth/preview/edit-mode and isolated DBs always bypass.

Existing sites are unaffected until they opt in.
A KV read that stalls without resolving or rejecting (cold cross-region
read, or one queued behind the Workers connection limit) could hang the
isolate: getEpoch cached the never-settling promise and every later
cached read on that namespace reused it, poisoning the isolate until it
recycled.

- Race every backend read against a timeout (default 2000ms, configurable
  via the `timeout` option on kvCache/objectCache, 0 disables). A timed-out
  read degrades to a cache miss; the database stays the source of truth.
- Apply the timeout in the KV backend (get/set/delete) and in the core
  read path (getEpoch + cachedQuery value read), so any backend that
  stalls self-heals once the bounded read settles.
- Also switch the cache debug-log gate from process.env to
  import.meta.env.DEV (repo convention).

Adds regression tests: a never-settling backend resolves via load()
instead of hanging, the namespace self-heals afterward, and the KV
backend rejects a stalled get/set.
Public renders that read an entry's terms still hit D1 on every request
even with the object cache on: getEmDashEntry caches the entry (and bakes
in byline/term hydration), but templates that call getEntryTerms /
getTermsForEntries / getTerm directly fell through to D1, because only the
taxonomy *definitions* (getTaxonomyDefs) and full term *lists*
(getTaxonomyTerms) were wrapped. On a warm content-cache hit, hydration
(which used to prime the request cache for getEntryTerms) doesn't run, so
those direct calls query the database — a cache-busted load that should be
served entirely from KV still pays D1 round-trips.

Wrap the per-entry/term taxonomy reads in cachedQuery:

- getEntryTerms, getTermsForEntries — namespaced under
  [content:<collection>, taxonomies]; assignments bump taxonomies and
  content writes bump content:<collection>, so they invalidate correctly.
  getEntryTerms keeps its requestCached wrapper so hydration priming still
  short-circuits within a request.
- getTerm — namespaced under taxonomies (count is TTL-bounded).
- getTermsForEntries returns a Map (not JSON-serializable): cache it as an
  array of [entryId, terms] pairs and rebuild the Map on read. Large id
  batches (which come from collection hydration, already served by the
  content cache) bypass the object cache to stay under KV's key-size limit.

getEntriesByTerm already delegates to the cached getEmDashCollection, and
getAllTermsForEntries only runs behind a content-cache miss, so neither
needs separate wrapping.

Test: with a configured backend, getEntryTerms and getTermsForEntries
serve the second read from KV with D1 made unavailable, and the Map
round-trips correctly.
…-trip

The read path did two sequential KV round-trips per cached query: read the
namespace epoch(s) to build the key, then read the value. On a cold isolate
(epochs not yet cached in-memory) a page making several cached reads paid
that doubled latency on each one.

Make the value key epoch-independent and store the namespace epochs inside
the value envelope ({ e: epochs, v: value }). A read now fetches the value
and all epochs concurrently (Promise.all) and treats it as a HIT only when
every stored epoch still matches the current one — one round-trip instead of
two. Invalidation is unchanged from the caller's view (bump the epoch; the
next read sees a mismatch and reloads), but a stale value is now overwritten
in place under its stable key rather than orphaned under a dead epoch-keyed
name — so KV no longer accumulates orphaned generations between TTL sweeps.

Note this parallelizes the epoch/value reads *within* each cached query;
ordering across a template's awaits is still the template's concern (use
Promise.all for independent reads).

Existing object-cache, content, taxonomy, and edge-cache tests pass
unchanged (behavior is identical: hit after first load, reload after
invalidation, multi-namespace busting, timeout-to-miss).
…ache

These were the last per-request D1 reads on a public post render. The
<Comments> component server-renders two reads on every page — even with
content/taxonomy reads already served from KV:

- getCollectionInfo (the commentsEnabled / supports / fields lookup), and
- getComments (approved comments), when comments are enabled.

Wrap both in cachedQuery:

- getCollectionInfo → `schema` namespace, busted by invalidateUrlPatternCache
  (every schema-mutation path already routes through it, so editing a
  collection's settings/fields invalidates it).
- getComments → `comments` namespace, busted by any CommentRepository write
  (create / status change / delete), so a new or moderated comment shows
  without waiting for TTL.

With this, a warm-isolate logged-out post render makes no D1 query — the
whole render is served from KV.

Tests: getCollectionInfo and getComments serve the second read with D1
unavailable, and reload after a schema change / comment write respectively.
@changeset-bot

changeset-bot Bot commented Jun 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a1eecf1

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

This PR includes changesets to release 16 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd 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 Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@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

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 2,636 lines across 39 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 8, 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 a1eecf1 Jun 22 2026, 05:34 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 8, 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 a1eecf1 Jun 22 2026, 05:35 PM

@pkg-pr-new

pkg-pr-new Bot commented Jun 8, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: a1eecf1

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 8, 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 a1eecf1 Jun 22 2026, 05:37 PM

@github-actions

github-actions Bot commented Jun 8, 2026

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.

@ascorbic ascorbic left a comment

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 looks great. I've not tested it yet, but it does seem like something we should be doing. Could you update it and resolve conflicts?

Comment thread packages/core/src/object-cache/index.ts Outdated
ascorbic added 5 commits June 22, 2026 08:01
# Conflicts:
#	packages/core/src/astro/integration/runtime.ts
#	packages/core/src/database/repositories/content.ts
#	packages/core/src/settings/index.ts
#	packages/core/src/taxonomies/index.ts
…alidation

An object-cache epoch read started before `invalidateObjectCache` could
resolve afterwards and unconditionally write the pre-bump backend value over
the freshly-bumped local epoch, resurrecting values the invalidation had just
orphaned (stale content served until the value TTL, default 1h). Epochs are
monotonic, so the resolved read now merges with the current cached epoch via
Math.max and never lowers it.
…path

When the value read errored or timed out, `cachedQuery` left `currentEpochs`
empty and re-read the epochs in the deferred write — *after* `load()`. A write
that invalidated the namespace mid-load would then be picked up by that re-read
and stamp the stale value under the new epoch, so a later read served it as a
HIT. The value and epoch reads now run concurrently but are awaited separately
(getEpoch never rejects), so the pre-load epochs are always captured, even when
the value read fails.
A scheduled entry becomes visible on a future clock tick, not on a write, so
an object-cache snapshot taken before its go-live time kept it hidden past that
time — until the publish sweep bumped the epoch or the value TTL (default 1h)
lapsed. getEmDashEntry now marks a resolution time-sensitive when it sees a
scheduled, not-yet-due candidate and skips caching that result.
Creating, updating, or deleting a field changes the collection's columns, but
the field handlers invalidated nothing — cached content snapshots (which embed
field values) kept serving the old shape to anonymous visitors until a content
write or the value TTL. The field create/update/delete handlers now bump the
collection's content cache. Reorder is untouched: field order isn't part of any
cached read.
ascorbic added 2 commits June 22, 2026 09:34
The object-cache codec rehydrated any object with a `$$emdashDate` string key
into a Date, so a user value that happened to carry that key alongside others
lost every other field. encode() only ever emits the tag as an object's sole
key, so decode now requires exactly one key before treating it as a tagged Date.
The memory backend computes expiry from `Date.now()`, not `performance.now()`.
@ascorbic ascorbic marked this pull request as ready for review June 22, 2026 16:51
@github-actions github-actions Bot added the review/needs-rereview Author pushed changes since the last review label Jun 22, 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.

Approach

This adds an optional, opt-in distributed read-through object cache (KV or in-isolate memory) sitting beneath requestCached and above the DB, with epoch-based invalidation. The approach is sound and fits EmDash well: epoch-stamped namespaces are the right invalidation primitive for KV (which has no prefix delete), the value+epoch parallel fetch and pre-load epoch capture correctly avoid masking mid-load writes, backend reads are timeout-bounded so a stalled KV read can't hang a render, and edit/preview/isolated-DB requests bypass. It's off by default and additive, so existing sites are unaffected — consistent with the pre-1.0 stability rule. The Discussion link is left as a placeholder in the PR body, but that's a process gap for the maintainer, not a code problem.

What I checked

  • Invalidation coverage: traced every write path — ContentRepository (create/update/delete/restore/permanentDelete/schedule/publish/unpublish/setDraftRevision/discardDraft), byline create/update/delete/setContentBylines/copyContentBylines, taxonomy create/update/delete/attach/detach/setTerms/clear/copy, menu + menu-item mutations, comment create/status/bulk/delete, SEO upsert/delete, schema field create/update/delete, settings, and the collection create/update/delete routes (which route through invalidateUrlPatternCacheinvalidateSchemaObjectCache). Content reads fold bylines+taxonomies epochs into their key, so author/term renames invalidate affected entries. All paths I checked are covered.
  • Codec/snapshot: Date preservation and the $$emdashDate single-key guard are correct; the content entrySnapshot/reviveEntry correctly drop+rebuild the edit proxy and the non-enumerable CURSOR_RAW_VALUES symbol, and sliceCollectionResult re-encodes the cursor from the raw date string so bucketed-limit pagination stays precise.
  • Scheduled content: getEmDashEntry marks a not-yet-due scheduled resolution timeSensitive and skips caching it, and cacheable is evaluated after load() — verified the closure variable can't leak across concurrent calls. Tested.
  • Concurrency: the in-flight epoch dedup, the Math.max guard preventing a stale backend epoch from reverting a concurrent bump, and the pendingBumps tick-coalescing all hold up under the test scenarios.
  • Backend wiring: virtual module, getBackend fail-open (a missing KV binding silently disables the cache, dev-only warn), KV TTL clamping, and timeout. dbIsIsolated is actually set by the playground/preview-DO middleware, so the bypass isn't a dead branch.

Conclusion

The implementation is strong and well-tested. I found one real correctness bug and one doc-accuracy concern.

The bug: getComments builds its cache key from only collection/contentId/threaded, omitting reactions and sort — both of which change the returned value. Calls that differ only in those options collide, so the second caller is served the first caller's ordering/reaction state. The built-in Comments.astro forwards both options, so this is reachable. This violates the AGENTS.md rule that a cache key must include every argument that changes the result.

The concern: the freshness/bypass wording in the runtime doc comment (mirrored in the PR description, changeset, and docs) overstates what the KV backend delivers — shouldBypass doesn't check authentication, and KV's eventual consistency means cross-isolate invalidation can lag well past revalidate's 1s.

Comment thread packages/core/src/comments/query.ts Outdated
return getCommentsWithDb(db, options);
return cachedQuery({
namespace: CacheNamespace.COMMENTS,
key: `comments:${options.collection}:${options.contentId}:${options.threaded ? "t" : "f"}`,

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.

[needs fixing] The cache key for getComments only folds in collection, contentId, and threaded, but getCommentsWithDb also branches on options.reactions (attaches reaction counts) and options.sort ("best" reorders top-level comments, implies reactions). Two calls that differ only in those options collide on the same key, so whichever call populates the entry first wins and the other is served its result. Concretely: a page rendering getComments({ collection, contentId, threaded: true }) (oldest, no reactions) and one rendering getComments({ collection, contentId, threaded: true, sort: "best" }) (or reactions: true) share comments:<c>:<id>:t; the second receives the first's oldest-ordered, reaction-less snapshot. The built-in components/Comments.astro forwards reactions/sort straight through, so this is reachable in practice. AGENTS.md is explicit that the cache key must include every argument that changes the result.

Comment on lines +182 to +184
* Authenticated, preview, and visual-edit requests always bypass the cache,
* so editors see live content immediately. Anonymous visitors may see
* content up to `revalidate` ms stale after an edit (default 1s).

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 comment (and the matching wording in the PR description, changeset, and docs/.../object-cache.mdx) makes two claims the code doesn't fully deliver for the KV backend:

  1. "Authenticated ... requests always bypass the cache." shouldBypass (object-cache/index.ts) checks only editMode, preview, and dbIsIsolated — it never inspects authentication. An authenticated editor browsing the public site outside edit/preview mode hits the cache like anyone else. That's not a correctness bug (the cache only stores published content), but the claim is inaccurate.

  2. "Anonymous visitors may see content up to revalidate ms stale after an edit (default 1s)." revalidate only bounds how long an isolate reuses its local copy of a namespace epoch. For the KV backend, cross-isolate invalidation depends on other isolates reading the freshly-bumped epoch from KV, and KV reads use the default edge cache (eventual consistency, up to ~60s) — the KV backend's get passes no cacheTtl. So the real cross-isolate staleness window is dominated by KV propagation, not revalidate, and can be far larger than 1s. Worth either documenting the KV propagation lag or passing a low cacheTtl on epoch reads.

@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 Jun 22, 2026
ascorbic added 3 commits June 22, 2026 18:26
getCommentsWithDb branches on `reactions` and `sort` ("best" reorders and
implies reactions), but the object-cache key folded in only collection,
contentId, and threaded. Two reads differing solely in those options collided,
so the second was served the first's snapshot (e.g. a `sort: "best"` render
getting oldest-ordered, reaction-less comments). The key now includes both,
normalizing the best-implies-reactions rule so identical results still share an
entry.
Reaction counts and `best` ordering are folded into cached getComments reads,
but handleReactionToggle never invalidated — so a toggled reaction left stale
counts (and stale "best" order) until a comment write or the value TTL. The
toggle handler now bumps the comments cache.
Two claims were inaccurate: `shouldBypass` checks only edit/preview/isolated
mode, not authentication, so authenticated browsing outside edit mode is served
from the cache (safe — it only stores published content); and `revalidate`
bounds only an isolate's local epoch reuse, not total cross-isolate staleness.
On KV the real window is dominated by KV's edge-cache propagation (eventually
consistent, up to ~60s) plus `revalidate`. Updated the runtime JSDoc, the
revalidate type doc, and the deployment guide to say so.
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jun 22, 2026
@ascorbic ascorbic merged commit 640e60a into main Jun 22, 2026
93 of 96 checks passed
@ascorbic ascorbic deleted the feat/object-cache branch June 22, 2026 18:37
@emdashbot emdashbot Bot mentioned this pull request Jun 22, 2026
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