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:

PieceWhat it doesThe lesson
MemorySaverin-memory store of checkpoints, keyed by (thread_id, checkpoint_id)the dev-time saver; prod swaps in Sqlite/Postgres
Checkpointfull values + next + step + parent_id, saved per super-stepdurability + the chain that enables replay
compile(checkpointer=...)wires the saver into the runnableone 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-firsttime travel
update_state(config, values, as_node=None)a manual write through the reducersa human edits the state
resume/forka config pointing at an earlier checkpoint_id re-runs from therereplay & branch

File map

FileRole
lab.pyyour implementation — Lab 01 engine given, persistence is the # TODOs
solution.pycomplete reference + worked example (python solution.py)
test_lab.py23 tests: per-step checkpointing, thread continuity/isolation, time travel, update_state, resume/fork, determinism
requirements.txtpytest 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 -> b graph that is 3 checkpoints.
  • Re-invoking with the same thread_id continues — the second turn sees the first turn's state. A different thread_id is fully isolated.
  • get_state returns the latest StateSnapshot; get_state_history returns all of them newest-first with correct next sets (empty next = the graph finished).
  • update_state applies the update through the reducers (so an add channel accumulates, not overwrites) and creates a new checkpoint; as_node sets next to that node's successors.
  • Invoking with a config pointing at an earlier checkpoint_id re-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 lab and solution.

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 real config key is checkpoint_ns alongside thread_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 MemorySaver for a real sqlite3-backed saver: serialize values with json, key rows by (thread_id, checkpoint_id, parent_id), and confirm a fresh process resumes the thread.
  • Add checkpoint_ns and 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_history time travel, update_state human edits through the channel reducers, and replay/fork from any earlier checkpoint — all deterministic via a monotonic checkpoint counter, no wall-clock."