mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-16 09:49:10 +00:00
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>
179 lines
8.1 KiB
Python
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)
|