Commit Graph
153 Commits
Author SHA1 Message Date
Sydney RunkleandClaude Sonnet 4.6 f80a18b376 fix(channels): address code review feedback on DeltaChannel
delta.py — value consistency:
- `__init__` now starts with `value=MISSING` (was `[]`); both fresh
  construction and clones are consistently uninitialised until
  `from_checkpoint()` or `copy()` sets the real value
- `_clone_empty` drops `__new__` in favour of the normal constructor;
  `typ` and `key` are restored explicitly afterwards (`typ` may differ
  from `list` when set via Annotated injection; `key` is injected by
  the graph builder after construction)

delta.py — snapshot cadence:
- `is_snapshot_step` now guards `step > 0`; snapshots fire at steps N,
  2N, 3N, … instead of also at step 0 where `0 % N == 0` always held

_checkpoint.py — runtime guards:
- replace both `assert isinstance(spec, DeltaChannel)` with proper
  `if not isinstance: raise TypeError`; `assert` is stripped by `-O`
  and is wrong for production invariant checks

binop.py — avoid unnecessary allocation:
- `_get_overwrite`: replace `set(value.keys()) == {OVERWRITE}` with
  `len(value) == 1 and OVERWRITE in value` to avoid allocating a
  throwaway set on every call

checkpoint-postgres — typed rows:
- add `_DeltaCombinedRow(TypedDict, total=False)` documenting the nine
  columns emitted by `SELECT_DELTA_COMBINED_SQL`'s UNION ALL; change
  `_build_delta_channel_writes_history` parameter from `Sequence[Any]`
  to `Sequence[_DeltaCombinedRow]`; call sites cast the psycopg
  `DictRow` result accordingly

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 09:22:05 -04:00
Sydney RunkleandClaude Sonnet 4.6 5787258a63 fix(checkpoint-postgres): remove unused _DeltaSnapshot import
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 17:58:49 -04:00
Sydney RunkleandClaude Sonnet 4.6 702895e484 fix(checkpoint-postgres): single-roundtrip CTE query + write-ordering fix
Replaces the three sequential SELECT roundtrips in _get_channel_writes_history
(checkpoints, checkpoint_writes, checkpoint_blobs) with one combined
UNION ALL query tagged by a _kind discriminator column. Both sync and async
paths now do one execute + one fetchall regardless of pipeline mode.

_build_delta_channel_writes_history is updated to accept the single tagged
rows list and dispatch on _kind while building its lookup dicts; the three
old SQL constants are removed.

Also fixes write-collection ordering in _build_delta_channel_writes_history:
the seed-terminator blob check previously fired before collecting that
ancestor's writes, silently dropping the transition writes needed to
reconstruct the child's state. Writes are now collected first.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 17:55:29 -04:00
9abee46990 feat(langgraph): DeltaChannel snapshot_frequency — bounded read depth with write-count snapshotting (#7634)
## Summary

Builds on #7586. Adds `snapshot_frequency: int | None` to
`DeltaChannel`, letting users trade storage for bounded read depth. Also
promotes `channels/_delta.py` from private to public
(`channels/delta.py`).

### How it works

Every Nth **pregel step**, `create_checkpoint` writes a `_DeltaSnapshot`
blob instead of `DELTA_SENTINEL`. The ancestor walk in
`_get_channel_writes_history` terminates at the snapshot rather than
walking the full chain, bounding replay to at most N steps.

Snapshots are **eager**: fired even on steps where the channel had no
write (via a `get_next_version` version bump), so the depth bound holds
unconditionally — no risk of the cadence drifting if a channel happens
to be silent at a snapshot step.

### Storage formula

| Mode | Blob storage | Read depth |
|------|-------------|------------|
| `snapshot_frequency=None` (pure delta) | O(N) — sentinels only | O(N)
steps |
| `snapshot_frequency=K` | O(N²/K) — periodic snapshots of growing size
| O(K) steps |
| add_messages / BinOp | O(N²) — full blob every step | O(1) |

At N turns with ~400 char/msg messages, total snapshot storage ≈ N²/(2K)
× avg_msg_size, since each snapshot blob grows linearly with accumulated
messages.

### Key design decisions

- **Step-based**: `snapshot_frequency=K` means "snapshot every K pregel
steps." `create_checkpoint` has the step number; the channel itself
doesn't need to track writes.
- **Eager**: version-bumped via `get_next_version` even on non-write
steps so `put()` always stores the blob.
- **`_DeltaSnapshot` NamedTuple + msgpack ext type**
(`EXT_DELTA_SNAPSHOT = 7`): serde type tag dispatches in
`from_checkpoint` — no dict key inspection, no collision risk.
- **`from_checkpoint` semantics**: `_DeltaSnapshot` → restore value
directly (no replay needed); `DELTA_SENTINEL` / `MISSING` → replay from
ancestor writes; plain value → pre-migration BinOp blob.
- **InMemorySaver and PostgresSaver updated**:
`_get_channel_writes_history` collects the snapshot ancestor's
pending_writes before terminating (they encode the *next* step's
transition, unlike pre-delta migration blobs which subsume their own
writes).
- **`snapshot_frequency=None`** is the pure-delta default (replaces
`math.inf`).

### Benchmark results (InMemory, ~400 char/msg)

**Storage**

| turns | ctx | freq=1 | freq=5 | freq=10 | freq=50 | freq=inf |
|------:|----:|-------:|-------:|--------:|--------:|---------:|
| 50 | ~10K tok | 5.9 MB | 1.2 MB | 601.3 KB | 119.8 KB | 29.5 KB |
| 100 | ~20K tok | 23.7 MB | 4.8 MB | 2.4 MB | 475.8 KB | 58.4 KB |
| 200 | ~40K tok | 94.6 MB | 19.0 MB | 9.5 MB | 1.9 MB | 116.4 KB |
| 500 | ~100K tok | 591.5 MB | 118.4 MB | 59.2 MB | 11.8 MB | 290.3 KB |

**Read latency** (avg of 5 `get_state` calls)

| turns | ctx | freq=1 | freq=5 | freq=10 | freq=50 | freq=inf |
|------:|----:|-------:|-------:|--------:|--------:|---------:|
| 50 | ~10K tok | 0.4ms | 0.4ms | 0.7ms | 0.9ms | 1.8ms |
| 100 | ~20K tok | 0.7ms | 0.9ms | 1.0ms | 1.7ms | 5.7ms |
| 200 | ~40K tok | 1.5ms | 1.7ms | 4.5ms | 3.7ms | 20.1ms |
| 500 | ~100K tok | 3.6ms | 4.2ms | 4.4ms | 9.0ms | 110.3ms |

**Per-invoke write latency**

| turns | ctx | freq=1 | freq=5 | freq=10 | freq=50 | freq=inf |
|------:|----:|-------:|-------:|--------:|--------:|---------:|
| 50 | ~10K tok | 1.5ms | 1.1ms | 1.1ms | 1.3ms | 1.7ms |
| 100 | ~20K tok | 2.5ms | 1.6ms | 1.5ms | 1.7ms | 3.3ms |
| 200 | ~40K tok | 3.4ms | 2.3ms | 2.2ms | 2.5ms | 8.3ms |
| 500 | ~100K tok | 6.2ms | 4.2ms | 3.6ms | 4.1ms | 39.2ms |

## Test plan

- [x] `make format` / `make lint` clean across `langgraph`,
`checkpoint`, `checkpoint-postgres`
- [x] `tests/test_channels.py` — 37 passing including step-based and
eager-snapshot tests
- [x] `tests/test_delta_channel_migration.py` — all passing
- [x] Full suite: 1387 passing, 6 pre-existing failures unrelated to
this branch

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 16:42:48 -04:00
Sydney RunkleandClaude Opus 4.7 f25d1935ef fix(postgres): handle missing checkpoint_id in _get_channel_writes_history; update test signatures
Two fixes exposed by running the postgres test suite against a local
postgres instance:

1. `PostgresSaver._get_channel_writes_history` /
   `AsyncPostgresSaver._aget_channel_writes_history` required
   `checkpoint_id` in the passed config, raising `KeyError` when called
   with just `thread_id` (e.g. `graph.aget_state({"thread_id": "..."})`).
   Now resolves to the latest checkpoint via `get_tuple`/`aget_tuple`
   when the id is missing.

2. `test_get_checkpoint_no_channel_values` (sync + async) monkeypatched
   `_load_checkpoint_tuple` with the old `(value, cur)` signature. Method
   now takes `(value)` only since delta reconstruction moved out of the
   tuple-load path — updated both tests.

Local postgres (`brew install pgvector postgresql@16`, running on port
5441) now exercises all 40 non-vector postgres tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:47:22 -04:00
Sydney RunkleandClaude Opus 4.7 3a7ed5b454 refactor(delta-channel): honest data model, private experimental API
Restructure DeltaChannel reconstruction so the hydration path matches
pregel's storage axes (blobs + writes) without leaking internal DTOs
into the public checkpoint contract.

Key changes:

* Deleted `DeltaChannelWrites` dataclass and `SEED_UNSET` sentinel.
  Reconstruction data no longer flows through `Checkpoint.channel_values`
  as a wrapped DTO — that field now carries a value or `DELTA_SENTINEL`,
  never a reconstruction shape.
* Added private `_ChannelWritesHistory(seed: Any, writes: list[PendingWrite])`
  NamedTuple as the return type for the new storage-level query.
* Added private, experimental `_get_channel_writes_history` /
  `_aget_channel_writes_history` on `BaseCheckpointSaver` — reference
  impl via `get_tuple` + `parent_config` walk, overridden on
  `InMemorySaver` / `PostgresSaver` / `AsyncPostgresSaver` for perf.
  Fixes a latent migration bug in the base fallback (now inspects
  ancestor `channel_values` for pre-delta seed).
* `DeltaChannel.from_checkpoint(seed)` simplified to two cases
  (sentinel/MISSING → empty, else → seed). New `replay_writes` method
  folds `list[PendingWrite]` through the reducer.
* Delta hydration consolidated inside `channels_from_checkpoint` via
  optional `saver` + `config` kwargs (+ async mirror
  `achannels_from_checkpoint`). All six pregel call sites updated.
  `get_tuple` no longer patches `channel_values` — removed
  `_resolve_delta_channels` (memory) and per-tuple reconstruction from
  `_load_checkpoint_tuple` (postgres sync + async).
* Hydration short-circuits on the target's own blob: if
  `channel_values[k]` is a real value (pre-migration tip, `update_state`
  result), use it directly. Only walks ancestors when the target holds
  sentinel or is missing. Fixes a correctness bug where migration-tip
  and `update_state` values would be lost.
* New test_delta_channel_migration.py: 10 scenarios covering
  BinaryOperatorAggregate → DeltaChannel migration (basic + async,
  time-travel, fork, `update_state`, tip-of-pre-migration, base-saver
  fallback parity, cross-thread isolation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:15:02 -04:00
Sydney Runkle 31ef0e942a refactor(delta-channel): drop snapshot_every and saver Overwrite terminator
snapshot_every was a knob for bounding reconstruction cost on deep threads.
Benchmarks (notes/add_messages_replay_problem.md + scratch work on
sr/add-messages-replay-bench) showed the add_messages fast-path
(optimize/add-messages-fast-path) closes the quadratic replay cost for
threads under ~1000 turns, where the crossover to snapshots makes sense.
For deeper threads we'll ship a first-class compaction primitive instead.

Removals:

* DeltaChannel: snapshot_every ctor param, _writes_since_snapshot counter,
  should_snapshot() / snapshot_write() methods, counter threading through
  _apply_write / update / from_checkpoint / copy.
* Pregel loop: post-checkpoint snapshot-injection block and
  SNAPSHOT_TASK_ID import + constant.
* Checkpoint base: _overwrite_types() helper and the ancestor-walk
  short-circuit on user-emitted Overwrite in sync + async
  get_channel_writes.
* InMemory + Postgres savers: same walk-terminator shortcut. The
  pre-delta blob terminator (seed-from-ancestor-blob) stays — it's
  required for migration correctness, not a snapshot optimization.
* Tests for all of the above.

Preserved:

* Channel-level Overwrite semantics in DeltaChannel / BinOpAggregate:
  Overwrite still resets the value at reducer level; same-super-step
  dedup and InvalidUpdateError on multiple Overwrites still enforced.
* Pre-delta migration seeding.
2026-04-23 09:54:12 -04:00
Sydney Runkle d120f127ca refactor(delta-channel): plain SELECT WHERE replaces recursive CTE
The recursive CTE was bottlenecked by a JSON-expression join
(`bl.version = checkpoint->'channel_versions'->>bl.channel`) that the
planner could not index, producing an O(ancestors x blobs) nested-loop.
At depth 1000 it ran ~275 ms and removed ~2M filter rows; the recursion
itself was 2.4 ms.

Switch to three plain indexed SELECTs per delta channel
(checkpoints, checkpoint_writes, checkpoint_blobs); a pure helper on
BasePostgresSaver walks the parent chain and assembles
DeltaChannelWrites. Sync (__init__.py) and async (aio.py) each own
their three-roundtrip I/O wrappers.

Bench numbers (notes/delta_channel_query_bench.md): 3x at depth 50,
15x at depth 200, ~100x at depth 1000. Plain over-fetches sibling rows
when the thread branches but still wins at every realistic depth on
both local and remote postgres.

Multi-channel coalescing dropped — reconstruction is per-channel now.
Same shape as InMemorySaver. Can come back as a SQL-level
optimization later if needed.
2026-04-23 08:21:00 -04:00
Sydney RunkleandClaude Opus 4.7 cd8fad5905 fix(delta-channel): target-exclusion, pre-delta seed, one-query postgres walk
Four fixes from an independent review of the reconstruction pipeline, plus
a structural cleanup:

1. Ancestor walk excludes the target checkpoint itself (matches pregel:
   writes stored under checkpoint_id=T are pending for the NEXT step and
   applied separately via apply_writes). Memory saver previously included
   them, diverging from Postgres and causing pending writes to be folded
   into the reconstructed snapshot — visible via get_state during
   interrupts and time-travel into a non-leaf checkpoint.

2. Pre-delta blob terminator. When the walk hits an ancestor whose blob
   for the channel is a real value (not DELTA_SENTINEL), bind that blob
   as DeltaChannelWrites.seed and stop. Without this, threads migrated
   from pre-delta storage would replay ancestor writes to the root
   forever AND lose any value that lived only in the old blob
   (e.g. from update_state). Per-ancestor, the blob is checked BEFORE
   its writes — a pre-delta blob subsumes writes at the same checkpoint,
   so including them would double-count.

3. Base-fallback get_channel_writes follows parent_checkpoint_id instead
   of list(before=...). The previous form returned every tuple with
   id<target, including sibling branches on forked threads.

4. seed replaces the Overwrite-wrapping hack for pre-delta values.
   DeltaChannelWrites(writes, seed=SEED_UNSET) makes the saver's
   reconstruction terminator semantically explicit; drops the lazy
   _make_overwrite import dance. User-emitted Overwrite still reset the
   chain via _apply_write as before.

Postgres: recursive CTE enumerates on-path ancestors and joins once
against checkpoint_writes and once against checkpoint_blobs for every
delta channel in the get_tuple — one roundtrip instead of the previous
3 queries × N channels.

Tests added:
- Pre-delta blob seeding (seed binding, no double-counting of ancestor
  writes at the terminator, pending-at-target excluded).
- Root checkpoint returns empty writes.
- Seed-based from_checkpoint replay (three scenarios: with writes,
  seed-only, seed=None distinct from SEED_UNSET).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:09:08 -04:00
Sydney Runkle acc7eda8c5 optimizations i sure hope 2026-04-22 20:47:51 -04:00
Sydney Runkle 2e7edb2b60 lint 2026-04-22 17:30:22 -04:00
Sydney Runkle 325cb42f19 lint and snapshot every 2026-04-22 17:06:53 -04:00
Sydney Runkle b9fad696ec cleanup 2026-04-22 16:45:32 -04:00
Sydney RunkleandClaude Sonnet 4.6 9342ae215a chore(delta-channel): remove snapshot_every — simpler design, better storage savings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 14:12:57 -04:00
Sydney Runkle ee5b3c2639 refactor(postgres): replace recursive CTE with two-query ancestor walk for DeltaChannel
Instead of a recursive SQL CTE, collect the ancestor checkpoint ID chain in
Python by fetching all (checkpoint_id, parent_checkpoint_id) for the thread
in one query, then fetch writes with a plain WHERE checkpoint_id = ANY(...).

Simpler, avoids recursive query planner overhead, and uses well-indexed lookups.
2026-04-22 14:03:37 -04:00
Sydney Runkle d7c3616620 feat(delta-channel): store sentinel in blobs, reconstruct from checkpoint_writes
DeltaChannel.checkpoint() now returns a zero-byte DeltaChannelSentinel
instead of duplicating delta data in checkpoint_blobs. Reconstruction
walks the parent checkpoint chain via checkpoint_writes (which already
holds per-step writes) and replays them through the operator.

In-memory benchmark (100 turns, ~20K tokens):
  storage: 10.2 MB → 40.5 KB (251x reduction)
  read:    0.6ms → 7.9ms (reconstruction cost, amortized by storage savings)

InMemorySaver and PostgresSaver override get_channel_writes() with
efficient implementations (Python dict walk and recursive CTE respectively).
The base class fallback uses self.list() with a thread-local recursion guard.
2026-04-22 14:03:37 -04:00
Sydney Runkle 10da2326a9 chore(delta-channel): remove supports_delta_channels flag
Rely on the runtime raise in DeltaChannel.from_checkpoint() instead of
a compile-time boolean flag. Savers that assemble DeltaChainValue inside
_load_blobs work transparently; savers that don't will pass through a raw
DeltaValue and hit a clear ValueError on first reload.

Removes: BaseCheckpointSaver.supports_delta_channels, the attribute on
InMemorySaver / PostgresSaver / AsyncPostgresSaver, the compile-time
UserWarning in StateGraph.compile(), and the associated test.
2026-04-22 14:03:37 -04:00
Sydney Runkle 1d8364a749 latest 2026-04-22 14:03:37 -04:00
Sydney RunkleandClaude Sonnet 4.6 ec52520389 chore: apply format/lint fixes across checkpoint, checkpoint-postgres, prebuilt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 14:03:37 -04:00
Sydney Runkle 55fdc7aec6 feat(postgres): remove _load_diff_chains; add get_channel_blob / aget_channel_blob 2026-04-22 14:03:37 -04:00
Sydney Runkle b799b95138 chore: rename DiffChannel/DiffDelta/DiffChainValue to Delta* across libs
Renames the diff-channel types to DeltaChannel, DeltaValue, and DeltaChainValue
for consistency with the settled naming convention.
2026-04-22 14:03:37 -04:00
Sydney RunkleandClaude Sonnet 4.6 ebd98f2e27 chore: format and lint fixes for DiffChannel implementation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 14:03:36 -04:00
Sydney RunkleandClaude Sonnet 4.6 318fee9fc6 fix(checkpoint/postgres): pass cursor to avoid deadlock in diff chain traversal
Fixes a critical deadlock that occurs when _load_diff_chains calls self._cursor()
from within _load_blobs while the outer _load_checkpoint_tuple already holds
self._cursor(). On bare (non-pool) connections, the threading.Lock is not
reentrant, causing a deadlock.

Solution: Pass the cursor as a parameter to _load_diff_chains and _load_blobs
instead of acquiring a new cursor within those methods. Updated _load_checkpoint_tuple
to acquire a cursor once at the top level and pass it through the call chain.

Changes:
- Updated _load_blobs signature to accept optional cur parameter
- Updated _load_diff_chains signature (base and implementations) to accept optional cur parameter
- Modified _load_checkpoint_tuple in PostgresSaver to acquire cursor and pass it
- Modified _load_checkpoint_tuple_async to acquire cursor only when diff_payloads exist
- Removed nested self._cursor() calls in _load_diff_chains and _load_diff_chains_async

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 14:03:36 -04:00
Sydney RunkleandClaude Sonnet 4.6 e37299af87 feat(checkpoint/postgres): diff chain reconstruction in async saver
Add `_load_diff_chains_async` to `AsyncPostgresSaver` and override
`_load_checkpoint_tuple` to inline blob-parsing and diff-chain
resolution via async point-lookup traversal, mirroring the sync
`PostgresSaver._load_diff_chains` implementation. Add integration test
`test_diff_channel_chain_reconstruction` that skips gracefully when
`langgraph` core is not installed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 14:03:36 -04:00
Sydney RunkleandClaude Sonnet 4.6 65d6ab2609 feat(checkpoint/postgres): diff chain reconstruction in _load_blobs (sync)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 14:03:36 -04:00
William FHandGitHub 2e0fc1c49d fix: re-use connection (#7220) 2026-03-18 13:31:46 -07:00
193e128c20 chore: Omit lock when using connection pool (#6734)
Co-authored-by: Conrad Ludgate <conradludgate@gmail.com>
2026-01-31 00:30:34 +00:00
Mason DaughertyandGitHub 196fbf2631 docs: storage nits (#6651) 2026-01-05 14:24:57 -05:00
William FHandGitHub 4d01e69b82 release(checkpoint-postgres): 3.0.1 (#6568) 2025-12-09 23:05:49 +00:00
William FHandGitHub e86b5f4da2 chore: pgqs (#6567)
Add more argument sanitization
2025-12-09 14:51:29 -08:00
a1b34efdb0 fix(checkpoint-postgres): ensure vector extension is created only if not exists (#6154)
Thank you for contributing to LangGraph! Follow these steps to mark your
pull request as ready for review. **If any of these steps are not
completed, your PR will not be considered for review.**

- [x] **PR title**: Follows the format: {TYPE}({SCOPE}): {DESCRIPTION}
  - Examples:
    - feat(core): add multi-tenant support
    - fix(cli): resolve flag parsing error
    - docs(openai): update API usage examples
  - Allowed `{TYPE}` values:
- feat, fix, docs, style, refactor, perf, test, build, ci, chore,
revert, release
  - Allowed `{SCOPE}` values (optional):
- langgraph, docs, cli, checkpoint, checkpoint-postgres,
checkpoint-sqlite, prebuilt, scheduler-kafka, sdk-py
- Once you've written the title, please delete this checklist item; do
not include it in the PR.

- **Description:** Azure Postgres SQL server has a limitation when doing
create extension vector is not exists, even though it manually created
before on a schema.
- **Issue:** Even though `CREATE EXTENSION vector` is executed manually
before, the permission issue arises. Putting it in an if else block
solves the issue and its not a breaking change.
```
Because vector isn't a trusted extension, only members of "azure_pg_admin" are allowed to use CREATE EXTENSION vector
HINT: to learn how to allow an extension or see the list of allowed extensions, please refer to https://go.microsoft.com/fwlink/?linkid=2301063
```

Co-authored-by: Josh Rogers <josh@langchain.dev>
2025-11-07 11:42:15 -05:00
Cole MurrayandGitHub b0a1029d55 fix(checkpoint-postgres): Replace f-string SQL formatting with parameterized queries in migration statements (#6328)
## Summary

Replace f-string SQL formatting with parameterized queries to prevent
potential SQL injection in checkpoint migration code.

## Changes

Updated the migration version tracking INSERT statements in all
checkpoint saver classes to use parameterized queries instead of
f-string formatting:

- `PostgresSaver`
(libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py:100)
- `AsyncPostgresSaver`
(libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py:104-106)
- `ShallowPostgresSaver`
(libs/checkpoint-postgres/langgraph/checkpoint/postgres/shallow.py:255)
- `AsyncShallowPostgresSaver`
(libs/checkpoint-postgres/langgraph/checkpoint/postgres/shallow.py:617-619)

**Before (vulnerable to SQL injection):**
```python
cur.execute(f"INSERT INTO checkpoint_migrations (v) VALUES ({v})")
```

**After (using parameterized query):**
```python
cur.execute("INSERT INTO checkpoint_migrations (v) VALUES (%s)", (v,))
```

## Risk Assessment

The practical risk is low since `v` is an integer loop variable
controlled by the codebase. However, using string formatting in SQL
queries is a well-known anti-pattern that can lead to SQL injection
vulnerabilities, especially if the code is later refactored or copied to
other contexts.

## Testing

-  All 216 tests passing on PostgreSQL 15 and 16
-  Linting and type checking passing
-  No functional changes to behavior
2025-11-07 08:00:10 -05:00
Josh RogersandGitHub ff38f75594 fix(checkpoint-postgres): make async PG checkpoint migration idempotent (#6400)
- **Description:** The final migration for the postgres checkpointer is
not currently idempotent. That presents problems when migrating from one
checkpointer to another or if migrations otherwise get applied twice.
This makes the final migration idempotent to avoid this problem.
- **Issue:** N/A
- **Dependencies:** N/A
- **Twitter handle:** N/A
2025-11-06 09:49:51 -05:00
Sydney RunkleandGitHub 2d3121a17c chore: drop Python 3.9 (and syntax) (#6289)
* `strict=False` is the default, pyupgrade to min version 3.10 adds this
to be explicit w/ behavior
2025-10-16 20:17:46 -04:00
d9e3d83894 docs: style linting (#6260)
also fixes some links

---------

Co-authored-by: Sydney Runkle <54324534+sydney-runkle@users.noreply.github.com>
Co-authored-by: ccurme <chester.curme@gmail.com>
Co-authored-by: Sydney Runkle <sydneymarierunkle@gmail.com>
Co-authored-by: William FH <13333726+hinthornw@users.noreply.github.com>
2025-10-16 11:25:50 +00:00
1ba96f49bf fix(checkpoint): handle metadata.writes when serializing old checkpoints with Jsonb (#6236)
Issue

Support for `Checkpoint.metadata.writes` was dropped in `langgraph`
v0.5.x.

In `langgraph-checkpoint-postgres` v2.0.23, metadata was serialized with
`BasePostgresSaver._dump_metadata` -> `JsonPlusSerializer.dumps` which
handles `pydantic.BaseModel`.

In v2.0.23, metadata is serialized with `psycopg.types.json.Jsonb`,
which raises `TypeError: Object of type AIMessage is not JSON
serializable` when trying to serialize `writes`.

Solution

- Add `BaseCheckpointSaver.get_serializable_checkpoint_metadata` which
pops the `writes` key.
- Log deprecation warning when strange version combinations are used 

Solves https://github.com/langchain-ai/langgraph/issues/5769

---------

Co-authored-by: Alex Kondratev <56111142+soapun@users.noreply.github.com>
2025-10-06 11:27:34 -07:00
f0fced262a fix(langgraph): fix PostgresSaver crashing when loading older checkpoints (#6162)
### Description

https://github.com/langchain-ai/langgraph/issues/6137 and
https://github.com/langchain-ai/langgraph/issues/5677 reported issues
where older checkpoints read by AsyncPostgresSaver/PostgresSaver from
`langgraph-checkpoint-postgres==2.0.19` fail to read channel values,
throwing `NoneType object is not a mapping`. This was due to a bug in
how `channel_values` is assembled:
```python
"channel_values": {
    **value["checkpoint"].get("channel_values"),  # <--- if channel_values doesn't exist (old checkpoint), **None errors
    **self._load_blobs(value["channel_values"]),
},
```
This bug was observed for checkpoints generated by
`langgraph-checkpoint-postgres<=2.0.19`.

Fixed by providing a fallback to
`value["checkpoint"].get("channel_values")`:
```python
**value["checkpoint"],
"channel_values": {
    **(
        value["checkpoint"].get("channel_values") or {}
    ),  # 'or {}' needed for backwards compat with v3 checkpoints and below, as v4 introduced channel_values key
    **self._load_blobs(value["channel_values"]),
},
```

### Tests
Added test for AsyncPostgresSaver and test for PostgresSaver, using
monkeypatch to remove `channel_values` before CheckpointTuple is
assembled in `_load_checkpoint_tuple`.

### Solves
https://github.com/langchain-ai/langgraph/issues/6137 and
https://github.com/langchain-ai/langgraph/issues/5677

---------

Co-authored-by: Shahrukh Shaik <144558473+shahrukh-shaik@users.noreply.github.com>
2025-09-17 17:50:39 -07:00
8b55dff7a5 chore(deps): upgrade dependencies with uv lock --upgrade (#6146)
This PR updates the dependencies in all Python packages using `uv lock
--upgrade`.

This is an automated PR created by the UV Lock Upgrade workflow.

To make tests pass:
* linting fixes
* whitespace fixes in snapshots

---------

Co-authored-by: sydney-runkle <54324534+sydney-runkle@users.noreply.github.com>
Co-authored-by: Sydney Runkle <sydneymarierunkle@gmail.com>
2025-09-14 19:36:43 -04:00
20581e61c0 fix(checkpoint-postgres): export PoolConfig from package init (#5934)
### Description
Export PoolConfig from langgraph.store.postgres.__init__ so the
documented import from langgraph.store.postgres import
AsyncPostgresStore, PoolConfig works as shown in the AsyncPostgresStore
examples. This resolves a docs vs. code inconsistency without changing
behavior.

### Issue
N/A

### Dependencies:
None

---------

Co-authored-by: William FH <13333726+hinthornw@users.noreply.github.com>
Co-authored-by: Sydney Runkle <54324534+sydney-runkle@users.noreply.github.com>
2025-09-09 21:41:51 +00:00
cb7b924006 feat: Implement durability mode argument (#5432)
- Replaces checkpoint_during: bool
- checkpoint_during is deprecated but still respected
- We implement three durability modes (from least to most durable):
- "exit" - save checkpoint only when the graph exits (equivalent to
checkpoint_during=False)
- "async" - save checkpoint asynchronously while the next step executes
(the default, equivalent to old checkpoint_during=True)
- "sync" - save checkpoint synchronously before the next step starts
(new mode, slower but most durable)

Co-authored-by: Sydney Runkle <54324534+sydney-runkle@users.noreply.github.com>
2025-07-20 15:42:18 +01:00
Sydney Runkle c6d674cd3e Merge branch 'main' into v1 2025-07-16 18:32:07 -04:00
Nuno Campos e973e936c3 perf: checkpoint-postgres: Reduce writes to checkpoint_blobs table
- Channels containing primitive values don't need to be stored in separate rows in blobs table, as the overhead of a separate row will usually be higher than the size of the value
- This applies for instance to all internal channels used to manage edges, so it has a big impact just from that. It can also apply to user-managed channels depending on their values
- The same channel may switch storage between versions without any issue
2025-07-16 11:43:51 +02:00
jitoandGitHub de1460937a docs(checkpoint-postgres): fix typo in comment (#5486)
fix typo in comment

Signed-off-by: jitokim <pigberger70@gmail.com>
2025-07-14 16:58:13 +00:00
Sydney RunkleandGitHub e0bf4a7bc3 Merge branch 'main' into v1 2025-07-14 12:54:11 -04:00
William FHandGitHub 9b9bf88aee fix(checkpoint-postgres): Remove python invalid escape warning (#5441) 2025-07-10 22:38:05 +00:00
Sydney Runkle a84b744eb6 Merge branch 'main' into v1 2025-07-08 09:55:13 -04:00
87f2e69395 chore[deps]: upgrade dependencies with uv lock --upgrade (#5358)
* chore: upgrade dependencies with `uv lock --upgrade`

* linting

* upgrade PR title

---------

Co-authored-by: sydney-runkle <54324534+sydney-runkle@users.noreply.github.com>
Co-authored-by: Sydney Runkle <sydneymarierunkle@gmail.com>
2025-07-07 23:24:32 +00:00
Sydney RunkleandGitHub c989f1c898 langgraph: remove support for thread_ts (old alias for checkpoint_id) (#5295)
* remove support for thread_ts

* docs and tests
2025-07-01 13:42:25 -04:00
Nuno CamposandGitHub 1134017d07 Preparation for 0.5 release: langgraph-checkpoint (#5124)
Prepare langgraph-checkpoint for 0.5

- Given we have no upper bound on langgraph-checkpoint dep need to undo all changes in langgraph-checkpoint that might break previous versions of langgraph
2025-06-16 21:57:11 +00:00
Nuno Campos 0cad7019cb Restore shallow checkpointer
- This should definitely be removed soon, but let's give people more time to update
2025-06-13 17:37:40 -07:00