checkpoint_blob versions not calculated globally #382

Open
opened 2026-02-15 18:16:22 -05:00 by yindo · 2 comments
Owner

Originally created by @carlosbaraza on GitHub (Dec 6, 2025).

Checked other resources

  • I added a very descriptive title to this issue.
  • I searched the LangGraph.js documentation with the integrated search.
  • I used the GitHub search to find a similar question and didn't find it.
  • I am sure that this is a bug in LangGraph.js rather than my code.
  • The bug is not resolved by updating to the latest stable version of LangGraph (or the specific integration package).

Example Code

// Use DO UPDATE instead of DO NOTHING for blobs
// This ensures branches can overwrite blobs at the same channel version
const UPSERT_BLOBS_WITH_UPDATE_SQL = `
  INSERT INTO public.checkpoint_blobs (thread_id, checkpoint_ns, channel, version, type, blob)
  VALUES ($1, $2, $3, $4, $5, $6)
  ON CONFLICT (thread_id, checkpoint_ns, channel, version) DO UPDATE SET
    type = EXCLUDED.type,
    blob = EXCLUDED.blob
`;

Error Message and Stack Trace (if applicable)

No response

Description

I am building a chatbot and I want to be able to rerun messages from a particular time in the checkpoints. However, when new messages are created, they are not stored correctly in Postgres, because the same version number is calculated for the rerun of the message, and blobs are not silently ignored by the DO NOTHING.

A preferable solution would be to change the version number calculation for the blobs not to be based on the previous state version, but something global.

Not sure if I might be doing something wrong here.

Why Branches Have The Same Version

When you rerun (branch from a checkpoint), LangGraph:

  1. Loads the parent checkpoint's state (e.g., messages at version 2 after first user
    message)
  2. Runs the AI again, which modifies messages
  3. Increments version: 2 → 3... BUT wait, version 3 already exists from the original
    branch!

The problem is LangGraph calculates the next version based on the current state being
modified, not globally. So:

  Original:  User "Hello" (v1) → AI response (v2) → User "Hi" (v3) → AI response (v4)
                                      ↓
  Rerun from v2:                     AI new response → also gets v3!
                                      ↓
  Rerun again from v2:               AI another response → also gets v3!

All reruns from the same point calculate the same next version.

System Info

Node version: v22.12.0
Operating system: darwin arm64
Package manager: npm
Package manager version: N/A

zod -> @3.25.55, , @"^3.25.55", @"^3.0.0", @"^3.23.8", @"^3.22.4", @"^3.24.1"

Originally created by @carlosbaraza on GitHub (Dec 6, 2025). ### Checked other resources - [x] I added a very descriptive title to this issue. - [x] I searched the LangGraph.js documentation with the integrated search. - [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.js rather than my code. - [x] The bug is not resolved by updating to the latest stable version of LangGraph (or the specific integration package). ### Example Code ``` // Use DO UPDATE instead of DO NOTHING for blobs // This ensures branches can overwrite blobs at the same channel version const UPSERT_BLOBS_WITH_UPDATE_SQL = ` INSERT INTO public.checkpoint_blobs (thread_id, checkpoint_ns, channel, version, type, blob) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (thread_id, checkpoint_ns, channel, version) DO UPDATE SET type = EXCLUDED.type, blob = EXCLUDED.blob `; ``` ### Error Message and Stack Trace (if applicable) _No response_ ### Description I am building a chatbot and I want to be able to rerun messages from a particular time in the checkpoints. However, when new messages are created, they are not stored correctly in Postgres, because the same version number is calculated for the rerun of the message, and blobs are not silently ignored by the DO NOTHING. A preferable solution would be to change the version number calculation for the blobs not to be based on the previous state version, but something global. Not sure if I might be doing something wrong here. ## Why Branches Have The Same Version When you rerun (branch from a checkpoint), LangGraph: 1. Loads the parent checkpoint's state (e.g., messages at version 2 after first user message) 2. Runs the AI again, which modifies messages 3. Increments version: 2 → 3... BUT wait, version 3 already exists from the original branch! The problem is LangGraph calculates the next version based on the current state being modified, not globally. So: ``` Original: User "Hello" (v1) → AI response (v2) → User "Hi" (v3) → AI response (v4) ↓ Rerun from v2: AI new response → also gets v3! ↓ Rerun again from v2: AI another response → also gets v3! ``` All reruns from the same point calculate the same next version. ### System Info Node version: v22.12.0 Operating system: darwin arm64 Package manager: npm Package manager version: N/A -------------------- zod -> @3.25.55, , @"^3.25.55", @"^3.0.0", @"^3.23.8", @"^3.22.4", @"^3.24.1"
Author
Owner

@carlosbaraza commented on GitHub (Dec 6, 2025):

My current solution is to extend the checkpointer class:

/**
 * Custom PostgresSaver that fixes checkpoint blob persistence for branching
 *
 * PROBLEM:
 * LangGraph's PostgresSaver uses `ON CONFLICT (thread_id, checkpoint_ns, channel, version) DO NOTHING`
 * when writing checkpoint blobs. This means when multiple branches create checkpoints with the same
 * channel version (which happens when reruns branch from the same parent), only the FIRST blob is kept.
 * Subsequent branches can't overwrite it, so when loading a checkpoint from a newer branch,
 * you get the messages from the first branch that wrote to that version.
 *
 * SOLUTION:
 * Override the `put` method to use `DO UPDATE` instead of `DO NOTHING` for checkpoint blobs.
 * This ensures each checkpoint writes its own blob data, and the most recent write wins.
 *
 * The trade-off is that blobs from earlier branches may be overwritten, but since we always
 * load the checkpoint we want (via checkpoint_id), this is acceptable. The key insight is
 * that when branching, we want the CURRENT branch's data to be stored, not the first one's.
 */
import { PostgresSaver } from '@langchain/langgraph-checkpoint-postgres';
import type { Checkpoint, CheckpointMetadata } from '@langchain/langgraph-checkpoint';
import type { RunnableConfig } from '@langchain/core/runnables';
import pg from 'pg';

const { Pool } = pg;

export class BranchingPostgresSaver extends PostgresSaver {
  /**
   * Create an instance from a connection string
   */
  static fromConnString(connString: string): BranchingPostgresSaver {
    const pool = new Pool({ connectionString: connString });
    return new BranchingPostgresSaver(pool);
  }

  /**
   * Save a checkpoint to the database.
   * This override changes the blob upsert from DO NOTHING to DO UPDATE
   * to ensure branches can overwrite blobs at the same channel version.
   */
  async put(
    config: RunnableConfig,
    checkpoint: Checkpoint,
    metadata: CheckpointMetadata,
    newVersions: Record<string, number | string>
  ): Promise<RunnableConfig> {
    if (config.configurable === undefined) {
      throw new Error(`Missing "configurable" field in "config" param`);
    }

    const { thread_id, checkpoint_ns = '', checkpoint_id } = config.configurable as {
      thread_id: string;
      checkpoint_ns?: string;
      checkpoint_id?: string;
    };

    const nextConfig: RunnableConfig = {
      configurable: {
        thread_id,
        checkpoint_ns,
        checkpoint_id: checkpoint.id,
      },
    };

    // @ts-expect-error - accessing private pool property
    const client = await this.pool.connect();

    try {
      await client.query('BEGIN');

      // Dump blobs - reuse parent class method
      const serializedBlobs = await this._dumpBlobs(
        thread_id,
        checkpoint_ns,
        checkpoint.channel_values,
        newVersions
      );

      // Use DO UPDATE instead of DO NOTHING for blobs
      // This ensures branches can overwrite blobs at the same channel version
      const UPSERT_BLOBS_WITH_UPDATE_SQL = `
        INSERT INTO public.checkpoint_blobs (thread_id, checkpoint_ns, channel, version, type, blob)
        VALUES ($1, $2, $3, $4, $5, $6)
        ON CONFLICT (thread_id, checkpoint_ns, channel, version) DO UPDATE SET
          type = EXCLUDED.type,
          blob = EXCLUDED.blob
      `;

      for (const serializedBlob of serializedBlobs) {
        await client.query(UPSERT_BLOBS_WITH_UPDATE_SQL, serializedBlob);
      }

      const serializedCheckpoint = this._dumpCheckpoint(checkpoint);
      const serializedMetadata = await this._dumpMetadata(metadata);

      // Upsert checkpoint record
      const UPSERT_CHECKPOINTS_SQL = `
        INSERT INTO public.checkpoints (thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, checkpoint, metadata)
        VALUES ($1, $2, $3, $4, $5, $6)
        ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id)
        DO UPDATE SET
          checkpoint = EXCLUDED.checkpoint,
          metadata = EXCLUDED.metadata
      `;

      await client.query(UPSERT_CHECKPOINTS_SQL, [
        thread_id,
        checkpoint_ns,
        checkpoint.id,
        checkpoint_id,
        serializedCheckpoint,
        serializedMetadata,
      ]);

      await client.query('COMMIT');
    } catch (e) {
      await client.query('ROLLBACK');
      throw e;
    } finally {
      client.release();
    }

    return nextConfig;
  }
}

@carlosbaraza commented on GitHub (Dec 6, 2025): My current solution is to extend the checkpointer class: ```typescript /** * Custom PostgresSaver that fixes checkpoint blob persistence for branching * * PROBLEM: * LangGraph's PostgresSaver uses `ON CONFLICT (thread_id, checkpoint_ns, channel, version) DO NOTHING` * when writing checkpoint blobs. This means when multiple branches create checkpoints with the same * channel version (which happens when reruns branch from the same parent), only the FIRST blob is kept. * Subsequent branches can't overwrite it, so when loading a checkpoint from a newer branch, * you get the messages from the first branch that wrote to that version. * * SOLUTION: * Override the `put` method to use `DO UPDATE` instead of `DO NOTHING` for checkpoint blobs. * This ensures each checkpoint writes its own blob data, and the most recent write wins. * * The trade-off is that blobs from earlier branches may be overwritten, but since we always * load the checkpoint we want (via checkpoint_id), this is acceptable. The key insight is * that when branching, we want the CURRENT branch's data to be stored, not the first one's. */ import { PostgresSaver } from '@langchain/langgraph-checkpoint-postgres'; import type { Checkpoint, CheckpointMetadata } from '@langchain/langgraph-checkpoint'; import type { RunnableConfig } from '@langchain/core/runnables'; import pg from 'pg'; const { Pool } = pg; export class BranchingPostgresSaver extends PostgresSaver { /** * Create an instance from a connection string */ static fromConnString(connString: string): BranchingPostgresSaver { const pool = new Pool({ connectionString: connString }); return new BranchingPostgresSaver(pool); } /** * Save a checkpoint to the database. * This override changes the blob upsert from DO NOTHING to DO UPDATE * to ensure branches can overwrite blobs at the same channel version. */ async put( config: RunnableConfig, checkpoint: Checkpoint, metadata: CheckpointMetadata, newVersions: Record<string, number | string> ): Promise<RunnableConfig> { if (config.configurable === undefined) { throw new Error(`Missing "configurable" field in "config" param`); } const { thread_id, checkpoint_ns = '', checkpoint_id } = config.configurable as { thread_id: string; checkpoint_ns?: string; checkpoint_id?: string; }; const nextConfig: RunnableConfig = { configurable: { thread_id, checkpoint_ns, checkpoint_id: checkpoint.id, }, }; // @ts-expect-error - accessing private pool property const client = await this.pool.connect(); try { await client.query('BEGIN'); // Dump blobs - reuse parent class method const serializedBlobs = await this._dumpBlobs( thread_id, checkpoint_ns, checkpoint.channel_values, newVersions ); // Use DO UPDATE instead of DO NOTHING for blobs // This ensures branches can overwrite blobs at the same channel version const UPSERT_BLOBS_WITH_UPDATE_SQL = ` INSERT INTO public.checkpoint_blobs (thread_id, checkpoint_ns, channel, version, type, blob) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (thread_id, checkpoint_ns, channel, version) DO UPDATE SET type = EXCLUDED.type, blob = EXCLUDED.blob `; for (const serializedBlob of serializedBlobs) { await client.query(UPSERT_BLOBS_WITH_UPDATE_SQL, serializedBlob); } const serializedCheckpoint = this._dumpCheckpoint(checkpoint); const serializedMetadata = await this._dumpMetadata(metadata); // Upsert checkpoint record const UPSERT_CHECKPOINTS_SQL = ` INSERT INTO public.checkpoints (thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, checkpoint, metadata) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id) DO UPDATE SET checkpoint = EXCLUDED.checkpoint, metadata = EXCLUDED.metadata `; await client.query(UPSERT_CHECKPOINTS_SQL, [ thread_id, checkpoint_ns, checkpoint.id, checkpoint_id, serializedCheckpoint, serializedMetadata, ]); await client.query('COMMIT'); } catch (e) { await client.query('ROLLBACK'); throw e; } finally { client.release(); } return nextConfig; } } ```
Author
Owner

@guicelso commented on GitHub (Dec 12, 2025):

I'm having the same problem!

@guicelso commented on GitHub (Dec 12, 2025): I'm having the same problem!
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: langchain-ai/langgraphjs#382