A checkpoint is a coherent snapshot of a thread after a superstep barrier: channel values, run status, interrupt payload if any, enough bookkeeping to continue.
Without checkpoints, voluta would be an in-process state machine. With them, a
run can pause, survive restart, and resume under the same ThreadId —
including from another process.
Why after every barrier?
Because that is when state is consistent:
- all node tasks for the tick finished
- writes were merged through reducers
- edges have not yet started the next tick (or the run stopped)
Checkpointing mid-handler would freeze half-thoughts. Checkpointing only at process exit loses the “human approved three hours later” story.
The only storage seam: ICheckpointer
Task PutAsync(CheckpointSnapshot snapshot, CancellationToken cancellationToken = default);
Task<CheckpointSnapshot?> GetAsync(string threadId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<CheckpointSnapshot>> ListAsync(string threadId, CancellationToken cancellationToken = default);
| Method | Contract |
|---|---|
PutAsync | Persist this snapshot for the thread |
GetAsync | Latest snapshot, or null if never written |
ListAsync | History ascending by step; if you can’t, throw NotSupportedException |
Your app should depend on the interface. Swap in-memory → file → future store without rewriting nodes.
What a snapshot carries
| Field | Why it exists |
|---|---|
ThreadId | Isolation between conversations / jobs |
Step | Superstep index — ordering and debugging |
Status | Done / Interrupted / Failed / … |
ChannelValues | The actual graph state |
InterruptPayload | What to show a human when paused |
PendingSends / PendingWrites | Mid-run recovery details |
You rarely construct these by hand — the runtime does. You read them for ops UIs, support tools, and tests.
Providers on main
| Type | Package | Use when |
|---|---|---|
InMemoryCheckpointer | Voluta | Samples, unit tests, throwaway demos |
FileCheckpointer | Voluta.Checkpoints.File | Single-node durability, JSON on disk |
var checkpointer = new FileCheckpointer("/var/lib/myapp/voluta-checkpoints");
In-memory is not “fake voluta.” It is the same protocol with a store that dies with the process — fine until durability is a product requirement.
Testing storage without lying
Voluta.Testing exists so you don’t invent half-broken doubles:
RecordingCheckpointer— see every put/get/listFaultInjectingCheckpointer— fail the n-th write; assert the run survives or fails cleanlyCheckpointerConformance.RunAllAsync— the suite yourICheckpointershould pass, interrupt fields included
If you implement a new store, conformance is the bar — not a happy-path unit test that never interrupts.
Operational habits
- Stable thread ids from your domain (ticket id, session id), not random per request unless you truly want a one-shot run.
- Treat Interrupted as normal — poll or webhook →
ResumeAsync. - Don’t hand-edit checkpoint files in production; use the API and UI.
Next: Interrupts for the pause/resume path that makes checkpoints earn their keep.