›
byrcsc/laravel-hold · 1.x
Use row locks and transactions to prevent two holders from taking the last slot.
Two acquirers racing for the last slot cannot both win. That holds on MySQL, PostgreSQL, and SQLite. This page describes the mechanism, because the guarantee is only as good as the setup it runs on.
Acquisition runs four steps inside one transaction:
holdCapacity().Acquirers of the same resource serialize behind step 1. Acquirers of unrelated resources never contend with each other.
The lock is taken on the holdable's row, not on the rows being counted. Locking the holds is the obvious choice and the wrong one.
A resource with every slot free has no active hold rows to lock. SELECT ... FOR UPDATE over an empty set locks nothing on PostgreSQL, so two acquirers
both read zero and both insert. Racing eight acquirers for one slot that way
produces eight winners.
The holdable's own row always exists, so it serializes the free case, which is the case a capacity of 1 spends its life in.
The count itself takes no locks. Locking those rows would look safer and is not: with no row to lock on a free resource, InnoDB locks the index gap instead, and on a sparse holds table that gap spans holdables the caller has nothing to do with. Acquisition for the whole application would queue behind one seat.
Global scopes are dropped when taking the lock. A soft-deleted holdable still needs its acquirers serialized, and a scope that hid the row would silently take no lock at all.
SELECT ... FOR UPDATE on the holdable row. Under REPEATABLE READ, the read
view is assigned at the first consistent read, and the lock above is a locking
read, which assigns none. The count is therefore the first consistent read and
lands after the acquirer this one queued behind has committed.
InnoDB can pick any transaction as a deadlock victim, which is one reason acquisition replays.
SELECT ... FOR UPDATE on the holdable row. READ COMMITTED takes a fresh
snapshot per statement, so the count sees the previous winner's committed
insert.
SQLite compiles FOR UPDATE to nothing and serializes writers itself. Mutual
exclusion still holds, but it comes from the single-writer transaction lock
rather than from a row lock, so the package writes the holdable's key back to
itself to take that lock up front.
That write changes no data. It exists because a deferred transaction that reads
before it writes holds a snapshot, and a writer committing before the insert
leaves that snapshot stale. The upgrade then fails with SQLITE_BUSY_SNAPSHOT,
which no busy timeout can wait out, because SQLite never calls the busy handler
for it.
SQLite is correct out of the box, but a losing acquirer can hit a "database is
locked" error rather than a clean null. If you run SQLite under real
concurrency, configure the connection:
// config/database.php
'sqlite' => [
// ...
'journal_mode' => 'WAL',
'busy_timeout' => 5000,
'transaction_mode' => 'IMMEDIATE',
],| Setting | Effect |
|---|---|
journal_mode WAL | Readers stop blocking the winner's commit |
busy_timeout milliseconds | Wait for the write lock instead of failing |
transaction_mode IMMEDIATE | Take the write lock up front; PHP 8.4 and up |
Laravel issues BEGIN IMMEDIATE only on PHP 8.4 and above. Below that, the
package's own write takes the lock instead, which is why acquisition still
replays.
Acquisition attempts the transaction up to three times on a database concurrency error.
A retry is not a second chance at the slot. The replay re-reads under a fresh lock and returns null as readily. It exists because InnoDB and SQLite can both abort a correctly written transaction purely for contention, and those callers deserve the honest answer that somebody else took the slot rather than an exception.
Once the replays are spent, the underlying exception surfaces. Treat that as a signal about load or configuration, not about capacity.
activeHoldFor() takes no lock. Two simultaneous requests can both read
null and both go on to acquire. On a resource with spare capacity, both
succeed. The double-submit guard is a convenience, not a mutex. See
acquiring holds.
Reads take no locks. availableSlots() and isFullyHeld() are true as of
the instant they ran and can be stale by the time you act on them. Acquire and
handle the null rather than checking availability and then acquiring.
// Wrong: the slot can go between the check and the acquire.
if (! $seat->isFullyHeld()) {
$hold = $seat->acquireHold($user);
}
// Right: one atomic operation, one branch.
$hold = $seat->acquireHold($user);
if ($hold === null) {
// Somebody was faster.
}