Files
sure/app
29c0a369d0 fix(transfers): isolate concurrent transfer match in a savepoint (#2769)
* fix(transfers): isolate concurrent transfer match in a savepoint

auto_match_transfers! opens one Transfer.transaction and, per candidate,
calls Transfer.find_or_create_by! while rescuing RecordNotUnique. The
transfers table has a composite unique index on (inflow_transaction_id,
outflow_transaction_id), so when two syncs of the same family run at once
the losing insert raises the unique violation.

On PostgreSQL a failed statement aborts the whole surrounding
transaction. Rescuing the Ruby exception does not clear that state, so
the next update! raises PG::InFailedSqlTransaction and every following
candidate is dropped.

Run the per-candidate insert in its own savepoint via
Transfer.transaction(requires_new: true), extracted into a private
find_or_create_transfer! helper. A lost race now rolls back only to the
savepoint; the outer transaction stays healthy and the loop keeps
matching. The same race surfacing through the uniqueness validation
(RecordInvalid with :taken) is treated as already-created; any other
validation failure is re-raised.

Fixes #2471

* fix(transfers): only swallow the uniqueness race for the exact pair

The rescue treated a :taken on inflow_transaction_id or outflow_transaction_id as
proof that this candidate's transfer was created. But the uniqueness validations are
per-column, so two same-amount candidates racing (one committing (inflow, outflow_a)
while another tries (inflow, outflow_b)) raise :taken on inflow_transaction_id even
though no Transfer exists for (inflow, outflow_b). The caller then marked outflow_b
as matched with no Transfer behind it.

Confirm the exact (inflow_transaction_id, outflow_transaction_id) row exists before
accepting the race; otherwise return nil and skip the candidate. Non-:taken
validation failures still re-raise. The RecordNotUnique path (composite index) already
implies the exact pair — it now returns that row for the same reason.

* test(transfers): assert matching continues past a skipped collision

The concurrent-race test had no surviving candidate, so a regression that stopped
processing after the skipped collision would still pass. Add a second, non-conflicting
candidate and assert its transfer is created and both entries are marked.

* test(transfers): pass insert! attributes as an explicit hash

Ruby 3 treats insert!(inflow_transaction_id: ..., outflow_transaction_id: ...) as
keyword arguments, so ActiveRecord's insert!(attributes) got zero positional args and
raised ArgumentError (given 0, expected 1). Wrap the attributes in { } so they are the
positional attributes hash.

---------

Co-authored-by: agentloop <agentloop@localhost>
Co-authored-by: pro3958 <pro3958@users.noreply.github.com>
2026-08-22 05:55:35 +02:00
..