deer-flow/backend/tests/test_multi_worker_run_ownership.py
heart-scalpel 3bc3af2530
fix(runs): close multi-worker ownership gaps in run atomicity (#3948) (#4003)
* 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>
2026-07-11 16:05:30 +08:00

968 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for multi-worker run ownership (work items 23).
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