test(curator): add scheduler startup non-blocking and execution tests
验证 curator 调度器不阻塞启动、启动延迟后真实执行、无锁时跳过。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
d0c696e523
commit
e9125b0900
1 file changed
+156
@@ -0,0 +1,156 @@
|
||||
"""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"
|
||||
Reference in new issue
Block a user