Locking an order for checkout suspends its holds: TransactionManager::lockTransaction() calls suspendHolds(), which extends each held line with a zero TTL, and a zero TTL clears hold_expires outright. That is deliberate and correct while a payment is in flight, since the hold cron only queries held lines whose hold_expires has already passed, so a visitor cannot lose their seats to the cron while they are at the payment provider.
But a line with no deadline is invisible to that cron for ever, so the only thing that can free it is the lock lifting. When the round trip never completes, nothing does: a webhook that never arrives, a process that dies between initiation and return, a visitor who abandons the provider's page. The seats stay held with no deadline and no owner until an operator finds the order by hand.
The backstop was designed and never finished. The order carries a locked_expires timestamp whose own description says "when a locked (checkout) order times out and is released, if the customer never completes payment", and lockTransaction() takes a $ttl documented as "the checkout timeout in seconds". Two things are missing:
- Nothing ever reads the field. It is written on lock, cleared on unlock and on place, and asserted in one test. No cron, no queue worker and no query looks at it, so even a set deadline would pass unnoticed.
- No caller passes a TTL.
OrderLockSubscribercallslockTransaction($order)with the default, so the value stored is always NULL.
Proposal: pass a checkout timeout when locking, and add the sweep that reads it. A locked order past its deadline releases its held lines (through the settlement path, which is allowed to let go of a frozen order) and returns the order to pending or cancels it. The deadline has to outlive the payment provider's own session expiry, or the sweep would race the payment it is protecting; taking it from the payment step's configured timeout is the obvious source, since that is what already governs how long the payer has.
On a site running the workflow, BookingTerminationReleaser covers the case where the instance itself ends, which is why this has not bitten yet. The no-workflow deployment (the one DefaultSettlementSubscriber serves) has no equivalent.
Why this now blocks other work. #3614424: The checkout amount is computed before the order is locked, so the basket can still change between the two wants the amount computed after the order is locked. The step that brackets payment lives in orchestra and cannot know about yoyaku orders, so the lock has to be taken by yoyaku's payable resolver, which runs when the amount is computed: at the landing page rather than at the redirect to the gateway. That widens the window in which an abandoned checkout leaves suspended holds behind, so this issue should land first.
Issue fork yoyaku-3614422
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 #3
mably commentedComment #5
mably commentedComment #7
mably commented