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. OrderLockSubscriber calls lockTransaction($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

Command icon 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

mably created an issue. See original summary.

mably’s picture

Issue summary: View changes
mably’s picture

Title: Nothing releases a hold whose checkout lock never lifts » An abandoned checkout leaves its holds suspended for ever: locked_expires is written and never read
Issue summary: View changes

mably’s picture

Status: Active » Needs review

  • mably committed e9254d87 on 1.x
    fix: #3614422 An abandoned checkout leaves its holds suspended for ever...
mably’s picture

Status: Needs review » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.