The problem
Under contention a claim can fail with the site's generic error page while nothing in the log says why. Both the cause and the report of the cause are lost, one behind the other.
What happens, from the outside in. The database rolls a transaction back on its own, which is what it does to a deadlock victim, and a rollback deletes every savepoint the transaction had. Core does not know that: the nested transaction opened by SqlContentEntityStorage::save() is still on its stack, so when that object is destroyed at the end of a save it commits it, which issues RELEASE SAVEPOINT savepoint_1, and the savepoint is gone. That raises SQLSTATE[42000]: 1305 SAVEPOINT savepoint_1 does not exist, thrown out of a save that had otherwise succeeded. The exception the database raised first is already gone at this point.
Then the module loses the second one too. BookingManager::lockedMutate() catches the throwable, calls rollBack() on the transaction it opened, and rethrows. That rollback is refused, because core allows a rollback only on the innermost item of the stack and the failed savepoint is still above it, so TransactionOutOfOrderException is thrown from inside the catch and replaces the exception being handled. The booker gets a 500, and the log holds one line about a rollback that could not be performed.
Where the pattern lives
Four places open a transaction, catch a throwable, roll it back and rethrow: BookingManager::lockedMutate(), the hold transaction in BookingManager, TransactionManager::withRowLock() and SlotSeats. Any of them can hit this, and the pattern itself is the one core documents and uses, so the fault is not that they roll back.
Evidence
Measured on a load harness driving the claim endpoint, forty concurrent bookers against one slot of eight thousand places in sixteen parts. Every failed claim in the run logged the same record and nothing else: Drupal\Core\Database\TransactionOutOfOrderException: Error attempting rollback of ...\drupal_transaction. Active stack: ...\drupal_transaction > ...\savepoint_1, from TransactionManagerBase->rollback(), reached through OfferClaim::reduceTarget() releasing a line into BookingManager::release(), releaseForSettlement() and lockedMutate().
With the rollback wrapped so the exception it was handling survives, the same run named what had been hidden: SQLSTATE[42000]: Syntax error or access violation: 1305 SAVEPOINT savepoint_1 does not exist: RELEASE SAVEPOINT savepoint_1, thrown from Transaction::__destruct() as SqlContentEntityStorage::save() returned.
The database side is visible from the server rather than from the module: the deadlock it resolved put a hold's consumption read, a grouped SUM over the booking rows taken FOR UPDATE, against a release updating one booking row by its id. The reader held locks on some three thousand five hundred rows of a secondary index and the writer held the primary key record it needed, so the server rolled the release back. That read is what #3617101: Keep what each scope has taken on its lock anchor, so a hold reads a row rather than a sum removes, and this issue is not about the deadlock: it is about the two layers that stop anyone from seeing it.
Measured on the same house and driver, arms alternated: with that read still in place, 45 and 68 claims of about three thousand seven hundred failed this way in ninety seconds; with #3617101: Keep what each scope has taken on its lock anchor, so a hold reads a row rather than a sum applied, none did, in either round. So the trigger seen here goes away with that issue, and what this one is about is what happens the next time anything ends a transaction the module is inside of.
Why it matters beyond the log
A deadlock victim is a booker who can be told to try again. Masked, it is an unexplained error page, and no amount of load measurement can attribute it: a run that lost every cause reads as if the engine were failing for unknown reasons. It also hides the difference between a contention failure and a genuine fault, which is the distinction operators need most during a rush. That is why this is worth fixing even though the one trigger found so far is being removed elsewhere: what is broken is the reporting, and the reporting is what the next occurrence depends on.
Proposed
Two pieces, separable, in this order.
First, stop the masking. A catch whose rollback fails has to report that failure and rethrow the exception it was handling, not the rollback's own. That is small, is independent of everything below it, and turns every future occurrence into a log line that names its cause.
Second, handle a transaction the database has already ended. Once the cause is visible the right response can be chosen for it: a deadlock victim can be refused with a message that says to try again rather than reported as a fault, and the stack that core still believes in has to be unwound without asking the server to release savepoints that no longer exist.
Note for whoever picks this up: removing the explicit rollBack() calls is not the fix. A Transaction destroyed without an explicit commit is committed by TransactionManagerBase::purge(), so dropping them would confirm the writes the code meant to abandon.
Remaining tasks
Reproduce the masking in a test that fails before the change, make the failing rollback report its own failure and rethrow the original, then decide and implement what a claim does when the server has already rolled its transaction back.
AI-Generated: Yes (Claude Code was used to run the load measurements, to diagnose this from the logs and the server's deadlock report, and to draft this issue summary. I reviewed it before posting; there is no code on this issue yet.)
Issue fork yoyaku-3618543
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
mably commentedComment #4
mably commentedComment #6
mably commented