deer-flow/backend/tests/test_run_evidence_reader.py
Xuehao Xu f0cb67b223
feat(extensions): expose incremental run evidence reader (#5405)
* feat(extensions): expose incremental run evidence reader

* fix(extensions): address run evidence review feedback

* docs(extensions): clarify run deletion reconciliation

* docs(migrations): align current head documentation

* fix(extensions): isolate run evidence event reads

* test: avoid pinning run change migration to latest head

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-16 21:18:05 +08:00

344 lines
14 KiB
Python

from __future__ import annotations
import asyncio
from types import SimpleNamespace
import pytest
from deerflow_extension_api import InvalidRunEvidenceCursor
from deerflow.extensions.run_evidence import StoreRunEvidenceReader
from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.runs.store.memory import MemoryRunStore
async def _put_run(store: MemoryRunStore, run_id: str, thread_id: str, *, user_id: str = "user-1") -> None:
await store.put(run_id, thread_id=thread_id, user_id=user_id)
@pytest.mark.asyncio
async def test_changed_run_cursor_pages_without_loss_and_replays_updates():
runs = MemoryRunStore()
events = MemoryRunEventStore()
reader = StoreRunEvidenceReader(runs, events, user_id="user-1")
await _put_run(runs, "run-a", "thread-a")
await _put_run(runs, "run-b", "thread-b")
first = await reader.list_changed_runs(cursor=None, limit=1)
assert [item.run_id for item in first.items] == ["run-a"]
assert first.has_more is True
assert first.next_cursor
await runs.update_status("run-a", "running")
second = await reader.list_changed_runs(cursor=first.next_cursor, limit=10)
assert [item.run_id for item in second.items] == ["run-b", "run-a"]
assert second.has_more is False
@pytest.mark.asyncio
async def test_changed_run_reader_is_bound_to_one_user():
runs = MemoryRunStore()
events = MemoryRunEventStore()
await _put_run(runs, "mine", "thread-1", user_id="user-1")
await _put_run(runs, "theirs", "thread-2", user_id="user-2")
page = await StoreRunEvidenceReader(runs, events, user_id="user-1").list_changed_runs(cursor=None, limit=10)
assert [item.run_id for item in page.items] == ["mine"]
@pytest.mark.asyncio
async def test_event_pages_resume_and_status_is_authoritative():
runs = MemoryRunStore()
events = MemoryRunEventStore()
await _put_run(runs, "run-a", "thread-a")
await runs.update_status("run-a", "error", error="failed")
for index in range(3):
await events.put(
thread_id="thread-a",
run_id="run-a",
event_type="test.event",
category="trace",
content={"index": index},
)
reader = StoreRunEvidenceReader(runs, events, user_id="user-1")
first = await reader.list_run_events(thread_id="thread-a", run_id="run-a", after_seq=None, limit=2)
assert [item.content for item in first.items] == [{"index": 0}, {"index": 1}]
assert first.has_more is True
second = await reader.list_run_events(
thread_id="thread-a",
run_id="run-a",
after_seq=first.next_after_seq,
limit=2,
)
assert [item.content for item in second.items] == [{"index": 2}]
assert second.has_more is False
status = await reader.get_run_status(thread_id="thread-a", run_id="run-a")
assert status is not None
assert status.status == "error"
assert status.error == "failed"
@pytest.mark.asyncio
@pytest.mark.parametrize("event_backend", ["memory", "jsonl", "db"])
async def test_event_views_detach_nested_payloads_from_store(tmp_path, event_backend):
engine = None
runs = MemoryRunStore()
if event_backend == "memory":
events = MemoryRunEventStore()
elif event_backend == "jsonl":
from deerflow.runtime.events.store.jsonl import JsonlRunEventStore
events = JsonlRunEventStore(tmp_path / "events")
else:
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from deerflow.persistence.base import Base
from deerflow.runtime.events.store.db import DbRunEventStore
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'events.db'}")
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
events = DbRunEventStore(async_sessionmaker(engine, expire_on_commit=False))
await _put_run(runs, "run-a", "thread-a")
try:
await events.put(
thread_id="thread-a",
run_id="run-a",
event_type="test.event",
category="trace",
content={"text": ["original"]},
metadata={"custom": {"value": "original"}},
)
reader = StoreRunEvidenceReader(runs, events, user_id=None)
page = await reader.list_run_events(thread_id="thread-a", run_id="run-a", after_seq=None, limit=10)
page.items[0].content["text"][0] = "changed"
page.items[0].metadata["custom"]["value"] = "changed"
reread = await reader.list_run_events(thread_id="thread-a", run_id="run-a", after_seq=None, limit=10)
stored = await events.list_events("thread-a", "run-a", user_id=None)
assert reread.items[0].content == {"text": ["original"]}
assert reread.items[0].metadata["custom"] == {"value": "original"}
assert stored[0]["content"] == {"text": ["original"]}
assert stored[0]["metadata"]["custom"] == {"value": "original"}
finally:
if engine is not None:
await engine.dispose()
@pytest.mark.asyncio
async def test_db_event_reads_use_reader_scope_not_ambient_user(tmp_path):
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from deerflow.persistence.base import Base
from deerflow.persistence.run import RunRepository
from deerflow.runtime import user_context
from deerflow.runtime.events.store.db import DbRunEventStore
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'scoped-events.db'}")
try:
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False)
runs = RunRepository(factory)
events = DbRunEventStore(factory)
await runs.put("run-a", thread_id="thread-a", user_id="alice")
alice_token = user_context.set_current_user(SimpleNamespace(id="alice"))
try:
await events.put(
thread_id="thread-a",
run_id="run-a",
event_type="test.event",
category="trace",
content={"owner": "alice"},
)
finally:
user_context.reset_current_user(alice_token)
for ambient_user in (None, SimpleNamespace(id="bob")):
ambient_token = user_context._current_user.set(ambient_user)
try:
for reader_user in (None, "alice"):
reader = StoreRunEvidenceReader(runs, events, user_id=reader_user)
page = await reader.list_run_events(
thread_id="thread-a",
run_id="run-a",
after_seq=None,
limit=10,
)
assert [item.content for item in page.items] == [{"owner": "alice"}]
finally:
user_context._current_user.reset(ambient_token)
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_reader_hides_runs_outside_scope_and_rejects_cursor_from_another_scope():
runs = MemoryRunStore()
events = MemoryRunEventStore()
await _put_run(runs, "run-a", "thread-a", user_id="user-1")
first = await StoreRunEvidenceReader(runs, events, user_id="user-1").list_changed_runs(cursor=None, limit=1)
other = StoreRunEvidenceReader(runs, events, user_id="user-2")
with pytest.raises(InvalidRunEvidenceCursor, match="cursor scope"):
await other.list_changed_runs(cursor=first.next_cursor, limit=1)
assert await other.get_run_status(thread_id="thread-a", run_id="run-a") is None
assert (await other.list_run_events(thread_id="thread-a", run_id="run-a", after_seq=None, limit=10)).items == ()
@pytest.mark.asyncio
async def test_memory_progress_updates_do_not_advance_changed_run_cursor():
runs = MemoryRunStore()
reader = StoreRunEvidenceReader(runs, MemoryRunEventStore(), user_id="user-1")
await _put_run(runs, "run-a", "thread-a")
await runs.start_run("run-a")
before_progress = await reader.list_changed_runs(cursor=None, limit=10)
await runs.update_run_progress("run-a", total_tokens=10, last_ai_message="working")
after_progress = await reader.list_changed_runs(cursor=before_progress.next_cursor, limit=10)
assert after_progress.items == ()
assert after_progress.next_cursor == before_progress.next_cursor
@pytest.mark.asyncio
async def test_sql_changed_run_cursor_survives_repository_restart(tmp_path):
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.persistence.run import RunRepository
url = f"sqlite+aiosqlite:///{tmp_path / 'evidence.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
try:
repo = RunRepository(get_session_factory())
await repo.put("run-a", thread_id="thread-a", user_id="user-1")
reader = StoreRunEvidenceReader(repo, MemoryRunEventStore(), user_id="user-1")
first = await reader.list_changed_runs(cursor=None, limit=1)
await repo.put("run-b", thread_id="thread-b", user_id="user-1")
cursor = first.next_cursor
finally:
await close_engine()
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
try:
restarted = StoreRunEvidenceReader(
RunRepository(get_session_factory()),
MemoryRunEventStore(),
user_id="user-1",
)
page = await restarted.list_changed_runs(cursor=cursor, limit=10)
assert [item.run_id for item in page.items] == ["run-b"]
finally:
await close_engine()
@pytest.mark.asyncio
async def test_sql_progress_updates_do_not_advance_changed_run_cursor(tmp_path):
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from deerflow.persistence.base import Base
from deerflow.persistence.run import RunRepository
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'progress.db'}")
try:
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
repo = RunRepository(async_sessionmaker(engine, expire_on_commit=False))
await repo.put("run-a", thread_id="thread-a", user_id="user-1")
await repo.start_run("run-a")
reader = StoreRunEvidenceReader(repo, MemoryRunEventStore(), user_id="user-1")
before_progress = await reader.list_changed_runs(cursor=None, limit=10)
await repo.update_run_progress("run-a", total_tokens=10, last_ai_message="working")
after_progress = await reader.list_changed_runs(cursor=before_progress.next_cursor, limit=10)
assert after_progress.items == ()
assert after_progress.next_cursor == before_progress.next_cursor
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_sql_atomic_interrupt_allocates_before_run_lock_and_shares_position(tmp_path):
from sqlalchemy import event
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from deerflow.persistence.base import Base
from deerflow.persistence.run import RunRepository
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'atomic.db'}")
try:
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
repo = RunRepository(async_sessionmaker(engine, expire_on_commit=False))
await repo.put(
"run-old",
thread_id="thread-a",
user_id="user-1",
status="running",
owner_worker_id="worker-1",
)
statements: list[str] = []
@event.listens_for(engine.sync_engine, "before_cursor_execute")
def capture_statement(_connection, _cursor, statement, _parameters, _context, _executemany):
statements.append(" ".join(statement.lower().split()))
new_run, claimed = await repo.create_thread_operation_atomic(
"run-new",
thread_id="thread-a",
user_id="user-1",
owner_worker_id="worker-1",
lease_expires_at=None,
multitask_strategy="interrupt",
)
assert [run["run_id"] for run in claimed] == ["run-old"]
assert claimed[0]["change_seq"] == new_run["change_seq"]
clock_update = next(index for index, statement in enumerate(statements) if statement.startswith("update run_change_clock"))
run_lock_query = next(index for index, statement in enumerate(statements) if statement.startswith("select runs."))
assert clock_update < run_lock_query
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_sql_change_positions_are_unique_across_concurrent_threads(tmp_path):
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from deerflow.persistence.base import Base
from deerflow.persistence.run import RunRepository
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'concurrent.db'}")
try:
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False)
repositories = [RunRepository(factory) for _ in range(12)]
await asyncio.gather(
*(
repository.put(
f"run-{index:02d}",
thread_id=f"thread-{index:02d}",
user_id="user-1",
)
for index, repository in enumerate(repositories)
)
)
rows = await repositories[0].list_changed(
after_change_seq=-1,
after_run_id="",
user_id="user-1",
limit=20,
)
positions = [row["change_seq"] for row in rows]
assert len(rows) == 12
assert positions == sorted(positions)
assert len(set(positions)) == len(positions)
finally:
await engine.dispose()