Skip to content

Commit c425317

Browse files
abrookinsclaude[bot]tylerhutcherson
authored
fix: AsyncRedisSaver aget_tuple returning None for checkpoint_id (#65)
Fixes issue where AsyncRedisSaver.aget_tuple() returned None for checkpoint_id when no checkpoint_id was specified in the config, while RedisSaver.get_tuple() correctly returned the retrieved checkpoint ID. The bug was caused by using the original checkpoint_id parameter (which may be None) instead of doc_checkpoint_id (the actual ID retrieved from Redis document). Fixed two locations in aget_tuple method: - Config construction: Use doc_checkpoint_id instead of checkpoint_id - _aload_pending_writes call: Use doc_checkpoint_id to match sync behavior Added comprehensive test coverage to verify the fix and prevent regression. Resolves #64 Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Tyler Hutcherson <[email protected]>
1 parent cf4cc5b commit c425317

File tree

2 files changed

+141
-2
lines changed

2 files changed

+141
-2
lines changed

langgraph/checkpoint/redis/aio.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
350350
"configurable": {
351351
"thread_id": thread_id,
352352
"checkpoint_ns": checkpoint_ns,
353-
"checkpoint_id": checkpoint_id,
353+
"checkpoint_id": doc_checkpoint_id,
354354
}
355355
}
356356

@@ -361,7 +361,7 @@ async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
361361
)
362362

363363
pending_writes = await self._aload_pending_writes(
364-
thread_id, checkpoint_ns, checkpoint_id or EMPTY_ID_SENTINEL
364+
thread_id, checkpoint_ns, doc_checkpoint_id
365365
)
366366

367367
return CheckpointTuple(
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""Test for AsyncRedisSaver aget_tuple checkpoint_id issue (GitHub issue #64)."""
2+
3+
import asyncio
4+
import uuid
5+
from typing import AsyncGenerator
6+
7+
import pytest
8+
from langchain_core.runnables import RunnableConfig
9+
from langgraph.checkpoint.base import empty_checkpoint
10+
11+
from langgraph.checkpoint.redis.aio import AsyncRedisSaver
12+
13+
14+
@pytest.fixture
15+
async def saver(redis_url: str) -> AsyncGenerator[AsyncRedisSaver, None]:
16+
"""Async saver fixture for this test."""
17+
saver = AsyncRedisSaver(redis_url)
18+
await saver.asetup()
19+
yield saver
20+
21+
22+
@pytest.mark.asyncio
23+
async def test_aget_tuple_returns_correct_checkpoint_id(saver: AsyncRedisSaver):
24+
"""Test that aget_tuple returns the correct checkpoint_id when not specified in config.
25+
26+
This test reproduces the issue described in GitHub issue #64 where AsyncRedisSaver
27+
aget_tuple was returning None for checkpoint_id while the sync version worked correctly.
28+
"""
29+
# Create a unique thread ID
30+
thread_id = str(uuid.uuid4())
31+
32+
# Config with only thread_id and checkpoint_ns (no checkpoint_id)
33+
runnable_config: RunnableConfig = {
34+
"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}
35+
}
36+
37+
# Put several checkpoints
38+
checkpoint_ids = []
39+
for run in range(3):
40+
checkpoint_id = str(run)
41+
checkpoint_ids.append(checkpoint_id)
42+
43+
await saver.aput(
44+
{
45+
"configurable": {
46+
"thread_id": thread_id,
47+
"checkpoint_id": checkpoint_id,
48+
"checkpoint_ns": "",
49+
}
50+
},
51+
empty_checkpoint(),
52+
{
53+
"source": "loop",
54+
"step": run,
55+
"writes": {},
56+
},
57+
{},
58+
)
59+
60+
# Get the tuple using the config without checkpoint_id
61+
# This should return the latest checkpoint
62+
get_tuple = await saver.aget_tuple(runnable_config)
63+
64+
# Verify the checkpoint_id is not None and matches the expected value
65+
assert get_tuple is not None, f"Expected checkpoint tuple, got None for run {run}"
66+
67+
returned_checkpoint_id = get_tuple.config["configurable"]["checkpoint_id"]
68+
assert returned_checkpoint_id is not None, (
69+
f"Expected checkpoint_id to be set, got None for run {run}. "
70+
f"This indicates the bug where aget_tuple returns None for checkpoint_id."
71+
)
72+
73+
# Since we're getting the latest checkpoint each time, it should be the current checkpoint_id
74+
assert returned_checkpoint_id == checkpoint_id, (
75+
f"Expected checkpoint_id {checkpoint_id}, got {returned_checkpoint_id} for run {run}"
76+
)
77+
78+
79+
@pytest.mark.asyncio
80+
async def test_aget_tuple_with_explicit_checkpoint_id(saver: AsyncRedisSaver):
81+
"""Test that aget_tuple works correctly when checkpoint_id is explicitly provided."""
82+
# Create a unique thread ID
83+
thread_id = str(uuid.uuid4())
84+
85+
# Put several checkpoints
86+
checkpoint_ids = []
87+
for run in range(3):
88+
checkpoint_id = str(run)
89+
checkpoint_ids.append(checkpoint_id)
90+
91+
await saver.aput(
92+
{
93+
"configurable": {
94+
"thread_id": thread_id,
95+
"checkpoint_id": checkpoint_id,
96+
"checkpoint_ns": "",
97+
}
98+
},
99+
empty_checkpoint(),
100+
{
101+
"source": "loop",
102+
"step": run,
103+
"writes": {},
104+
},
105+
{},
106+
)
107+
108+
# Test retrieving each checkpoint by explicit checkpoint_id
109+
for checkpoint_id in checkpoint_ids:
110+
config_with_id: RunnableConfig = {
111+
"configurable": {
112+
"thread_id": thread_id,
113+
"checkpoint_id": checkpoint_id,
114+
"checkpoint_ns": ""
115+
}
116+
}
117+
118+
get_tuple = await saver.aget_tuple(config_with_id)
119+
120+
assert get_tuple is not None, f"Expected checkpoint tuple, got None for checkpoint_id {checkpoint_id}"
121+
122+
returned_checkpoint_id = get_tuple.config["configurable"]["checkpoint_id"]
123+
assert returned_checkpoint_id == checkpoint_id, (
124+
f"Expected checkpoint_id {checkpoint_id}, got {returned_checkpoint_id}"
125+
)
126+
127+
128+
@pytest.mark.asyncio
129+
async def test_aget_tuple_no_checkpoint_returns_none(saver: AsyncRedisSaver):
130+
"""Test that aget_tuple returns None when no checkpoint exists for the thread."""
131+
# Use a thread ID that doesn't exist
132+
thread_id = str(uuid.uuid4())
133+
134+
runnable_config: RunnableConfig = {
135+
"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}
136+
}
137+
138+
get_tuple = await saver.aget_tuple(runnable_config)
139+
assert get_tuple is None, "Expected None when no checkpoint exists for thread"

0 commit comments

Comments
 (0)