Skip to content

fix(cloudflare): remove D1 ConnectionMutex on the raw-binding singleton (#2040)#2125

Open
swissky wants to merge 1 commit into
emdash-cms:mainfrom
swissky:fix/d1-deadlock-2040
Open

fix(cloudflare): remove D1 ConnectionMutex on the raw-binding singleton (#2040)#2125
swissky wants to merge 1 commit into
emdash-cms:mainfrom
swissky:fix/d1-deadlock-2040

Conversation

@swissky

@swissky swissky commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

With the default D1 config (d1({ binding: "DB" }), sessions disabled) all runtime queries go through one module-scope Kysely singleton. Its dialect (EmDashD1Dialect → kysely-d1) uses Kysely's SqliteAdapter, which reports supportsMultipleConnections: false, so Kysely's RuntimeDriver serializes every query behind a ConnectionMutex (acquire → execute → release). On Workers, when a request is canceled while its query is in flight, the pending I/O promise never settles, so releaseLock() never runs — and every later obtainLock() waits forever. One canceled request (a visitor clicking away on a slow cold page is enough) permanently deadlocks the whole isolate.

The mutex protects nothing on the raw binding: kysely-d1's D1Connection rejects transactions outright, releaseConnection is a no-op, and concurrent prepare().all() calls are independent subrequests. This PR adds RawBindingD1Dialect — identical to EmDashD1Dialect except its adapter reports supportsMultipleConnections: true — and uses it for the raw-binding singleton in createDialect. This is the same override the existing CoalescingD1Adapter already applies, scoped to the path where it's safe.

The fix is deliberately scoped to the raw-binding path. The session-backed non-coalesce path in createRequestScopedDb also builds an EmDashD1Dialect, and there the mutex is load-bearing: a D1DatabaseSession advances its bookmark per executed query, and concurrent physical calls on one session could interleave the bookmark and persist a stale one at commit(), breaking read-your-writes. That path keeps the stock adapter (mutex) — verified by a dedicated test.

Closes #2040

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
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • 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). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Cursor + Kimi K3

Screenshots / test output

Test Files  17 passed (17)
     Tests  228 passed (228)

New regression tests prove (via an in-flight overlap counter) that concurrent queries on the raw-binding dialect overlap (maxInFlight === 2) instead of serializing, while the session-backed EmDashD1Dialect still reports supportsMultipleConnections: false and still serializes (maxInFlight === 1), preserving the session bookmark invariant.

With the default D1 config (sessions disabled) all runtime queries go
through one module-scope Kysely singleton whose dialect reports
supportsMultipleConnections: false, so Kysely serializes every query
behind a ConnectionMutex. On Workers a request canceled mid-query never
settles its promise, so releaseLock() never runs and every later
obtainLock() waits forever — one canceled request deadlocks the isolate
(emdash-cms#2040).

The mutex protects nothing on the raw binding: kysely-d1 rejects
transactions and concurrent prepare().all() calls are independent
subrequests. Add RawBindingD1Dialect, identical to EmDashD1Dialect except
it reports supportsMultipleConnections: true, and use it for the
raw-binding singleton in createDialect. The session-backed dialects keep
their serialization (the non-coalesce path relies on the mutex to keep a
D1DatabaseSession's bookmark single-in-flight).

Fixes emdash-cms#2040
@changeset-bot

changeset-bot Bot commented Jul 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 384fb66

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

This PR includes changesets to release 16 packages
Name Type
@emdash-cms/cloudflare Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
emdash Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@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

@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet area/cloudflare size/M labels Jul 18, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jul 18, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-verification

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-verification@2125

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 384fb66

@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.

This is the right fix for #2040, scoped correctly and implemented cleanly.

Approach judgment: The deadlock is real: Kysely's RuntimeDriver serializes queries when the adapter reports supportsMultipleConnections: false. On Workers, a cancelled request can strand the ConnectionMutex, deadlocking the whole isolate. The raw D1 binding has no transaction support, no meaningful connection lifecycle, and no session bookmark to protect, so the mutex there provides no benefit while creating a catastrophic failure mode. Keeping the mutex on the session-backed path (which advances a D1 bookmark per query and must not interleave physical calls) is the correct boundary. Adding a separate RawBindingD1Dialect rather than mutating EmDashD1Dialect preserves that invariant and mirrors the override already used by CoalescingD1Adapter.

What I checked:

  • All changed hunks in d1-dialect.ts, d1.ts, the new test file, and the changeset.
  • Cross-referenced against coalescing-d1.ts to confirm the supportsMultipleConnections: true override pattern is already established and safe there.
  • Verified createRequestScopedDb still uses EmDashD1Dialect for the plain session path and CoalescingD1Dialect for the coalescing session path, so the mutex is preserved where load-bearing.
  • Checked AGENTS.md conventions: no new user-facing strings, no SQL interpolation, no content-table queries, no migrations, no API routes, no authorization changes, no new dependencies, changeset present and user-facing.

Headline conclusion: The change is minimal, consistent with existing patterns, and well-defended by the new regression tests (raw binding overlaps, session dialect still serializes). I found no blockers or correctness issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/cloudflare review/approved Approved; no new commits since size/M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Default D1 config: one canceled request permanently deadlocks all queries on the isolate (site-wide hang)

1 participant