Lock combined-charge purchases in id order before refunding#5972
Conversation
Charge#refund_and_save! (and refund_for_fraud_and_block_buyer!) wrapped all per-purchase refunds in one transaction whose lock acquisition order was purchase₁ → balance → purchase₂ → …, holding the shared seller balance while waiting for later purchase rows. A concurrent failed-refund reversal (Purchase::HandleFailedRefundService) holding one of those later purchases and waiting for the same balance completed a deadlock cycle. Take every purchase lock up front, in id order, before any refund work — the combined-charge extension of the purchase-first order from #5917. Also brings Purchase#refund_partial_purchase! (console/legacy-only caller surface) into the same order with the reload.lock! + moved partial-refund-flag read, and adds a real-thread combined-charge scenario to the concurrency spec. Fixes #5918
Greptile SummaryThis PR updates refund locking so combined-charge refunds follow the same purchase-first order as single-purchase refunds. The main changes are:
Confidence Score: 5/5Safe to merge with minimal risk. The updated code is narrowly scoped to refund lock ordering and keeps existing refund behavior intact. The changed paths include targeted real-thread tests for the deadlock scenario. No files require special attention.
What T-Rex did
Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant ChargeRefund as Charge refund transaction
participant Purchase1 as Purchase 1 row
participant Purchase2 as Purchase 2 row
participant Balance as Seller balance
participant Reversal as Failed-refund reversal
ChargeRefund->>Purchase1: lock! in id order
ChargeRefund->>Purchase2: lock! in id order
ChargeRefund->>Balance: refund purchase 1 balance update
Reversal->>Purchase2: waits for purchase lock
ChargeRefund->>Balance: refund purchase 2 balance update
ChargeRefund-->>Purchase1: commit releases locks
ChargeRefund-->>Purchase2: commit releases locks
Reversal->>Purchase2: acquires lock after charge refund
Reversal->>Balance: reverse failed refund balance rows
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant ChargeRefund as Charge refund transaction
participant Purchase1 as Purchase 1 row
participant Purchase2 as Purchase 2 row
participant Balance as Seller balance
participant Reversal as Failed-refund reversal
ChargeRefund->>Purchase1: lock! in id order
ChargeRefund->>Purchase2: lock! in id order
ChargeRefund->>Balance: refund purchase 1 balance update
Reversal->>Purchase2: waits for purchase lock
ChargeRefund->>Balance: refund purchase 2 balance update
ChargeRefund-->>Purchase1: commit releases locks
ChargeRefund-->>Purchase2: commit releases locks
Reversal->>Purchase2: acquires lock after charge refund
Reversal->>Balance: reverse failed refund balance rows
Reviews (3): Last reviewed commit: "Preserve subscription association across..." | Re-trigger Greptile |
- lock_successful_purchases_in_id_order! now returns the locked instances and all three Charge refund paths iterate THOSE instead of re-querying successful_purchases: under REPEATABLE READ a re-query reads the pre-lock transaction snapshot, so refundable amounts could be stale relative to a reversal that committed while the pre-pass waited for a lock (fable P2-1). - Charge#refund_gumroad_taxes! gets the same pre-pass, and Purchase#refund_gumroad_taxes! locks the purchase row before inserting the refund — that path still acquired refund→purchase locks in reverse order against HandleFailedRefundService's FOR UPDATE refunds scan (fable P2-2). - charge_spec tax-refund mock example updated for the lock pre-pass.
|
Fable-5 principal-engineer review completed on the diff. Findings and resolutions:
Re-verified locally after the fixes: concurrency spec 4/4 green, |
|
Assigning @nyomanjyotisa for review (charge/refund backend lane): although this is a bug-class fix with green local specs and fable P1/P2 findings resolved, it changes lock acquisition inside the core refund money-movement paths ( |
reload.lock! clears the association cache, so Subscription#charge! callers holding the same in-memory subscription no longer saw the VAT ID stored by refund_gumroad_taxes!. Capture the subscription instance before reloading and store the VAT ID on it (SubscriptionTest VAT transfer was red on CI).
|
CI triage: the Test Minitest failure ( |
Fixes #5918
Combined-charge refunds were still outside the purchase-first lock order established for single-purchase refunds in #5917.
Charge#refund_and_save!(andrefund_for_fraud_and_block_buyer!) refund every purchase in one transaction; eachPurchase#refund_purchase!locks its purchase then the shared seller balance, so the transaction's acquisition sequence was purchase₁ → balance → purchase₂ → … — it held the balance while waiting for later purchase rows. A concurrentHandleFailedRefundServicereversal holding purchase₂ and waiting for the same balance completed a deadlock cycle.Progress
successful_purchasesin id order at the top ofCharge#refund_and_save!'s transaction before refunding any (new privateCharge#lock_successful_purchases_in_id_order!)refund_for_fraud_and_block_buyer!'swith_lockreload.lock!+ moved partial-refund-flag read toPurchase#refund_partial_purchase!(kept rather than deleted: console/legacy callers plus a wide spec-caller surface)charge_spec#refund_and_save!example for the lock pre-passTest results (local)
spec/services/purchase/handle_failed_refund_service_concurrency_spec.rb— 4 examples, 0 failures (includes the new combined-charge scenario; real threads on separate DB connections)Chargefix stashed, the new example times out exactly as the issue describes (the sibling reversal overtakes between per-purchase refunds) — the spec genuinely exercises the cyclespec/models/charge_spec.rb+spec/models/purchase/purchase_refunds_spec.rb— 184 examples, 1 failure:charge_spec.rb:21fails identically on a cleanorigin/mainworktree (merchant_account already connected— shared-test-DB env issue), pre-existing, not this changerubocopclean on all touched filesBefore / after
Permanent copies:
qa-media/on this branch. Backend-only concurrency fix — no UI surface, no schema change; no preview URL applicable (no user-visible behavior change; refund flows behave identically except that the deadlock window is closed).QA steps
app/models/charge.rb(id-ordered lock pre-pass + comment),app/modules/purchase/refundable.rb(refund_partial_purchase!lock + updated comment), and the new spec example.bundle exec rspec spec/services/purchase/handle_failed_refund_service_concurrency_spec.rb— expect 4 examples, 0 failures.app/models/charge.rbhunk and re-run thecombined-chargeexample to watch it reproduce the cycle (times out on the latch), then restore.AI disclosure: authored autonomously by gumclaw (Hermes Agent, model claude-fable-5) from issue #5918 as part of the autonomous dev dispatcher. Prompts: standing dispatcher instruction ("plan and then build and merge fully autonomously… certainly bug fixes"), issue #5918 body as the spec.