›
›
›
  1. docs
  2. ›
  3. byrcsc/laravel-hold
1.x
Browse documentationOpenClose

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Holdables and holders
  • Capacity and slots
  • Acquiring holds
  • Releasing and extending
  • Expiry
  • Hold state

Operations

  • Events and listeners
  • Scheduling expiry
  • Pruning history
  • Concurrency and databases

Reference

  • Configuration
  • Console commands
  • Database schema
  • Testing
  • Troubleshooting

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Holdables and holders
  • Capacity and slots
  • Acquiring holds
  • Releasing and extending
  • Expiry
  • Hold state

Operations

  • Events and listeners
  • Scheduling expiry
  • Pruning history
  • Concurrency and databases

Reference

  • Configuration
  • Console commands
  • Database schema
  • Testing
  • Troubleshooting

byrcsc/laravel-hold · 1.x

Concurrency and databases.

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.

The critical section

Acquisition runs four steps inside one transaction:

  1. Take a row lock on the holdable's own row.
  2. Count the resource's active holds.
  3. Compare that count against holdCapacity().
  4. Insert the hold, or return null.

Acquirers of the same resource serialize behind step 1. Acquirers of unrelated resources never contend with each other.

The lock anchors on the resource, not on its holds

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.

Per-engine notes

MySQL

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.

PostgreSQL

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

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.

Configuring SQLite for real concurrency

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',
],
SettingEffect
journal_mode WALReaders stop blocking the winner's commit
busy_timeout millisecondsWait for the write lock instead of failing
transaction_mode IMMEDIATETake 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.

The replay

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.

What is not serialized

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.
}

What to read next

  • Acquiring holds for the caller-facing side of this mechanism.
  • Capacity and slots for why capacity is declared rather than passed.
  • Troubleshooting for the errors this page's failure modes produce in practice.
PreviousPruning historyNextConfiguration
View source

On this page

  1. The critical section
  2. The lock anchors on the resource, not on its holds
  3. Per-engine notes
  4. MySQL
  5. PostgreSQL
  6. SQLite
  7. Configuring SQLite for real concurrency
  8. The replay
  9. What is not serialized
  10. What to read next