Skip to main content

Database-Backed State Machines

Using database state to drive reliable workflow execution in distributed AI systems with atomic transitions, crash recovery, and idempotency keys.

5 min read·Intermediate·Concept·Aug 25, 2026
distributed systemsstate management

Database-Backed State Machines

What Was Built​

This article documents the database-backed state machine pattern as implemented across shorts-generator (SQLite stage checkpoints), md-ame (PostgreSQL RPC transitions with FOR UPDATE SKIP LOCKED), and edge-polymarket-agent (Postgres event bus with worker claim lifecycle). In each system, the database is the single source of truth for workflow progress.

The Problem​

Distributed AI workflows need to survive crashes, support concurrent workers, and prevent duplicate processing. In-memory state machines lose everything on process death. File-based state (JSON checkpoints, log files) races under concurrency and lacks atomic transition guarantees. You need a store that supports atomic state transitions with queryable history.

Why This Problem Is Difficult​

  1. Partial writes — updating status and output in separate queries leaves inconsistent state.
  2. Concurrent claims — two workers picking the same job causes duplicate LLM calls or trades.
  3. Crash mid-transition — the process dies between read and write.
  4. Recovery ambiguity — without explicit states, you cannot distinguish "in progress" from "failed" from "stuck".
  5. Idempotency at scale — retries must not create duplicate side effects.

Beginner Mental Model​

A database-backed state machine is a ledger with rules. Each row is a work item with a current status. Transitions follow allowed paths (queued → processing → completed). Only the database (via RPC or a single adapter) can change a status. Workers read the ledger, claim one item, do the work, and write the result back — atomically.

Requirements and Constraints​

Requirementshorts-generatormd-amepolymarket-agent
State storeSQLite (single file)Supabase PostgreSQLPostgreSQL event bus
Transition atomicityAdapter layer writesPostgreSQL RPC functionsEvent claim/complete API
Concurrent claimsSingle-process orchestratorFOR UPDATE SKIP LOCKEDFOR UPDATE SKIP LOCKED
IdempotencyContent-addressable IDsSHA-256 idempotency_keyEvent deduplication
RecoveryResume from last stageCron replay + recovery passUnclaimed events re-claimed
Schema changesPython migrationsAppend-only SQL migrationsVersioned DB migrations

Architecture Overview​

Execution Flow​

  1. Create work item — insert row with initial status and idempotency_key.
  2. Claim — worker selects next eligible row with row-level lock (FOR UPDATE SKIP LOCKED).
  3. Process — worker executes stage logic (may include LLM calls).
  4. Transition — atomically update status, output, and timestamps in one operation.
  5. Complete or fail — terminal states are explicit; failed items may retry with backoff.
  6. Recovery — orchestrator scans for stuck items (processing too long) and resumes or fails.

Important Components​

ComponentResponsibility
State table / event busStores current status and payload per work item
Claim queryFOR UPDATE SKIP LOCKED prevents double-claiming
RPC / adapter layerSole authority for multi-row transitions
Idempotency keyUnique constraint prevents duplicate work units
Recovery scannerFinds stalled items before new work generation
Dead-letter queueCaptures permanently failed items for inspection

Simplified Implementation Examples​

SQLite stage checkpoint (simplified):

# simplified — shorts-generator pattern
def record_stage_complete(video_id: str, stage: str, output: dict):
db.execute(
"INSERT INTO stage_results (video_id, stage, output, completed_at) "
"VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING",
(video_id, stage, json.dumps(output), now()),
)

PostgreSQL RPC transition (simplified):

-- simplified — md-ame pattern: atomic status update via RPC
CREATE FUNCTION transition_job_status(
p_job_id UUID, p_from TEXT, p_to TEXT
) RETURNS BOOLEAN AS $$
UPDATE jobs SET status = p_to, updated_at = now()
WHERE id = p_job_id AND status = p_from
RETURNING id IS NOT NULL;
$$ LANGUAGE sql;

Event bus claim (simplified):

# simplified — polymarket pattern
def claim_next(event_type: str) -> Event | None:
return db.fetchone("""
UPDATE events SET status = 'processing', claimed_at = now()
WHERE id = (
SELECT id FROM events
WHERE event_type = %s AND status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED LIMIT 1
) RETURNING *
""", (event_type,))

Reliability and Idempotency​

  • Atomic transitions: Status changes and output writes happen in a single database operation (RPC, transaction, or UPDATE ... RETURNING).
  • Claim isolation: SKIP LOCKED lets concurrent workers proceed without blocking on each other's claims.
  • Idempotency keys: ON CONFLICT DO NOTHING or unique constraints prevent duplicate work units on retry.
  • Recovery: Stuck detection uses timestamps — items in processing beyond a threshold are resumed or marked failed.

Failure Modes​

FailureBehaviour
Worker crash after claimItem stays processing; recovery scanner reclaims
Duplicate insertIdempotency key rejects duplicate
RPC transition raceWHERE status = p_from guard fails safely
Database unavailableWorkers fail fast; no silent in-memory fallback
Schema migration errorAppend-only migrations; rollback via new migration

Trade-offs and Rejected Alternatives​

StoreBest forLimitation
SQLiteSingle-machine pipelines (shorts-generator)No concurrent writers across processes
PostgreSQL RPCMulti-worker, cloud-hosted (md-ame, polymarket)Requires managed DB and migration discipline
In-memory statePrototyping onlyLost on crash
JSON file checkpointsSimple scriptsRace conditions under concurrency
RedisFast ephemeral queuesNot authoritative for complex state graphs

Testing​

Test state machines by asserting allowed transitions, rejecting invalid transitions, and verifying concurrent claim behaviour. All three source repos include tests for adapter/RPC layers and worker claim logic.

Operations and Observability​

  • Query stuck items: SELECT * FROM jobs WHERE status = 'processing' AND updated_at < now() - interval '30 minutes'
  • Monitor dead-letter queue depth for permanently failed events
  • Use structured logging with work-item IDs correlated to database rows

Lessons Learned​

  1. The database is the state machine — do not mirror state in application memory.
  2. RPC functions enforce invariants — move multi-row logic to the database boundary.
  3. SKIP LOCKED is essential — for concurrent workers without a central dispatcher.
  4. Explicit terminal states — every work item must end in completed, failed, or cancelled; never ambiguous processing forever.

Sources​