perf(cloudflare): opt-in coalescing of same-turn SELECTs into one D1 batch() round trip#1410
Conversation
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 detectedLatest commit: 4f1bcd2 The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 packages
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 |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | 4f1bcd2 | Jun 11 2026, 04:58 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | 4f1bcd2 | Jun 11 2026, 04:56 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | 4f1bcd2 | Jun 11 2026, 04:57 PM |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Scope checkThis 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>
|
Added |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
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?: booleanto the Cloudflared1()adapter config and route per-request session DB traffic through a newCoalescingD1Dialectwhen enabled. - Introduce the coalescing dialect/driver implementation plus a refactor moving
EmDashD1Dialectinto 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.
| 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")); |
| * 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. |
There was a problem hiding this comment.
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>
|
All three review items addressed in 4c42244:
Cloudflare suite 162/162, typecheck/lint/format clean. |
…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.
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.allin templates, parallel component renders) collapse into one round trip.Design constraints (deliberately conservative):
WITH(SQLite allows CTEs on writes), PRAGMA, EXPLAIN — everything else takes the direct path immediately, with kysely-d1's exact result mapping.createRequestScopedDb, so cross-request buffering is impossible by construction. The shared singleton never coalesces.sessionmust be enabled.setTimeout(0)(macrotask) — Kysely awaits internally between connection acquisition and execution, so a microtask window closes before sibling queries arrive. A lone query skipsbatch()and executes directly. Queries arriving mid-batch open a fresh window.Notable finding while implementing: kysely's
SqliteAdapterreportssupportsMultipleConnections: false, which makes Kysely'sRuntimeDriverserialize every query behind a connection mutex — evenPromise.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:
EmDashD1Dialectmoved fromd1.tsto a newd1-dialect.tsto avoid a circular ESM import (the coalescing dialect extends it whiled1.tsimports the coalescing dialect); behavior unchanged.Closes #
Type of change
Checklist
pnpm typecheckpasses (cloudflare + core)pnpm lintpassespnpm testpasses (or targeted tests for my change) — 7 new tests; full @emdash-cms/cloudflare suite 162/162pnpm formathas been runAI-generated code disclosure
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.