Skip to content

fix(dispute): create the dispute row before flagging the order (#921) - #923

Open
ToRyVand wants to merge 1 commit into
MostroP2P:mainfrom
ToRyVand:fix/921-dispute-write-ordering
Open

fix(dispute): create the dispute row before flagging the order (#921)#923
ToRyVand wants to merge 1 commit into
MostroP2P:mainfrom
ToRyVand:fix/921-dispute-write-ordering

Conversation

@ToRyVand

@ToRyVand ToRyVand commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Closes #921. Taken with @21Mill's go-ahead (comment).

The problem

dispute_action persisted the order's dispute flag and its Dispute status before inserting the disputes row — two statements, no transaction:

order.setup_dispute(is_buyer_dispute).map_err(MostroCantDo)?;
order.clone().update(pool).await.map_err(...)?;   // flag + status = Dispute
let dispute = dispute.create(pool).await.map_err(...)?;   // may fail

A DbAccessError on the insert returns to the client and leaves the order flagged, in status Dispute, with no row. Nothing reconciles it.

That state is unrecoverable, and worse than the issue originally described (corrected here):

  • get_valid_order admits only Active/FiatSent, and it runs before the sender is identified, so every retry fails with CantDo(NotAllowedByStatus) — for the counterparty as much as for the initiator. Not a one-sided lockout.
  • With no disputes row, solvers cannot see it either.

So the order sits in Dispute, invisible, undisputable by anyone, with its escrow held.

The fix

Reorder: row first, then the status flip. Only the update moves. setup_dispute is in-memory validation, so a rejection there still writes nothing, and Dispute::new keeps capturing the pre-dispute status for order_previous_status.

This is not a new invariant — job_escrow_deadline already does these two writes in this order and documents why (src/scheduler.rs:988):

Row first, then the status flip: a dispute row pointing at a fiat-sent order is recoverable (the next tick sees it and stops), a dispute order with no row would be invisible.

Its recovery pass finds a half-completed transition by the row, and its own test notes that such a row "can only mean a previous pass (or a user dispute) died between the two writes". The reorder puts dispute_action's failure mode back inside a recovery path that already exists and already anticipates it.

Why not a transaction

Stronger, and pool.begin() does exist in the tree (src/app/rate_user.rs, src/db.rs) — but Crud::create/Crud::update (mostro-core 0.14.5) take &Pool<Sqlite>, not a generic executor. A tx therefore means either changing mostro-core (separate crate, separate release) or replacing both calls with raw sqlx. The reorder needs neither and yields the recoverable ordering. Happy to revisit if you'd rather carry it in mostro-core.

Test

dispute_action_leaves_the_order_untouched_when_the_dispute_row_fails drops the disputes table so the insert fails deterministically — the ordering is pinned with no timing involved.

Verified it actually detects the bug rather than merely running: on the previous write order it fails at assertion failed: !stored_order.buyer_dispute; on this one it passes.

Verification

  • cargo test — 1234 passed, 0 failed, 3 ignored
  • cargo clippy --all-targets --all-features — clean
  • cargo fmt --check — clean

Diff is one file, +54/−7.

Summary by CodeRabbit

  • Bug Fixes
    • Improved dispute processing reliability by ensuring order details remain unchanged if dispute creation fails.
    • Added safeguards to prevent partially completed dispute updates.

…oP2P#921)

`dispute_action` persisted the order's dispute flag and its `Dispute`
status before inserting the `disputes` row, in two statements with no
transaction. A `DbAccessError` on the insert returned to the client and
left the order flagged, in status `Dispute`, with no row.

That state is unrecoverable. `get_valid_order` admits only `Active` and
`FiatSent`, and it runs before the sender is identified, so every retry
fails with `CantDo(NotAllowedByStatus)` — for the counterparty as much as
for the initiator. With no row, solvers cannot see the dispute either, so
the order is stuck with its escrow.

`job_escrow_deadline` already does these two writes in the opposite order
and documents why:

    Row first, then the status flip: a dispute row pointing at a
    `fiat-sent` order is recoverable (the next tick sees it and stops), a
    `dispute` order with no row would be invisible.

Its recovery pass finds a half-completed transition *by the row*, and its
test notes the half-write "can only mean a previous pass (or a user
dispute) died between the two writes". Reordering here puts
`dispute_action`'s failure mode back inside that existing recovery path.

Only the `update` moves: `setup_dispute` is in-memory validation, so a
rejection there still writes nothing, and `Dispute::new` keeps capturing
the pre-dispute status for `order_previous_status`.

A transaction would be stronger, but `Crud::create`/`Crud::update` take
`&Pool<Sqlite>` rather than a generic executor, so it would require a
mostro-core change or raw sqlx at both call sites. The reorder needs
neither.

The test drops the `disputes` table to make the insert fail
deterministically, so it pins the ordering with no timing involved: it
fails on the previous order at `!stored_order.buyer_dispute` and passes
on this one.
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 72034f2b-c4ec-49e0-bf70-d2541432407c

📥 Commits

Reviewing files that changed from the base of the PR and between 848ee3b and 3025a27.

📒 Files selected for processing (1)
  • src/app/dispute.rs

Included review availability: Your plan provides up to 2 included reviews per hour; 1 remains after this review.


Walkthrough

dispute_action now inserts the dispute row before updating the order. A regression test verifies that a failed insert leaves the order active with both dispute flags unset.

Changes

Dispute persistence

Layer / File(s) Summary
Insert dispute before updating order
src/app/dispute.rs
The dispute row is created before the order status and dispute flags are persisted. The integration test drops the disputes table and verifies that the failed action leaves the order unchanged.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 3025a

The change creates the dispute record before updating the order’s dispute status, keeping failed transitions recoverable; no actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers: arkanoider, grunch

Poem

A rabbit checks the dispute row,
Then lets the order status flow.
If storage fails along the way,
The flags remain untouched that day.
“Safe paws!” the little rabbit cheers.

🚥 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 primary fix: creating the dispute row before flagging the order.
Linked Issues check ✅ Passed The changes satisfy issue #921 by reordering writes and adding a regression test for failed dispute-row insertion.
Out of Scope Changes check ✅ Passed The changes are limited to dispute write ordering and its regression test, with no unrelated scope.
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

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

Blocking change required

The reordered writes fix the original insert-failure direction, but the inverse failure window is not recoverable for an Active order. If the dispute insert succeeds and the subsequent order update fails, the durable initiated dispute has no notifications or event, while the order remains active. Retries are rejected as DisputeAlreadyExists; the deadline worker only resumes this half-transition for fiat-sent and cancels active escrow instead.

Please make the two writes atomic, or recover an existing live dispute before the Active deadline-cancellation path. Add failure-injection coverage for "create succeeds, update fails" from both Active and FiatSent.

Local verification on this head: cargo fmt --all -- --check, the new focused regression test, the existing FiatSent recovery test, and cargo clippy --all-targets --all-features -- -D warnings all passed.

Comment thread src/app/dispute.rs
.await
.map_err(|cause| MostroInternalErr(ServiceError::DbAccessError(cause.to_string())))?;

order

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.

A successful Dispute::create followed by a failed order.update now leaves an initiated row plus an active order. That state is not safely recoverable: dispute_action permits Active, retries hit DisputeAlreadyExists, and the deadline worker resumes existing rows only in its FiatSent branch; its Active branch cancels the escrow. Make the writes atomic or add recovery before the active cancellation path, with failure-injection tests for both Active and FiatSent.

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.

dispute_action persists the dispute flag before creating the dispute row

1 participant