mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-17 21:25:46 +02:00
fde3068970679184b68d3d068a92c83c966a4888
10
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fde3068970 |
release(checkpoint-postgres): 3.1.2 (#8565)
Version bump only. No library code changes in this PR. Unreleased since `checkpointpostgres==3.1.1`: - #8535 - find plain-value seeds when walking delta history Follows `checkpoint==4.2.0`, which is already live on PyPI. No dependency change needed here, since `langgraph-checkpoint>=4.1.0,<5.0.0` already admits 4.2.0. |
||
|
|
f55e77274d |
release(checkpoint): 4.2.0 (#8563)
Version bump only. No library code changes in this PR. Unreleased since `checkpoint==4.1.1`: - #8526 - collect writes at plain-value seed in delta channel history - #8354 - add opt-in `TTLConfig.omit_expired` to skip expired rows on read Minor rather than patch because #8354 adds a new public `TTLConfig` key. Note that the checkpoint-postgres half of #8354 already shipped in `checkpointpostgres==3.1.1`, so the currently released Postgres saver honors an option that no released `langgraph-checkpoint` declares. This closes that gap. `langgraph-checkpoint-postgres` 3.1.2 (#8535) follows once this is live on PyPI. |
||
|
|
a90ab44358 |
fix(checkpoint): collect writes at plain-value seed in delta channel history (#8526)
Fixes langchain-ai/langgraph#8384 `InMemorySaver.get_delta_channel_history` skipped the writes stored at the ancestor it seeded from whenever that ancestor's blob was a plain value rather than a `_DeltaSnapshot`, silently dropping the first write made after migrating a thread to `DeltaChannel`. ### Why the old rule was wrong A stored blob is the value *entering* its checkpoint; the writes stored under that same checkpoint are what produce its child. That's true for `_DeltaSnapshot` blobs and pre-delta plain values alike, so there was never a reason to treat them differently. Writes at ancestors *older* than the seed genuinely are subsumed by the seed value — but that's already guaranteed by terminating the walk, since the channel leaves `remaining` once its seed is found. The removed check re-solved that and overreached by one checkpoint. `BaseCheckpointSaver`, `SqliteSaver` and `PostgresSaver` never had this check. `InMemorySaver` was the only outlier. ### How I verified it Built a differential harness running the same migration scenarios through `InMemorySaver`, the `BaseCheckpointSaver` reference walk, and `SqliteSaver`. **4 of 11 scenarios agreed before this change; 11 of 11 after.** The loss is wider than one write — on the `add_messages` → `DeltaChannel` path it drops a real user message. Suites: `libs/checkpoint` 156 passed, `libs/langgraph` 1972 passed, `libs/checkpoint-sqlite` 117 passed, `libs/checkpoint-postgres` passed against PG 16. `make format`, `make lint` clean in each. ### Two things worth a closer look in review **1. I inverted two existing assertions** in `TestPreDeltaBlobTerminator` (`libs/checkpoint/tests/test_memory.py`). They encoded the old rule. Their fixture is the real migration shape — a plain-value blob carrying pending writes, with a delta-era child — which I confirmed against a dumped checkpoint chain from the issue's repro, so the assertions were wrong rather than the fixture being unrealistic. I added an ancestor *older* than the seed so the terminator still guards what it legitimately should: older writes stay excluded, the seed's own writes replay. **2. The new conformance test fails against Postgres**, for a reason unrelated to this change. Postgres `aput` leaves an inline `True` marker in `channel_values` only for `_DeltaSnapshot`; plain non-primitive values are popped with no marker, and seed detection is `(checkpoint -> 'channel_values' -> ch) IS NOT NULL`. So Postgres can't locate a plain-value seed at all: ``` seed stored as plain list: InMemorySaver -> [10, 20] AsyncPostgresSaver -> no seed key seed stored as _DeltaSnapshot: InMemorySaver -> found AsyncPostgresSaver -> found ``` The pre-existing `test_history_migration_plain_value_as_seed` already fails there too — conformance CI only validates `InMemorySaver`, so nobody was watching. Values still come out correct today (with no seed the walk runs to the root and replays everything), but early termination is lost: 1 write replayed on `InMemorySaver` vs 7 on Postgres for the same 6-turn thread. Filing separately rather than folding a write-path/format decision into this PR. ### Note on scope This touches three packages: the fix in `libs/checkpoint`, graph-level regression tests in `libs/langgraph` (the bug is only observable through a graph read), and the contract test in `libs/checkpoint-conformance` so third-party savers are covered too. --------- Co-authored-by: PiedPiper911 <32931126+PiedPiper911@users.noreply.github.com> |
||
|
|
ea5f9cc9fb |
chore: enforce PLC0415 in tests for the remaining packages (#8547)
Follow-up to #8540, which turned on `PLC0415` (import-outside-top-level) for checkpoint-postgres and checkpoint-sqlite. This does the remaining six packages: checkpoint, checkpoint-conformance, langgraph, prebuilt, cli, sdk-py. Scoped to tests, per @sydney-runkle's call on #8540: library code is exempted with `per-file-ignores`, since it still has deferred imports nobody has reviewed and mixing that in would make this hard to read. ## What changed Function-level imports across 56 test files moved to module level. Nine could not move and carry an explicit `# noqa: PLC0415` with a reason: | File | Why it stays local | |---|---| | `libs/langgraph/tests/test_deprecation.py` (4) | the import has to run inside `pytest.warns` for the warning to be observed | | `libs/langgraph/tests/test_serde_allowlist.py` | try/except guard, skips when langchain_core is absent | | `libs/langgraph/tests/test_delta_channel_benchmark.py` | optional psycopg probe | | `libs/checkpoint/tests/test_conformance_delta.py` (3) | protected by a module-level `pytest.importorskip`; hoisting past the guard turns a skip into a collection error | That last one is the trap: an import moved above `pytest.importorskip` silently defeats the guard. I hit it locally and it turned the skip into a `ModuleNotFoundError` at collection. Every file with an `importorskip` or `except ImportError` was checked by hand for this. ## Verification `make lint` and `make test` in each of the six: | Package | Tests | |---|---| | checkpoint | 156 passed, 17 skipped | | checkpoint-conformance | 1 passed | | langgraph | 1968 passed, 4 skipped | | prebuilt | 284 passed | | cli | 336 passed | | sdk-py | 493 passed | Also confirmed the rule actually fires: a throwaway test file with a function-level import is flagged in all six packages, and the source exemption holds. |
||
|
|
36a505ac65 |
test(checkpoint-postgres,checkpoint-sqlite): run the conformance suite (#8537)
Depends on #8535 `libs/checkpoint-conformance/tests/` only validates `InMemorySaver`. `checkpoint-sqlite` has had a `test_conformance_delta.py` for a while, but it guards on `importorskip("langgraph.checkpoint.conformance")` and the package was never in its test environment — so it has been skipping silently every run. `checkpoint-postgres` had no runner at all. Net effect: the shared checkpointer contract was effectively unenforced everywhere except in-memory. ### Change Adds `langgraph-checkpoint-conformance` to the `test` dependency group of both packages, with a path source like the existing `langgraph-checkpoint` entry. That alone is what makes sqlite's runner start executing. Postgres gets the equivalent runner. Both pass the `delta_channel_history` capability. ### Why it's stacked Against `main`'s Postgres, the new runner fails: ``` Capability delta_channel_history failed: test_history_migration_plain_value_as_seed ``` That is exactly the bug #8535 fixes, and it had been failing unnoticed precisely because nothing ran the suite there. So this is based on that branch rather than `main` — the diff here is the one conformance commit, and it will retarget once #8535 lands. Reasonable to read that as the change justifying itself: the first thing turning the suite on did was catch a real bug that had been sitting in `main`. ### Verified `checkpoint-postgres` 270 passed on PG 15 and 16, `checkpoint-sqlite` 118 passed, lint and `ty` clean in both. The `uv.lock` updates are the conformance package entry only. ### Note The sync `PostgresSaver` and `SqliteSaver` aren't covered — the conformance harness reports every capability as `detected=False` for them, so only the async savers are exercised. Pre-existing and not addressed here, but worth knowing the coverage isn't total. |
||
|
|
d569e18f4b |
fix(checkpoint-postgres): find plain-value seeds when walking delta history (#8535)
Fixes langchain-ai/langgraph#8534 `put` splits stored values in two: primitives stay inline in the checkpoint's `channel_values`, everything else moves to `checkpoint_blobs`, and only `_DeltaSnapshot` leaves an inline marker behind when it moves. Stage-1 seed detection tested for that marker, so a plain value — what a thread migrated from `BinaryOperatorAggregate` leaves behind — was invisible to the walk. ### Effect Migrated threads found no seed, walked to the root, and replayed every write on every read. Values still came out correct, because replaying an additive reducer from empty rebuilds the same list, which is why nothing looked wrong. What was lost is early termination — the entire point of `DeltaChannel`: <!-- linear:table-colwidths:266,266,266 --> | thread length | writes replayed, before | after | | -- | -- | -- | | 2 turns | 3 | 1 | | 6 turns | 7 | 1 | | 20 turns | 21 | 1 | Read latency is flat at \~0.6ms across all three after the change. ### Approach Stage 1 now checks both places a value can live rather than trusting the marker. It probes `checkpoint_blobs`: ```sql EXISTS (SELECT 1 FROM checkpoint_blobs b0 WHERE b0.thread_id = checkpoints.thread_id AND b0.checkpoint_ns = checkpoints.checkpoint_ns AND b0.channel = %s AND b0.version = checkpoint -> 'channel_versions' ->> %s AND b0.type <> 'empty') AS hb_0 ``` and selects the inline value alongside it, since `None`, `str`, `int`, `float` and `bool` stay in `channel_values` with no blob row: ```sql checkpoint -> 'channel_values' -> %s AS inline_0 ``` The blob predicate matches `checkpoint_blobs`' primary key `(thread_id, checkpoint_ns, channel, version)` exactly, so it is one index lookup per row per channel, bounded by the 1024-row page. I picked reading storage over the cheaper alternative — also writing the marker for plain values — because **that would not fix any thread already on disk.** Existing checkpoints have no marker and there is nowhere to add one retroactively. The seed resolves to the blob when one exists and the inline value otherwise. That ordering is also what keeps a genuine inline `true` — a `bool` channel holding `True` — distinguishable from the literal `true` marker `put` inlines for a `_DeltaSnapshot`: only the snapshot has a blob. `None` is deliberately not treated as a seed; a JSON null is indistinguishable from "nothing stored" at this layer, so the walk continues and replay from empty is correct. Params go from two to four per channel; both callers updated. The inline half came out of review on this PR — a blob-only probe would have left scalar-aggregate migrations (an integer sum, say) still replaying their full history. ### On the `type <> 'empty'` predicate Being upfront since it isn't demonstrable with a test: `put` does not currently produce `empty` rows on this path — `blob_versions` is filtered to keys present in `channel_values`, so `_dump_blobs`' empty branch is unreachable from it. I confirmed there are no `empty` rows in a populated test database. I kept it because stage 2 already applies the same check when resolving the seed blob. Without it the two stages could disagree: stage 1 terminates the walk on a row stage 2 then discards, producing no seed *and* a truncated write chain — the same failure shape this function exists to avoid. Rationale is in the docstring so the next reader doesn't have to ask. Happy to drop it if you'd rather not carry an unexercised predicate. ### Tests `libs/checkpoint-postgres/tests/test_delta_plain_value_seed.py` — blob-stored plain-value seed, `_DeltaSnapshot` seed, a version bump with nothing stored (which must not stop the walk short of an older real value), inline primitives (`int`, `str`, `float`, `None`), and inline `True` versus the snapshot marker. Each fails against the behaviour it fixes. Verified: postgres suite 269 passed on PG 15 and 16; delta-channel conformance against `AsyncPostgresSaver` went from 6 of 8 to 8 of 8, including the pre-existing `test_history_migration_plain_value_as_seed` failure this was causing; `make lint` clean. ### Not included I wanted a Postgres conformance runner alongside `checkpoint-sqlite`'s, but it needs `langgraph-checkpoint-conformance` as a dev dependency and the contributing guide asks for maintainer sign-off before adding one. The direct tests above cover the same ground without it. Worth flagging separately: **conformance effectively runs against** `InMemorySaver` **only today.** `libs/checkpoint-conformance/tests/` contains just `test_validate_memory.py`, and `checkpoint-sqlite`'s `test_conformance_delta.py` silently skips because the package isn't installed in its test environment (`importorskip`). Wiring it up for sqlite and postgres is what would have caught this bug, and langchain-ai/langgraph#8534 notes it. Sqlite is unaffected by the bug itself — it stores `channel_values` inline and inspects them directly. `langgraph-api` already resolves seeds by version rather than by marker. |
||
|
|
f22af6248c |
chore: enable RUF100 and clear unused noqa directives (#8546)
Follow-up to review on #8540, where a stale `# noqa: E402` slipped past me and Sydney spotted it by eye. This turns on the rule that catches that automatically. `RUF100` flags a `noqa` that suppresses nothing. `sdk-py` already had it through its blanket `RUF` selection; this adds it to the other seven packages and clears what it finds. ### The 33 it flags, all autofixed **Blanket `# noqa` on docstring-closing lines** (4, in `checkpoint-postgres` and `checkpoint-sqlite`). `E501` is in `lint.ignore` for those packages, so nothing was being suppressed: ```diff - """ # noqa + """ ``` **`# noqa: F821` on `anext(aiter_)`** (2). Left over from Python 3.9 support. `anext` became a builtin in 3.10, which is the floor now, so `F821` no longer fires: ```diff - anext(aiter_), # type: ignore[arg-type] # noqa: F821 + anext(aiter_), # type: ignore[arg-type] ``` **Suppressions naming rules the package does not enable** (27), across `langgraph`, `prebuilt` and `checkpoint-sqlite`: `FBT001`, `FBT002`, `TC002`, `BLE001`, `ANN001`, `ANN002`, `ANN003`, `E501`, `F401`. Mostly copied between packages whose rule sets differ. ### One measurement note If you check these numbers yourself, use `--extend-select`: ``` ruff check --select RUF100 . # 81, misleading ruff check --extend-select RUF100 . # 33, real ``` With a bare `--select`, ruff treats every other rule as disabled, so every suppression for another rule looks unused. I quoted 81 before catching that. ### Verified `checkpoint-sqlite` 118 passed, `prebuilt` 284 passed, `langgraph` 1968 passed, `checkpoint-postgres` 264 passed on PG 15 and 16. `make lint` clean in every package. Independent of #8540 and #8537, so it can land in any order. |
||
|
|
658541c496 |
chore(checkpoint-postgres,checkpoint-sqlite): enable PLC0415 lint rule (#8540)
Follow-up to review on #8537: turn on ruff's `PLC0415` (`import-outside-top-level`) so deferred imports in tests stop accumulating. Scoped to `checkpoint-postgres` and `checkpoint-sqlite` rather than repo-wide, because the sweep turns up three different things and only one of them is a style problem. ### What the rule finds today ``` package tests src files checkpoint 13 10 11 checkpoint-conformance 0 10 4 checkpoint-postgres 6 0 2 checkpoint-sqlite 9 0 3 langgraph 130 23 32 prebuilt 14 3 7 cli 9 14 10 sdk-py 189 23 38 ──────────────────── 370 83 107 ``` 453 violations across 107 files, and ruff has no autofix for this rule. ### Three categories, not one **Style — hoist.** `checkpoint-sqlite/tests/test_store.py` deferred `math`, `random`, `time`, `Counter` and `defaultdict` inside methods for no reason. **Deliberate — keep, annotate.** `checkpoint-postgres/tests/test_async.py` defers behind `pytest.importorskip("langgraph.channels.delta")` because langgraph core is *not* a test dependency of that package. Hoisting would break the skip. Those get `# noqa: PLC0415` and a comment. **Redundant guard — hoist.** `checkpoint-sqlite/tests/test_conformance_delta.py` deferred imports only to get past its own `importorskip`. Imports move up; the `aiosqlite` guard stays, since that dependency genuinely can be absent. The second category is why I did not enable this everywhere in one go. Most of the 83 source-level violations look like the same pattern — optional-dependency handling and circular-import avoidance in `jsonplus.py`, `embed.py`, `encrypted.py` and friends. Blanket-enabling would mean `# noqa` on a lot of correct code, and each one wants an owner's eye rather than a mechanical pass. These two packages are clean to enforce today because both have **zero** source-level violations. ### Suggested rollout for the rest Either extend package by package as owners confirm which deferrals are intentional, or enable everywhere at once with `per-file-ignores` grandfathering the current 107 files so new code is blocked immediately and the debt burns down. Happy to do either — the second is a smaller diff but leaves a long ignore list. ### Verified `checkpoint-sqlite` 118 passed, `checkpoint-postgres` 264 passed on PG 15 and 16, `make lint` clean in both. One overlap worth flagging: `checkpoint-sqlite/tests/test_conformance_delta.py` is also touched by #8537. The change is identical in both, so it should merge cleanly either way. |
||
|
|
b2926a0ff9 | release(checkpoint-sqlite): 3.1.1 (#8481) | ||
|
|
fcdf520938 | release(checkpoint-postgres): 3.1.1 (#8480) |