PostgreSQL-backed actor framework for Python β built on Trio.
No Redis. No RabbitMQ. No Celery. Just PostgreSQL and Python.
Most task-queue and actor frameworks bolt on an external broker, add operational
complexity, and scatter your system state across multiple datastores. trio-pg-actors
turns your existing PostgreSQL database into a reliable, observable message bus β with
actors, pub/sub, delayed messages, dead-letter queues, and auto-diff schema migrations,
all in one library.
Actors are stateless workers that load their state from PostgreSQL before every message and persist it back after. You never think about in-memory state management or node affinity β any worker on any machine can process any message.
Messages are rows in pg_actor_messages. They survive restarts, crashes, and
deployments. No message is ever lost once it is INSERTed into the database.
Workers sleep until PostgreSQL sends a NOTIFY on pg_actor_new_msg. No polling
loops. No wasted CPU. New messages wake workers in milliseconds.
The listener uses psycopg3's native conn.notifies() async generator β clean,
no callbacks.
Schedule any message to be delivered in the future:
await actor.remind_me(RetryPayment(order_id=42), delay=300) # 5 minutes laterFailed messages are retried up to max_retries times. Each retry waits
min(2^n, 60) seconds. Messages that exhaust retries (or raise ValidationError)
go to the dead-letter table automatically.
Every message that cannot be processed ends up in pg_actor_dead_letters with the
full error text. Easy to inspect, replay, or archive.
Each tenant gets its own PostgreSQL schema. The message queue is shared
(public.pg_actor_messages), but actor state is isolated per schema
(<tenant>.pg_actor_state). Call system.ensure_schema("tenant_xyz") and you're done.
MigrationManager compares your Python schema dict against the live
information_schema and generates JSON migration files β only for what actually
changed. Apply them atomically in a transaction. Roll back the last migration with
one call.
target = {
"orders": {"id": "TEXT PRIMARY KEY", "amount": "DOUBLE PRECISION"},
}
await mgr.create_migration("add_orders", target)
await mgr.apply_migrations()Actor state can evolve across deployments without downtime. Override
migrate_state() to transform old state shapes on the fly β the first time an
actor processes a message after an upgrade, its state is migrated in the same
transaction.
class OrderActor(VirtualActor):
schema_version: int = 2
amount: float = 0.0
currency: str = "USD" # new in v2
@classmethod
def migrate_state(cls, old_version: int, state: dict) -> dict:
if old_version < 2:
state.setdefault("currency", "USD")
return stateWorkers run inside a trio.Nursery. If one worker crashes, Trio's structured
concurrency guarantees a clean, predictable shutdown of the entire system β no
silent half-dead processes.
| asyncio version | trio version |
|---|---|
asyncio.Event |
trio.Event (recreated per cycle) |
asyncio.gather(*workers) |
nursery.start_soon(worker) Γ N |
asyncio.wait_for(e, timeout=3) |
with trio.move_on_after(3): await e.wait() |
| asyncpg callback LISTEN | async for notify in conn.notifies() |
A background task periodically deletes old done messages (default: 7-day
retention). Configurable, runs in the same nursery as the workers.
AsyncDBOperations provides a safe, injection-proof query builder with:
- Rich
where-dict operators:__gt,__lte,__in,__notin,__isnull,__like,__ilike,__neq OR/ANDgrouping- JSONB operators:
{"metadata->>'theme'": "dark"} JOINwith collision resolution (prefixed keys + nested sub-dicts)FOR UPDATE [SKIP LOCKED]/FOR SHARErow lockinginsert_many/update_many/delete_manyaggregateandgroup_by
pip install trio-pg-actors
# or with uv:
uv add trio-pg-actorsRequirements: Python 3.11+, PostgreSQL 14+
# myapp/actors.py
from trio_pg_actors import BaseMessage, VirtualActor, actor, message, subscribe
@message
class CreateOrder(BaseMessage):
user_id: str
amount: float
@message
class OrderCreated(BaseMessage):
order_id: str
amount: float
@actor
class OrderActor(VirtualActor):
total_orders: int = 0
total_revenue: float = 0.0
async def on_create_order(self, msg: CreateOrder) -> None:
self.total_orders += 1
self.total_revenue += msg.amount
# fan-out an event to all subscribers
await self.publish(
target_id=msg.user_id,
event=OrderCreated(order_id=self._actor_id, amount=msg.amount),
)
@subscribe(OrderCreated)
@actor
class EmailActor(VirtualActor):
emails_sent: int = 0
async def on_order_created(self, msg: OrderCreated) -> None:
# send confirmation email here
self.emails_sent += 1# main.py
import trio
from trio_pg_actors import PgActorSystem
DSN = "postgresql://user:password@localhost/mydb"
async def main():
system = PgActorSystem(
dsn=DSN,
node_name="worker-1",
discover_packages=["myapp.actors"], # auto-imports your actor modules
)
async with system.run() as nursery:
# start 4 worker tasks + GC + LISTEN/NOTIFY listener
nursery.start_soon(system.run_workers, nursery, 4)
# send a message β picked up by the next free worker
await system.tell(
target_type="OrderActor",
target_id="order-001",
msg=CreateOrder(user_id="alice", amount=99.90),
)
await trio.sleep_forever() # run until Ctrl+C
trio.run(main)async def onboard_tenant(system: PgActorSystem, tenant_id: str) -> None:
# creates schema + isolated pg_actor_state table
await system.ensure_schema(tenant_id)
await system.tell(
target_type="OrderActor",
target_id="order-001",
msg=CreateOrder(user_id="bob", amount=49.00),
schema_name=tenant_id, # state is isolated to this tenant
)src/trio_pg_actors/
βββ __init__.py # public API re-exports
βββ base_msg.py # BaseMessage (immutable, UTC timestamp, JSONB helpers)
βββ database.py # AsyncDBOperations + MigrationManager (psycopg3)
βββ core.py # VirtualActor, PgActorSystem, decorators (trio)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PostgreSQL β
β β
β public.pg_actor_messages β INSERT (tell/publish) β
β public.pg_actor_dead_letters β
β public.pg_actor_state β actor state (JSON) β
β <tenant>.pg_actor_state β isolated per tenant β
ββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ
β LISTEN / NOTIFY
ββββββββββββΌβββββββββββββββββββ
β PgActorSystem β
β βββββββββββββββββββββββ β
β β Trio Nursery β β
β β β Worker-0 β β
β β β Worker-1 ββββββββΌβββββ€ββΊ SELECT ... FOR UPDATE SKIP LOCKED
β β β Worker-N β β dispatch β persist state β mark done
β β β GC task β β
β β β Listener task β β
β βββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββ
Every worker claims a batch of messages atomically with FOR UPDATE SKIP LOCKED β
no message is ever processed twice, even with many workers across many machines.
tell() / publish()
β
βΌ
pg_actor_messages (status = 'pending')
β
βΌ worker claims batch
dispatch to actor handler
β
βββββ΄βββββββββββββββββββββββ
β success β failure
βΌ βΌ
status = 'done' retries < max_retries?
β yes β backoff, status stays 'pending'
β no β pg_actor_dead_letters
βΌ status = 'dead'
ValidationError β dead immediately
MIT