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·Jul 26, 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 (queuedprocessingcompleted). 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