Repository navigation
fix(checkpoint): return only the stored checkpoint's pending writes from the shallow savers - #213
Merged
limjoobin merged 1 commit intoOct 23, 2026
Conversation
…rom the shallow savers The shallow savers keep one write registry per thread and namespace, and since redis-developer#134 it is no longer cleared when a new checkpoint is saved. The loaders returned every write in it, so get_tuple/aget_tuple reported writes made against earlier checkpoints as pending writes of the current one. LangGraph applies null-task pending writes (the update in Command(update=..., resume=...)) at the start of every run, so an update was applied again on each later resume. Keep only the writes whose checkpoint_id is the stored checkpoint's, as RedisSaver does. Nothing is deleted, so the redis-developer#133 behaviour is unchanged.
limjoobin
pushed a commit
that referenced
this pull request
Oct 24, 2026
…ow savers (#219) Runs the graph from #215 end to end on ShallowRedisSaver and AsyncShallowRedisSaver: a plain (LastValue) channel set by Command(resume=..., update=...) on two resumes of the same thread. Before #213 the second resume raised InvalidUpdateError because the first resume's update was loaded again as a pending write. Both turns and the final state must match InMemorySaver.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
#134 fixed #133 by no longer deleting a thread's writes when the shallow savers save a new checkpoint, so interrupt writes survive until they are consumed. The write registry (
write_registry:{thread}:{ns}:shallowin the sync saver,write_keys_zset:{thread}:{ns}:shallowin the async one) is now cleared only bydelete_threador TTL, so it keeps the write keys of every earlier checkpoint in the thread.ShallowRedisSaver._load_pending_writes(shallow.py512-552) andAsyncShallowRedisSaver._aload_pending_writes(ashallow.py619-672) return every write in that registry as the pending writes of the current checkpoint (the comments still say "All writes belong to the current checkpoint"). Each write document already stores thecheckpoint_idit was written against, but the loaders do not look at it.LangGraph applies the null-task pending writes of the checkpoint it resumes from (for example the
updateinCommand(update=..., resume=...)) at the start of every run. So an update sent once is applied again on each later resume of the thread.RedisSaver,AsyncRedisSaverand LangGraph'sInMemorySaverkeep pending writes per checkpoint id and apply it once.Reproduction
Against Redis 8.10.2 (search + JSON) on
main, nothing mocked or injected: a graph of three nodes, each callinginterrupt(), run withinvoke({"log": ["start"]}), thenCommand(resume="A", update={"log": ["update"]}), thenCommand(resume="B")andCommand(resume="C"), wherelogusesoperator.add:InMemorySaverandRedisSaver:["start", "update", "ask1:A", "ask2:B", "ask3:C"].ShallowRedisSaverandAsyncShallowRedisSaver:"update"appears four times.get_tuplereturns 8, 12 and 14 pending writes after the three resumes, whereRedisSaverreturns 1, 1 and 0.Two other effects of the same leftover writes:
InvalidUpdateError: At key 'status': Can receive only one value per step. Before that,get_state()already shows the old update's value in place of the newer value a node wrote.durability="exit"and no update at all, the second resume of the three-question graph raisesRuntimeError: When there are multiple pending interrupts, you must specify the interrupt id when resuming.I compared both shallow savers with
InMemorySaverover four graphs (two interrupts in one node, a chain, two parallel interrupting nodes, an interrupt inside a subgraph) in each durability mode, checking the result,get_state().next, the pending interrupts and the values after every call. Onmaineach shallow saver matched in 3 of the 12 runs; with this change, in all 12.Change
Both loaders now keep only the write documents whose
checkpoint_idis the stored checkpoint's id, the same setRedisSaverreturns. Nothing is deleted: earlier writes stay in Redis as they do since #134, and the 7 tests intest_issue_133_hitl_shallow.pypass unchanged. The source change is two conditions and the comments (+8/-5 across 2 files).This is a behaviour change:
get_tuple,aget_tupleandShallowRedisSaver.listno longer return writes made against earlier checkpoints.test_shallow_ulid_ttl_cache.py::test_put_writes_persist_for_hitl_supportasserted that they do, so I changed it to check that the earlier checkpoint's writes are still stored in Redis and thatget_tuplereturns only the current checkpoint's write.I left two things alone: the registry still grows until
delete_threador TTL (cleaning it up safely is a separate change), and the sync and async shallow savers still use different registry key names (mentioned in #211).Testing
New
tests/test_shallow_stale_pending_writes.py, integration tests against real Redis:get_tuple/aget_tuplereturn no pending writes for a new checkpoint when the previous checkpoint had writes, and only the new checkpoint's write once it has one.InMemorySaverlog with each shallow saver, in each durability mode (sync,async,exit).InMemorySaverreference run, and a checkpoint's own writes are returned in order (sync and async).The 8 new failing cases and the updated existing test fail on
main(5 of 5 runs) and pass with the change (5 of 5 runs).The shallow-saver test files (20 files: every file that uses either shallow saver except the sentinel tests, plus the TTL, registry and cross-slot files):
main: 168 passed, 9 failed (the 8 new cases and the updated test)poetry run format,sort-importsandcheck-mypyare clean.This PR was written with AI assistance (Claude Code); the tests above were run before opening it.