mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-14 20:25:23 +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>
138 lines
4.9 KiB
Python
138 lines
4.9 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from collections.abc import Iterable, Sequence
|
|
from typing import Any
|
|
|
|
from langchain_core.runnables import RunnableConfig
|
|
from langgraph.checkpoint.base import PendingWrite, get_checkpoint_id, writes_sort_key
|
|
from langgraph.checkpoint.serde.base import SerializerProtocol
|
|
|
|
_FILTER_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]+$")
|
|
|
|
|
|
def _validate_filter_key(key: str) -> None:
|
|
"""Validate that a filter key is safe for use in SQL queries.
|
|
|
|
Args:
|
|
key: The filter key to validate
|
|
|
|
Raises:
|
|
ValueError: If the key contains invalid characters that could enable SQL injection
|
|
"""
|
|
# Allow alphanumeric characters, underscores, dots, and hyphens
|
|
# This covers typical JSON property names while preventing SQL injection
|
|
if not _FILTER_PATTERN.match(key):
|
|
raise ValueError(
|
|
f"Invalid filter key: '{key}'. Filter keys must contain only alphanumeric characters, underscores, dots, and hyphens."
|
|
)
|
|
|
|
|
|
def _metadata_predicate(
|
|
metadata_filter: dict[str, Any],
|
|
) -> tuple[Sequence[str], Sequence[Any]]:
|
|
"""Return WHERE clause predicates for (a)search() given metadata filter.
|
|
|
|
This method returns a tuple of a string and a tuple of values. The string
|
|
is the parametered WHERE clause predicate (excluding the WHERE keyword):
|
|
"column1 = ? AND column2 IS ?". The tuple of values contains the values
|
|
for each of the corresponding parameters.
|
|
"""
|
|
|
|
def _where_value(query_value: Any) -> tuple[str, Any]:
|
|
"""Return tuple of operator and value for WHERE clause predicate."""
|
|
if query_value is None:
|
|
return ("IS ?", None)
|
|
elif (
|
|
isinstance(query_value, str)
|
|
or isinstance(query_value, int)
|
|
or isinstance(query_value, float)
|
|
):
|
|
return ("= ?", query_value)
|
|
elif isinstance(query_value, bool):
|
|
return ("= ?", 1 if query_value else 0)
|
|
elif isinstance(query_value, dict) or isinstance(query_value, list):
|
|
# query value for JSON object cannot have trailing space after separators (, :)
|
|
# SQLite json_extract() returns JSON string without whitespace
|
|
return ("= ?", json.dumps(query_value, separators=(",", ":")))
|
|
else:
|
|
return ("= ?", str(query_value))
|
|
|
|
predicates = []
|
|
param_values = []
|
|
|
|
# process metadata query
|
|
for query_key, query_value in metadata_filter.items():
|
|
_validate_filter_key(query_key)
|
|
operator, param_value = _where_value(query_value)
|
|
predicates.append(
|
|
f"json_extract(CAST(metadata AS TEXT), '$.{query_key}') {operator}"
|
|
)
|
|
param_values.append(param_value)
|
|
|
|
return (predicates, param_values)
|
|
|
|
|
|
def search_where(
|
|
config: RunnableConfig | None,
|
|
filter: dict[str, Any] | None,
|
|
before: RunnableConfig | None = None,
|
|
) -> tuple[str, Sequence[Any]]:
|
|
"""Return WHERE clause predicates for (a)search() given metadata filter
|
|
and `before` config.
|
|
|
|
This method returns a tuple of a string and a tuple of values. The string
|
|
is the parametered WHERE clause predicate (including the WHERE keyword):
|
|
"WHERE column1 = ? AND column2 IS ?". The tuple of values contains the
|
|
values for each of the corresponding parameters.
|
|
"""
|
|
wheres = []
|
|
param_values = []
|
|
|
|
# construct predicate for config filter
|
|
if config is not None:
|
|
wheres.append("thread_id = ?")
|
|
param_values.append(config["configurable"]["thread_id"])
|
|
checkpoint_ns = config["configurable"].get("checkpoint_ns")
|
|
if checkpoint_ns is not None:
|
|
wheres.append("checkpoint_ns = ?")
|
|
param_values.append(checkpoint_ns)
|
|
|
|
if checkpoint_id := get_checkpoint_id(config):
|
|
wheres.append("checkpoint_id = ?")
|
|
param_values.append(checkpoint_id)
|
|
|
|
# construct predicate for metadata filter
|
|
if filter:
|
|
metadata_predicates, metadata_values = _metadata_predicate(filter)
|
|
wheres.extend(metadata_predicates)
|
|
param_values.extend(metadata_values)
|
|
|
|
# construct predicate for `before`
|
|
if before is not None:
|
|
wheres.append("checkpoint_id < ?")
|
|
param_values.append(get_checkpoint_id(before))
|
|
|
|
return ("WHERE " + " AND ".join(wheres) if wheres else "", param_values)
|
|
|
|
|
|
def pending_writes_sql(has_task_path: bool) -> str:
|
|
task_path = "task_path" if has_task_path else "''"
|
|
return (
|
|
f"SELECT task_id, channel, type, value, {task_path}, idx FROM writes "
|
|
"WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ?"
|
|
)
|
|
|
|
|
|
def load_pending_writes(
|
|
rows: Iterable[Any], serde: SerializerProtocol
|
|
) -> list[PendingWrite]:
|
|
"""Deserialize `pending_writes_sql` rows in `writes_sort_key` order."""
|
|
return [
|
|
(task_id, channel, serde.loads_typed((type_, value)))
|
|
for task_id, channel, type_, value, _, _ in sorted(
|
|
rows, key=lambda r: writes_sort_key(r[4], r[0], r[5])
|
|
)
|
|
]
|