langchain-ai / langchain-ai/langgraph
DeltaChannel: get_delta_channel_history treats a None channel value as a valid seed, breaking migrated threads
- Dominant language
- Python
- Stars
- 41.8k
- Forks
- 7.1k
- Avg merge
- 23h 7m
- Merged PRs (30d)
- 30
Description
### Checked other resources
- [x] This is a bug, not a usage question.
- [x] I added a clear and descriptive title that summarizes this issue.
- [x] I used the GitHub search to find a similar question and didn't find it.
- [x] I am sure that this is a bug in LangGraph rather than my code.
- [x] The bug is not resolved by updating to the latest stable version of LangGraph (or the specific integration package).
- [x] This is not related to the langchain-community package.
- [x] I posted a self-contained, minimal, reproducible example. A maintainer can copy it and run it AS IS.
### Related Issues / PRs
#8535 fixed a related plain-value-seed detection gap for `PostgresSaver` and added the `has_blob or inline is not None` guard that this issue is about extending to the other three implementations.
### Reproduction Steps / Example Code (Python)
```python
from typing import Annotated, TypedDict
from langgraph.channels.binop import BinaryOperatorAggregate
from langgraph.channels.delta import DeltaChannel
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
def clear_or_add(a, b):
if b == "RESET":
return None
return (a or 0) + b
def noop(_state):
return {}
saver = InMemorySaver()
config = {"configurable": {"thread_id": "t1"}}
class BinopState(TypedDict):
total: Annotated[int, BinaryOperatorAggregate(int, clear_or_add)]
binop_graph = (
StateGraph(BinopState)
.add_node("noop", noop)
.add_edge(START, "noop")
.add_edge("noop", END)
.compile(checkpointer=saver)
)
binop_graph.invoke({"total": 5}, config)
binop_graph.invoke({"total": "RESET"}, config) # last pre-migration checkpoint: total = None
class DeltaState(TypedDict):
total: Annotated[int, DeltaChannel(lambda state, writes: sum(writes, state))]
delta_graph = (
StateGraph(DeltaState)
.add_node("noop", noop)
.add_edge(START, "noop")
.add_edge("noop", END)
.compile(checkpointer=saver)
)
delta_graph.invoke({"total": 100}, config)
```
Same shape reproduces against `SqliteSaver`/`AsyncSqliteSaver` by calling `get_delta_channel_history` directly on a thread whose migration-boundary checkpoint has `channel_values["ch"] = None`.
### Error Message and Stack Trace (if applicable)
```
Traceback (most recent call last):
...
File ".../langgraph/channels/delta.py", line 182, in update
self.value = self.reducer(base, list(values))
File "", line 27, in
TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'
```
### Description
`get_delta_channel_history`'s ancestor walk treats a `None` value in `channel_values[ch]` as a valid seed — terminating the walk there — instead of continuing past it as "nothing stored". This affects:
- `BaseCheckpointSaver.get_delta_channel_history` / `aget_delta_channel_history` (the default used by `InMemorySaver` and any third-party checkpointer that doesn't override it) — `libs/checkpoint/langgraph/checkpoint/base/__init__.py`
- `InMemorySaver`'s own optimized override — `libs/checkpoint/langgraph/checkpoint/memory/__init__.py`
- `SqliteSaver` / `AsyncSqliteSaver`'s shared ancestor-walk helper — `libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/_delta.py`
`PostgresSaver` already guards against this (`has_blob or inline is not None`, added in #8535 for a related plain-value-seed bug) — the other three implementations never got the same check.
A `None` shows up here for threads migrated from `BinaryOperatorAggregate` to `DeltaChannel` where the accumulated value had been reset to `None` (e.g. a reducer with "clear" semantics). Treating it as a seed means:
- reconstruction stops at that checkpoint instead of walking further back to find the real seed (or determining there is none, i.e. "start empty");
- `DeltaChannel.from_checkpoint`/`replay_writes` then folds subsequent writes on top of a `None` base, which crashes for any ordinary reducer that doesn't special-case `None`.
**Proposed fix:** mirror `PostgresSaver`'s existing guard in the three other implementations — require the stored value to be non-`None` before treating it as a seed, otherwise keep walking. Small, localized change, no redesign of the walk/seed contract.
**Test status:** I have a patch plus regression tests ready and can open the PR.
- A conformance-suite test (`test_history_none_plain_value_is_not_a_seed`, registered in `ALL_DELTA_CHANNEL_HISTORY_TESTS`) asserting a `None` channel value produces no `"seed"` entry, the walk continues, and expected writes are preserved — runs against every backend wired to the suite (currently `InMemorySaver`, `AsyncSqliteSaver`).
- Narrow backend tests for `InMemorySaver` and `SqliteSaver` pinning their optimized paths.
- Confirmed each new test fails without the fix and passes with it.
- Full `libs/checkpoint` suite (158 passed), sqlite delta-related tests (12 passed), `libs/langgraph` delta-migration suite (16 passed). `ruff format`, `ruff check`, `ty check` clean on all three touched packages.
- `PostgresSaver` is unchanged — it already implements the intended behavior.
### System Info
```
System Information
------------------
> OS: Darwin
> OS Version: Darwin Kernel Version 24.3.0: Thu Jan 2 20:24:06 PST 2025; root:xnu-11215.81.4~3/RELEASE_ARM64_T8103
> Python Version: 3.10.11 (v3.10.11:7d4cc5aa85, Apr 4 2023, 19:05:19) [Clang 13.0.0 (clang-1300.0.29.30)]
Package Information
-------------------
> langchain_core: 1.5.3
> langsmith: 0.8.18
> langchain_protocol: 0.0.18
> langgraph_cli: 0.4.31
> langgraph_sdk: 0.4.3
```
Contributor guide
Research direction
Start with get_delta_channel_history and aget_delta_channel_history in libs/checkpoint/langgraph/checkpoint/base/__init__.py, then compare the optimized paths in libs/checkpoint/langgraph/checkpoint/memory/__init__.py and libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/_delta.py with the existing PostgresSaver behavior. Run test_history_none_plain_value_is_not_a_seed and the listed checkpoint and SQLite delta tests; done means the walk continues past a None value without creating a seed and preserves subsequent writes.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python, sqlite
- Domain
- backend, databases
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 61/100