›
byrcsc/laravel-assignment · 1.x
Two callers racing for one slot produce one assignment and one exception, on MySQL, PostgreSQL, and SQLite.
Assignment is where races happen. Two requests read an empty slot at the same moment, both decide it is free, and both write. Without a constraint, both succeed and one holder silently disappears.
This page lists what the package promises under load, and what it leaves to you.
Two concurrent assign() calls on the same slot produce one assignment. The
loser gets SlotOccupied, never a second row.
The guarantee is a unique index on the assignable and an internal slot
column, not an application check. A check in PHP has a window between reading
and writing; an index does not.
try {
$callout->assign($crew, role: 'crew');
} catch (SlotOccupied) {
// somebody else got there first
}The same holds across the mixed cases: assign racing offer, offer racing offer, and a reassignment racing an assignment. One row survives in every combination.
Round robin cursors are correct under concurrency. Selection takes a row lock on the scope for the whole operation, which serializes selections that share a scope.
Two consequences follow:
If the selected candidate's slot turns out to be taken, the transaction rolls back and the cursor with it, so a failed attempt does not consume a turn.
Accept racing decline resolves to one winner. The offer is locked, the assignee
is checked against the locked row, and the state is checked after the lock, so
the loser gets OfferNotOpen rather than overwriting an answer.
An offer that expires while both the tick and a delayed job are running is advanced once. Expiry claims the row before ending it, and a claim that finds the row already ended does nothing.
That is what makes running the tick and dispatch_expiry_jobs together safe.
Two flushes running at once do not assign the same entry twice. Each entry is claimed with a row lock inside its own transaction, and an entry that another flush already took is skipped.
Each entry commits independently, so a flush that fails partway leaves the entries it already assigned assigned.
| Database | Slot rule | Scope isolation |
|---|---|---|
| MySQL | Enforced | Different scopes proceed in parallel |
| PostgreSQL | Enforced | Different scopes proceed in parallel |
| SQLite | Enforced | One writer at a time, database-wide |
The correctness guarantees hold on all three. What differs is throughput: SQLite serializes writes at the database level, so the independence of different scopes is a property you see on the server databases.
The package's own suite runs its concurrency tests against all three, with real parallel processes rather than simulated interleaving.
Test races against the database you deploy on. SQLite lets some broken code pass, because its own serialization hides the window. A change to the open-slot rule needs a concurrency test on MySQL and PostgreSQL.
Retry the loser. SlotOccupied means somebody else took the slot. Whether
to retry with a different candidate, queue the work, or surface an error is a
decision the package cannot make for you.
try {
$assignment = $enquiry->autoAssign();
} catch (SlotOccupied) {
$assignment = $enquiry->autoAssign(); // the profile picks again
}Deadlocks. Package writes run inside transactions with a small number of automatic retries. Application transactions that wrap package calls and also touch other tables can still deadlock, in the ordinary way, and are yours to order sensibly.
Long transactions. A package call inside a long application transaction holds its locks until the outer transaction commits, including the scope row that serializes a rotation. Keep assignment near the end of a long transaction, or outside it.
Your own eligibility read. Between building a candidate list and the engine
selecting from it, a candidate can go offline. The engine will still assign
them. Where that matters, verify inside a listener on Assigned and reassign.