Persistence and recovery¶
By default an Engine keeps everything in memory. Give it a durable store and runs survive restarts; add recover() and
a crashed process's open runs are continued.
Choosing a store¶
| Store | Durability | Processes | Use for |
|---|---|---|---|
InMemoryEventStore |
none | one | tests, notebooks, short-lived scripts |
SQLiteEventStore(path) |
disk (WAL) | one engine (readers are fine) | a service on one machine, local development |
PostgresEventStore.create(dsn) |
database | many | production, clusters |
RedisEventStore.create(url) |
your Redis persistence settings | many | low-latency clusters where Redis already runs |
All implement the same EventStore protocol and pass the same contract tests, so switching is a one-line change.
from aaa.events import SQLiteEventStore
store = SQLiteEventStore("agent.db") # creates the file and schema
async with Engine(store=store) as engine:
...
await store.aclose()
SQLite specifics¶
- Pure standard library (no extra install). One dedicated thread owns the connection, so the event loop never blocks on disk.
- WAL mode,
synchronous=NORMAL: crash-safe (the file cannot be corrupted), but a power loss may lose the last few commits (never reorders or tears them). For strict durability passSQLiteEventStore(path, synchronous="FULL"). - Appends are single
IMMEDIATEtransactions: the optimistic-concurrency check and the inserts are atomic, and a failed append leaves nothing behind. - One engine process per database file. The in-process bus has no cross-process coordination; two engines on one file would both react to the same events. For multiple processes use Postgres or Redis.
Recovering after a crash¶
"""Durable runs: kill the process mid-run, start a new one, and the run finishes where it left off."""
import asyncio
import tempfile
from pathlib import Path
from aaa.algos import ToolLoop
from aaa.events import SQLiteEventStore
from aaa.llm import FakeLLMClient, LLMHandler
from aaa.llm.fake import text, tool_call
from aaa.runtime import Engine
from aaa.tools import ToolHandler, ToolRegistry, tool
@tool
def lookup(key: str) -> str:
"""Look something up."""
return f"value-of-{key}"
class HangsOnSecondCall:
"""A model whose second call never returns, like a process that dies mid-request."""
calls = 0
async def complete(self, request):
self.calls += 1
if self.calls == 1:
return tool_call("lookup", key="x")
await asyncio.Event().wait()
async def embed(self, *a, **k): ...
async def aclose(self): ...
async def boot(db: Path, llm):
store = SQLiteEventStore(db)
engine = await Engine(store=store).__aenter__()
LLMHandler(llm).attach(engine)
registry = ToolRegistry([lookup])
ToolHandler(registry).attach(engine)
return engine, ToolLoop(registry), store
async def main() -> None:
db = Path(tempfile.mkdtemp()) / "agent.db"
# --- process 1: starts a run, then "crashes" with the LLM request in flight
engine, agent, store = await boot(db, HangsOnSecondCall())
await engine.start(agent, {"task": "look up x"}, run_id="job-42")
while not any(e.type == "aaa.tool.completed" for e in await engine.store.read("job-42")):
await asyncio.sleep(0.02)
await asyncio.sleep(0.2)
await engine.abort() # models a crash: nothing is completed, drained, or cleaned up
await store.aclose()
print("process 1 died with the run open")
# --- process 2: same database, new engine; recover() re-adopts every open run
engine, agent, store = await boot(
db, FakeLLMClient(lambda r: text(f"result: {r.messages[-1].content}"))
)
handles = await engine.recover([agent])
outcome = await handles["job-42"].result()
print(f"process 2 recovered {len(handles)} run(s):", outcome.value["text"])
await engine.__aexit__(None, None, None)
await store.aclose()
asyncio.run(main())
await engine.recover([algo, ...]) returns {run_id: RunHandle} for every run it re-adopted. Pass every algorithm
that might have open runs (children included, e.g. the PlanAndExecute and its executor).
What recover() does:
- Re-adopts each open run: rebuilds its budget, spend and deadline from the log. A deadline keeps counting through the
outage, so a run that should have timed out is completed as
timeoutimmediately. - Repairs relationships: cancels orphaned children, re-announces a finished child's result that never reached its parent.
- Redelivers every event that nothing reacted to (an event is "reacted to" if some other event names it as its cause). Because algorithms decide from state and derived ids are deterministic, repeating a reaction is absorbed.
engine.last_recovery is a RecoveryReport: recovered, unknown_algo, orphans_cancelled, timed_out, announced,
redelivered.
Open runs whose algorithm you did not pass
They are left untouched and listed in last_recovery.unknown_algo. Recovery needs unique algorithm names
(it matches by name) and raises ValueError if two different instances share one.
What happens to work in flight¶
| In flight at the crash | After recovery |
|---|---|
| An LLM request with no response | Requested again (you pay for the repeated call). |
| A tool call that had not started | Runs. |
| A tool call that had started but had no result | Depends on its delivery policy: re-run as attempt 2 (at_least_once) or reported as OutcomeUnknown (at_most_once). |
| A tool call that finished | Not repeated: its result is on the log. |
| A pending approval | Still pending; it is just events. |
| A running child run | Resumed with its parent. |
Clusters do not call recover(): the distributed bus redelivers by itself. See Running a cluster.
Growth and retention¶
- Nothing is deleted unless you configure it. The log is append-only; completed runs stay until you erase them with
engine.erase/purgeor retention policies, which also remove snapshots, blobs and outbox copies. - For the distributed bus, trim the outbox periodically (
store.trim_outbox(keep_last=...); see Running a cluster). - Blobs of erased runs are deleted with them; see Large payloads.
- Long conversations are linear in size (Performance).
Exporting events¶
aaa.events.cloudevents.to_cloudevent(event) yields a CloudEvents 1.0 dict you can ship to Kafka, a warehouse or a
tracing pipeline. See Events and the log.