From f0fced262a14620ce853463dd2cb8db76b4041ca Mon Sep 17 00:00:00 2001 From: Caspar Broekhuizen Date: Wed, 17 Sep 2025 17:50:39 -0700 Subject: [PATCH] fix(langgraph): fix PostgresSaver crashing when loading older checkpoints (#6162) ### Description https://github.com/langchain-ai/langgraph/issues/6137 and https://github.com/langchain-ai/langgraph/issues/5677 reported issues where older checkpoints read by AsyncPostgresSaver/PostgresSaver from `langgraph-checkpoint-postgres==2.0.19` fail to read channel values, throwing `NoneType object is not a mapping`. This was due to a bug in how `channel_values` is assembled: ```python "channel_values": { **value["checkpoint"].get("channel_values"), # <--- if channel_values doesn't exist (old checkpoint), **None errors **self._load_blobs(value["channel_values"]), }, ``` This bug was observed for checkpoints generated by `langgraph-checkpoint-postgres<=2.0.19`. Fixed by providing a fallback to `value["checkpoint"].get("channel_values")`: ```python **value["checkpoint"], "channel_values": { **( value["checkpoint"].get("channel_values") or {} ), # 'or {}' needed for backwards compat with v3 checkpoints and below, as v4 introduced channel_values key **self._load_blobs(value["channel_values"]), }, ``` ### Tests Added test for AsyncPostgresSaver and test for PostgresSaver, using monkeypatch to remove `channel_values` before CheckpointTuple is assembled in `_load_checkpoint_tuple`. ### Solves https://github.com/langchain-ai/langgraph/issues/6137 and https://github.com/langchain-ai/langgraph/issues/5677 --------- Co-authored-by: Shahrukh Shaik <144558473+shahrukh-shaik@users.noreply.github.com> --- .../langgraph/checkpoint/postgres/__init__.py | 2 +- .../langgraph/checkpoint/postgres/aio.py | 2 +- libs/checkpoint-postgres/tests/test_async.py | 31 +++++++++++++++++++ libs/checkpoint-postgres/tests/test_sync.py | 30 ++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py index 6a0ba0cc1..b689df5d5 100644 --- a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py @@ -450,7 +450,7 @@ class PostgresSaver(BasePostgresSaver): { **value["checkpoint"], "channel_values": { - **value["checkpoint"].get("channel_values"), + **(value["checkpoint"].get("channel_values") or {}), **self._load_blobs(value["channel_values"]), }, }, diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py index 1d7a9dff4..ed36daa4a 100644 --- a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py @@ -409,7 +409,7 @@ class AsyncPostgresSaver(BasePostgresSaver): { **value["checkpoint"], "channel_values": { - **value["checkpoint"].get("channel_values"), + **(value["checkpoint"].get("channel_values") or {}), **self._load_blobs(value["channel_values"]), }, }, diff --git a/libs/checkpoint-postgres/tests/test_async.py b/libs/checkpoint-postgres/tests/test_async.py index cb1d2fa55..cca805901 100644 --- a/libs/checkpoint-postgres/tests/test_async.py +++ b/libs/checkpoint-postgres/tests/test_async.py @@ -344,3 +344,34 @@ async def test_pending_sends_migration(saver_name: str) -> None: TASKS: ["send-1", "send-2", "send-3"] } assert TASKS in search_results[0].checkpoint["channel_versions"] + + +@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"]) +async def test_get_checkpoint_no_channel_values( + monkeypatch, saver_name: str, test_data +) -> None: + """Backwards compatibility test that verifies a checkpoint with no channel_values key can be retrieved without throwing an error.""" + async with _saver(saver_name) as saver: + config = { + "configurable": { + "thread_id": "thread-2", + "checkpoint_ns": "", + "__super_private_key": "super_private_value", + }, + "metadata": {"run_id": "my_run_id"}, + } + chkpnt: Checkpoint = create_checkpoint(empty_checkpoint(), {}, 1) + await saver.aput(config, chkpnt, {}, {}) + + load_checkpoint_tuple = saver._load_checkpoint_tuple + + def patched_load_checkpoint_tuple(value): + value["checkpoint"].pop("channel_values", None) + return load_checkpoint_tuple(value) + + monkeypatch.setattr( + saver, "_load_checkpoint_tuple", patched_load_checkpoint_tuple + ) + + checkpoint = await saver.aget_tuple(config) + assert checkpoint.checkpoint["channel_values"] == {} diff --git a/libs/checkpoint-postgres/tests/test_sync.py b/libs/checkpoint-postgres/tests/test_sync.py index ef0fabc74..c8564c4e4 100644 --- a/libs/checkpoint-postgres/tests/test_sync.py +++ b/libs/checkpoint-postgres/tests/test_sync.py @@ -332,3 +332,33 @@ def test_pending_sends_migration(saver_name: str) -> None: TASKS: ["send-1", "send-2", "send-3"] } assert TASKS in search_results[0].checkpoint["channel_versions"] + + +@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"]) +def test_get_checkpoint_no_channel_values( + monkeypatch, saver_name: str, test_data +) -> None: + """Backwards compatibility test that verifies a checkpoint with no channel_values key can be retrieved without throwing an error.""" + with _saver(saver_name) as saver: + config = { + "configurable": { + "thread_id": "thread-2", + "checkpoint_ns": "", + "__super_private_key": "super_private_value", + }, + } + chkpnt: Checkpoint = create_checkpoint(empty_checkpoint(), {}, 1) + saver.put(config, chkpnt, {}, {}) + + load_checkpoint_tuple = saver._load_checkpoint_tuple + + def patched_load_checkpoint_tuple(value): + value["checkpoint"].pop("channel_values", None) + return load_checkpoint_tuple(value) + + monkeypatch.setattr( + saver, "_load_checkpoint_tuple", patched_load_checkpoint_tuple + ) + + checkpoint = saver.get_tuple(config) + assert checkpoint.checkpoint["channel_values"] == {}