perf(core): byline hydration reads options.byline_fields_version from D1 on every request
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
- Domain
- backend, databases, performance
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
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:
hydrateEntryBylinesstarts only aftergetLiveCollection()resolves (src/query.ts:910and: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_bylineshas 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-52accepts "one cheapoptionsread 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_fieldsis empty and the version is0. - 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 nocoalesce, 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 bothAccept: text/htmland*/*. - 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
- Fold the version into the content query as an uncorrelated scalar subquery. The existing
_emdash_bylines_existprobe (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. InhydrateEntryBylines, callsetRequestCacheEntry("byline-fields-version", foldedVersion)beforegetBylineFieldDefs(). 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 to0, which matchesgetVersion().- A simpler variant: fold
(SELECT 1 FROM _emdash_byline_fields LIMIT 1) AS _emdash_byline_fields_exist, and skipgetBylineFieldDefs()when it is NULL. The fast path only needshasCustomFields, so this covers the common case of no custom fields. Sites that have custom fields keep the current path.
- A simpler variant: fold
- A smaller change with less gain: start the request-cached version read alongside the content query, for example
void getBylineFieldsVersion(db)beforegetLiveCollection(). It overlaps on concurrent bindings and joins the batch withcoalesce: true. It still costs a query, and it still serializes on the plain session path. - 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
- Use a D1-backed site with at least one byline (
_emdash_bylinesnot empty) and no custom byline fields, e.g. the blog template with the seeded author. - Render any page that hydrates bylines on the folded fast path, such as a list from
getEmDashCollection("posts")whose entries all have explicit credits, orgetEmDashCollection("pages"). - Log the queries (
EMDASH_QUERY_LOG=1). After the content query returns,select "value" from "options" where "name" = ?withbyline_fields_versionruns 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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from emdash-cms/emdash
-
plugin-forms: autoresponder and Reply-To use the first email field, even an optional onePossibly taken @eisenbruch claimed this 3 days ago. Openarea/plugins bot:fix triage/awaiting-reporter
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
emdash-cms/emdash#4504 · 4 comments ·
Maintainers usually reply within 1 day
-
WordPress import review labels the SEO toggle "SEO settings (Yoast)" even when the site uses Rank MathPossibly taken @iNerdStack claimed this 13 days ago. Openbot:bug bot:needs-attention
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
emdash-cms/emdash#4110 · 3 comments ·
Maintainers usually reply within 1 day
-
bot:bug bot:needs-attention bug
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
emdash-cms/emdash#3992 · 2 comments ·
Maintainers usually reply within 1 day
-
bot:bug bot:needs-attention
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
emdash-cms/emdash#3972 · 2 comments ·
Maintainers usually reply within 1 day
-
bot:needs-attention bot:task
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
emdash-cms/emdash#3821 · 1 comment ·
Maintainers usually reply within 1 day
All issues in emdash-cms/emdash
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
JuliusBrussee/caveman#1449 ·
Maintainers usually reply within 1 day
-
Difficulty 1/5 Under an hour Newbie friendliness 78/100
linagora/twake-calendar-frontend#1924 ·
Maintainers usually reply within 1 day
-
Add: Class TV ModaOpencheck:passed streams:add
Difficulty 1/5 Under an hour Newbie friendliness 65/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
libnativeapi/nativeapi#21 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 62/100
GiganticMinecraft/seichi-playguide#183 ·
Maintainers usually reply within 1 day