验证 curator 调度器不阻塞启动、启动延迟后真实执行、无锁时跳过。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
157 lines
5.7 KiB
Python
157 lines
5.7 KiB
Python
"""Tests that the curator scheduler does not block application startup.
|
|
|
|
Verifies:
|
|
1. _curator_scheduler_loop is launched as a non-blocking asyncio task.
|
|
2. The scheduler sleeps for the startup delay before doing any work.
|
|
3. maybe_run_curator is NOT called during the startup delay window.
|
|
4. When the lock is unavailable the loop exits immediately without calling the curator.
|
|
|
|
NOTE: ``import app.gateway.app as m`` resolves to the FastAPI *instance* because
|
|
``app/gateway/__init__.py`` re-exports ``app = FastAPI(...)``. The actual module
|
|
object lives in ``sys.modules["app.gateway.app"]`` and is used here via a helper.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import sys
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
def _gateway_module():
|
|
"""Return the real app.gateway.app *module* (not the FastAPI instance)."""
|
|
import importlib
|
|
importlib.import_module("app.gateway.app") # side-effect: populates sys.modules
|
|
return sys.modules["app.gateway.app"]
|
|
|
|
|
|
_STARTUP_DELAY = 300 # matches _CURATOR_STARTUP_DELAY_SECONDS in app.gateway.app
|
|
_CHECK_INTERVAL = 3600 # matches _CURATOR_CHECK_INTERVAL_SECONDS
|
|
|
|
|
|
def test_scheduler_does_not_block_startup():
|
|
"""create_task must return immediately — startup must not await the loop body."""
|
|
mod = _gateway_module()
|
|
curator_called = False
|
|
|
|
async def fake_sleep(_delay):
|
|
pass
|
|
|
|
async def fake_curator():
|
|
nonlocal curator_called
|
|
curator_called = True
|
|
|
|
async def run():
|
|
with (
|
|
patch.object(mod, "_acquire_singleton_lock", return_value=MagicMock()),
|
|
patch.object(mod, "_release_singleton_lock"),
|
|
patch.object(mod, "_CURATOR_STARTUP_DELAY_SECONDS", _STARTUP_DELAY),
|
|
patch.object(mod, "_CURATOR_CHECK_INTERVAL_SECONDS", _CHECK_INTERVAL),
|
|
patch("deerflow.skills.curator.maybe_run_curator", fake_curator),
|
|
patch("asyncio.sleep", fake_sleep),
|
|
):
|
|
task = asyncio.create_task(mod._curator_scheduler_loop())
|
|
# create_task must return immediately; the coroutine is still pending
|
|
assert not task.done(), "task should be running in the background, not finished"
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
asyncio.run(run())
|
|
assert not curator_called, "curator must not fire before the startup delay completes"
|
|
|
|
|
|
def test_scheduler_sleeps_startup_delay_before_first_curator_call():
|
|
"""The very first asyncio.sleep must use the startup-delay value (300 s)."""
|
|
mod = _gateway_module()
|
|
sleep_calls: list[float] = []
|
|
curator_called = False
|
|
|
|
async def fake_sleep(delay):
|
|
sleep_calls.append(delay)
|
|
raise asyncio.CancelledError # stop after recording first sleep
|
|
|
|
async def fake_curator():
|
|
nonlocal curator_called
|
|
curator_called = True
|
|
|
|
async def run():
|
|
with (
|
|
patch.object(mod, "_acquire_singleton_lock", return_value=MagicMock()),
|
|
patch.object(mod, "_release_singleton_lock"),
|
|
patch.object(mod, "_CURATOR_STARTUP_DELAY_SECONDS", _STARTUP_DELAY),
|
|
patch.object(mod, "_CURATOR_CHECK_INTERVAL_SECONDS", _CHECK_INTERVAL),
|
|
patch("deerflow.skills.curator.maybe_run_curator", fake_curator),
|
|
patch("asyncio.sleep", fake_sleep),
|
|
):
|
|
task = asyncio.create_task(mod._curator_scheduler_loop())
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
asyncio.run(run())
|
|
|
|
assert sleep_calls, "asyncio.sleep was never called"
|
|
assert sleep_calls[0] == _STARTUP_DELAY, (
|
|
f"first sleep must be the startup delay ({_STARTUP_DELAY}s), got {sleep_calls[0]}"
|
|
)
|
|
assert not curator_called, "curator must not be called before startup delay elapses"
|
|
|
|
|
|
def test_scheduler_actually_calls_curator_after_startup_delay():
|
|
"""After the startup delay elapses, maybe_run_curator must be called."""
|
|
mod = _gateway_module()
|
|
curator_call_count = 0
|
|
|
|
async def fake_sleep(*_):
|
|
pass # instant — simulate time passing
|
|
|
|
async def fake_curator():
|
|
nonlocal curator_call_count
|
|
curator_call_count += 1
|
|
# After first call cancel the loop so the test terminates
|
|
raise asyncio.CancelledError
|
|
|
|
async def run():
|
|
with (
|
|
patch.object(mod, "_acquire_singleton_lock", return_value=MagicMock()),
|
|
patch.object(mod, "_release_singleton_lock"),
|
|
patch.object(mod, "_CURATOR_STARTUP_DELAY_SECONDS", _STARTUP_DELAY),
|
|
patch.object(mod, "_CURATOR_CHECK_INTERVAL_SECONDS", _CHECK_INTERVAL),
|
|
patch("deerflow.skills.curator.maybe_run_curator", fake_curator),
|
|
patch("asyncio.sleep", fake_sleep),
|
|
):
|
|
task = asyncio.create_task(mod._curator_scheduler_loop())
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
asyncio.run(run())
|
|
assert curator_call_count >= 1, (
|
|
f"maybe_run_curator should have been called at least once, got {curator_call_count}"
|
|
)
|
|
|
|
|
|
def test_scheduler_skips_when_lock_unavailable():
|
|
"""When another worker holds the lock the loop must exit without doing any work."""
|
|
mod = _gateway_module()
|
|
curator_called = False
|
|
|
|
async def fake_curator():
|
|
nonlocal curator_called
|
|
curator_called = True
|
|
|
|
async def run():
|
|
with (
|
|
patch.object(mod, "_acquire_singleton_lock", return_value=None),
|
|
patch("deerflow.skills.curator.maybe_run_curator", fake_curator),
|
|
):
|
|
await mod._curator_scheduler_loop()
|
|
|
|
asyncio.run(run())
|
|
assert not curator_called, "curator must not run when lock is not acquired"
|