mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-14 12:15:25 +02:00
Fixes langchain-ai/langgraph#8382 `DeltaChannel` rebuilds its value by replaying ancestor writes through the reducer. Every saver ordered a checkpoint's writes by `(task_id, idx)`, but live execution applies a super-step's writes in task-path order (`apply_writes` sorts tasks by `task_path_str(task.path[:3])`). `task_id` is a hash of the path, so when two or more tasks write one `DeltaChannel` in the same super-step, replay applies them in an arbitrary permutation. Reducers must be batching-invariant, not order-invariant, so the permutation changes the value: `get_state` disagrees with what `invoke` returned, and continuing the thread saves the reordered value as the base for every later write. ## Fix Replay orders by `(task_path, task_id, idx)`, the order `SELECT_PENDING_SENDS_SQL` already uses for sends. `apply_writes` and every saver now sort with one `writes_sort_key` from `langgraph.checkpoint.base`, in Python, so live and replay can't drift apart and don't depend on a database collation. `apply_writes` sorts by the full task path, the string `put_writes` stores, instead of `path[:3]`. The paths that reach `apply_writes` no longer carry task ids, so the live order doesn't change. `BaseCheckpointSaver`'s default `get_delta_channel_history` can't sort by path, since `PendingWrite` has no task path, so it replays each checkpoint's writes in `get_tuple` order. `get_tuple` now has to return `pending_writes` in `writes_sort_key` order, and memory, SQLite and Postgres do, so the default walk is right for any saver that follows that. `AsyncPostgresSaver` also gets the sync `get_delta_channel_history` that `AsyncSqliteSaver` already had; sync reads used to fall back to the default walk. Memory and Postgres already stored `task_path`. SQLite accepted it on `put_writes` and dropped it, so `writes` gains the column and `setup()` adds it to existing databases. Writes without a task path (graph input, `update_state` updates, rows written before the column existed) sort first by `task_id`, which is also where live execution applies input. ## Compatibility - Memory and Postgres already had `task_path` on disk, so existing threads replay in the corrected order after upgrade. - On SQLite, rows from before the upgrade read back as `''` and keep their old order. Only new writes get the corrected order, since there is no stored path to recover. - SQLite has no `ADD COLUMN IF NOT EXISTS`, so `setup()` runs the `ALTER` and treats `duplicate column name` as success, which also covers two processes migrating one file. That error comes while SQLite prepares the statement, before it asks for the write lock, so `setup()` on an up-to-date database doesn't wait on other writers. A read-only database from before the column is read with empty paths instead of failing setup. - Downgrade is safe: older code's `INSERT` omits the column and the column has a default. - `get_tuple` and `list` return `pending_writes` in `writes_sort_key` order instead of task id order (SQLite, Postgres) or put order (memory). Nothing in langgraph depends on the old order. - Postgres reads `task_path` and `idx` with each pending write, so a subclass that pairs `SELECT_SQL` with its own `_load_writes`, or the other way round, has to match the new row shape. - A third-party saver has to replay in `writes_sort_key` order: in its own `get_delta_channel_history` if it has one, otherwise by returning `pending_writes` from `get_tuple` in that order. Otherwise it fails the new conformance tests; the `get_tuple` one is a base test, so it runs for every saver. - langgraph, checkpoint-sqlite and checkpoint-postgres import `writes_sort_key` from langgraph-checkpoint, so this bumps langgraph-checkpoint to 4.3.0 and requires it there. 4.3.0 has to be released first. ## Limits - Several updates in one `bulk_update_state` super-step share a task id, so the saver keeps only the first one's writes and the `DeltaChannel` loses the rest (plain channels are fine). That's on main too; #9128 fixes it and stores a task path per update, so with this PR those updates also replay in the order given. - Exit durability stores its writes differently; resume ordering there is fixed in #9114, stacked on this PR. - langgraphjs applies `DeltaChannel` writes in task id order, live and on replay (langchain-ai/langgraphjs#2544), so its reads match too. Moving JS to task path order is a follow-up. ## Tests Each fails on main and passes here: - `test_delta_channel_parallel_order.py`, across memory, SQLite and Postgres: parallel writers and a `Send` fan-out, comparing `get_state` with the live result, including sync `get_state` on the async savers. - Three conformance tests whose task ids sort opposite to their task paths, so they cannot pass by chance: two on `get_delta_channel_history`, one on `get_tuple`'s `pending_writes` order. - SQLite migration tests from a pre-column database, sync and async, including a read-only file. One more that passes on main too: SQLite `setup()` while another connection holds the write lock, with no busy timeout, sync and async. It keeps the `ALTER` from waiting on that lock. Thanks to @ErenAta16 for the report, the reproduction, and for tracing `task_path` through all three backends, and to @ragnarok268 for the conformance-test design with task ids ordered opposite to their paths. --------- Co-authored-by: ErenAta16 <149434812+ErenAta16@users.noreply.github.com> Co-authored-by: ragnarok268 <58264829+ragnarok268@users.noreply.github.com>
100 lines
3.6 KiB
YAML
100 lines
3.6 KiB
YAML
name: langgraph-v3-integration
|
|
|
|
services:
|
|
postgres:
|
|
image: pgvector/pgvector:pg16
|
|
environment:
|
|
POSTGRES_DB: postgres
|
|
POSTGRES_USER: postgres
|
|
POSTGRES_PASSWORD: postgres
|
|
command: ["postgres", "-c", "shared_preload_libraries=vector", "-c", "max_connections=150"]
|
|
ports:
|
|
- "5443:5432"
|
|
healthcheck:
|
|
test: pg_isready -U postgres
|
|
interval: 5s
|
|
timeout: 1s
|
|
retries: 5
|
|
start_period: 10s
|
|
tmpfs:
|
|
- /var/lib/postgresql/data
|
|
|
|
redis:
|
|
image: redis:7-alpine
|
|
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
|
|
ports:
|
|
- "6380:6379"
|
|
healthcheck:
|
|
test: redis-cli ping
|
|
interval: 5s
|
|
timeout: 1s
|
|
retries: 5
|
|
start_period: 2s
|
|
tmpfs:
|
|
- /data
|
|
|
|
api:
|
|
# Thin layer on top of langchain/langgraph-api:latest-py3.12 —
|
|
# see ./Dockerfile. The base image bundles langgraph-api +
|
|
# langgraph_runtime_postgres + langgraph_license + the Go core-server;
|
|
# on top we add graph deps (deepagents), the graph files, and this
|
|
# monorepo's local langgraph *core* and *checkpoint* (so the server runs
|
|
# the code under test, not the released langgraph).
|
|
build:
|
|
context: .
|
|
dockerfile: Dockerfile
|
|
additional_contexts:
|
|
# The monorepo's local langgraph core (libs/langgraph) and checkpoint
|
|
# (libs/checkpoint), installed over the base image so the server runs
|
|
# the langgraph under test. See the Dockerfile for why.
|
|
langgraph_src: ../../langgraph
|
|
checkpoint_src: ../../checkpoint
|
|
image: langgraph-v3-integration-api:local
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
redis:
|
|
condition: service_healthy
|
|
environment:
|
|
# Database + cache (the production-shape postgres+redis runtime).
|
|
# Recent langgraph-api reads DATABASE_URI first and falls back to
|
|
# POSTGRES_URI; set both so older code paths also work.
|
|
DATABASE_URI: postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
|
POSTGRES_URI: postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
|
REDIS_URI: redis://redis:6379
|
|
LANGGRAPH_RUNTIME_EDITION: postgres
|
|
|
|
LANGGRAPH_AUTH_TYPE: noop
|
|
LANGSMITH_TRACING: "false"
|
|
|
|
PORT: "8000"
|
|
|
|
# Required for the v3 thread-centric streaming protocol. Without this
|
|
# flag the API falls back to the legacy v2 streaming surface and the
|
|
# client's v3 endpoints (POST /threads/{id}/stream/events, /commands,
|
|
# etc.) won't be exposed.
|
|
FF_OPTIMIZED_STREAMING: "true"
|
|
|
|
# Tell langgraph-api which graphs to register. The langgraph CLI sets
|
|
# this from langgraph.json; we're bypassing the CLI (running uvicorn
|
|
# directly) so we set it manually.
|
|
LANGSERVE_GRAPHS: '{"agent":"/app/graph/streaming_graph.py:graph","tools_agent":"/app/graph/tools_agent.py:graph","deep_agent":"/app/graph/deep_agent.py:graph","factory_agent":"/app/graph/factory_graph.py:make_graph"}'
|
|
|
|
# The published langgraph-api image is the `licensed` variant and
|
|
# requires a real LANGSMITH_API_KEY (or LANGGRAPH_CLOUD_LICENSE_KEY)
|
|
# at runtime. Passed through from the host shell / CI secrets.
|
|
LANGSMITH_API_KEY: ${LANGSMITH_API_KEY:-}
|
|
LANGGRAPH_CLOUD_LICENSE_KEY: ${LANGGRAPH_CLOUD_LICENSE_KEY:-}
|
|
# Mount the graph + config so edits don't require a rebuild.
|
|
volumes:
|
|
- ./graph:/app/graph:ro
|
|
- ./langgraph.json:/app/langgraph.json:ro
|
|
ports:
|
|
- "2024:8000"
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -q -O- http://localhost:8000/ok || exit 1"]
|
|
interval: 5s
|
|
timeout: 3s
|
|
retries: 30
|
|
start_period: 30s
|