deer-flow/backend/tests/test_thread_operation_release_recovery.py
liyinlong 99e395aa9c
fix(runtime): recover completed thread reservations on admission (#8068)
* fix(runtime): recover completed thread reservations on admission

* Address thread reservation recovery review

* Place reservation recovery note below agent fixes

---------

Co-authored-by: liyinlong <173714821+lilong555@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-10 15:40:34 +08:00

361 lines
18 KiB
Python

"""Finished thread operations remain recoverable after a failed SQL release."""
import asyncio
import sqlite3
from contextlib import asynccontextmanager
from types import SimpleNamespace
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from starlette.requests import Request
from app.gateway.services import reserve_checkpoint_write
from deerflow.config.run_ownership_config import RunOwnershipConfig
from deerflow.persistence.run.model import CompletedRunSnapshotRow, RunChangeClockRow, RunRow
from deerflow.persistence.run.sql import RunRepository
from deerflow.runtime import RunManager, RunStatus, ThreadOperationKind
from deerflow.runtime.runs.manager import ConflictError, PersistenceRetryPolicy
from deerflow.runtime.user_context import reset_current_user, set_current_user
@asynccontextmanager
async def database(tmp_path, *, run_ownership_config=None):
path = tmp_path / "runs.sqlite"
engine = create_async_engine(f"sqlite+aiosqlite:///{path}", connect_args={"timeout": 0.01})
try:
async with engine.begin() as conn:
await conn.run_sync(lambda c: RunRow.metadata.create_all(c, tables=[RunRow.__table__, RunChangeClockRow.__table__, CompletedRunSnapshotRow.__table__]))
repository = RunRepository(async_sessionmaker(engine, expire_on_commit=False))
manager = RunManager(
store=repository,
persistence_retry_policy=PersistenceRetryPolicy(max_attempts=2, initial_delay=0),
run_ownership_config=run_ownership_config,
)
with sqlite3.connect(path) as blocker:
yield manager, repository, blocker
finally:
await engine.dispose()
async def fail_release(manager, blocker, *, kind=ThreadOperationKind.checkpoint_write, body_error=False):
try:
async with manager.reserve_thread_operation("thread", kind=kind, user_id="alice") as record:
blocker.execute("BEGIN IMMEDIATE")
if body_error:
raise ValueError("original body failure")
except ValueError:
if not body_error:
raise
return record
@pytest.mark.asyncio
@pytest.mark.parametrize("kind", [kind for kind in ThreadOperationKind if kind != ThreadOperationKind.run])
@pytest.mark.parametrize("strategy", ["reject", "interrupt", "rollback"])
async def test_unlock_recovers_finished_reservation_before_new_run(tmp_path, kind, strategy):
async with database(tmp_path) as (manager, repository, blocker):
assert not manager.heartbeat_enabled
finished = await fail_release(manager, blocker, kind=kind)
blocker.rollback()
assert (await repository.get(finished.run_id, user_id="alice"))["status"] == "pending"
token = set_current_user(SimpleNamespace(id="different-context"))
try:
successor = await manager.create_or_reject("thread", user_id="alice", multitask_strategy=strategy)
finally:
reset_current_user(token)
assert successor.status.value == "pending"
assert await repository.get(finished.run_id, user_id="alice") is None
assert [row["run_id"] for row in await repository.list_inflight()] == [successor.run_id]
@pytest.mark.asyncio
async def test_gateway_checkpoint_boundary_can_retry_after_body_and_release_fail(tmp_path):
async with database(tmp_path) as (manager, repository, blocker):
app = SimpleNamespace(state=SimpleNamespace(run_manager=manager))
request = Request({"type": "http", "app": app})
with pytest.raises(ValueError, match="original body failure"):
async with reserve_checkpoint_write(request, "thread", user_id="alice"):
blocker.execute("BEGIN IMMEDIATE")
raise ValueError("original body failure")
blocker.rollback()
async with reserve_checkpoint_write(request, "thread", user_id="alice"):
rows = await repository.list_inflight()
assert len(rows) == 1
assert rows[0]["operation_kind"] == "checkpoint_write"
assert await repository.list_inflight() == []
@pytest.mark.asyncio
@pytest.mark.parametrize("strategy", ["reject", "interrupt", "rollback"])
async def test_active_reservation_remains_exclusive(tmp_path, strategy):
async with database(tmp_path) as (manager, repository, _blocker):
peer = RunManager(store=repository)
async with manager.reserve_thread_operation("thread", kind=ThreadOperationKind.checkpoint_write, user_id="alice") as active:
for contender in (manager, peer):
with pytest.raises(ConflictError):
await contender.create_or_reject("thread", user_id="alice", multitask_strategy=strategy)
assert (await repository.get(active.run_id, user_id="alice"))["status"] == "pending"
assert await repository.list_inflight() == []
@pytest.mark.asyncio
async def test_recovery_and_concurrent_admission_share_the_thread_gate(tmp_path):
async with database(tmp_path) as (manager, repository, blocker):
finished = await fail_release(manager, blocker)
blocker.rollback()
results = await asyncio.gather(*(manager.create_or_reject("thread", user_id="alice") for _ in range(2)), return_exceptions=True)
admitted = [result for result in results if not isinstance(result, BaseException)]
assert len(admitted) == 1
assert sum(isinstance(result, ConflictError) for result in results) == 1
assert await repository.get(finished.run_id, user_id="alice") is None
assert [row["run_id"] for row in await repository.list_inflight()] == [admitted[0].run_id]
@pytest.mark.asyncio
async def test_persistent_failure_is_bounded_and_does_not_schedule_retries(tmp_path, monkeypatch):
async with database(tmp_path) as (manager, repository, blocker):
original = repository.delete_thread_operation
attempts = []
async def release(run_id, *, user_id):
attempts.append((run_id, user_id))
await original(run_id, user_id=user_id)
monkeypatch.setattr(repository, "delete_thread_operation", release)
finished = await fail_release(manager, blocker)
tasks_before = asyncio.all_tasks()
assert len(attempts) == 2
for attempt in range(2):
with pytest.raises(ConflictError):
await asyncio.wait_for(manager.create_or_reject("thread", user_id="alice"), timeout=1)
assert len(attempts) == 4 + 2 * attempt
assert asyncio.all_tasks() == tasks_before
blocker.rollback()
successor = await manager.create_or_reject("thread", user_id="alice")
assert await repository.get(finished.run_id, user_id="alice") is None
assert successor.status.value == "pending"
assert all(owner == "alice" for _run_id, owner in attempts)
@pytest.mark.asyncio
@pytest.mark.parametrize("phase", ["initial_release", "retry", "after_commit"])
async def test_cancelled_release_remains_owned_until_a_later_admission(tmp_path, monkeypatch, phase):
async with database(tmp_path) as (manager, repository, blocker):
if phase == "retry":
finished = await fail_release(manager, blocker)
blocker.rollback()
entered = asyncio.Event()
original = repository.delete_thread_operation
calls = 0
async def pause_once(run_id, *, user_id):
nonlocal calls
calls += 1
if calls == 1:
if phase == "after_commit":
await original(run_id, user_id=user_id)
entered.set()
await asyncio.Event().wait()
await original(run_id, user_id=user_id)
monkeypatch.setattr(repository, "delete_thread_operation", pause_once)
async def operation():
nonlocal finished
if phase == "retry":
return await manager.create_or_reject("thread", user_id="alice")
async with manager.reserve_thread_operation("thread", kind=ThreadOperationKind.checkpoint_write, user_id="alice") as finished:
pass
pending = asyncio.create_task(operation())
try:
await asyncio.wait_for(entered.wait(), timeout=1)
finally:
pending.cancel()
try:
await pending
except (asyncio.CancelledError, ConflictError):
pass
successor = await manager.create_or_reject("thread", user_id="alice")
assert await repository.get(finished.run_id, user_id="alice") is None
assert [row["run_id"] for row in await repository.list_inflight()] == [successor.run_id]
@pytest.mark.asyncio
async def test_shutdown_is_bounded_with_failed_release_and_startup_can_reconcile(tmp_path):
async with database(tmp_path) as (manager, repository, blocker):
finished = await fail_release(manager, blocker)
tasks_before = asyncio.all_tasks()
await asyncio.wait_for(manager.shutdown(timeout=0.01), timeout=0.2)
assert asyncio.all_tasks() == tasks_before
assert (await repository.get(finished.run_id, user_id="alice"))["status"] == "pending"
blocker.rollback()
restarted = RunManager(store=repository)
assert await restarted.reconcile_orphaned_inflight_runs(error="startup recovery") == []
successor = await restarted.create_or_reject("thread", user_id="alice")
assert successor.status.value == "pending"
@pytest.mark.asyncio
async def test_new_lease_starts_after_slow_release_retry(tmp_path, monkeypatch):
from datetime import UTC, datetime, timedelta
from deerflow.runtime.runs import manager as manager_module
async with database(tmp_path) as (manager, repository, blocker):
# Use heartbeat admission semantics; the background timer is unnecessary
# for testing the initial durable deadline after delayed recovery.
manager._run_ownership_config = RunOwnershipConfig(heartbeat_enabled=True, lease_seconds=5)
finished = await fail_release(manager, blocker)
blocker.rollback()
original = repository.delete_thread_operation
clock_time = datetime.now(UTC)
class Clock(datetime):
@classmethod
def now(cls, tz=None):
return clock_time.astimezone(tz)
async def delayed_release(run_id, *, user_id):
nonlocal clock_time
await asyncio.sleep(0)
clock_time += timedelta(seconds=10)
await original(run_id, user_id=user_id)
monkeypatch.setattr(manager_module, "datetime", Clock)
monkeypatch.setattr(repository, "delete_thread_operation", delayed_release)
try:
successor = await manager.create_or_reject("thread", user_id="alice")
row = await repository.get(successor.run_id, user_id="alice")
deadline = datetime.fromisoformat(row["lease_expires_at"])
assert deadline >= clock_time + timedelta(seconds=5)
assert successor.lease_expires_at == row["lease_expires_at"]
assert await repository.get(finished.run_id, user_id="alice") is None
finally:
for handle in manager._lease_deadlines.values():
handle.cancel()
@pytest.mark.asyncio
async def test_pending_release_conflict_remains_an_im_busy_response(tmp_path):
import httpx
from langgraph_sdk.errors import ConflictError as SDKConflictError
from app.channels.manager import _is_thread_busy_error
async with database(tmp_path) as (manager, _repository, blocker):
await fail_release(manager, blocker)
with pytest.raises(ConflictError) as failure:
await manager.create_or_reject("thread", user_id="alice")
detail = str(failure.value)
response = httpx.Response(409, request=httpx.Request("POST", "http://gateway/threads/thread/runs"))
assert _is_thread_busy_error(SDKConflictError(detail, response=response, body={"detail": detail}))
blocker.rollback()
@pytest.mark.asyncio
async def test_completed_key_replays_while_later_reservation_awaits_release(tmp_path):
async with database(tmp_path) as (manager, repository, blocker):
original = await manager.create_or_reject("thread", user_id="alice", idempotency_key="completed-request")
await manager.set_status(original.run_id, RunStatus.success)
finished = await fail_release(manager, blocker)
try:
replay = await manager.create_or_reject("thread", user_id="alice", idempotency_key="completed-request")
assert replay is original
assert replay.status == RunStatus.success
assert replay.idempotency_reused
assert (await repository.get(finished.run_id, user_id="alice"))["status"] == "pending"
finally:
blocker.rollback()
@pytest.mark.asyncio
@pytest.mark.parametrize("strategy", ["reject", "interrupt", "rollback"])
async def test_evicted_completed_key_replays_while_later_release_fails(monkeypatch, strategy):
from deerflow.runtime.runs.store.memory import MemoryRunStore
store = MemoryRunStore()
manager = RunManager(store=store)
original = await manager.create_or_reject("thread", user_id="alice", idempotency_key="completed-request")
await manager.set_status(original.run_id, RunStatus.success)
await manager.cleanup(original.run_id, delay=0)
async def unavailable_release(run_id, *, user_id):
raise RuntimeError("release unavailable")
monkeypatch.setattr(store, "delete_thread_operation", unavailable_release)
async with manager.reserve_thread_operation("thread", kind=ThreadOperationKind.checkpoint_write, user_id="alice") as finished:
pass
replay = await manager.create_or_reject("thread", user_id="alice", idempotency_key="completed-request", multitask_strategy=strategy)
assert replay.run_id == original.run_id
assert replay.status == RunStatus.success
assert replay.idempotency_reused and replay.store_only
assert (await store.get(finished.run_id))["status"] == "pending"
@pytest.mark.asyncio
@pytest.mark.parametrize("strategy", ["reject", "interrupt", "rollback"])
async def test_new_key_recovers_after_release_failure(tmp_path, strategy):
async with database(tmp_path) as (manager, repository, blocker):
finished = await fail_release(manager, blocker)
blocker.rollback()
successor = await manager.create_or_reject("thread", user_id="alice", idempotency_key="new-request", multitask_strategy=strategy)
replay = await manager.create_or_reject("thread", user_id="alice", idempotency_key="new-request", multitask_strategy=strategy)
assert replay.run_id == successor.run_id
assert replay.idempotency_reused
assert await repository.get(finished.run_id, user_id="alice") is None
assert [row["run_id"] for row in await repository.list_inflight()] == [successor.run_id]
@pytest.mark.asyncio
async def test_uncertain_release_keeps_an_active_peer_reservation(tmp_path, monkeypatch):
async with database(tmp_path) as (manager, repository, _blocker):
original = repository.delete_thread_operation
first = True
async def lose_first_acknowledgement(run_id, *, user_id):
nonlocal first
await original(run_id, user_id=user_id)
if first:
first = False
raise RuntimeError("release acknowledgement lost")
monkeypatch.setattr(repository, "delete_thread_operation", lose_first_acknowledgement)
async with manager.reserve_thread_operation("thread", kind=ThreadOperationKind.checkpoint_write, user_id="alice") as finished:
pass
assert await repository.get(finished.run_id, user_id="alice") is None
peer = RunManager(store=repository)
async with peer.reserve_thread_operation("thread", kind=ThreadOperationKind.checkpoint_write, user_id="alice") as active:
with pytest.raises(ConflictError):
await manager.create_or_reject("thread", user_id="alice")
assert [row["run_id"] for row in await repository.list_inflight()] == [active.run_id]
assert await repository.list_inflight() == []
@pytest.mark.asyncio
async def test_release_retry_after_peer_takeover_preserves_its_new_reservation(tmp_path):
ownership = RunOwnershipConfig(heartbeat_enabled=True, grace_seconds=0)
async with database(tmp_path, run_ownership_config=ownership) as (manager, repository, blocker):
peer = RunManager(store=repository, run_ownership_config=ownership)
finished = await fail_release(manager, blocker)
blocker.rollback()
assert await peer.reconcile_orphaned_inflight_runs(error="peer takeover") == []
pending = await repository.get(finished.run_id, user_id="alice")
assert pending["status"] == "pending"
assert pending["lease_expires_at"] is not None
# Expire the durable lease without waiting for the heartbeat interval.
blocker.execute("UPDATE runs SET lease_expires_at = datetime('now', '-1 minute') WHERE run_id = ?", (finished.run_id,))
blocker.commit()
assert await peer.reconcile_orphaned_inflight_runs(error="peer takeover") == []
reclaimed = await repository.get(finished.run_id, user_id="alice")
assert reclaimed["status"] == "error"
assert reclaimed["error"] == "peer takeover"
async with peer.reserve_thread_operation("thread", kind=ThreadOperationKind.checkpoint_write, user_id="alice") as active:
with pytest.raises(ConflictError):
await manager.create_or_reject("thread", user_id="alice")
assert await repository.get(finished.run_id, user_id="alice") is None
assert [row["run_id"] for row in await repository.list_inflight()] == [active.run_id]