mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-21 10:15:47 +00:00
fix(runs): cancel degrades to lease takeover for multi-worker (#4064)
* fix(runs): cancel degrades to lease takeover for multi-worker Work item 4 of the multi-worker ownership epic (https://github.com/bytedance/deer-flow/issues/3948). Problem: POST /runs/{run_id}/cancel landing on a non-owning worker returns 409 — the cancel button silently fails under GATEWAY_WORKERS>1 with no sticky routing. cancel() required the current worker to hold the in-memory task/abort_event, which any non-owner pod cannot satisfy. Changes: - RunManager.cancel() returns CancelOutcome enum (cancelled / taken_over / lease_valid_elsewhere / not_active_locally / not_cancellable / unknown) instead of bool, so the router can map each outcome to the right HTTP response. - New store primitive claim_for_takeover(): a single atomic conditional UPDATE that marks a run as error only when status IN (pending, running) AND (lease IS NULL OR lease < now - grace). Closes the stale-read / concurrent-heartbeat race — if the owner renews between our read and write, the UPDATE matches 0 rows and we surface lease_valid_elsewhere. - HTTP cancel + stream-join endpoints route on CancelOutcome: cancelled -> 202 (or 204 with wait=true); taken_over -> 202 immediately (no SSE streaming — the run is terminal on another worker, streaming would hang); lease_valid_elsewhere -> 409 + Retry-After header computed from lease_expires_at + grace_seconds. - RunManager.grace_seconds exposed as a public property; the router no longer reaches into _run_ownership_config. - _is_lease_expired extracted to a module-level function, shared by RunManager.cancel() and MemoryRunStore.claim_for_takeover(). - GATEWAY_WORKERS=1 + heartbeat_enabled=false is zero-regression: the non-local path short-circuits to not_active_locally, preserving the original 409 behaviour the existing tests pin. Tests: 12 new (5 store primitive + 4 cancel-takeover unit + 3 HTTP including a regression guard verifying POST /stream?action=interrupt on a dead-owner run returns 202 instead of hanging on SSE). 244 directly-related tests pass; 36/36 blocking-IO gate pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(runs): guard update_status and self-terminate on takeover Two defenses close a split-brain window where the original owner could overwrite a peer's takeover status: - update_status (SQL + memory store) now guards on status IN ('pending','running'). When takeover already set the row to 'error', the owner's final status write matches 0 rows and is dropped. - _persist_status: when update_status returns False, check whether the row exists before attempting recovery via put(). If the row exists (takeover by another worker), skip recovery instead of blindly upserting over the takeover. - Heartbeat _renew_leases: when update_lease returns False (row no longer pending/running or owner changed), cancel the local task so wasted CPU is bounded to the next heartbeat tick (~10s) instead of the full task lifetime. Also fix three reviewer feedback items: - Re-fetch the store row when cancel() returns lease_valid_elsewhere, so Retry-After uses the owner's freshly-renewed lease instead of a stale value from request start. - Fallback 'unknown' in takeover error message when owner_worker_id is NULL (pre-ownership data). - Remove dead else-10 branch from grace_seconds property (unreachable — all callers are downstream of the heartbeat_enabled guard). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(runs): pin split-brain defences from update_status guard + heartbeat Three tests lock down the takeover authoritativeness so a late-running owner cannot overwrite a peer's claim: - update_status must reject writes when the store row is already terminal (taken over by another worker). - _persist_status must skip row-recovery via put() when the row exists but has been taken over. - Heartbeat _renew_leases must cancel the local task when update_lease returns False (row claimed by another worker). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(runs): precise outcome + log when local cancel loses to peer takeover Two reviewer precision nits on the split-brain defence: - _persist_status: branch the skip-reason log on existing["status"]. error → WARNING "peer takeover" (anomalous); interrupted/success → INFO "local cancel/completion race" (expected when user hits stop as the run finishes). Stops noisy false-positive takeover warnings in operator logs. - cancel() local path: when _persist_status returns False, re-check the store. If a peer's claim_for_takeover flipped the row to error between our in-memory cancel and the guarded update_status, surface taken_over instead of cancelled so the client sees a status consistent with the store. Test: test_cancel_returns_taken_over_when_peer_claims_during_local_cancel pins the race outcome. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(runs): widen update_status guard, de-duplicate lease helpers, add coverage Round 3 of reviewer feedback: - Widen update_status guard to status IN ('pending','running','interrupted'). The original guard blocked interrupted→error (the rollback finalize path), losing the "Rolled back by user" message. interrupted is now permitted while error/success stay locked — takeover protection unchanged. - claim_for_takeover False now re-reads the store row to distinguish causes: owner renewed lease → lease_valid_elsewhere; row went terminal → not_cancellable; another worker already took it over → taken_over. - Extract _raise_lease_valid_elsewhere() helper to de-duplicate the 409+Retry-After block shared across cancel_run and stream_existing_run. - Extract _lease_expired_or_null() in persistence/run/sql.py to de-duplicate the lease-expiry SQL WHERE clause shared by claim_for_takeover and list_inflight_with_expired_lease. - 11 new tests: 5 SQL-layer claim_for_takeover (expired/valid/NULL/ terminal/nonexistent), 3 _compute_retry_after unit (NULL/unparseable/ normal), 2 claim re-read precision (terminal/takeover), 1 stream endpoint 409+Retry-After. Not addressed (non-blocking, reviewer agreed): - The 2–3 store.gets in the takeover cold path: optimizing the API to accept a pre-fetched record would couple the router to the manager more tightly than justified by the perf gain. - The lease-expiry inline loop in MemoryRunStore.list_inflight_with_- expired_lease pre-computes cutoff once for all rows; switching to the shared _is_lease_expired helper would recompute datetime.now() per row with no real benefit. 260 related tests pass; 36/36 blocking-IO gate pass; ruff clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(runs): de-duplicate lease-expiry helper, restore defensive fallback Address final round of review feedback: - Extract is_lease_expired to deerflow.utils.time (no _ prefix, public utility). Manager and MemoryRunStore now import from the same place instead of the store reaching backward into the manager for a private function. - Restore defensive else-10 fallback in grace_seconds property (removed in an earlier round). The guard is unreachable for current callers but protects future ones from AttributeError. - Comment the transient in-memory interrupted vs store error state when a local cancel is superseded by a peer takeover. - Comment the max(1, ...) floor in _compute_retry_after — the floor is a lower bound, not a poll interval; clients should apply jitter. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: rayhpeng <rayhpeng@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
rayhpeng
parent
3e7baba39a
commit
b53c1ae0e0
+2
-2
@@ -341,8 +341,8 @@ metadata only.
|
||||
**RunManager / RunStore contract**:
|
||||
- `RunManager.get()` is async; direct callers must `await` it.
|
||||
- When a persistent `RunStore` is configured, `get()` and `list_by_thread()` hydrate historical runs from the store. In-memory records win for the same `run_id` so task, abort, and stream-control state stays attached to active local runs.
|
||||
- `cancel()` and `create_or_reject(..., multitask_strategy="interrupt"|"rollback")` persist interrupted status through `RunStore.update_status()`, matching normal `set_status()` transitions.
|
||||
- Store-only hydrated runs are readable history. If the current worker has no in-memory task/control state for that run, cancellation APIs can return 409 because this worker cannot stop the task.
|
||||
- `cancel()` returns a :class:`~deerflow.runtime.CancelOutcome` enum: `cancelled` (local cancel), `taken_over` (non-owning worker claimed the run because the owner's lease expired — marks it as `error`), `lease_valid_elsewhere` (owner's lease is still alive — caller should return 409 + `Retry-After`), `not_active_locally` (heartbeat disabled, preserving the old 409 path), `not_cancellable` (terminal state), or `unknown` (not found in memory or store). `create_or_reject(..., multitask_strategy="interrupt"|"rollback")` persists interrupted status through `RunStore.update_status()`, matching normal `set_status()` transitions.
|
||||
- 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.
|
||||
- `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.
|
||||
- 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.
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
@@ -24,7 +25,7 @@ from app.gateway.authz import require_permission
|
||||
from app.gateway.deps import get_checkpointer, get_current_user, get_feedback_repo, get_run_event_store, get_run_manager, get_run_store, get_stream_bridge
|
||||
from app.gateway.pagination import trim_run_message_page
|
||||
from app.gateway.services import sse_consumer, start_run, wait_for_run_completion
|
||||
from deerflow.runtime import RunRecord, RunStatus, serialize_channel_values_for_api
|
||||
from deerflow.runtime import CancelOutcome, RunRecord, RunStatus, serialize_channel_values_for_api
|
||||
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY, get_original_user_content_text, message_to_text
|
||||
from deerflow.workspace_changes import get_workspace_changes_response
|
||||
|
||||
@@ -145,6 +146,54 @@ def _cancel_conflict_detail(run_id: str, record: RunRecord) -> str:
|
||||
return f"Run {run_id} is not cancellable (status: {record.status.value})"
|
||||
|
||||
|
||||
def _compute_retry_after(lease_expires_at: str | None, grace_seconds: int) -> int | None:
|
||||
"""Return seconds until the lease expires + grace, for ``Retry-After``.
|
||||
|
||||
Returns ``None`` when the lease is NULL or unparseable so the caller
|
||||
can decide whether to send a generic 409 without the header.
|
||||
|
||||
The ``max(1, ...)`` floor means a lease just about to expire yields
|
||||
``Retry-After: 1``. This is a lower bound, not a recommended poll
|
||||
interval — clients that honour this header should apply minimum
|
||||
backoff / jitter rather than retrying every second.
|
||||
"""
|
||||
if lease_expires_at is None:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(lease_expires_at)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
remaining = (dt - datetime.now(UTC)).total_seconds() + grace_seconds
|
||||
return max(1, int(remaining))
|
||||
|
||||
|
||||
async def _raise_lease_valid_elsewhere(
|
||||
run_id: str,
|
||||
run_mgr, # RunManager (avoid import for testability)
|
||||
record: RunRecord,
|
||||
) -> None:
|
||||
"""Re-fetch the lease and raise HTTP 409 + Retry-After.
|
||||
|
||||
``record.lease_expires_at`` may be stale (fetched at request start while
|
||||
the owner renewed between our read and the conditional UPDATE). Re-read
|
||||
from the store to get the fresh value so ``Retry-After`` is accurate.
|
||||
"""
|
||||
fresh = await run_mgr.get(run_id)
|
||||
if fresh is not None:
|
||||
record = fresh
|
||||
retry_after = _compute_retry_after(record.lease_expires_at, run_mgr.grace_seconds)
|
||||
headers: dict[str, str] = {}
|
||||
if retry_after is not None:
|
||||
headers["Retry-After"] = str(retry_after)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Run {run_id} is active on another worker; retry after lease expiry.",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def _record_to_response(record: RunRecord) -> RunResponse:
|
||||
return RunResponse(
|
||||
run_id=record.run_id,
|
||||
@@ -512,24 +561,34 @@ async def cancel_run(
|
||||
- action=rollback: Stop execution, revert to pre-run checkpoint state
|
||||
- wait=true: Block until the run fully stops, return 204
|
||||
- wait=false: Return immediately with 202
|
||||
|
||||
In multi-worker deployments, a cancel landing on a non-owning worker
|
||||
can take over the run when the owner's lease has expired. When the
|
||||
lease is still valid a 409 + ``Retry-After`` header is returned.
|
||||
"""
|
||||
run_mgr = get_run_manager(request)
|
||||
record = await run_mgr.get(run_id)
|
||||
if record is None or record.thread_id != thread_id:
|
||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||
|
||||
cancelled = await run_mgr.cancel(run_id, action=action)
|
||||
if not cancelled:
|
||||
raise HTTPException(status_code=409, detail=_cancel_conflict_detail(run_id, record))
|
||||
outcome = await run_mgr.cancel(run_id, action=action)
|
||||
|
||||
if wait and record.task is not None:
|
||||
try:
|
||||
await record.task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
return Response(status_code=204)
|
||||
# Success paths — the run was either cancelled locally or taken over
|
||||
# from a dead worker.
|
||||
if outcome in (CancelOutcome.cancelled, CancelOutcome.taken_over):
|
||||
if wait and record.task is not None:
|
||||
try:
|
||||
await record.task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
return Response(status_code=204)
|
||||
return Response(status_code=202)
|
||||
|
||||
return Response(status_code=202)
|
||||
if outcome == CancelOutcome.lease_valid_elsewhere:
|
||||
await _raise_lease_valid_elsewhere(run_id, run_mgr, record)
|
||||
|
||||
# not_cancellable, not_active_locally, unknown
|
||||
raise HTTPException(status_code=409, detail=_cancel_conflict_detail(run_id, record))
|
||||
|
||||
|
||||
@router.get("/{thread_id}/runs/{run_id}/join")
|
||||
@@ -586,8 +645,16 @@ async def stream_existing_run(
|
||||
|
||||
# Cancel if an action was requested (stop-button / interrupt flow)
|
||||
if action is not None:
|
||||
cancelled = await run_mgr.cancel(run_id, action=action)
|
||||
if not cancelled:
|
||||
outcome = await run_mgr.cancel(run_id, action=action)
|
||||
if outcome == CancelOutcome.taken_over:
|
||||
# The run was on another worker and is now marked ``error`` in the
|
||||
# store. There is no local stream to drain — return immediately so
|
||||
# the client doesn't hang on an SSE subscription this worker can
|
||||
# never serve.
|
||||
return Response(status_code=202)
|
||||
if outcome != CancelOutcome.cancelled:
|
||||
if outcome == CancelOutcome.lease_valid_elsewhere:
|
||||
await _raise_lease_valid_elsewhere(run_id, run_mgr, record)
|
||||
raise HTTPException(status_code=409, detail=_cancel_conflict_detail(run_id, record))
|
||||
if wait and record.task is not None:
|
||||
try:
|
||||
|
||||
@@ -20,6 +20,11 @@ from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
||||
from deerflow.utils.time import coerce_iso
|
||||
|
||||
|
||||
def _lease_expired_or_null(lease_col, cutoff: datetime):
|
||||
"""SQLAlchemy filter: True when the lease is NULL or has expired past *cutoff*."""
|
||||
return or_(lease_col.is_(None), lease_col < cutoff)
|
||||
|
||||
|
||||
class RunRepository(RunStore):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
@@ -165,8 +170,13 @@ class RunRepository(RunStore):
|
||||
values: dict[str, Any] = {"status": status, "updated_at": datetime.now(UTC)}
|
||||
if error is not None:
|
||||
values["error"] = error
|
||||
# Guard: only transition rows that are still active. ``interrupted`` is
|
||||
# included because the rollback path goes ``running → interrupted``
|
||||
# (cancel acknowledged) then ``interrupted → error`` (task finalize).
|
||||
# ``error`` and ``success`` remain locked so a peer's takeover (or a
|
||||
# completed run) cannot be overwritten by a late writer.
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(update(RunRow).where(RunRow.run_id == run_id).values(**values))
|
||||
result = await session.execute(update(RunRow).where(RunRow.run_id == run_id, RunRow.status.in_(("pending", "running", "interrupted"))).values(**values))
|
||||
await session.commit()
|
||||
return result.rowcount != 0
|
||||
|
||||
@@ -404,6 +414,27 @@ class RunRepository(RunStore):
|
||||
await session.commit()
|
||||
return result.rowcount != 0
|
||||
|
||||
async def claim_for_takeover(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
grace_seconds: int,
|
||||
error: str,
|
||||
) -> bool:
|
||||
cutoff = datetime.now(UTC) - timedelta(seconds=grace_seconds)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
update(RunRow)
|
||||
.where(
|
||||
RunRow.run_id == run_id,
|
||||
RunRow.status.in_(("pending", "running")),
|
||||
_lease_expired_or_null(RunRow.lease_expires_at, cutoff),
|
||||
)
|
||||
.values(status="error", error=error, updated_at=datetime.now(UTC))
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount != 0
|
||||
|
||||
async def list_inflight_with_expired_lease(
|
||||
self,
|
||||
*,
|
||||
@@ -422,10 +453,7 @@ class RunRepository(RunStore):
|
||||
.where(
|
||||
RunRow.status.in_(("pending", "running")),
|
||||
RunRow.created_at <= before_dt,
|
||||
or_(
|
||||
RunRow.lease_expires_at.is_(None),
|
||||
RunRow.lease_expires_at < cutoff,
|
||||
),
|
||||
_lease_expired_or_null(RunRow.lease_expires_at, cutoff),
|
||||
)
|
||||
.order_by(RunRow.created_at.asc())
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ directly from ``deerflow.runtime``.
|
||||
"""
|
||||
|
||||
from .checkpointer import checkpointer_context, get_checkpointer, make_checkpointer, reset_checkpointer
|
||||
from .runs import ConflictError, DisconnectMode, RunContext, RunManager, RunRecord, RunStatus, UnsupportedStrategyError, run_agent
|
||||
from .runs import 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
|
||||
|
||||
@@ -22,6 +22,7 @@ __all__ = [
|
||||
"make_checkpointer",
|
||||
"reset_checkpointer",
|
||||
# runs
|
||||
"CancelOutcome",
|
||||
"ConflictError",
|
||||
"DisconnectMode",
|
||||
"RunContext",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Run lifecycle management for LangGraph Platform API compatibility."""
|
||||
|
||||
from .manager import ConflictError, RunManager, RunRecord, UnsupportedStrategyError
|
||||
from .manager import CancelOutcome, ConflictError, RunManager, RunRecord, UnsupportedStrategyError
|
||||
from .schemas import DisconnectMode, RunStatus
|
||||
from .worker import RunContext, run_agent
|
||||
|
||||
__all__ = [
|
||||
"CancelOutcome",
|
||||
"ConflictError",
|
||||
"DisconnectMode",
|
||||
"RunContext",
|
||||
|
||||
@@ -10,10 +10,12 @@ import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy.exc import IntegrityError as SAIntegrityError
|
||||
|
||||
from deerflow.utils.time import is_lease_expired
|
||||
from deerflow.utils.time import now_iso as _now_iso
|
||||
|
||||
from .schemas import DisconnectMode, RunStatus
|
||||
@@ -340,6 +342,29 @@ class RunManager:
|
||||
lambda: self._store.update_status(record.run_id, status.value, error=error),
|
||||
)
|
||||
if updated is False:
|
||||
# ``update_status`` is now guarded by ``status IN ('pending','running')``.
|
||||
# False can mean either:
|
||||
# (a) the row was never persisted (initial ``put()`` failed) → recreate.
|
||||
# (b) the row is terminal — either a peer takeover (``error``)
|
||||
# or a local cancel/completion race (``interrupted`` /
|
||||
# ``success``). The log severity branches on which.
|
||||
existing = await self._store.get(record.run_id)
|
||||
if existing is not None:
|
||||
existing_status = existing.get("status")
|
||||
if existing_status == "error":
|
||||
logger.warning(
|
||||
"Run %s status update to %s skipped: store row already at error (peer takeover)",
|
||||
record.run_id,
|
||||
status.value,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Run %s status update to %s skipped: store row already at %s (local cancel/completion race)",
|
||||
record.run_id,
|
||||
status.value,
|
||||
existing_status,
|
||||
)
|
||||
return False
|
||||
return await self._persist_snapshot_to_store(record.run_id, row_recovery_payload)
|
||||
return True
|
||||
except Exception:
|
||||
@@ -662,41 +687,151 @@ class RunManager:
|
||||
await self._persist_model_name(run_id, model_name)
|
||||
logger.info("Run %s model_name=%s", run_id, model_name)
|
||||
|
||||
async def cancel(self, run_id: str, *, action: str = "interrupt") -> bool:
|
||||
async def cancel(self, run_id: str, *, action: str = "interrupt") -> CancelOutcome:
|
||||
"""Request cancellation of a run.
|
||||
|
||||
When the call lands on the owning worker the run is cancelled
|
||||
locally as before (in-memory abort + status persisted to store).
|
||||
|
||||
When the call lands on a non-owning worker in a multi-worker
|
||||
deployment with heartbeat enabled:
|
||||
|
||||
- **Lease expired** — the run's lease has passed the grace
|
||||
threshold, so this worker takes ownership and marks it as
|
||||
``error``. The owning worker is assumed dead (its heartbeat
|
||||
stopped renewing).
|
||||
|
||||
- **Lease still valid** — returns ``lease_valid_elsewhere`` so
|
||||
the caller can return HTTP 409 + ``Retry-After`` to tell the
|
||||
client when to retry.
|
||||
|
||||
In single-worker mode (``heartbeat_enabled=False``) store-only
|
||||
hydrated runs that aren't in-memory return ``not_active_locally``,
|
||||
preserving the original 409 behaviour.
|
||||
|
||||
Args:
|
||||
run_id: The run ID to cancel.
|
||||
action: "interrupt" keeps checkpoint, "rollback" reverts to pre-run state.
|
||||
action: ``"interrupt"`` keeps checkpoint, ``"rollback"``
|
||||
reverts to pre-run state.
|
||||
|
||||
Sets the abort event with the action reason and cancels the asyncio task.
|
||||
Returns ``True`` if cancellation was initiated **or** the run was already
|
||||
interrupted (idempotent — a second cancel is a no-op success).
|
||||
Returns ``False`` only when the run is unknown to this worker or has
|
||||
reached a terminal state other than interrupted (completed, failed, etc.).
|
||||
Returns:
|
||||
A :class:`CancelOutcome` enum describing what happened.
|
||||
"""
|
||||
# ------------------------------------------------------------------
|
||||
# Local path — this worker owns the run in-memory.
|
||||
# ------------------------------------------------------------------
|
||||
async with self._lock:
|
||||
record = self._runs.get(run_id)
|
||||
if record is None:
|
||||
return False
|
||||
if record.status == RunStatus.interrupted:
|
||||
return True # idempotent — already cancelled on this worker
|
||||
if record.status not in (RunStatus.pending, RunStatus.running):
|
||||
return False
|
||||
record.abort_action = action
|
||||
record.abort_event.set()
|
||||
task_active = record.task is not None and not record.task.done()
|
||||
record.finalizing = task_active
|
||||
if task_active:
|
||||
record.task.cancel()
|
||||
record.status = RunStatus.interrupted
|
||||
record.updated_at = _now_iso()
|
||||
await self._persist_status(record, RunStatus.interrupted)
|
||||
logger.info("Run %s cancelled (action=%s)", run_id, action)
|
||||
return True
|
||||
if record is not None:
|
||||
if record.status == RunStatus.interrupted:
|
||||
return CancelOutcome.cancelled # idempotent
|
||||
if record.status not in (RunStatus.pending, RunStatus.running):
|
||||
return CancelOutcome.not_cancellable
|
||||
record.abort_action = action
|
||||
record.abort_event.set()
|
||||
task_active = record.task is not None and not record.task.done()
|
||||
record.finalizing = task_active
|
||||
if task_active:
|
||||
record.task.cancel()
|
||||
record.status = RunStatus.interrupted
|
||||
record.updated_at = _now_iso()
|
||||
|
||||
# Persist outside the lock so store calls don't block other mutations.
|
||||
if record is not None:
|
||||
persisted = await self._persist_status(record, RunStatus.interrupted)
|
||||
if not persisted and self._store is not None:
|
||||
# ``_persist_status`` already fetched ``existing`` internally;
|
||||
# re-check the store to see if a peer takeover flipped the
|
||||
# row to ``error`` between our in-memory cancel and the
|
||||
# guarded ``update_status``. If so, surface ``taken_over``
|
||||
# so the client sees a status consistent with the store.
|
||||
try:
|
||||
existing = await self._store.get(run_id)
|
||||
except Exception:
|
||||
existing = None
|
||||
if existing is not None and existing.get("status") == "error":
|
||||
# The in-memory ``record.status`` is still ``interrupted``
|
||||
# (set under the lock above) while the store row is now
|
||||
# ``error``. This transient staleness is harmless: the
|
||||
# ``_persist_status`` guard prevents the late finalisation
|
||||
# write from overwriting the takeover, and the store is the
|
||||
# authoritative source for subsequent reads.
|
||||
logger.info("Run %s local cancel superseded by peer takeover", run_id)
|
||||
return CancelOutcome.taken_over
|
||||
logger.info("Run %s cancelled (action=%s)", run_id, action)
|
||||
return CancelOutcome.cancelled
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Non-local path — no in-memory record, must consult the store.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
if not self.heartbeat_enabled:
|
||||
return CancelOutcome.not_active_locally
|
||||
|
||||
if self._store is None:
|
||||
return CancelOutcome.unknown
|
||||
|
||||
try:
|
||||
row = await self._store.get(run_id)
|
||||
except Exception:
|
||||
logger.warning("Failed to fetch run %s from store during cancel", run_id, exc_info=True)
|
||||
return CancelOutcome.unknown
|
||||
|
||||
if row is None:
|
||||
return CancelOutcome.unknown
|
||||
|
||||
store_status = row.get("status")
|
||||
if store_status not in ("pending", "running"):
|
||||
return CancelOutcome.not_cancellable
|
||||
|
||||
grace_seconds = self.grace_seconds
|
||||
lease_expires_at: str | None = row.get("lease_expires_at")
|
||||
|
||||
if not is_lease_expired(lease_expires_at, grace_seconds=grace_seconds):
|
||||
return CancelOutcome.lease_valid_elsewhere
|
||||
|
||||
take_over_msg = f"Run reclaimed by worker {self._worker_id}: the owning worker ({row.get('owner_worker_id') or 'unknown'}) stopped renewing its lease and is presumed dead."
|
||||
try:
|
||||
taken = await self._call_store_with_retry(
|
||||
"claim_for_takeover",
|
||||
run_id,
|
||||
lambda: self._store.claim_for_takeover(
|
||||
run_id,
|
||||
grace_seconds=grace_seconds,
|
||||
error=take_over_msg,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Take-over claim for run %s failed with exception", run_id, exc_info=True)
|
||||
return CancelOutcome.unknown
|
||||
|
||||
if taken:
|
||||
logger.warning("Run %s taken over by worker %s (action=%s)", run_id, self._worker_id, action)
|
||||
return CancelOutcome.taken_over
|
||||
|
||||
# The conditional UPDATE matched 0 rows. Two causes:
|
||||
# (a) the owner renewed the lease → lease_valid_elsewhere.
|
||||
# (b) the row went terminal between our read and the claim
|
||||
# (run finished, or another worker already took it over)
|
||||
# → not_cancellable or taken_over.
|
||||
# Re-read to distinguish.
|
||||
try:
|
||||
fresh = await self._store.get(run_id)
|
||||
except Exception:
|
||||
fresh = None
|
||||
if fresh is None:
|
||||
return CancelOutcome.unknown
|
||||
fresh_status = fresh.get("status")
|
||||
if fresh_status not in ("pending", "running"):
|
||||
if fresh_status == "error":
|
||||
logger.info("Run %s takeover lost to another worker already at error", run_id)
|
||||
return CancelOutcome.taken_over
|
||||
return CancelOutcome.not_cancellable
|
||||
# Row is still active — lease must have been renewed by the owner.
|
||||
return CancelOutcome.lease_valid_elsewhere
|
||||
|
||||
def _compute_lease_expires_at(self) -> str | None:
|
||||
"""Compute the lease expiration timestamp for a new run.
|
||||
"""Return the lease expiry ISO timestamp for a freshly created run.
|
||||
|
||||
Returns ``None`` when heartbeat is disabled (single-worker mode) so
|
||||
reconciliation treats crashed runs as orphans (NULL lease) and
|
||||
@@ -967,6 +1102,17 @@ class RunManager:
|
||||
return False
|
||||
return self._run_ownership_config.heartbeat_enabled
|
||||
|
||||
@property
|
||||
def grace_seconds(self) -> int:
|
||||
"""Return the configured grace seconds.
|
||||
|
||||
All current callers are downstream of ``heartbeat_enabled``, which
|
||||
is False whenever ``_run_ownership_config`` is None. The fallback
|
||||
matches the Pydantic model default and is defensive against future
|
||||
callers that might reach this property without that guard.
|
||||
"""
|
||||
return self._run_ownership_config.grace_seconds if self._run_ownership_config else 10
|
||||
|
||||
async def start_heartbeat(self) -> None:
|
||||
"""Start the background lease-renewal task.
|
||||
|
||||
@@ -1084,6 +1230,22 @@ class RunManager:
|
||||
# fields). Re-acquiring ``self._lock`` here would
|
||||
# serialise against unrelated run mutations for no gain.
|
||||
record.lease_expires_at = new_expiry
|
||||
else:
|
||||
# ``update_lease`` returned False — the row was claimed
|
||||
# by another worker (status is no longer pending/running,
|
||||
# or ``owner_worker_id`` changed). Stop the local task so
|
||||
# we don't waste CPU or overwrite the takeover status on
|
||||
# finalisation.
|
||||
logger.warning(
|
||||
"Run %s lease renewal failed (status=%s,owner=%s) – worker likely taken over; aborting local task",
|
||||
run_id,
|
||||
record.status.value,
|
||||
record.owner_worker_id,
|
||||
)
|
||||
record.abort_event.set()
|
||||
task_active = record.task is not None and not record.task.done()
|
||||
if task_active:
|
||||
record.task.cancel()
|
||||
except Exception:
|
||||
logger.warning("Failed to renew lease for run %s", run_id, exc_info=True)
|
||||
|
||||
@@ -1198,6 +1360,17 @@ class RunManager:
|
||||
logger.info("Drained %d in-flight run(s) on shutdown (%d settled within %.1fs)", len(inflight), len(inflight) - len(pending), timeout)
|
||||
|
||||
|
||||
class CancelOutcome(StrEnum):
|
||||
"""Result of a :meth:`RunManager.cancel` call."""
|
||||
|
||||
cancelled = "cancelled"
|
||||
taken_over = "taken_over"
|
||||
lease_valid_elsewhere = "lease_valid_elsewhere"
|
||||
not_cancellable = "not_cancellable"
|
||||
not_active_locally = "not_active_locally"
|
||||
unknown = "unknown"
|
||||
|
||||
|
||||
class ConflictError(Exception):
|
||||
"""Raised when multitask_strategy=reject and thread has inflight runs."""
|
||||
|
||||
|
||||
@@ -156,6 +156,28 @@ class RunStore(abc.ABC):
|
||||
"""Renew the lease on an active run. Returns ``False`` when no row matched."""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def claim_for_takeover(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
grace_seconds: int,
|
||||
error: str,
|
||||
) -> 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.
|
||||
|
||||
Returns ``False`` when:
|
||||
- the run is no longer ``pending`` / ``running``,
|
||||
- the lease is still valid (owner heartbeat is alive), or
|
||||
- the row doesn't exist.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def list_inflight_with_expired_lease(
|
||||
self,
|
||||
|
||||
@@ -88,13 +88,18 @@ class MemoryRunStore(RunStore):
|
||||
return results[:limit]
|
||||
|
||||
async def update_status(self, run_id, status, *, error=None):
|
||||
if run_id in self._runs:
|
||||
self._runs[run_id]["status"] = status
|
||||
if error is not None:
|
||||
self._runs[run_id]["error"] = error
|
||||
self._runs[run_id]["updated_at"] = datetime.now(UTC).isoformat()
|
||||
return True
|
||||
return False
|
||||
run = self._runs.get(run_id)
|
||||
if run is None:
|
||||
return False
|
||||
# Guard: only transition rows that are still active. ``interrupted``
|
||||
# is included for the rollback path (``interrupted → error`` finalize).
|
||||
if run["status"] not in ("pending", "running", "interrupted"):
|
||||
return False
|
||||
run["status"] = status
|
||||
if error is not None:
|
||||
run["error"] = error
|
||||
run["updated_at"] = datetime.now(UTC).isoformat()
|
||||
return True
|
||||
|
||||
async def update_model_name(self, run_id, model_name):
|
||||
if run_id in self._runs:
|
||||
@@ -194,6 +199,28 @@ class MemoryRunStore(RunStore):
|
||||
run["updated_at"] = datetime.now(UTC).isoformat()
|
||||
return True
|
||||
|
||||
async def claim_for_takeover(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
grace_seconds: int,
|
||||
error: str,
|
||||
) -> bool:
|
||||
from deerflow.utils.time import is_lease_expired
|
||||
|
||||
run = self._runs.get(run_id)
|
||||
if run is None:
|
||||
return False
|
||||
if run["status"] not in ("pending", "running"):
|
||||
return False
|
||||
lease = run.get("lease_expires_at")
|
||||
if not is_lease_expired(lease, grace_seconds=grace_seconds):
|
||||
return False
|
||||
run["status"] = "error"
|
||||
run["error"] = error
|
||||
run["updated_at"] = datetime.now(UTC).isoformat()
|
||||
return True
|
||||
|
||||
async def list_inflight_with_expired_lease(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -15,9 +15,29 @@ records that historically stored ``str(time.time())`` floats.
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
__all__ = ["coerce_iso", "is_lease_expired", "now_iso"]
|
||||
|
||||
|
||||
def is_lease_expired(lease_expires_at: str | None, *, grace_seconds: int) -> bool:
|
||||
"""Return ``True`` when *lease_expires_at* has elapsed past grace.
|
||||
|
||||
A NULL lease (pre-ownership data) is always considered expired so
|
||||
take-over (cancel from a non-owning worker) can reclaim it in the
|
||||
same way reconciliation does. Unparseable timestamps are also
|
||||
treated as expired (defence in depth).
|
||||
"""
|
||||
if lease_expires_at is None:
|
||||
return True
|
||||
try:
|
||||
dt = datetime.fromisoformat(lease_expires_at)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
except (ValueError, TypeError):
|
||||
return True
|
||||
return dt < datetime.now(UTC) - timedelta(seconds=grace_seconds)
|
||||
|
||||
__all__ = ["coerce_iso", "now_iso"]
|
||||
|
||||
_UNIX_TIMESTAMP_PATTERN = re.compile(r"^\d{10}(?:\.\d+)?$")
|
||||
"""Matches the unix-timestamp string shape historically written by
|
||||
|
||||
@@ -14,7 +14,7 @@ from _router_auth_helpers import make_authed_test_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.gateway.routers import thread_runs
|
||||
from deerflow.runtime import RunManager, RunStatus
|
||||
from deerflow.runtime import CancelOutcome, RunManager, RunStatus
|
||||
|
||||
THREAD_ID = "thread-cancel-test"
|
||||
|
||||
@@ -49,22 +49,22 @@ def _create_interrupted_run(mgr: RunManager) -> str:
|
||||
|
||||
|
||||
class TestRunManagerCancelIdempotency:
|
||||
def test_cancel_returns_true_for_already_interrupted_run(self):
|
||||
"""cancel() must return True when the run is already interrupted."""
|
||||
def test_cancel_returns_cancelled_for_already_interrupted_run(self):
|
||||
"""cancel() must return CancelledOutcome.cancelled when the run is already interrupted."""
|
||||
|
||||
async def run():
|
||||
mgr = RunManager()
|
||||
record = await mgr.create(THREAD_ID)
|
||||
await mgr.set_status(record.run_id, RunStatus.running)
|
||||
first = await mgr.cancel(record.run_id)
|
||||
assert first is True
|
||||
assert first == CancelOutcome.cancelled
|
||||
second = await mgr.cancel(record.run_id)
|
||||
assert second is True # idempotent
|
||||
assert second == CancelOutcome.cancelled # idempotent
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
def test_cancel_returns_false_for_successful_run(self):
|
||||
"""cancel() must still return False for runs that completed successfully."""
|
||||
def test_cancel_returns_not_cancellable_for_successful_run(self):
|
||||
"""cancel() must return not_cancellable for runs that completed successfully."""
|
||||
|
||||
async def run():
|
||||
mgr = RunManager()
|
||||
@@ -72,15 +72,15 @@ class TestRunManagerCancelIdempotency:
|
||||
await mgr.set_status(record.run_id, RunStatus.running)
|
||||
await mgr.set_status(record.run_id, RunStatus.success)
|
||||
result = await mgr.cancel(record.run_id)
|
||||
assert result is False
|
||||
assert result == CancelOutcome.not_cancellable
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
def test_cancel_returns_false_for_unknown_run(self):
|
||||
def test_cancel_returns_not_active_locally_for_unknown_run(self):
|
||||
async def run():
|
||||
mgr = RunManager()
|
||||
result = await mgr.cancel("nonexistent-run-id")
|
||||
assert result is False
|
||||
assert result == CancelOutcome.not_active_locally
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import pytest
|
||||
|
||||
from deerflow.config.run_ownership_config import RunOwnershipConfig
|
||||
from deerflow.runtime import RunManager, RunStatus
|
||||
from deerflow.runtime.runs.manager import ConflictError, _generate_worker_id
|
||||
from deerflow.runtime.runs.manager import CancelOutcome, ConflictError, _generate_worker_id
|
||||
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -467,30 +467,30 @@ async def test_cancel_local_run_succeeds():
|
||||
await manager.set_status(record.run_id, RunStatus.running)
|
||||
|
||||
result = await manager.cancel(record.run_id)
|
||||
assert result is True
|
||||
assert result == CancelOutcome.cancelled
|
||||
assert record.status == RunStatus.interrupted
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cancel_unknown_run_returns_false():
|
||||
"""Cancel must return False for a run not known to this worker."""
|
||||
"""Cancel must return not_active_locally for a run not known to this worker (heartbeat off)."""
|
||||
store = MemoryRunStore()
|
||||
manager = _make_manager(store=store)
|
||||
|
||||
result = await manager.cancel("nonexistent-run")
|
||||
assert result is False
|
||||
assert result == CancelOutcome.not_active_locally
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cancel_idempotent():
|
||||
"""Cancel must return True when the run is already interrupted."""
|
||||
"""Cancel must return cancelled when the run is already interrupted."""
|
||||
store = MemoryRunStore()
|
||||
manager = _make_manager(store=store)
|
||||
record = await manager.create("thread-1")
|
||||
await manager.set_status(record.run_id, RunStatus.interrupted)
|
||||
|
||||
result = await manager.cancel(record.run_id)
|
||||
assert result is True
|
||||
assert result == CancelOutcome.cancelled
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -965,3 +965,565 @@ async def test_list_inflight_with_expired_lease_null_lease_always_reclaimed():
|
||||
results = await store.list_inflight_with_expired_lease(grace_seconds=grace)
|
||||
result_ids = {r["run_id"] for r in results}
|
||||
assert "null-run" in result_ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# claim_for_takeover — store primitive
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_claim_for_takeover_succeeds_with_expired_lease():
|
||||
"""claim_for_takeover must succeed when the lease has passed the grace window."""
|
||||
store = MemoryRunStore()
|
||||
grace = 10
|
||||
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")
|
||||
assert ok is True
|
||||
|
||||
row = await store.get("run-1")
|
||||
assert row is not None
|
||||
assert row["status"] == "error"
|
||||
assert row["error"] == "claimed"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_claim_for_takeover_fails_with_valid_lease():
|
||||
"""claim_for_takeover must return False when the lease is still valid."""
|
||||
store = MemoryRunStore()
|
||||
grace = 10
|
||||
valid_lease = (datetime.now(UTC) + timedelta(seconds=60)).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=valid_lease)
|
||||
|
||||
ok = await store.claim_for_takeover("run-1", grace_seconds=grace, error="claimed")
|
||||
assert ok is False
|
||||
|
||||
row = await store.get("run-1")
|
||||
assert row is not None
|
||||
assert row["status"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_claim_for_takeover_succeeds_with_null_lease():
|
||||
"""NULL-lease rows (pre-ownership data) must be claimable."""
|
||||
store = MemoryRunStore()
|
||||
await store.put("run-null", thread_id="t1", status="running", created_at=datetime.now(UTC).isoformat())
|
||||
|
||||
ok = await store.claim_for_takeover("run-null", grace_seconds=10, error="claimed")
|
||||
assert ok is True
|
||||
|
||||
row = await store.get("run-null")
|
||||
assert row["status"] == "error"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_claim_for_takeover_fails_on_terminal_status():
|
||||
"""claim_for_takeover must return False for already-terminal runs."""
|
||||
store = MemoryRunStore()
|
||||
await store.put("run-done", thread_id="t1", status="success", created_at=datetime.now(UTC).isoformat())
|
||||
|
||||
ok = await store.claim_for_takeover("run-done", grace_seconds=10, error="claimed")
|
||||
assert ok is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_claim_for_takeover_fails_for_nonexistent_run():
|
||||
"""claim_for_takeover must return False when the run doesn't exist."""
|
||||
store = MemoryRunStore()
|
||||
ok = await store.claim_for_takeover("no-such-run", grace_seconds=10, error="claimed")
|
||||
assert ok is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cancel() cross-worker takeover — work item 4
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cancel_takeover_from_crashed_worker():
|
||||
"""cancel must take over (mark error) when lease is expired and owner is another worker."""
|
||||
store = MemoryRunStore()
|
||||
grace = 10
|
||||
expired_lease = (datetime.now(UTC) - timedelta(seconds=grace + 5)).isoformat()
|
||||
await store.put("run-expired", thread_id="t1", status="running", created_at=datetime.now(UTC).isoformat(), owner_worker_id="dead-worker", lease_expires_at=expired_lease)
|
||||
|
||||
manager = _make_manager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=True, grace_seconds=grace))
|
||||
outcome = await manager.cancel("run-expired")
|
||||
assert outcome == CancelOutcome.taken_over
|
||||
|
||||
row = await store.get("run-expired")
|
||||
assert row is not None
|
||||
assert row["status"] == "error"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cancel_refuses_active_lease_from_other_worker():
|
||||
"""cancel must return lease_valid_elsewhere when the run is owned by another worker with a valid lease."""
|
||||
store = MemoryRunStore()
|
||||
grace = 10
|
||||
valid_lease = (datetime.now(UTC) + timedelta(seconds=60)).isoformat()
|
||||
await store.put("run-alive", thread_id="t1", status="running", created_at=datetime.now(UTC).isoformat(), owner_worker_id="alive-worker", lease_expires_at=valid_lease)
|
||||
|
||||
manager = _make_manager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=True, grace_seconds=grace))
|
||||
outcome = await manager.cancel("run-alive")
|
||||
assert outcome == CancelOutcome.lease_valid_elsewhere
|
||||
|
||||
row = await store.get("run-alive")
|
||||
assert row is not None
|
||||
assert row["status"] == "running" # untouched
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cancel_returns_unknown_when_no_store():
|
||||
"""cancel must return unknown when there's no store and the run is not in memory."""
|
||||
manager = _make_manager(run_ownership_config=_lease_config(heartbeat_enabled=True))
|
||||
outcome = await manager.cancel("no-such-run")
|
||||
assert outcome == CancelOutcome.unknown
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cancel_returns_not_active_locally_when_heartbeat_disabled():
|
||||
"""With heartbeat disabled, store-only runs must not be cancellable (old 409 path)."""
|
||||
store = MemoryRunStore()
|
||||
await store.put("store-only", thread_id="t1", status="running", created_at=datetime.now(UTC).isoformat())
|
||||
|
||||
manager = _make_manager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=False))
|
||||
outcome = await manager.cancel("store-only")
|
||||
assert outcome == CancelOutcome.not_active_locally
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cancel_takeover_race_owner_renewed_lease():
|
||||
"""When the owner heartbeats between our read and the conditional UPDATE, cancel must return lease_valid_elsewhere."""
|
||||
store = MemoryRunStore()
|
||||
grace = 10
|
||||
expired_lease = (datetime.now(UTC) - timedelta(seconds=grace + 5)).isoformat()
|
||||
await store.put("run-race", thread_id="t1", status="running", created_at=datetime.now(UTC).isoformat(), owner_worker_id="w-a", lease_expires_at=expired_lease)
|
||||
|
||||
# Simulate the race: right before claim_for_takeover writes, another
|
||||
# heartbeat renews the lease. We monkey-patch claim_for_takeover to
|
||||
# simulate the lease having been renewed.
|
||||
original = store.claim_for_takeover
|
||||
|
||||
async def race_lost(run_id, *, grace_seconds, error):
|
||||
# Simulate a heartbeat renewal between the read and the write
|
||||
run = store._runs.get(run_id)
|
||||
if run and run["status"] in ("pending", "running"):
|
||||
run["lease_expires_at"] = (datetime.now(UTC) + timedelta(seconds=60)).isoformat()
|
||||
return await original(run_id, grace_seconds=grace_seconds, error=error)
|
||||
|
||||
store.claim_for_takeover = race_lost
|
||||
manager = _make_manager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=True, grace_seconds=grace))
|
||||
|
||||
outcome = await manager.cancel("run-race")
|
||||
assert outcome == CancelOutcome.lease_valid_elsewhere
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cancel_takeover_respects_grace_seconds():
|
||||
"""Cancel must not take over when the lease is within the grace window."""
|
||||
store = MemoryRunStore()
|
||||
grace = 10
|
||||
# Lease expired, but only by 3s — still within the 10s grace window
|
||||
just_expired = (datetime.now(UTC) - timedelta(seconds=3)).isoformat()
|
||||
await store.put("run-grace", thread_id="t1", status="running", created_at=datetime.now(UTC).isoformat(), owner_worker_id="w-a", lease_expires_at=just_expired)
|
||||
|
||||
manager = _make_manager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=True, grace_seconds=grace))
|
||||
outcome = await manager.cancel("run-grace")
|
||||
assert outcome == CancelOutcome.lease_valid_elsewhere
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cancel_not_cancellable_for_store_terminal_run():
|
||||
"""cancel must return not_cancellable when the store run is already in a terminal state."""
|
||||
store = MemoryRunStore()
|
||||
await store.put("run-done", thread_id="t1", status="success", created_at=datetime.now(UTC).isoformat())
|
||||
|
||||
manager = _make_manager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=True))
|
||||
outcome = await manager.cancel("run-done")
|
||||
assert outcome == CancelOutcome.not_cancellable
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP-level — cancel endpoint cross-worker responses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_cancel_test_app(mgr: RunManager):
|
||||
"""Build a TestClient wired with the thread_runs router + memory bridge."""
|
||||
from _router_auth_helpers import make_authed_test_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.gateway.routers import thread_runs
|
||||
from deerflow.runtime import MemoryStreamBridge
|
||||
|
||||
app = make_authed_test_app()
|
||||
app.include_router(thread_runs.router)
|
||||
app.state.run_manager = mgr
|
||||
app.state.stream_bridge = MemoryStreamBridge()
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def test_http_cancel_non_owner_valid_lease_returns_409_with_retry_after():
|
||||
"""POST /cancel on a non-owning worker with a valid lease must return 409 + Retry-After."""
|
||||
store = MemoryRunStore()
|
||||
grace = 10
|
||||
valid_lease = (datetime.now(UTC) + timedelta(seconds=60)).isoformat()
|
||||
asyncio.run(
|
||||
store.put(
|
||||
"run-alive",
|
||||
thread_id="t1",
|
||||
status="running",
|
||||
created_at=datetime.now(UTC).isoformat(),
|
||||
owner_worker_id="alive-worker",
|
||||
lease_expires_at=valid_lease,
|
||||
)
|
||||
)
|
||||
mgr = _make_manager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=True, grace_seconds=grace))
|
||||
client = _make_cancel_test_app(mgr)
|
||||
|
||||
resp = client.post("/api/threads/t1/runs/run-alive/cancel")
|
||||
assert resp.status_code == 409
|
||||
assert "Retry-After" in resp.headers
|
||||
# Retry-After = remaining lease (≈60s) + grace (10s) = ≈70s
|
||||
retry_after = int(resp.headers["Retry-After"])
|
||||
assert 50 <= retry_after <= 75
|
||||
|
||||
# Store row must be untouched
|
||||
row = asyncio.run(store.get("run-alive"))
|
||||
assert row["status"] == "running"
|
||||
|
||||
|
||||
def test_http_cancel_non_owner_expired_lease_returns_202_takeover():
|
||||
"""POST /cancel on a non-owning worker with an expired lease must return 202 (takeover)."""
|
||||
store = MemoryRunStore()
|
||||
grace = 10
|
||||
expired_lease = (datetime.now(UTC) - timedelta(seconds=grace + 30)).isoformat()
|
||||
asyncio.run(
|
||||
store.put(
|
||||
"run-dead",
|
||||
thread_id="t1",
|
||||
status="running",
|
||||
created_at=datetime.now(UTC).isoformat(),
|
||||
owner_worker_id="dead-worker",
|
||||
lease_expires_at=expired_lease,
|
||||
)
|
||||
)
|
||||
mgr = _make_manager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=True, grace_seconds=grace))
|
||||
client = _make_cancel_test_app(mgr)
|
||||
|
||||
resp = client.post("/api/threads/t1/runs/run-dead/cancel")
|
||||
assert resp.status_code == 202
|
||||
|
||||
# Store row must be marked error
|
||||
row = asyncio.run(store.get("run-dead"))
|
||||
assert row["status"] == "error"
|
||||
|
||||
|
||||
def test_http_stream_action_interrupt_takeover_returns_202_not_hang():
|
||||
"""POST /stream?action=interrupt on a dead-owner run must return 202 immediately, not hang on SSE."""
|
||||
store = MemoryRunStore()
|
||||
grace = 10
|
||||
expired_lease = (datetime.now(UTC) - timedelta(seconds=grace + 30)).isoformat()
|
||||
asyncio.run(
|
||||
store.put(
|
||||
"run-dead-stream",
|
||||
thread_id="t1",
|
||||
status="running",
|
||||
created_at=datetime.now(UTC).isoformat(),
|
||||
owner_worker_id="dead-worker",
|
||||
lease_expires_at=expired_lease,
|
||||
)
|
||||
)
|
||||
mgr = _make_manager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=True, grace_seconds=grace))
|
||||
client = _make_cancel_test_app(mgr)
|
||||
|
||||
# This must NOT hang — the takeover path returns 202 before reaching StreamingResponse.
|
||||
resp = client.post("/api/threads/t1/runs/run-dead-stream/stream", params={"action": "interrupt"})
|
||||
assert resp.status_code == 202
|
||||
|
||||
row = asyncio.run(store.get("run-dead-stream"))
|
||||
assert row["status"] == "error"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Split-brain defences — update_status guard + heartbeat self-termination
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_status_rejects_terminal_row():
|
||||
"""update_status must return False when the store row is already terminal
|
||||
(error/success), so a late writer cannot overwrite a peer's takeover or
|
||||
a completed run. interrupted is NOT terminal — the rollback path needs
|
||||
``interrupted → error`` to finalize."""
|
||||
store = MemoryRunStore()
|
||||
# error (takeover) must stay locked
|
||||
await store.put("run-err", thread_id="t1", status="error", created_at=datetime.now(UTC).isoformat())
|
||||
assert await store.update_status("run-err", "success") is False
|
||||
assert (await store.get("run-err"))["status"] == "error"
|
||||
|
||||
# success must stay locked
|
||||
await store.put("run-ok", thread_id="t1", status="success", created_at=datetime.now(UTC).isoformat())
|
||||
assert await store.update_status("run-ok", "error") is False
|
||||
assert (await store.get("run-ok"))["status"] == "success"
|
||||
|
||||
# interrupted → error MUST pass (rollback finalize path)
|
||||
await store.put("run-rb", thread_id="t1", status="interrupted", created_at=datetime.now(UTC).isoformat())
|
||||
assert await store.update_status("run-rb", "error", error="Rolled back by user") is True
|
||||
row = await store.get("run-rb")
|
||||
assert row["status"] == "error"
|
||||
assert row["error"] == "Rolled back by user"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_persist_status_skips_recovery_when_row_taken_over():
|
||||
"""_persist_status must not recreate a row that was taken over by another worker.
|
||||
|
||||
When update_status returns False, the recovery path checks whether the
|
||||
row still exists. A row that exists but is terminal (taken over) must
|
||||
be left alone — calling put() would overwrite the takeover."""
|
||||
store = MemoryRunStore()
|
||||
mgr = RunManager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=True))
|
||||
|
||||
# Simulate: this worker created and started a run, but a peer took it over.
|
||||
record = await mgr.create("thread-1")
|
||||
await mgr.set_status(record.run_id, RunStatus.running)
|
||||
# Peer takeover: directly flip the store row to error
|
||||
await store.update_status(record.run_id, "error")
|
||||
# Now simulate the original owner's task finishing and trying to write success
|
||||
ok = await mgr._persist_status(record, RunStatus.success)
|
||||
assert ok is False # skipped recovery, row already exists and is terminal
|
||||
row = await store.get(record.run_id)
|
||||
assert row["status"] == "error" # not overwritten
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_heartbeat_cancels_task_on_lease_loss():
|
||||
"""Heartbeat must cancel the local asyncio task when update_lease returns False.
|
||||
|
||||
If the store row was claimed by another worker (status no longer
|
||||
pending/running, or owner changed), the heartbeat tick must abort the
|
||||
local task so wasted CPU is bounded to ~10s instead of the full task
|
||||
lifetime."""
|
||||
store = MemoryRunStore()
|
||||
mgr = RunManager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=True, lease_seconds=30))
|
||||
|
||||
# Create a run that this worker owns
|
||||
record = await mgr.create("thread-1")
|
||||
await mgr.set_status(record.run_id, RunStatus.running)
|
||||
|
||||
# Spawn a dummy task so cancel has something to stop
|
||||
loop = asyncio.get_running_loop()
|
||||
record.task = loop.create_task(asyncio.sleep(3600))
|
||||
|
||||
# Simulate takeover: directly flip the store row to error
|
||||
await store.update_status(record.run_id, "error")
|
||||
|
||||
# Run a single heartbeat tick — it should see update_lease return False
|
||||
# and cancel the task
|
||||
await mgr._renew_leases()
|
||||
|
||||
# Let the event loop process the cancellation (task.cancel() schedules,
|
||||
# doesn't await).
|
||||
await asyncio.sleep(0)
|
||||
assert record.task.cancelled()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cancel_returns_taken_over_when_peer_claims_during_local_cancel():
|
||||
"""When a peer's claim_for_takeover flips the row to error between this
|
||||
worker's in-memory cancel and the guarded update_status, cancel() must
|
||||
surface taken_over (not cancelled) so the client sees a status consistent
|
||||
with the store."""
|
||||
store = MemoryRunStore()
|
||||
mgr = RunManager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=True))
|
||||
|
||||
record = await mgr.create("thread-1")
|
||||
await mgr.set_status(record.run_id, RunStatus.running)
|
||||
|
||||
# Wrap update_status so that the first call (from cancel's _persist_status)
|
||||
# is rejected as if a peer already marked the row error. This simulates
|
||||
# the race: in-memory cancel succeeds, but store write is blocked.
|
||||
original = store.update_status
|
||||
|
||||
async def race_update(run_id, status, *, error=None):
|
||||
# Simulate peer takeover: flip to error before our write lands
|
||||
run = store._runs.get(run_id)
|
||||
if run and run["status"] == "running" and status == "interrupted":
|
||||
run["status"] = "error"
|
||||
run["error"] = "peer takeover"
|
||||
run["updated_at"] = datetime.now(UTC).isoformat()
|
||||
return False # our write was blocked
|
||||
return await original(run_id, status, error=error)
|
||||
|
||||
store.update_status = race_update
|
||||
|
||||
outcome = await mgr.cancel(record.run_id)
|
||||
assert outcome == CancelOutcome.taken_over
|
||||
|
||||
# Store row must reflect the takeover, not the local cancel
|
||||
row = await store.get(record.run_id)
|
||||
assert row["status"] == "error"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cancel_action_rollback_finalizes_to_error_in_store():
|
||||
"""action=rollback must end up as error in the store with the
|
||||
"Rolled back by user" message preserved.
|
||||
|
||||
Regression guard: the update_status guard was originally
|
||||
``status IN ('pending','running')`` which blocked the rollback path's
|
||||
``interrupted → error`` transition — the store stayed interrupted and
|
||||
the rollback message was lost.
|
||||
"""
|
||||
store = MemoryRunStore()
|
||||
mgr = RunManager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=True))
|
||||
|
||||
record = await mgr.create("thread-1")
|
||||
await mgr.set_status(record.run_id, RunStatus.running)
|
||||
|
||||
# Step 1: cancel(action=rollback) flips running → interrupted
|
||||
outcome = await mgr.cancel(record.run_id, action="rollback")
|
||||
assert outcome == CancelOutcome.cancelled
|
||||
row = await store.get(record.run_id)
|
||||
assert row["status"] == "interrupted"
|
||||
|
||||
# Step 2: worker.py finalize path — task raises CancelledError, then
|
||||
# set_status(error, "Rolled back by user"). The widened guard
|
||||
# (interrupted is in the whitelist) must let this through.
|
||||
await mgr.set_status(record.run_id, RunStatus.error, error="Rolled back by user")
|
||||
row = await store.get(record.run_id)
|
||||
assert row["status"] == "error"
|
||||
assert row["error"] == "Rolled back by user"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cancel() claim_for_takeover False → re-read precision
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cancel_claim_lost_to_terminal_returns_not_cancellable():
|
||||
"""When cancel() reads the run as active but claim_for_takeover returns
|
||||
False because the row went terminal (run finished) between the read and
|
||||
the conditional UPDATE, the re-read must surface not_cancellable."""
|
||||
store = MemoryRunStore()
|
||||
mgr = _make_manager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=True, grace_seconds=10))
|
||||
|
||||
# Seed as running so cancel()'s first read passes the status guard.
|
||||
expired = (datetime.now(UTC) - timedelta(seconds=60)).isoformat()
|
||||
await store.put(
|
||||
"run-race",
|
||||
thread_id="t1",
|
||||
status="running",
|
||||
owner_worker_id="w-a",
|
||||
lease_expires_at=expired,
|
||||
created_at=datetime.now(UTC).isoformat(),
|
||||
)
|
||||
|
||||
# Wrap claim_for_takeover: flip the row to success just before the
|
||||
# conditional UPDATE so it matches 0 rows.
|
||||
original = store.claim_for_takeover
|
||||
|
||||
async def race_claim(run_id, *, grace_seconds, error):
|
||||
store._runs[run_id]["status"] = "success"
|
||||
return await original(run_id, grace_seconds=grace_seconds, error=error)
|
||||
|
||||
store.claim_for_takeover = race_claim
|
||||
|
||||
outcome = await mgr.cancel("run-race")
|
||||
assert outcome == CancelOutcome.not_cancellable
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cancel_claim_lost_to_takeover_returns_taken_over():
|
||||
"""When cancel() reads the run as active but claim_for_takeover returns
|
||||
False because another worker already took it over (row is error), the
|
||||
re-read must surface taken_over."""
|
||||
store = MemoryRunStore()
|
||||
mgr = _make_manager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=True, grace_seconds=10))
|
||||
|
||||
expired = (datetime.now(UTC) - timedelta(seconds=60)).isoformat()
|
||||
await store.put(
|
||||
"run-race",
|
||||
thread_id="t1",
|
||||
status="running",
|
||||
owner_worker_id="w-a",
|
||||
lease_expires_at=expired,
|
||||
created_at=datetime.now(UTC).isoformat(),
|
||||
)
|
||||
|
||||
# Wrap claim_for_takeover: flip the row to error before the conditional
|
||||
# UPDATE so it matches 0 rows (peer already took it over).
|
||||
original = store.claim_for_takeover
|
||||
|
||||
async def race_takeover(run_id, *, grace_seconds, error):
|
||||
store._runs[run_id]["status"] = "error"
|
||||
store._runs[run_id]["error"] = "peer claim"
|
||||
return await original(run_id, grace_seconds=grace_seconds, error=error)
|
||||
|
||||
store.claim_for_takeover = race_takeover
|
||||
|
||||
outcome = await mgr.cancel("run-race")
|
||||
assert outcome == CancelOutcome.taken_over
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _compute_retry_after unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_compute_retry_after_null_lease_returns_none():
|
||||
from app.gateway.routers.thread_runs import _compute_retry_after
|
||||
|
||||
assert _compute_retry_after(None, 10) is None
|
||||
|
||||
|
||||
def test_compute_retry_after_unparseable_returns_none():
|
||||
from app.gateway.routers.thread_runs import _compute_retry_after
|
||||
|
||||
assert _compute_retry_after("not-a-date", 10) is None
|
||||
|
||||
|
||||
def test_compute_retry_after_normal():
|
||||
from app.gateway.routers.thread_runs import _compute_retry_after
|
||||
|
||||
future = (datetime.now(UTC) + timedelta(seconds=45)).isoformat()
|
||||
val = _compute_retry_after(future, 10)
|
||||
assert val is not None
|
||||
# lease_expires_at is ~45s from now + grace_seconds 10 = ~55, within reason
|
||||
assert 40 <= val <= 65
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP — stream endpoint cross-worker 409
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_http_stream_action_interrupt_non_owner_returns_409_with_retry_after():
|
||||
"""POST /stream?action=interrupt on a non-owner with valid lease must
|
||||
return 409 + Retry-After, not hang on SSE."""
|
||||
store = MemoryRunStore()
|
||||
grace = 10
|
||||
valid_lease = (datetime.now(UTC) + timedelta(seconds=60)).isoformat()
|
||||
asyncio.run(
|
||||
store.put(
|
||||
"run-alive-stream",
|
||||
thread_id="t1",
|
||||
status="running",
|
||||
owner_worker_id="alive-worker",
|
||||
lease_expires_at=valid_lease,
|
||||
created_at=datetime.now(UTC).isoformat(),
|
||||
)
|
||||
)
|
||||
mgr = _make_manager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=True, grace_seconds=grace))
|
||||
client = _make_cancel_test_app(mgr)
|
||||
|
||||
resp = client.post("/api/threads/t1/runs/run-alive-stream/stream", params={"action": "interrupt"})
|
||||
assert resp.status_code == 409
|
||||
assert "Retry-After" in resp.headers
|
||||
retry_after = int(resp.headers["Retry-After"])
|
||||
assert 50 <= retry_after <= 75
|
||||
|
||||
@@ -10,7 +10,7 @@ import pytest
|
||||
from sqlalchemy.exc import DatabaseError as SQLAlchemyDatabaseError
|
||||
|
||||
from deerflow.runtime import DisconnectMode, RunManager, RunStatus
|
||||
from deerflow.runtime.runs.manager import ConflictError, PersistenceRetryPolicy
|
||||
from deerflow.runtime.runs.manager import CancelOutcome, ConflictError, PersistenceRetryPolicy
|
||||
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
||||
|
||||
ISO_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
|
||||
@@ -151,7 +151,7 @@ async def test_cancel(manager: RunManager):
|
||||
await manager.set_status(record.run_id, RunStatus.running)
|
||||
|
||||
cancelled = await manager.cancel(record.run_id)
|
||||
assert cancelled is True
|
||||
assert cancelled == CancelOutcome.cancelled
|
||||
assert record.abort_event.is_set()
|
||||
assert record.status == RunStatus.interrupted
|
||||
|
||||
@@ -167,7 +167,7 @@ async def test_cancel_persists_interrupted_status_to_store():
|
||||
cancelled = await manager.cancel(record.run_id)
|
||||
|
||||
stored = await store.get(record.run_id)
|
||||
assert cancelled is True
|
||||
assert cancelled == CancelOutcome.cancelled
|
||||
assert stored is not None
|
||||
assert stored["status"] == "interrupted"
|
||||
|
||||
@@ -323,12 +323,12 @@ async def test_reconcile_orphaned_inflight_runs_skips_rows_when_error_status_is_
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cancel_not_inflight(manager: RunManager):
|
||||
"""Cancelling a completed run should return False."""
|
||||
"""Cancelling a completed run should return not_cancellable."""
|
||||
record = await manager.create("thread-1")
|
||||
await manager.set_status(record.run_id, RunStatus.success)
|
||||
|
||||
cancelled = await manager.cancel(record.run_id)
|
||||
assert cancelled is False
|
||||
assert cancelled == CancelOutcome.not_cancellable
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
Uses a temp SQLite DB to test ORM-backed CRUD operations.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from deerflow.persistence.run import RunRepository
|
||||
from deerflow.runtime import RunManager, RunStatus
|
||||
from deerflow.runtime import CancelOutcome, RunManager, RunStatus
|
||||
from deerflow.runtime.runs.manager import ConflictError
|
||||
from deerflow.runtime.runs.store.base import RunStore
|
||||
|
||||
@@ -66,6 +68,9 @@ class _CustomRunStoreWithoutProgress(RunStore):
|
||||
async def create_run_atomic(self, *args, **kwargs):
|
||||
return {}, []
|
||||
|
||||
async def claim_for_takeover(self, *args, **kwargs):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_run_progress_defaults_to_noop_for_custom_store():
|
||||
@@ -578,7 +583,7 @@ class TestRunRepository:
|
||||
cancelled = await manager.cancel(record.run_id)
|
||||
row = await repo.get(record.run_id)
|
||||
|
||||
assert cancelled is True
|
||||
assert cancelled == CancelOutcome.cancelled
|
||||
assert row is not None
|
||||
assert row["status"] == "interrupted"
|
||||
await _cleanup()
|
||||
@@ -818,3 +823,64 @@ class TestRunRepository:
|
||||
)
|
||||
|
||||
await _cleanup()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# claim_for_takeover SQL path
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_claim_for_takeover_succeeds_with_expired_lease(self, tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
grace = 10
|
||||
expired = (datetime.now(UTC) - timedelta(seconds=grace + 5)).isoformat()
|
||||
await repo.put("run-1", thread_id="t1", status="running", owner_worker_id="w-a", lease_expires_at=expired, created_at=datetime.now(UTC).isoformat())
|
||||
|
||||
ok = await repo.claim_for_takeover("run-1", grace_seconds=grace, error="claimed")
|
||||
assert ok is True
|
||||
|
||||
row = await repo.get("run-1")
|
||||
assert row["status"] == "error"
|
||||
assert row["error"] == "claimed"
|
||||
await _cleanup()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_claim_for_takeover_fails_on_valid_lease(self, tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
grace = 10
|
||||
valid = (datetime.now(UTC) + timedelta(seconds=30)).isoformat()
|
||||
await repo.put("run-1", thread_id="t1", status="running", owner_worker_id="w-a", lease_expires_at=valid, created_at=datetime.now(UTC).isoformat())
|
||||
|
||||
ok = await repo.claim_for_takeover("run-1", grace_seconds=grace, error="claimed")
|
||||
assert ok is False
|
||||
|
||||
row = await repo.get("run-1")
|
||||
assert row["status"] == "running"
|
||||
await _cleanup()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_claim_for_takeover_succeeds_with_null_lease(self, tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
await repo.put("run-null", thread_id="t1", status="running", created_at=datetime.now(UTC).isoformat())
|
||||
|
||||
ok = await repo.claim_for_takeover("run-null", grace_seconds=10, error="claimed")
|
||||
assert ok is True
|
||||
|
||||
row = await repo.get("run-null")
|
||||
assert row["status"] == "error"
|
||||
await _cleanup()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_claim_for_takeover_fails_on_terminal_row(self, tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
await repo.put("run-done", thread_id="t1", status="success", created_at=datetime.now(UTC).isoformat())
|
||||
|
||||
ok = await repo.claim_for_takeover("run-done", grace_seconds=10, error="claimed")
|
||||
assert ok is False
|
||||
await _cleanup()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_claim_for_takeover_nonexistent_run(self, tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
ok = await repo.claim_for_takeover("no-such-run", grace_seconds=10, error="claimed")
|
||||
assert ok is False
|
||||
await _cleanup()
|
||||
|
||||
Reference in New Issue
Block a user