perf(core): byline hydration reads options.byline_fields_version from D1 on every request

Open
#3,903 2 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
52/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Active
Tech stack
typescript

Research direction

Read src/bylines/field-defs-cache.ts and the folded fast path in src/query.ts, then inspect the content-query probes in src/loader.ts and the related decision in #1498. Compare the suggested approaches against the existing consistency behavior; done means the version check no longer adds the avoidable D1 round trip while preserving correct invalidation.

Written by the indexing model from the issue text.

Description

bot:bug bot:needs-attention
Description

When entries' bylines are hydrated on the folded fast path, hydrateEntryBylines calls getBylineFieldDefs() to find out whether any custom byline fields exist. getBylineFieldDefs() first reads options.byline_fields_version, and that read is cached per request only (requestCached). So every request that hydrates bylines runs select "value" from "options" where "name" = ? after the content query has returned. This happens even on a warm isolate whose field-defs cache is current, and even when no custom byline fields are registered. PR #1498 deferred removing this read because of the cross-isolate consistency trade-off. Below is a way to keep that consistency and still drop the round trip.

Where

[email protected], src/bylines/field-defs-cache.ts:110-112 and :127-129:

async function getBylineFieldsVersion(db: Kysely<Database>): Promise<number> {
  return requestCached(REQUEST_CACHE_KEY_VERSION, () => new BylineSchemaRegistry(db).getVersion());
}
// ...
export async function getBylineFieldDefs(db: Kysely<Database>): Promise<BylineFieldDefinition[]> {
  const isolated = getRequestContext()?.dbIsIsolated === true;
  const version = await getBylineFieldsVersion(db);

It is called from the fold fast path in src/query.ts:1537-1542:

if (!needsQueryPath && !knownEmpty) {
  try {
    const { getDb } = await import("./loader.js");
    const db = await getDb();
    const { getBylineFieldDefs } = await import("./bylines/field-defs-cache.js");
    hasCustomFields = (await getBylineFieldDefs(db)).length > 0;

On the query path it is called from BylineRepository.applyCustomFieldsTo (src/database/repositories/byline.ts:367).

Notes:

  • hydrateEntryBylines starts only after getLiveCollection() resolves (src/query.ts:910 and :951-956), so the version read can never overlap the content query.
  • The only skip is knownEmpty (src/query.ts:1520-1522), which applies when _emdash_bylines has no rows at all. After a site creates its first byline, every byline hydration pays the read, including entries that have no byline references.
  • The design note at field-defs-cache.ts:47-52 accepts "one cheap options read per request". On D1 that read is a full round trip.
Impact
  • One extra query per request that hydrates bylines, on warm isolates. When no custom fields are registered, the result is always "no custom fields", so the read gains nothing. On our site _emdash_byline_fields is empty and the version is 0.
  • On D1 each query is a round trip of about 25 ms. When nothing else is pending after the content query, the read adds that much to TTFB. An example is a single page or entry whose credits are folded, or one that has no byline references. In our traces the read always started right after the request's first content query returned.
  • With d1({ session: "auto" }) and no coalesce, which is the config the Cloudflare templates ship, a request's queries run one at a time. The read then always takes its own ~25 ms slot. We saw this in every recorded request: home, post list, category and post pages, for both Accept: text/html and */*.
  • When the byline query path runs anyway (for example the author fallback), the read overlaps it on a non-serialized binding and costs no extra time.
Suggested fix
  1. Fold the version into the content query as an uncorrelated scalar subquery. The existing _emdash_bylines_exist probe (src/loader.ts:165-169) already does this. The subquery would be (SELECT value FROM options WHERE name = 'byline_fields_version') AS _emdash_byline_fields_version. Stash the value as a non-enumerable marker. In hydrateEntryBylines, call setRequestCacheEntry("byline-fields-version", foldedVersion) before getBylineFieldDefs(). Warm isolates then issue no extra query. Invalidation stays exact, because the version is read in the same statement as the rows it applies to. A missing row (NULL) maps to 0, which matches getVersion().
    • A simpler variant: fold (SELECT 1 FROM _emdash_byline_fields LIMIT 1) AS _emdash_byline_fields_exist, and skip getBylineFieldDefs() when it is NULL. The fast path only needs hasCustomFields, so this covers the common case of no custom fields. Sites that have custom fields keep the current path.
  2. A smaller change with less gain: start the request-cached version read alongside the content query, for example void getBylineFieldsVersion(db) before getLiveCollection(). It overlaps on concurrent bindings and joins the batch with coalesce: true. It still costs a query, and it still serializes on the plain session path.
  3. For comparison: upstream chose in-memory invalidation for taxonomy definitions (#1399, CHANGELOG 0.26.0), on the grounds that "a per-request version read would merely replace the query being removed". For byline field defs that would allow cross-isolate staleness, which the current design avoids on purpose. So option 1 looks like the better fit.

Trade-offs of option 1: every content statement gains one primary-key lookup. The new subquery depends on options / _emdash_byline_fields (both created by migration 042), the same way the existing folded subqueries depend on _emdash_bylines and _emdash_content_bylines.

Related
  • #1498: the PR body lists "dropping the per-request byline-fields version read (cross-isolate consistency trade-off)" as investigated and deferred. No tracking issue found.
  • #1619: folded byline/term hydration, which introduced this fast-path probe.
  • #1399 / CHANGELOG 0.26.0: precedent for taxonomy definitions.
  • #3258: the same class of problem (per-render epoch reads for the KV object cache).

Full per-query waterfalls for the affected pages (before/after tables): https://github.com/mateusz-zadorozny/shift64-emdash-cms-benchmark/issues/11#issuecomment-6004118071

Steps to reproduce
  1. Use a D1-backed site with at least one byline (_emdash_bylines not empty) and no custom byline fields, e.g. the blog template with the seeded author.
  2. Render any page that hydrates bylines on the folded fast path, such as a list from getEmDashCollection("posts") whose entries all have explicit credits, or getEmDashCollection("pages").
  3. Log the queries (EMDASH_QUERY_LOG=1). After the content query returns, select "value" from "options" where "name" = ? with byline_fields_version runs on every request, warm isolate included.
Environment

emdash 1.1.0, @emdash-cms/cloudflare 1.1.0, Astro 6.4 (6.4.8), @astrojs/cloudflare 13.7.0, Cloudflare Workers + D1. The code is identical on main as of 2026-10-06. Traces come from a local replay of production data under wrangler dev, with 20 ms injected per D1 call (about 25 ms per query in total, the same as production).

Screenshots

Not applicable.

Dominant language
TypeScript
Stars
13.4k
Forks
1.3k
Avg merge
2d 14h
Merged PRs (30d)
365

Getting set up

This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from emdash-cms/emdash

All issues in emdash-cms/emdash

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.