Skip to content

fix(cloudflare): reclaim stranded coalescing flush after request cancel (#1927)#2135

Merged
ascorbic merged 2 commits into
emdash-cms:mainfrom
swissky:fix/coalescing-flush-cancellation
Jul 20, 2026
Merged

fix(cloudflare): reclaim stranded coalescing flush after request cancel (#1927)#2135
ascorbic merged 2 commits into
emdash-cms:mainfrom
swissky:fix/coalescing-flush-cancellation

Conversation

@swissky

@swissky swissky commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes a hang where a request cancelled mid-query on Cloudflare D1 could wedge the query-coalescing buffer. On workerd, a request that is cancelled after queueing a coalesced query drops its pending setTimeout(0), so the flush it scheduled never fires and #flushScheduled stays true — every later query on the connection then awaits a flush that never runs. This is the same hazard class that single-flight-cache.ts already documents and defends against; the two coalescing connections had escaped that hardening.

The flush flag is now reclaimable: each schedule stamps a short deadline (1s — a live setTimeout(0) fires within a turn, so this only ever trips on a genuinely dropped timer), and if that deadline lapses with the flag still set, the next caller clears the stale flag and reschedules instead of queueing behind a dead timer. Applied to both the coalescing D1 connection and the Durable Object SQL connection (the latter is long-lived and its buffer can be shared across requests, so it is the more exposed of the two).

Context: this came up alongside #2040 (the default-config ConnectionMutex deadlock, fixed in #2125). As analysed in #1927, the coalescing connections are not the default path and are mostly short-lived/per-request, but the hazard is real and the fix is cheap defence-in-depth.

Closes #1927

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 + Claude (Kimi K3)

Screenshots / test output

New regression tests in packages/cloudflare/tests/db/coalescing-d1.test.ts (cancellation-safe flush (#1927)): one reclaims a stranded flush so a later query is not left hanging, one confirms a live flush within its deadline is not prematurely reclaimed. Full tests/db suite passes (165 tests).

…el (emdash-cms#1927)

On workerd a request cancelled after queueing a coalesced query drops its
pending setTimeout(0), so the flush it scheduled never fires and
#flushScheduled stays true — every later query on the connection awaits a
flush that never runs (the same hazard class single-flight-cache.ts
already guards). Make the flush flag reclaimable: each schedule stamps a
deadline, and if it lapses with the flag still set the next caller clears
the stale flag and reschedules rather than queueing behind a dead timer.
Applied to both the D1 and Durable Object SQL coalescing connections.
@changeset-bot

changeset-bot Bot commented Jul 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6538d33

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

@pkg-pr-new

pkg-pr-new Bot commented Jul 19, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-verification

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 6538d33

@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Jul 19, 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.

This is the right fix applied in the right place. The bug it addresses—a cancelled workerd request dropping its pending setTimeout(0) and leaving #flushScheduled stuck true—is the same cancellation hazard already documented and mitigated by single-flight-cache.ts / init-lock.ts. A 1 s reclaim deadline is cheap, generous defense-in-depth for a setTimeout(0) flush, and the logic is safe: reclaiming clears only a stale flag, queued queries stay in the buffer, and a late-arriving original timer just drains an empty buffer.

What I checked:

  • Read the diff and the full coalescing-d1.ts, coalescing-do-sql.ts, d1.ts, do-sql.ts, and both test files.
  • Traced the reclaim flow against race conditions (reclaim while a live flush is in flight, double-reclaim, queries arriving between flushScheduled = false and splice). No correctness issues.
  • Verified the changeset is scoped to @emdash-cms/cloudflare as a patch.
  • Confirmed AGENTS.md conventions are not violated (no UI/i18n/SQL/API/authorization/locale-filter/index concerns in this change).
  • Confirmed the PR includes regression tests for D1; DO SQL uses the same pattern but was not covered.

Headline conclusion: the fix is sound and well-tested for D1. Two minor follow-ups are worth doing before merge: keep the D1/DO SQL implementations structurally consistent, and add the same DO SQL regression tests so both fixed paths are verified.


Findings

  • [suggestion] packages/cloudflare/src/db/coalescing-do-sql.ts:145-147

    The DO SQL connection inlines the stale-flush reclaim check that CoalescingD1Connection extracts into #reclaimStrandedFlush. Since the two dialects intentionally mirror each other, using the same structure reduces the chance a future edit updates one but not the other.

    		if (this.#flushScheduled) {
    			this.#reclaimStrandedFlush();
    			if (this.#flushScheduled) return;
    		}
    
  • [suggestion] packages/cloudflare/tests/db/coalescing-do-sql.test.ts:155

    The cancellation-safe flush fix is also applied to CoalescingDOSqlConnection, but the new regression tests only cover CoalescingD1Connection. This file already has a convenient setup() helper, so adding the same two stranded/live-flush cases here would verify both paths mentioned in the bug report (#1927).

    	describe("cancellation-safe flush (#1927)", () => {
    		afterEach(() => {
    			vi.useRealTimers();
    		});
    
    		it("reclaims a stranded flush so a later query is not left hanging", async () => {
    			vi.useFakeTimers();
    			const { batchQuery, dialect } = setup({
    				batchQuery: vi.fn().mockResolvedValue([{ rows: [{ id: 1 }] }] as DOQueryResult[]),
    			});
    			const conn = await dialect.createDriver().acquireConnection();
    
    			const first = conn.executeQuery(CompiledQuery.raw("select 1"));
    			vi.clearAllTimers();
    			await vi.advanceTimersByTimeAsync(2_000);
    
    			const second = conn.executeQuery(CompiledQuery.raw("select 1"));
    			await vi.runAllTimersAsync();
    
    			await expect(first).resolves.toMatchObject({ rows: [{ id: 1 }] });
    			await expect(second).resolves.toMatchObject({ rows: [{ id: 1 }] });
    		});
    
    		it("does not reclaim a live flush within its deadline", async () => {
    			vi.useFakeTimers();
    			const { batchQuery, dialect } = setup();
    			const conn = await dialect.createDriver().acquireConnection();
    
    			const p1 = conn.executeQuery(CompiledQuery.raw("select 1"));
    			const p2 = conn.executeQuery(CompiledQuery.raw("select 2"));
    			await vi.runAllTimersAsync();
    			await Promise.all([p1, p2]);
    
    			expect(batchQuery).toHaveBeenCalledTimes(1);
    			expect(batchQuery.mock.calls[0]![0]).toEqual([
    				{ sql: "select 1", params: [] },
    				{ sql: "select 2", params: [] },
    			]);
    		});
    	});
    

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 19, 2026
Addressing emdashbot review on emdash-cms#2135: extract the stranded-flush reclaim
into a #reclaimStrandedFlush method on the DO SQL connection so it mirrors
CoalescingD1Connection structurally (future edits can't drift one but not
the other), and add the same two cancellation-safe flush regression tests
for CoalescingDOSqlConnection so both fixed paths are verified (emdash-cms#1927).
@github-actions github-actions Bot added size/L review/needs-rereview Author pushed changes since the last review and removed size/M review/needs-review No maintainer or bot review yet labels Jul 19, 2026
@swissky

swissky commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

@emdashbot both suggestions addressed in 6538d33:

Structural parity (coalescing-do-sql.ts:145-147): the DO SQL connection now extracts the stale-flush check into a #reclaimStrandedFlush() method mirroring CoalescingD1Connection, so a future edit updates one shape, not two divergent inlines.

DO SQL regression coverage (coalescing-do-sql.test.ts): added the same cancellation-safe flush (#1927) describe block to the DO SQL suite — one test reclaims a stranded flush so a later query is not left hanging (the stranded query stays buffered and coalesces with the reclaiming query into a single two-statement batch), one confirms a live flush within its deadline is not prematurely reclaimed.

Full tests/db suite: 167 passed. Typecheck + lint clean.

@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Jul 19, 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.

This is a re-review of the Cloudflare coalescing-flush cancellation fix. Both of the prior review's suggestions have been addressed, and the change remains the right fix in the right place.

What changed since the last review:

  • packages/cloudflare/src/db/coalescing-do-sql.ts now extracts the stale-flush reclaim check into its own #reclaimStrandedFlush() method, matching the structure in coalescing-d1.ts. The two dialect implementations are now structurally consistent.
  • packages/cloudflare/tests/db/coalescing-do-sql.test.ts now has the same two regression tests — one that reclaims a stranded flush so a later query is not left hanging, and one that confirms a live flush within its deadline is not prematurely reclaimed.

What I checked:

  • Re-read the diff and the full coalescing-d1.ts, coalescing-do-sql.ts, and both test files.
  • Re-traced the reclaim race: a genuinely dropped timer leaves #flushScheduled stuck until the next caller passes the deadline, clears the flag, and reschedules. A late-arriving original timer clears the flag and drains an empty buffer; this can create a harmless extra empty flush but does not lose queries or break ordering.
  • Verified the approach is consistent with the existing single-flight-cache.ts / init-lock.ts reclaimable-lock pattern in packages/core.
  • Verified the changeset is present, scoped to @emdash-cms/cloudflare as a patch, and written as user-facing release notes.
  • Confirmed no AGENTS.md conventions are violated: no UI/i18n strings, no SQL interpolation, no API/auth/locale/index/migration changes.

Headline conclusion: the fix is sound, both code paths are now consistent, and both are covered by regression tests. LGTM.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 19, 2026
@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Jul 19, 2026

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice

@ascorbic
ascorbic merged commit c37750c into emdash-cms:main Jul 20, 2026
47 checks passed
@emdashbot emdashbot Bot mentioned this pull request Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CoalescingD1Connection: a cancelled request strands the shared flush, wedging all subsequent queries isolate-wide

2 participants