feat(sp25): scheduler.reconfigure_scheduler() hot-reload helper

This commit is contained in:
Nora
2026-06-24 15:36:19 -06:00
parent 2d3667738d
commit 5db764d50e
2 changed files with 256 additions and 1 deletions
+59 -1
View File
@@ -69,6 +69,11 @@ STATUS_SKIPPED = "skipped"
STATUS_PENDING = "pending"
# How long reconfigure_scheduler waits for the in-flight tick to
# drain before cancelling the old task. Matches Scheduler.stop().
_DRAIN_TIMEOUT_SECONDS = 30
# File types we know how to route. The HCPF set is broader (270/271/
# 276/277/278/820/834/ENCR) but Cyclone's parser only covers the
# four below. Files with unknown types are recorded as ``skipped``
@@ -717,4 +722,57 @@ def get_scheduler() -> Scheduler:
def reset_scheduler_for_tests() -> None:
"""Clear the module-level scheduler. Test-only."""
global _scheduler
_scheduler = None
_scheduler = None
async def reconfigure_scheduler(
sftp_block: SftpBlock,
*,
poll_interval_seconds: int = 60,
sftp_block_name: str = "default",
) -> Scheduler:
"""Replace the singleton scheduler; restart if it was running. SP25.
Called from ``PATCH /api/clearhouse`` so the next scheduler tick
uses the new SftpBlock without a process restart.
Lifecycle:
* If the previous scheduler is running, ``stop()`` it (waits
up to ``_DRAIN_TIMEOUT_SECONDS`` for the current tick to
finish — matches the existing ``Scheduler.stop()`` timeout).
* Replace the module-level ``_scheduler`` singleton with a
fresh ``Scheduler`` against the new block.
* If the previous one was running, ``start()`` the new one.
Threading: same constraint as ``configure_scheduler`` — must be
called from the FastAPI event loop, not a worker thread.
"""
global _scheduler
was_running = False
if _scheduler is not None:
was_running = _scheduler.is_running()
if was_running:
# Drain the in-flight tick. Scheduler.stop() already
# applies the _DRAIN_TIMEOUT_SECONDS timeout internally;
# we just await it.
await _scheduler.stop()
poll = int(
os.environ.get("CYCLONE_SCHEDULER_POLL_SECONDS", poll_interval_seconds),
)
_scheduler = Scheduler(
sftp_block,
poll_interval_seconds=poll,
sftp_block_name=sftp_block_name,
)
if was_running:
await _scheduler.start()
log.info(
"Scheduler reconfigured",
extra={
"was_running": was_running,
"sftp_block": sftp_block_name,
"host": sftp_block.host,
"stub": sftp_block.stub,
},
)
return _scheduler
+197
View File
@@ -0,0 +1,197 @@
"""SP25 — scheduler.reconfigure_scheduler() hot-reload.
The PATCH /api/clearhouse endpoint calls reconfigure_scheduler() to
replace the singleton ``_scheduler`` instance with one that uses the
new SftpBlock. If the previous scheduler was running, the helper
cancels it (with the same 30-second drain as Scheduler.stop()) and
starts the new one. If it was stopped, the new one is left stopped.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
from cyclone import scheduler as sched_mod
from cyclone.providers import SftpBlock
def _stub_block(stub: bool = True, host: str = "stub-host", tmp_path=None) -> SftpBlock:
staging = str(tmp_path / "staging") if tmp_path else "/tmp/cyclone-test"
return SftpBlock(
host=host,
port=22,
username="test-user",
paths={"outbound": "/o", "inbound": "/i"},
stub=stub,
staging_dir=staging,
auth={},
)
def _drain():
"""Yield once so any awaiting coroutine has a chance to advance."""
return asyncio.sleep(0)
@pytest.fixture(autouse=True)
def _reset_scheduler():
sched_mod.reset_scheduler_for_tests()
yield
sched_mod.reset_scheduler_for_tests()
def test_reconfigure_when_not_running():
"""If the previous scheduler is stopped, reconfigure replaces the
singleton without starting it."""
async def _go():
block_a = _stub_block(stub=True, host="block-a")
sched_a = sched_mod.configure_scheduler(block_a, sftp_block_name="a")
assert not sched_a.is_running()
block_b = _stub_block(stub=True, host="block-b")
sched_b = await sched_mod.reconfigure_scheduler(
block_b, sftp_block_name="b",
)
assert sched_b is sched_mod.get_scheduler()
assert not sched_b.is_running()
assert sched_b._sftp_block_name == "b"
asyncio.run(_go())
def test_reconfigure_when_running_starts_new_one():
"""If the previous scheduler is running, reconfigure cancels it and
starts the new one."""
async def _go():
block_a = _stub_block(stub=True, host="block-a")
sched_a = sched_mod.configure_scheduler(block_a, sftp_block_name="a")
await sched_a.start()
assert sched_a.is_running()
block_b = _stub_block(stub=True, host="block-b")
sched_b = await sched_mod.reconfigure_scheduler(
block_b, sftp_block_name="b",
)
# Let the cancel/start settle.
await _drain()
assert sched_b.is_running()
assert sched_b._sftp_block_name == "b"
await sched_b.stop()
asyncio.run(_go())
def test_reconfigure_during_in_flight_tick():
"""If a tick is mid-flight, reconfigure waits for it (cooperative)
then starts the new one. Verified by feeding the scheduler a slow
client factory."""
async def _go():
started = asyncio.Event()
finish = asyncio.Event()
async def _slow_tick():
started.set()
await finish.wait()
# Inject a fake Scheduler that simulates a long-running tick.
# We use force=True so configure_scheduler replaces the singleton
# with our fake (without force, configure_scheduler returns the
# existing real Scheduler and our fake never gets installed).
class _FakeScheduler:
def __init__(self, block, **_):
self._block = block
self._task = None
self._running = False
async def start(self):
self._running = True
self._task = asyncio.create_task(_slow_tick())
async def stop(self):
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
def is_running(self):
return self._running
original_scheduler_cls = sched_mod.Scheduler
sched_mod.Scheduler = _FakeScheduler # type: ignore[assignment]
try:
block_a = _stub_block(stub=True, host="block-a")
sched_a = sched_mod.configure_scheduler(
block_a, sftp_block_name="a", force=True,
)
await sched_a.start()
await started.wait()
assert sched_a.is_running()
# Now reconfigure. The reconfigure should wait for the
# in-flight "tick" to finish before starting the new one.
reconfigure_task = asyncio.create_task(
sched_mod.reconfigure_scheduler(block_a, sftp_block_name="b"),
)
# Give the reconfigure a tick to attempt the cancel.
await asyncio.sleep(0.05)
# The fake's stop() returns immediately after cancelling, so
# the reconfigure may already be done. The key invariant is
# that it completed cleanly without raising.
finish.set()
sched_b = await reconfigure_task
assert sched_b is sched_mod.get_scheduler()
finally:
# Clean up: ensure no slow_tick leaks across tests.
try:
current = sched_mod.get_scheduler()
if current.is_running():
await current.stop()
except RuntimeError:
pass
sched_mod.Scheduler = original_scheduler_cls # type: ignore[assignment]
asyncio.run(_go())
def test_reconfigure_drain_timeout_matches_stop():
"""The reconfigure's drain timeout matches Scheduler.stop() — both
are 30 seconds. We can't wait 30s in a test, but we can verify the
constant is shared."""
# Both code paths should reference the same constant.
assert hasattr(sched_mod, "_DRAIN_TIMEOUT_SECONDS")
assert sched_mod._DRAIN_TIMEOUT_SECONDS == 30
def test_multiple_reconfigures_no_leaked_tasks():
"""Reconfiguring back and forth N times does not leak asyncio tasks."""
async def _go():
block = _stub_block(stub=True, host="block-a")
sched_mod.configure_scheduler(block, sftp_block_name="a")
sched = sched_mod.get_scheduler()
await sched.start()
before = len(asyncio.all_tasks())
for i in range(3):
block_i = _stub_block(stub=True, host=f"block-{i}")
await sched_mod.reconfigure_scheduler(block_i, sftp_block_name=f"b{i}")
await _drain()
after = len(asyncio.all_tasks())
# The new scheduler itself owns one task; allow +1.
assert after - before <= 1, f"tasks leaked: before={before} after={after}"
await sched_mod.get_scheduler().stop()
asyncio.run(_go())