Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ADR-0005: The session lock lives behind an optional interface

Status: Accepted · Reference: PLAN.md §7 decisions 19, 20

Context

Two test processes running in parallel can both decide a cassette is missing and both start recording into it. Whoever writes last wins, and the loser’s interactions vanish — or worse, the two interleave into a file that replays as neither run.

A lock fixes it, but not every store can take one. A filesystem can; an object store or an in-memory persister used in the library’s own tests cannot, and a store that cannot lock is still a perfectly good store for replaying.

Decision

CassettePersisterInterface stays the minimum a store must do: read, write, delete, exists, list, describe. Locking is a second, optional interface — Persistence\SupportsSessionLocking with lock() and unlock(). A persister that implements it gets locked recording sessions; one that does not still works for playback.

The lock is held for the whole recording session, not just around the write.

Consequences

Good. Implementing a store stays cheap. The library asks instanceof once and adapts. Holding the lock for the session rather than the write is what actually prevents interleaving: a lock taken only at write time would let two runs both read an empty cassette, both record different interactions, and both write “correctly”.

Bad. A parallel run against a non-locking store has no protection, and the library cannot warn about it usefully because the same store is the right answer for replay-only suites. The trade is documented rather than solved.

Related. describe(string $key): string is on the main interface so exception messages can say where a cassette was expected — a filesystem path, an object key, whatever the store considers a location. Without it, “cassette not found” cannot say not found where.