Skip to content

perf(cloudflare): opt-in coalescing of same-turn SELECTs into one D1 batch() round trip#1410

Merged
ascorbic merged 5 commits into
mainfrom
perf/d1-query-coalescing
Jun 11, 2026
Merged

perf(cloudflare): opt-in coalescing of same-turn SELECTs into one D1 batch() round trip#1410
ascorbic merged 5 commits into
mainfrom
perf/d1-query-coalescing

Conversation

@ascorbic

@ascorbic ascorbic commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Adds an experimental, opt-in coalescing driver for D1: SELECT queries issued in the same event-loop turn are buffered and executed as a single D1Database.batch() call — one HTTP round trip instead of N. Production pages currently issue 5–7 fully serialized queries at 15–40ms each; with coalescing, queries that are issued concurrently (e.g. Promise.all in templates, parallel component renders) collapse into one round trip.

emdash({
	database: d1({ binding: "DB", session: "auto", coalesce: true }),
});

Design constraints (deliberately conservative):

  • SELECT-only. Writes, WITH (SQLite allows CTEs on writes), PRAGMA, EXPLAIN — everything else takes the direct path immediately, with kysely-d1's exact result mapping.
  • Per-request session only. The coalescing Kysely (and its buffer) is created per request in createRequestScopedDb, so cross-request buffering is impossible by construction. The shared singleton never coalesces. session must be enabled.
  • Atomic-batch error fallback. D1 batches are atomic — one bad statement rejects the whole call. On batch failure every buffered statement is re-executed individually (safe: all SELECTs), so innocent queries resolve and only the failing one rejects with its own error. Per-query error semantics preserved.
  • Flush window is setTimeout(0) (macrotask) — Kysely awaits internally between connection acquisition and execution, so a microtask window closes before sibling queries arrive. A lone query skips batch() and executes directly. Queries arriving mid-batch open a fresh window.

Notable finding while implementing: kysely's SqliteAdapter reports supportsMultipleConnections: false, which makes Kysely's RuntimeDriver serialize every query behind a connection mutex — even Promise.all'd queries never run concurrently against D1 today. The coalescing dialect overrides this (its shared connection is concurrent-safe by design; transactions throw regardless). This mutex is plausibly a contributor to the fully-serialized query waterfalls observed in production even where templates already parallelize.

Refactor note: EmDashD1Dialect moved from d1.ts to a new d1-dialect.ts to avoid a circular ESM import (the coalescing dialect extends it while d1.ts imports the coalescing dialect); behavior unchanged.

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 (cloudflare + core)
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change) — 7 new tests; full @emdash-cms/cloudflare suite 162/162
  • 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) — minor, @emdash-cms/cloudflare
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/... Opt-in experimental flag on existing d1() config — flagged in the description for a maintainer call on whether this needs a Discussion.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Fable 5 (Claude Code)

Screenshots / test output

New mock-D1 test suite covers: same-turn coalescing in issue order with per-caller rows and preserved bind params; sequential awaits never batch; writes + WITH execute immediately (order-asserted) while sibling SELECTs still coalesce; batch rejection → individual fallback with the underlying error; direct-path result mapping (rows / numAffectedRows / insertId, 0 changes → undefined); new window after each flush; transactions rejected.

🤖 Generated with Claude Code


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 perf/d1-query-coalescing. Updated automatically when the playground redeploys.

Adds an experimental `coalesce` option to the d1() adapter. When enabled,
SELECT queries issued in the same event-loop turn on the per-request
session Kysely are buffered and executed as one D1 batch() round trip
instead of N fully-serialized HTTP calls (production shows 5-7 queries
per page at 15-40ms each).

- CoalescingD1Connection buffers SELECTs and flushes on a setTimeout(0)
  macrotask; non-SELECTs (including WITH, since SQLite allows CTEs on
  writes) take the direct kysely-d1-identical path immediately.
- D1 batches are atomic, so on batch() rejection the buffered SELECTs
  are re-run individually to preserve per-query error semantics.
- CoalescingD1Adapter reports supportsMultipleConnections: true —
  without it Kysely's RuntimeDriver connection mutex serializes every
  query and nothing can ever coalesce.
- Only the per-request session db coalesces; the shared singleton never
  does (concurrent requests would share a buffer).
- EmDashD1Dialect moved to db/d1-dialect.ts so the coalescing dialect
  can extend it without a circular import or pulling cloudflare:workers
  into tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4f1bcd2

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

This PR includes changesets to release 14 packages
Name Type
@emdash-cms/cloudflare Minor
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
emdash Minor
@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/sandbox-workerd Patch
@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

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 11, 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 4f1bcd2 Jun 11 2026, 04:58 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 11, 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 4f1bcd2 Jun 11 2026, 04:56 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 11, 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 4f1bcd2 Jun 11 2026, 04:57 PM

@pkg-pr-new

pkg-pr-new Bot commented Jun 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 4f1bcd2

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 524 lines across 6 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.

cache-demo and blog-demo opt into d1({ coalesce: true }) so the
before/after numbers for same-turn SELECT batching come from real
deployments before any default-on decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ascorbic

Copy link
Copy Markdown
Collaborator Author

Added coalesce: true to the two benchmark sites (infra/cache-demo, infra/blog-demo) in f9791b1, so merging this PR turns coalescing on for both deployments and the before/after comparison is direct. Pre-merge baseline for cache-demo (from London, worker remote-LHR, ~20ms/query): warm-isolate MISS render 148–250ms with 6–10 fully serialized queries (db span = entire middleware window), cold-isolate MISS 945–1075ms total. Expected post-merge: db span collapses to 1–2 batch windows; warm-MISS render roughly 60–100ms. I'll re-run the identical battery after deploy.

@ascorbic ascorbic marked this pull request as ready for review June 11, 2026 14:26
Copilot AI review requested due to automatic review settings June 11, 2026 14:26
@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Jun 11, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jun 11, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI 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.

Pull request overview

This PR introduces an experimental, opt-in D1 “coalescing” dialect/driver for Cloudflare that batches multiple same-turn SELECT queries into a single D1Database.batch() call to reduce round trips, and wires it into the per-request (session-backed) database path.

Changes:

  • Add coalesce?: boolean to the Cloudflare d1() adapter config and route per-request session DB traffic through a new CoalescingD1Dialect when enabled.
  • Introduce the coalescing dialect/driver implementation plus a refactor moving EmDashD1Dialect into its own module to avoid circular imports.
  • Add a dedicated test suite for the coalescing behavior and enable the flag in infra demos.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/cloudflare/tests/db/coalescing-d1.test.ts Adds unit tests covering same-turn SELECT coalescing, non-SELECT passthrough, batch failure fallback, and transaction rejection.
packages/cloudflare/src/index.ts Documents new coalesce option on D1Config.
packages/cloudflare/src/db/d1.ts Adds runtime coalesce config and selects between EmDashD1Dialect vs CoalescingD1Dialect for request-scoped DB.
packages/cloudflare/src/db/d1-dialect.ts New module containing EmDashD1Dialect (moved from d1.ts).
packages/cloudflare/src/db/coalescing-d1.ts New coalescing driver/dialect implementation that buffers SELECTs and flushes via batch().
infra/cache-demo/astro.config.mjs Enables coalesce: true in the cache demo config.
infra/blog-demo/astro.config.mjs Enables coalesce: true in the blog demo config.
.changeset/dirty-pugs-attack.md Publishes the new experimental coalesce option as a minor change for @emdash-cms/cloudflare.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +124 to +133
await Promise.all(
pending.map(async (p) => {
try {
p.resolve(mapD1Result(await p.statement.all()));
} catch (error) {
p.reject(error);
}
}),
);
return;
entry.reject(error);
}
} else {
entry.reject(new Error("D1 batch() returned no result for statement"));
Comment thread packages/cloudflare/src/index.ts Outdated
Comment on lines +77 to +81
* SELECT queries issued in the same event-loop turn are buffered and
* executed as a single D1 `batch()` call (one HTTP round trip) instead
* of N serialized round trips. Writes, CTEs and other statements always
* execute immediately. If the batch fails, queries are retried
* individually so each query keeps its own error semantics.
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Jun 11, 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 and implementation look solid. The coalescing driver solves a real production problem (serialized D1 query waterfalls) with a conservative, safe design: SELECT-only buffering, per-request scope, atomic-batch fallback to individual execution, and explicit transaction rejection. Tests cover the happy path, sequential vs. concurrent queries, writes bypassing the buffer, batch failure fallback, and result-shape parity.

One suggestion: coalesce silently does nothing when session is disabled (including the default where session is omitted). Adding a small runtime validation or warning would prevent the foot-gun of d1({ binding: "DB", coalesce: true }) appearing to be enabled while falling back to the singleton path. The code is otherwise clean, well-tested, and follows EmDash conventions.

…terministic fallback, richer errors

- Warn at startup when coalesce: true is set without sessions enabled
  (it would otherwise be a silent no-op).
- Batch-failure fallback now re-runs statements sequentially in issue
  order instead of concurrently.
- Missing-batch-result rejection includes the statement index and SQL.
- Document the read-delay ordering caveat on the coalesce option.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ascorbic

Copy link
Copy Markdown
Collaborator Author

All three review items addressed in 4c42244:

  • Silent no-op foot-gun (emdashbot)createDialect now warns at startup when coalesce: true is set without sessions enabled: [emdash] d1({ coalesce: true }) has no effect without sessions — set session: "auto" (or "primary-first") to enable query coalescing. Chose a warning over a hard error since the config is otherwise valid and functional. (No unit test for the warn itself — it lives in the cloudflare:workers-importing module that's deliberately kept out of the test import graph.)
  • Fallback determinism (Copilot) — the batch-failure fallback now re-executes statements sequentially in issue order rather than via Promise.all; it's an error path where determinism beats latency, and it avoids piling concurrent retries onto a database that just failed.
  • Debuggability (Copilot)PendingQuery carries the SQL text and the missing-result rejection now names the statement index and SQL.
  • Ordering caveat (Copilot) — documented on the coalesce option: buffered reads execute at the flush window while writes execute immediately, so a read and write issued concurrently in the same turn may run write-first; sequential await code is unaffected.

Cloudflare suite 162/162, typecheck/lint/format clean.

@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 11, 2026
…iver

The coalescing driver overrides supportsMultipleConnections to true so
same-turn SELECTs can reach the buffer together, but that also removed
Kysely's connection mutex for writes and direct-path statements. A write
issued in the same turn could overlap the buffered SELECT batch on the
shared D1DatabaseSession, interleaving the session bookmark and breaking
read-your-writes.

Route every physical session call (direct-path .all(), single-SELECT,
batch(), and the per-statement fallback) through one promise-chain
serializer so only one is ever in flight. SELECTs still coalesce into a
single batch; a same-turn write enqueues first and the batch chains
behind it, never overlapping.

Also warn once when coalesce is enabled but the binding cannot do
sessions at runtime, and clarify the ordering docs.
@ascorbic ascorbic merged commit aa815aa into main Jun 11, 2026
69 of 70 checks passed
@ascorbic ascorbic deleted the perf/d1-query-coalescing branch June 11, 2026 19:30
@emdashbot emdashbot Bot mentioned this pull request Jun 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/cloudflare bot:review Trigger an emdashbot code review on this PR cla: signed review/needs-rereview Author pushed changes since the last review size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants