mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-11 15:28:37 +00:00
* feat(runs): cross-process run ownership with lease + reconciliation (#3948) Implements work items 2 and 3 of the multi-worker P0 plan (docs/multi_worker.md). Work item 1 (Postgres startup gate, #3960) already landed; this PR makes run creation race-safe across worker processes and lets Postgres deployments recover orphaned inflight runs from crashed workers without mis-marking live runs as orphans. Work item 2 — cross-process atomic create_or_reject - Alembic revision 0004_run_ownership adds runs.owner_worker_id, runs.lease_expires_at, idx_runs_lease, and a partial unique index uq_runs_thread_active (one pending/running run per thread). The index is declared on RunRow.__table_args__ with sqlite_where + postgresql_where (mirroring uq_channel_connection_active_identity) so the empty-DB bootstrap path — which runs Base.metadata.create_all + alembic stamp head without executing any revision's upgrade() — also lands it on fresh deployments. Migration 0004 additionally creates it idempotently for legacy/versioned upgrades. - RunRepository.create_run_atomic is the new atomic primitive: - reject: INSERT directly; the partial unique index catches duplicate active runs; the manager surfaces the result as ConflictError. - interrupt/rollback: SELECT FOR UPDATE the conflicting rows, skip rows whose lease is still valid AND owned by another live worker (raise ConflictError — the INSERT would have failed on the index anyway, and a retry loop cannot make progress), cancel the rest in the same transaction, then INSERT the new row. Rows owned by this worker are interruptible regardless of lease state. - RunManager.create_or_reject dispatches to the store under the existing local lock; same-worker in-memory cancellation runs after the store commit succeeds. MemoryRunStore mirrors the same semantics for tests and database.backend=memory. Work item 3 — lease heartbeat + Postgres reconciliation - RunOwnershipConfig (lease_seconds=30, grace_seconds=10, heartbeat_enabled=false by default), registered as startup-only in reload_boundary.STARTUP_ONLY_FIELDS because the heartbeat background task is created once in langgraph_runtime() and is not rebuilt on config.yaml edits. - When heartbeat_enabled, each worker renewes leases on its own active runs with interval = lease_seconds / 3. The loop is bounded and stop-event-cancellable so shutdown is prompt. - reconcile_orphaned_inflight_runs now runs on every backend — the sqlite-only gate in app/gateway/deps.py is dropped in the same commit so there is no window where Postgres would mis-mark live Worker A runs as orphans. Reconciliation errors only runs whose lease is NULL (legacy pre-ownership rows) or older than grace_seconds. In single-worker mode (heartbeat off, NULL leases) all inflight rows reclaim immediately, preserving the pre-ownership recovery latency. - Heartbeat starts AFTER startup reconciliation and stops BEFORE the in-flight run drain on shutdown so the two cannot race. GATEWAY_WORKERS=1 with heartbeat_enabled=false keeps current behavior. Verified: 170 related tests + full backend suite (minus Docker-gated live tests) green; ruff check + ruff format clean. * fix(runs): tighten unique-violation handling and document clock-sync budget Three follow-up fixes to the cross-process run ownership work in #3948, surfacing during review. 1. _is_unique_violation: detect by driver-native signal, not message text The previous substring heuristic ("unique" + "violat", or "duplicate") missed SQLite's actual phrasing "UNIQUE constraint failed: <table>.<index>" — SQLite says "failed", not "violates", and never "duplicate". On SQLite the detector returned False, the reject path re-raised the raw IntegrityError, and clients saw HTTP 500 instead of ConflictError 409. The conversion is the load-bearing piece of the "store is source of truth" design but was untested — every atomic test used MemoryRunStore, which raises ConflictError directly and never reached this branch. Now prefers driver-native signals: psycopg pgcode/sqlcode "23505" and sqlite3 sqlite_errorcode SQLITE_CONSTRAINT_UNIQUE (reachable through SQLAlchemy IntegrityError.orig). Message matching stays as a fallback with SQLite's exact "unique constraint failed" phrase added. 2. interrupt/rollback: convert exhausted-retry IntegrityError to ConflictError The reject branch converts unique violations to ConflictError. The interrupt/rollback retry loop did not — on the 3rd attempt it re-raised the raw IntegrityError, leaking HTTP 500 for the same race condition that reject surfaces as 409. Symmetric conversion added after the loop; callers now see a consistent ConflictError regardless of strategy. 3. Document clock-sync requirement for multi-worker lease reconciliation reconcile_orphaned_inflight_runs compares another worker's UTC lease_expires_at against this worker's datetime.now(UTC). The only skew budget is grace_seconds (default 10s) — worst case, with the owning worker's heartbeat just about to fire, a peer whose clock is more than ~grace_seconds ahead can mis-reclaim a still-live run as an orphan. Documented in RunOwnershipConfig's docstring (with the math) and in config.example.yaml (with operational guidance), so operators in NTP-poor environments know to raise grace_seconds. Default unchanged: 10s is reasonable for NTP-synced K8s/cloud, and bumping it would slow recovery of genuinely dead workers (lease_seconds + grace_seconds from last heartbeat to reclaim). Tests: - test_create_run_atomic_reject_propagates_conflict_on_unique_violation: end-to-end against a real SQLite-backed RunRepository, pre-inserts an active run, asserts reject-strategy create surfaces as ConflictError rather than raw IntegrityError. - test_is_unique_violation_detects_real_sqlite_integrity_error: unit test for the detector against a real SQLite-raised IntegrityError; asserts driver-level sqlite_errorcode is SQLITE_CONSTRAINT_UNIQUE. - test_interrupt_exhausted_retries_surface_as_conflict_error: pins the symmetric 409 behavior after the retry loop exhausts. Verified: ruff check + ruff format clean; multi-worker + run_repository + owner_isolation + reload_boundary suites green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(runs): close multi-worker ownership gaps in lease heartbeat and unique-violation detection Five code-review fixes from docs/multi_worker.md: 1. Drop unused ``claim_inflight_runs`` primitive — no caller anywhere. ``create_run_atomic`` does its own inline claim (SELECT FOR UPDATE + cancel) inside the INSERT transaction; a separate claim primitive would split that into two transactions and open a claim→INSERT race. Removes ~40 lines across base.py / memory.py / sql.py plus the unused ``now_iso`` parameter, freeing future RunStore implementations from providing it. 2. Broaden ``_renew_leases`` filter to renew pending/running runs owned by this worker even when ``record.task is None``. The previous ``task is not None`` requirement skipped the brief window between ``create_run_atomic`` inserting the row and the worker spawning the agent task; under event-loop load that window can approach ``lease_seconds``, after which peer reconciliation marks the run ``error`` (visible) or a peer's ``create_or_reject("interrupt")`` silently kills the queued run. Filter now: ``task is None or not task.done()``. 3. Document the unsynchronised ``record.lease_expires_at = new_expiry`` write. ``lease_expires_at`` is the only field on an existing record this path mutates; ``set_status`` / ``_persist_status`` touch other fields, so there is no concurrent writer to race against. Re-acquiring ``self._lock`` would serialise unrelated run mutations for no gain. 4. Gate ``_is_unique_violation`` message fallbacks on ``isinstance(current, (SAIntegrityError, sqlite3.IntegrityError))``. The driver-code path (pgcode/sqlite_errorcode) remains load-bearing; substring fallbacks are now belt-and-suspenders only for cases where the driver attribute isn't reachable through the cause chain. Without the gate, any application exception whose ``str()`` happens to contain "duplicate key" / "unique" + "violat" (CHECK constraint, validation error) would silently surface as HTTP 409 instead of 500. 5. Route ``update_lease`` through ``_call_store_with_retry`` for consistency with every other store call, and wrap ``await self._renew_leases()`` in ``_heartbeat_loop`` with ``except Exception: logger.warning(...)``. Previously a transient error from the snapshot path or an unexpected exception would kill the heartbeat task silently — after which no lease is ever renewed again and every active run eventually looks orphaned. ``except Exception`` lets ``CancelledError`` (BaseException since 3.8) propagate so shutdown cancellation still works. Regression tests: - ``test_heartbeat_renews_pending_run_before_task_is_spawned`` - ``test_is_unique_violation_does_not_misclassify_application_exception`` Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(runs): harden multi-worker migration, memory atomicity, and tz-naive lease comparison Three follow-up fixes to the multi-worker run ownership work: - migration 0004 dedupe pass: cancel superseded duplicate active rows per thread before creating the partial UNIQUE index ``uq_runs_thread_active`` so dirty DBs (Postgres deployments that had reconciliation skipped by the old sqlite-only gate, or any env that ran GATEWAY_WORKERS>1 before this PR) do not abort the alembic upgrade and block gateway startup. Keeps the newest active row per thread, marks the rest as error with an explanatory message. - MemoryRunStore.create_run_atomic interrupt/rollback path: split the single- pass loop into two passes (collect candidates, validate, then mutate) so a ConflictError raised on a later candidate does not leave earlier candidates half-interrupted. Mirrors the SQL store's transactional rollback semantics; the entire test_multi_worker_run_ownership.py suite runs against memory so this divergence was giving false confidence. - RunRepository.create_run_atomic interrupt path: coerce tz-naive ``row.lease_expires_at`` to UTC before comparing against the aware ``cutoff``. SQLite drops tzinfo on read despite ``DateTime(timezone=True)`` (this file's own comment acknowledges it), so the Python-side comparison raised ``TypeError: can't compare offset-naive and offset-aware datetimes`` whenever heartbeat was enabled on SQLite and a lease was non-NULL. Defaults (heartbeat off -> leases always NULL) masked it, but there was no guard against the combination. Follows the existing "naive is UTC" convention from ``coerce_iso``. Each fix ships with a regression test pinning the behavior. Co-Authored-By: heart-scalpel <heart-scalpel@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(runs): enforce heartbeat for multi-worker, fix memory-store datetime comparison, lazy-import ConflictError in store layer Three fixes from code review: 1. Extend the startup gate (GATEWAY_WORKERS>1) to also require run_ownership.heartbeat_enabled=true. Without heartbeat every run has a NULL lease, so reconciliation treats all inflight rows as orphans and Worker B would kill Worker A's live runs on every rolling update or scale-up. 2. Fix MemoryRunStore.list_inflight_with_expired_lease to parse created_at as datetime instead of ISO string lexical comparison, and handle tz-naive lease values uniformly with the SQL store. 3. Store layer (sql.py, memory.py) now lazy-imports ConflictError inside create_run_atomic instead of importing from the higher RunManager layer at module level. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(runs): add owner check to update_lease, document create() assumption, restore deleted comment - update_lease (SQL + memory) now requires owner_worker_id match in WHERE clause so the primitive is safe by construction against misuse - create() docstring notes it bypasses atomic create_run_atomic and assumes no active run exists for the thread - restore explanatory comment in MemoryRunStore.aggregate_tokens_by_thread that was dropped in an earlier commit Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(runs): add psycopg3 sqlstate detection and periodic orphan reconciliation - _is_unique_violation now checks sqlstate attribute (psycopg3 uses this instead of pgcode). On Postgres, the only supported multi-worker backend, detection was falling through to the message-substring fallback. - _heartbeat_loop now runs reconcile_orphaned_inflight_runs every 3rd cycle (every lease_seconds) to catch orphans whose lease expires between pod restarts. Single-worker deployments are unaffected (heartbeat off). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: heart-scalpel <heart-scalpel@users.noreply.github.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
968 lines
36 KiB
Python
968 lines
36 KiB
Python
"""Tests for multi-worker run ownership (work items 2–3).
|
||
|
||
Coverage:
|
||
- create_or_reject with reject strategy blocks duplicate active runs
|
||
- 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
|
||
- Worker reconciliation skips runs with unexpired leases
|
||
- Lease heartbeat renews active run leases
|
||
- GATEWAY_WORKERS=1 + heartbeat_enabled=false behaviour unchanged
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from datetime import UTC, datetime, timedelta
|
||
from unittest.mock import AsyncMock
|
||
|
||
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.store.memory import MemoryRunStore
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _lease_config(**kwargs) -> RunOwnershipConfig:
|
||
return RunOwnershipConfig(
|
||
lease_seconds=kwargs.get("lease_seconds", 30),
|
||
grace_seconds=kwargs.get("grace_seconds", 10),
|
||
heartbeat_enabled=kwargs.get("heartbeat_enabled", False),
|
||
)
|
||
|
||
|
||
def _make_manager(store=None, **kwargs) -> RunManager:
|
||
return RunManager(
|
||
store=store or MemoryRunStore(),
|
||
run_ownership_config=kwargs.pop("run_ownership_config", _lease_config()),
|
||
**kwargs,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# create_or_reject — reject strategy
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_reject_blocks_when_active_run_exists():
|
||
"""reject strategy must raise ConflictError when thread has an active run."""
|
||
store = MemoryRunStore()
|
||
manager = _make_manager(store=store)
|
||
await manager.create("thread-1")
|
||
await manager.set_status((await manager.list_by_thread("thread-1"))[0].run_id, RunStatus.running)
|
||
|
||
with pytest.raises(ConflictError, match="already has an active run"):
|
||
await manager.create_or_reject("thread-1", multitask_strategy="reject")
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_reject_succeeds_when_no_active_run():
|
||
"""reject strategy must succeed when the thread has no active run."""
|
||
store = MemoryRunStore()
|
||
manager = _make_manager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=True))
|
||
record = await manager.create_or_reject("thread-1", multitask_strategy="reject")
|
||
assert record is not None
|
||
assert record.status == RunStatus.pending
|
||
assert record.owner_worker_id is not None
|
||
assert record.lease_expires_at is not None
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_reject_blocks_reentrant_same_thread_locally():
|
||
"""reject must also block when a local in-memory active run exists."""
|
||
store = MemoryRunStore()
|
||
manager = _make_manager(store=store)
|
||
await manager.create_or_reject("thread-1", multitask_strategy="reject")
|
||
|
||
with pytest.raises(ConflictError, match="already has an active run"):
|
||
await manager.create_or_reject("thread-1", multitask_strategy="reject")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# create_or_reject — interrupt strategy
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_interrupt_cancels_old_run_and_creates_new():
|
||
"""interrupt must cancel the previous active run and create a new one."""
|
||
store = MemoryRunStore()
|
||
manager = _make_manager(store=store)
|
||
old = await manager.create_or_reject("thread-1", multitask_strategy="reject")
|
||
await manager.set_status(old.run_id, RunStatus.running)
|
||
|
||
new = await manager.create_or_reject("thread-1", multitask_strategy="interrupt")
|
||
|
||
assert new.run_id != old.run_id
|
||
assert new.status == RunStatus.pending
|
||
|
||
# Old run must be interrupted locally
|
||
assert old.status == RunStatus.interrupted
|
||
assert old.abort_event.is_set()
|
||
|
||
# Old run must be marked interrupted in-store (persist_status after local cancel)
|
||
old_after = await store.get(old.run_id)
|
||
assert old_after["status"] == "interrupted"
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_interrupt_creates_new_when_old_completed():
|
||
"""interrupt must succeed when the previous run already reached a terminal status."""
|
||
store = MemoryRunStore()
|
||
manager = _make_manager(store=store)
|
||
old = await manager.create_or_reject("thread-1")
|
||
await manager.set_status(old.run_id, RunStatus.success)
|
||
|
||
new = await manager.create_or_reject("thread-1", multitask_strategy="interrupt")
|
||
assert new.run_id != old.run_id
|
||
assert new.status == RunStatus.pending
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_interrupt_exhausted_retries_surface_as_conflict_error():
|
||
"""When all retry attempts collide with a unique violation, the loop must
|
||
surface ConflictError (HTTP 409) — matching the reject branch — instead of
|
||
leaking the raw IntegrityError (HTTP 500).
|
||
|
||
Without the post-loop conversion, the last attempt's ``raise`` re-raises
|
||
the IntegrityError, giving callers an inconsistent signal depending on
|
||
which strategy they picked. The reject path already converts; this test
|
||
pins the symmetric behaviour for interrupt/rollback.
|
||
"""
|
||
import sqlite3
|
||
|
||
class _AlwaysUniqueViolationStore(MemoryRunStore):
|
||
"""MemoryRunStore whose ``create_run_atomic`` always raises a
|
||
real-flavoured unique-violation IntegrityError, simulating a worker
|
||
that keeps losing the cross-worker race for the same thread."""
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.atomic_call_count = 0
|
||
|
||
async def create_run_atomic(self, *args, **kwargs):
|
||
self.atomic_call_count += 1
|
||
err = sqlite3.IntegrityError("UNIQUE constraint failed: runs.uq_runs_thread_active")
|
||
err.sqlite_errorcode = sqlite3.SQLITE_CONSTRAINT_UNIQUE
|
||
raise err
|
||
|
||
store = _AlwaysUniqueViolationStore()
|
||
manager = _make_manager(store=store)
|
||
|
||
with pytest.raises(ConflictError, match="already has an active run"):
|
||
await manager.create_or_reject("thread-1", multitask_strategy="interrupt")
|
||
|
||
# Sanity: the loop actually retried 3 times before giving up.
|
||
assert store.atomic_call_count == 3
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# create_or_reject — run ownership metadata
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_run_record_stores_owner_and_lease():
|
||
"""Newly created runs must carry owner_worker_id and lease_expires_at (when heartbeat is on)."""
|
||
store = MemoryRunStore()
|
||
manager = _make_manager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=True))
|
||
record = await manager.create_or_reject("thread-1")
|
||
|
||
assert record.owner_worker_id == manager.worker_id
|
||
assert isinstance(record.owner_worker_id, str) and len(record.owner_worker_id) > 0
|
||
assert record.lease_expires_at is not None
|
||
|
||
# Store row must also carry the fields
|
||
stored = await store.get(record.run_id)
|
||
assert stored is not None
|
||
assert stored["owner_worker_id"] == manager.worker_id
|
||
assert stored["lease_expires_at"] is not None
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_store_row_roundtrips_ownership_fields():
|
||
"""Records hydrated from the store must surface ownership fields."""
|
||
store = MemoryRunStore()
|
||
manager = _make_manager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=True))
|
||
record = await manager.create_or_reject("thread-1")
|
||
|
||
hydrated = await manager.get(record.run_id)
|
||
assert hydrated is not None
|
||
assert hydrated.owner_worker_id == manager.worker_id
|
||
assert hydrated.lease_expires_at is not None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# reconcile_orphaned_inflight_runs — lease-based
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_reconciliation_claims_expired_lease_runs():
|
||
"""A run with an expired lease must be reclaimed as orphaned."""
|
||
store = MemoryRunStore()
|
||
manager = _make_manager(store=store)
|
||
|
||
# Insert a run with an already-expired lease
|
||
expired_lease = (datetime.now(UTC) - timedelta(seconds=60)).isoformat()
|
||
await store.put(
|
||
"expired-run",
|
||
thread_id="thread-1",
|
||
status="running",
|
||
owner_worker_id="worker-dead",
|
||
lease_expires_at=expired_lease,
|
||
created_at=(datetime.now(UTC) - timedelta(seconds=120)).isoformat(),
|
||
)
|
||
|
||
recovered = await manager.reconcile_orphaned_inflight_runs(
|
||
error="Gateway restarted before this run reached a durable final state.",
|
||
)
|
||
|
||
assert len(recovered) == 1
|
||
assert recovered[0].run_id == "expired-run"
|
||
assert recovered[0].status == RunStatus.error
|
||
|
||
stored = await store.get("expired-run")
|
||
assert stored["status"] == "error"
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_reconciliation_skips_active_lease_runs():
|
||
"""A run with a still-valid lease must NOT be reclaimed."""
|
||
store = MemoryRunStore()
|
||
manager = _make_manager(store=store)
|
||
|
||
# Insert a run with a still-valid lease
|
||
valid_lease = (datetime.now(UTC) + timedelta(seconds=60)).isoformat()
|
||
await store.put(
|
||
"live-run",
|
||
thread_id="thread-1",
|
||
status="running",
|
||
owner_worker_id="worker-alive",
|
||
lease_expires_at=valid_lease,
|
||
created_at=(datetime.now(UTC) - timedelta(seconds=10)).isoformat(),
|
||
)
|
||
|
||
recovered = await manager.reconcile_orphaned_inflight_runs(
|
||
error="Gateway restarted before this run reached a durable final state.",
|
||
)
|
||
|
||
# Live run's lease is still valid — must not be reclaimed
|
||
assert all(r.run_id != "live-run" for r in recovered)
|
||
|
||
stored = await store.get("live-run")
|
||
assert stored["status"] == "running"
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_reconciliation_claims_null_lease_runs():
|
||
"""Pre-ownership rows (NULL lease) must be reclaimed."""
|
||
store = MemoryRunStore()
|
||
manager = _make_manager(store=store)
|
||
|
||
await store.put(
|
||
"legacy-run",
|
||
thread_id="thread-1",
|
||
status="running",
|
||
created_at=(datetime.now(UTC) - timedelta(seconds=120)).isoformat(),
|
||
)
|
||
|
||
recovered = await manager.reconcile_orphaned_inflight_runs(
|
||
error="Gateway restarted before this run reached a durable final state.",
|
||
)
|
||
|
||
assert len(recovered) == 1
|
||
assert recovered[0].run_id == "legacy-run"
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_heartbeat_disabled_crashed_run_reclaimed_immediately():
|
||
"""Single-worker regression: when heartbeat is off, a crashed run must be
|
||
reclaimed on the next restart without waiting for lease expiry.
|
||
|
||
The run is created with lease_expires_at=NULL (no heartbeat => no lease),
|
||
so reconciliation treats it as an orphan and reclaims it right away —
|
||
preserving the pre-ownership recovery latency.
|
||
"""
|
||
store = MemoryRunStore()
|
||
# Worker A: heartbeat disabled (single-worker default)
|
||
manager_a = _make_manager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=False))
|
||
record = await manager_a.create("thread-1")
|
||
await manager_a.set_status(record.run_id, RunStatus.running)
|
||
|
||
# Verify the run was stored WITHOUT a lease (heartbeat off)
|
||
stored = await store.get(record.run_id)
|
||
assert stored is not None
|
||
assert stored["lease_expires_at"] is None
|
||
|
||
# Simulate crash: drop manager_a's local state, build a fresh manager
|
||
# (same store) as if Worker A restarted.
|
||
manager_b = _make_manager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=False))
|
||
|
||
# Reconciliation must reclaim the run IMMEDIATELY — no lease to wait out.
|
||
recovered = await manager_b.reconcile_orphaned_inflight_runs(
|
||
error="Gateway restarted before this run reached a durable final state.",
|
||
)
|
||
|
||
assert len(recovered) == 1
|
||
assert recovered[0].run_id == record.run_id
|
||
assert recovered[0].status == RunStatus.error
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_reconciliation_skips_locally_active_runs():
|
||
"""An active local run (owned by this worker) must NOT be reclaimed even with an expired lease."""
|
||
store = MemoryRunStore()
|
||
manager = _make_manager(store=store)
|
||
|
||
# Create a live local run
|
||
record = await manager.create("thread-1")
|
||
await manager.set_status(record.run_id, RunStatus.running)
|
||
|
||
# Its lease hasn't expired yet, so this is mostly testing the local-ownership guard
|
||
recovered = await manager.reconcile_orphaned_inflight_runs(
|
||
error="Gateway restarted before this run reached a durable final state.",
|
||
)
|
||
|
||
assert all(r.run_id != record.run_id for r in recovered)
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_reconciliation_returns_empty_when_no_orphaned_runs():
|
||
"""Reconciliation must return empty when there are no orphaned runs."""
|
||
store = MemoryRunStore()
|
||
manager = _make_manager(store=store)
|
||
|
||
recovered = await manager.reconcile_orphaned_inflight_runs(
|
||
error="Gateway restarted before this run reached a durable final state.",
|
||
)
|
||
|
||
assert recovered == []
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Lease heartbeat
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_heartbeat_renews_active_run_leases():
|
||
"""Heartbeat must extend the lease on active runs owned by this worker."""
|
||
config = _lease_config(lease_seconds=30, heartbeat_enabled=True)
|
||
store = MemoryRunStore()
|
||
manager = _make_manager(store=store, run_ownership_config=config)
|
||
|
||
record = await manager.create_or_reject("thread-1")
|
||
await manager.set_status(record.run_id, RunStatus.running)
|
||
|
||
original_lease = record.lease_expires_at
|
||
assert original_lease is not None
|
||
|
||
# Start heartbeat and let it tick once
|
||
await manager.start_heartbeat()
|
||
await asyncio.sleep(0.2) # heartbeat interval = 10s, too long; manually renew
|
||
|
||
await manager._renew_leases()
|
||
await manager.stop_heartbeat()
|
||
|
||
assert record.lease_expires_at is not None
|
||
# Lease should have been extended
|
||
assert record.lease_expires_at >= original_lease
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_heartbeat_renews_pending_run_before_task_is_spawned():
|
||
"""A run sitting in ``pending`` between ``create_run_atomic`` and task
|
||
spawn must still have its lease renewed.
|
||
|
||
Pre-fix the renewal filter required ``record.task is not None``, so a
|
||
pending run with no task yet (the brief window after
|
||
``create_run_atomic`` inserts the row before the worker layer spawns
|
||
the agent task) was silently skipped. If that window stretched past
|
||
``lease_seconds`` — e.g. event-loop saturation, slow checkpoint
|
||
hydrate — peer reconciliation reclaimed the run as an orphan and
|
||
marked it ``error`` even though this worker still intended to run it.
|
||
"""
|
||
config = _lease_config(lease_seconds=30, heartbeat_enabled=True)
|
||
store = MemoryRunStore()
|
||
manager = _make_manager(store=store, run_ownership_config=config)
|
||
|
||
record = await manager.create_or_reject("thread-1")
|
||
assert record.status == RunStatus.pending
|
||
# No task has been spawned — this is the regression sentinel.
|
||
assert record.task is None
|
||
|
||
original_lease = record.lease_expires_at
|
||
assert original_lease is not None
|
||
|
||
# Force a measurable gap so the renewed lease strictly post-dates the
|
||
# original — without this the two timestamps land in the same
|
||
# microsecond on fast hosts and the strict comparison fails trivially.
|
||
await asyncio.sleep(0.001)
|
||
|
||
store.update_lease = AsyncMock(wraps=store.update_lease)
|
||
|
||
await manager._renew_leases()
|
||
|
||
store.update_lease.assert_awaited_once()
|
||
assert record.lease_expires_at is not None
|
||
assert record.lease_expires_at > original_lease
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_heartbeat_skips_runs_not_owned_by_this_worker():
|
||
"""Heartbeat must only renew leases for runs owned by this worker."""
|
||
config = _lease_config(lease_seconds=30, heartbeat_enabled=True)
|
||
store = MemoryRunStore()
|
||
manager = _make_manager(store=store, run_ownership_config=config)
|
||
|
||
# Create a run owned by a different worker
|
||
old_lease = (datetime.now(UTC) + timedelta(seconds=5)).isoformat()
|
||
await store.put(
|
||
"other-worker-run",
|
||
thread_id="thread-1",
|
||
status="running",
|
||
owner_worker_id="other-worker",
|
||
lease_expires_at=old_lease,
|
||
created_at=(datetime.now(UTC) - timedelta(seconds=10)).isoformat(),
|
||
)
|
||
|
||
await manager._renew_leases()
|
||
|
||
stored = await store.get("other-worker-run")
|
||
# Lease should be unchanged (other worker's run)
|
||
assert stored["lease_expires_at"] == old_lease
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_heartbeat_not_started_when_disabled():
|
||
"""When heartbeat_enabled is False, start_heartbeat must be a no-op."""
|
||
config = _lease_config(heartbeat_enabled=False)
|
||
store = MemoryRunStore()
|
||
manager = _make_manager(store=store, run_ownership_config=config)
|
||
|
||
assert manager.heartbeat_enabled is False
|
||
await manager.start_heartbeat()
|
||
assert manager._heartbeat_task is None
|
||
assert manager._heartbeat_stop is None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# cancel with cross-worker lease awareness
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_cancel_local_run_succeeds():
|
||
"""Cancel must succeed for a locally-owned active run."""
|
||
store = MemoryRunStore()
|
||
manager = _make_manager(store=store)
|
||
record = await manager.create("thread-1")
|
||
await manager.set_status(record.run_id, RunStatus.running)
|
||
|
||
result = await manager.cancel(record.run_id)
|
||
assert result is True
|
||
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."""
|
||
store = MemoryRunStore()
|
||
manager = _make_manager(store=store)
|
||
|
||
result = await manager.cancel("nonexistent-run")
|
||
assert result is False
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_cancel_idempotent():
|
||
"""Cancel must return True 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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# GATEWAY_WORKERS=1 backward compatibility
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_single_worker_default_config_behavior_unchanged():
|
||
"""With default config (heartbeat_enabled=False), behavior must match pre-ownership code."""
|
||
config = _lease_config(heartbeat_enabled=False)
|
||
store = MemoryRunStore()
|
||
manager = _make_manager(store=store, run_ownership_config=config)
|
||
|
||
# Create runs, cancel, create_or_reject — all must work
|
||
r1 = await manager.create("thread-1")
|
||
assert r1.owner_worker_id is not None
|
||
|
||
r2 = await manager.create_or_reject("thread-2", multitask_strategy="reject")
|
||
assert r2.owner_worker_id is not None
|
||
|
||
await manager.cancel(r2.run_id)
|
||
stored = await store.get(r2.run_id)
|
||
assert stored["status"] == "interrupted"
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_manager_without_run_ownership_config():
|
||
"""Manager without run_ownership_config must still work (backward compat)."""
|
||
store = MemoryRunStore()
|
||
manager = RunManager(store=store) # no run_ownership_config
|
||
|
||
record = await manager.create_or_reject("thread-1")
|
||
assert record is not None
|
||
assert record.owner_worker_id is not None # always set, even without config
|
||
|
||
# Heartbeat must be a no-op without config
|
||
assert manager.heartbeat_enabled is False
|
||
await manager.start_heartbeat()
|
||
assert manager._heartbeat_task is None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# worker_id uniqueness
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_worker_id_is_generated():
|
||
"""worker_id must be a non-empty string containing hostname."""
|
||
wid = _generate_worker_id()
|
||
assert isinstance(wid, str)
|
||
assert len(wid) > 0
|
||
assert ":" in wid
|
||
|
||
|
||
def test_two_managers_have_different_default_ids():
|
||
"""Two managers without explicit worker_id must get unique ids."""
|
||
m1 = RunManager()
|
||
m2 = RunManager()
|
||
assert m1.worker_id != m2.worker_id
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Store atomic methods
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_create_run_atomic_reject_prevents_duplicate():
|
||
"""store.create_run_atomic with reject must raise ConflictError on duplicate."""
|
||
store = MemoryRunStore()
|
||
config = _lease_config()
|
||
|
||
store.create_run_atomic = AsyncMock(wraps=store.create_run_atomic)
|
||
|
||
await store.create_run_atomic(
|
||
run_id="run-1",
|
||
thread_id="thread-1",
|
||
owner_worker_id="w1",
|
||
lease_expires_at=(datetime.now(UTC) + timedelta(seconds=30)).isoformat(),
|
||
multitask_strategy="reject",
|
||
grace_seconds=config.grace_seconds,
|
||
)
|
||
|
||
with pytest.raises(ConflictError, match="already has an active run"):
|
||
await store.create_run_atomic(
|
||
run_id="run-2",
|
||
thread_id="thread-1",
|
||
owner_worker_id="w2",
|
||
lease_expires_at=(datetime.now(UTC) + timedelta(seconds=30)).isoformat(),
|
||
multitask_strategy="reject",
|
||
grace_seconds=config.grace_seconds,
|
||
)
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_create_run_atomic_interrupt_claims_and_creates():
|
||
"""store.create_run_atomic with interrupt must claim old and create new."""
|
||
store = MemoryRunStore()
|
||
config = _lease_config()
|
||
# Create an active run with an expired lease (simulating a crashed worker)
|
||
expired_lease = (datetime.now(UTC) - timedelta(seconds=60)).isoformat()
|
||
|
||
await store.create_run_atomic(
|
||
run_id="run-old",
|
||
thread_id="thread-1",
|
||
owner_worker_id="w1",
|
||
lease_expires_at=expired_lease,
|
||
multitask_strategy="reject",
|
||
grace_seconds=config.grace_seconds,
|
||
)
|
||
|
||
new_row, claimed = await store.create_run_atomic(
|
||
run_id="run-new",
|
||
thread_id="thread-1",
|
||
owner_worker_id="w2",
|
||
lease_expires_at=(datetime.now(UTC) + timedelta(seconds=30)).isoformat(),
|
||
multitask_strategy="interrupt",
|
||
grace_seconds=config.grace_seconds,
|
||
)
|
||
|
||
assert new_row["run_id"] == "run-new"
|
||
assert new_row["status"] == "pending"
|
||
assert len(claimed) == 1
|
||
assert claimed[0]["run_id"] == "run-old"
|
||
|
||
# Old run must be interrupted in-store
|
||
old_row = await store.get("run-old")
|
||
assert old_row["status"] == "interrupted"
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_create_run_atomic_interrupt_rejects_other_worker_valid_lease():
|
||
"""Interrupt must raise ConflictError when a valid-lease run is owned by another worker.
|
||
|
||
The partial unique index ``uq_runs_thread_active`` would reject the INSERT
|
||
anyway; surfacing ConflictError here gives the caller a clean signal
|
||
instead of a futile retry loop on IntegrityError.
|
||
"""
|
||
store = MemoryRunStore()
|
||
config = _lease_config(grace_seconds=10)
|
||
valid_lease = (datetime.now(UTC) + timedelta(seconds=30)).isoformat()
|
||
|
||
await store.create_run_atomic(
|
||
run_id="valid-lease-run",
|
||
thread_id="thread-1",
|
||
owner_worker_id="other-worker",
|
||
lease_expires_at=valid_lease,
|
||
multitask_strategy="reject",
|
||
grace_seconds=config.grace_seconds,
|
||
)
|
||
|
||
with pytest.raises(ConflictError, match="another worker"):
|
||
await store.create_run_atomic(
|
||
run_id="run-new",
|
||
thread_id="thread-1",
|
||
owner_worker_id="w2",
|
||
lease_expires_at=(datetime.now(UTC) + timedelta(seconds=30)).isoformat(),
|
||
multitask_strategy="interrupt",
|
||
grace_seconds=config.grace_seconds,
|
||
)
|
||
|
||
# The valid-lease run must be untouched (transaction rolled back).
|
||
old_row = await store.get("valid-lease-run")
|
||
assert old_row["status"] == "pending"
|
||
assert old_row["owner_worker_id"] == "other-worker"
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_create_run_atomic_interrupt_allows_self_owned_valid_lease():
|
||
"""Interrupt must succeed when the existing valid-lease run is owned by this worker."""
|
||
store = MemoryRunStore()
|
||
config = _lease_config(grace_seconds=10)
|
||
valid_lease = (datetime.now(UTC) + timedelta(seconds=30)).isoformat()
|
||
|
||
await store.create_run_atomic(
|
||
run_id="self-run",
|
||
thread_id="thread-1",
|
||
owner_worker_id="w1",
|
||
lease_expires_at=valid_lease,
|
||
multitask_strategy="reject",
|
||
grace_seconds=config.grace_seconds,
|
||
)
|
||
|
||
new_row, claimed = await store.create_run_atomic(
|
||
run_id="run-new",
|
||
thread_id="thread-1",
|
||
owner_worker_id="w1", # same worker
|
||
lease_expires_at=(datetime.now(UTC) + timedelta(seconds=30)).isoformat(),
|
||
multitask_strategy="interrupt",
|
||
grace_seconds=config.grace_seconds,
|
||
)
|
||
|
||
assert new_row["run_id"] == "run-new"
|
||
assert len(claimed) == 1
|
||
assert claimed[0]["run_id"] == "self-run"
|
||
assert claimed[0]["status"] == "interrupted"
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_create_run_atomic_interrupt_rolls_back_earlier_mutations_on_conflict():
|
||
"""Interrupt must not leave earlier candidates interrupted when a later
|
||
candidate raises ConflictError.
|
||
|
||
Mirrors the SQL store's transactional semantics: the whole interrupt pass
|
||
is one transaction, so a raise on any candidate must roll back mutations
|
||
already applied to earlier candidates. Without this, the memory store
|
||
diverges from SQL (which the production path uses), and the
|
||
test_multi_worker_run_ownership.py suite gives false confidence by
|
||
passing against memory while SQL would behave differently.
|
||
|
||
Setup: expired-lease run (interruptible) inserted FIRST, then a
|
||
valid-lease run owned by another worker. Iteration order means the
|
||
expired run is mutated before the valid-lease run raises — so a naive
|
||
single-pass implementation would leave the expired run interrupted.
|
||
"""
|
||
store = MemoryRunStore()
|
||
config = _lease_config(grace_seconds=10)
|
||
expired_lease = (datetime.now(UTC) - timedelta(seconds=60)).isoformat()
|
||
valid_lease = (datetime.now(UTC) + timedelta(seconds=30)).isoformat()
|
||
|
||
# Seed both active rows directly via ``put`` (bypassing create_run_atomic's
|
||
# reject check, which would refuse the second row). Insert the
|
||
# interruptible run first so dict iteration visits it first — that's the
|
||
# ordering that exposes the half-interrupted divergence in a naive
|
||
# single-pass implementation.
|
||
await store.put(
|
||
"expired-run",
|
||
thread_id="thread-1",
|
||
status="pending",
|
||
owner_worker_id="old-worker",
|
||
lease_expires_at=expired_lease,
|
||
)
|
||
await store.put(
|
||
"valid-lease-run",
|
||
thread_id="thread-1",
|
||
status="pending",
|
||
owner_worker_id="other-worker",
|
||
lease_expires_at=valid_lease,
|
||
)
|
||
|
||
with pytest.raises(ConflictError, match="another worker"):
|
||
await store.create_run_atomic(
|
||
run_id="run-new",
|
||
thread_id="thread-1",
|
||
owner_worker_id="w1",
|
||
lease_expires_at=(datetime.now(UTC) + timedelta(seconds=30)).isoformat(),
|
||
multitask_strategy="interrupt",
|
||
grace_seconds=config.grace_seconds,
|
||
)
|
||
|
||
# The expired run must be UNTOUCHED — the interrupt pass must roll back
|
||
# on ConflictError, not leave a half-interrupted store.
|
||
expired_row = await store.get("expired-run")
|
||
assert expired_row["status"] == "pending"
|
||
assert expired_row["owner_worker_id"] == "old-worker"
|
||
assert expired_row["error"] is None
|
||
|
||
# The valid-lease run that caused the conflict is also untouched.
|
||
valid_row = await store.get("valid-lease-run")
|
||
assert valid_row["status"] == "pending"
|
||
assert valid_row["owner_worker_id"] == "other-worker"
|
||
|
||
# The new run was never inserted.
|
||
assert await store.get("run-new") is None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# update_lease
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_update_lease_renews_row():
|
||
"""update_lease must update the lease_expires_at on the stored row."""
|
||
store = MemoryRunStore()
|
||
old_lease = (datetime.now(UTC) + timedelta(seconds=5)).isoformat()
|
||
await store.put(
|
||
"run-1",
|
||
thread_id="thread-1",
|
||
status="running",
|
||
owner_worker_id="w1",
|
||
lease_expires_at=old_lease,
|
||
)
|
||
|
||
new_lease = (datetime.now(UTC) + timedelta(seconds=30)).isoformat()
|
||
updated = await store.update_lease(
|
||
"run-1",
|
||
owner_worker_id="w1",
|
||
lease_expires_at=new_lease,
|
||
)
|
||
assert updated is True
|
||
|
||
stored = await store.get("run-1")
|
||
assert stored["lease_expires_at"] == new_lease
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_update_lease_returns_false_for_terminal_run():
|
||
"""update_lease must return False when the run is not pending/running."""
|
||
store = MemoryRunStore()
|
||
await store.put("run-1", thread_id="thread-1", status="success", owner_worker_id="w1")
|
||
|
||
new_lease = (datetime.now(UTC) + timedelta(seconds=30)).isoformat()
|
||
updated = await store.update_lease(
|
||
"run-1",
|
||
owner_worker_id="w1",
|
||
lease_expires_at=new_lease,
|
||
)
|
||
assert updated is False
|
||
|
||
stored = await store.get("run-1")
|
||
assert stored["status"] == "success"
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_update_lease_returns_false_for_wrong_owner():
|
||
"""update_lease must reject renewal when owner_worker_id does not match."""
|
||
store = MemoryRunStore()
|
||
old_lease = (datetime.now(UTC) + timedelta(seconds=5)).isoformat()
|
||
await store.put(
|
||
"run-1",
|
||
thread_id="thread-1",
|
||
status="running",
|
||
owner_worker_id="w1",
|
||
lease_expires_at=old_lease,
|
||
)
|
||
|
||
new_lease = (datetime.now(UTC) + timedelta(seconds=30)).isoformat()
|
||
updated = await store.update_lease(
|
||
"run-1",
|
||
owner_worker_id="w2", # different worker
|
||
lease_expires_at=new_lease,
|
||
)
|
||
assert updated is False
|
||
|
||
# The original lease must be untouched
|
||
stored = await store.get("run-1")
|
||
assert stored["owner_worker_id"] == "w1"
|
||
assert stored["lease_expires_at"] == old_lease
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# list_inflight_with_expired_lease
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_list_inflight_with_expired_lease_filters_correctly():
|
||
"""Only runs with expired or NULL leases must be returned."""
|
||
store = MemoryRunStore()
|
||
now = datetime.now(UTC)
|
||
grace = 10
|
||
|
||
# Expired lease
|
||
expired = (now - timedelta(seconds=60)).isoformat()
|
||
await store.put("expired-run", thread_id="t1", status="running", owner_worker_id="w1", lease_expires_at=expired, created_at=expired)
|
||
|
||
# Valid lease
|
||
valid = (now + timedelta(seconds=60)).isoformat()
|
||
await store.put("valid-run", thread_id="t2", status="running", owner_worker_id="w2", lease_expires_at=valid, created_at=valid)
|
||
|
||
# NULL lease (legacy)
|
||
await store.put("null-lease-run", thread_id="t3", status="running", created_at=(now - timedelta(seconds=30)).isoformat())
|
||
|
||
# Terminal status (should not appear)
|
||
await store.put("success-run", thread_id="t4", status="success", created_at=(now - timedelta(seconds=60)).isoformat())
|
||
|
||
results = await store.list_inflight_with_expired_lease(grace_seconds=grace)
|
||
|
||
result_ids = {r["run_id"] for r in results}
|
||
assert "expired-run" in result_ids
|
||
assert "null-lease-run" in result_ids
|
||
assert "valid-run" not in result_ids
|
||
assert "success-run" not in result_ids
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# MemoryRunStore — datetime comparison for created_at filtering
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_list_inflight_with_expired_lease_compares_created_at_as_datetime():
|
||
"""``before`` filter must use datetime comparison, not string lexical order.
|
||
|
||
ISO-8601 strings compare lexically only when every component is zero-padded
|
||
to the same width and the timezone suffix matches. Datetime parsing is
|
||
order-safe regardless of format.
|
||
"""
|
||
store = MemoryRunStore()
|
||
now = datetime.now(UTC)
|
||
grace = 10
|
||
|
||
# A run created "now" — should be included when before=None (defaults to now).
|
||
await store.put("recent-run", thread_id="t1", status="running", created_at=now.isoformat())
|
||
# A run created far in the future — should be excluded by the before filter
|
||
# even though the string "2300-01-01..." > "2025-..." lexically.
|
||
far_future = "2300-01-01T00:00:00+00:00"
|
||
await store.put("future-run", thread_id="t2", status="running", created_at=far_future)
|
||
|
||
results = await store.list_inflight_with_expired_lease(before=now.isoformat(), grace_seconds=grace)
|
||
result_ids = {r["run_id"] for r in results}
|
||
assert "recent-run" in result_ids
|
||
assert "future-run" not in result_ids
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_list_inflight_with_expired_lease_handles_malformed_created_at():
|
||
"""Malformed ``created_at`` values must not crash the listing."""
|
||
store = MemoryRunStore()
|
||
grace = 10
|
||
|
||
store._runs["bad-run"] = {
|
||
"run_id": "bad-run",
|
||
"thread_id": "t1",
|
||
"status": "running",
|
||
"created_at": "not-a-datetime",
|
||
}
|
||
store._runs["empty-run"] = {
|
||
"run_id": "empty-run",
|
||
"thread_id": "t2",
|
||
"status": "running",
|
||
"created_at": "",
|
||
}
|
||
|
||
results = await store.list_inflight_with_expired_lease(grace_seconds=grace)
|
||
# Both should be skipped because their created_at can't be parsed
|
||
result_ids = {r["run_id"] for r in results}
|
||
assert "bad-run" not in result_ids
|
||
assert "empty-run" not in result_ids
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_list_inflight_with_expired_lease_datetime_aware_naive_handling():
|
||
"""Lease comparison must handle aware and naive datetimes.
|
||
|
||
``lease_expires_at`` stored with a trailing ``+00:00`` (aware) and without
|
||
(naive) should both be comparable against the aware ``cutoff``. The MemoryRunStore
|
||
uses ``datetime.fromisoformat`` which preserves the offset, so both paths
|
||
must work.
|
||
"""
|
||
store = MemoryRunStore()
|
||
now = datetime.now(UTC)
|
||
grace = 10
|
||
|
||
# Naive datetime (no timezone suffix) — common on SQLite read-back
|
||
naive_expired = (now - timedelta(seconds=60)).isoformat() # "2025-01-01T00:00:00"
|
||
await store.put("naive-run", thread_id="t1", status="running", lease_expires_at=naive_expired, created_at=naive_expired)
|
||
|
||
# Aware datetime (with +00:00)
|
||
aware_expired = (now - timedelta(seconds=60)).replace(tzinfo=UTC).isoformat() # "2025-01-01T00:00:00+00:00"
|
||
await store.put("aware-run", thread_id="t2", status="running", lease_expires_at=aware_expired, created_at=aware_expired)
|
||
|
||
results = await store.list_inflight_with_expired_lease(grace_seconds=grace)
|
||
result_ids = {r["run_id"] for r in results}
|
||
# Both expired, both should be returned
|
||
assert "naive-run" in result_ids
|
||
assert "aware-run" in result_ids
|
||
|
||
|
||
@pytest.mark.anyio
|
||
async def test_list_inflight_with_expired_lease_null_lease_always_reclaimed():
|
||
"""NULL lease rows are always reclaimed regardless of created_at value."""
|
||
store = MemoryRunStore()
|
||
grace = 10
|
||
|
||
# NULL lease is the single-worker mode default — every inflight row
|
||
# must be returned so reconciliation can reclaim it.
|
||
await store.put("null-run", thread_id="t1", status="running", created_at=datetime.now(UTC).isoformat())
|
||
|
||
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
|