mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 02:56:17 +00:00
Replaces the pre-hexagonal scheduled-task implementation with a slice built to the layering spec: a pure domain (two aggregates, two state machines, the policy value object), output ports it declares itself, SQL/launcher/thread adapters implementing them under `app/adapters/`, and a composition root that is the one place any of them is instantiated. The old implementation mixed all of that into `app/scheduler/service.py` and a router that reached straight into repositories, so the rules that matter -- overlap policy, lease handling, which write owns which timestamp -- were only reachable through a live database. They are now unit-assertable on in-memory fakes, with the contract suite running each port against both the fake and real sqlite, and the concurrency invariants pinned by dedicated race tests. Two bugs the old shape hid are fixed on the way: a completion hook that replayed a stale snapshot and rolled back the launch write, and a corrupt stored row surfacing to the client as a 4xx. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
147 lines
6.0 KiB
Python
147 lines
6.0 KiB
Python
"""Tests for the composition root.
|
|
|
|
The point of extracting `build_domain_services` from the lifespan is that the
|
|
rules below become assertions. Before, "a memory backend means no service,
|
|
and the routes answer 503" was a comment inside a 180-line startup function
|
|
that no test could reach without booting the whole application.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.composition import DomainServices, build_domain_services, build_run_completion_hook, build_schedule_policy
|
|
from deerflow.config.scheduler_config import SchedulerConfig
|
|
from deerflow.domain.schedule.model import SchedulePolicy
|
|
|
|
|
|
class _StubThreadStore:
|
|
async def check_access(self, thread_id: str, user_id: str, *, require_existing: bool = False) -> bool:
|
|
return True
|
|
|
|
|
|
async def _launch_run(**kwargs):
|
|
return {"run_id": "run-1", "thread_id": kwargs.get("thread_id", "thread-1")}
|
|
|
|
|
|
def _build(session_factory, scheduler_config=None) -> DomainServices:
|
|
return build_domain_services(
|
|
session_factory=session_factory,
|
|
thread_store=_StubThreadStore(),
|
|
launch_run=_launch_run,
|
|
scheduler_config=scheduler_config or SchedulerConfig(),
|
|
)
|
|
|
|
|
|
class TestMemoryBackend:
|
|
"""`session_factory is None` is how `database.backend: memory` presents."""
|
|
|
|
def test_no_session_factory_yields_no_services(self):
|
|
assert _build(None).schedule is None
|
|
|
|
def test_it_does_not_degrade_to_an_in_memory_implementation(self):
|
|
"""Refusing is the intended behaviour: a scheduled task that silently
|
|
vanishes on restart is worse than one the API declines to accept."""
|
|
assert _build(None).schedule is None
|
|
|
|
|
|
class TestSqlBackend:
|
|
def test_a_session_factory_yields_the_service(self):
|
|
"""A fake sessionmaker is enough -- wiring must not touch the database,
|
|
which is what makes this assertable without a live engine."""
|
|
assert _build(object()).schedule is not None
|
|
|
|
|
|
class TestSchedulePolicy:
|
|
def test_every_threshold_comes_from_the_operator_config(self):
|
|
policy = build_schedule_policy(
|
|
SchedulerConfig(
|
|
min_once_delay_seconds=30,
|
|
max_concurrent_runs=7,
|
|
lease_seconds=90,
|
|
)
|
|
)
|
|
assert policy == SchedulePolicy(
|
|
min_once_delay_seconds=30,
|
|
max_concurrent_runs=7,
|
|
lease_seconds=90,
|
|
)
|
|
|
|
def test_the_domain_defaults_are_not_what_production_gets(self):
|
|
"""The domain's defaults are permissive so that "nobody configured a
|
|
policy" invents no business constraint. Production must not inherit
|
|
them by accident -- the config's own defaults are the real values."""
|
|
from_config = build_schedule_policy(SchedulerConfig())
|
|
assert from_config != SchedulePolicy()
|
|
assert from_config.min_once_delay_seconds == SchedulerConfig().min_once_delay_seconds
|
|
|
|
@pytest.mark.parametrize(
|
|
("field", "value"),
|
|
[
|
|
("min_once_delay_seconds", 45),
|
|
("max_concurrent_runs", 5),
|
|
("lease_seconds", 300),
|
|
],
|
|
)
|
|
def test_each_field_is_mapped_from_its_own_config_key(self, field, value):
|
|
"""Guards against two thresholds being wired from one key -- a
|
|
transposition the type checker cannot see, since all three are ints."""
|
|
policy = build_schedule_policy(SchedulerConfig(**{field: value}))
|
|
assert getattr(policy, field) == value
|
|
|
|
def test_poll_interval_is_not_part_of_the_policy(self):
|
|
"""How often to look is the poller's business, not a rule any task is
|
|
subject to."""
|
|
assert not hasattr(SchedulePolicy(), "poll_interval_seconds")
|
|
|
|
|
|
class TestEveryPortGetsTheRightAdapter:
|
|
"""Deliberately white-box: verifying which adapter landed in which slot is
|
|
the entire job of a composition root, and all four are keyword arguments
|
|
of compatible shape, so a transposition type-checks cleanly and would only
|
|
surface as a production error.
|
|
"""
|
|
|
|
def test_each_schedule_port_is_filled_with_its_own_adapter(self):
|
|
from app.adapters.schedule.run_launcher import GatewayRunLauncher
|
|
from app.adapters.schedule.scheduled_run_repository import SqlScheduledRunRepository
|
|
from app.adapters.schedule.scheduled_task_repository import SqlScheduledTaskRepository
|
|
from app.adapters.schedule.thread_lookup import ThreadStoreThreadLookup
|
|
|
|
service = _build(object()).schedule
|
|
assert isinstance(service._tasks, SqlScheduledTaskRepository)
|
|
assert isinstance(service._runs, SqlScheduledRunRepository)
|
|
assert isinstance(service._launcher, GatewayRunLauncher)
|
|
assert isinstance(service._threads, ThreadStoreThreadLookup)
|
|
|
|
def test_the_policy_reaches_the_service(self):
|
|
service = _build(object(), SchedulerConfig(max_concurrent_runs=9)).schedule
|
|
assert service._policy.max_concurrent_runs == 9
|
|
|
|
|
|
class TestRunCompletionHook:
|
|
"""The inbound half of the wiring.
|
|
|
|
What the hook *does* with a run is asserted in
|
|
`test_schedule_run_completion.py`; all that is left here is the assembly
|
|
decision, which is this module's whole job.
|
|
"""
|
|
|
|
def test_no_service_installs_no_hook(self):
|
|
"""`None` rather than a hook that always declines: with no service
|
|
there is nothing for the runtime to call, and saying so lets it skip
|
|
the callback entirely."""
|
|
assert build_run_completion_hook(None) is None
|
|
|
|
def test_a_service_is_wrapped_in_the_inbound_adapter(self):
|
|
from app.adapters.schedule.run_completion import ScheduleRunCompletionListener
|
|
|
|
hook = build_run_completion_hook(_build(object()).schedule)
|
|
assert isinstance(hook, ScheduleRunCompletionListener)
|
|
|
|
def test_the_hook_is_bound_to_the_service_it_was_given(self):
|
|
"""Deliberately white-box, for the same reason as the port assertions
|
|
above: a hook wired to the wrong service type-checks cleanly."""
|
|
service = _build(object()).schedule
|
|
assert build_run_completion_hook(service)._service is service
|