Skip to content

fix(core): CAS-guard the draft-revision pointer flip on concurrent saves#2130

Open
marcusbellamyshaw-cell wants to merge 5 commits into
emdash-cms:mainfrom
Emdash-Bug-Testing:fix/1158-draft-revision-save-race
Open

fix(core): CAS-guard the draft-revision pointer flip on concurrent saves#2130
marcusbellamyshaw-cell wants to merge 5 commits into
emdash-cms:mainfrom
Emdash-Bug-Testing:fix/1158-draft-revision-save-race

Conversation

@marcusbellamyshaw-cell

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes a save race reported as a follow-up comment on closed issue #1158. EmDashRuntime.handleContentUpdate's draft-revision path does read-existing → merge → create-revision → flip-pointer with no wrapping transaction (D1 has no multi-statement transactions) and an unconditional pointer-flip UPDATE. Two concurrent saves (autosave racing an explicit save, or two explicit saves) that both read draft_revision_id: null before either writes both create a new revision and race to flip the pointer — whichever UPDATE lands last wins regardless of which read the fresher data, so the other save's edit is silently discarded even though the API reports success. This matches the DCathal repro in the #1158 thread (two revisions created in the same second, the stale one becomes draft_revision_id).

Note: the original #1158 report (TipTap editor.view.dispatch bypassing onUpdate) was already fixed by #1119 and is unaffected here — this addresses the separate, still-reproducing save race described in the newest comment on that thread (0.28.1/0.29.0).

Fix: the pointer-flip UPDATE is now a compare-and-set keyed on the draft_revision_id read at the start of the attempt (COALESCE(draft_revision_id, '') = <value read>, NULL-safe across SQLite/D1 and Postgres). If the CAS fails, the losing save retries — re-reading the now-current draft, re-merging its own edit on top, and retrying the create+CAS — instead of silently overwriting. Bounded to 3 attempts. The orphaned revision row from a lost race is harmless; pruneOldRevisions sweeps it up on a later successful save since it's keyed by entry_id, not by pointer.

Known residual, out of scope for this PR: the autosave-only "update existing draft revision in place" path (skipRevision when a draft already exists) still isn't CAS-guarded — two concurrent autosaves (no explicit save involved) can still last-write-wins on that single row's data column. Closing that fully would need a version column on revisions, which felt like more surface than this fix needs; flagging in case a maintainer disagrees.

Closes (comment thread on) #1158

Type of change

  • Bug fix

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes (pre-existing unrelated errors in oauth-state-store.ts / plugins/context.ts confirmed present without this change)
  • pnpm lint passes (oxlint, scoped to changed files)
  • pnpm test passes (targeted: new race test + adjacent content/revision/media-usage integration suites, both sqlite and postgres dialects)
  • pnpm format has been run (skipped locally — CRLF checkout makes format:check unreliable on Windows; will check CI)
  • I have added/updated tests for my changes
  • I have added a changeset (patch, emdash)

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Sonnet 5

Test plan

Added packages/core/tests/integration/database/content-update-save-race.test.ts: creates a published entry, then fires two concurrent handleContentUpdate calls editing different fields, with a RevisionRepository.create spy that gates the first call until the second also arrives — forcing both to read draft_revision_id: null before either writes, regardless of driver timing. Verified the test fails against the pre-fix code (save B's edit silently dropped, matching the report) and passes with the fix (both edits survive).

@changeset-bot

changeset-bot Bot commented Jul 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a6ff8f0

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

This PR includes changesets to release 17 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke 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-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/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

@emdashbot

emdashbot Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Could not push formatting changes to this fork. The contributor may have "Allow edits by maintainers" disabled.

Please run the formatter locally:

pnpm format

@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/admin@2130

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-verification

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: a6ff8f0

marcusbellamyshaw-cell added a commit to Emdash-Bug-Testing/emdash that referenced this pull request Jul 20, 2026
CI format:check flagged this file after the PR emdash-cms#2130 diff shifted
line wrapping. Reformatted with oxfmt, no logic change.
@marcusbellamyshaw-cell
marcusbellamyshaw-cell force-pushed the fix/1158-draft-revision-save-race branch from f501b03 to 5e6438f Compare July 22, 2026 04:18
marcusbellamyshaw-cell added a commit to Emdash-Bug-Testing/emdash that referenced this pull request Jul 22, 2026
CI format:check flagged this file after the PR emdash-cms#2130 diff shifted
line wrapping. Reformatted with oxfmt, no logic change.
@github-actions

Copy link
Copy Markdown
Contributor

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

File Note
packages/admin/src/locales/ar/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/ca/messages.po Localization added, will be marked as complete. 🔄️
packages/admin/src/locales/de/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/en-GB/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/en/messages.po Source changed, localizations will be marked as outdated.
packages/admin/src/locales/es-419/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/es-ES/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/eu/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/fa/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/fr/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/hu/messages.po Localization added, will be marked as complete. 🔄️
packages/admin/src/locales/id/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/ja/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/ko/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/nb/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/nl/messages.po Localization added, will be marked as complete. 🔄️
packages/admin/src/locales/pl/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/pseudo/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/pt-BR/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/sv/messages.po Localization added, will be marked as complete. 🔄️
packages/admin/src/locales/th/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/tr/messages.po Localization added, will be marked as complete. 🔄️
packages/admin/src/locales/zh-CN/messages.po Localization changed, will be marked as complete. 🔄️
packages/admin/src/locales/zh-TW/messages.po Localization changed, will be marked as complete. 🔄️
Warnings reference
Icon Description
🔄️ The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

marcusbellamyshaw-cell and others added 3 commits July 21, 2026 23:29
Two saves to a collection with no draft yet (autosave + explicit save, or
two concurrent explicit saves) both read draft_revision_id: null, then race
an unconditional UPDATE to flip the pointer. Whichever lands last wins
regardless of which read the fresher base data, so the other save's edit is
silently discarded even though the API reports success.

D1 has no multi-statement transactions, so the pointer-flip UPDATE is now a
CAS keyed on the draft_revision_id read at the start of the attempt; a
losing save retries against the fresh pointer (re-merging its edit on top)
instead of clobbering it. Reported as a comment on emdash-cms#1158
(the original TipTap-dispatch cause of emdash-cms#1158 itself was already fixed by
0.28.1/0.29.0).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CI format:check flagged this file after the PR emdash-cms#2130 diff shifted
line wrapping. Reformatted with oxfmt, no logic change.
Branch was rebased against the fork's stale main; regenerating
against the real emdash-cms/emdash main to match the PR's actual
merge base and pick up upstream's own lockfile fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@marcusbellamyshaw-cell
marcusbellamyshaw-cell force-pushed the fix/1158-draft-revision-save-race branch from 5e6438f to 786dfa8 Compare July 22, 2026 04:32
@marcusbellamyshaw-cell
marcusbellamyshaw-cell marked this pull request as ready for review July 24, 2026 04:17
@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Jul 24, 2026
@marcusbellamyshaw-cell

Copy link
Copy Markdown
Contributor Author

All checks green, no scope-gate applies (bug fix, save-race repro from #1158). Marking ready for review.

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

The approach is the right one for the reported race: D1 has no multi-statement transactions, so a compare-and-set on draft_revision_id keyed to the value read at the start of the attempt, plus bounded retries, makes the loser re-merge on top of the fresh draft instead of clobbering it. The test is well designed—it gates both saves at RevisionRepository.create so the interleaving is deterministic across drivers, and it asserts the observable behavior (both fields survive) rather than implementation details.

However, the retry bound leaves a small but real silent-data-loss path, and there are some unrelated/scope/convention items to clean up:

  • Retry exhaustion still drops data silently. After three failed CAS attempts the loop exits with draftStorageChanged === false. Because usesDraftRevisions is already true, the following handleContentUpdate call writes metadata only (data: undefined) and the method returns success even though the caller's edit never landed. The fix should detect exhaustion and return a conflict error instead of pretending the save succeeded.
  • Unrelated lockfile entry. pnpm-lock.yaml adds demos/emdash-dev-main, but that package does not exist in the repo, so it looks like an accidental commit. Scope discipline (AGENTS.md) says no drive-by changes.
  • Comment issue/PR references. AGENTS.md prohibits issue/PR references in code comments. The new test file's header and describe title reference #1158 and #1119.
  • Sibling restore path has the same race. handleContentRestore still does an unconditional UPDATE of draft_revision_id; the same CAS guard should be applied there as a follow-up.

None of these are catastrophic, but the silent-drop case should be addressed before merge.


Findings

  • [needs fixing] packages/core/src/emdash-runtime.ts:2892-2893

    If the CAS UPDATE loses the race for all three attempts, the loop exits with draftStorageChanged still false. Because usesDraftRevisions is already true, the subsequent handleContentUpdate call updates only metadata (data: undefined) and the API returns success even though the caller's edit was never written. This keeps a bounded version of the same silent data loss the PR is meant to eliminate.

    Return a conflict error when the edit couldn't be applied instead of falling through to a metadata-only update.

    					}
    					if (
    						!draftStorageChanged &&
    						processedData &&
    						Object.keys(processedData).length > 0
    					) {
    						return {
    							success: false as const,
    							error: {
    								code: "DRAFT_SAVE_CONFLICT",
    								message: "Could not save draft due to concurrent edits; please retry.",
    							},
    						};
    					}
    				}
    
  • [needs fixing] packages/core/tests/integration/database/content-update-save-race.test.ts:2-4

    AGENTS.md prohibits issue/PR references in code comments because they become stale narrative. The test header and describe title name #1158 and #1119; keep that context in the commit message and PR description, and leave the comment for future readers.

     * Concurrent draft-revision saves should not silently drop an edit.
     *
     * `EmDashRuntime.handleContentUpdate`'s draft-revision path is a
     * read-existing -> merge -> create-revision -> flip-pointer sequence.
     * Two concurrent saves that both read `draft_revision_id: null` before
     * either writes can both create a revision and race to flip the pointer;
     * the slower save's edit is silently discarded even though the API
     * reports success.
    
  • [needs fixing] pnpm-lock.yaml:471-504

    This adds an importer entry for demos/emdash-dev-main, but that package does not exist in the repo (demos/ only contains cloudflare, playground, plugins-demo, postgres, preview, simple). It's unrelated to the save-race fix and appears to have been committed accidentally. Please remove it.

  • [suggestion] packages/core/src/emdash-runtime.ts:3292-3296

    Systemic follow-up: handleContentRestore still performs an unconditional UPDATE of draft_revision_id. Two concurrent restores can race the same way the save path did, and the loser will be silently discarded. Consider applying the same CAS-and-retry guard here (read the current draft_revision_id, flip only if it matches, and retry on conflict).

@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 Jul 24, 2026
…xhaustion

Addresses review on emdash-cms#2130: when all 3 CAS retries on the draft-revision
pointer flip lose the race, the save now returns DRAFT_SAVE_CONFLICT
instead of falling through to a metadata-only update that reported
success while discarding the edit. Also drops issue/PR references from
comments and removes an accidental pnpm-lock.yaml entry for a local-only
demos/emdash-dev-main workspace member.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@marcusbellamyshaw-cell

Copy link
Copy Markdown
Contributor Author

Addressed all "needs fixing" items:

  • CAS exhaustion no longer falls through to a metadata-only update: after 3 lost retries, the save now returns DRAFT_SAVE_CONFLICT instead of silently discarding the edit. Added a test that forces exhaustion (another save wins the pointer flip on every attempt) and asserts the conflict, not a false success.
  • Removed the #1158/#1119 references from the test file's header comment and describe title.
  • Removed the accidental pnpm-lock.yaml entry for demos/emdash-dev-main (a local-only workspace member, git-excluded but still picked up by pnpm install).

Left the handleContentRestore follow-up (suggestion, not blocking) out of scope for this PR.

@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 Jul 26, 2026
The DRAFT_SAVE_CONFLICT fix (previous commit) broke
tests/integration/mcp/concurrency.test.ts: 10 concurrent updates to the
same item now legitimately exhaust 3 CAS attempts with zero backoff,
since all racers retry in lockstep and keep re-colliding on the same
tick. That test only passed before because of the silent-drop bug it
exposed -- unlucky racers were succeeding with a metadata-only update
instead of erroring.

Bumps MAX_ATTEMPTS to 8 and adds jittered backoff between retries so
concurrent savers spread out instead of colliding every round. Verified
tests/integration/mcp/concurrency.test.ts 5x in a row (it's inherently
timing-sensitive) plus a full `vitest run` of packages/core -- the only
failures are pre-existing Windows-environment flakes (tar/vite-config/
sqlite-file-persist), confirmed identical on a stash of this branch's
changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@marcusbellamyshaw-cell

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up: my previous commit broke `tests/integration/mcp/concurrency.test.ts` in CI — 10 concurrent updates to the same item now legitimately exhaust 3 CAS attempts with zero backoff (all racers retry in lockstep and keep re-colliding on the same tick). That test only passed before because of the exact silent-drop bug this PR fixes — unlucky racers were succeeding with a metadata-only update instead of erroring.

Bumped `MAX_ATTEMPTS` to 8 and added jittered backoff between retries. Verified the concurrency test 5x in a row (it's timing-sensitive) plus a full core `vitest run` — the only remaining failures are pre-existing Windows-environment flakes (tar/vite-config/sqlite-file-persist), confirmed identical against a stash of this branch's changes.

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.

1 participant