Skip to content

Fix/update draft race with send - #774

Merged
jbpenrath merged 2 commits into
mainfrom
fix/update-draft-race-with-send
Aug 5, 2026
Merged

Fix/update draft race with send#774
jbpenrath merged 2 commits into
mainfrom
fix/update-draft-race-with-send

Conversation

@jbpenrath

@jbpenrath jbpenrath commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Purpose

Fix rare race condition that can occured between message send and draft update.

Summary by CodeRabbit

  • Bug Fixes
    • Improved draft autosave and message sending to prevent conflicting updates.
    • Prevented edits to messages after they have been finalized for sending.
    • Prevented outbound delivery errors when recipients are deleted or changed during sending.
    • Ensured successfully delivered messages are not incorrectly retried after partial or malformed delivery responses.
    • Improved recovery by restarting autosave when sending or draft creation fails.
    • Ensured failed sends correctly mark recipients for retry and preserve message statistics.
    • Prevented messages from being delivered when sending changes fail to save.

@jbpenrath jbpenrath self-assigned this Aug 4, 2026
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@jbpenrath, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 26 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1aa6ef5d-62bc-4d7d-860d-adf3549c1bea

📥 Commits

Reviewing files that changed from the base of the PR and between cf0b70e and b6581a2.

📒 Files selected for processing (8)
  • src/backend/core/api/viewsets/draft.py
  • src/backend/core/api/viewsets/send.py
  • src/backend/core/mda/draft.py
  • src/backend/core/mda/outbound.py
  • src/backend/core/tests/api/test_draft_send_race_guards.py
  • src/backend/core/tests/mda/conftest.py
  • src/backend/core/tests/mda/test_outbound_recipient_race.py
  • src/frontend/src/features/forms/components/message-form/index.tsx
📝 Walkthrough

Walkthrough

This PR adds message-row locking during draft update and send flows. It adds guarded recipient-status persistence for outbound delivery. It also adds frontend send guards and regression tests for delivery races and finalized-message updates.

Changes

Draft and Delivery Race Fix

Layer / File(s) Summary
Lock messages during draft updates and send finalization
src/backend/core/api/viewsets/draft.py, src/backend/core/api/viewsets/send.py, src/backend/core/mda/draft.py
Draft updates and send preparation lock the message row inside transactions. Finalized messages reject further draft updates.
Persist delivery outcomes across recipient races
src/backend/core/mda/outbound.py
Recipient status saves use savepoints and tolerate concurrent deletion or validation errors. MTA statuses are processed independently, malformed entries do not stop other updates, and omitted recipients are marked for retry.
Validate delivery and finalization races
src/backend/core/tests/mda/conftest.py, src/backend/core/tests/mda/test_outbound_recipient_race.py
Fixtures and tests cover recipient deletion and recreation, finalized-message updates, status persistence, malformed MTA results, complete SMTP failure, and save-time database errors.
Pause draft saves during submission
src/frontend/src/features/forms/components/message-form/index.tsx
A send guard stops autosave and non-forced draft saves during submission. Export-triggered saves are awaited, and autosave resumes after failure paths.
Verify endpoint locking and finalized-message responses
src/backend/core/tests/api/test_draft_send_race_guards.py
API tests verify finalized-message responses and FOR UPDATE row locks for draft update and send requests.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant MessageForm
  participant SendMessageView
  participant Database
  participant OutboundWorker
  participant MTA

  User->>MessageForm: submit message
  MessageForm->>SendMessageView: send draft
  SendMessageView->>Database: lock and finalize message
  SendMessageView->>OutboundWorker: dispatch after commit
  OutboundWorker->>MTA: send message
  MTA-->>OutboundWorker: delivery statuses
  OutboundWorker->>Database: save each recipient status
  alt send fails
    SendMessageView-->>MessageForm: error
    MessageForm->>MessageForm: restore autosave
  else recipient row changed
    Database-->>OutboundWorker: savepoint error
    OutboundWorker->>OutboundWorker: log warning and continue
  end
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: fixing the race condition between draft updates and message sending.
Docstring Coverage ✅ Passed Docstring coverage is 88.89% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/frontend/src/features/forms/components/message-form/index.tsx`:
- Around line 629-632: Keep autosave disabled throughout the send flow by adding
a submission-state ref used by every autosave restart path, including
saveDraftInner’s finally block and the [draft] effect’s startAutoSave call. Set
the ref before the awaited send preparation begins, prevent startAutoSave while
submission is active, and clear the ref only after messageMutation.mutate()
settles or the submission aborts.
- Around line 651-653: Prevent draft saves from starting while a message
submission is active: update the visible saveDraft/descendant blur handling to
check the submission ref used by messageMutation and return without saving when
submission has begun. Ensure this guard covers blur events occurring during or
after the existing saveDraftPromiseRef wait, and keep editing disabled or
blocked until the mutation settles.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6d1d2e6d-3322-4b19-b0d1-8d0ab6bb5def

📥 Commits

Reviewing files that changed from the base of the PR and between cf0b70e and 2e903b1.

📒 Files selected for processing (4)
  • src/backend/core/api/viewsets/draft.py
  • src/backend/core/mda/outbound.py
  • src/backend/core/tests/mda/test_outbound_recipient_race.py
  • src/frontend/src/features/forms/components/message-form/index.tsx

Comment thread src/frontend/src/features/forms/components/message-form/index.tsx Outdated
Comment thread src/frontend/src/features/forms/components/message-form/index.tsx

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/frontend/src/features/forms/components/message-form/index.tsx (1)

666-668: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Abort the send when draft persistence fails.

saveDraftInner catches draft mutation errors at Line 594 to Line 596 and returns the current draft ID. The waits at Line 651 to Line 652 and Line 666 to Line 668 therefore only wait for completion. They do not prove that the latest recipient update was stored.

The send payload at Line 674 to Line 681 contains no recipients. A failed recipient update can therefore send the message using the old persisted recipient set. Propagate the send-preparation save error and abort before messageMutation.mutate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/features/forms/components/message-form/index.tsx` around
lines 666 - 668, Update the send preparation flow in the message form, including
saveDraftInner and the waits on saveDraftPromiseRef.current, to propagate
recipient-persistence failures instead of swallowing them. Before
messageMutation.mutate, abort the send when the latest recipient save fails;
preserve the existing send path only after the draft save completes
successfully.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backend/core/mda/outbound.py`:
- Around line 522-532: The exception handler in the atomic transaction block
catches all DatabaseError instances, including deadlocks and timeouts, and logs
them as "vanished during delivery" without recording the actual failure. This
causes the recipient row to retain delivery_status = NULL and get re-processed,
creating duplicate sends. After logging the warning for DatabaseError (and
removing the unreachable ValidationError branch), check whether the recipient
row still exists in the database by querying with recipient.pk. If the row
exists, re-raise the caught DatabaseError so the caller-side handler at lines
815-820 records the real failure. Only absorb the exception if the row is
confirmed to be gone, which is the safe vanished-row case.

In `@src/backend/core/tests/mda/test_outbound_recipient_race.py`:
- Around line 88-93: Add API-level tests alongside the existing
finalized-message coverage for the draft PUT and send endpoints: verify PUT
returns 404 for a finalized message and send rejects a message that cannot be
locked. Exercise the endpoint routes and assert the response status, ensuring
the tests depend on the endpoint’s row-locking and is_draft=True behavior rather
than only calling core.mda functions.

In `@src/frontend/src/features/forms/components/message-form/index.tsx`:
- Line 533: The ensureDraft method allows force-bypassed mutations that race
with messageMutation during send, as shown by the condition that permits
mutations when force=true despite isSendingRef.current. Create a private
prepare-on-send method without the force parameter for internal use during
message delivery, update the send operation to call this private path instead of
ensureDraft, and modify ensureDraft to reject all mutations (including forced
ones) when isSendingRef.current is true by removing the force bypass. Also guard
deleteDraft to return early when isSendingRef.current is true to prevent draft
deletion until the send operation completes.

---

Outside diff comments:
In `@src/frontend/src/features/forms/components/message-form/index.tsx`:
- Around line 666-668: Update the send preparation flow in the message form,
including saveDraftInner and the waits on saveDraftPromiseRef.current, to
propagate recipient-persistence failures instead of swallowing them. Before
messageMutation.mutate, abort the send when the latest recipient save fails;
preserve the existing send path only after the draft save completes
successfully.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: dee2d29a-0bd2-4d02-921c-0a5123e7e4be

📥 Commits

Reviewing files that changed from the base of the PR and between 2e903b1 and f4d5f1a.

📒 Files selected for processing (7)
  • src/backend/core/api/viewsets/draft.py
  • src/backend/core/api/viewsets/send.py
  • src/backend/core/mda/draft.py
  • src/backend/core/mda/outbound.py
  • src/backend/core/tests/mda/conftest.py
  • src/backend/core/tests/mda/test_outbound_recipient_race.py
  • src/frontend/src/features/forms/components/message-form/index.tsx

Comment thread src/backend/core/mda/outbound.py
Comment thread src/backend/core/tests/mda/test_outbound_recipient_race.py
Comment thread src/frontend/src/features/forms/components/message-form/index.tsx

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backend/core/tests/api/test_draft_send_race_guards.py`:
- Around line 132-137: The locking-query assertions in the draft PUT and send
race-guard tests must verify the locked query itself checks the draft state. In
src/backend/core/tests/api/test_draft_send_race_guards.py lines 132-137 and
181-186, inspect each captured FOR UPDATE query’s WHERE clause and require the
messages_message.is_draft predicate, while keeping the assertions focused on the
respective locking query.
- Around line 140-153: The test_send_finalized_message_returns_404 test must
also verify that rejected sends do not initiate outbound delivery. Patch
prepare_outbound_message and send_message_task in the test, then assert both
mocks were not called after the finalized-message request returns 404.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e9891195-191b-4224-9ca8-2ae5eef03402

📥 Commits

Reviewing files that changed from the base of the PR and between cf85d9b and dd08769.

📒 Files selected for processing (1)
  • src/backend/core/tests/api/test_draft_send_race_guards.py

Comment thread src/backend/core/tests/api/test_draft_send_race_guards.py Outdated
Comment thread src/backend/core/tests/api/test_draft_send_race_guards.py
@jbpenrath
jbpenrath force-pushed the fix/update-draft-race-with-send branch from dd08769 to 43c0563 Compare August 5, 2026 13:47
A draft PUT racing a send could pass its is_draft=True fetch before the
send finalized the message, then rewrite the MessageRecipient rows
(delete + recreate, new UUIDs) while the outbound worker held the old
rows. The worker's post-SMTP status save then crashed the delivery with
"Save with update_fields did not affect any rows", and the recreated
rows were left without delivery status, so the retry task re-sent an
already-delivered email.

The PUT now locks the message row and re-checks is_draft in the same
transaction as the rewrite, serializing it against the send's finalize.
The worker records statuses through a queryset UPDATE (warning instead
of crash when the row is gone), and the SMTP-failure fallback no longer
flips already-delivered recipients back to RETRY.
The 30s autosave tick could fire between the submit's awaits (draft
save, editor export) and the send mutation, dispatching a draft PUT
concurrently with POST /send/ — the client half of the recipient-rewrite
race fixed backend-side. Stop the timer before any await, wait for a
blur-triggered save to settle right before sending, and restore the
timer when the submit aborts since the draft stays open.
@jbpenrath
jbpenrath force-pushed the fix/update-draft-race-with-send branch from d595b94 to b6581a2 Compare August 5, 2026 14:10
@jbpenrath
jbpenrath merged commit b6581a2 into main Aug 5, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant