purchases: two concurrent purchases creating the same new item names in opposite order deadlock on the unique index #83
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found by
Reviewer78r1during review of PR #78, confirmed pre-existing onorigin/mainand therefore not fixed there.What deadlocks
Two purchases that each create the same two new item names in opposite order deadlock on the uncommitted unique-index entries for
lower(btrim(item_name)).The contention is not on
inventoryrows — neither row exists yet. It is on the index entries: each transaction inserts one name, the other transaction's matching insert blocks on that uncommitted entry, and they wait on each other.Why PR #78's fix does not cover it
#78 added
lockInventoryRows, which takes every needed row lock ascending in one statement before the per-line loop. That closes the deadlock #78 itself introduced for existing rows, and it cannot help here — but not for the reason first written in this issue.The real reason: a row lock cannot order contention on an index entry for a row that does not exist yet. Whether a pre-lock statement is issued is beside the point. Measured across three variants:
The disjoint case is decisive: both transactions issue a pre-lock, and it is useless, because the rows they contend on are the ones the pre-lock could not name. The shared case is clean only incidentally — the pre-lock on
Aserialises the two transactions before either reaches its inserts, so the index entries are never contended concurrently.An earlier version of this issue said "an all-
newItemset issues no pre-lock at all", which is true but implies a set that does issue one is protected. It is not.The reviewer diffed the
newItembranch ofupsertPurchaseItemagainstorigin/mainand it is identical apart fromError→ServiceRejection, so this is in a file #78 touched but is not #78's doing.Severity
Low reachability, and it fails safely. Postgres kills one transaction and both are atomic, so nothing is half-written — the loser gets a raw
Failed query: … deadlock detected, which is the unreadable-error class #71 was filed about but on a genuinely concurrent path rather than a routine one.Reaching it needs two purchases in flight simultaneously, each introducing the same two previously-unknown item names, in opposite line order. The likely real-world source is the WhatsApp bot: two receipt photos for the same delivery processed at once.
Options
lockInventoryRowsalready does for existing rows, and — per the measurements above — the only one of these options that actually closes it. ExtendinglockInventoryRowscannot, since the contended object is an index entry for a row that has no id yet.40P01at the transport and retry once. More general, but hides a real ordering bug behind a retry.40P01into a readable "coba lagi sebentar" message. Closes the unreadable-error symptom without closing the deadlock.Option 1 is the one that matches the reasoning already recorded in
lockInventoryRows.Related
lockInventoryRowsdocblock documents the ordering rule this would extend to names.Correcting one line, at the filer's invitation.
The description says
lockInventoryRowscannot help "because an all-newItemset issues no pre-lock at all". That is true but it is not the reason, and as written it implies a set that does issue a pre-lock is protected. It is not. Measured, three variants, two connections:The third case is the one that matters: both transactions take a pre-lock, and they still deadlock, because the contention is not on the inventory rows at all — it is on the uncommitted unique-index entries for
lower(btrim(item_name))that eachinserttakes for a row the other transaction is also creating.So the accurate reason is: a row lock cannot order contention on an index entry for a row that does not exist yet.
lockInventoryRowsorders access to rows that are already there; twonewItemlines naming the same not-yet-existing products are ordered by nothing. The shared-existing-item case is clean only incidentally — the pre-lock onAhappens to serialise the two transactions before either reaches its inserts, which is luck of the line-up rather than a property of the fix.That distinction changes what a fix would have to do: ordering the
newItemlines by name before inserting them would work, whereas extendinglockInventoryRowscannot, since there is nothing to lock.Everything else in the description matches what I measured, including that this is pre-existing — I diffed the
newItembranch ofupsertPurchaseItemagainstorigin/mainand it is identical apart fromErrorbecomingServiceRejection.