fix(runs): terminate orphaned streams after lease recovery (#4420)

* fix(runs): terminate orphaned streams after lease recovery

* fix(runs): include recovered ids in callback warnings

* fix(runs): harden orphan recovery lifecycle
This commit is contained in:
H Haidong 2026-07-24 19:34:20 +08:00 committed by GitHub
parent a4ede80deb
commit c7538cfb35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 820 additions and 49 deletions

View File

@ -275,7 +275,7 @@ Browser login uses `HttpOnly` session cookies. The login page offers a "keep me
DeerFlow still uses `Forwarded` / `X-Forwarded-*` headers to recover the browser-facing scheme and origin behind a proxy. The bundled nginx sets `X-Forwarded-Proto`, but preserves an upstream HTTPS value and does not overwrite every forwarded header. Configure the outer trusted proxy to replace or strip client-supplied forwarding headers before traffic reaches DeerFlow.
> [!IMPORTANT]
> The Gateway still owns active run tasks in process, so production defaults to a single Gateway worker (`GATEWAY_WORKERS=1`). The Redis stream bridge (`stream_bridge.type: redis`) shares SSE delivery and `Last-Event-ID` replay across workers, with a rolling retained-buffer TTL (`stream_ttl_seconds`) as a cleanup safety net. Malformed reconnect IDs live-tail new events instead of replaying the retained buffer. It does not make run cancellation, request de-duplication, or IM channel state fully cross-worker by itself; use single-worker Gateway or explicit sticky routing/ownership before raising `GATEWAY_WORKERS`.
> The Gateway still owns active run tasks in process, so production defaults to a single Gateway worker (`GATEWAY_WORKERS=1`). Multi-worker deployments require Postgres, the Redis stream bridge (`stream_bridge.type: redis`), and `run_ownership.heartbeat_enabled: true`. The bridge shares SSE delivery and `Last-Event-ID` replay across workers, while lease reconciliation marks runs from dead workers as errors, publishes the terminal stream marker, schedules retained-stream cleanup, and updates the affected thread status. SSE and `/wait` consumers also refresh durable status on heartbeats as a fallback if terminal publication fails. The rolling retained-buffer TTL (`stream_ttl_seconds`) remains a cleanup safety net rather than a run timeout. IM channel state and other process-local services still need their own multi-worker coordination.
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed Docker development guide.

View File

@ -444,7 +444,7 @@ metadata only.
- Store-only hydrated runs are readable history. In multi-worker mode with heartbeat enabled, cancel on a store-only run can take over (mark `error`) when the owner's lease has expired past the grace window; otherwise it fails with 409 + `Retry-After`. In single-worker mode (heartbeat off), store-only runs still return 409.
- Startup/orphan reconciliation must claim stale active rows with `RunStore.claim_for_takeover()`, not a plain `update_status()`. The final claim re-checks `status` and lease expiry atomically, so a heartbeat renewal between the candidate scan and the recovery write keeps the run active.
- `POST /wait` (both thread-scoped and `/api/runs/wait`) drains the stream bridge via `wait_for_run_completion()` instead of bare `await record.task`, so it honours the run's `on_disconnect` setting and cancels the background run on real client disconnect rather than returning a stale checkpoint (issue #3265).
- Redis `StreamBridge` keys use a rolling retained-buffer TTL (`stream_bridge.stream_ttl_seconds`, refreshed on `publish()` / `publish_end()`) as a leak safety net, not as a run timeout. Startup orphan recovery publishes `END_SENTINEL` and schedules stream cleanup for recovered runs; malformed `Last-Event-ID` reconnect values live-tail new Redis events rather than replaying the retained buffer. Do not broaden this into a shared-database multi-pod reaper without adding worker ownership/liveness first.
- Redis `StreamBridge` keys use a rolling retained-buffer TTL (`stream_bridge.stream_ttl_seconds`, refreshed on `publish()` / `publish_end()`) as a leak safety net, not as a run timeout. Startup and lease-driven periodic orphan recovery share one Gateway stream-terminalization path: after `RunManager` durably marks a run `error` with `stop_reason=orphan_recovered`, Gateway publishes `END_SENTINEL` and schedules stream cleanup. The periodic store scan, per-row status writes, and Gateway callback run as one supervised single-flight task, so a slow pass is skipped at the next interval instead of piling up or pausing the sole lease-renewal loop. Store retries have bounded attempts/backoff; an individual operation still relies on the database driver/pool timeout. `RunManager.shutdown()` gives active user runs priority within its shared deadline, then drains or cancels orphan recovery. Gateway tracks delayed recovered-stream cleanups and converts unfinished delays to immediate deletes before closing the bridge; the Redis TTL remains the outage safety net. Only startup recovery, before the runtime yields to requests, projects the latest affected thread to `error`; periodic recovery deliberately avoids that non-atomic projection because `ThreadMetaStore` has no `latest_run_id` conditional-update contract. Store-only SSE and `/wait` consumers wait for the bridge's real END marker after an ordinary durable terminal status, because status persistence can precede tail events. The explicit `orphan_recovered` signal is the only heartbeat fallback: its publisher is known to be gone, so it supplies the liveness boundary if END publication fails or the retained key expires. Malformed `Last-Event-ID` reconnect values live-tail new Redis events rather than replaying the retained buffer. Keep cross-component recovery orchestration in Gateway through the generic `RunManager.on_orphans_recovered` callback; do not introduce a harness-to-app dependency. Callback failure warnings include every recovered `run_id` so operators can identify rows whose Gateway-side terminalization needs inspection.
- Thread-scoped run creation accepts `checkpoint` / `checkpoint_id`; Gateway validates the checkpoint belongs to the request thread before writing `checkpoint_id` / `checkpoint_ns` into `config.configurable` for LangGraph branching.
- Thread-scoped Gateway runs evaluate an active `ThreadState.goal` after the visible turn completes. `runtime/goal.py` asks a non-thinking evaluator model to judge only visible conversation evidence and return a typed blocker; the evaluator model is created once per run and reused across hidden continuation checks. The evaluator runs after the graph root's tracing scope has already closed, so `create_goal_evaluator_model`/`evaluate_goal_completion` attach their own model-level tracing callbacks (`attach_tracing=True`) and inject Langfuse trace metadata (`thread_id`/`user_id`/`deerflow_trace_id`) directly onto the `ainvoke` call — the same standalone-caller pattern as `oneshot_llm.run_oneshot_llm` and `MemoryUpdater` (see Tracing System below). Satisfied goals are cleared; every non-satisfied evaluation — continuable or stand-down — is persisted with `last_evaluation` (the blocker, reason, and evidence summary; outcomes that stop the loop additionally record a `stand_down_reason` for observability), but only `goal_not_met_yet` evaluations are streamed as hidden `HumanMessage` continuations, and only when a durable assistant end-of-turn checkpoint exists, the run has not been aborted, the thread did not change during evaluation, and the no-progress breaker has not fired. The continuation cap is 8 — a hard maximum in the `0``8` range; callers requesting more are clamped (`set_goal`/TUI) or rejected with 422 (`PUT /goal`). The no-progress breaker keys on the latest visible assistant evidence (not the evaluator's free-text reason, which an LLM rewords every turn), so two consecutive continuations that add no new visible assistant output stop the loop after 2 attempts. Model-response cleanup helpers such as think-block stripping and code-fence stripping live in `deerflow.utils.llm_text` so `runtime/goal.py` and Gateway suggestion parsing share the same JSON-prep behavior.
- Run event stream changes must keep producer code, `deerflow/constants.py`, `runtime/events/catalog.py`, `contracts/run_event_stream_contract.json`, `backend/docs/RUN_EVENT_STREAM.md`, and `tests/test_run_event_stream_contract.py` in sync. The dependency-free constants module owns the persisted envelope limits (`event_type` 32 characters, `category` 16) and cross-layer workspace event identity; the catalog owns validated runtime definitions and categories. Dynamic middleware tags are limited to 21 characters after the `middleware:` prefix. The JSON contract owns payload schemas, backend-specific storage semantics, legacy aliases, and compatibility rules; conformance tests require both views and all producer groups to agree. `run.end.content` remains opaque and may retain nested Python values in memory while JSONL/database stores stringify non-JSON nested values, so consumers must not assume backend-identical nested output representations.

View File

@ -30,7 +30,7 @@ from langgraph.types import Checkpointer
from deerflow.community.browser_automation.session import browser_multi_worker_error
from deerflow.config.app_config import AppConfig, get_app_config
from deerflow.persistence.feedback import FeedbackRepository
from deerflow.runtime import RunContext, RunManager, StreamBridge
from deerflow.runtime import ORPHAN_RECOVERY_STOP_REASON, STARTUP_ORPHAN_RECOVERY_ERROR, RunContext, RunManager, StreamBridge
from deerflow.runtime.events.store.base import RunEventStore
from deerflow.runtime.runs.store.base import RunStore
@ -163,8 +163,10 @@ async def _publish_recovered_run_stream_end(
recovered_runs: list[RunRecord],
*,
cleanup_delay: float = 60.0,
) -> None:
"""Terminate retained streams for runs recovered as orphaned at startup."""
on_cleanup_scheduled: Callable[[str, asyncio.Task[None]], None] | None = None,
) -> list[tuple[str, asyncio.Task[None]]]:
"""Terminate retained streams for runs recovered as orphaned."""
cleanup_tasks: list[tuple[str, asyncio.Task[None]]] = []
for record in recovered_runs:
stream_exists = getattr(bridge, "stream_exists", None)
if stream_exists is not None:
@ -185,6 +187,10 @@ async def _publish_recovered_run_stream_end(
continue
task = asyncio.create_task(bridge.cleanup(record.run_id, delay=cleanup_delay))
task.add_done_callback(lambda task, run_id=record.run_id: _log_recovered_stream_cleanup_result(task, run_id))
cleanup_tasks.append((record.run_id, task))
if on_cleanup_scheduled is not None:
on_cleanup_scheduled(record.run_id, task)
return cleanup_tasks
def _log_recovered_stream_cleanup_result(task: asyncio.Task[None], run_id: str) -> None:
@ -196,6 +202,45 @@ def _log_recovered_stream_cleanup_result(task: asyncio.Task[None], run_id: str)
logger.warning("Failed to clean up recovered run stream for %s", run_id, exc_info=True)
async def _flush_recovered_stream_cleanups(
bridge: StreamBridge,
cleanup_tasks: dict[asyncio.Task[None], str],
*,
timeout: float = 1.0,
) -> None:
"""Cancel delayed cleanups and delete their streams before bridge shutdown."""
pending = [(task, run_id) for task, run_id in cleanup_tasks.items() if not task.done()]
if not pending:
return
for task, _run_id in pending:
task.cancel()
await asyncio.gather(*(task for task, _run_id in pending), return_exceptions=True)
run_ids = list(dict.fromkeys(run_id for _task, run_id in pending))
try:
results = await asyncio.wait_for(
asyncio.gather(
*(bridge.cleanup(run_id, delay=0) for run_id in run_ids),
return_exceptions=True,
),
timeout=max(0.0, timeout),
)
except TimeoutError:
logger.warning(
"Immediate recovered stream cleanup exceeded %.1fs for run_ids=%s; bridge TTL remains the final safety net",
timeout,
run_ids,
)
else:
for run_id, result in zip(run_ids, results):
if isinstance(result, BaseException):
logger.warning(
"Failed to immediately clean up recovered run stream for %s: %r",
run_id,
result,
)
if TYPE_CHECKING:
from app.gateway.auth.local_provider import LocalAuthProvider
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
@ -206,12 +251,19 @@ if TYPE_CHECKING:
T = TypeVar("T")
async def _mark_latest_recovered_threads_error(
async def _mark_latest_startup_recovered_threads_error(
run_manager: RunManager,
thread_store: ThreadMetaStore,
recovered_runs: list[RunRecord],
) -> None:
"""Mark thread status as error only when its newest run was recovered."""
"""Project startup recovery before request-serving concurrency begins.
This helper must remain on the pre-``yield`` startup path. ``ThreadMetaStore``
has no ``latest_run_id`` column, so it cannot express an atomic conditional
update keyed by the recovered run. Periodic recovery deliberately skips this
projection; moving this helper after request serving starts would reintroduce
a read/update race with newer runs.
"""
recovered_by_thread: dict[str, set[str]] = {}
for record in recovered_runs:
recovered_by_thread.setdefault(record.thread_id, set()).add(record.run_id)
@ -230,6 +282,22 @@ async def _mark_latest_recovered_threads_error(
logger.warning("Failed to mark thread %s as error during run reconciliation", thread_id, exc_info=True)
async def _terminalize_recovered_runs(
bridge: StreamBridge,
recovered_runs: list[RunRecord],
*,
cleanup_delay: float,
on_cleanup_scheduled: Callable[[str, asyncio.Task[None]], None] | None = None,
) -> list[tuple[str, asyncio.Task[None]]]:
"""Publish terminal markers and schedule retained-stream cleanup."""
return await _publish_recovered_run_stream_end(
bridge,
recovered_runs,
cleanup_delay=cleanup_delay,
on_cleanup_scheduled=on_cleanup_scheduled,
)
def get_config() -> AppConfig:
"""Return the freshest ``AppConfig`` for the current request.
@ -358,9 +426,29 @@ async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGen
# RunManager with store backing for persistence
run_ownership_config = getattr(config, "run_ownership", None)
sb_config = getattr(config, "stream_bridge", None)
cleanup_delay = getattr(sb_config, "recovered_stream_cleanup_delay_seconds", 60.0) if sb_config else 60.0
recovered_stream_cleanup_tasks: dict[asyncio.Task[None], str] = {}
def track_recovered_stream_cleanup(
run_id: str,
task: asyncio.Task[None],
) -> None:
recovered_stream_cleanup_tasks[task] = run_id
task.add_done_callback(lambda completed: recovered_stream_cleanup_tasks.pop(completed, None))
async def terminalize_recovered_runs(recovered_runs: list[RunRecord]) -> None:
await _terminalize_recovered_runs(
app.state.stream_bridge,
recovered_runs,
cleanup_delay=cleanup_delay,
on_cleanup_scheduled=track_recovered_stream_cleanup,
)
app.state.run_manager = RunManager(
store=app.state.run_store,
run_ownership_config=run_ownership_config,
on_orphans_recovered=terminalize_recovered_runs,
)
# Startup recovery: mark inflight runs whose lease has expired as error.
# In single-worker mode (SQLite / backend=memory), no run has a lease, so
@ -370,13 +458,21 @@ async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGen
from deerflow.utils.time import now_iso
recovered_runs = await app.state.run_manager.reconcile_orphaned_inflight_runs(
error="Gateway restarted before this run reached a durable final state.",
error=STARTUP_ORPHAN_RECOVERY_ERROR,
before=now_iso(),
stop_reason=ORPHAN_RECOVERY_STOP_REASON,
)
await _terminalize_recovered_runs(
app.state.stream_bridge,
recovered_runs,
cleanup_delay=cleanup_delay,
on_cleanup_scheduled=track_recovered_stream_cleanup,
)
await _mark_latest_startup_recovered_threads_error(
app.state.run_manager,
app.state.thread_store,
recovered_runs,
)
sb_config = getattr(config, "stream_bridge", None)
cleanup_delay = getattr(sb_config, "recovered_stream_cleanup_delay_seconds", 60.0) if sb_config else 60.0
await _publish_recovered_run_stream_end(app.state.stream_bridge, recovered_runs, cleanup_delay=cleanup_delay)
await _mark_latest_recovered_threads_error(app.state.run_manager, app.state.thread_store, recovered_runs)
# Start the lease heartbeat if enabled (multi-worker deployments).
await app.state.run_manager.start_heartbeat()
@ -391,7 +487,21 @@ async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGen
# raises PoolClosed (issue #3373).
run_manager = getattr(app.state, "run_manager", None)
if run_manager is not None:
await _drain_inflight_runs(run_manager)
shutdown_deadline = asyncio.get_running_loop().time() + _RUN_DRAIN_TIMEOUT_SECONDS
try:
await _drain_inflight_runs(run_manager)
finally:
await _flush_recovered_stream_cleanups(
app.state.stream_bridge,
recovered_stream_cleanup_tasks,
timeout=min(
1.0,
max(
0.0,
shutdown_deadline - asyncio.get_running_loop().time(),
),
),
)
await close_engine()

View File

@ -36,6 +36,7 @@ from deerflow.config.app_config import get_app_config
from deerflow.runtime import (
END_SENTINEL,
HEARTBEAT_SENTINEL,
ORPHAN_RECOVERY_STOP_REASON,
CheckpointStateAccessor,
ConflictError,
DisconnectMode,
@ -122,6 +123,24 @@ async def _terminal_record_stream_missing(bridge: StreamBridge, record: RunRecor
return False
async def _orphan_recovery_observed_after_heartbeat(
record: RunRecord,
run_mgr: RunManager,
) -> bool:
"""Return whether durable orphan recovery is the consumer's liveness edge.
A normal terminal status is not sufficient: the producer persists status
before publishing its final error/data frames and END. Orphan recovery is
different because the producer is known to be gone and the durable
``stop_reason`` is written atomically with the terminal status. Only that
explicit signal may synthesize END after a heartbeat.
"""
if not record.store_only:
return False
refreshed = await run_mgr.get(record.run_id, user_id=record.user_id)
return refreshed is not None and _run_is_terminal(refreshed) and refreshed.stop_reason == ORPHAN_RECOVERY_STOP_REASON
# ---------------------------------------------------------------------------
# Input / config helpers
# ---------------------------------------------------------------------------
@ -1111,7 +1130,7 @@ async def sse_consumer(
break
if entry is HEARTBEAT_SENTINEL:
if await _terminal_record_stream_missing(bridge, record):
if await _orphan_recovery_observed_after_heartbeat(record, run_mgr):
yield format_sse("end", None)
return
yield ": heartbeat\n\n"
@ -1174,7 +1193,7 @@ async def wait_for_run_completion(
if entry is END_SENTINEL:
completed = True
return True
if entry is HEARTBEAT_SENTINEL and await _terminal_record_stream_missing(bridge, record):
if entry is HEARTBEAT_SENTINEL and await _orphan_recovery_observed_after_heartbeat(record, run_mgr):
completed = True
return True
if await request.is_disconnected():

View File

@ -143,7 +143,7 @@ sequenceDiagram
关键组件:
- `runtime/runs/worker.py::run_agent` — 在 `asyncio.Task` 里跑 `agent.astream()`,把每个 chunk 通过 `serialize(chunk, mode=mode)` 转成 JSON`bridge.publish()`
- `runtime/stream_bridge` — 抽象 Queue。`publish/subscribe` 解耦生产者和消费者,支持 `Last-Event-ID` 重连、心跳、多订阅者 fan-out。Redis backend 会在每次 `publish()` / `publish_end()` 刷新 retained stream key TTL启动恢复将 orphan run 标记为 error 后也会发布 `END_SENTINEL`,避免重连的 SSE 客户端只收到心跳。注意TTL 是 Redis 内存安全网(防止 key 泄漏),不是 subscriber 终止机制——如果 worker 和 gateway 同时挂掉,已连接的 SSE 客户端在 TTL 过期后仍无法收到 END 信号,需要依赖客户端侧超时。完整的跨 pod subscriber 终止需要 worker 存活检测liveness当前版本不包含此功能
- `runtime/stream_bridge` — 抽象 Queue。`publish/subscribe` 解耦生产者和消费者,支持 `Last-Event-ID` 重连、心跳、多订阅者 fan-out。Redis backend 会在每次 `publish()` / `publish_end()` 刷新 retained stream key TTL启动恢复与基于 worker lease 的周期恢复共用 Gateway stream terminalization 路径:`RunManager` 先将 orphan run 持久化为 `error` 并写入显式的 `stop_reason=orphan_recovered`,随后 Gateway 发布 `END_SENTINEL` 并安排 stream cleanup。周期扫描、逐行状态写入和 Gateway callback 作为一个受监督的 single-flight 后台 task 执行;慢任务不会堆积,也不会阻塞唯一的 lease heartbeat。shutdown 优先收敛活跃 run再处理恢复 task尚未执行的延迟 stream cleanup 会改为立即删除。只有 runtime `yield` 前、无并发请求的启动恢复会把最新受影响 thread 标记为 error周期恢复不做非原子的 thread 投影。store-only SSE 与 `/wait` consumer 不能把普通 durable terminal status 当成流已完成,否则可能跳过延迟发布的 error 等尾部事件;只有 `orphan_recovered` 信号能在 heartbeat 时触发 END fallback因为此时 producer 已被确认失联。TTL 仍是 Redis 内存和故障安全网,不是正常的 subscriber 终止机制
- `app/gateway/services.py::sse_consumer` — 从 bridge 订阅,格式化为 SSE wire 帧。
- `runtime/serialization.py::serialize` — mode-aware 序列化;`messages` mode 下 `serialize_messages_tuple``(chunk, metadata)` 转成 `[chunk.model_dump(), metadata]`

View File

@ -461,8 +461,16 @@ class RunRepository(RunStore):
*,
grace_seconds: int,
error: str,
stop_reason: str | None = None,
) -> bool:
cutoff = datetime.now(UTC) - timedelta(seconds=grace_seconds)
values: dict[str, Any] = {
"status": "error",
"error": error,
"updated_at": datetime.now(UTC),
}
if stop_reason is not None:
values["stop_reason"] = stop_reason
async with self._sf() as session:
result = await session.execute(
update(RunRow)
@ -471,7 +479,7 @@ class RunRepository(RunStore):
RunRow.status.in_(("pending", "running")),
_lease_expired_or_null(RunRow.lease_expires_at, cutoff),
)
.values(status="error", error=error, updated_at=datetime.now(UTC))
.values(**values)
)
await session.commit()
return result.rowcount != 0

View File

@ -7,7 +7,7 @@ directly from ``deerflow.runtime``.
from .checkpoint_state import CheckpointStateAccessor, build_state_mutation_graph
from .checkpointer import checkpointer_context, get_checkpointer, make_checkpointer, reset_checkpointer
from .runs import CancelOutcome, ConflictError, DisconnectMode, RunContext, RunManager, RunRecord, RunStatus, UnsupportedStrategyError, run_agent
from .runs import ORPHAN_RECOVERY_STOP_REASON, STARTUP_ORPHAN_RECOVERY_ERROR, CancelOutcome, ConflictError, DisconnectMode, RunContext, RunManager, RunRecord, RunStatus, UnsupportedStrategyError, run_agent
from .serialization import serialize, serialize_channel_values, serialize_channel_values_for_api, serialize_lc_object, serialize_messages_tuple, strip_data_url_image_blocks
from .store import get_store, make_store, reset_store, store_context
@ -29,10 +29,12 @@ __all__ = [
"CancelOutcome",
"ConflictError",
"DisconnectMode",
"ORPHAN_RECOVERY_STOP_REASON",
"RunContext",
"RunManager",
"RunRecord",
"RunStatus",
"STARTUP_ORPHAN_RECOVERY_ERROR",
"UnsupportedStrategyError",
"run_agent",
# serialization

View File

@ -1,6 +1,6 @@
"""Run lifecycle management for LangGraph Platform API compatibility."""
from .manager import CancelOutcome, ConflictError, RunManager, RunRecord, UnsupportedStrategyError
from .manager import ORPHAN_RECOVERY_STOP_REASON, STARTUP_ORPHAN_RECOVERY_ERROR, CancelOutcome, ConflictError, RunManager, RunRecord, UnsupportedStrategyError
from .schemas import DisconnectMode, RunStatus
from .worker import RunContext, run_agent
@ -8,10 +8,12 @@ __all__ = [
"CancelOutcome",
"ConflictError",
"DisconnectMode",
"ORPHAN_RECOVERY_STOP_REASON",
"RunContext",
"RunManager",
"RunRecord",
"RunStatus",
"STARTUP_ORPHAN_RECOVERY_ERROR",
"UnsupportedStrategyError",
"run_agent",
]

View File

@ -27,6 +27,10 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
ORPHAN_RECOVERY_STOP_REASON = "orphan_recovered"
STARTUP_ORPHAN_RECOVERY_ERROR = "Gateway restarted before this run reached a durable final state."
LEASE_ORPHAN_RECOVERY_ERROR = "Run lease expired — owning worker is unreachable."
_RETRYABLE_SQLITE_MESSAGES = (
"database is locked",
"database table is locked",
@ -184,6 +188,9 @@ class RunRecord:
stop_reason: str | None = None
OrphanRecoveryCallback = Callable[[list[RunRecord]], Awaitable[None]]
class RunManager:
"""In-memory run registry with optional persistent RunStore backing.
@ -199,6 +206,7 @@ class RunManager:
persistence_retry_policy: PersistenceRetryPolicy | None = None,
worker_id: str | None = None,
run_ownership_config: RunOwnershipConfig | None = None,
on_orphans_recovered: OrphanRecoveryCallback | None = None,
) -> None:
self._runs: dict[str, RunRecord] = {}
# Secondary index: thread_id -> insertion-ordered run_id set (a dict is
@ -211,8 +219,10 @@ class RunManager:
self._persistence_retry_policy = persistence_retry_policy or PersistenceRetryPolicy()
self._worker_id = worker_id or _generate_worker_id()
self._run_ownership_config = run_ownership_config
self._on_orphans_recovered = on_orphans_recovered
self._heartbeat_task: asyncio.Task | None = None
self._heartbeat_stop: asyncio.Event | None = None
self._orphan_recovery_task: asyncio.Task[None] | None = None
def _index_run_locked(self, record: RunRecord) -> None:
"""Register *record* in the thread index. Caller must hold ``self._lock``."""
@ -580,7 +590,12 @@ class RunManager:
if self._store is None:
return sorted(memory_records, key=lambda r: r.created_at, reverse=True)[:limit]
records_by_id = {record.run_id: record for record in memory_records}
store_limit = max(0, limit - len(memory_records))
# Query enough rows to cover both the requested page and every possible
# in-memory/store duplicate. Local records can be older than persisted
# rows, so subtracting them from the store limit can hide the actual
# newest run before the merge; querying only ``limit`` can still lose a
# distinct row when that page is occupied by duplicate local records.
store_limit = limit + len(memory_records)
try:
rows = await self._store.list_by_thread(thread_id, user_id=user_id, limit=store_limit)
except Exception:
@ -1090,6 +1105,7 @@ class RunManager:
*,
error: str,
before: str | None = None,
stop_reason: str | None = None,
) -> list[RunRecord]:
"""Mark persisted active runs as failed when their lease has expired.
@ -1138,6 +1154,7 @@ class RunManager:
record.run_id,
grace_seconds=grace_seconds,
error=error,
stop_reason=stop_reason,
),
)
except Exception:
@ -1151,6 +1168,7 @@ class RunManager:
continue
record.status = RunStatus.error
record.error = error
record.stop_reason = stop_reason
record.updated_at = now
recovered.append(record)
@ -1215,21 +1233,21 @@ class RunManager:
self._heartbeat_task = task
logger.info("Run lease heartbeat started for worker %s", self._worker_id)
async def stop_heartbeat(self) -> None:
"""Stop the background heartbeat task."""
async def stop_heartbeat(self, *, timeout: float = 5.0) -> None:
"""Stop the background heartbeat task within ``timeout`` seconds."""
if self._heartbeat_stop is not None:
self._heartbeat_stop.set()
if self._heartbeat_task is not None and not self._heartbeat_task.done():
try:
await asyncio.wait_for(self._heartbeat_task, timeout=5.0)
except TimeoutError:
_, pending = await asyncio.wait(
(self._heartbeat_task,),
timeout=max(0.0, timeout),
)
if pending:
self._heartbeat_task.cancel()
try:
await self._heartbeat_task
except asyncio.CancelledError:
pass
except asyncio.CancelledError:
pass
self._heartbeat_task = None
self._heartbeat_stop = None
logger.info("Run lease heartbeat stopped for worker %s", self._worker_id)
@ -1273,10 +1291,7 @@ class RunManager:
# replacement starts before the lease expires, and the
# startup pass skips the still-valid lease.
if cycle % 3 == 0:
try:
await self._reconcile_orphans_periodic()
except Exception:
logger.warning("Periodic orphan reconciliation failed", exc_info=True)
self._schedule_orphan_reconciliation()
async def _renew_leases(self) -> None:
"""Renew the lease on every locally-owned active run."""
@ -1339,22 +1354,74 @@ class RunManager:
async def _reconcile_orphans_periodic(self) -> None:
"""Sweep for expired leases owned by dead peers.
Called from ``_heartbeat_loop`` every ``lease_seconds``. Startup
reconciliation handles the initial sweep; this periodic pass
catches orphans whose lease expires between restarts.
Scheduled as a single-flight background task by ``_heartbeat_loop``.
This keeps both the store scan/status writes and the Gateway callback
off the lease-renewal loop. Startup reconciliation handles the initial
sweep; this periodic pass catches orphans whose lease expires between
restarts.
"""
error_msg = "Run lease expired — owning worker is unreachable."
recovered = await self.reconcile_orphaned_inflight_runs(error=error_msg)
recovered = await self.reconcile_orphaned_inflight_runs(
error=LEASE_ORPHAN_RECOVERY_ERROR,
stop_reason=ORPHAN_RECOVERY_STOP_REASON,
)
if recovered:
logger.warning(
"Periodic reconciliation recovered %d orphaned run(s) as error",
len(recovered),
)
if self._on_orphans_recovered is not None:
try:
await self._on_orphans_recovered(recovered)
except Exception:
logger.warning(
"Periodic orphan recovery callback failed for %d run(s): run_ids=%s",
len(recovered),
[record.run_id for record in recovered],
exc_info=True,
)
def _schedule_orphan_reconciliation(self) -> None:
"""Start one supervised recovery pass unless one is already running."""
task = self._orphan_recovery_task
if task is not None and not task.done():
logger.debug("Skipping periodic orphan reconciliation: previous pass is still running")
return
task = asyncio.create_task(self._reconcile_orphans_periodic())
task.set_name("deerflow-periodic-orphan-recovery")
self._orphan_recovery_task = task
task.add_done_callback(self._orphan_reconciliation_done)
def _orphan_reconciliation_done(self, task: asyncio.Task[None]) -> None:
"""Clear and inspect the supervised single-flight recovery task."""
if self._orphan_recovery_task is task:
self._orphan_recovery_task = None
if task.cancelled():
return
try:
task.result()
except Exception:
logger.warning("Periodic orphan reconciliation failed", exc_info=True)
async def _drain_orphan_recovery_task(self, *, timeout: float) -> None:
"""Boundedly await the supervised recovery pass during shutdown."""
task = self._orphan_recovery_task
if task is None or task.done():
return
_, pending = await asyncio.wait((task,), timeout=max(0.0, timeout))
if pending:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
logger.warning(
"Orphan recovery drain exceeded %.1fs on shutdown; cancelled the active pass",
timeout,
)
async def shutdown(self, *, timeout: float = 5.0) -> None:
"""Cancel and bounded-await all in-flight runs on process shutdown.
Stops the lease heartbeat first so no renewal races against the drain.
Signals active runs first so their cancellation/cleanup can overlap a
bounded heartbeat stop. The heartbeat may perform one final benign
lease renewal before it observes the stop event.
Chat runs execute in fire-and-forget background ``asyncio`` tasks that
write checkpoints through a shared checkpointer. On shutdown the
@ -1380,7 +1447,6 @@ class RunManager:
``app.gateway.app._SHUTDOWN_HOOK_TIMEOUT_SECONDS``. Runs still active
after ``timeout`` are logged and may still race teardown.
"""
await self.stop_heartbeat()
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
@ -1393,11 +1459,14 @@ class RunManager:
# Status is decided AFTER the drain (below), not here: a run that
# completes on its own during the drain must keep its real status.
await self.stop_heartbeat(timeout=max(0.0, deadline - loop.time()))
if not inflight:
await self._drain_orphan_recovery_task(timeout=max(0.0, deadline - loop.time()))
return
tasks = [record.task for record in inflight]
_, pending = await asyncio.wait(tasks, timeout=timeout)
_, pending = await asyncio.wait(tasks, timeout=max(0.0, deadline - loop.time()))
# Only mark/persist ``interrupted`` for runs that did not settle on their
# own (still pending after the timeout, or ended cancelled). A run that
@ -1445,6 +1514,7 @@ class RunManager:
if pending:
logger.warning("Run drain exceeded %.1fs on shutdown; %d run task(s) still active and may race checkpointer teardown", timeout, len(pending))
logger.info("Drained %d in-flight run(s) on shutdown (%d settled within %.1fs)", len(inflight), len(inflight) - len(pending), timeout)
await self._drain_orphan_recovery_task(timeout=max(0.0, deadline - loop.time()))
class CancelOutcome(StrEnum):

View File

@ -188,13 +188,15 @@ class RunStore(abc.ABC):
*,
grace_seconds: int,
error: str,
stop_reason: str | None = None,
) -> bool:
"""Atomically mark an expired-lease active run as ``error``.
Only rows whose lease has expired past *grace_seconds* (or whose
lease is NULL pre-ownership data) are updated. The conditional
WHERE closes the race between the caller's stale read of the lease
and a concurrent heartbeat renewal by the owning worker.
and a concurrent heartbeat renewal by the owning worker. When
provided, *stop_reason* is persisted in the same atomic update.
Returns ``False`` when:
- the run is no longer ``pending`` / ``running``,

View File

@ -227,6 +227,7 @@ class MemoryRunStore(RunStore):
*,
grace_seconds: int,
error: str,
stop_reason: str | None = None,
) -> bool:
from deerflow.utils.time import is_lease_expired
@ -240,6 +241,8 @@ class MemoryRunStore(RunStore):
return False
run["status"] = "error"
run["error"] = error
if stop_reason is not None:
run["stop_reason"] = stop_reason
run["updated_at"] = datetime.now(UTC).isoformat()
return True

View File

@ -2,7 +2,9 @@
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
import anyio
@ -11,10 +13,13 @@ from fastapi import FastAPI
import deerflow.runtime as runtime_module
from app.gateway import deps as gateway_deps
from deerflow.config.run_ownership_config import RunOwnershipConfig
from deerflow.persistence import engine as engine_module
from deerflow.persistence import thread_meta as thread_meta_module
from deerflow.runtime import END_SENTINEL, MemoryStreamBridge, RunManager
from deerflow.runtime.checkpointer import async_provider as checkpointer_module
from deerflow.runtime.events import store as event_store_module
from deerflow.runtime.runs.store.memory import MemoryRunStore
@asynccontextmanager
@ -29,16 +34,23 @@ class _FakeRunManager:
recovered_runs = [SimpleNamespace(run_id="run-1", thread_id="thread-1")]
latest_by_thread: dict[str, list[SimpleNamespace]] = {}
def __init__(self, *, store, run_ownership_config=None):
def __init__(self, *, store, run_ownership_config=None, on_orphans_recovered=None):
self.store = store
self.run_ownership_config = run_ownership_config
self.on_orphans_recovered = on_orphans_recovered
self.reconcile_calls: list[dict] = []
self.list_by_thread_calls: list[dict] = []
self.shutdown_calls: int = 0
_FakeRunManager.instances.append(self)
async def reconcile_orphaned_inflight_runs(self, *, error: str, before: str | None = None):
self.reconcile_calls.append({"error": error, "before": before})
async def reconcile_orphaned_inflight_runs(
self,
*,
error: str,
before: str | None = None,
stop_reason: str | None = None,
):
self.reconcile_calls.append({"error": error, "before": before, "stop_reason": stop_reason})
return self.recovered_runs
async def list_by_thread(self, thread_id: str, *, user_id=None, limit: int = 100):
@ -81,6 +93,35 @@ class _FakeStreamBridge:
self.cleanup_calls.append((run_id, delay))
class _RetainedMemoryStreamBridge(MemoryStreamBridge):
"""Memory bridge that records cleanup without deleting test history."""
def __init__(self) -> None:
super().__init__()
self.cleanup_calls: list[tuple[str, float]] = []
async def cleanup(self, run_id: str, *, delay: float = 0) -> None:
self.cleanup_calls.append((run_id, delay))
class _DelayedCleanupStreamBridge(_FakeStreamBridge):
def __init__(self) -> None:
super().__init__(existing_streams={"run-1"})
self.delayed_cleanup_started = asyncio.Event()
self.delayed_cleanup_cancelled = asyncio.Event()
async def cleanup(self, run_id: str, *, delay: float = 0) -> None:
self.cleanup_calls.append((run_id, delay))
if delay <= 0:
return
self.delayed_cleanup_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
self.delayed_cleanup_cancelled.set()
raise
@pytest.mark.anyio
async def test_recovered_run_stream_end_skips_expired_stream():
"""Startup recovery should not recreate an already-expired retained stream."""
@ -95,6 +136,78 @@ async def test_recovered_run_stream_end_skips_expired_stream():
assert stream_bridge.cleanup_calls == []
@pytest.mark.anyio
async def test_shutdown_flushes_delayed_recovered_stream_cleanup_immediately():
"""Bridge shutdown must not abandon a delayed cleanup until the stream TTL."""
stream_bridge = _DelayedCleanupStreamBridge()
cleanups = await gateway_deps._publish_recovered_run_stream_end(
stream_bridge,
[SimpleNamespace(run_id="run-1", thread_id="thread-1")],
cleanup_delay=60.0,
)
cleanup_tasks = {task: run_id for run_id, task in cleanups}
await asyncio.wait_for(stream_bridge.delayed_cleanup_started.wait(), timeout=0.5)
await gateway_deps._flush_recovered_stream_cleanups(
stream_bridge,
cleanup_tasks,
)
assert stream_bridge.delayed_cleanup_cancelled.is_set()
assert stream_bridge.cleanup_calls == [("run-1", 60.0), ("run-1", 0)]
@pytest.mark.anyio
async def test_periodic_recovery_terminalizes_stream_without_thread_projection():
"""Periodic recovery must close streams without racing thread projection."""
store = MemoryRunStore()
stream_bridge = _RetainedMemoryStreamBridge()
thread_store = _FakeThreadStore()
expired = (datetime.now(UTC) - timedelta(seconds=60)).isoformat()
await store.put(
"periodic-orphan",
thread_id="thread-1",
status="running",
owner_worker_id="dead-worker",
lease_expires_at=expired,
created_at=expired,
)
await stream_bridge.publish("periodic-orphan", "values", {"step": 1})
async def terminalize(recovered_runs):
await gateway_deps._terminalize_recovered_runs(
stream_bridge,
recovered_runs,
cleanup_delay=60.0,
)
manager = RunManager(
store=store,
worker_id="live-worker",
run_ownership_config=RunOwnershipConfig(
heartbeat_enabled=True,
lease_seconds=30,
grace_seconds=10,
),
on_orphans_recovered=terminalize,
)
await manager._reconcile_orphans_periodic()
await anyio.sleep(0)
received = [
entry
async for entry in stream_bridge.subscribe(
"periodic-orphan",
heartbeat_interval=0.01,
)
]
await anyio.sleep(0)
assert received[-1] is END_SENTINEL
assert stream_bridge.cleanup_calls == [("periodic-orphan", 60.0)]
assert thread_store.status_updates == []
@pytest.mark.anyio
async def test_sqlite_runtime_reconciles_orphaned_runs_on_startup(monkeypatch):
"""SQLite startup should recover stale active runs before serving requests."""
@ -133,6 +246,7 @@ async def test_sqlite_runtime_reconciles_orphaned_runs_on_startup(monkeypatch):
assert len(_FakeRunManager.instances) == 1
assert _FakeRunManager.instances[0].reconcile_calls
assert _FakeRunManager.instances[0].reconcile_calls[0]["error"]
assert _FakeRunManager.instances[0].reconcile_calls[0]["stop_reason"] == runtime_module.ORPHAN_RECOVERY_STOP_REASON
assert _FakeRunManager.instances[0].list_by_thread_calls == [{"thread_id": "thread-1", "user_id": None, "limit": 1}]
assert thread_store.status_updates == [("thread-1", "error", None)]
assert stream_bridge.publish_end_calls == ["run-1"]

View File

@ -5,6 +5,7 @@ Coverage:
- create_or_reject with interrupt strategy claims and cancels old runs
- create_run_atomic refuses to interrupt a run owned by another live worker
- reconcile_orphaned_inflight_runs uses lease-based detection
- periodic reconciliation notifies Gateway recovery orchestration
- Worker reconciliation skips runs with unexpired leases
- Lease heartbeat renews active run leases
- GATEWAY_WORKERS=1 + heartbeat_enabled=false behaviour unchanged
@ -19,7 +20,7 @@ from unittest.mock import AsyncMock
import pytest
from deerflow.config.run_ownership_config import RunOwnershipConfig
from deerflow.runtime import RunManager, RunStatus
from deerflow.runtime import ORPHAN_RECOVERY_STOP_REASON, RunManager, RunStatus
from deerflow.runtime.runs.manager import CancelOutcome, ConflictError, _generate_worker_id
from deerflow.runtime.runs.store.memory import MemoryRunStore
@ -389,6 +390,262 @@ async def test_reconciliation_returns_empty_when_no_orphaned_runs():
assert recovered == []
@pytest.mark.anyio
async def test_periodic_reconciliation_notifies_recovery_callback():
"""Periodic recovery must hand terminalized rows to Gateway orchestration."""
store = MemoryRunStore()
on_orphans_recovered = AsyncMock()
manager = _make_manager(
store=store,
on_orphans_recovered=on_orphans_recovered,
)
expired_lease = (datetime.now(UTC) - timedelta(seconds=60)).isoformat()
await store.put(
"periodic-orphan",
thread_id="thread-1",
status="running",
owner_worker_id="dead-worker",
lease_expires_at=expired_lease,
created_at=(datetime.now(UTC) - timedelta(seconds=120)).isoformat(),
)
await manager._reconcile_orphans_periodic()
await asyncio.sleep(0)
on_orphans_recovered.assert_awaited_once()
recovered = on_orphans_recovered.await_args.args[0]
assert [record.run_id for record in recovered] == ["periodic-orphan"]
assert recovered[0].status == RunStatus.error
assert recovered[0].stop_reason == ORPHAN_RECOVERY_STOP_REASON
stored = await store.get("periodic-orphan")
assert stored is not None
assert stored["stop_reason"] == ORPHAN_RECOVERY_STOP_REASON
@pytest.mark.anyio
async def test_periodic_reconciliation_logs_recovered_run_ids_when_callback_fails(caplog):
"""Callback failures must identify every recovered run in the warning."""
store = MemoryRunStore()
on_orphans_recovered = AsyncMock(side_effect=RuntimeError("callback failed"))
manager = _make_manager(
store=store,
on_orphans_recovered=on_orphans_recovered,
)
expired_lease = (datetime.now(UTC) - timedelta(seconds=60)).isoformat()
created_at = (datetime.now(UTC) - timedelta(seconds=120)).isoformat()
for run_id in ("periodic-orphan-1", "periodic-orphan-2"):
await store.put(
run_id,
thread_id=f"thread-{run_id}",
status="running",
owner_worker_id="dead-worker",
lease_expires_at=expired_lease,
created_at=created_at,
)
with caplog.at_level("WARNING", logger="deerflow.runtime.runs.manager"):
await manager._reconcile_orphans_periodic()
await asyncio.sleep(0)
assert "Periodic orphan recovery callback failed for 2 run(s)" in caplog.text
assert "periodic-orphan-1" in caplog.text
assert "periodic-orphan-2" in caplog.text
@pytest.mark.anyio
async def test_periodic_terminalization_does_not_block_lease_renewal_or_shutdown():
"""The real heartbeat loop must keep renewing during a slow callback."""
store = MemoryRunStore()
callback_started = asyncio.Event()
callback_release = asyncio.Event()
callback_finished = asyncio.Event()
async def on_orphans_recovered(_recovered):
callback_started.set()
await callback_release.wait()
callback_finished.set()
manager = _make_manager(
store=store,
run_ownership_config=_lease_config(heartbeat_enabled=True, lease_seconds=5),
on_orphans_recovered=on_orphans_recovered,
)
active = await manager.create_or_reject("active-thread")
await manager.set_status(active.run_id, RunStatus.running)
original_expiry = active.lease_expires_at
expired_lease = (datetime.now(UTC) - timedelta(seconds=60)).isoformat()
await store.put(
"periodic-orphan",
thread_id="orphan-thread",
status="running",
owner_worker_id="dead-worker",
lease_expires_at=expired_lease,
created_at=expired_lease,
)
await manager.start_heartbeat()
await asyncio.wait_for(callback_started.wait(), timeout=4.5)
expiry_during_callback = active.lease_expires_at
await asyncio.sleep(1.2)
assert expiry_during_callback != original_expiry
assert active.lease_expires_at != expiry_during_callback
assert callback_finished.is_set() is False
shutdown_task = asyncio.create_task(manager.shutdown(timeout=1.0))
await asyncio.sleep(0)
assert shutdown_task.done() is False
callback_release.set()
await asyncio.wait_for(shutdown_task, timeout=1.0)
assert callback_finished.is_set() is True
@pytest.mark.anyio
async def test_periodic_store_scan_does_not_block_real_heartbeat_loop():
"""A slow orphan scan must not stall later lease-renewal cycles."""
class SlowScanStore(MemoryRunStore):
def __init__(self):
super().__init__()
self.scan_started = asyncio.Event()
self.scan_release = asyncio.Event()
async def list_inflight_with_expired_lease(
self,
*,
before=None,
grace_seconds=10,
):
self.scan_started.set()
await self.scan_release.wait()
return await super().list_inflight_with_expired_lease(
before=before,
grace_seconds=grace_seconds,
)
store = SlowScanStore()
manager = _make_manager(
store=store,
run_ownership_config=_lease_config(heartbeat_enabled=True, lease_seconds=5),
)
active = await manager.create_or_reject("active-thread")
await manager.set_status(active.run_id, RunStatus.running)
await manager.start_heartbeat()
await asyncio.wait_for(store.scan_started.wait(), timeout=4.5)
expiry_during_scan = active.lease_expires_at
await asyncio.sleep(1.2)
assert active.lease_expires_at != expiry_during_scan
store.scan_release.set()
await manager.stop_heartbeat()
await manager._drain_orphan_recovery_task(timeout=0.5)
@pytest.mark.anyio
async def test_periodic_recovery_is_single_flight():
"""A second trigger must not create another recovery pipeline."""
store = MemoryRunStore()
callback_started = asyncio.Event()
callback_release = asyncio.Event()
callback_calls = 0
async def on_orphans_recovered(_recovered):
nonlocal callback_calls
callback_calls += 1
callback_started.set()
await callback_release.wait()
manager = _make_manager(
store=store,
on_orphans_recovered=on_orphans_recovered,
)
expired_lease = (datetime.now(UTC) - timedelta(seconds=60)).isoformat()
await store.put(
"periodic-orphan",
thread_id="orphan-thread",
status="running",
owner_worker_id="dead-worker",
lease_expires_at=expired_lease,
created_at=expired_lease,
)
manager._schedule_orphan_reconciliation()
await asyncio.wait_for(callback_started.wait(), timeout=0.5)
first_task = manager._orphan_recovery_task
manager._schedule_orphan_reconciliation()
assert manager._orphan_recovery_task is first_task
assert callback_calls == 1
callback_release.set()
await asyncio.wait_for(first_task, timeout=0.5)
@pytest.mark.anyio
async def test_shutdown_cancels_recovery_that_exceeds_drain_budget():
"""The pending -> cancel -> gather branch must observe callback cancellation."""
store = MemoryRunStore()
callback_started = asyncio.Event()
callback_cancelled = asyncio.Event()
async def on_orphans_recovered(_recovered):
callback_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
callback_cancelled.set()
raise
manager = _make_manager(
store=store,
on_orphans_recovered=on_orphans_recovered,
)
expired_lease = (datetime.now(UTC) - timedelta(seconds=60)).isoformat()
await store.put(
"periodic-orphan",
thread_id="orphan-thread",
status="running",
owner_worker_id="dead-worker",
lease_expires_at=expired_lease,
created_at=expired_lease,
)
manager._schedule_orphan_reconciliation()
await asyncio.wait_for(callback_started.wait(), timeout=0.5)
await manager.shutdown(timeout=0.01)
assert callback_cancelled.is_set()
assert manager._orphan_recovery_task is None
@pytest.mark.anyio
async def test_shutdown_applies_shared_deadline_to_heartbeat_stop():
"""A stuck heartbeat must not receive a separate five-second budget."""
manager = _make_manager(
run_ownership_config=_lease_config(heartbeat_enabled=True),
)
heartbeat_cancelled = asyncio.Event()
async def stuck_heartbeat():
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
heartbeat_cancelled.set()
raise
manager._heartbeat_stop = asyncio.Event()
manager._heartbeat_task = asyncio.create_task(stuck_heartbeat())
started = asyncio.get_running_loop().time()
await manager.shutdown(timeout=0.01)
elapsed = asyncio.get_running_loop().time() - started
assert heartbeat_cancelled.is_set()
assert elapsed < 0.5
# ---------------------------------------------------------------------------
# Lease heartbeat
# ---------------------------------------------------------------------------
@ -1023,13 +1280,19 @@ async def test_claim_for_takeover_succeeds_with_expired_lease():
expired_lease = (datetime.now(UTC) - timedelta(seconds=grace + 5)).isoformat()
await store.put("run-1", thread_id="t1", status="running", created_at=datetime.now(UTC).isoformat(), owner_worker_id="w-a", lease_expires_at=expired_lease)
ok = await store.claim_for_takeover("run-1", grace_seconds=grace, error="claimed")
ok = await store.claim_for_takeover(
"run-1",
grace_seconds=grace,
error="claimed",
stop_reason=ORPHAN_RECOVERY_STOP_REASON,
)
assert ok is True
row = await store.get("run-1")
assert row is not None
assert row["status"] == "error"
assert row["error"] == "claimed"
assert row["stop_reason"] == ORPHAN_RECOVERY_STOP_REASON
@pytest.mark.anyio

View File

@ -68,7 +68,7 @@ class FailingTakeoverRunStore(MemoryRunStore):
super().__init__()
self.takeover_attempts = 0
async def claim_for_takeover(self, run_id, *, grace_seconds, error):
async def claim_for_takeover(self, run_id, *, grace_seconds, error, stop_reason=None):
self.takeover_attempts += 1
raise sqlite3.OperationalError("database is locked")
@ -576,6 +576,25 @@ async def test_list_by_thread_merges_store_runs_newest_first():
assert runs[0] is memory_record
@pytest.mark.anyio
async def test_list_by_thread_limit_does_not_let_old_memory_hide_new_store_run():
"""A local row must not consume the store query's newest-run limit."""
store = MemoryRunStore()
manager = RunManager(store=store)
old_memory = await manager.create("thread-1")
old_memory.created_at = "2026-01-01T00:00:00+00:00"
await store.put(
"new-store",
thread_id="thread-1",
status="success",
created_at="2026-01-02T00:00:00+00:00",
)
runs = await manager.list_by_thread("thread-1", limit=1)
assert [run.run_id for run in runs] == ["new-store"]
@pytest.mark.anyio
async def test_create_defaults(manager: RunManager):
"""Create with no optional args should use defaults."""

View File

@ -1,4 +1,4 @@
"""Regression tests for issue #3265.
"""Regression tests for issues #3265 and #3932.
The non-streaming ``/wait`` endpoints used to ``await record.task`` with no
disconnect handling and silently swallow ``CancelledError``. When a long
@ -10,7 +10,10 @@ The fix introduces ``wait_for_run_completion`` in ``app.gateway.services``:
it subscribes to the stream bridge until ``END_SENTINEL``, polls
``request.is_disconnected()`` on every wake-up, and honours the record's
``on_disconnect`` mode by cancelling the background run on real client
disconnect.
disconnect. Store-only consumers wait for the bridge's real terminal marker
instead of treating an ordinary durable terminal status as proof that all tail
events have already been published. A durable ``orphan_recovered`` stop reason
provides the narrow heartbeat fallback when the publisher is known to be gone.
"""
from __future__ import annotations
@ -19,7 +22,7 @@ import asyncio
from dataclasses import dataclass, field
from typing import Any
from deerflow.runtime import RunManager, RunRecord, RunStatus
from deerflow.runtime import ORPHAN_RECOVERY_STOP_REASON, RunManager, RunRecord, RunStatus
from deerflow.runtime.runs.schemas import DisconnectMode
from deerflow.runtime.stream_bridge.memory import MemoryStreamBridge
@ -69,6 +72,17 @@ class _MissingStreamBridge:
return None
class _FastHeartbeatBridge(MemoryStreamBridge):
"""Memory bridge with a short heartbeat for durable-status refresh tests."""
def subscribe(self, run_id, *, last_event_id=None, heartbeat_interval=15.0):
return super().subscribe(
run_id,
last_event_id=last_event_id,
heartbeat_interval=0.01,
)
async def _create_running_record(mgr: RunManager, *, on_disconnect: DisconnectMode) -> Any:
record = await mgr.create_or_reject(
THREAD_ID,
@ -249,3 +263,148 @@ class TestWaitForRunCompletion:
assert bridge.subscribed is False
asyncio.run(run())
def test_sse_consumer_preserves_tail_events_after_durable_terminal_status(self) -> None:
"""A durable terminal row must not overtake delayed error and END events."""
from app.gateway.services import sse_consumer
from deerflow.runtime.runs.store.memory import MemoryRunStore
async def run() -> None:
store = MemoryRunStore()
await store.put(
"periodic-orphan",
thread_id=THREAD_ID,
status="running",
)
mgr = RunManager(store=store)
record = await mgr.get("periodic-orphan")
assert record is not None
assert record.store_only is True
bridge = _FastHeartbeatBridge()
await bridge.publish(record.run_id, "values", {"step": 1})
request = _FakeRequest()
consumer = sse_consumer(bridge, record, request, mgr)
first_frame = await anext(consumer)
assert first_frame.startswith("event: values\n")
await store.update_status(record.run_id, "error", error="lease expired")
async def publish_tail() -> None:
await asyncio.sleep(0.05)
await bridge.publish(record.run_id, "error", {"message": "late error"})
await bridge.publish_end(record.run_id)
publisher = asyncio.create_task(publish_tail())
tail_frames = [frame async for frame in consumer]
await publisher
error_index = next(index for index, frame in enumerate(tail_frames) if frame.startswith("event: error\n"))
end_index = next(index for index, frame in enumerate(tail_frames) if frame.startswith("event: end\n"))
assert error_index < end_index
assert record.status == RunStatus.running
asyncio.run(run())
def test_wait_preserves_tail_events_after_durable_terminal_status(self) -> None:
"""The wait path must remain blocked until the real END is published."""
from app.gateway.services import wait_for_run_completion
from deerflow.runtime.runs.store.memory import MemoryRunStore
async def run() -> None:
store = MemoryRunStore()
await store.put(
"periodic-orphan",
thread_id=THREAD_ID,
status="running",
)
mgr = RunManager(store=store)
record = await mgr.get("periodic-orphan")
assert record is not None
assert record.store_only is True
bridge = _FastHeartbeatBridge()
await bridge.publish(record.run_id, "values", {"step": 1})
await store.update_status(record.run_id, "error", error="lease expired")
wait_task = asyncio.create_task(wait_for_run_completion(bridge, record, _FakeRequest(), mgr))
await asyncio.sleep(0.05)
assert wait_task.done() is False
await bridge.publish(record.run_id, "error", {"message": "late error"})
await asyncio.sleep(0)
assert wait_task.done() is False
await bridge.publish_end(record.run_id)
completed = await asyncio.wait_for(wait_task, timeout=1.0)
assert completed is True
assert record.status == RunStatus.running
asyncio.run(run())
def test_sse_consumer_uses_explicit_orphan_recovery_liveness_boundary(
self,
) -> None:
"""A recovered orphan may synthesize END when its publisher is gone."""
from app.gateway.services import sse_consumer
from deerflow.runtime.runs.store.memory import MemoryRunStore
async def run() -> None:
store = MemoryRunStore()
await store.put(
"periodic-orphan",
thread_id=THREAD_ID,
status="running",
)
mgr = RunManager(store=store)
record = await mgr.get("periodic-orphan")
assert record is not None
bridge = _FastHeartbeatBridge()
await bridge.publish(record.run_id, "values", {"step": 1})
consumer = sse_consumer(bridge, record, _FakeRequest(), mgr)
assert (await anext(consumer)).startswith("event: values\n")
await store.update_status(
record.run_id,
"error",
error="lease expired",
stop_reason=ORPHAN_RECOVERY_STOP_REASON,
)
end_frame = await asyncio.wait_for(anext(consumer), timeout=1.0)
assert end_frame == "event: end\ndata: null\n\n"
asyncio.run(run())
def test_wait_uses_explicit_orphan_recovery_liveness_boundary(self) -> None:
"""The non-streaming consumer shares the recovered-orphan boundary."""
from app.gateway.services import wait_for_run_completion
from deerflow.runtime.runs.store.memory import MemoryRunStore
async def run() -> None:
store = MemoryRunStore()
await store.put(
"periodic-orphan",
thread_id=THREAD_ID,
status="running",
)
mgr = RunManager(store=store)
record = await mgr.get("periodic-orphan")
assert record is not None
bridge = _FastHeartbeatBridge()
await bridge.publish(record.run_id, "values", {"step": 1})
await store.update_status(
record.run_id,
"error",
error="lease expired",
stop_reason=ORPHAN_RECOVERY_STOP_REASON,
)
completed = await asyncio.wait_for(
wait_for_run_completion(bridge, record, _FakeRequest(), mgr),
timeout=1.0,
)
assert completed is True
asyncio.run(run())