langchain-ai / langchain-ai/langgraph

DeltaChannel: get_delta_channel_history treats a None channel value as a valid seed, breaking migrated threads

Open
#8,686 6 comments 0 reactions 0 assignees View on GitHub
external
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.