Skip to content

RedisSaver.put() delta-filters channel_values but getTuple() does not reconstruct them — channels not written by the last node are lost #2334

Description

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 codeRedisSaver.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:

  1. In put(), write each channel as a separate Redis JSON key (checkpoint_blob:{thread_id}:{ns}:{channel}:{version})
  2. In getTuple(), look up each channel at the version recorded in channel_versions and reassemble full channel_values
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions