›
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 is lost without an error.
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 comes from a unique index on the assignable and an internal
slot column, not from an application check. A check in PHP leaves a window
between the read and the write. 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. The lock serializes selections that share a scope.
As a result:
If the selected candidate's slot turns out to be taken, the transaction rolls back, and the cursor rolls back with it. A failed attempt does not consume a turn.
When an accept races a decline, one of them wins. The engine locks the offer,
checks the assignee against the locked row, and checks the state after it takes
the lock. The loser gets OfferNotOpen and does not overwrite the first
answer.
The engine advances an expired offer once, even when the tick and a delayed job both process it. Expiry claims the row before ending it, and a claim that finds the row already ended does nothing.
The claim makes it safe to run the tick and dispatch_expiry_jobs together.
Two flushes running at once do not assign the same entry twice. A flush claims each entry with a row lock inside its own transaction, and skips an entry that another flush already took.
Each entry commits on its own. A flush that fails partway keeps the assignments it already made.
| 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. Throughput differs. SQLite serializes writes at the database level, so different scopes proceed in parallel only on MySQL and PostgreSQL.
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 it serializes writes and so hides the race 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. Your own transactions that wrap package calls and also touch other tables can still deadlock. You choose the lock order in those transactions.
Long transactions. A package call inside a long application transaction holds its locks until the outer transaction commits. Those locks include 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 still assigns that
candidate. If that matters, check the candidate inside a listener on
Assigned and reassign.