fix(checkpoints): validate cached delta history before replay (#7548)

* fix(checkpoints): reject malformed Redis history write records

Validate pending-write tuples before optional Redis history entries reach
delta materialization. Treat the whole malformed entry as a miss so the
authoritative saver rebuilds it without dropping or inventing state.

Cover actual checkpoint reads, whole-entry replacement, valid tuple/list
records, neighbor preservation and warm-cache rereads.

* fix(checkpoints): bind cached history writes to their channel

* fix(checkpoints): keep cache warning failures best-effort

Guard warning handlers across decode, ingestion, serialization, Redis outages and purge. Preserve authoritative rebuilds and valid neighboring writes, with native graph regression coverage.

* docs(gateway): keep guidance below the hard size budget

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
SumEn 2026-10-27 00:28:03 +08:00 committed by GitHub
parent c5193d26c7
commit f7d02a7515
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 365 additions and 53 deletions

View File

@ -571,7 +571,12 @@ never frozen and workers sharing one checkpoint database may safely run
different cache settings. Returned state snapshots have independent nested values,
so editing one cannot change later checkpoint reads. Redis thread cleanup treats a configured `key_prefix`
as literal text, including glob metacharacters, so deletion stays within that
namespace and thread.
namespace and thread. Redis rejects malformed write records on ingestion; malformed
or wrong-channel cached histories are rebuilt in full from authoritative checkpoints.
Seeds and write values remain opaque to the cache: these checks do not validate
schema-specific state contents.
Checkpoint reads, valid neighboring writes and optional-cache cleanup continue
even if a warning handler fails.
Gateway checkpoint cleanup runs when the next run wins durable same-thread
admission, before its first checkpoint read. It checks only the root head and

View File

@ -10,13 +10,13 @@ environment keys; reject arbitrary commands and manifest-policy bypasses. Keep
credentials out of tool schemas and preserve admin checks, masked atomic edits,
and MCP cache reloads.
Memory shutdown resolves hot-reloaded config/backend, flushes, then closes as
one `await_drained` operation. Resolve config inside the best-effort handler and
off-loop so malformed edits cannot abort teardown. Cancellation waits for owned
workers; `close()` must be quick or internally bounded because the host sets no
timeout. Budget it, the flush timeout, and other hooks in the pod grace period.
Likewise drain extension `stop_services()` across cancellation before later
resources unwind; each service `stop()` has a 30-second `asyncio.timeout`.
Memory shutdown resolves hot-reloaded config/backend, flushes and closes in one
`await_drained` operation. Resolve config off-loop inside the best-effort handler
so malformed edits cannot abort teardown. Cancellation waits for owned workers.
The host sets no close timeout: `close()` must be quick or internally bounded;
budget it, flush timeout and other hooks within pod grace. Drain extension
`stop_services()` across cancellation before later resources unwind; each
service `stop()` has a 30-second `asyncio.timeout`.
Routers with persistent writes drain through
`app.gateway.persistent_writes.run_drained_write`.
@ -43,13 +43,12 @@ locations and keeps `merge_run_context_overrides` clear. Admission and
`/api/features` use `conversation_references_enabled()` as the sole configured
predicate.
FastAPI listens on port 8001. `GET /health` is liveness; `GET /health/ready`
concurrently probes the ORM engine, the checkpointer/Store and the stream
bridge backend (`StreamBridge.ping()`) under one deadline and per-kind gates,
from startup snapshots. Unreachable or unresolved gating backends return 503;
`memory` reports `not_configured`; the provisioner `/health` verdict is
report-only. See `health.py`. `GATEWAY_ENABLE_DOCS=false` disables `/docs`,
`/redoc`, and `/openapi.json`.
FastAPI listens on 8001. `GET /health` is liveness; `GET /health/ready` concurrently
probes startup snapshots of ORM, checkpointer/Store and stream bridge
(`StreamBridge.ping()`) under one deadline and per-kind gates. Unreachable or
unresolved gating backends return 503; `memory` reports `not_configured`;
provisioner `/health` is report-only. See `health.py`. `GATEWAY_ENABLE_DOCS=false`
disables `/docs`, `/redoc` and `/openapi.json`.
`build_run_config()` resolves the default LangGraph super-step budget from the
hot-reloaded top-level `recursion_limit` setting. A valid request-level value
@ -58,7 +57,12 @@ takes precedence; invalid values fall back to that default, and
Durable MCP notifications use internal Agent runs. Keep their trusted delivery instruction outside the user-input boundary, and frame serialized remote events as untrusted before model invocation. Strict thread existence/ownership admission dead-letters events whose task outlives its deleted chat instead of recreating the thread. `start_run` marks run-manager admission conflicts with `BusyThreadConflict`; the notification launcher retries only that typed 409 and treats other 409 responses as permanent.
CORS is same-origin by default when requests enter through nginx on port 2026. Split-origin or port-forwarded browser clients must opt in with `GATEWAY_CORS_ORIGINS` (exact origins); Gateway `CORSMiddleware` and `CSRFMiddleware` both read that variable so browser CORS and auth-origin checks stay aligned. Those clients also need `CORS_EXPOSED_HEADERS` (`csrf_middleware.py`): run-creating routes return the run's id in `Content-Location`, which is not CORS-safelisted, so JS cannot read it unless it is exposed — and the LangGraph SDK resolves run metadata from that header alone, so withholding it breaks `useStream`'s `onCreated` and thread-gated actions.
CORS defaults to same-origin behind nginx on 2026. Split-origin and port-forwarded
browser clients must set exact `GATEWAY_CORS_ORIGINS`, shared by `CORSMiddleware`
and `CSRFMiddleware`. Expose `Content-Location` through `CORS_EXPOSED_HEADERS`
(`csrf_middleware.py`): run-creating routes put the run ID there, and the LangGraph
SDK uses only that header for run metadata. Without exposure, JS cannot read it,
breaking `useStream.onCreated` and thread-gated actions.
Browser sessions belong to `app.gateway.auth.session_cookie`. Login's `remember_me`
flag never stores passwords. `SessionCookiePolicy` persists `HttpOnly access_token`

View File

@ -1,29 +1,17 @@
### Checkpoint Cache Concurrency
### Checkpoint History Cache
The memory history cache is shared by sync saver wrappers. Its threading lock
serializes lookup/LRU promotion, population/eviction, thread purge, close, and
statistics snapshots. Keep cache critical sections free of awaits and saver I/O;
immutable histories do not make the mutable LRU registry thread-safe.
Memory entries own deep copies of write payloads and snapshot seeds on ingest
and read. Clone payloads outside the registry lock: user-defined copy callbacks
can re-enter the cache. Preserve sync/async state isolation through
`CheckpointStateAccessor`; tests live in `tests/test_checkpoint_cache_payload_isolation.py`.
Lock registry/LRU/stats/lifecycle without awaits, saver I/O or copy callbacks.
Deep-copy writes/seeds on caller-thread ingest/read (async too): O(payload)
CPU/allocation per entry, O(N²) across N growing seeds. LRU bounds entries, not
bytes/scan cost; profile before offload. Isolation:
`tests/test_checkpoint_cache_payload_isolation.py`.
Copies run synchronously on the caller's thread, including `aget_many` and
`aset_many` on the event loop. Each ingest or cache hit copies the whole
entry, so CPU and allocation costs grow with payload size. A warm scan of
N checkpoints can repeatedly copy growing snapshot seeds, reaching O(N²)
total copied data. The LRU entry cap bounds entry count, not payload bytes
or scan cost. Profile long-thread history reads before offloading copies
to worker threads; preserve payload isolation and copy callbacks outside
the registry lock.
### Redis Checkpoint Cache Purge
Thread purge escapes Redis glob metacharacters in the complete literal key stem
before SCAN, and checks returned byte/string keys against that stem before UNLINK.
Do not interpret operator `key_prefix` text as a pattern or widen thread deletion
to sibling namespaces. Keep the cursor loop and outage/TTL fallback unchanged.
Redis read/write records require tuple/list triples and string task/channel IDs;
skip bad ingests, rebuild bad reads. Bind sync/async target/ancestor hits to the
requested channel; seeds/values stay opaque. Warnings are best-effort: handler
errors preserve cache fallbacks and valid neighbors, including Redis outages/purge.
Escape literal stems for SCAN; recheck byte/string keys before UNLINK. Preserve
namespace/thread bounds, cursor iteration and outage/TTL fallback.
### Stream Bridge Heartbeats

View File

@ -23,6 +23,13 @@ from typing import Any, Protocol
CACHE_FORMAT_VERSION = 1
def valid_history_entry(entry: Any, *, channel: str | None = None) -> bool:
"""Check replay records; seed and write values belong to the channel schema."""
if not isinstance(entry, dict) or not isinstance(entry.get("writes"), list):
return False
return all(isinstance(write, (tuple, list)) and len(write) == 3 and isinstance(write[0], str) and isinstance(write[1], str) and (channel is None or write[1] == channel) for write in entry["writes"])
def make_history_key(
key_prefix: str,
thread_id: str,

View File

@ -12,9 +12,10 @@ The redis import is lazy (module is importable without the optional
from __future__ import annotations
import logging
from contextlib import suppress
from typing import Any
from deerflow.runtime.checkpoint_cache.base import CheckpointCacheStats, thread_key_stem
from deerflow.runtime.checkpoint_cache.base import CheckpointCacheStats, thread_key_stem, valid_history_entry
logger = logging.getLogger(__name__)
@ -23,6 +24,12 @@ REDIS_INSTALL = "redis is required for the redis checkpoint cache backend. Insta
_TAG_SEPARATOR = b"\x00"
def _warn(message: str, *args: Any) -> None:
"""Keep optional-cache diagnostics from changing fallback behavior."""
with suppress(Exception):
logger.warning(message, *args)
def _create_client(redis_url: str, *, max_connections: int | None) -> Any:
try:
import redis.asyncio as redis_async
@ -68,7 +75,7 @@ class RedisCheckpointHistoryCache:
raws = await self._client.mget(keys)
except _redis_error() as exc:
# Performance-only bypass: a redis outage costs hits, never availability.
logger.warning("checkpoint history cache mget failed; treating as all-miss: %s", exc)
_warn("checkpoint history cache mget failed; treating as all-miss: %s", exc)
self._misses += len(keys)
return {}
found: dict[str, dict[str, Any]] = {}
@ -79,12 +86,15 @@ class RedisCheckpointHistoryCache:
try:
tag, payload = raw.split(_TAG_SEPARATOR, 1)
entry = self._serde.loads_typed((tag.decode(), payload))
if not isinstance(entry, dict) or not isinstance(entry.get("writes"), list):
raise ValueError("invalid checkpoint history entry shape")
# Delta materialization unpacks each pending write as a triple.
# A three-character string also unpacks, silently inventing a
# channel value, so validate the record rather than just len().
if not valid_history_entry(entry):
raise ValueError("invalid checkpoint history entry or write record")
except Exception as exc:
# Cached history is optional. A malformed entry must not prevent
# rebuilding history from authoritative checkpoints.
logger.warning(
_warn(
"checkpoint history cache decode failed; treating as miss (key=%s): %s",
key,
type(exc).__name__,
@ -101,13 +111,16 @@ class RedisCheckpointHistoryCache:
try:
pipe = self._client.pipeline(transaction=False)
for key, entry in entries.items():
if not valid_history_entry(entry):
_warn("checkpoint history cache invalid entry; skipping (key=%s)", key)
continue
try:
tag, data = self._serde.dumps_typed(entry)
except Exception as exc:
self._write_serialization_failures += 1
# Cache writes are optional; one unserializable entry must not
# abort authoritative history reads or discard other entries.
logger.warning(
_warn(
"checkpoint history cache serialization failed; skipping (key=%s): %s",
key,
type(exc).__name__,
@ -117,7 +130,7 @@ class RedisCheckpointHistoryCache:
await pipe.execute()
except _redis_error() as exc:
# Writes are optional; the next read simply recomputes the history.
logger.warning("checkpoint history cache write failed; skipping: %s", exc)
_warn("checkpoint history cache write failed; skipping: %s", exc)
async def adelete_thread(self, key_prefix: str, thread_id: str) -> None:
"""SCAN+UNLINK every entry of one thread. Failure degrades to
@ -139,7 +152,7 @@ class RedisCheckpointHistoryCache:
if cursor == 0:
break
except _redis_error() as exc:
logger.warning("checkpoint history cache thread purge failed; residual entries expire via TTL: %s", exc)
_warn("checkpoint history cache thread purge failed; residual entries expire via TTL: %s", exc)
def stats(self) -> CheckpointCacheStats:
return CheckpointCacheStats(

View File

@ -30,7 +30,7 @@ from typing import Any
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointTuple, PendingWrite
from deerflow.runtime.checkpoint_cache.base import make_history_key
from deerflow.runtime.checkpoint_cache.base import make_history_key, valid_history_entry
logger = logging.getLogger(__name__)
@ -132,7 +132,7 @@ class CachedHistorySaver(BaseCheckpointSaver):
missing: list[str] = []
for ch in channels:
entry = hits.get(keys[ch])
if entry is None:
if not valid_history_entry(entry, channel=ch):
missing.append(ch)
else:
found[ch] = entry
@ -178,7 +178,7 @@ class CachedHistorySaver(BaseCheckpointSaver):
key = self._key(parent, channel)
hits = await self._cache.aget_many([key])
parent_history = hits.get(key)
if parent_history is None:
if not valid_history_entry(parent_history, channel=channel):
if depth > 0:
parent_history = await self._aresolve(parent, channel, depth - 1)
else:
@ -222,7 +222,7 @@ class CachedHistorySaver(BaseCheckpointSaver):
missing: list[str] = []
for ch in channels:
entry = hits.get(keys[ch])
if entry is None:
if not valid_history_entry(entry, channel=ch):
missing.append(ch)
else:
found[ch] = entry
@ -253,7 +253,7 @@ class CachedHistorySaver(BaseCheckpointSaver):
key = self._key(parent, channel)
parent_history = self._cache.get_many([key]).get(key)
if parent_history is None:
if not valid_history_entry(parent_history, channel=channel):
if depth > 0:
parent_history = self._resolve_sync(parent, channel, depth - 1)
else:

View File

@ -0,0 +1,295 @@
"""Malformed cache write records must rebuild from authoritative checkpoints."""
from types import SimpleNamespace
from typing import Annotated, TypedDict
from unittest.mock import AsyncMock
import pytest
from langgraph.channels import DeltaChannel
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
from langgraph.graph import StateGraph
from deerflow.runtime.checkpoint_cache import redis as redis_cache
from deerflow.runtime.checkpoint_cache.memory import MemoryCheckpointHistoryCache
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor
from deerflow.runtime.checkpointer.cached_saver import CachedHistorySaver
class RedisTransport:
"""Only the Redis transport is replaced; serialization and graph reads are real."""
def __init__(self):
self.values = {}
async def mget(self, keys):
return [self.values.get(key) for key in keys]
def pipeline(self, transaction=False):
return self
def set(self, key, value, ex=None):
self.values[key] = value
return self
async def execute(self):
return []
async def aclose(self):
pass
def fold(state, writes):
result = list(state)
for batch in writes:
result.extend(batch)
return result
class State(TypedDict):
items: Annotated[list[str], DeltaChannel(fold, snapshot_frequency=100)]
BAD_WRITES = [None, 17, "abc", {"task": "x"}, (), ("task", "items"), ("task", "items", [], "extra"), (17, "items", []), ("task", 17, [])]
def fail_warning(*args, **kwargs):
raise RuntimeError("handler failed")
@pytest.mark.asyncio
@pytest.mark.parametrize("bad_write", BAD_WRITES, ids=["null", "number", "string", "mapping", "empty", "short", "long", "task-type", "channel-type"])
@pytest.mark.parametrize("warning_handler_fails", [False, True], ids=["logging", "raising-handler"])
async def test_bad_write_rebuilds_snapshot_and_replaces_corrupt_cache(monkeypatch, bad_write, warning_handler_fails):
if warning_handler_fails:
monkeypatch.setattr(redis_cache.logger, "warning", fail_warning)
transport = RedisTransport()
monkeypatch.setattr(redis_cache, "_create_client", lambda *args, **kwargs: transport)
serde = JsonPlusSerializer()
cache = redis_cache.RedisCheckpointHistoryCache("redis://unused", serde=serde, ttl_seconds=60)
inner = InMemorySaver()
saver = CachedHistorySaver(inner, cache, key_prefix="record-validation")
builder = StateGraph(State)
builder.add_node("one", lambda state: {"items": ["saved"]})
builder.set_entry_point("one")
builder.set_finish_point("one")
graph = builder.compile(checkpointer=saver)
accessor = CheckpointStateAccessor.bind(graph, saver, mode="delta")
config = {"configurable": {"thread_id": "invalid-record"}}
try:
await graph.ainvoke({"items": ["input"]}, config)
expected = (await accessor.aget(config)).values
assert expected == {"items": ["input", "saved"]}
target = await inner.aget_tuple(config)
key = saver._key(target, "items")
# Keep a plausible prefix too: accepting only part of a corrupt entry
# would inject this uncommitted value into an otherwise successful read.
tag, payload = serde.dumps_typed({"writes": [("task", "items", ["injected"]), bad_write]})
corrupted = tag.encode() + b"\x00" + payload
transport.values[key] = corrupted
misses = cache.stats().misses
assert (await accessor.aget(config)).values == expected
assert cache.stats().misses > misses
assert transport.values[key] != corrupted
# The repaired cache is warm: reads stay correct without a new walk.
misses = cache.stats().misses
assert (await accessor.aget(config)).values == expected
assert cache.stats().misses == misses
finally:
await cache.aclose()
@pytest.mark.asyncio
@pytest.mark.parametrize("record", [("task", "items", ["saved"]), ["task", "items", ["saved"]]], ids=["tuple", "list"])
@pytest.mark.parametrize("include_corrupt", [False, True], ids=["valid-control", "corrupt-neighbor"])
async def test_valid_record_shapes_and_neighbor_entries_are_preserved(monkeypatch, record, include_corrupt):
transport = RedisTransport()
monkeypatch.setattr(redis_cache, "_create_client", lambda *args, **kwargs: transport)
serde = JsonPlusSerializer()
cache = redis_cache.RedisCheckpointHistoryCache("redis://unused", serde=serde, ttl_seconds=60)
try:
await cache.aset_many({"good": {"writes": [record]}, "empty": {"writes": [], "seed": None}})
if include_corrupt:
await cache.aset_many({"bad": {"writes": ["abc"]}})
found = await cache.aget_many(["good", "bad", "empty"] if include_corrupt else ["good", "empty"])
assert set(found) == {"good", "empty"}
assert list(found["good"]["writes"][0]) == list(record)
assert found["empty"] == {"writes": [], "seed": None}
assert cache.stats().hits == 2
assert cache.stats().misses == int(include_corrupt)
finally:
await cache.aclose()
@pytest.mark.asyncio
@pytest.mark.parametrize("entry", [{}, None, [], {"writes": None}, {"writes": ()}] + [{"writes": [bad]} for bad in BAD_WRITES])
async def test_invalid_ingest_skips_only_bad_entry(monkeypatch, entry):
transport = RedisTransport()
monkeypatch.setattr(redis_cache, "_create_client", lambda *args, **kwargs: transport)
cache = redis_cache.RedisCheckpointHistoryCache("redis://unused", serde=JsonPlusSerializer(), ttl_seconds=60)
try:
await cache.aset_many({"before": {"writes": []}, "bad": entry, "after": {"writes": [("task", "items", ["saved"])]}})
assert set(transport.values) == {"before", "after"}
assert set(await cache.aget_many(["before", "after"])) == {"before", "after"}
assert cache.stats().write_serialization_failures == 0
finally:
await cache.aclose()
@pytest.mark.asyncio
async def test_invalid_ingest_logging_cannot_discard_valid_neighbor(monkeypatch):
transport = RedisTransport()
monkeypatch.setattr(redis_cache, "_create_client", lambda *args, **kwargs: transport)
monkeypatch.setattr(redis_cache.logger, "warning", fail_warning)
cache = redis_cache.RedisCheckpointHistoryCache("redis://unused", serde=JsonPlusSerializer(), ttl_seconds=60)
try:
await cache.aset_many({"bad": {"writes": [None]}, "good": {"writes": []}})
assert set(transport.values) == {"good"}
finally:
await cache.aclose()
@pytest.mark.asyncio
async def test_serialization_logging_cannot_discard_valid_neighbors(monkeypatch):
transport = RedisTransport()
monkeypatch.setattr(redis_cache, "_create_client", lambda *args, **kwargs: transport)
monkeypatch.setattr(redis_cache.logger, "warning", fail_warning)
execute = AsyncMock(wraps=transport.execute)
monkeypatch.setattr(transport, "execute", execute)
cache = redis_cache.RedisCheckpointHistoryCache("redis://unused", serde=JsonPlusSerializer(), ttl_seconds=60)
try:
await cache.aset_many({"before": {"writes": []}, "bad": {"writes": [], "seed": object()}, "after": {"writes": [("task", "items", ["saved"])]}})
assert set(transport.values) == {"before", "after"}
assert set(await cache.aget_many(["before", "after"])) == {"before", "after"}
assert cache.stats().write_serialization_failures == 1
execute.assert_awaited_once()
finally:
await cache.aclose()
@pytest.mark.asyncio
@pytest.mark.parametrize("operation", ["read", "write", "purge"])
async def test_warning_handler_failure_preserves_redis_error_fallback(monkeypatch, operation):
from redis.exceptions import RedisError
transport = RedisTransport()
monkeypatch.setattr(redis_cache, "_create_client", lambda *args, **kwargs: transport)
monkeypatch.setattr(redis_cache.logger, "warning", fail_warning)
cache = redis_cache.RedisCheckpointHistoryCache("redis://unused", serde=JsonPlusSerializer(), ttl_seconds=60)
error = RedisError("Redis unavailable")
try:
if operation == "read":
monkeypatch.setattr(transport, "mget", AsyncMock(side_effect=error))
assert await cache.aget_many(["one", "two"]) == {}
assert cache.stats().misses == 2
elif operation == "write":
monkeypatch.setattr(transport, "execute", AsyncMock(side_effect=error))
await cache.aset_many({"good": {"writes": []}})
else:
transport.connection_pool = SimpleNamespace(get_encoder=lambda: SimpleNamespace(encode=lambda key: key.encode()))
transport.scan = AsyncMock(side_effect=error)
await cache.adelete_thread("logging", "thread")
finally:
await cache.aclose()
def build_history_graph(saver, steps=4):
builder = StateGraph(State)
for index in range(steps):
builder.add_node(str(index), lambda state, index=index: {"items": [f"saved{index}"]})
if index:
builder.add_edge(str(index - 1), str(index))
builder.set_entry_point("0")
builder.set_finish_point(str(steps - 1))
return builder.compile(checkpointer=saver)
@pytest.mark.asyncio
@pytest.mark.parametrize("async_read", [False, True], ids=["sync", "async"])
@pytest.mark.parametrize("location", ["target", "ancestor"])
@pytest.mark.parametrize("record_type", [tuple, list], ids=["tuple", "list"])
async def test_foreign_channel_rebuilds_complete_history(async_read, location, record_type):
cache = MemoryCheckpointHistoryCache()
inner = InMemorySaver()
saver = CachedHistorySaver(inner, cache, key_prefix="channel-validation")
graph = build_history_graph(saver)
accessor = CheckpointStateAccessor.bind(graph, saver, mode="delta")
authority = CheckpointStateAccessor.bind(build_history_graph(inner), inner, mode="delta")
config = {"configurable": {"thread_id": "foreign-channel"}}
try:
# The memory backend supports sync reads even in a running loop. Keep
# both accessor entry points covered without a transport-only assertion.
graph.invoke({"items": ["input"]}, config)
target = inner.get_tuple(config)
poisoned = target
if location == "ancestor":
selected = None
for candidate in inner.list(config):
if candidate.parent_config is None:
continue
parent = inner.get_tuple(candidate.parent_config)
if parent is not None and "items" not in (parent.checkpoint.get("channel_values") or {}):
selected = candidate, parent
break
assert selected is not None, "fixture must reach recursive non-snapshot composition"
target, poisoned = selected
expected = authority.get(target.config).values
assert expected["items"]
before = inner.get_delta_channel_history(config=target.config, channels=["items"])
cache.delete_thread("channel-validation", "foreign-channel")
key = saver._key(poisoned, "items")
bad = {"writes": [("task", "items", ["INJECTED"]), record_type(("task", "foreign-channel", ["FOREIGN"]))]}
cache.set_many({key: bad})
if location == "ancestor":
assert cache.get_many([saver._key(target, "items")]) == {}
observed = await accessor.aget(target.config) if async_read else accessor.get(target.config)
assert observed.values == expected
assert cache.get_many([key])[key] != bad
assert inner.get_delta_channel_history(config=target.config, channels=["items"]) == before
misses = cache.stats().misses
observed = await accessor.aget(target.config) if async_read else accessor.get(target.config)
assert observed.values == expected
assert cache.stats().misses == misses
finally:
await cache.aclose()
def fold_text(state, writes):
return state + "".join(writes)
class StringState(TypedDict):
items: Annotated[str, DeltaChannel(fold_text, snapshot_frequency=100)]
@pytest.mark.asyncio
async def test_plain_string_seed_remains_a_warm_valid_state(monkeypatch):
transport = RedisTransport()
monkeypatch.setattr(redis_cache, "_create_client", lambda *args, **kwargs: transport)
cache = redis_cache.RedisCheckpointHistoryCache("redis://unused", serde=JsonPlusSerializer(), ttl_seconds=60)
inner = InMemorySaver()
saver = CachedHistorySaver(inner, cache, key_prefix="string-seed")
builder = StateGraph(StringState)
builder.add_node("one", lambda state: {"items": "saved"})
builder.set_entry_point("one")
builder.set_finish_point("one")
graph = builder.compile(checkpointer=saver)
accessor = CheckpointStateAccessor.bind(graph, saver, mode="delta")
config = {"configurable": {"thread_id": "string-seed"}}
try:
await graph.ainvoke({"items": "input"}, config)
expected = (await accessor.aget(config)).values
assert expected == {"items": "inputsaved"}
target = await inner.aget_tuple(config)
key = saver._key(target, "items")
await cache.aset_many({key: {"writes": [], "seed": "inputsaved"}})
misses = cache.stats().misses
for _ in range(2):
assert (await accessor.aget(config)).values == expected
assert cache.stats().misses == misses
finally:
await cache.aclose()