deer-flow/backend/tests/test_schedule_run_launcher.py
rayhpeng 6f84a4094d refactor(schedule): fill the ports with adapters and delete the old path
The outer ring for the domain added in #4597: SQL repositories, the run
launcher, the thread lookup, and the run-completion listener implementing
the ports it declared, plus the HTTP router and the poller that drive
them. All of it is instantiated in one composition root, so no route or
lifespan hook builds an adapter of its own.

With the ports filled, the pre-hexagonal implementation is deleted rather
than left alongside: `app/scheduler/service.py` and its router mixed
policy, persistence, and HTTP into one class, which is why its rules were
only reachable through a live database. Keeping both would leave two
implementations of the same rules writing to the same table.

Three of the domain's contracts needed real work on this side rather than
a straight port of the pre-#4597 adapters:

- The launcher now distinguishes certain failure from doubt. Only a 4xx
  is certain enough to raise LaunchFailedError, which releases the task's
  single active slot; a 5xx, an arbitrary exception, or a reply whose
  identity will not decode all raise LaunchIndeterminateError and keep
  the slot held. Guessing "failed" after the launch request was sent is
  what re-opens #4452's duplicate execution.

- The task repository implements the optimistic token. `save` is a
  conditional UPDATE on `version` rather than read-check-write, because
  the latter lets two savers observe the same version and both commit;
  every other committed write increments it. This needs a column, so it
  ships with migration 0011 -- the only schema change in the slice, and
  the reason the alembic head pins move.

- The router builds commands with plain `None` for "not supplied", and
  maps ConcurrentUpdateError onto a retryable 409.

The concurrency invariants are pinned by contract suites that run each
port against both the in-memory double and real sqlite -- including a new
TestOptimisticConcurrency covering what invalidates an earlier read --
plus the dispatch-race tests against a real database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:01:32 +08:00

179 lines
8.1 KiB
Python

"""Contract tests for the run-launcher anti-corruption layer.
The `RunLauncher` port allows exactly three exceptions to escape --
`ThreadBusyError`, `LaunchFailedError` and `LaunchIndeterminateError` -- and the
domain branches on the difference: a busy thread on a scheduled dispatch is a
*skipped* occurrence, a certain failure is a *recorded* one that releases the
task's active slot, and an indeterminate launch *keeps* the slot with the run's
identity unknown. Everything the Gateway can raise is therefore classified here,
and this file is what pins that classification.
The failed/indeterminate line is the #4452 duplicate-execution guard: releasing
the slot for a launch that may already have started lets the next dispatch run
the task twice. Only a 4xx -- rejected before the launch path did anything --
is certain enough to release it.
That translation is the whole point of the adapter: it is what lets
`app/scheduler/service.py`'s `from fastapi import HTTPException` disappear
without the busy/failed distinction disappearing with it.
There is deliberately no `isinstance(launcher, RunLauncher)` assertion. The
adapter inherits the port explicitly, which makes that check trivially true --
and worse than useless: inheritance is exactly what turns a misspelled method
into a silent inherited `...` body returning `None`. Calling every port method
and asserting on what it returns, as this file does, is what actually catches
that.
"""
from __future__ import annotations
import asyncio
import pytest
from fastapi import HTTPException
from app.adapters.schedule.run_launcher import GatewayRunLauncher
from deerflow.domain.schedule.exceptions import LaunchFailedError, LaunchIndeterminateError, ThreadBusyError
from deerflow.domain.schedule.ports import LaunchedRun
from deerflow.runtime import ConflictError
LAUNCH_KWARGS = {
"thread_id": "thread-1",
"assistant_id": "assistant-1",
"prompt": "do the thing",
"owner_user_id": "user-1",
"metadata": {"scheduled_task_id": "task-1", "scheduled_task_run_id": "rec-1", "scheduled_trigger": "scheduled"},
}
def _launcher_returning(payload):
async def launch_run(**kwargs):
launch_run.calls.append(kwargs)
return payload
launch_run.calls = []
return GatewayRunLauncher(launch_run), launch_run
def _launcher_raising(exc):
async def launch_run(**kwargs):
raise exc
return GatewayRunLauncher(launch_run)
class TestSuccessfulLaunch:
@pytest.mark.asyncio
async def test_returns_what_the_gateway_reported(self):
launcher, _ = _launcher_returning({"run_id": "run-9", "thread_id": "thread-other"})
result = await launcher.launch(**LAUNCH_KWARGS)
assert result == LaunchedRun(run_id="run-9", thread_id="thread-other")
@pytest.mark.asyncio
async def test_echoes_the_gateways_thread_not_the_requested_one(self):
"""`LaunchedRun.thread_id` is documented as what actually ran, so the
adapter must not substitute the thread it asked for."""
launcher, _ = _launcher_returning({"run_id": "run-9", "thread_id": "thread-substituted"})
result = await launcher.launch(**LAUNCH_KWARGS)
assert result.thread_id == "thread-substituted"
@pytest.mark.asyncio
async def test_carries_every_argument_through_untouched(self):
launcher, spy = _launcher_returning({"run_id": "r", "thread_id": "t"})
await launcher.launch(**LAUNCH_KWARGS)
assert spy.calls == [LAUNCH_KWARGS]
@pytest.mark.asyncio
@pytest.mark.parametrize("payload", [{"thread_id": "t"}, {"run_id": "r"}, {}, {"run_id": 7, "thread_id": "t"}])
async def test_a_malformed_gateway_payload_is_indeterminate(self, payload):
"""The launch returned, so a run probably exists -- we just cannot name
it. Calling that a failure would release the task's active slot and let
the next dispatch start a duplicate (#4452)."""
launcher, _ = _launcher_returning(payload)
with pytest.raises(LaunchIndeterminateError):
await launcher.launch(**LAUNCH_KWARGS)
class TestBusyThreadTranslation:
@pytest.mark.asyncio
async def test_conflict_error_becomes_thread_busy(self):
launcher = _launcher_raising(ConflictError("thread already has an active run"))
with pytest.raises(ThreadBusyError):
await launcher.launch(**LAUNCH_KWARGS)
@pytest.mark.asyncio
async def test_http_409_becomes_thread_busy(self):
"""`start_run` rejects a busy thread as an HTTP 409 rather than a
ConflictError on some paths; both mean the same thing here."""
launcher = _launcher_raising(HTTPException(status_code=409, detail="thread is busy"))
with pytest.raises(ThreadBusyError):
await launcher.launch(**LAUNCH_KWARGS)
@pytest.mark.asyncio
async def test_the_cause_survives_in_the_message(self):
launcher = _launcher_raising(ConflictError("thread already has an active run"))
with pytest.raises(ThreadBusyError, match="thread already has an active run"):
await launcher.launch(**LAUNCH_KWARGS)
@pytest.mark.asyncio
async def test_http_409_message_is_the_detail_not_the_repr(self):
launcher = _launcher_raising(HTTPException(status_code=409, detail="thread is busy"))
with pytest.raises(ThreadBusyError, match="^thread is busy$"):
await launcher.launch(**LAUNCH_KWARGS)
class TestFailureTranslation:
@pytest.mark.asyncio
@pytest.mark.parametrize("status_code", [400, 404, 422])
async def test_a_4xx_is_a_certain_failure(self, status_code):
"""Rejected on the way in: the launch path never got far enough to
start anything, so releasing the task's active slot is safe."""
launcher = _launcher_raising(HTTPException(status_code=status_code, detail="nope"))
with pytest.raises(LaunchFailedError):
await launcher.launch(**LAUNCH_KWARGS)
@pytest.mark.asyncio
@pytest.mark.parametrize("status_code", [500, 502, 503])
async def test_a_5xx_is_indeterminate_not_a_failure(self, status_code):
"""Raised from inside the launch path, which may already have created
the run. Releasing the slot here is what #4452 was."""
launcher = _launcher_raising(HTTPException(status_code=status_code, detail="boom"))
with pytest.raises(LaunchIndeterminateError):
await launcher.launch(**LAUNCH_KWARGS)
@pytest.mark.asyncio
async def test_an_arbitrary_exception_is_indeterminate(self):
"""A dropped connection after the request was sent is indistinguishable
from this, so the adapter cannot certify that no run started."""
launcher = _launcher_raising(RuntimeError("database is on fire"))
with pytest.raises(LaunchIndeterminateError, match="database is on fire"):
await launcher.launch(**LAUNCH_KWARGS)
@pytest.mark.asyncio
async def test_the_original_exception_is_chained(self):
"""The domain only needs the categories, but an operator reading a log
needs the real traceback."""
original = RuntimeError("database is on fire")
launcher = _launcher_raising(original)
with pytest.raises(LaunchIndeterminateError) as caught:
await launcher.launch(**LAUNCH_KWARGS)
assert caught.value.__cause__ is original
@pytest.mark.asyncio
async def test_failed_and_indeterminate_are_not_the_same_family(self):
"""The service branches on the exact type -- one releases the slot, the
other retains it -- so neither may be a subclass of the other."""
assert not issubclass(LaunchIndeterminateError, LaunchFailedError)
assert not issubclass(LaunchFailedError, LaunchIndeterminateError)
class TestCancellationIsNotSwallowed:
@pytest.mark.asyncio
async def test_cancelled_error_propagates(self):
"""`CancelledError` is shutdown control flow, not a launch outcome.
Translating it would record a spurious outcome and break cooperative
cancellation of the poll loop."""
launcher = _launcher_raising(asyncio.CancelledError())
with pytest.raises(asyncio.CancelledError):
await launcher.launch(**LAUNCH_KWARGS)