Skip to content

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

Draft
scottbuscemi wants to merge 6 commits into
mainfrom
feat/object-cache
Draft

feat(core): optional distributed object cache for query results#1378
scottbuscemi wants to merge 6 commits into
mainfrom
feat/object-cache

Conversation

@scottbuscemi

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. 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 (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

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: 18164d6

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

This PR includes changesets to release 14 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/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

PR template validation failed

Please fix the following issues by editing your PR description:

See CONTRIBUTING.md for the full contribution policy.

@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

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 18164d6 Jun 08 2026, 05:10 AM

@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 failed
View logs
emdash-playground 18164d6 Jun 08 2026, 05:14 AM

@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: 18164d6

@cloudflare-workers-and-pages

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 18164d6 Jun 08 2026, 05:12 AM

@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.

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.

1 participant