Vanzeren e01173d8b2
bench(checkpoint): production-shaped full/delta benchmark with configurable snapshot frequency (#4467)
* feat(checkpoint): production-shaped full/delta benchmark with configurable snapshot frequency

- Group benchmark scripts into per-family folders (checkpoint/, sandbox/)
- Extract shared benchmark infrastructure into checkpoint_bench_common.py
- Add checkpoint_delta_snapshot_frequency config (default 1000, process-frozen);
  freeze it in make_lead_agent and DeerFlowClient; key the state-schema
  adaptation cache by resolved frequency
- New bench_production.py: per-case child processes run N ainvoke turns through
  the real lead-agent graph (scripted deterministic model, real AsyncSqliteSaver),
  then measure GET /state + POST /history through the real Gateway route stack
  in one event loop (httpx ASGITransport), cold/warm accessor-cache split,
  cross-mode digest gates
- New summarize_production.py: delta/full ratios plus decision metrics
  (snapshot_write_spike, cache_effect_ms, checkpoint_write_share,
  auto-discovered history per-limit ratios)

* fix(checkpoint): address production benchmark review
2026-07-27 11:47:49 +08:00

717 lines
32 KiB
Python
Executable File

#!/usr/bin/env python3
"""Production-shaped full/delta checkpoint benchmark.
Measures user-visible run, state, and history latency on the production path:
the real lead-agent graph (scripted deterministic model), a real
AsyncSqliteSaver, and the real Gateway route stack for reads. Every case runs
in a fresh child process with a fresh database, mirroring the
restart-required checkpoint mode boundary.
Examples::
PYTHONPATH=. uv run python scripts/benchmark/checkpoint/bench_production.py \
--turns 100,1000 --payload-bytes 128 \
--snapshot-frequencies 10,100,1000 \
--repetitions 7 --output production-bench.jsonl
PYTHONPATH=. uv run python scripts/benchmark/checkpoint/summarize_production.py \
production-bench.jsonl
"""
from __future__ import annotations
import argparse
import json
import sys
import tempfile
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Literal
sys.path.insert(0, str(Path(__file__).resolve().parent))
import checkpoint_bench_common as common # noqa: E402
Mode = Literal["full", "delta"]
SCHEMA_VERSION = 1
BENCHMARK_VERSION = 1
BENCHMARK_NAME = "checkpoint_production"
DEFAULT_HISTORY_LIMITS = (10, 50, 200)
DEFAULT_READ_REPETITIONS = 25
WARMUP_TURNS = 2
_MODES: tuple[Mode, ...] = ("full", "delta")
@dataclass(frozen=True)
class ProductionCase:
mode: Mode
turns: int
payload_bytes: int
snapshot_frequency: int | None
history_limits: tuple[int, ...]
read_repetitions: int
repetition: int
seed: int
def __post_init__(self) -> None:
if self.mode not in _MODES:
raise ValueError(f"unsupported mode: {self.mode!r}")
if self.turns <= WARMUP_TURNS:
raise ValueError(f"turns must exceed the {WARMUP_TURNS}-turn warm-up")
if self.payload_bytes <= 0:
raise ValueError("payload_bytes must be positive")
if self.mode == "full" and self.snapshot_frequency is not None:
raise ValueError("snapshot_frequency is a delta-only axis")
if self.mode == "delta" and (self.snapshot_frequency is None or self.snapshot_frequency <= 0):
raise ValueError("delta mode requires a positive snapshot_frequency")
if not self.history_limits or any(limit <= 0 for limit in self.history_limits):
raise ValueError("history_limits must be positive")
if self.read_repetitions <= 0:
raise ValueError("read_repetitions must be positive")
if self.repetition < 0:
raise ValueError("repetition must be non-negative")
def expand_cases(
*,
modes: list[str],
turn_counts: list[int],
payload_bytes: list[int],
snapshot_frequencies: list[int],
repetitions: int,
seed: int = 1,
history_limits: tuple[int, ...] = DEFAULT_HISTORY_LIMITS,
read_repetitions: int = DEFAULT_READ_REPETITIONS,
) -> list[ProductionCase]:
"""Build the matrix with alternating mode order to reduce order bias."""
cases: list[ProductionCase] = []
group_index = 0
for repetition in range(repetitions):
for payload in payload_bytes:
for turns in turn_counts:
ordered_modes = list(modes)
if group_index % 2 == 1:
ordered_modes.reverse()
for mode in ordered_modes:
frequencies: list[int | None] = snapshot_frequencies if mode == "delta" else [None]
for frequency in frequencies:
cases.append(
ProductionCase(
mode=mode, # type: ignore[arg-type]
turns=turns,
payload_bytes=payload,
snapshot_frequency=frequency,
history_limits=history_limits,
read_repetitions=read_repetitions,
repetition=repetition,
seed=seed,
)
)
group_index += 1
return cases
def _base_row(case: ProductionCase) -> dict:
import importlib.metadata
import os
import platform
git_sha = os.environ.get(common.GIT_SHA_ENV) or common.resolve_git_sha()
try:
langgraph_version = importlib.metadata.version("langgraph")
except importlib.metadata.PackageNotFoundError:
langgraph_version = "unknown"
return {
"schema_version": SCHEMA_VERSION,
"benchmark_version": BENCHMARK_VERSION,
"benchmark": BENCHMARK_NAME,
"success": True,
"error": None,
"profiled": False,
"git_sha": git_sha,
"python_version": platform.python_version(),
"langgraph_version": langgraph_version,
"platform": platform.platform(),
"mode": case.mode,
"snapshot_frequency": case.snapshot_frequency,
"turns": case.turns,
"payload_bytes": case.payload_bytes,
"history_limits": list(case.history_limits),
"read_repetitions": case.read_repetitions,
"repetition": case.repetition,
"seed": case.seed,
}
def _failure_row(case: ProductionCase, error: str) -> dict:
row = _base_row(case)
row["success"] = False
row["error"] = common.safe_error(error)
return row
def _comparison_key(row: dict) -> tuple:
# snapshot_frequency is intentionally excluded: every delta frequency
# pairs against the same full row, and all frequencies must agree on
# the materialized digests.
return (row.get("turns"), row.get("payload_bytes"), row.get("repetition"))
def validate_cross_mode_rows(rows: list[dict]) -> None:
grouped: dict[tuple, list[dict]] = {}
for row in rows:
grouped.setdefault(_comparison_key(row), []).append(row)
for group in grouped.values():
successful = [row for row in group if row.get("success")]
modes = {row.get("mode") for row in successful}
if not {"full", "delta"}.issubset(modes):
continue
signatures = {(row.get("message_count"), row.get("seed_content_sha256"), row.get("state_wire_sha256"), row.get("history_wire_sha256")) for row in successful}
if len(signatures) == 1:
continue
for row in successful:
row["success"] = False
row["error"] = "cross-mode materialized state mismatch"
def _profile_filename(case: ProductionCase) -> str:
return f"production-{case.mode}-turns-{case.turns}-payload-{case.payload_bytes}-freq-{case.snapshot_frequency}-rep-{case.repetition}.prof"
def _worker_main(encoded_case: str, *, profile_path: Path | None = None) -> int:
try:
raw = json.loads(encoded_case)
history_limits = raw.get("history_limits")
if history_limits is not None:
raw["history_limits"] = tuple(history_limits)
case = ProductionCase(**raw)
except (TypeError, ValueError) as exc:
print(json.dumps({"schema_version": SCHEMA_VERSION, "benchmark_version": BENCHMARK_VERSION, "benchmark": BENCHMARK_NAME, "success": False, "error": common.safe_error(exc)}, separators=(",", ":")))
return 2
with tempfile.TemporaryDirectory(prefix="deerflow-checkpoint-production-") as temp_dir:
if profile_path is None:
row = _run_case(case, work_dir=Path(temp_dir))
else:
row = common.run_profiled(_run_case, case, work_dir=Path(temp_dir), profile_path=profile_path)
print(json.dumps(row, ensure_ascii=False, separators=(",", ":")))
return 0 if row.get("success") else 1
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Production-shaped full/delta checkpoint benchmark")
parser.add_argument("--modes", default="full,delta")
parser.add_argument("--turns", default="10,100,500,1000,2000")
parser.add_argument("--payload-bytes", default="128")
parser.add_argument("--snapshot-frequencies", default="10,50,100,500,1000")
parser.add_argument("--history-limits", default=",".join(str(v) for v in DEFAULT_HISTORY_LIMITS))
parser.add_argument("--read-repetitions", type=int, default=DEFAULT_READ_REPETITIONS)
parser.add_argument("--repetitions", type=int, default=3)
parser.add_argument("--seed", type=int, default=1)
parser.add_argument("--timeout-seconds", type=float, default=900)
parser.add_argument("--profile-dir", type=Path)
parser.add_argument("--output", type=Path)
parser.add_argument("--worker-case", help=argparse.SUPPRESS)
parser.add_argument("--worker-profile", type=Path, help=argparse.SUPPRESS)
return parser
def main(argv: list[str] | None = None) -> int:
parser = _build_parser()
args = parser.parse_args(argv)
if args.worker_case is not None:
return _worker_main(args.worker_case, profile_path=args.worker_profile)
if args.output is None:
parser.error("--output is required")
try:
modes = common.parse_choice_csv(args.modes, option="--modes", choices=_MODES)
turn_counts = common.parse_positive_int_csv(args.turns, option="--turns")
payload_bytes = common.parse_positive_int_csv(args.payload_bytes, option="--payload-bytes")
frequencies = common.parse_positive_int_csv(args.snapshot_frequencies, option="--snapshot-frequencies")
history_limits = tuple(common.parse_positive_int_csv(args.history_limits, option="--history-limits"))
if args.repetitions <= 0 or args.read_repetitions <= 0 or args.timeout_seconds <= 0:
raise ValueError("repetition and timeout values must be positive")
except ValueError as exc:
parser.error(str(exc))
cases = expand_cases(
modes=modes,
turn_counts=turn_counts,
payload_bytes=payload_bytes,
snapshot_frequencies=frequencies,
repetitions=args.repetitions,
seed=args.seed,
history_limits=history_limits,
read_repetitions=args.read_repetitions,
)
git_sha = common.resolve_git_sha()
rows: list[dict] = []
for index, case in enumerate(cases, start=1):
print(
f"[{index}/{len(cases)}] {case.mode} turns={case.turns} payload={case.payload_bytes} freq={case.snapshot_frequency} repetition={case.repetition}",
file=sys.stderr,
)
worker_args = ["--worker-case", json.dumps(asdict(case), separators=(",", ":"))]
if args.profile_dir is not None:
worker_args.extend(["--worker-profile", str(args.profile_dir / _profile_filename(case))])
rows.append(
common.run_child_case(
script=Path(__file__).resolve(),
worker_args=worker_args,
failure_row=lambda error, c=case: _failure_row(c, error),
timeout_seconds=args.timeout_seconds,
git_sha=git_sha,
)
)
validate_cross_mode_rows(rows)
args.output.parent.mkdir(parents=True, exist_ok=True)
with args.output.open("w", encoding="utf-8") as output_file:
for row in rows:
output_file.write(json.dumps(row, ensure_ascii=False, separators=(",", ":")) + "\n")
failures = sum(1 for row in rows if not row.get("success"))
print(f"Wrote {len(rows)} result row(s) to {args.output}; failures={failures}", file=sys.stderr)
return 1 if failures else 0
# ---------------------------------------------------------------------------
# Worker: production-shaped run + read phases (single event loop)
# ---------------------------------------------------------------------------
import asyncio # noqa: E402
import hashlib # noqa: E402
import time # noqa: E402
from collections import defaultdict # noqa: E402
from typing import Any # noqa: E402
from unittest.mock import patch # noqa: E402
import httpx # noqa: E402
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel # noqa: E402
from langchain_core.messages import AIMessage, HumanMessage # noqa: E402
from langchain_core.outputs import ChatGeneration, ChatResult # noqa: E402
class _ScriptedBenchModel(FakeMessagesListChatModel):
"""Deterministic model: call k returns a fixed-size AIMessage.
Content and IDs depend only on the call index, so full and delta modes
produce byte-identical message streams. Serves the ainvoke path via
BaseChatModel's default ``_agenerate`` fallback (``run_in_executor`` over
``_generate``); streaming is not supported.
"""
def __init__(self, *, payload_bytes: int) -> None:
super().__init__(responses=[])
self._bench_payload_bytes = payload_bytes
self._bench_call_index = 0
def bind_tools(self, tools: Any, **kwargs: Any) -> Any:
return self
def _generate(self, messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any) -> ChatResult:
index = self._bench_call_index
self._bench_call_index += 1
message = AIMessage(id=f"bench-ai-{index:08d}", content="x" * self._bench_payload_bytes)
return ChatResult(generations=[ChatGeneration(message=message)])
class _TimingSaver:
"""Delegating saver wrapper recording write timing.
LangGraph issues ``aput``/``aput_writes`` as concurrent asyncio tasks, so
per-call latencies overlap (queueing on the single sqlite connection is
counted in every concurrent call). Summing them double-counts and can
exceed the turn wall clock. ``intervals`` keeps absolute (start, end) per
call so the run phase can compute the merged busy time — the honest,
wall-bounded write cost. ``timings_ms`` remains the cumulative-per-method
ledger used for read-path attribution.
"""
def __init__(self, saver: Any) -> None:
self._saver = saver
self.timings_ms: dict[str, float] = defaultdict(float)
self.intervals: list[tuple[str, float, float]] = []
def __getattr__(self, name: str) -> Any:
return getattr(self._saver, name)
async def _timed(self, label: str, fn: Any, *args: Any, **kwargs: Any) -> Any:
start = time.perf_counter()
try:
return await fn(*args, **kwargs)
finally:
end = time.perf_counter()
self.timings_ms[label] += (end - start) * 1000
self.intervals.append((label, start * 1000, end * 1000))
async def aput(self, *args: Any, **kwargs: Any) -> Any:
return await self._timed("aput", self._saver.aput, *args, **kwargs)
async def aput_writes(self, *args: Any, **kwargs: Any) -> Any:
return await self._timed("aput_writes", self._saver.aput_writes, *args, **kwargs)
async def aget_tuple(self, *args: Any, **kwargs: Any) -> Any:
return await self._timed("aget_tuple", self._saver.aget_tuple, *args, **kwargs)
def alist(self, *args: Any, **kwargs: Any) -> Any:
# alist is an async generator on real savers; wrap it to time the
# full iteration when consumed.
agen = self._saver.alist(*args, **kwargs)
timing = self.timings_ms
async def _wrapped() -> Any:
start_total = time.perf_counter()
try:
async for item in agen:
yield item
finally:
timing["alist"] += (time.perf_counter() - start_total) * 1000
return _wrapped()
def _thread_id(case: ProductionCase) -> str:
return f"checkpoint-production-{case.seed}-{case.repetition}"
async def _run_phase(case: ProductionCase, saver: Any, timing: _TimingSaver, work_dir: Path) -> dict[str, Any]:
"""Seed the thread through the real lead-agent graph; measure per-turn latency."""
from deerflow.agents.lead_agent.agent import make_lead_agent
from deerflow.config.app_config import AppConfig, set_app_config
from deerflow.runtime.checkpoint_mode import inject_checkpoint_mode
app_config = AppConfig.model_validate(
{
# Mirrors tests/test_lead_agent_model_resolution.py::_make_model:
# `use` is a required ModelConfig field, so a provider/api_key
# style entry does not validate.
"models": [
{
"name": "bench-model",
"display_name": "bench-model",
"description": None,
"use": "langchain_openai:ChatOpenAI",
"model": "bench",
"supports_thinking": False,
"supports_vision": False,
}
],
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
# Title and memory middleware would fire background LLM calls and
# debounce timers; both pollute per-turn latency, so disable them.
"title": {"enabled": False},
"memory": {"enabled": False},
"database": {
"backend": "sqlite",
"sqlite_dir": str(work_dir),
"checkpoint_channel_mode": case.mode,
"checkpoint_delta_snapshot_frequency": case.snapshot_frequency or 1000,
},
}
)
set_app_config(app_config)
model = _ScriptedBenchModel(payload_bytes=case.payload_bytes)
def _fake_create_chat_model(**kwargs: Any) -> Any:
return model
with patch("deerflow.agents.lead_agent.agent.create_chat_model", _fake_create_chat_model):
graph = make_lead_agent({"configurable": {}})
# Attach the timed saver post-construction, mirroring the run worker
# (deerflow.runs.worker assigns ``agent.checkpointer`` the same way), so
# every checkpoint write flows through ``timing``.
graph.checkpointer = timing
config: dict[str, Any] = {"configurable": {"thread_id": _thread_id(case)}}
inject_checkpoint_mode(config, case.mode)
turn_ms: list[float] = []
write_ms: list[float] = []
write_methods = {"aput", "aput_writes"}
for turn in range(case.turns):
intervals_before = len(timing.intervals)
start = time.perf_counter()
await graph.ainvoke(
{"messages": [HumanMessage(id=f"bench-h-{turn:08d}", content="y" * case.payload_bytes)]},
config,
)
turn_ms.append((time.perf_counter() - start) * 1000)
write_ms.append(_merged_busy_ms(iv for iv in timing.intervals[intervals_before:] if iv[0] in write_methods))
return {"graph": graph, "config": config, "turn_ms": turn_ms[WARMUP_TURNS:], "write_ms": write_ms[WARMUP_TURNS:]}
def _merged_busy_ms(intervals: Any) -> float:
"""Union of (start, end) write intervals in ms — wall-bounded write cost.
Concurrent write tasks overlap; merging their intervals yields the time
the event loop was actually inside aput/aput_writes, which cannot exceed
the turn wall clock the way a naive latency sum does.
"""
merged: list[list[float]] = []
for _label, start, end in sorted(intervals, key=lambda iv: iv[1]):
if merged and start <= merged[-1][1]:
if end > merged[-1][1]:
merged[-1][1] = end
else:
merged.append([start, end])
return sum(end - start for start, end in merged)
# ``ThreadHistoryRequest.limit`` is validated with ``le=100``: the production
# route cannot serve a larger page. Configured history limits above the cap
# are measured at the cap and labelled by the *effective* limit so no metric
# field ever claims a request the API would reject.
_HISTORY_ROUTE_MAX_LIMIT = 100
def _wire_messages_digest(messages: Any) -> str:
"""Digest wire-format messages, excluding volatile checkpoint ids/timestamps."""
canonical = [[message.get("type"), message.get("id"), message.get("content") if isinstance(message.get("content"), str) else json.dumps(message.get("content"), sort_keys=True)] for message in messages or []]
payload = json.dumps(canonical, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def _combined_digest(digests: list[str]) -> str:
return hashlib.sha256("".join(digests).encode("utf-8")).hexdigest()
def _make_gateway_app(saver: Any, mode: str, store: Any) -> Any:
"""Minimal authed gateway app, mirroring tests/_router_auth_helpers.py.
Reimplemented inline: benchmark scripts must not import from tests/.
"""
from unittest.mock import MagicMock
from uuid import uuid4
from fastapi import FastAPI, Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from app.gateway.auth.models import User
from app.gateway.authz import AuthContext, Permissions
from app.gateway.routers import threads
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
from deerflow.runtime.user_context import reset_current_user, set_current_user
permissions = [Permissions.THREADS_READ, Permissions.THREADS_WRITE, Permissions.THREADS_DELETE]
class _StubAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Any) -> Response:
user = User(email="bench@example.com", password_hash="x", system_role="user", id=uuid4())
request.state.user = user
request.state.auth = AuthContext(user=user, permissions=list(permissions))
# Mirror the production AuthMiddleware: the user contextvar must be
# set or thread-meta lookups hit user_id=AUTO and raise per request.
token = set_current_user(user)
try:
return await call_next(request)
finally:
reset_current_user(token)
app = FastAPI()
app.add_middleware(_StubAuthMiddleware)
app.state.store = store
app.state.checkpointer = saver
app.state.thread_store = MemoryThreadMetaStore(store)
app.state.checkpoint_channel_mode = mode
app.state.run_event_store = MagicMock()
app.include_router(threads.router)
return app
async def _read_phase(case: ProductionCase, saver: Any, timing: _TimingSaver) -> dict[str, Any]:
"""Measure state/history endpoint latency through the real route stack.
Runs in the same event loop as the run phase (the AsyncSqliteSaver is
bound to its creating loop), driving the real threads router over an
ASGI transport. ``saver`` must be the RAW saver, not the timing
wrapper: ``Pregel.aget_state`` gates delta replay behind
``isinstance(self.checkpointer, BaseCheckpointSaver)``
(langgraph/pregel/main.py), so the duck-typed ``_TimingSaver``
silently materializes delta channels as empty. The wrapper therefore
stays on the run phase only and the row records
``saver_read_attribution: False``; the ``*_saver_ms`` fields remain in
the schema but report 0.0.
"""
from langgraph.store.memory import InMemoryStore
from app.gateway import services as gateway_services
store = InMemoryStore()
app = _make_gateway_app(saver, case.mode, store)
graph_build_ms: list[float] = []
original_resolve = gateway_services.resolve_agent_factory
real_factory = original_resolve(None)
def _timed_factory(config: Any = None) -> Any:
start = time.perf_counter()
graph = real_factory(config)
graph_build_ms.append((time.perf_counter() - start) * 1000)
return graph
# The accessor graph cache is keyed (assistant_id, mode); the factory
# identity check compares the callable, so one stable wrapper instance.
gateway_services.resolve_agent_factory = lambda assistant_id=None: _timed_factory
# The accessor graph is compiled lazily inside the first request and
# rebuilds the lead agent, so the scripted model must be patched in for
# the whole read phase, exactly as in _run_phase.
model = _ScriptedBenchModel(payload_bytes=case.payload_bytes)
def _fake_create_chat_model(**kwargs: Any) -> Any:
return model
thread_id = _thread_id(case)
transport = httpx.ASGITransport(app=app)
result: dict[str, Any] = {}
try:
with patch("deerflow.agents.lead_agent.agent.create_chat_model", _fake_create_chat_model):
async with httpx.AsyncClient(transport=transport, base_url="http://bench") as client:
async def _timed_request(label: str, method: str, url: str, **kwargs: Any) -> tuple[Any, float, float]:
del label # timing only; labels aid debugging failures
saver_before = timing.timings_ms["aget_tuple"] + timing.timings_ms["alist"]
start = time.perf_counter()
response = await client.request(method, url, **kwargs)
elapsed_ms = (time.perf_counter() - start) * 1000
# The read phase drives the RAW saver (see docstring), so
# the timing wrapper never advances here: saver_ms — and
# thus every read-path *_saver_ms field — is structurally 0.0.
saver_ms = timing.timings_ms["aget_tuple"] + timing.timings_ms["alist"] - saver_before
response.raise_for_status()
return response, elapsed_ms, saver_ms
# --- state endpoint: one cold sample after clearing the accessor cache
gateway_services._state_accessor_graph_cache.clear()
graph_build_ms.clear()
response, state_cold_ms, state_cold_saver_ms = await _timed_request("state-cold", "GET", f"/api/threads/{thread_id}/state")
# Cold/warm contract, asserted structurally: the cold request
# MUST build the accessor graph exactly once (otherwise the
# cold sample did not exercise the cold path), and warm
# requests MUST NOT build again (otherwise the cache is not
# hit and warm samples silently measure the cold path).
if len(graph_build_ms) != 1:
raise AssertionError(f"state cold read triggered {len(graph_build_ms)} accessor graph builds; expected exactly 1")
cold_graph_build_ms = graph_build_ms[0]
state_payload = response.json()
state_digest = _wire_messages_digest((state_payload.get("values") or {}).get("messages"))
state_warm: list[float] = []
for _ in range(case.read_repetitions):
_response, elapsed_ms, _saver_ms = await _timed_request("state-warm", "GET", f"/api/threads/{thread_id}/state")
state_warm.append(elapsed_ms)
if len(graph_build_ms) != 1:
raise AssertionError("state warm reads triggered an accessor graph rebuild; cache was not hit")
# --- history endpoint per limit, same cold/warm split
history_metrics: dict[str, Any] = {}
history_digests: list[str] = []
effective_limits = list(dict.fromkeys(min(limit, _HISTORY_ROUTE_MAX_LIMIT) for limit in case.history_limits))
for limit_index, limit in enumerate(effective_limits):
gateway_services._state_accessor_graph_cache.clear()
response, cold_ms, cold_saver_ms = await _timed_request(
f"history-cold-{limit}",
"POST",
f"/api/threads/{thread_id}/history",
json={"limit": limit},
)
expected_builds = 2 + limit_index
if len(graph_build_ms) != expected_builds:
raise AssertionError(f"history cold read (limit={limit}) triggered {len(graph_build_ms)} total accessor graph builds; expected {expected_builds}")
entries = response.json()
digest_source: list[Any] = []
for entry in entries:
digest_source.extend((entry.get("values") or {}).get("messages") or [])
history_digests.append(_wire_messages_digest(digest_source))
warm: list[float] = []
for _ in range(case.read_repetitions):
_r, elapsed_ms, _s = await _timed_request(
f"history-warm-{limit}",
"POST",
f"/api/threads/{thread_id}/history",
json={"limit": limit},
)
warm.append(elapsed_ms)
if len(graph_build_ms) != expected_builds:
raise AssertionError(f"history warm reads (limit={limit}) triggered an accessor graph rebuild; cache was not hit")
history_metrics[f"history_limit_{limit}_cold_ms"] = cold_ms
history_metrics[f"history_limit_{limit}_cold_saver_ms"] = cold_saver_ms
history_metrics[f"history_limit_{limit}_warm_p50_ms"] = common.percentile(warm, 50)
history_metrics[f"history_limit_{limit}_warm_p95_ms"] = common.percentile(warm, 95)
result.update(
{
"state_cold_ms": state_cold_ms,
"state_cold_saver_ms": state_cold_saver_ms,
"state_cold_graph_build_ms": cold_graph_build_ms,
"state_warm_p50_ms": common.percentile(state_warm, 50),
"state_warm_p95_ms": common.percentile(state_warm, 95),
"state_wire_sha256": state_digest,
"history_wire_sha256": _combined_digest(history_digests),
"saver_read_attribution": False,
**history_metrics,
}
)
finally:
gateway_services.resolve_agent_factory = original_resolve
return result
def _storage_file_stats(db_path: Path) -> dict[str, Any]:
return {
"db_bytes": common.file_size(db_path),
"wal_bytes": common.file_size(Path(f"{db_path}-wal")),
"shm_bytes": common.file_size(Path(f"{db_path}-shm")),
}
def _run_case(case: ProductionCase, *, work_dir: Path) -> dict:
"""Run one benchmark case: run phase + read phase in a single event loop."""
row = _base_row(case)
db_path = work_dir / "production-benchmark.sqlite"
async def _async_body() -> dict[str, Any]:
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
async with AsyncSqliteSaver.from_conn_string(str(db_path)) as saver:
await saver.setup()
timing = _TimingSaver(saver)
run_info = await _run_phase(case, saver, timing, work_dir)
# Seed correctness: materialize through the mode-matched accessor.
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor
accessor = CheckpointStateAccessor.bind(run_info["graph"], saver, mode=case.mode)
snapshot = await accessor.aget(run_info["config"])
messages = list(snapshot.values.get("messages", []))
seed_digest = common.canonical_messages_digest(messages)
read_metrics = await _read_phase(case, saver, timing)
storage_metrics = _storage_file_stats(db_path)
metrics = {
"run_turn_p50_ms": common.percentile(run_info["turn_ms"], 50),
"run_turn_p95_ms": common.percentile(run_info["turn_ms"], 95),
"checkpoint_write_p50_ms": common.percentile(run_info["write_ms"], 50),
"checkpoint_write_p95_ms": common.percentile(run_info["write_ms"], 95),
"checkpoint_write_p99_ms": common.percentile(run_info["write_ms"], 99),
"message_count": len(messages),
"seed_content_sha256": seed_digest,
"peak_rss_bytes": common.peak_rss_bytes(),
**storage_metrics,
**read_metrics,
}
return metrics
try:
row.update(asyncio.run(_async_body()))
except Exception as exc:
row["success"] = False
row["error"] = common.safe_error(exc, work_dir=work_dir)
return row
if __name__ == "__main__":
raise SystemExit(main())