Testing race guards¶
Runabout runs one step at a time in one process, so it can't produce real concurrency, and neither can PHPUnit. Check-then-act races are still ordering bugs, and they are the kind journeys exist to find:
- an existence check followed by an insert ("one open request per user");
- a stock check followed by an allocation;
- an availability check followed by an assignment.
Apps guard these with a row lock and a re-check, but nothing tests that the guard is there, or that it works. $ctx->racing() does, by injecting the interference deterministically.
racing()¶
Step::make('request item')
->act(fn (Context $ctx) => $ctx->racing(
table: 'item_requests',
competitor: fn () => RequestItem::run($ctx->instance('item', Item::class), $ctx->as('member')->user()),
request: fn () => $ctx->as('member')->postJson("/items/{$ctx->integer('item id')}/request"),
));
While request runs, Runabout watches the queries it makes. At an unlocked read of table, after the read returns and before the app can act on it, the competitor runs: the effect of a parallel request landing in the check-then-act window. Then the request carries on with what it read, now out of date. racing() returns whatever request returns.
Your invariants judge the result (no duplicates, no over-allocation), so no new assertions are needed. Invariants::uniqueBy() is the usual partner for an existence check.
- Locked reads aren't interfered with. A
SELECTwithFOR UPDATE,FOR SHAREorLOCK IN SHARE MODE(Laravel'slockForUpdate()andsharedLock()) would hold a real competitor back, so injecting there would be a false positive. A correctly guarded path passes. Once the request has locked a row of the table inside a transaction it opened, later reads aren't interfered with either, until that transaction ends: a real competitor would be waiting on the lock. A lock taken outside such a transaction is released at once, as in production, so it doesn't count. - Which read is seeded. At each unlocked read of the table the step draws whether to interfere there, usually at the first. The choice replays from the seed or
RUNABOUT_TRAIL, and shrinking moves it to the earliest read that still fails. - At most once per request. The competitor runs once at most, and its own queries are never interfered with.
- The competitor should call the app's own code, such as the action or service the endpoint uses, so it writes the logs, counters and events a real request would.
Only steps that call racing() draw anything extra, so other steps' seeds and artifacts replay unchanged.
Drivers¶
Interference needs a driver whose SQL carries lock clauses, so a locked read can be told from an unlocked one: MySQL, MariaDB or Postgres. Laravel's SQLite grammar drops them. On SQLite, racing() runs the request without interference, the rest of the journey runs as normal, and the run prints a note on stderr once. Run race journeys against the database you deploy on.
Limits¶
- One competitor, at one interleaving point, per
racing()call. - Competitors are written by hand.
- Unlocked reads are found from the SQL text: a
SELECTnaming the table afterFROMorJOIN, without a lock clause. A read through a view, or a schema-qualified name, isn't matched. - The competitor runs on the same connection and in the same transaction as the request, so it sees the request's uncommitted writes. A real parallel request would not.