Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .changeset/perf-taxonomy-defs-isolate-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"emdash": patch
---

Cut per-render D1 round trips on public pages by caching taxonomy definitions across the worker isolate, and harden the runtime/DB singletons against bundler module duplication.

Every public render that hydrates entry terms read `SELECT * FROM _emdash_taxonomy_defs` (via `getTaxonomyDefs` → `getCollectionTaxonomyNames`), which only had per-request caching. On Cloudflare D1, where the worker colo is often far from the database primary, each query is a ~40ms cross-region round trip, so this fired on every warm request for no benefit — taxonomy _definitions_ change extremely rarely (created via the admin API or a seed; there is no edit/delete-def path). They're now cached per-isolate behind a `globalThis` Symbol holder (the same two-tier pattern as `settings/index.ts` and the byline field-defs cache), keyed by resolved locale and invalidated in-memory by every def write (`handleTaxonomyCreate`, seed application). Invalidation is in-memory rather than a persisted version probe on purpose: a per-request version read would merely replace the query being removed, yielding no net saving on warm isolates. Isolated databases (playground / DO preview) bypass the cache.

Separately, the cached runtime instance, the DB-instance cache, and the in-flight DB-init promise (`astro/middleware.ts`, `emdash-runtime.ts`) were plain module-scoped variables. Under Vite SSR chunk duplication those can become multiple independent copies, letting cold-start migrations and bootstrap reads re-run on requests that should have hit the warm cache. They now live on `globalThis` behind Symbol keys, matching the existing `SETUP_VERIFIED_KEY` / request-context / request-cache singletons.

No schema changes, no public API changes, fully backwards compatible.
6 changes: 5 additions & 1 deletion packages/core/src/api/handlers/taxonomies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { ulid } from "ulidx";

import { TaxonomyRepository } from "../../database/repositories/taxonomy.js";
import type { Database, TaxonomyDefTable } from "../../database/types.js";
import { invalidateTermCache } from "../../taxonomies/index.js";
import { invalidateTaxonomyDefsCache, invalidateTermCache } from "../../taxonomies/index.js";
import type { ApiResult } from "../types.js";

const NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
Expand Down Expand Up @@ -290,6 +290,10 @@ export async function handleTaxonomyCreate(
})
.execute();

// A new def changes which taxonomies exist — drop the isolate-wide
// defs/names caches so this isolate reflects it immediately.
invalidateTaxonomyDefsCache();

const row = await db
.selectFrom("_emdash_taxonomy_defs")
.selectAll()
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,18 @@ async function getTaxonomyNames(db: Kysely<Database>): Promise<Set<string>> {
}
}

/**
* Reset the module-scoped taxonomy-names cache.
*
* Called from `invalidateTaxonomyDefsCache()` so that creating or seeding a
* taxonomy definition is reflected within the current isolate instead of
* waiting for the isolate to recycle. Keeps this cache consistent with the
* isolate-wide taxonomy-defs cache in `taxonomies/index.ts`.
*/
export function resetTaxonomyNamesCache(): void {
taxonomyNames = null;
}

/**
* System columns to include in data (mapped to camelCase where needed)
*/
Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/object-cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ async function getBackend(): Promise<ObjectCacheBackend | null> {
} catch (error) {
// Importing the virtual module fails outside an Astro/Vite context
// (e.g. unit tests, CLI). Treat as "no cache configured".
if (import.meta.env.DEV) {
if (import.meta.env?.DEV) {
console.warn("[object-cache] backend unavailable:", error);
}
holder.backend = null;
Expand Down Expand Up @@ -378,7 +378,7 @@ export async function cachedQuery<T>(options: CachedQueryOptions<T>): Promise<T>
const encoded = encode({ e: currentEpochs, v: value } satisfies CacheEnvelope<T>);
await backend.set(fullKey, encoded, ttl);
} catch (error) {
if (import.meta.env.DEV) {
if (import.meta.env?.DEV) {
console.warn("[object-cache] set failed:", error);
}
}
Expand All @@ -388,6 +388,12 @@ export async function cachedQuery<T>(options: CachedQueryOptions<T>): Promise<T>
return value;
}

/** Whether object-cache reads are active for the current request. */
export async function isObjectCacheActive(): Promise<boolean> {
const backend = await getBackend();
return backend !== null && !shouldBypass();
}

/**
* Invalidate every cached value in `namespace` by bumping its epoch.
*
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/seed/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,12 @@ export async function applySeed(
}
}
}

// Seeded/updated defs change which taxonomies exist — clear the
// isolate-wide defs + names caches so later reads in this isolate
// (e.g. an auto-seed triggered mid-request) reflect them immediately.
const { invalidateTaxonomyDefsCache } = await import("../taxonomies/index.js");
invalidateTaxonomyDefsCache();
}

// 6. Bylines
Expand Down
139 changes: 121 additions & 18 deletions packages/core/src/taxonomies/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@
*/

import { resolveLocale, resolveLocaleChain } from "../i18n/resolve.js";
import { getDb } from "../loader.js";
import { getDb, resetTaxonomyNamesCache } from "../loader.js";
import {
cachedQuery,
CacheNamespace,
contentNamespace,
invalidateTaxonomyObjectCache,
isObjectCacheActive,
} from "../object-cache/index.js";
import { peekRequestCache, requestCached, setRequestCacheEntry } from "../request-cache.js";
import { getRequestContext } from "../request-context.js";
import { chunks, SQL_BATCH_SIZE } from "../utils/chunks.js";
import { isMissingTableError } from "../utils/db-errors.js";
import type { TaxonomyDef, TaxonomyTerm, TaxonomyTermRow } from "./types.js";
Expand All @@ -28,35 +30,136 @@ export interface TaxonomyQueryOptions {
locale?: string;
}

/** Invalidate cached taxonomy term data and any content that hydrates terms. */
export function invalidateTermCache(): void {
invalidateTaxonomyObjectCache();
}

/**
* Invalidate cached taxonomy data in the distributed object cache (and any
* content that hydrates taxonomy terms). The legacy in-isolate term cache was
* removed, so this used to be a no-op; it now drives object-cache invalidation.
* Worker-isolate cache for taxonomy definitions, keyed by resolved locale.
*
* Taxonomy *definitions* (the "category"/"tag" taxonomies themselves, not
* their terms) are read on every public render that hydrates entry terms —
* `getAllTermsForEntries` → `getCollectionTaxonomyNames` → `getTaxonomyDefs` —
* but change extremely rarely: they're created via the admin API or applied
* from a seed, and there is no edit/delete-def path. Caching them across the
* isolate lifetime drops the per-render `SELECT * FROM _emdash_taxonomy_defs`
* to once-per-isolate.
*
* Stored on globalThis behind a Symbol key (same pattern as
* `settings/index.ts`) so the bundler duplicating this module across SSR
* chunks can't produce two independent caches.
*
* When the distributed object cache is configured, it is authoritative and this
* fallback is bypassed so cross-isolate epoch invalidation cannot be undercut
* by stale per-isolate data on an object-cache miss.
*
* **Isolated databases bypass the cache.** Playground / DO preview requests
* set `requestContext.dbIsIsolated`; they point at a divergent schema, so we
* skip both reading and writing the global holder and fall back to the
* per-request cache (same precedent as `getTaxonomyNames` / byline field defs).
*/
export function invalidateTermCache(): void {
interface TaxonomyDefsHolder {
version: number;
/** locale key ("*" for "all locales") → { version it was fetched at, promise }. */
cache: Map<string, { version: number; promise: Promise<TaxonomyDef[]> }>;
}

const TAXONOMY_DEFS_CACHE_KEY = Symbol.for("emdash:taxonomy-defs");
const taxonomyDefsStore = globalThis as Record<symbol, unknown>;
const defsHolder: TaxonomyDefsHolder =
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis singleton pattern (see settings/index.ts)
(taxonomyDefsStore[TAXONOMY_DEFS_CACHE_KEY] as TaxonomyDefsHolder | undefined) ??
(() => {
const h: TaxonomyDefsHolder = { version: 0, cache: new Map() };
taxonomyDefsStore[TAXONOMY_DEFS_CACHE_KEY] = h;
return h;
})();

/**
* Invalidate the isolate-wide taxonomy-definitions cache (and the related
* loader taxonomy-names cache). Called from every taxonomy-def write path
* (`handleTaxonomyCreate`, seed application). Other isolates refresh on their
* next recycle — staleness bounded by isolate lifetime.
*/
export function invalidateTaxonomyDefsCache(): void {
defsHolder.version++;
defsHolder.cache.clear();
resetTaxonomyNamesCache();
invalidateTaxonomyObjectCache();
}

/**
* Test/internal helper: clear the per-isolate taxonomy-defs cache. Useful for
* unit tests that insert defs directly and need to force a refetch without
* going through a write path. Production code should rely on
* `invalidateTaxonomyDefsCache()`.
*/
export function resetTaxonomyDefsCacheForTests(): void {
defsHolder.version++;
defsHolder.cache.clear();
}

/**
* Fetch taxonomy definitions straight from the database (no caching).
*/
async function fetchTaxonomyDefs(locale: string | undefined): Promise<TaxonomyDef[]> {
const db = await getDb();
let query = db.selectFrom("_emdash_taxonomy_defs").selectAll();
if (locale !== undefined) query = query.where("locale", "=", locale);
const rows = await query.execute();
return rows.map(rowToTaxonomyDef);
}

/**
* Resolve taxonomy defs through the isolate fallback cache, bypassing it for
* isolated databases. The returned promise is cached (not the resolved value)
* so concurrent cold-isolate readers share one in-flight query; a rejection
* evicts the entry so the next caller retries.
*/
function loadTaxonomyDefs(localeKey: string, locale: string | undefined): Promise<TaxonomyDef[]> {
if (getRequestContext()?.dbIsIsolated === true) {
return fetchTaxonomyDefs(locale);
}
const existing = defsHolder.cache.get(localeKey);
if (existing && existing.version === defsHolder.version) {
return existing.promise;
}
const version = defsHolder.version;
const promise = fetchTaxonomyDefs(locale).catch((error: unknown) => {
const current = defsHolder.cache.get(localeKey);
if (current && current.promise === promise) {
defsHolder.cache.delete(localeKey);
}
throw error;
});
defsHolder.cache.set(localeKey, { version, promise });
return promise;
}

/**
* Get every taxonomy definition. Definitions are per-locale (one row per
* locale inside the same translation_group) — by default we resolve to the
* active locale.
*
* Two-tier cache: per-request via `requestCached` (so a single render that
* hydrates terms for several collections pays at most one call), then
* per-isolate via the global holder (so warm renders issue zero queries).
* The `requestCached` key is unchanged so `getTaxonomyDef`'s peek still hits.
*/
export async function getTaxonomyDefs(options: TaxonomyQueryOptions = {}): Promise<TaxonomyDef[]> {
const locale = resolveLocale(options.locale);
return requestCached(`taxonomy-defs:${locale ?? "*"}`, () =>
cachedQuery({
namespace: CacheNamespace.TAXONOMIES,
key: `defs:${locale ?? "*"}`,
load: async () => {
const db = await getDb();
let query = db.selectFrom("_emdash_taxonomy_defs").selectAll();
if (locale !== undefined) query = query.where("locale", "=", locale);
const rows = await query.execute();
return rows.map(rowToTaxonomyDef);
},
}),
);
const localeKey = locale ?? "*";
return requestCached(`taxonomy-defs:${localeKey}`, async () => {
if (await isObjectCacheActive()) {
return cachedQuery({
namespace: CacheNamespace.TAXONOMIES,
key: `defs:${localeKey}`,
load: () => fetchTaxonomyDefs(locale),
});
}
return loadTaxonomyDefs(localeKey, locale);
});
}

/**
Expand Down
128 changes: 128 additions & 0 deletions packages/core/tests/unit/taxonomies/defs-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Isolate-wide taxonomy-definitions cache (perf: removes the per-render
* `SELECT * FROM _emdash_taxonomy_defs` on warm isolates).
*
* The cache lives on globalThis and is keyed by resolved locale. Because
* `requestCached` dedupes within a single request scope, we exercise the
* isolate cache by running each `getTaxonomyDefs()` call inside its own
* `runWithContext` scope (a fresh context object => a fresh per-request
* cache bucket), so a second call only avoids the DB if the *isolate*
* cache served it.
*/

import Database from "better-sqlite3";
import { Kysely, SqliteDialect } from "kysely";
import { ulid } from "ulidx";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import { runMigrations } from "../../../src/database/migrations/runner.js";
import type { Database as EmDashDatabase } from "../../../src/database/types.js";
import { runWithContext } from "../../../src/request-context.js";
import {
getTaxonomyDefs,
invalidateTaxonomyDefsCache,
resetTaxonomyDefsCacheForTests,
} from "../../../src/taxonomies/index.js";

let queryCount = 0;

function makeDb(): { db: Kysely<EmDashDatabase>; sqlite: Database.Database } {
const sqlite = new Database(":memory:");
const db = new Kysely<EmDashDatabase>({
dialect: new SqliteDialect({ database: sqlite }),
log(event) {
if (event.level === "query" && event.query.sql.includes("_emdash_taxonomy_defs")) {
queryCount += 1;
}
},
});
return { db, sqlite };
}

async function insertDef(db: Kysely<EmDashDatabase>, name: string): Promise<void> {
await db
.insertInto("_emdash_taxonomy_defs")
.values({
id: ulid(),
name,
label: name,
label_singular: null,
hierarchical: 0,
collections: JSON.stringify(["posts"]),
})
.execute();
}

/** Run a getter in a fresh per-request scope with the test db as the ALS db. */
function inScope<T>(
db: Kysely<EmDashDatabase>,
fn: () => Promise<T>,
opts?: { dbIsIsolated?: boolean },
): Promise<T> {
return runWithContext({ editMode: false, db, ...opts }, fn);
}

describe("getTaxonomyDefs — isolate cache", () => {
let db: Kysely<EmDashDatabase>;
let sqlite: Database.Database;

beforeEach(async () => {
({ db, sqlite } = makeDb());
await runMigrations(db);
// Holder lives on globalThis; reset so sibling tests don't leak the
// previous test's db/promise into this one.
resetTaxonomyDefsCacheForTests();
await insertDef(db, "genre");
queryCount = 0;
});

afterEach(async () => {
await db.destroy();
sqlite.close();
});

it("queries once per isolate, serving later requests from cache", async () => {
const first = await inScope(db, () => getTaxonomyDefs());
expect(queryCount).toBe(1);
expect(first.map((d) => d.name)).toContain("genre");

// A separate request scope: per-request cache can't help, so a second
// query would fire unless the isolate cache served it.
const second = await inScope(db, () => getTaxonomyDefs());
expect(queryCount).toBe(1);
expect(second.map((d) => d.name).toSorted()).toEqual(first.map((d) => d.name).toSorted());
});

it("re-queries after invalidateTaxonomyDefsCache()", async () => {
await inScope(db, () => getTaxonomyDefs());
expect(queryCount).toBe(1);

invalidateTaxonomyDefsCache();

await inScope(db, () => getTaxonomyDefs());
expect(queryCount).toBe(2);
});

it("reflects a newly inserted def only after invalidation (in-memory invalidation semantics)", async () => {
const before = await inScope(db, () => getTaxonomyDefs());
expect(before.map((d) => d.name)).not.toContain("topic");

await insertDef(db, "topic");

// Still cached — stale read is expected without an explicit bump.
const stale = await inScope(db, () => getTaxonomyDefs());
expect(stale.map((d) => d.name)).not.toContain("topic");

invalidateTaxonomyDefsCache();

const fresh = await inScope(db, () => getTaxonomyDefs());
expect(fresh.map((d) => d.name)).toContain("topic");
});

it("bypasses the isolate cache for isolated databases (playground / DO preview)", async () => {
await inScope(db, () => getTaxonomyDefs(), { dbIsIsolated: true });
await inScope(db, () => getTaxonomyDefs(), { dbIsIsolated: true });
// Never cached across requests => one query each.
expect(queryCount).toBe(2);
});
});
Loading
Loading