Skip to content

Commit 5bdcc79

Browse files
committed
fix: handle missing blob attribute in shallow checkpoint savers (#80)
Add defensive checks in AsyncShallowRedisSaver and ShallowRedisSaver to filter out documents with None or missing blob attributes when loading pending sends. This prevents AttributeError when querying checkpoint writes from the TASKS channel. Added comprehensive test for empty blob (b'') handling as suggested in PR review: - Test empty byte string in pending sends - Test various empty values (empty string, dict, list) - Verify proper handling in TASKS channel writes Fixes #80
1 parent 13ddc96 commit 5bdcc79

File tree

4 files changed

+468
-8
lines changed

4 files changed

+468
-8
lines changed

langgraph/checkpoint/redis/ashallow.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -646,12 +646,11 @@ async def _aload_pending_sends(
646646

647647
# Extract type and blob pairs
648648
# Handle both direct attribute access and JSON path access
649+
# Filter out documents where blob is None (similar to AsyncRedisSaver in aio.py)
649650
return [
650-
(
651-
getattr(doc, "type", ""),
652-
getattr(doc, "$.blob", getattr(doc, "blob", b"")),
653-
)
651+
(getattr(doc, "type", ""), blob)
654652
for doc in sorted_writes
653+
if (blob := getattr(doc, "$.blob", getattr(doc, "blob", None))) is not None
655654
]
656655

657656
async def _aload_pending_writes(

langgraph/checkpoint/redis/shallow.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -675,12 +675,11 @@ def _load_pending_sends(
675675

676676
# Extract type and blob pairs
677677
# Handle both direct attribute access and JSON path access
678+
# Filter out documents where blob is None (similar to RedisSaver in __init__.py)
678679
return [
679-
(
680-
getattr(doc, "type", ""),
681-
getattr(doc, "$.blob", getattr(doc, "blob", b"")),
682-
)
680+
(getattr(doc, "type", ""), blob)
683681
for doc in sorted_writes
682+
if (blob := getattr(doc, "$.blob", getattr(doc, "blob", None))) is not None
684683
]
685684

686685
def _make_shallow_redis_checkpoint_key_cached(

tests/test_blob_encoding_error_handling.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,77 @@ def test_load_writes_empty_cases(redis_url: str) -> None:
248248
assert len(result2.pending_writes) == 0
249249

250250

251+
def test_empty_blob_in_pending_sends(redis_url: str) -> None:
252+
"""Test handling of empty byte string (b"") in pending sends as per PR #82 review suggestion."""
253+
with _saver(redis_url) as saver:
254+
thread_id = str(uuid4())
255+
256+
# Create a checkpoint
257+
config: RunnableConfig = {
258+
"configurable": {
259+
"thread_id": thread_id,
260+
"checkpoint_ns": "",
261+
"checkpoint_id": "test-checkpoint",
262+
}
263+
}
264+
265+
checkpoint = create_checkpoint(
266+
checkpoint=empty_checkpoint(),
267+
channels={},
268+
step=1,
269+
)
270+
271+
saved_config = saver.put(
272+
config, checkpoint, {"source": "test", "step": 1, "writes": {}}, {}
273+
)
274+
275+
# Test 1: Write with empty byte string blob
276+
from langgraph.constants import TASKS
277+
278+
# This tests the edge case where blob is b""
279+
empty_blob_data = b""
280+
saver.put_writes(
281+
saved_config,
282+
[(TASKS, empty_blob_data)], # Empty blob
283+
task_id="empty_blob_task",
284+
)
285+
286+
# Test 2: Write with None-like values that should be handled gracefully
287+
none_like_values = [
288+
("channel1", ""), # Empty string
289+
("channel2", b""), # Empty bytes
290+
("channel3", {}), # Empty dict
291+
("channel4", []), # Empty list
292+
]
293+
294+
saver.put_writes(
295+
saved_config,
296+
none_like_values,
297+
task_id="empty_values_task",
298+
)
299+
300+
# Retrieve and verify all writes are handled correctly
301+
result = saver.get_tuple(saved_config)
302+
assert result is not None
303+
304+
# Check pending writes
305+
pending_writes = result.pending_writes
306+
307+
# Find our writes
308+
empty_blob_writes = [w for w in pending_writes if w[0] == "empty_blob_task"]
309+
empty_values_writes = [w for w in pending_writes if w[0] == "empty_values_task"]
310+
311+
# Verify empty blob is handled correctly
312+
assert len(empty_blob_writes) == 1
313+
assert empty_blob_writes[0][1] == TASKS
314+
assert empty_blob_writes[0][2] == b"" # Should preserve empty bytes
315+
316+
# Verify other empty values are handled
317+
assert len(empty_values_writes) == 4
318+
for write in empty_values_writes:
319+
assert write[2] is not None # Should not be None even for empty values
320+
321+
251322
def test_checkpoint_with_special_characters(redis_url: str) -> None:
252323
"""Test handling of special characters and null bytes in data.
253324

0 commit comments

Comments
 (0)