feat(checkpoint-postgres)!: align version format and inline primitive storage with Python - #1968
Conversation
🦋 Changeset detectedLatest commit: 7a85bb7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
…s with Python parity fixes Fixes branching bug (#1812), adds task_path support, inline primitives, and globally unique version IDs. Upstream PRs: langchain-ai/langgraphjs#1967, langchain-ai/langgraphjs#1968
|
This PR directly addresses #1812 — the branching bug caused by deterministic integer versions in Root cause: When branching from the same checkpoint, Fix: In the meantime, we've published a temporary drop-in replacement package with all fixes from both #1967 and this PR:
Usage is a one-line import change: // Before:
import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres";
// After:
import { PostgresSaver } from "@logicpanel/langgraph-checkpoint-postgres";We'll deprecate the temporary package once these PRs are merged. |
Christian Bromann (christian-bromann)
left a comment
There was a problem hiding this comment.
Can we have some unit tests here to ensure we don't regress in the future?
863dddd to
d71f9ae
Compare
|
Christian Bromann (@christian-bromann) Christian BromannAdded unit tests in
All passing: |
|
so? |
… storage with Python BREAKING: Align checkpoint version format and channel_values storage with the Python implementation. Breaking changes: 1. Version format: getNextVersion now produces zero-padded string versions (e.g. "00000000000000000000000000000001.0482910384729105") instead of integer versions (1, 2, 3). Migration impact: Existing checkpoints with integer versions will still be readable. New checkpoints will use string versions. 2. Inline primitives: Primitive channel values (string, number, boolean, null) are now stored inline in the checkpoint JSONB column instead of in checkpoint_blobs. Requires PR langchain-ai#1967 (read-side merge) to be deployed first, so existing readers can handle both formats. These changes enable cross-compatibility between Python and JS checkpoint implementations sharing the same database, required for hybrid Python/JS LangGraph deployments.
…ne primitive storage Add unit tests covering the changes introduced in this PR: - getNextVersion: format validation, counter increment, backward compat with integer versions, uniqueness, string sorting, Python format parity - _dumpCheckpoint: inline primitive extraction (string, number, boolean, null) vs complex value exclusion - _dumpBlobs: primitive skipping, complex value serialization, mixed value handling - _loadCheckpoint: inline + blob merge, blob-wins-on-collision, backward compat with missing inline values
d71f9ae to
7a85bb7
Compare
@langchain/langgraph-checkpoint
@langchain/langgraph-checkpoint-mongodb
@langchain/langgraph-checkpoint-postgres
@langchain/langgraph-checkpoint-redis
@langchain/langgraph-checkpoint-sqlite
@langchain/langgraph-checkpoint-validation
create-langgraph
@langchain/langgraph-api
@langchain/langgraph-cli
@langchain/langgraph
@langchain/langgraph-cua
@langchain/langgraph-supervisor
@langchain/langgraph-swarm
@langchain/langgraph-ui
@langchain/langgraph-sdk
commit: |
Christian Bromann (christian-bromann)
left a comment
There was a problem hiding this comment.
I don't think the PR is mergable at current state due to its breaking nature.
| string, | ||
| Uint8Array | undefined | ||
| ][] = []; | ||
| for (const [k, ver] of Object.entries(versions)) { |
There was a problem hiding this comment.
We should keep Promise.all. For checkpoints with many non-primitive channel values, this serializes all dumpsTyped calls that were previously concurrent.
|
|
||
| Align checkpoint version format and channel_values storage with the Python implementation: | ||
|
|
||
| 1. **Version format:** `getNextVersion` now produces zero-padded string versions (e.g. `"00000000000000000000000000000001.0482910384729105"`) instead of integer versions (`1`, `2`, `3`). |
There was a problem hiding this comment.
I don't think this can be released as minor change. The getNextVersion signature changes the version format stored in Postgres, and the inline primitive storage changes the shape of the checkpoint JSONB column. Both are breaking changes for users who upgrade without first deploying #1967. The PR description itself uses the ! breaking-change marker.
We currently don't plan major releases , so I can't merge this PR in the current form.
| * ``` | ||
| */ | ||
| export class PostgresSaver extends BaseCheckpointSaver { | ||
| export class PostgresSaver extends BaseCheckpointSaver<string | number> { |
There was a problem hiding this comment.
Since getNextVersion always returns a string, the type parameter should be BaseCheckpointSaver<string>, not BaseCheckpointSaver<string | number>. The union is needed to accept legacy integer values as input, but the output is always a string. The union type leaks into callers and weakens type safety. We should handle the integer input case internally and keep the declared version type as string.
| .padEnd(16, "0")}`; | ||
| } | ||
| const currentStr = String(current); | ||
| const currentV = parseInt(currentStr.split(".")[0], 10) || 0; |
There was a problem hiding this comment.
The || 0 fallback silently swallows a NaN and a legitimate 0. If parseInt returns NaN (e.g. a malformed version), this produces nextV = 1, which looks like a fresh start and could silently cause a version regression. Use Number.isNaN(parsed) ? 0 : parsed instead:
const parsed = parseInt(currentStr.split(".")[0], 10);
const currentV = Number.isNaN(parsed) ? 0 : parsed;| encoder.encode("json"), | ||
| encoder.encode(JSON.stringify({ complex: true })), |
There was a problem hiding this comment.
This test bypasses the real serde (the default JsonPlusSerializer) and manually encodes values as json type. The _loadBlobs method calls this.serde.loadsTyped("json", bytes), if the default serde doesn't handle plain "json" type strings (it uses "msgpack" or "json+" under the hood), this test could pass while the real runtime path fails. Consider using the actual serde round-trip (await saver.serde.dumpsTyped(...)) to produce the blob values in the test, to ensure you're exercising the real deserialization path.
Summary
BREAKING: Align checkpoint version format and channel_values storage with the Python implementation.
Breaking changes:
Version format:
getNextVersionnow produces zero-padded string versions (e.g."00000000000000000000000000000001.0482910384729105") instead of integer versions (1,2,3).Migration impact: Existing checkpoints with integer versions will still be readable. New checkpoints will use string versions. Active threads may need to be completed before upgrading, as
getNextVersionneeds to parse the current version format.Inline primitives: Primitive channel values (
string,number,boolean,null) are now stored inline in the checkpoint JSONB column instead of incheckpoint_blobs. Requires feat(checkpoint-postgres): add task_path support and inline primitive merging #1967 (read-side merge) to be deployed first, so existing readers can handle both formats.Why:
These changes enable cross-compatibility between Python and JS checkpoint implementations sharing the same database, which is required for hybrid Python/JS LangGraph deployments.
Upgrade path:
Test plan
getNextVersionproduces correct zero-padded string format