mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-30 21:45:08 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8e732c879 | ||
|
|
eb69f67b65 | ||
|
|
c0279f0910 | ||
|
|
f5804a5bf5 | ||
|
|
07b33185ea |
@@ -139,23 +139,10 @@ jobs:
|
||||
echo EOF
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
test-pypi-publish:
|
||||
needs:
|
||||
- build
|
||||
- release-notes
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/_test_release.yml
|
||||
with:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
secrets: inherit
|
||||
|
||||
pre-release-checks:
|
||||
needs:
|
||||
- build
|
||||
- release-notes
|
||||
- test-pypi-publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -180,31 +167,20 @@ jobs:
|
||||
enable-cache: false
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- name: Import published package
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: dist
|
||||
path: ${{ inputs.working-directory }}/dist/
|
||||
|
||||
- name: Import dist package
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
env:
|
||||
PKG_NAME: ${{ needs.build.outputs.pkg-name }}
|
||||
VERSION: ${{ needs.build.outputs.version }}
|
||||
# Here we use:
|
||||
# - The default regular PyPI index as the *primary* index, meaning
|
||||
# that it takes priority (https://pypi.org/simple)
|
||||
# - The test PyPI index as an extra index, so that any dependencies that
|
||||
# are not found on test PyPI can be resolved and installed anyway.
|
||||
# (https://test.pypi.org/simple). This will include the PKG_NAME==VERSION
|
||||
# package because VERSION will not have been uploaded to regular PyPI yet.
|
||||
# - attempt install again after 5 seconds if it fails because there is
|
||||
# sometimes a delay in availability on test pypi
|
||||
# Install directly from the locally-built wheel (no index resolution needed).
|
||||
run: |
|
||||
uv run pip install \
|
||||
--extra-index-url https://test.pypi.org/simple/ \
|
||||
"$PKG_NAME==$VERSION" || \
|
||||
( \
|
||||
sleep 5 && \
|
||||
uv run pip install \
|
||||
--extra-index-url https://test.pypi.org/simple/ \
|
||||
"$PKG_NAME==$VERSION" \
|
||||
)
|
||||
uv run pip install dist/*.whl
|
||||
|
||||
if [[ "$PKG_NAME" == *prebuilt* ]]; then
|
||||
uv run pip install langgraph
|
||||
@@ -226,7 +202,7 @@ jobs:
|
||||
run: uv sync --group test
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
# Overwrite the local version of the package with the test PyPI version.
|
||||
# Overwrite the local version of the package with the built version
|
||||
- name: Import published package (again)
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
shell: bash
|
||||
@@ -234,14 +210,25 @@ jobs:
|
||||
PKG_NAME: ${{ needs.build.outputs.pkg-name }}
|
||||
VERSION: ${{ needs.build.outputs.version }}
|
||||
run: |
|
||||
uv run pip install \
|
||||
--extra-index-url https://test.pypi.org/simple/ \
|
||||
"$PKG_NAME==$VERSION"
|
||||
uv run pip install dist/*.whl
|
||||
|
||||
- name: Run unit tests
|
||||
run: make test
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
test-pypi-publish:
|
||||
needs:
|
||||
- build
|
||||
- release-notes
|
||||
- pre-release-checks
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/_test_release.yml
|
||||
with:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
secrets: inherit
|
||||
|
||||
publish:
|
||||
needs:
|
||||
- build
|
||||
|
||||
@@ -448,11 +448,12 @@ class PostgresSaver(BasePostgresSaver):
|
||||
|
||||
Two-stage query, both stages cover ALL requested channels:
|
||||
|
||||
* Stage 1 (paged): dynamic SELECT over `checkpoints` with K parallel
|
||||
JSONB key lookups (one column pair per channel) — no subquery, no
|
||||
aggregation. Pages newest-first by `checkpoint_id` with a cursor;
|
||||
page size is `_DELTA_PAGE_SIZE`. Stops paging when every channel
|
||||
has found its seed or the chain is exhausted.
|
||||
* Stage 1 (paged): dynamic SELECT over `checkpoints` with three
|
||||
columns per channel: its version, an `EXISTS` probe for a stored
|
||||
blob at that version, and its inline value. Pages newest-first by
|
||||
`checkpoint_id` with a cursor; page size is `_DELTA_PAGE_SIZE`.
|
||||
Stops paging when every channel has found its seed or a page comes
|
||||
back short.
|
||||
|
||||
* Stage 2 (per-channel UNION ALL): one branch per channel reading
|
||||
`checkpoint_writes` filtered to that channel's specific
|
||||
|
||||
@@ -172,30 +172,8 @@ class _DeltaStage2Row(TypedDict, total=False):
|
||||
version: str | None # "b" rows only
|
||||
|
||||
|
||||
# Multi-channel two-stage DeltaChannel reconstruction.
|
||||
#
|
||||
# Stage 1 scans checkpoint metadata (no blob bytes) and emits one row per
|
||||
# checkpoint with K parallel JSONB key lookups (one column pair per
|
||||
# requested delta channel: ver_i / hs_i). No subqueries, no aggregation.
|
||||
# Python walks the parent chain once across all channels.
|
||||
#
|
||||
# Stage 2 fetches all writes and the seed blobs for ALL channels in a
|
||||
# single roundtrip via `channel = ANY(%s)` and chain/seed-version
|
||||
# filtering.
|
||||
#
|
||||
# Empirical comparison vs an alternative "ship full channel_versions /
|
||||
# channel_values JSONB and let Python pick" form (1000 checkpoints,
|
||||
# 8 total channels in graph, 3 delta channels requested):
|
||||
#
|
||||
# Postgres execution: A=0.24ms vs B=0.38ms (both negligible)
|
||||
# End-to-end latency: A=6.83ms vs B=2.28ms (B is 3.0x faster)
|
||||
# Wire payload: A=836KB vs B=330KB (61% smaller)
|
||||
# Buffer hits: identical (167 blocks)
|
||||
#
|
||||
# B (this dynamic-columns design) wins because it avoids JSONB
|
||||
# serialization on the wire and JSONB-to-dict deserialization in
|
||||
# psycopg. Even at K=8 (8 delta channels = 16 dynamic columns), B
|
||||
# still beats A end-to-end (4.2ms vs 6.8ms).
|
||||
# Delta history is rebuilt in two queries; `_build_delta_stage1_sql` and
|
||||
# `_build_delta_stage2_sql` document their shapes.
|
||||
|
||||
|
||||
def _build_delta_stage1_sql(channels: Sequence[str], *, paged: bool) -> str:
|
||||
@@ -335,10 +313,8 @@ def _build_delta_stage2_sql(
|
||||
return " UNION ALL ".join(branches)
|
||||
|
||||
|
||||
# Stage 1 rows are dynamic-shape dicts: {checkpoint_id, parent_checkpoint_id,
|
||||
# ver_0, hs_0, ver_1, hs_1, ...}. Walking is parameterized by the channel
|
||||
# list to map indices back to channel names — no static TypedDict here.
|
||||
# `dict[str, Any]` is the practical signature.
|
||||
# Stage 1 rows are dicts keyed by the per-channel aliases
|
||||
# `_build_delta_stage1_sql` emits, so there is no static TypedDict.
|
||||
|
||||
|
||||
class BasePostgresSaver(BaseCheckpointSaver[str]):
|
||||
@@ -431,9 +407,11 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
|
||||
(a) it found a stored value for its channel — a blob or an inline
|
||||
primitive (channel becomes seeded),
|
||||
(b) it reached a real root (parent_of[cid] is None — fully
|
||||
materialized at this point), or
|
||||
materialized at this point),
|
||||
(c) the next ancestor cid isn't in `parent_of` yet (waiting for
|
||||
a later page; the cursor stays put).
|
||||
a later page; the cursor stays put), or
|
||||
(d) the target's own row isn't in `parent_of` yet (the walk has
|
||||
not started; no cursor is set, so a later page retries).
|
||||
|
||||
Mutates `chain_by_ch`, `seed_ver_by_ch`, `seed_inline_by_ch`,
|
||||
`walk_cursor_by_ch`, and `seeded` in place.
|
||||
@@ -441,9 +419,12 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
|
||||
for i, ch in enumerate(channels):
|
||||
if ch in seeded:
|
||||
continue
|
||||
# First-time entry: cursor starts at the target's parent.
|
||||
# Pages start at the thread head, so the target may not have
|
||||
# loaded yet; a `None` cursor would read as "target is a root".
|
||||
if ch not in walk_cursor_by_ch:
|
||||
walk_cursor_by_ch[ch] = parent_of.get(target_id)
|
||||
if target_id not in parent_of:
|
||||
continue
|
||||
walk_cursor_by_ch[ch] = parent_of[target_id]
|
||||
cur_cid = walk_cursor_by_ch[ch]
|
||||
ch_chain = chain_by_ch[ch]
|
||||
hb_i = hb_by_i_by_cid[i]
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from langgraph.checkpoint.base import (
|
||||
Checkpoint,
|
||||
DeltaChannelHistory,
|
||||
empty_checkpoint,
|
||||
)
|
||||
from langgraph.checkpoint.base.id import uuid6
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
|
||||
from langgraph.checkpoint.postgres import PostgresSaver
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
from langgraph.checkpoint.postgres.base import _DELTA_PAGE_SIZE
|
||||
from tests.conftest import DEFAULT_URI
|
||||
|
||||
CHANNEL = "items"
|
||||
STEPS = 8
|
||||
SEED_STEP = 1
|
||||
SEED_VALUE = [10, 20]
|
||||
TARGET_STEP = 4
|
||||
|
||||
# The real page size is the control; the rest leave the target off the first
|
||||
# page (three checkpoints are newer than it).
|
||||
PAGE_SIZES = [_DELTA_PAGE_SIZE, 3, 2, 1]
|
||||
|
||||
|
||||
def _step_args(
|
||||
thread_id: str, step: int, parent: dict | None
|
||||
) -> tuple[dict, Checkpoint, dict[str, Any]]:
|
||||
config: dict = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||
if parent is not None:
|
||||
config["configurable"]["checkpoint_id"] = parent["configurable"][
|
||||
"checkpoint_id"
|
||||
]
|
||||
checkpoint: Checkpoint = empty_checkpoint()
|
||||
checkpoint["id"] = str(uuid6(clock_seq=step))
|
||||
checkpoint["channel_versions"][CHANNEL] = f"v{step}"
|
||||
if step == SEED_STEP:
|
||||
checkpoint["channel_values"][CHANNEL] = _DeltaSnapshot(list(SEED_VALUE))
|
||||
return config, checkpoint, {CHANNEL: f"v{step}"}
|
||||
return config, checkpoint, {}
|
||||
|
||||
|
||||
async def _abuild_chain(saver: AsyncPostgresSaver) -> list[dict]:
|
||||
thread_id = str(uuid4())
|
||||
parent: dict | None = None
|
||||
configs: list[dict] = []
|
||||
for step in range(STEPS):
|
||||
config, checkpoint, new_versions = _step_args(thread_id, step, parent)
|
||||
parent = await saver.aput(
|
||||
config,
|
||||
checkpoint,
|
||||
{"source": "loop", "step": step, "parents": {}},
|
||||
new_versions,
|
||||
)
|
||||
await saver.aput_writes(parent, [(CHANNEL, f"w{step}")], str(uuid4()))
|
||||
configs.append(parent)
|
||||
return configs
|
||||
|
||||
|
||||
def _build_chain(saver: PostgresSaver) -> list[dict]:
|
||||
thread_id = str(uuid4())
|
||||
parent: dict | None = None
|
||||
configs: list[dict] = []
|
||||
for step in range(STEPS):
|
||||
config, checkpoint, new_versions = _step_args(thread_id, step, parent)
|
||||
parent = saver.put(
|
||||
config,
|
||||
checkpoint,
|
||||
{"source": "loop", "step": step, "parents": {}},
|
||||
new_versions,
|
||||
)
|
||||
saver.put_writes(parent, [(CHANNEL, f"w{step}")], str(uuid4()))
|
||||
configs.append(parent)
|
||||
return configs
|
||||
|
||||
|
||||
def _assert_history(entry: DeltaChannelHistory, page_size: int) -> None:
|
||||
seed = entry.get("seed")
|
||||
assert isinstance(seed, _DeltaSnapshot), (
|
||||
f"page_size={page_size}: expected a snapshot seed, "
|
||||
f"got {entry.get('seed', '<missing>')!r}"
|
||||
)
|
||||
assert seed.value == SEED_VALUE
|
||||
assert [w[2] for w in entry["writes"]] == ["w1", "w2", "w3"], (
|
||||
f"page_size={page_size}: got {[w[2] for w in entry['writes']]}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("page_size", PAGE_SIZES)
|
||||
async def test_async_target_older_than_the_first_page(
|
||||
page_size: int, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr("langgraph.checkpoint.postgres.aio._DELTA_PAGE_SIZE", page_size)
|
||||
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||
await saver.setup()
|
||||
configs = await _abuild_chain(saver)
|
||||
result = await saver.aget_delta_channel_history(
|
||||
config=configs[TARGET_STEP], channels=[CHANNEL]
|
||||
)
|
||||
_assert_history(result[CHANNEL], page_size)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("page_size", PAGE_SIZES)
|
||||
def test_sync_target_older_than_the_first_page(
|
||||
page_size: int, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr("langgraph.checkpoint.postgres._DELTA_PAGE_SIZE", page_size)
|
||||
with PostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||
saver.setup()
|
||||
configs = _build_chain(saver)
|
||||
result = saver.get_delta_channel_history(
|
||||
config=configs[TARGET_STEP], channels=[CHANNEL]
|
||||
)
|
||||
_assert_history(result[CHANNEL], page_size)
|
||||
|
||||
|
||||
async def test_root_target_has_no_history_and_still_terminates(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("langgraph.checkpoint.postgres.aio._DELTA_PAGE_SIZE", 1)
|
||||
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||
await saver.setup()
|
||||
configs = await _abuild_chain(saver)
|
||||
result = await saver.aget_delta_channel_history(
|
||||
config=configs[0], channels=[CHANNEL]
|
||||
)
|
||||
assert result[CHANNEL] == {"writes": []}
|
||||
@@ -507,13 +507,12 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
||||
|
||||
Two-stage query:
|
||||
|
||||
* Stage 1 (paged): newest-first slice of `checkpoints` returning
|
||||
`(checkpoint_id, parent_checkpoint_id, type, checkpoint)` per
|
||||
ancestor. Sqlite has no JSONB, so we ship the full serialized
|
||||
checkpoint blob and inspect `channel_values` in Python. Pages
|
||||
newest-first by `checkpoint_id` with a `< cursor` predicate;
|
||||
page size is `DELTA_PAGE_SIZE`. Stops paging when every channel
|
||||
has found its seed or the chain is exhausted.
|
||||
* Stage 1 (streamed): recursive CTE over `checkpoints` following
|
||||
`parent_checkpoint_id` from the target, returning
|
||||
`(checkpoint_id, type, checkpoint)` per ancestor. Sqlite has no
|
||||
JSONB, so we ship the full serialized checkpoint blob and inspect
|
||||
`channel_values` in Python. Stops reading when every channel has
|
||||
found its seed or the chain is exhausted.
|
||||
|
||||
* Stage 2 (per-channel UNION ALL): one branch per channel reading
|
||||
`writes` filtered to that channel's specific `chain_cids`. No
|
||||
@@ -538,12 +537,14 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
||||
seeded: set[str] = set()
|
||||
|
||||
with self.cursor(transaction=False) as cur:
|
||||
cur.execute(DELTA_STAGE1_SQL, (thread_id, checkpoint_ns, checkpoint_id))
|
||||
cur.execute(
|
||||
DELTA_STAGE1_SQL,
|
||||
(thread_id, checkpoint_ns, checkpoint_id, thread_id, checkpoint_ns),
|
||||
)
|
||||
for row in cur:
|
||||
cid, parent_cid, type_tag, blob = row
|
||||
cid, type_tag, blob = row
|
||||
if step_walk_with_row(
|
||||
cid=cid,
|
||||
parent_cid=parent_cid,
|
||||
type_tag=type_tag,
|
||||
blob=blob,
|
||||
target_id=checkpoint_id,
|
||||
|
||||
@@ -26,16 +26,33 @@ from typing import Any
|
||||
|
||||
from langgraph.checkpoint.base import DeltaChannelHistory, PendingWrite
|
||||
|
||||
# Stage 1 streams ancestors of `target_cid` newest-first. The `<=`
|
||||
# predicate keeps target itself in the stream so we can read its
|
||||
# `parent_checkpoint_id` from the first row without a separate lookup;
|
||||
# the caller skips target's own writes/seed (matches the
|
||||
# `BaseCheckpointSaver` contract).
|
||||
# Stage 1 streams target, then its ancestors nearest-first, by following
|
||||
# `parent_checkpoint_id` rather than id order: ids are only monotonic within
|
||||
# one process, so a range scan by id can miss a parent whose id sorts above
|
||||
# its child's. Target is the anchor row; its own writes/seed are skipped
|
||||
# (matches the `BaseCheckpointSaver` contract).
|
||||
#
|
||||
# `put` is `INSERT OR REPLACE`, so re-putting an existing id under a
|
||||
# descendant's config makes the chain a loop. `step_walk_with_row` stops on a
|
||||
# repeated id; sqlite yields recursive rows lazily, so abandoning the cursor
|
||||
# ends the recursion.
|
||||
#
|
||||
# `CROSS JOIN` pins `ancestors` as the outer loop, so each step is one primary
|
||||
# key lookup. With a plain `JOIN` and no `ANALYZE` stats, sqlite can put
|
||||
# `checkpoints` outside and scan the whole thread per step.
|
||||
DELTA_STAGE1_SQL = (
|
||||
"WITH RECURSIVE ancestors(checkpoint_id, parent_checkpoint_id, type, "
|
||||
"checkpoint) AS ("
|
||||
"SELECT checkpoint_id, parent_checkpoint_id, type, checkpoint "
|
||||
"FROM checkpoints "
|
||||
"WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id <= ? "
|
||||
"ORDER BY checkpoint_id DESC"
|
||||
"WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? "
|
||||
"UNION ALL "
|
||||
"SELECT c.checkpoint_id, c.parent_checkpoint_id, c.type, c.checkpoint "
|
||||
"FROM ancestors a CROSS JOIN checkpoints c "
|
||||
"ON c.checkpoint_id = a.parent_checkpoint_id "
|
||||
"WHERE c.thread_id = ? AND c.checkpoint_ns = ?"
|
||||
") "
|
||||
"SELECT checkpoint_id, type, checkpoint FROM ancestors"
|
||||
)
|
||||
|
||||
|
||||
@@ -68,7 +85,6 @@ def build_delta_stage2_sql(*, chain_lens: Sequence[int]) -> str:
|
||||
def step_walk_with_row(
|
||||
*,
|
||||
cid: str,
|
||||
parent_cid: str | None,
|
||||
type_tag: str,
|
||||
blob: bytes,
|
||||
target_id: str,
|
||||
@@ -81,36 +97,32 @@ def step_walk_with_row(
|
||||
) -> bool:
|
||||
"""Process one streamed stage-1 row in the merged ancestor walk.
|
||||
|
||||
The cursor returns (cid, parent_cid, type, blob) rows in
|
||||
`checkpoint_id` DESC order starting at target. The first row is
|
||||
target itself; we read its parent_cid to seed the walk and otherwise
|
||||
skip it (target's own writes/seed are not part of the contract).
|
||||
The cursor returns (cid, type, blob) rows in walk order starting at
|
||||
target. The first row is target itself and is skipped (target's own
|
||||
writes/seed are not part of the contract).
|
||||
|
||||
For each subsequent row, if `cid` matches the walk's current
|
||||
position, we deserialize the blob, append the cid to every
|
||||
not-yet-seeded channel's chain, and check `channel_values` for
|
||||
For each subsequent row we deserialize the blob, append the cid to
|
||||
every not-yet-seeded channel's chain, and check `channel_values` for
|
||||
seeds. The deserialized checkpoint is dropped before advancing — no
|
||||
cross-row cache, so peak in-flight is one deserialized checkpoint.
|
||||
|
||||
Off-path rows (different branch on the same thread) advance the
|
||||
cursor without doing any work.
|
||||
|
||||
Returns True when every requested channel is seeded — the caller
|
||||
can stop iterating and close the cursor.
|
||||
Returns True when the caller can stop iterating and close the cursor:
|
||||
every requested channel is seeded, or the chain revisited a checkpoint.
|
||||
"""
|
||||
if "started" not in walk_state:
|
||||
if cid == target_id:
|
||||
walk_state["started"] = True
|
||||
walk_state["cur_cid"] = parent_cid
|
||||
walk_state["active"] = {ch for ch in channels if ch not in seeded}
|
||||
walk_state["walked"] = {cid}
|
||||
# Not target yet (or target not present): keep streaming.
|
||||
return False
|
||||
active: set[str] = walk_state["active"]
|
||||
if not active:
|
||||
return True
|
||||
if cid != walk_state["cur_cid"]:
|
||||
# Off-path row from a sibling branch — skip without deserializing.
|
||||
return False
|
||||
walked: set[str] = walk_state["walked"]
|
||||
if cid in walked:
|
||||
return True
|
||||
walked.add(cid)
|
||||
for ch in active:
|
||||
chain_by_ch[ch].append(cid)
|
||||
ckpt = serde.loads_typed((type_tag, blob))
|
||||
@@ -120,7 +132,6 @@ def step_walk_with_row(
|
||||
seeded.add(ch)
|
||||
active.discard(ch)
|
||||
del ckpt, channel_values
|
||||
walk_state["cur_cid"] = parent_cid
|
||||
return not active
|
||||
|
||||
|
||||
|
||||
@@ -625,8 +625,8 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
||||
"""Fast-path override of `BaseCheckpointSaver.aget_delta_channel_history`.
|
||||
|
||||
See `SqliteSaver.get_delta_channel_history` for design notes; this
|
||||
is the async equivalent using `aiosqlite` cursors. Stage 1 pages
|
||||
the parent chain newest-first and Python-deserializes each
|
||||
is the async equivalent using `aiosqlite` cursors. Stage 1 streams
|
||||
the parent chain from the target and Python-deserializes each
|
||||
checkpoint blob to find per-channel snapshots; stage 2 fetches
|
||||
only the relevant writes via per-channel UNION ALL.
|
||||
"""
|
||||
@@ -650,13 +650,13 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
||||
|
||||
async with self.lock, self.conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
DELTA_STAGE1_SQL, (thread_id, checkpoint_ns, checkpoint_id)
|
||||
DELTA_STAGE1_SQL,
|
||||
(thread_id, checkpoint_ns, checkpoint_id, thread_id, checkpoint_ns),
|
||||
)
|
||||
async for row in cur:
|
||||
cid, parent_cid, type_tag, blob = row
|
||||
cid, type_tag, blob = row
|
||||
if step_walk_with_row(
|
||||
cid=cid,
|
||||
parent_cid=parent_cid,
|
||||
type_tag=type_tag,
|
||||
blob=blob,
|
||||
target_id=checkpoint_id,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from langgraph.checkpoint.base import (
|
||||
BaseCheckpointSaver,
|
||||
Checkpoint,
|
||||
DeltaChannelHistory,
|
||||
empty_checkpoint,
|
||||
)
|
||||
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
from langgraph.checkpoint.sqlite._delta import DELTA_STAGE1_SQL
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||||
|
||||
CHANNEL = "ch"
|
||||
CONFIG: dict[str, Any] = {"configurable": {"thread_id": "t", "checkpoint_ns": ""}}
|
||||
EXPECTED: DeltaChannelHistory = {
|
||||
"writes": [("task", CHANNEL, "write-root")],
|
||||
"seed": "seed",
|
||||
}
|
||||
|
||||
|
||||
def _checkpoint(checkpoint_id: str, values: dict[str, Any]) -> Checkpoint:
|
||||
value = empty_checkpoint()
|
||||
value["id"] = checkpoint_id
|
||||
value["channel_values"] = values
|
||||
return value
|
||||
|
||||
|
||||
PARENT_ID_ORDERS = [
|
||||
pytest.param("z-older", "a-newer", id="parent_id_sorts_above_child"),
|
||||
pytest.param("a-older", "z-newer", id="parent_id_sorts_below_child"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("root_id", "child_id"), PARENT_ID_ORDERS)
|
||||
def test_sync_walk_reaches_parent_whatever_the_id_order(
|
||||
root_id: str, child_id: str
|
||||
) -> None:
|
||||
with SqliteSaver.from_conn_string(":memory:") as saver:
|
||||
root = saver.put(CONFIG, _checkpoint(root_id, {CHANNEL: "seed"}), {}, {})
|
||||
saver.put_writes(root, [(CHANNEL, "write-root")], "task")
|
||||
child = saver.put(root, _checkpoint(child_id, {}), {}, {})
|
||||
|
||||
got = saver.get_delta_channel_history(config=child, channels=[CHANNEL])
|
||||
reference = BaseCheckpointSaver.get_delta_channel_history(
|
||||
saver, config=child, channels=[CHANNEL]
|
||||
)
|
||||
assert got[CHANNEL] == EXPECTED
|
||||
assert got[CHANNEL] == reference[CHANNEL], "fast path disagrees with base"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("root_id", "child_id"), PARENT_ID_ORDERS)
|
||||
async def test_async_walk_reaches_parent_whatever_the_id_order(
|
||||
root_id: str, child_id: str
|
||||
) -> None:
|
||||
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
|
||||
root = await saver.aput(CONFIG, _checkpoint(root_id, {CHANNEL: "seed"}), {}, {})
|
||||
await saver.aput_writes(root, [(CHANNEL, "write-root")], "task")
|
||||
child = await saver.aput(root, _checkpoint(child_id, {}), {}, {})
|
||||
|
||||
got = await saver.aget_delta_channel_history(config=child, channels=[CHANNEL])
|
||||
assert got[CHANNEL] == EXPECTED
|
||||
|
||||
|
||||
def test_walk_reaches_root_of_long_chain_with_descending_ids() -> None:
|
||||
steps = 40
|
||||
with SqliteSaver.from_conn_string(":memory:") as saver:
|
||||
parent = saver.put(
|
||||
CONFIG, _checkpoint(f"id-{steps:03d}", {CHANNEL: "seed"}), {}, {}
|
||||
)
|
||||
saver.put_writes(parent, [(CHANNEL, "write-root")], "task")
|
||||
for step in range(steps - 1, 0, -1):
|
||||
parent = saver.put(parent, _checkpoint(f"id-{step:03d}", {}), {}, {})
|
||||
|
||||
got = saver.get_delta_channel_history(config=parent, channels=[CHANNEL])
|
||||
assert got[CHANNEL] == EXPECTED
|
||||
|
||||
|
||||
def test_walk_terminates_when_put_makes_the_parent_chain_cycle() -> None:
|
||||
with SqliteSaver.from_conn_string(":memory:") as saver:
|
||||
a = saver.put(CONFIG, _checkpoint("cid-a", {}), {}, {})
|
||||
b = saver.put(a, _checkpoint("cid-b", {}), {}, {})
|
||||
repoint_a_under_b = _checkpoint("cid-a", {})
|
||||
saver.put(b, repoint_a_under_b, {}, {})
|
||||
|
||||
got = saver.get_delta_channel_history(config=b, channels=[CHANNEL])
|
||||
assert got[CHANNEL] == {"writes": []}
|
||||
|
||||
|
||||
def test_walk_step_looks_up_the_parent_by_primary_key() -> None:
|
||||
with SqliteSaver.from_conn_string(":memory:") as saver:
|
||||
saver.setup()
|
||||
plan = [
|
||||
row[3]
|
||||
for row in saver.conn.execute(
|
||||
f"EXPLAIN QUERY PLAN {DELTA_STAGE1_SQL}", ("t", "", "id", "t", "")
|
||||
)
|
||||
]
|
||||
assert any(
|
||||
step.startswith("SEARCH c ") and "checkpoint_id=?" in step for step in plan
|
||||
), f"recursive step should look up the parent by key, got {plan}"
|
||||
@@ -103,6 +103,8 @@ The CLI uses a `langgraph.json` configuration file with these key settings:
|
||||
}
|
||||
```
|
||||
|
||||
Git dependencies should use credential-free URLs. The CLI conservatively scans direct `langgraph.json` dependencies, common Python package files, uv project and lock files, and common Node.js package and lock files for HTTP Git URLs with userinfo. This check is not exhaustive: generated Docker builds can copy other files, including nested requirement or constraint files, into image layers without scanning them. For private dependencies, provide short-lived credentials through your build environment's secret-backed Git credential helper. Do not store credentials in copied files such as `langgraph.json` or `pip_config_file`.
|
||||
|
||||
See the [full documentation](https://reference.langchain.com/python/langgraph-cli) for detailed configuration options.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -6,6 +6,7 @@ import re
|
||||
import shlex
|
||||
import textwrap
|
||||
from collections import Counter
|
||||
from collections.abc import Iterable
|
||||
from typing import Literal, NamedTuple
|
||||
|
||||
import click
|
||||
@@ -36,6 +37,10 @@ DISALLOWED_BUILD_COMMAND_CHARS = [
|
||||
# This blocks background execution (cmd &) while allowing command
|
||||
# chaining (cmd1 && cmd2) which is common in build commands.
|
||||
_SINGLE_AMPERSAND_RE = re.compile(r"(?<!&)&(?:&&)*(?!&)")
|
||||
_GIT_HTTP_AUTHORITY_RES = (
|
||||
re.compile(r"git\+https?://(?P<authority>[^/\s\"']+)", re.I),
|
||||
re.compile(r"\bgit\s*=\s*[\"']https?://(?P<authority>[^/\s\"']+)", re.I),
|
||||
)
|
||||
_API_VERSION_PATTERN = re.compile(
|
||||
r"^(?P<major>\d+)"
|
||||
r"(?:\.(?P<minor>\d+))?"
|
||||
@@ -78,6 +83,62 @@ def has_disallowed_build_command_content(command: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _has_git_http_url_userinfo(dependency: str) -> bool:
|
||||
"""Check whether a Git HTTP URL contains userinfo."""
|
||||
return any(
|
||||
"@" in match.group("authority")
|
||||
for pattern in _GIT_HTTP_AUTHORITY_RES
|
||||
for match in pattern.finditer(dependency)
|
||||
)
|
||||
|
||||
|
||||
def _validate_git_http_url_userinfo(
|
||||
values: Iterable[str], *, source: pathlib.Path | None = None
|
||||
) -> None:
|
||||
"""Reject credential-bearing Git HTTP URLs without echoing their values."""
|
||||
if not any(_has_git_http_url_userinfo(value) for value in values):
|
||||
return
|
||||
message = (
|
||||
"Git dependency URLs must not contain credentials or other URL "
|
||||
"userinfo because generated Dockerfiles and image layers can retain "
|
||||
"them. Use a credential-free Git URL and provide short-lived "
|
||||
"credentials through your build environment's secret-backed Git "
|
||||
"credential helper."
|
||||
)
|
||||
if source is not None:
|
||||
message += f" Found in: {source}"
|
||||
raise click.UsageError(message)
|
||||
|
||||
|
||||
def _validate_git_http_url_userinfo_files(paths: Iterable[pathlib.Path]) -> None:
|
||||
"""Reject credential-bearing Git HTTP URLs in dependency files."""
|
||||
for path in paths:
|
||||
path = path.resolve()
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
contents = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
raise click.UsageError(
|
||||
f"Could not inspect dependency file for embedded credentials: {path}"
|
||||
) from None
|
||||
_validate_git_http_url_userinfo([contents], source=path)
|
||||
|
||||
|
||||
def _validate_local_dependency_files(config_path: pathlib.Path, config: Config) -> None:
|
||||
"""Validate dependency files copied into a non-uv Python image."""
|
||||
paths: list[pathlib.Path] = []
|
||||
for dependency in config["dependencies"]:
|
||||
if not isinstance(dependency, str) or not dependency.startswith("."):
|
||||
continue
|
||||
root = (config_path.parent / dependency).resolve()
|
||||
paths.extend(
|
||||
root / name
|
||||
for name in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg")
|
||||
)
|
||||
_validate_git_http_url_userinfo_files(paths)
|
||||
|
||||
|
||||
MIN_PYTHON_VERSION = "3.11"
|
||||
DEFAULT_PYTHON_VERSION = "3.11"
|
||||
|
||||
@@ -320,7 +381,9 @@ def _get_source_kind(config: Config) -> str | None:
|
||||
return kind if isinstance(kind, str) else None
|
||||
|
||||
|
||||
def validate_config(config: Config) -> Config:
|
||||
def validate_config(
|
||||
config: Config, *, source_path: pathlib.Path | None = None
|
||||
) -> Config:
|
||||
"""Validate a configuration dictionary."""
|
||||
|
||||
graphs = config.get("graphs", {})
|
||||
@@ -415,6 +478,15 @@ def validate_config(config: Config) -> Config:
|
||||
' "source": {"kind": "uv", "root": ".."}'
|
||||
)
|
||||
|
||||
_validate_git_http_url_userinfo(
|
||||
(
|
||||
dependency
|
||||
for dependency in config["dependencies"]
|
||||
if isinstance(dependency, str)
|
||||
),
|
||||
source=source_path,
|
||||
)
|
||||
|
||||
source = config.get("source")
|
||||
source_kind = _get_source_kind(config)
|
||||
if source is not None and not isinstance(source, dict):
|
||||
@@ -609,7 +681,7 @@ def validate_config_file(config_path: pathlib.Path) -> Config:
|
||||
"""Load and validate a configuration file."""
|
||||
with open(config_path) as f:
|
||||
config = json.load(f)
|
||||
validated = validate_config(config)
|
||||
validated = validate_config(config, source_path=config_path.resolve())
|
||||
# Enforce the package.json doesn't enforce an
|
||||
# incompatible Node.js version
|
||||
if validated.get("node_version"):
|
||||
@@ -1280,6 +1352,7 @@ def python_config_to_docker(
|
||||
api_version=api_version,
|
||||
build_tools_to_uninstall=build_tools_to_uninstall,
|
||||
)
|
||||
_validate_local_dependency_files(config_path, config)
|
||||
if pip_installer == "auto":
|
||||
if _image_supports_uv(base_image):
|
||||
pip_installer = "uv"
|
||||
@@ -1490,7 +1563,18 @@ def node_config_to_docker(
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
# Calculate paths for monorepo support
|
||||
install_root = (
|
||||
pathlib.Path(build_context).resolve() if build_context else config_path.parent
|
||||
pathlib.Path(build_context).resolve()
|
||||
if build_context
|
||||
else config_path.parent.resolve()
|
||||
)
|
||||
config_root = config_path.parent.resolve()
|
||||
dependency_roots = (
|
||||
(install_root, config_root) if install_root != config_root else (install_root,)
|
||||
)
|
||||
_validate_git_http_url_userinfo_files(
|
||||
root / name
|
||||
for root in dependency_roots
|
||||
for name in ("package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml")
|
||||
)
|
||||
install_cmd = install_command or _get_node_pm_install_cmd(install_root)
|
||||
if build_context:
|
||||
|
||||
@@ -650,7 +650,8 @@ class Config(TypedDict, total=False):
|
||||
|
||||
pip_config_file: str | None
|
||||
"""Optional. Path to a pip config file (e.g., "/etc/pip.conf" or "pip.ini") for controlling
|
||||
package installation (custom indices, credentials, etc.).
|
||||
package installation (custom indices, timeouts, etc.). The file is copied into the
|
||||
generated image, so it must not contain credentials or other secrets.
|
||||
|
||||
Only relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.
|
||||
"""
|
||||
@@ -689,6 +690,9 @@ class Config(TypedDict, total=False):
|
||||
- "." or "./src" if you have a local Python package
|
||||
- str (aka "anthropic") for a PyPI package
|
||||
- "git+https://github.com/org/repo.git@main" for a Git-based package
|
||||
Git HTTP URLs must not contain userinfo such as a username or token. For private
|
||||
dependencies, provide short-lived credentials through the build environment's
|
||||
secret-backed Git credential helper.
|
||||
Defaults to an empty list, meaning no additional packages installed beyond your base environment.
|
||||
|
||||
This field is not supported when `source.kind` is `uv`.
|
||||
|
||||
@@ -880,6 +880,7 @@ def python_config_to_docker_uv_lock(
|
||||
_get_node_pm_install_cmd,
|
||||
_get_pip_cleanup_lines,
|
||||
_image_supports_uv,
|
||||
_validate_git_http_url_userinfo_files,
|
||||
docker_tag,
|
||||
)
|
||||
|
||||
@@ -890,11 +891,20 @@ def python_config_to_docker_uv_lock(
|
||||
)
|
||||
|
||||
config_root = config_path.parent.resolve()
|
||||
source_root = config["source"].get("root", ".")
|
||||
project_root = (config_root / source_root).resolve()
|
||||
_validate_git_http_url_userinfo_files(
|
||||
[project_root / "pyproject.toml", project_root / "uv.lock"]
|
||||
)
|
||||
|
||||
install_cmd = "uv pip install --system"
|
||||
_, global_reqs_pip_install, pip_config_file_str = _build_python_install_commands(
|
||||
config, install_cmd
|
||||
)
|
||||
plan = _plan_uv_lock_workspace(config_path, config)
|
||||
_validate_git_http_url_userinfo_files(
|
||||
package.pyproject_path for package in plan.install_order
|
||||
)
|
||||
|
||||
_update_uv_lock_graph_paths(config_path, config, plan)
|
||||
for section, key in [
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
||||
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
||||
},
|
||||
"_INTERNAL_docker_tag": {
|
||||
"anyOf": [
|
||||
@@ -270,7 +270,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
||||
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
||||
},
|
||||
"_INTERNAL_docker_tag": {
|
||||
"anyOf": [
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
||||
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
||||
},
|
||||
"_INTERNAL_docker_tag": {
|
||||
"anyOf": [
|
||||
@@ -270,7 +270,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
||||
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
||||
},
|
||||
"_INTERNAL_docker_tag": {
|
||||
"anyOf": [
|
||||
|
||||
@@ -255,6 +255,243 @@ def test_validate_config():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dependency",
|
||||
[
|
||||
"git+https://user:secret-token@github.com/org/private.git@main",
|
||||
"private-package @ git+http://token@github.com/org/private.git",
|
||||
"git+HTTPS://user%40example.com:secret%2Ftoken@github.com/org/private.git",
|
||||
"git+https://${GIT_TOKEN}@github.com/org/private.git",
|
||||
],
|
||||
)
|
||||
def test_validate_config_rejects_git_http_url_userinfo(dependency: str):
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
validate_config(
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"dependencies": [dependency],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
}
|
||||
)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "must not contain credentials or other URL userinfo" in message
|
||||
assert "secret-token" not in message
|
||||
assert "secret%2Ftoken" not in message
|
||||
|
||||
|
||||
def test_validate_config_file_reports_source_for_git_http_url_userinfo(
|
||||
tmp_path: pathlib.Path,
|
||||
):
|
||||
config_path = tmp_path / "langgraph.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["git+https://secret-token@github.com/org/private.git"],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
validate_config_file(config_path)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "secret-token" not in message
|
||||
assert f"Found in: {config_path.resolve()}" in message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"manifest", ["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"]
|
||||
)
|
||||
def test_config_to_docker_rejects_git_http_url_userinfo_in_node_files(
|
||||
tmp_path: pathlib.Path, manifest: str
|
||||
):
|
||||
config_path = tmp_path / "langgraph.json"
|
||||
config_path.write_text("{}\n")
|
||||
(tmp_path / "agent.js").write_text("export const graph = {};\n")
|
||||
(tmp_path / "package.json").write_text('{"name":"agent"}\n')
|
||||
(tmp_path / manifest).write_text(
|
||||
'"priv": "git+https://user:secret-token@github.com/org/private.git"\n'
|
||||
)
|
||||
config = validate_config(
|
||||
{
|
||||
"node_version": "20",
|
||||
"graphs": {"agent": "./agent.js:graph"},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
config_to_docker(
|
||||
config_path,
|
||||
config,
|
||||
base_image="langchain/langgraphjs-api",
|
||||
)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "must not contain credentials or other URL userinfo" in message
|
||||
assert "secret-token" not in message
|
||||
assert f"Found in: {(tmp_path / manifest).resolve()}" in message
|
||||
|
||||
|
||||
def test_config_to_docker_allows_node_git_urls_without_http_userinfo(
|
||||
tmp_path: pathlib.Path,
|
||||
):
|
||||
config_path = tmp_path / "langgraph.json"
|
||||
config_path.write_text("{}\n")
|
||||
(tmp_path / "agent.js").write_text("export const graph = {};\n")
|
||||
(tmp_path / "package.json").write_text(
|
||||
'{"dependencies":{"public":"git+https://github.com/org/public.git"}}\n'
|
||||
)
|
||||
config = validate_config(
|
||||
{
|
||||
"node_version": "20",
|
||||
"graphs": {"agent": "./agent.js:graph"},
|
||||
}
|
||||
)
|
||||
|
||||
docker, _ = config_to_docker(
|
||||
config_path,
|
||||
config,
|
||||
base_image="langchain/langgraphjs-api",
|
||||
)
|
||||
|
||||
assert f"ADD . /deps/{tmp_path.name}" in docker
|
||||
|
||||
|
||||
def test_config_to_docker_rejects_git_http_url_userinfo_in_node_workspace(
|
||||
tmp_path: pathlib.Path,
|
||||
):
|
||||
config_root = tmp_path / "apps" / "agent"
|
||||
config_root.mkdir(parents=True)
|
||||
config_path = config_root / "langgraph.json"
|
||||
config_path.write_text("{}\n")
|
||||
(config_root / "agent.js").write_text("export const graph = {};\n")
|
||||
(config_root / "package.json").write_text(
|
||||
'{"dependencies":{"priv":"git+https://secret-token@github.com/org/private.git"}}\n'
|
||||
)
|
||||
(tmp_path / "package.json").write_text('{"name":"workspace"}\n')
|
||||
config = validate_config(
|
||||
{
|
||||
"node_version": "20",
|
||||
"graphs": {"agent": "./agent.js:graph"},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
config_to_docker(
|
||||
config_path,
|
||||
config,
|
||||
base_image="langchain/langgraphjs-api",
|
||||
build_context=str(tmp_path),
|
||||
)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "secret-token" not in message
|
||||
assert f"Found in: {(config_root / 'package.json').resolve()}" in message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dependency",
|
||||
[
|
||||
"git+https://github.com/org/public.git@main",
|
||||
"private-package @ git+https://github.com/org/private.git@main",
|
||||
"git+ssh://git@github.com/org/private.git@main",
|
||||
],
|
||||
)
|
||||
def test_validate_config_allows_git_urls_without_http_userinfo(dependency: str):
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"dependencies": [dependency],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
}
|
||||
)
|
||||
|
||||
assert config["dependencies"] == [dependency]
|
||||
|
||||
|
||||
def test_config_to_docker_rejects_git_http_url_userinfo_in_requirements(
|
||||
tmp_path: pathlib.Path,
|
||||
):
|
||||
config_path = tmp_path / "langgraph.json"
|
||||
config_path.write_text("{}\n")
|
||||
(tmp_path / "agent.py").write_text("graph = object()\n")
|
||||
(tmp_path / "requirements.txt").write_text(
|
||||
"private @ git+https://secret-token@github.com/org/private.git\n"
|
||||
)
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
config_to_docker(
|
||||
config_path,
|
||||
config,
|
||||
base_image="langchain/langgraph-api:0.2.47",
|
||||
)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "must not contain credentials or other URL userinfo" in message
|
||||
assert "secret-token" not in message
|
||||
assert f"Found in: {(tmp_path / 'requirements.txt').resolve()}" in message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("manifest", ["pyproject.toml", "uv.lock"])
|
||||
def test_config_to_docker_rejects_git_http_url_userinfo_in_uv_files(
|
||||
tmp_path: pathlib.Path, manifest: str
|
||||
):
|
||||
config_path = tmp_path / "langgraph.json"
|
||||
config_path.write_text("{}\n")
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "agent.py").write_text("graph = object()\n")
|
||||
pyproject = textwrap.dedent(
|
||||
"""
|
||||
[project]
|
||||
name = "agent"
|
||||
version = "0.1.0"
|
||||
dependencies = ["private"]
|
||||
|
||||
[tool.uv.sources]
|
||||
private = { git = "https://github.com/org/private.git" }
|
||||
"""
|
||||
).strip()
|
||||
uv_lock = "# uv lock file\n"
|
||||
if manifest == "pyproject.toml":
|
||||
pyproject = pyproject.replace(
|
||||
"https://github.com", "https://secret-token@github.com"
|
||||
)
|
||||
else:
|
||||
uv_lock += (
|
||||
'source = { git = "https://secret-token@github.com/org/private.git" }\n'
|
||||
)
|
||||
(tmp_path / "pyproject.toml").write_text(pyproject + "\n")
|
||||
(tmp_path / "uv.lock").write_text(uv_lock)
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"graphs": {"agent": "./src/agent.py:graph"},
|
||||
"source": {"kind": "uv"},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
config_to_docker(
|
||||
config_path,
|
||||
config,
|
||||
base_image="langchain/langgraph-api:0.2.47",
|
||||
)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "must not contain credentials or other URL userinfo" in message
|
||||
assert "secret-token" not in message
|
||||
|
||||
|
||||
def test_validate_config_image_distro():
|
||||
"""Test validation of image_distro field."""
|
||||
# Valid image_distro values should work
|
||||
|
||||
@@ -1950,7 +1950,7 @@ class Pregel(
|
||||
run_tasks: list[PregelTaskWrites] = []
|
||||
run_task_ids: list[str] = []
|
||||
|
||||
for as_node, values, provided_task_id in valid_updates:
|
||||
for i, (as_node, values, provided_task_id) in enumerate(valid_updates):
|
||||
# create task to run all writers of the chosen node
|
||||
writers = self.nodes[as_node].flat_writers
|
||||
if not writers:
|
||||
@@ -1964,7 +1964,7 @@ class Pregel(
|
||||
task_id = provided_task_id or (
|
||||
prepared_task_ids.popleft()
|
||||
if prepared_task_ids
|
||||
else str(uuid5(UUID(checkpoint["id"]), INTERRUPT))
|
||||
else _update_task_id(checkpoint["id"], i)
|
||||
)
|
||||
run_tasks.append(task)
|
||||
run_task_ids.append(task_id)
|
||||
@@ -2410,7 +2410,7 @@ class Pregel(
|
||||
run_tasks: list[PregelTaskWrites] = []
|
||||
run_task_ids: list[str] = []
|
||||
|
||||
for as_node, values, provided_task_id in valid_updates:
|
||||
for i, (as_node, values, provided_task_id) in enumerate(valid_updates):
|
||||
# create task to run all writers of the chosen node
|
||||
writers = self.nodes[as_node].flat_writers
|
||||
if not writers:
|
||||
@@ -2424,7 +2424,7 @@ class Pregel(
|
||||
task_id = provided_task_id or (
|
||||
prepared_task_ids.popleft()
|
||||
if prepared_task_ids
|
||||
else str(uuid5(UUID(checkpoint["id"]), INTERRUPT))
|
||||
else _update_task_id(checkpoint["id"], i)
|
||||
)
|
||||
run_tasks.append(task)
|
||||
run_task_ids.append(task_id)
|
||||
@@ -4172,6 +4172,16 @@ class Pregel(
|
||||
await self.cache.aclear(namespaces)
|
||||
|
||||
|
||||
def _update_task_id(checkpoint_id: str, i: int) -> str:
|
||||
"""Task id for the `i`th update of a superstep that has no task to reuse.
|
||||
|
||||
Savers keep one write per `(task_id, idx)`, so updates sharing an id lose
|
||||
all but the first one's writes, which a `DeltaChannel` replays from. The
|
||||
first update keeps the id a lone update has always had.
|
||||
"""
|
||||
return str(uuid5(UUID(checkpoint_id), INTERRUPT if i == 0 else f"{INTERRUPT}:{i}"))
|
||||
|
||||
|
||||
def _trigger_to_nodes(nodes: dict[str, PregelNode]) -> Mapping[str, Sequence[str]]:
|
||||
"""Index from a trigger to nodes that depend on it."""
|
||||
trigger_to_nodes: defaultdict[str, list[str]] = defaultdict(list)
|
||||
|
||||
@@ -20,6 +20,7 @@ from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
from typing_extensions import TypedDict
|
||||
@@ -27,16 +28,17 @@ from typing_extensions import TypedDict
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.graph import START, StateGraph
|
||||
from langgraph.graph.message import _messages_delta_reducer
|
||||
from langgraph.types import StateUpdate
|
||||
from langgraph.types import StateSnapshot, StateUpdate
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
def _build_graph(
|
||||
checkpointer: InMemorySaver,
|
||||
checkpointer: BaseCheckpointSaver,
|
||||
*,
|
||||
two_nodes: bool = False,
|
||||
snapshot_frequency: int = 1000,
|
||||
interrupt_before: list[str] | None = None,
|
||||
) -> Any:
|
||||
"""Compile a minimal DeltaChannel-backed `messages` graph.
|
||||
|
||||
@@ -63,7 +65,7 @@ def _build_graph(
|
||||
builder.set_finish_point("assistant")
|
||||
else:
|
||||
builder.set_finish_point("model")
|
||||
return builder.compile(checkpointer=checkpointer)
|
||||
return builder.compile(checkpointer=checkpointer, interrupt_before=interrupt_before)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -273,10 +275,6 @@ def test_bulk_update_state_multi_task_per_superstep_delta_channel() -> None:
|
||||
that each call `put_writes`. Guards the regression where moving
|
||||
`put_writes` outside the per-task loop would persist only the last
|
||||
task's writes.
|
||||
|
||||
Explicit `task_id`s are required to disambiguate writes belonging to
|
||||
different `StateUpdate`s targeting the same node — otherwise both share
|
||||
the deterministic interrupt-derived id and collide in the saver.
|
||||
"""
|
||||
|
||||
saver = InMemorySaver()
|
||||
@@ -310,6 +308,92 @@ def test_bulk_update_state_multi_task_per_superstep_delta_channel() -> None:
|
||||
assert sorted(ids) == ["m1", "m2"]
|
||||
|
||||
|
||||
def _update(content: str, as_node: str) -> StateUpdate:
|
||||
return StateUpdate(
|
||||
values={"messages": [HumanMessage(content=content, id=content)]},
|
||||
as_node=as_node,
|
||||
)
|
||||
|
||||
|
||||
def _contents(state: StateSnapshot) -> list[str]:
|
||||
return [m.content for m in state.values["messages"]]
|
||||
|
||||
|
||||
def test_bulk_update_state_keeps_every_update_without_task_ids(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
graph = _build_graph(sync_checkpointer, two_nodes=True)
|
||||
config = {"configurable": {"thread_id": "bulk-no-task-ids"}}
|
||||
graph.invoke({"messages": [HumanMessage(content="hi", id="hi")]}, config)
|
||||
|
||||
graph.bulk_update_state(
|
||||
config,
|
||||
[
|
||||
[
|
||||
_update("first", "model"),
|
||||
_update("second", "model"),
|
||||
_update("third", "assistant"),
|
||||
]
|
||||
],
|
||||
)
|
||||
|
||||
contents = _contents(graph.get_state(config))
|
||||
assert sorted(contents) == ["first", "hi", "second", "third"], (
|
||||
f"every update's writes must persist; got {contents}"
|
||||
)
|
||||
|
||||
|
||||
async def test_abulk_update_state_keeps_every_update_without_task_ids(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
graph = _build_graph(async_checkpointer, two_nodes=True)
|
||||
config = {"configurable": {"thread_id": "bulk-no-task-ids"}}
|
||||
await graph.ainvoke({"messages": [HumanMessage(content="hi", id="hi")]}, config)
|
||||
|
||||
await graph.abulk_update_state(
|
||||
config,
|
||||
[
|
||||
[
|
||||
_update("first", "model"),
|
||||
_update("second", "model"),
|
||||
_update("third", "assistant"),
|
||||
]
|
||||
],
|
||||
)
|
||||
|
||||
contents = _contents(await graph.aget_state(config))
|
||||
assert sorted(contents) == ["first", "hi", "second", "third"], (
|
||||
f"every update's writes must persist; got {contents}"
|
||||
)
|
||||
|
||||
|
||||
def test_bulk_update_state_keeps_every_update_next_to_a_pending_task(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
graph = _build_graph(
|
||||
sync_checkpointer, two_nodes=True, interrupt_before=["assistant"]
|
||||
)
|
||||
config = {"configurable": {"thread_id": "bulk-pending-task"}}
|
||||
graph.invoke({"messages": [HumanMessage(content="hi", id="hi")]}, config)
|
||||
assert graph.get_state(config).next == ("assistant",)
|
||||
|
||||
graph.bulk_update_state(
|
||||
config,
|
||||
[
|
||||
[
|
||||
_update("first", "assistant"),
|
||||
_update("second", "model"),
|
||||
_update("third", "model"),
|
||||
]
|
||||
],
|
||||
)
|
||||
|
||||
contents = _contents(graph.get_state(config))
|
||||
assert sorted(contents) == ["first", "hi", "second", "third"], (
|
||||
f"every update's writes must persist; got {contents}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public-API observation of fresh-thread checkpoint shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user