Running a cluster¶
Run the same worker process on several machines against one Postgres (or Redis) database. Runs are spread across workers, and when a worker dies another finishes its work.
flowchart LR
C[Client / API server<br/>Engine + ClientBus] -->|append run.started| DB[(Postgres or Redis<br/>events + bus outbox)]
subgraph Workers
W1[Worker 1<br/>Engine + DistributedBus]
W2[Worker 2]
W3[Worker 3]
end
DB <-->|consume partitions<br/>append results| W1
DB <--> W2
DB <--> W3
The worker¶
"""A cluster worker. Run N copies of this process against the same Postgres (or Redis) database.
Not executed by the test suite (it needs a database and runs forever); the cluster behavior is
covered by tests/unit/test_multiprocess.py, which launches real worker processes and SIGKILLs them.
Requires: pip install "autonomous-agent-algos[postgres]" and a reachable PostgreSQL."""
import asyncio
import os
from aaa.algos import ToolLoop
from aaa.events import DistributedBus
from aaa.events.postgres import (
PostgresEventStore,
) # or: from aaa.events.redis import RedisEventStore
from aaa.llm import LLMHandler, build_client, gateways
from aaa.runtime import Engine
from aaa.tools import GuardConfig, ToolHandler, ToolRegistry, tool
@tool(delivery="at_most_once")
async def send_email(to: str, subject: str) -> str:
"""Send an email."""
return "sent"
async def main() -> None:
store = await PostgresEventStore.create(os.environ["DATABASE_URL"], partitions=16)
bus = DistributedBus(store.bus_backend()) # partitions, leases, rebalancing, at-least-once
registry = ToolRegistry([send_email])
async with Engine(store=store, bus=bus, sweep_interval_s=5) as engine:
LLMHandler(build_client(gateways.portkey("gpt-4o-mini"))).attach(engine)
ToolHandler(registry, guard=GuardConfig(lease_ttl_s=30)).attach(engine)
engine.register(ToolLoop(registry)) # every worker registers the same algorithms and tools
await asyncio.Event().wait() # work until killed
if __name__ == "__main__":
asyncio.run(main())
Every worker runs identical code: the same algorithms (same names), the same tools, the same handlers. Start as many as
you like.
The client¶
A process that only submits and observes runs (an API server, a CLI) uses ClientBus, which delivers and consumes
nothing, so the process never becomes a worker.
"""A submit-only client (an API server, a CLI): starts runs and waits for results, hosts no work.
Not executed by the test suite (needs a database). Pair it with examples/cluster_worker.py."""
import asyncio
import os
from aaa.algos import ToolLoop
from aaa.events import ClientBus
from aaa.events.postgres import PostgresEventStore
from aaa.runtime import Budget, Engine
from aaa.tools import ToolRegistry
async def main() -> None:
store = await PostgresEventStore.create(os.environ["DATABASE_URL"], partitions=16)
async with Engine(store=store, bus=ClientBus()) as client:
run = await client.start(
ToolLoop(ToolRegistry([])), # must match the workers' algorithm name
{"task": "Email the weekly report to the team"},
run_id="weekly-report-2026-w42", # idempotent: starting the same id twice is safe
budget=Budget(max_cost=0.50, deadline_s=300),
)
outcome = await run.result() # works from any process, whichever worker did the work
print(outcome.kind.value, outcome.value)
await store.aclose()
if __name__ == "__main__":
asyncio.run(main())
run_id makes submission idempotent: retrying after a timeout cannot start a second run. await run.result() works from any
process, even one that did not start the run.
These two examples are compile-checked, not executed
They need a database and run forever. The behavior they show is covered by tests/unit/test_multiprocess.py, which
launches real worker processes, kills them with SIGKILL (including in the middle of a tool call) and verifies every
run still completes.
How it works¶
- Atomic enqueue. Appending an event also writes it to a per-partition outbox, in the same Postgres transaction (or Redis Lua script). Nothing stored can fail to be delivered.
- Partitions. An event goes to partition
crc32(stream) % P. All events of one run share a partition, so they are consumed in order. - Consumer groups. A group is a logical consumer: "algorithm
tool_loop", "the LLM handler", "the tool handler". Within a group, each partition has exactly one owner at a time (a lease), which consumes it in order and commits a cursor. - Rebalancing. Workers heartbeat their membership. Each takes a fair share of partitions (
ceil(P / members)). Joiners trigger others to hand back extras; leavers' partitions are picked up when their leases lapse. - At-least-once. A new owner resumes from the last committed cursor. Anything that was unacknowledged is redelivered; idempotent appends absorb the repeats.
- Adoption. Any worker can host any run, because run state (spend, completion, open children) is a fold of the log. An algorithm event arriving for an unknown run causes the worker to build that state from a snapshot plus the tail.
Consumer groups you will see in lag dashboards: aaa.algo.<name> (one per algorithm), aaa.llm, aaa.tool.request,
aaa.tool.granted, aaa.tool.denied, aaa.validate, aaa.engine.cancel. Subscriptions with group=None are broadcasts
(every worker sees every event, e.g. cancelling a run's local tool tasks).
Setting it up¶
from aaa.events import DistributedBus
from aaa.events.postgres import PostgresEventStore
store = await PostgresEventStore.create(
"postgresql://user:pw@host/db",
partitions=16, # fixed for the life of the cluster
max_pool=10, # connections for queries (plus 2: one LISTEN, one for leases)
)
bus = DistributedBus(store.bus_backend())
Creates its tables (aaa_events, aaa_outbox, aaa_partitions, aaa_cursors, aaa_members, aaa_snapshots, aaa_meta)
on first use, safely under concurrency. Wake-ups use LISTEN/NOTIFY; if that connection drops, workers fall back to
polling (slower, never incorrect).
from aaa.events.redis import RedisEventStore
store = await RedisEventStore.create("redis://host:6379/0", partitions=16, prefix="aaa")
bus = DistributedBus(store.bus_backend())
Events and the bus are Redis Streams; one Lua script appends atomically (OCC check, de-duplication, event + bus entry).
Keys carry a {<partition>} hash tag (e.g. aaa:{3}:ev:<stream>) so a partition's keys share a cluster slot.
DistributedBus parameters¶
| Parameter | Default | |
|---|---|---|
member |
random id | This worker's name; set it to something recognizable (hostname-pid). |
lease_ttl_s |
6.0 |
How long a silent worker keeps its partitions (Redis/memory; Postgres leases are session-bound). |
tick_s |
1.0 |
Rebalance/heartbeat period. Keep it well below lease_ttl_s. |
poll_s |
0.5 |
Maximum wait for new events when idle. |
batch |
64 |
Entries read per fetch. |
handover_timeout_s |
30.0 |
On graceful shutdown or rebalance, how long to wait for in-flight work before releasing a partition. |
Failure behavior¶
| What happens | Result |
|---|---|
A worker is killed (SIGKILL) |
Postgres: its session ends, leases free at once. Redis: leases lapse after lease_ttl_s. Another worker takes the partitions and resumes from the cursor; unacknowledged events (including a tool call that was running) are redelivered. |
| A worker stalls (GC pause, VM freeze) | Redis: replaced after lease_ttl_s; if it wakes, its lease renewal fails and it stops. A tool it was running is fenced by the execution guard. Postgres: a frozen but still-connected worker keeps its lease until it resumes or its connection dies; configure TCP keepalives or idle_session_timeout if you need to evict such workers. |
| A worker leaves gracefully | It finishes in-flight work (up to handover_timeout_s), commits cursors, releases leases and leaves the membership. Partitions move immediately. |
| A worker joins | Existing workers hand back extra partitions on their next tick. |
| The database becomes unavailable briefly | Handlers retry with backoff (store_retry); beyond that the bus redelivers the event until it succeeds. A tool result is retried until recorded (or the message is left unacknowledged if you bounded result_retry). Runs are not failed because the store was down. Tested with real killed connections on Postgres and Redis. Not tested: a full server restart or managed failover: drill it in staging. See Status and limitations. |
| The notify/listen connection drops (Postgres) | Polling takes over. |
| Two workers briefly own a partition (stall edge case) | Both may process an event; the second's appends are duplicates and are dropped. Tools are protected by the guard. |
Requirements and constraints¶
- Unique, identical algorithm names on every worker. Algorithm
nameis the routing key. Two instances with the same name in one process raise a clear error at registration. - Identical tools on every worker (a worker lacking a tool would reject calls as
UnknownTool). Heterogeneous worker pools, e.g. GPU vs CPU tools, are not supported yet. - The partition count is fixed per database/prefix and checked on connect. Choose headroom: 16 supports up to 16 workers per group in parallel; 64 is fine for most.
- Run
sweep_interval_s(say5) on at least one worker so deadlines of dead owners and lost child announcements are repaired. - Clock skew matters little: lease expiry uses the lease store's own clock (Redis
TIME, Postgresnow()), and deadlines compare event timestamps written by workers, so keep NTP running.
Maintenance¶
- Trim the bus outbox: it grows with every event. Run, e.g. daily:
await store.trim_outbox(keep_last=100_000)(per partition). A consumer that falls further behind than that loses the gap, so size it to your worst outage. Postgres deletes rows; Redis trims the stream approximately. - Monitor lag: for a group
gand partitionp, lag isawait backend.tail(p) - await backend.get_cursor(g, p). Alert when it keeps growing. - Connections (Postgres): each worker uses up to
max_pool + 2connections. Sizemax_connectionsaccordingly. - Backups: the event tables are the source of truth. Back them up like any ledger. Snapshots (
aaa_snapshots) and the outbox are derived and disposable.
Redis¶
Redis is a fine bus and a workable store, but understand its trade-off: a Redis without persistence loses the event log on
restart. For an event log use AOF with appendfsync everysec or always, a replica, or prefer Postgres as the system of
record. Redis Cluster compatibility is by design (hash-tagged keys) but only a single node has been tested.
Testing a cluster¶
The repository's tests run the same scenarios against memory, Postgres and Redis backends and launch real worker processes.
Look at tests/unit/test_distributed_bus.py, test_cluster_engine.py and test_multiprocess.py for patterns you can reuse.