statecraft_

00 · methodology

statecraft vs statesman vs aasm

Three measurements, each honest about what it shows — including the ones statecraft does not win. Every figure below comes from one full run of the reproducible scripts in benchmark/; run them yourself with docker compose run --rm bench (smoke) or the full command from the benchmark README.

statecraft 0.3.0 · statesman 12.1.0 · aasm 5.5.2 · activerecord 8.1.3.1 · pg 1.6.3 · benchmark-ips 2.15.1 ruby 3.4.10 · PostgreSQL 16.15 · Docker (aarch64-linux) · 2026-08-25 corpus: 200,000 rows per stack (100,000 paid) · concurrency: 8 threads · ips: 5s runs, 2s warmup

The stacks are isolated: separate tables, separate models, identical data. Bars show iterations per second — one iteration is the whole described operation, and higher is better; bar widths are relative to each chart's leader. Numbers are a single machine's snapshot — the shape of the comparison is the point, not the absolute i/s.

01 · reading state

A column is a column; a join is a join

statecraft and aasm store current state in an indexed column — counting paid orders is the same index-only scan for both. statesman derives state from its transition table, so in_state(:paid) pays a most_recent join.

Honest notes. The column read is a tie by construction and measures as one: statecraft and aasm run the same index-only scan over identical tables. An earlier revision of this page showed a 1.46x gap here — that was a methodology bug, not a difference: sequential measurement over freshly bulk-loaded tables charged the first-measured stack for PostgreSQL's hint-bit writes and empty visibility map. The corpus is now vacuumed before any measurement and an interleaved cross-check confirms parity (~3.9 vs ~4.0 ms per query). statesman's 5.48x is architecture — the most_recent join. On the page load the ranking splits for a model-layer reason: instantiating 100 aasm records costs 3x plain ones — the aasm module rides every instance.

02 · eight writers, one record

What a race actually writes

Eight threads load the same pending order and fire the same transition. Correctness, not speed: who wins, what the losers see, what lands in the database.

gemsuccesseslosers seelog rowscallbacks firedlost updates
statecraft1 of 87 × Statecraft::TransitionConflict — deterministic, rescuable, savepoint already rolled back110
statesman1 of 87 × Statesman::TransitionConflictError — caught via the unique index on the transition INSERT110
aasm8 of 8nothing — every thread believes it won87, state overwritten silently

Honest notes. aasm without an explicit lock is not broken — it simply makes no concurrency promise: guards run on a stale snapshot, callbacks fire once per "winner", and the column ends up written eight times. Both statecraft and statesman keep exactly one truth; the difference is the shape of the refusal — a first-class conflict error from a CAS UPDATE versus a unique-index violation surfacing from an INSERT.

03 · the price of one transition

What the audit log costs

One uncontended pay/reset round trip per iteration. Every statecraft iteration writes two log rows inside savepoints with CAS updates; aasm does two plain UPDATEs; statesman two INSERTs plus most_recent flips.

Honest notes. This is the benchmark statecraft was expected to lose: a savepoint, a CAS UPDATE and a log INSERT against aasm's single UPDATE. Measured on PostgreSQL, the difference lands within the run's error margin (~12% on the mean) — commit latency dominates, and the audit log rides along almost for free. It is still not free: if you need neither the log nor conflict detection, aasm's write path is the cheapest by construction.

04 · when to pick what

Three different promises

pickwhenyou accept
statecraftstate lives in a column, every transition must be race-safe and leave an append-only audit row with write-once metadataa savepoint + CAS + log INSERT on every write; PostgreSQL first
statesmanhistory is the first-class record and you prefer no state column at alla most_recent join on every read; conflicts surface as index violations
aasmyou need a readable DSL and neither an audit log nor concurrency guaranteeslost updates under concurrent writers; the cheapest single-writer path there is

All three are fine gems. This page exists so the trade-off is measured, not vibes-based — rerun it on your hardware before deciding: docker compose run --rm bench.