Durability & resume¶
reactifact is single-process and has no broker, but a session-backed run survives a process restart: reopen the session and keep going instead of losing the work an interrupted run had queued. This page states exactly what that guarantee is — and what it is not.
What is persisted¶
A SessionStore persists the Context under a session_id — as one blob for
FileKVBackend/InMemoryKVBackend, or as a snapshot plus an appended commit
log for AppendBackend:
- the artifacts (typed, versioned) and the relations between them;
- the commit chain (
Context.history()), so provenance anddiff/rollback/replaystill work; - the pending trigger queue — the events no generation has dispatched yet.
Artifact payloads are content-addressed in the snapshot: blobs stores each
payload once under (data_type, content_hash), artifact entries hold a version
index, and commit Create/Update operations reference the same blobs via
data_ref. ctx.artifact_version() / version_history() / find_versions()
read that store; Artifact.at/history remain compatibility bridges. The
payload carries a format_version; a payload from a newer reactifact is
rejected explicitly, older ones (including v1 inline history) go through the
registered migrations (Context.from_dict).
With an AppendBackend (SQLiteKVBackend, PostgreSQLKVBackend) the store
does not rewrite the whole Context on every commit: the first save (or the first
save after a compaction/rollback) writes a snapshot, and later saves append only
the new commits plus the current pending-event queue. FileKVBackend and
InMemoryKVBackend keep the full-blob behavior.
SessionStore(backend, compact_every=N) bounds the append log: roughly every
N saves the store rewrites the snapshot and clears the entries. The default is
100; pass compact_every=None to keep the log until an explicit
context.compact() or a rollback. BranchStore takes the same option per
branch key.
With the default session_save_policy="per_commit", the runtime saves at every
generation boundary, after that generation's trigger batch is consumed.
So the saved snapshot is consistent: an already-consumed trigger is not replayed,
and one whose consumer never ran is.
from reactifact import Runtime, SessionStore
from reactifact.checkpoints import SQLiteKVBackend
store = SessionStore(SQLiteKVBackend("sessions.sqlite3"))
session = await store.open("user-1") # loads, or creates if absent
runtime = Runtime(session.context, agents=[...], session=session)
await runtime.arun()
# after a crash / restart — a brand-new process, nothing in memory
session = await store.open("user-1")
runtime = Runtime(session.context, agents=[...], session=session)
await runtime.arun() # continues: pending triggers are still there
A crashed run therefore resumes from the last committed generation, and any trigger that was queued but not yet reacted to is re-dispatched.
The guarantee: at-least-once¶
Resume is at-least-once, not exactly-once. A produce that had already committed before the crash may run again. If it is not idempotent, that means a duplicate artifact (or a repeated external side effect).
Give produces a stable id so a re-run upserts instead of duplicating (§42):
@step(produces=Summary, consumes=Msg)
async def summarize(call):
doc = call.trigger
# A stable id makes the re-run idempotent: creating it twice returns the
# same artifact and emits no second event.
call.effects.create(Summary(text=...), id=f"summary:{doc.data.url}")
effects.create_once_from(...) (stable id derived from the trigger) and
Consume(..., debounce=True) are the other tools for the same goal.
What is not covered¶
- External side effects inside a produce. An email, a file write, or a
third-party API call made directly in a produce can be repeated by a retry.
Do not do that — record an intent instead:
effects.act(kind=..., key=..., payload=...)writes aPendingActionandRuntime(dispatcher=...)performs it once after the intent commits (see the outbox pattern in Patterns). The framework still cannot make the I/O itself exactly-once — the dispatcher must be idempotent onidempotency_key(passed through inaction.data), which closes the residual at-least-once window. (call.requestis available for a correlation id; seereactifact.request.) - No background relay. The framework never runs a process that keeps
retrying the outbox on its own: the next
arun()drains committed actions, andflush_pending_actions()drains them without a generation — call it on startup and periodically (in your app's own timer) so an action committed just before a crash is not stranded. Retry policy is the application's: a failing dispatcher marks the actionfailed(bumpingattempts), firesRuntime(on_dispatch_error=...)if set, and re-raises;context.retry_action( id)re-arms it. - In-flight work. A produce that was mid-flight at the crash is simply re-run; only committed generations are durable.
- Budgets reset per run.
Budgetcounters (max_runs,max_seconds) are per-arun()call; resuming starts a fresh budget, not the remainder of the old one. per_turngranularity. Withsession_save_policy="per_turn"the session is saved once at the end of a turn, so a crash mid-turn rolls the whole turn back and re-runs it.per_commit(default) saves at every generation boundary instead.
Outbox in production¶
The outbox primitive is safe by construction, but it is a library feature, not a delivery service: it runs no relay and invents no retry policy. To ship outbound effects (email, webhooks, payments) in production you own four things:
- An idempotent dispatcher. Pass
action.data.idempotency_keyto the external system so the at-least-once window (a crash between the send and thedispatchedcommit) is closed on the far side. Never mint a new key on a retry. - A drain. Call
flush_pending_actions()on startup and periodically from your own timer, so an action committed just before a crash is not stranded. The nextarun()drains too — but only if a turn actually runs. - A retry policy. A failing dispatcher marks the action
failed(bumpingattempts) and re-raises; wrap your dispatcher with your own retry/backoff, and/or hookRuntime(on_dispatch_error=...)to alert andcontext.retry_action(id)to re-arm. Backoff is deliberately not in the runtime. - Observability. Query
context.list_artifacts(PendingAction)(or thereplayCLI) forfailedand long-pending actions and alert on them.
With those four in place it is production-ready; if you want the framework itself to keep retrying with no app timer, that is a broker/relay — out of scope by design (§75). Implement the dispatcher-level retry wrapper in the app.
Schema evolution¶
A persisted artifact's type is stored as a string (Artifact.data_type, the
data_type of a compiled Create/Update, Event.artifact_type) — by default
the Python qualified name (module.Qualname). You do not have to register
anything: on save the id is derived automatically, and on load resolve
imports the qualified name back, so a normal module-level model round-trips with
no boilerplate. reactifact.types exists only to decouple that stored identity
from Python's module layout when you want to.
Registration matters in three cases:
- Renames or moves — a stable
type_idkeeps old sessions/recordings loadable after a model is renamed or moved between modules. - Aliases — payloads carrying a previous id keep resolving.
- Schema migration — a
migratehook renames fields / fills defaults before validation.
from reactifact import register_type
register_type(
Answer,
type_id="answer", # written from now on
aliases=["old.pkg.Answer"], # what old payloads carry
migrate=lambda d: {**d, "prose": d.pop("text", "")}, # a field rename
)
When you only need a stable id (no aliases/migration), a TYPE_ID classvar on
the model is enough — no call needed. Call register_type at import time, next
to the model.
type_id_of(model)— the id written on save: a registeredtype_id, aTYPE_IDclassvar, else the qualified name.resolve(type_id)— the class on load: registry → alias → import by qualified name, so pre-registry payloads keep loading.migrateruns on the raw dict before validation (field renames, defaults).
The one real caveat: a qualified name is only loadable if the class is
importable in the new process. A model defined in __main__, in a notebook
cell, inside a function (<locals>), or built with pydantic.create_model is
not — its id will not resolve on resume/replay. Give such a model an explicit
type_id (and a real module to live in); resolve's error names the id it
could not find and points at register.
This covers artifacts, commit operations and events — a whole session's state and its pending trigger queue — so an upgrade that renames/moves a model no longer breaks resume, replay or golden snapshots.
Resume vs. replay¶
Two different operations, often confused:
- Resume (
session.open(...)+Runtime.arun()) continues an interrupted run: it re-dispatches pending triggers and may re-run produces. - Replay (
reactifact replay,ReplayLLM) reconstructs why a completed run reached its state, deterministically, without re-running agents — see Replay.
The first is execution; the second is audit.
Deadlines, graceful shutdown & in-flight runs¶
Budget.max_seconds is enforced as a hard per-turn deadline: the runtime
cancels the generation in flight (a hung LLM call is actually stopped) rather
than only checking between generations. The cancelled generation's trigger batch
is not consumed, so a later arun() resumes it; the turn ends with
RunOutcome.BUDGET_TIME_EXCEEDED.
On process shutdown, Runtime.request_stop() asks the run to stop at the next
generation boundary — the current generation finishes (its commit lands), no new
one starts, and RunOutcome.STOPPED is set. await runtime.ashutdown(timeout=…)
does that and waits for the turn, force-cancelling after timeout. It never
closes shared RuntimeResources; close those in your own lifespan
(resources.aclose() or a ResourceScope).
The process's in-flight turns are visible read-only:
from reactifact.runtime import active_runs, cancel_run
len(active_runs()) # a readiness signal
active_runs()[0].session_id # what is running
cancel_run(run_id) # cancel one turn
active_runs() is in-process only — single event loop, not persisted, not
cross-process. It is an ops/readiness view, not a task queue.
Bounding a long-lived context¶
A session that lives for many turns keeps every commit and every artifact
version forever — memory (and to_dict) grows without bound. Context.compact
bounds it the way git squashes history:
It collapses every commit older than the last keep_commits into a baseline
snapshot and (with keep_versions) trims each artifact's retained version
history. The absolute version/head_id are preserved, so context_hash,
commit version numbers and resumes are unaffected.
Two honest consequences:
- It is irreversible.
checkout/diffbelow the resulting baseline raise (Context.compacted_atreports it), and provenance for artifacts last written before the baseline is gone — the operations that produced them were collapsed. - It does not evict artifacts. To drop old artifacts (e.g. out-of-window
messages), use the memory recipes (
WindowPruner,RollingDigestSummarizer, Recipes);compactbounds the history, not the working set.
Related¶
- Execution model — the generation loop and its explicit non-guarantees.
- Concepts —
Context, commits, and the event queue. - Replay — verifying a finished run's
context_hash.