Lab 02 — Persistence, Checkpointing & Time Travel
Phase 18 · Lab 02 · « Phase 18 README · Lab 01 »
The problem
The Lab 01 engine is amnesiac. Every invoke starts from a blank state and forgets
everything the moment it returns. Real agents are the opposite:
- a chat assistant remembers the last turn (multi-turn memory),
- a long research run survives a crash and resumes where it stopped (durability),
- a reviewer inspects the state mid-run and edits it (human-in-the-loop, Lab 03),
- an engineer rewinds to an earlier step and forks to try a different branch (time travel).
Every one of those is the same feature under the hood: persistence via a checkpointer. After
each super-step the engine saves a checkpoint — the full channel state plus which nodes run
next — keyed by (thread_id, checkpoint_id). A thread_id scopes one conversation; re-invoking
with the same thread_id continues from the saved state. Because every super-step is saved, you
can read any past state, edit it, and replay from it.
What you build
You extend the Lab 01 StateGraph/CompiledGraph with a checkpointer layer:
| Piece | What it does | The lesson |
|---|---|---|
MemorySaver | in-memory store of checkpoints, keyed by (thread_id, checkpoint_id) | the dev-time saver; prod swaps in Sqlite/Postgres |
Checkpoint | full values + next + step + parent_id, saved per super-step | durability + the chain that enables replay |
compile(checkpointer=...) | wires the saver into the runnable | one line turns an agent durable |
invoke(input, config) / stream(...) | config = {"configurable": {"thread_id": ...}} | same thread_id = one conversation that continues |
get_state(config) | the latest StateSnapshot(values, next, config, ...) | read the live state |
get_state_history(config) | every checkpoint, newest-first | time travel |
update_state(config, values, as_node=None) | a manual write through the reducers | a human edits the state |
| resume/fork | a config pointing at an earlier checkpoint_id re-runs from there | replay & branch |
File map
| File | Role |
|---|---|
lab.py | your implementation — Lab 01 engine given, persistence is the # TODOs |
solution.py | complete reference + worked example (python solution.py) |
test_lab.py | 23 tests: per-step checkpointing, thread continuity/isolation, time travel, update_state, resume/fork, determinism |
requirements.txt | pytest only |
Run it
pytest test_lab.py -v # your lab.py (red until you implement)
LAB_MODULE=solution pytest test_lab.py -v # the reference (green)
python solution.py # worked example
Success criteria
-
A checkpoint is saved after every super-step, plus one for the input (step 0). For a
linear
a -> bgraph that is 3 checkpoints. -
Re-invoking with the same
thread_idcontinues — the second turn sees the first turn's state. A differentthread_idis fully isolated. -
get_statereturns the latestStateSnapshot;get_state_historyreturns all of them newest-first with correctnextsets (emptynext= the graph finished). -
update_stateapplies the update through the reducers (so anaddchannel accumulates, not overwrites) and creates a new checkpoint;as_nodesetsnextto that node's successors. -
Invoking with a config pointing at an earlier
checkpoint_idre-runs from there and does not re-run the nodes that already ran before it. -
Checkpoint ids come from a monotonic counter (no clock, no uuid), so a replay is
byte-identical. All 23 tests pass under both
labandsolution.
How this maps to the real stack
The API you build here is the real LangGraph persistence API, one-for-one.
Wiring a checkpointer — the only change to make a graph durable:
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver # dev / tests
# from langgraph.checkpoint.sqlite import SqliteSaver # single-node prod
# from langgraph.checkpoint.postgres import PostgresSaver # multi-node prod
# from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver # async prod
builder = StateGraph(State)
# ... add_node / add_edge ...
graph = builder.compile(checkpointer=MemorySaver())
thread_id = a conversation. The config scopes which conversation you are in; re-invoking
the same thread continues it:
config = {"configurable": {"thread_id": "user-1"}}
graph.invoke({"messages": [("user", "hi, I'm Sam")]}, config)
graph.invoke({"messages": [("user", "what's my name?")]}, config) # remembers "Sam"
Time travel — read history, resume, and fork:
snap = graph.get_state(config) # latest StateSnapshot: .values, .next, .config
for snap in graph.get_state_history(config): # newest-first, every checkpoint
print(snap.config["configurable"]["checkpoint_id"], snap.next)
# resume/fork from an EARLIER checkpoint by passing its checkpoint_id:
past = {"configurable": {"thread_id": "user-1", "checkpoint_id": "<an-older-id>"}}
graph.invoke(None, past) # re-runs from that point (a new branch)
update_state — a human (or your code) edits the state; the update flows through the same
channel reducers, and as_node tells the graph what to run next:
graph.update_state(config, {"messages": [("system", "user prefers brevity")]})
graph.update_state(config, {"tool_result": corrected}, as_node="tools")
What persistence unlocks (why every production LangGraph app uses it):
- Durability / crash recovery — the run resumes from the last checkpoint instead of step 0 (this is the durable-execution idea from Phase 08, at the graph level).
- Memory — short-term memory is the thread's checkpoint history; long-term memory is a
separate
Store, but per-conversation memory is just the checkpointer. - Human-in-the-loop — an
interrupt()must persist the pause so a human can answer minutes or days later; that requires a checkpointer. Lab 03 builds exactly this on top of Lab 02. checkpoint_ns— subgraphs get a checkpoint namespace so a parent graph and its nested subgraph don't collide; the realconfigkey ischeckpoint_nsalongsidethread_id.
Limits of the miniature. MemorySaver here is a plain dict, so state dies with the process —
the whole point of SqliteSaver/PostgresSaver is to put it in a durable store. Real checkpoints
also serialize with a pluggable SerializerProtocol, track pending writes per task for
fault-tolerant partial super-steps, and store checkpoint_ns for subgraphs. The mechanism —
save-after-every-super-step, keyed by thread, replayable by id — is identical.
Extensions (your own machine)
- Swap
MemorySaverfor a realsqlite3-backed saver: serializevalueswithjson, key rows by(thread_id, checkpoint_id, parent_id), and confirm a fresh process resumes the thread. - Add
checkpoint_nsand a nested subgraph; show the parent and child checkpoints don't collide. - Add pending-write tracking: if a node crashes mid-super-step, resume should re-run only the failed node, reusing the already-saved writes of its siblings.
Interview / resume signal
"Built LangGraph-style persistence from scratch — a checkpointer that saves the full graph state after every Pregel super-step keyed by
(thread_id, checkpoint_id), giving multi-turn memory (same thread continues), crash-durability,get_state/get_state_historytime travel,update_statehuman edits through the channel reducers, and replay/fork from any earlier checkpoint — all deterministic via a monotonic checkpoint counter, no wall-clock."