Database approval stores¶
Available · from agent_adapter.approval import SQLAlchemyApprovalStore, DynamoDBApprovalStore
Two more approval stores keep parked calls in a database you already run: Postgres through SQLAlchemy 2 (async), and DynamoDB for AWS serverless code that has no Redis. Both pass the same shared test suite as the other stores, and both keep the safety model: only an adapter-issued decision, consumed once, lets a paused call run.
sequenceDiagram
participant W as web process
participant S as SQLAlchemy / DynamoDB store
participant R as reviewer
participant K as worker (another host)
W->>S: create (atomic per run + call)
R->>S: list, decide (first decide wins)
K->>S: find, consume (true exactly once)
K->>K: run the tool with the decided arguments
| Store | Install | Use when |
|---|---|---|
SQLAlchemyApprovalStore(engine, *, table="aa_approvals", create_schema=True) |
agent-adapter[sqlalchemy] plus a driver (asyncpg) |
your service has Postgres |
DynamoDBApprovalStore(client_factory, *, table, ttl_s=604800) |
agent-adapter[dynamodb] (aioboto3) |
Lambda or other AWS code; no Redis to run |
SQL: Postgres through SQLAlchemy¶
def postgres_store(url: str = "postgresql+asyncpg://app@db/app") -> SQLAlchemyApprovalStore:
"""Needs the sqlalchemy extra; one engine per event loop; creates its table on first use."""
return SQLAlchemyApprovalStore(url, table="aa_approvals")
The same flow runs on every store: the web process pauses, a reviewer decides, a worker on another host resumes once.
web = Executor(registry, approval=DeferredApproval(web_store))
first = await web.run_call(CALL, web.context(run_id=RUN_ID))
print("web:", first.status) # pending: nothing ran
[waiting] = await web_store.list(run_id=RUN_ID, status="pending")
edit = {"order_id": "o-7", "amount": 450}
await web_store.decide(waiting.approval_id, Decision(approved=True, edited_args=edit))
worker = Executor(registry, approval=DeferredApproval(worker_store)) # another host
resumed = await worker.run_call(CALL, worker.context(run_id=RUN_ID))
replay = await worker.run_call(CALL, worker.context(run_id=RUN_ID))
print("worker:", resumed.status, resumed.value, "| replay:", replay.status)
| Rule | Detail |
|---|---|
engine |
a URL (postgresql+asyncpg://…) or a callable returning an AsyncEngine; one engine per event loop (LoopLocal); await store.aclose() disposes the running loop's engine |
| Dialects | Postgres, and SQLite (sqlite+aiosqlite://, for tests); others raise ConfigError on first use |
| Schema | one table (table, default aa_approvals); ids are binary (bytea), so any id survives |
| First use | runs once per store and loop (concurrent first calls wait for it); create_schema=True runs CREATE TABLE/INDEX IF NOT EXISTS in one transaction (Postgres takes an advisory lock first), so racing hosts are safe; a failed CREATE logs a WARNING with the type, then the checks decide; a table without the store's columns, or without a unique index on (run_id, call_id) WHERE consumed = 0, raises ConfigError |
| Retention | rows stay until you delete them: await store.prune(older_than_s=…) deletes older records (a WARNING counts any never used) |
| Outages | database errors propagate (logged at ERROR with the type only); nothing is approved |
SQL: what it runs¶
| Step | SQL |
|---|---|
| schema | partial unique index aa_approvals_open on (run_id, call_id) WHERE consumed = 0 |
create |
INSERT … ON CONFLICT DO NOTHING RETURNING seq; on a conflict, SELECT the call's unconsumed row |
decide |
UPDATE … WHERE approval_id = … AND status = 'pending' RETURNING seq: one winner |
consume |
UPDATE … SET consumed = 1 WHERE approval_id = … AND consumed = 0 AND status <> 'pending' RETURNING seq: a returned row means True |
list |
SELECT … ORDER BY seq DESC LIMIT n, indexed by run and by status |
Alembic¶
Let Alembic own the table in production: pass create_schema=False and add the
store's metadata to target_metadata, then autogenerate a migration.
def alembic_target_metadata(store: SQLAlchemyApprovalStore) -> list[object]:
"""In Alembic's env.py: add the store's table to your own metadata."""
from sqlalchemy import MetaData
app_metadata = MetaData() # your models' metadata
return [app_metadata, store.metadata]
DynamoDB: AWS serverless¶
def dynamodb_store(region: str = "eu-west-1") -> DynamoDBApprovalStore:
"""Needs the dynamodb extra; one client per event loop; items expire after a week."""
import aioboto3
session = aioboto3.Session()
return DynamoDBApprovalStore(
lambda: session.client("dynamodb", region_name=region),
table="approvals",
ttl_s=7 * 24 * 3600,
)
| Rule | Detail |
|---|---|
| Client | client_factory() returns an async DynamoDB client, or the context manager session.client("dynamodb") gives (it is entered once per event loop and exited by aclose) |
| Table | create it with your infrastructure code: DynamoDBApprovalStore.table_definition(name) returns the CreateTable arguments (key pk, GSIs by_run and by_status, on-demand billing) |
| TTL | enable DynamoDB TTL on expires_at; items expire ttl_s after creation; an expired item reads as absent at once, though DynamoDB deletes it later; ttl_s=None never expires |
| Atomic steps | create is one TransactWriteItems of two conditional puts (the record, and the call's open marker); decide and consume are conditional updates that compare the stored record |
| Consistency | get, find, create, decide and consume use strongly consistent reads; list reads a GSI, so a new record can take a moment to appear |
| Outages | client errors propagate, logged at ERROR with the exception type, the AWS error code and any cancellation reasons; throttling and validation errors are never retried as races |
| Lost races | only ConditionalCheckFailed or TransactionConflict (for every cancelled item) counts; it retries up to 5 times with full-jitter backoff (20 ms doubling, 0.5 s cap), then raises ApprovalStoreCorrupt |
| Throughput | every create adds 1 to the one meta#seq item, so creates per table are capped by one item's write rate (about 1,000 a second); decide, consume and reads are not |
| Item | Key pk |
Holds |
|---|---|---|
| record | rec#<id> |
the record JSON, ids, st (status), consumed, seq, expires_at |
| open marker | open#<len>:<run>:<call> |
the id of the call's newest record |
| counter | meta#seq |
n, the creation order list sorts by |
Pick ttl_s longer than your slowest review plus the resume: an expired decision is
never consumed, so the resumed call asks again (it fails closed, never runs).
Access and permissions¶
Anyone who can write the table can forge an approval. Treat it like a credential store.
| Store | Do |
|---|---|
| SQLAlchemy | a database role per service with SELECT, INSERT, UPDATE, DELETE on the table only (plus CREATE while create_schema=True); TLS to the database |
| DynamoDB | an IAM policy on the table and its indexes only: GetItem, UpdateItem, PutItem, Query, TransactWriteItems; no other writer |
Failing closed¶
Both stores check every row they read, as the other durable stores do: a row that is
not an ApprovalRecord, whose digest disagrees with its request, or whose indexed
columns disagree with the record raises ApprovalStoreCorrupt before anything is
consumed. A DynamoDB open marker that points at another call's record does too, in
find and in create.
Files¶
| File | Does |
|---|---|
approval/sqlalchemy_store.py |
SQLAlchemyApprovalStore: statements, one-time setup per loop, prune |
approval/sqlalchemy_schema.py |
the table, its CREATE … IF NOT EXISTS DDL, and the column and index check |
approval/dynamodb_store.py |
DynamoDBApprovalStore: create transaction, compare-and-set, list paging |
approval/dynamodb_items.py |
item keys and attributes, table_definition, the per-loop client |
approval/persist.py |
shared with the other durable stores: record codec and row checks |
examples/approval_stores_sql.py |
the snippets above; runs offline on SQLite |
Data enters through create and decide and leaves through get, find, list and
consume, as for every approval store.