RedisSaver.put() delta-filters channel_values but getTuple() does not reconstruct them — channels not written by the last node are lost
Checked other resources
Example Code
import { Annotation, StateGraph } from "@langchain/langgraph";
import { MemorySaver } from "@langchain/langgraph-checkpoint";
import { RedisSaver } from "@langchain/langgraph-checkpoint-redis";
import { AIMessage, BaseMessage, HumanMessage, SystemMessage } from "@langchain/core/messages";
// Multi-channel state: different nodes write different channels
const GraphState = Annotation.Root({
messages: Annotation<BaseMessage[]>({
reducer: (left, right) => left.concat(right),
default: () => [],
}),
category: Annotation<string>({
reducer: (_, right) => right,
default: () => "",
}),
status: Annotation<string>({
reducer: (_, right) => right,
default: () => "idle",
}),
});
// Agent node: writes { messages } only
function agent(state: typeof GraphState.State) {
const lastMsg = state.messages[state.messages.length - 1];
const content = typeof lastMsg?.content === "string" ? lastMsg.content : "?";
console.log(`[agent] messages before reply=${state.messages.length}`);
return { messages: [new AIMessage(`I heard: "${content}"`)] };
}
// Post-process node: writes { status, category } — does NOT write messages
function postProcess(state: typeof GraphState.State) {
console.log(`[post_process] messages=${state.messages.length}`);
return { status: "completed", category: "2" };
}
function buildGraph(checkpointer: any) {
return new StateGraph(GraphState)
.addNode("agent", agent)
.addNode("post_process", postProcess)
.addEdge("__start__", "agent")
.addEdge("agent", "post_process")
.addEdge("post_process", "__end__")
.compile({ checkpointer });
}
async function main() {
// --- Test with MemorySaver (works correctly) ---
const memGraph = buildGraph(new MemorySaver());
const memConfig = { configurable: { thread_id: "test-mem" } };
await memGraph.invoke({
messages: [new SystemMessage("You are a helpful assistant."), new HumanMessage("Hi there")],
}, memConfig);
const memState1 = await memGraph.getState(memConfig);
console.log("MemorySaver turn 1:", memState1.values.messages?.length, "messages"); // 3
await memGraph.invoke({ messages: [new HumanMessage("My name is Alice")] }, memConfig);
const memState2 = await memGraph.getState(memConfig);
console.log("MemorySaver turn 2:", memState2.values.messages?.length, "messages"); // 5
// --- Test with RedisSaver (loses messages) ---
const redisSaver = await RedisSaver.fromUrl("redis://localhost:6379");
const redisGraph = buildGraph(redisSaver);
const redisConfig = { configurable: { thread_id: `test-redis-${Date.now()}` } };
await redisGraph.invoke({
messages: [new SystemMessage("You are a helpful assistant."), new HumanMessage("Hi there")],
}, redisConfig);
const redisState1 = await redisGraph.getState(redisConfig);
console.log("RedisSaver turn 1:", redisState1.values.messages?.length, "messages"); // 0!
await redisGraph.invoke({ messages: [new HumanMessage("My name is Alice")] }, redisConfig);
const redisState2 = await redisGraph.getState(redisConfig);
console.log("RedisSaver turn 2:", redisState2.values.messages?.length, "messages"); // 0!
await redisSaver.end();
}
main().catch(console.error);
Error Message and Stack Trace (if applicable)
No error is thrown. The bug is silent data loss — getState() returns messages: [] instead of the accumulated conversation history.
Output:
[agent] messages before reply=2
[post_process] messages=3
MemorySaver turn 1: 3 messages
[agent] messages before reply=4
[post_process] messages=5
MemorySaver turn 2: 5 messages
[agent] messages before reply=2
[post_process] messages=3
RedisSaver turn 1: 0 messages <-- should be 3, ALL messages lost
[agent] messages before reply=1 <-- starts from 1 (only new HumanMessage), not 4
[post_process] messages=2
RedisSaver turn 2: 0 messages <-- should be 5, no history accumulated
Redis diagnostic (inspecting the latest checkpoint document directly):
channel_versions (tracked): {messages:3, status:4, category:4, branch:to:agent:3, branch:to:post_process:4}
channel_values (stored): [status, category]
>>> 'messages' MISSING from channel_values
Description
What I'm doing: Building a multi-node graph (agent → post_process) where different nodes write different state channels. The agent node writes messages, the post_process node writes status + category.
Expected behavior: getState() returns accumulated messages across turns. MemorySaver works correctly.
Actual behavior with RedisSaver: getState() returns 0 messages. Conversation history is silently lost between turns.
Root Cause
RedisSaver.put() delta-filters channel_values based on newVersions — only storing channels written by the current node. This mirrors PostgresSaver.put(), which stores channel blobs in a separate checkpoint_blobs table and reconstructs full state in getTuple() via version-keyed lookups.
RedisSaver stores everything in a single JSON document with no separate blob storage and no reconstruction logic in getTuple(). The delta-filter discards channels, and getTuple() has no way to recover them.
The offending code — RedisSaver.put() (libs/checkpoint-redis/src/index.ts):
if (storedCheckpoint.channel_values && newVersions !== undefined) {
if (Object.keys(newVersions).length === 0) {
storedCheckpoint.channel_values = {};
} else {
const filteredChannelValues: Record<string, any> = {};
for (const channel of Object.keys(newVersions)) {
if (channel in storedCheckpoint.channel_values)
filteredChannelValues[channel] = storedCheckpoint.channel_values[channel];
}
storedCheckpoint.channel_values = filteredChannelValues;
}
}
Supporting evidence:
ensureIndexes() creates a checkpoint_blobs RediSearch index with the right schema (thread_id, channel, version) — but put() never writes checkpoint_blob:* keys and getTuple() never reads them. This looks like scaffolding for a reconstruction layer that was never completed.
ShallowRedisSaver.put() takes _newVersions (underscore-prefixed — intentionally ignored) and stores channel_values directly. The shallow variant was written with awareness that single-document storage requires full channel values.
Impact
Any multi-node graph where the last node doesn't write to all channels will silently lose data.
Proposed Fix Options
There are two valid approaches. Happy to submit a PR for whichever direction you prefer.
Option A: Fix in put() — store full channel_values
Remove the delta-filter block. The checkpoint object passed by the Pregel loop already contains complete channel_values. Without separate blob storage, filtering is pure data loss.
const storedCheckpoint = copyCheckpoint(checkpoint);
- if (storedCheckpoint.channel_values && newVersions !== undefined) { ... }
const zsetKey = ...
Consistent with MemorySaver, SqliteSaver, and MongoDBSaver — all store full state. The checkpoint-validation delta-storage test (put.ts:204-213) already skips those three; RedisSaver would join the skip list.
Trade-off: Slightly larger JSON documents (unchanged channels stored redundantly). Matches how ShallowRedisSaver already works.
Option B: Fix in getTuple() — implement blob-based reconstruction
Keep the delta-filter in put() but complete the blob storage layer:
- In
put(), write each channel as a separate Redis JSON key (checkpoint_blob:{thread_id}:{ns}:{channel}:{version})
- In
getTuple(), look up each channel at the version recorded in channel_versions and reassemble full channel_values
- The
checkpoint_blobs RediSearch index already exists with the right schema — just needs to be wired up
This matches the PostgresSaver architecture and would pass the checkpoint-validation delta-storage test.
Trade-off: More code, more Redis keys per checkpoint, TTL management for blob keys — but storage-efficient for large channel values that don't change often.
Related Issues & Prior Decisions
Workaround
Until the fix is released:
const saver = await RedisSaver.fromUrl("redis://localhost:6379");
const originalPut = saver.put.bind(saver);
saver.put = (config, checkpoint, metadata, _newVersions) => {
return originalPut(config, checkpoint, metadata, undefined as any);
};
Reproduction Setup
Minimal package.json to reproduce the bug:
{
"name": "redis-checkpoint-bug",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx redis-bug.ts",
"dev:redis": "REDIS_URL=redis://localhost:6379 tsx redis-bug.ts"
},
"dependencies": {
"@langchain/core": "^1.1.40",
"@langchain/langgraph": "^1.2.9",
"@langchain/langgraph-checkpoint": "^1.0.1",
"@langchain/langgraph-checkpoint-redis": "1.0.4"
},
"devDependencies": {
"tsx": "^4.19.0"
}
}
Steps:
# 1. Install
pnpm install # or npm install
# 2. Run with MemorySaver only (baseline — always passes)
pnpm dev
# 3. Start Redis (e.g., via Docker)
docker run -d --name redis-test -p 6379:6379 redis:7-alpine
# 4. Run with RedisSaver (reproduces the bug)
pnpm dev:redis
Save the Example Code above as redis-bug.ts. With MemorySaver, turn 1 returns 3 messages, turn 2 returns 5. With RedisSaver, both return 0 — all conversation history is silently lost.
System Info
@langchain/langgraph-checkpoint-redis: 1.0.4
@langchain/langgraph: 1.2.9
@langchain/langgraph-checkpoint: 1.0.1
@langchain/core: 1.1.40
Node: v22.18.0
Platform: linux x64
Package manager: pnpm 10.30.1
RedisSaver.put() delta-filters channel_values but getTuple() does not reconstruct them — channels not written by the last node are lost
Checked other resources
SqliteSaverignoresnewVersionsargument onput- not storing deltas #594 (SqliteSaver variant) and RedisSaver.put() overwrites has_writes flag after putWrites() sets it for interrupt checkpoints #1996 (related has_writes overwrite)@langchain/langgraph-checkpoint-redis@1.0.4)Example Code
Error Message and Stack Trace (if applicable)
No error is thrown. The bug is silent data loss —
getState()returnsmessages: []instead of the accumulated conversation history.Output:
Redis diagnostic (inspecting the latest checkpoint document directly):
Description
What I'm doing: Building a multi-node graph (
agent → post_process) where different nodes write different state channels. Theagentnode writesmessages, thepost_processnode writesstatus+category.Expected behavior:
getState()returns accumulated messages across turns.MemorySaverworks correctly.Actual behavior with RedisSaver:
getState()returns 0 messages. Conversation history is silently lost between turns.Root Cause
RedisSaver.put()delta-filterschannel_valuesbased onnewVersions— only storing channels written by the current node. This mirrorsPostgresSaver.put(), which stores channel blobs in a separatecheckpoint_blobstable and reconstructs full state ingetTuple()via version-keyed lookups.RedisSaverstores everything in a single JSON document with no separate blob storage and no reconstruction logic ingetTuple(). The delta-filter discards channels, andgetTuple()has no way to recover them.The offending code —
RedisSaver.put()(libs/checkpoint-redis/src/index.ts):Supporting evidence:
ensureIndexes()creates acheckpoint_blobsRediSearch index with the right schema (thread_id,channel,version) — butput()never writescheckpoint_blob:*keys andgetTuple()never reads them. This looks like scaffolding for a reconstruction layer that was never completed.ShallowRedisSaver.put()takes_newVersions(underscore-prefixed — intentionally ignored) and storeschannel_valuesdirectly. The shallow variant was written with awareness that single-document storage requires full channel values.Impact
Any multi-node graph where the last node doesn't write to all channels will silently lose data.
Proposed Fix Options
There are two valid approaches. Happy to submit a PR for whichever direction you prefer.
Option A: Fix in
put()— store fullchannel_valuesRemove the delta-filter block. The
checkpointobject passed by the Pregel loop already contains completechannel_values. Without separate blob storage, filtering is pure data loss.const storedCheckpoint = copyCheckpoint(checkpoint); - if (storedCheckpoint.channel_values && newVersions !== undefined) { ... } const zsetKey = ...Consistent with
MemorySaver,SqliteSaver, andMongoDBSaver— all store full state. Thecheckpoint-validationdelta-storage test (put.ts:204-213) already skips those three;RedisSaverwould join the skip list.Trade-off: Slightly larger JSON documents (unchanged channels stored redundantly). Matches how
ShallowRedisSaveralready works.Option B: Fix in
getTuple()— implement blob-based reconstructionKeep the delta-filter in
put()but complete the blob storage layer:put(), write each channel as a separate Redis JSON key (checkpoint_blob:{thread_id}:{ns}:{channel}:{version})getTuple(), look up each channel at the version recorded inchannel_versionsand reassemble fullchannel_valuescheckpoint_blobsRediSearch index already exists with the right schema — just needs to be wired upThis matches the
PostgresSaverarchitecture and would pass thecheckpoint-validationdelta-storage test.Trade-off: More code, more Redis keys per checkpoint, TTL management for blob keys — but storage-efficient for large channel values that don't change often.
Related Issues & Prior Decisions
PostgresSaverdelta-storage fix; maintainer confirmed "respectingnewVersionsis the correct thing to do"MemorySaverignoresnewVersionsargument onput- not storing deltas #593 —MemorySaverignoresnewVersions(closed — accepted as-is)SqliteSaverignoresnewVersionsargument onput- not storing deltas #594 —SqliteSaverignoresnewVersions(open, unfixed)MongoDbSaverignoresnewVersionsargument onput- not storing deltas #595 —MongoDBSaverignoresnewVersions(open, unfixed)RedisSaverimplementation (introduced the delta-filter)RedisSaver.put()overwriteshas_writesflagWorkaround
Until the fix is released:
Reproduction Setup
Minimal
package.jsonto reproduce the bug:{ "name": "redis-checkpoint-bug", "private": true, "type": "module", "scripts": { "dev": "tsx redis-bug.ts", "dev:redis": "REDIS_URL=redis://localhost:6379 tsx redis-bug.ts" }, "dependencies": { "@langchain/core": "^1.1.40", "@langchain/langgraph": "^1.2.9", "@langchain/langgraph-checkpoint": "^1.0.1", "@langchain/langgraph-checkpoint-redis": "1.0.4" }, "devDependencies": { "tsx": "^4.19.0" } }Steps:
Save the Example Code above as
redis-bug.ts. WithMemorySaver, turn 1 returns 3 messages, turn 2 returns 5. WithRedisSaver, both return 0 — all conversation history is silently lost.System Info