The problem
Any state machine that persists to a shared database has a race condition waiting to happen. Two processes read state = “sent” at the same moment, both decide the transition to “paid” is legal, both write the row. Now the audit trail says the invoice was paid twice, or worse, two payment providers were both charged.
Hand-rolled state machines usually paper over this with a WHERE state = ‘sent’guard on the UPDATE. Better than nothing, but the moment your business logic is anything more than a single-column update (send an email, call an API, notify a queue), the guard doesn’t cover the whole transition.
What stateledger does by default
Every transitionTo call acquires a Postgres advisory lock scoped to (machineName, subjectId). Only one process at a time can hold this lock for a given subject.
-- What stateledger runs (simplified):
SELECT pg_advisory_xact_lock(hashtext($1 || $2));
-- $1 = machine name, $2 = subject idThe lock is held for the duration of the enclosing transaction, then released automatically on COMMIT or ROLLBACK. If another process tries to acquire the same lock, it blocks until the first is done | no busy-loops, no explicit release code.
Why advisory instead of row locks?
You could hold a row lock (SELECT … FOR UPDATE) on the latest transitions row. But that couples locking to a specific row that may or may not exist yet (first transition creates the row) and doesn’t serialise across the whole subject.
Advisory locks are application-defined, cheap, and scoped to whatever key you want. They’re Postgres’s native answer to “serialise this business operation, not a specific row.”
Optimistic locking (alternative mode)
If you don’t want the blocking behaviour, switch the adapter to optimistic mode. stateledger will attempt the transition without a lock; if another process transitioned between your read and write, the second attempt fails with OptimisticConcurrencyError.
const adapter = createPrismaAdapter(prisma, { locking: "optimistic" });
try {
await invoice.transitionTo("paid");
} catch (err) {
if (err instanceof OptimisticConcurrencyError) {
// Someone else transitioned this invoice. Re-read and decide what to do.
const current = await invoice.readCurrent();
// ...
}
throw err;
}What about serverless / short-lived connections?
Advisory transaction locks (pg_advisory_xact_lock) auto-release when the transaction ends, so serverless is fine as long as you run each transition inside a real transaction (which stateledger does by default).
If you use a connection pooler like PgBouncer in transactionmode, that’s fine too — the lock lives with the transaction, not the session. Avoid PgBouncer in session mode combined with reused connections; that can cause spurious lock conflicts.
Performance
Advisory locks are one of the cheapest operations in Postgres | a hash lookup in shared memory. In practice the transition throughput is bound by the INSERT into the transitions table, not the lock.
A single subject can transition ~1000 times/second on modest hardware. Different subjects have independent locks and don’t contend at all, so total throughput scales linearly with distinct subject ids.