Initial commit

This commit is contained in:
jsahn
2026-08-31 14:33:53 +09:00
commit 8bc43b3ce4
51 changed files with 8902 additions and 0 deletions
+576
View File
@@ -0,0 +1,576 @@
"""Daemon tests with a fake scheduler (captures add_job) and a fake clock."""
from __future__ import annotations
import signal
import threading
import time
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
import pytest
from _orchestration_stubs import FakeClock, FakeDeliverer, FakeStore
from apscheduler.events import EVENT_JOB_MISSED, JobExecutionEvent
from apscheduler.schedulers import SchedulerNotRunningError
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger
from us2kr import daemon as daemon_mod
from us2kr.config import Settings
from us2kr.daemon import (
BRIEFING_JOB_ID,
HEARTBEAT_JOB_ID,
PREMARKET_JOB_ID,
RECOVERY_JOB_ID,
Daemon,
_parse_hhmm,
)
KST = ZoneInfo("Asia/Seoul")
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=KST) # Tuesday noon KST -> next briefing Wed 06:15 KST
class FakeScheduler:
def __init__(self):
self.jobs: dict[str, dict] = {}
self.add_calls: list[dict] = []
self.shutdown_calls: list[bool] = []
self.listeners: list[tuple[object, int | None]] = []
def add_listener(self, callback, mask=None):
self.listeners.append((callback, mask))
def add_job(self, func, trigger=None, **kwargs):
entry = {"func": func, "trigger": trigger, **kwargs}
self.add_calls.append(entry)
self.jobs[kwargs.get("id", func.__name__)] = entry
return entry
def shutdown(self, wait=True):
self.shutdown_calls.append(wait)
def make_settings(tmp_path, **overrides) -> Settings:
values = {"US2KR_DATA_DIR": str(tmp_path / "data"), "US2KR_LOG_DIR": str(tmp_path / "logs")}
values.update(overrides)
return Settings(_env_file=None, **values)
class RunRecorder:
def __init__(self, fail: bool = False):
self.sessions: list[date] = []
self.fail = fail
def __call__(self, session: date):
self.sessions.append(session)
if self.fail:
raise RuntimeError("pipeline exploded")
def make_daemon(tmp_path, *, clock=None, store=None, run_fn=None, settings=None, deliverer=None):
scheduler = FakeScheduler()
clock = clock or FakeClock()
store = store or FakeStore()
run_fn = run_fn or RunRecorder()
deliverer = deliverer or FakeDeliverer("file")
d = Daemon(
settings or make_settings(tmp_path),
scheduler=scheduler,
clock=clock,
store=store,
run_fn=run_fn,
now_fn=lambda: NOW,
deliverers_fn=lambda s: [deliverer],
)
return d, scheduler, clock, store, run_fn, deliverer
# -------------------------------------------------------------------------------- scheduling
def test_schedule_next_schedules_one_shot_job_at_next_briefing_time(tmp_path):
d, scheduler, *_ = make_daemon(tmp_path)
expected_run_at = datetime(2026, 8, 26, 6, 15, tzinfo=KST) # Tue 16:00 EDT + 75 min
session, run_at = d.schedule_next()
assert session == date(2026, 8, 25) and run_at == expected_run_at
assert d.next_run == (session, run_at)
job = scheduler.jobs[BRIEFING_JOB_ID]
assert isinstance(job["trigger"], DateTrigger)
assert job["trigger"].run_date == expected_run_at
assert job["args"] == [session, run_at]
assert job["replace_existing"] is True
# None: a late (sleep/wake) job must still FIRE so it can reschedule itself; see BRIEFING_MISFIRE_GRACE
assert job["misfire_grace_time"] is None
def test_schedule_next_uses_clock_next_briefing_verbatim(tmp_path):
run_at = datetime(2026, 11, 28, 4, 15, tzinfo=KST) # early close day example
clock = FakeClock(next_run=(date(2026, 11, 27), run_at))
d, scheduler, *_ = make_daemon(tmp_path, clock=clock)
d.schedule_next()
assert scheduler.jobs[BRIEFING_JOB_ID]["trigger"].run_date == run_at
assert clock.calls[-1] == ("next_briefing", NOW)
def test_schedule_next_survives_calendar_error(tmp_path):
class BrokenClock(FakeClock):
def next_briefing(self, now):
raise RuntimeError("calendar unavailable")
d, scheduler, *_ = make_daemon(tmp_path, clock=BrokenClock())
assert d.schedule_next() is None
assert BRIEFING_JOB_ID not in scheduler.jobs
# heartbeat retries scheduling
d.clock = FakeClock()
d._heartbeat_job()
assert BRIEFING_JOB_ID in scheduler.jobs
def test_start_schedules_heartbeat_and_no_premarket_by_default(tmp_path):
d, scheduler, *_ = make_daemon(tmp_path)
d.start()
assert set(scheduler.jobs) == {BRIEFING_JOB_ID, HEARTBEAT_JOB_ID}
hb = scheduler.jobs[HEARTBEAT_JOB_ID]
assert isinstance(hb["trigger"], IntervalTrigger)
assert hb["trigger"].interval.total_seconds() == 3600
d._heartbeat_job() # must not raise
def test_premarket_job_scheduled_when_enabled(tmp_path, caplog):
settings = make_settings(
tmp_path, US2KR_PREMARKET_UPDATE_ENABLED="true", US2KR_PREMARKET_TIME_KST="08:20"
)
d, scheduler, *_ = make_daemon(tmp_path, settings=settings)
d.start()
job = scheduler.jobs[PREMARKET_JOB_ID]
trig = job["trigger"]
assert isinstance(trig, CronTrigger)
fields = {f.name: str(f) for f in trig.fields}
assert fields["hour"] == "8" and fields["minute"] == "20" and fields["day_of_week"] == "mon-fri"
assert str(trig.timezone) == "Asia/Seoul"
with caplog.at_level("INFO", logger="us2kr.daemon"):
job["func"]()
assert "premarket update not yet implemented" in caplog.text
def test_premarket_extension_point_is_called_when_provided(tmp_path):
calls = []
d, *_ = make_daemon(tmp_path)
d.premarket_fn = lambda: calls.append("ran")
d._premarket_job()
assert calls == ["ran"]
@pytest.mark.parametrize("bad", ["8:", "25:00", "08:60", "abc", "0820"])
def test_parse_hhmm_rejects_invalid(bad):
with pytest.raises(ValueError):
_parse_hhmm(bad)
def test_parse_hhmm_ok():
assert _parse_hhmm("08:20") == (8, 20)
assert _parse_hhmm(" 7:05 ") == (7, 5)
# ---------------------------------------------------------------------------------- catch-up
def test_catchup_runs_when_candidate_and_not_yet_reported(tmp_path):
clock = FakeClock(catchup=date(2026, 8, 24))
d, _scheduler, clock, _store, run_fn, _ = make_daemon(tmp_path, clock=clock)
assert d.run_catchup() is True
assert run_fn.sessions == [date(2026, 8, 24)]
assert clock.calls[0] == ("catchup_candidate", (NOW, 3))
def test_catchup_skipped_when_report_exists(tmp_path):
clock = FakeClock(catchup=date(2026, 8, 24))
store = FakeStore()
store.reports[(date(2026, 8, 25), "briefing")] = 1 # krx_date_for(2026-08-24) == 2026-08-25
d, _scheduler, clock, store, run_fn, _ = make_daemon(tmp_path, clock=clock, store=store)
assert d.run_catchup() is False
assert run_fn.sessions == []
def test_catchup_skipped_without_candidate(tmp_path):
d, _scheduler, _clock, _store, run_fn, _ = make_daemon(tmp_path)
assert d.run_catchup() is False
assert run_fn.sessions == []
def test_catchup_failure_is_logged_and_start_continues(tmp_path):
clock = FakeClock(catchup=date(2026, 8, 24))
d, scheduler, clock, _store, run_fn, deliverer = make_daemon(
tmp_path, clock=clock, run_fn=RunRecorder(fail=True)
)
d.start() # must not raise
assert run_fn.sessions == [date(2026, 8, 24)]
assert BRIEFING_JOB_ID in scheduler.jobs
assert deliverer.errors and "catch-up" in deliverer.errors[0][0]
def test_start_uses_catchup_window_from_settings(tmp_path):
settings = make_settings(tmp_path, US2KR_CATCHUP_WINDOW_HOURS="5")
d, _scheduler, clock, *_ = make_daemon(tmp_path, settings=settings)
d.start()
assert clock.calls[0] == ("catchup_candidate", (NOW, 5))
# -------------------------------------------------------------------------------------- job
def test_job_runs_pipeline_and_reschedules(tmp_path):
d, scheduler, clock, _store, run_fn, deliverer = make_daemon(tmp_path)
d.schedule_next()
job = scheduler.jobs[BRIEFING_JOB_ID]
session = job["args"][0]
clock.next_run = (date(2026, 8, 26), datetime(2026, 8, 27, 6, 15, tzinfo=KST))
job["func"](*job["args"])
assert run_fn.sessions == [session]
assert len(scheduler.add_calls) == 2
assert scheduler.jobs[BRIEFING_JOB_ID]["args"] == [date(2026, 8, 26), clock.next_run[1]]
assert d.next_run == clock.next_run
assert deliverer.errors == []
def test_job_exception_notifies_and_still_reschedules(tmp_path):
d, scheduler, clock, _store, run_fn, deliverer = make_daemon(tmp_path, run_fn=RunRecorder(fail=True))
d.schedule_next()
job = scheduler.jobs[BRIEFING_JOB_ID]
clock.next_run = (date(2026, 8, 26), datetime(2026, 8, 27, 6, 15, tzinfo=KST))
job["func"](*job["args"]) # must not raise
assert run_fn.sessions == [date(2026, 8, 25)]
assert scheduler.jobs[BRIEFING_JOB_ID]["trigger"].run_date == clock.next_run[1]
assert len(deliverer.errors) == 1
subject, detail = deliverer.errors[0]
assert "브리핑 실패" in subject and "pipeline exploded" in detail
def test_notify_error_failures_are_swallowed(tmp_path):
class Exploding(FakeDeliverer):
def notify_error(self, subject, detail):
raise RuntimeError("telegram down")
d, *_ = make_daemon(tmp_path, run_fn=RunRecorder(fail=True), deliverer=Exploding("tg"))
d._briefing_job(date(2026, 8, 25)) # must not raise
d.deliverers_fn = lambda s: (_ for _ in ()).throw(RuntimeError("cannot build"))
d._briefing_job(date(2026, 8, 25)) # must not raise either
# --------------------------------------------------------------------------------- shutdown
def test_signal_handler_shuts_scheduler_down(tmp_path):
d, scheduler, *_ = make_daemon(tmp_path)
old_term, old_int = signal.getsignal(signal.SIGTERM), signal.getsignal(signal.SIGINT)
try:
d.install_signal_handlers()
handler = signal.getsignal(signal.SIGTERM)
assert callable(handler) and handler is not old_term
handler(signal.SIGTERM, None)
finally:
signal.signal(signal.SIGTERM, old_term)
signal.signal(signal.SIGINT, old_int)
assert scheduler.shutdown_calls == [False]
def test_shutdown_is_idempotent(tmp_path):
class Once(FakeScheduler):
def shutdown(self, wait=True):
super().shutdown(wait)
if len(self.shutdown_calls) > 1:
raise RuntimeError("not running")
d, *_ = make_daemon(tmp_path)
d.scheduler = Once()
d.shutdown()
d.shutdown() # second call raises inside scheduler but is swallowed
# ------------------------------------------------------------------------------- run_daemon
def test_run_daemon_wires_real_parts_and_blocks_on_scheduler(monkeypatch, tmp_path):
"""run_daemon builds Daemon with a BlockingScheduler; we replace start() so the test does not block."""
started = {}
fake_clock = FakeClock()
fake_store = FakeStore()
class FakeBlocking(FakeScheduler):
def __init__(self, timezone=None):
super().__init__()
started["timezone"] = timezone
def start(self, paused=False):
started["started"] = True
raise KeyboardInterrupt # simulate Ctrl-C ending the blocking loop
monkeypatch.setattr(daemon_mod, "BlockingScheduler", FakeBlocking)
monkeypatch.setattr(daemon_mod, "MarketClock", lambda offset, **_kw: fake_clock)
monkeypatch.setattr(daemon_mod, "Store", lambda path: fake_store)
monkeypatch.setattr(daemon_mod.signal, "signal", lambda *a, **k: None)
daemon_mod.run_daemon(make_settings(tmp_path))
assert started["started"] is True
assert str(started["timezone"]) == "Asia/Seoul"
assert fake_store.closed is True
# ------------------------------------------------------------------- missed / late briefing job
def test_start_registers_missed_job_listener(tmp_path):
d, scheduler, *_ = make_daemon(tmp_path)
d.start()
assert (d._on_job_missed, EVENT_JOB_MISSED) in scheduler.listeners
@pytest.mark.parametrize(("lateness_h", "expect_run"), [(0, True), (2, True), (3, False), (5, False)])
def test_late_briefing_job_runs_inside_window_and_skips_outside(tmp_path, lateness_h, expect_run):
"""With misfire grace None the job fires late after sleep/wake; it must apply the catch-up window
(default 3h) and reschedule in every case."""
d, scheduler, clock, _store, run_fn, deliverer = make_daemon(tmp_path)
run_at = NOW - timedelta(hours=lateness_h)
clock.next_run = (date(2026, 8, 26), datetime(2026, 8, 27, 6, 15, tzinfo=KST))
d._briefing_job(date(2026, 8, 25), run_at)
assert (run_fn.sessions == [date(2026, 8, 25)]) is expect_run
assert scheduler.jobs[BRIEFING_JOB_ID]["trigger"].run_date == clock.next_run[1] # always rescheduled
assert d.next_run == clock.next_run
assert deliverer.errors == []
def test_heartbeat_recovers_when_briefing_job_was_dropped(tmp_path):
"""Scheduler lost the one-shot job: heartbeat must catch up (inside window) and re-arm the schedule."""
now = {"t": NOW}
d, scheduler, clock, _store, run_fn, _ = make_daemon(tmp_path)
d.now_fn = lambda: now["t"]
session, run_at = d.schedule_next()
del scheduler.jobs[BRIEFING_JOB_ID] # what APScheduler does when it drops a misfired DateTrigger job
now["t"] = run_at + timedelta(hours=1, minutes=45)
clock.catchup = session # still inside the 3h catch-up window
clock.next_run = (date(2026, 8, 26), datetime(2026, 8, 27, 6, 15, tzinfo=KST))
d._heartbeat_job()
assert run_fn.sessions == [session]
job = scheduler.jobs[BRIEFING_JOB_ID]
assert job["trigger"].run_date == clock.next_run[1] and job["trigger"].run_date > now["t"]
assert d.next_run == clock.next_run
def test_heartbeat_reschedules_stale_next_run_without_running_outside_window(tmp_path):
now = {"t": NOW}
d, scheduler, clock, _store, run_fn, _ = make_daemon(tmp_path)
d.now_fn = lambda: now["t"]
_session, run_at = d.schedule_next()
del scheduler.jobs[BRIEFING_JOB_ID]
now["t"] = run_at + timedelta(hours=4) # clock.catchup stays None: outside the window
clock.next_run = (date(2026, 8, 26), datetime(2026, 8, 27, 6, 15, tzinfo=KST))
d._heartbeat_job()
assert run_fn.sessions == []
assert scheduler.jobs[BRIEFING_JOB_ID]["trigger"].run_date == clock.next_run[1]
assert d.next_run == clock.next_run
def test_heartbeat_leaves_schedule_alone_when_briefing_is_due_or_barely_late(tmp_path):
"""A run time in the last few minutes means the job is running or about to: do not touch it."""
now = {"t": NOW}
d, scheduler, _clock, _store, run_fn, _ = make_daemon(tmp_path)
d.now_fn = lambda: now["t"]
_session, run_at = d.schedule_next()
for delta in (timedelta(minutes=-30), timedelta(0), timedelta(minutes=2)):
now["t"] = run_at + delta
d._heartbeat_job()
assert len(scheduler.add_calls) == 1 and run_fn.sessions == []
def test_heartbeat_does_not_run_catchup_while_briefing_in_progress(tmp_path):
now = {"t": NOW}
d, scheduler, clock, _store, run_fn, _ = make_daemon(tmp_path)
d.now_fn = lambda: now["t"]
session, run_at = d.schedule_next()
now["t"] = run_at + timedelta(hours=1)
clock.catchup = session
clock.next_run = (date(2026, 8, 26), datetime(2026, 8, 27, 6, 15, tzinfo=KST))
with d._run_lock: # a briefing is running on another executor thread
d._heartbeat_job()
assert run_fn.sessions == [] # no concurrent second run
assert scheduler.jobs[BRIEFING_JOB_ID]["trigger"].run_date == clock.next_run[1] # but re-armed
def test_on_job_missed_queues_recovery_under_a_different_id(tmp_path):
"""Re-adding 'briefing' from the listener would race APScheduler's own remove_job of the exhausted
one-shot job; recovery must go through a separately named job."""
d, scheduler, clock, _store, run_fn, _ = make_daemon(tmp_path)
d.start()
session, run_at = d.next_run
del scheduler.jobs[BRIEFING_JOB_ID]
clock.catchup = session
clock.next_run = (date(2026, 8, 26), datetime(2026, 8, 27, 6, 15, tzinfo=KST))
d._on_job_missed(JobExecutionEvent(EVENT_JOB_MISSED, BRIEFING_JOB_ID, "default", run_at))
assert scheduler.add_calls[-1]["id"] == RECOVERY_JOB_ID
assert BRIEFING_JOB_ID not in scheduler.jobs and run_fn.sessions == []
recovery = scheduler.jobs[RECOVERY_JOB_ID]
assert isinstance(recovery["trigger"], DateTrigger) and recovery["misfire_grace_time"] is None
recovery["func"]()
assert run_fn.sessions == [session]
assert scheduler.jobs[BRIEFING_JOB_ID]["args"] == [date(2026, 8, 26), clock.next_run[1]]
n_calls = len(scheduler.add_calls)
d._on_job_missed(JobExecutionEvent(EVENT_JOB_MISSED, HEARTBEAT_JOB_ID, "default", run_at)) # ignored
assert len(scheduler.add_calls) == n_calls
def test_recover_schedule_survives_catchup_error(tmp_path):
class BrokenCatchup(FakeClock):
def catchup_candidate(self, now, window_hours):
raise RuntimeError("calendar unavailable")
d, scheduler, *_ = make_daemon(tmp_path, clock=BrokenCatchup())
d._recover_schedule() # must not raise
assert BRIEFING_JOB_ID in scheduler.jobs
class LateThenNormalClock(FakeClock):
"""next_briefing answers a queue of (session, run_at) pairs; the last answer repeats."""
def __init__(self, answers):
super().__init__()
self.answers = list(answers)
def next_briefing(self, now):
self.calls.append(("next_briefing", now))
if len(self.answers) > 1:
return self.answers.pop(0)
return self.answers[0]
@pytest.mark.parametrize(("lateness_h", "expect_run"), [(1, True), (4, False)])
def test_real_scheduler_fires_late_one_shot_job_and_reschedules(tmp_path, lateness_h, expect_run):
"""Regression for the misfire bug: a DateTrigger already in the past (machine slept) must still fire
through the real APScheduler, apply the window, and leave a fresh 'briefing' job behind."""
real_now = datetime.now(KST)
late_session, late_run_at = date(2026, 8, 24), real_now - timedelta(hours=lateness_h)
next_session, next_run_at = date(2026, 8, 25), real_now + timedelta(hours=20)
clock = LateThenNormalClock([(late_session, late_run_at), (next_session, next_run_at)])
run_fn = RunRecorder()
scheduler = BackgroundScheduler(timezone=KST)
d = Daemon(
make_settings(tmp_path),
scheduler=scheduler,
clock=clock,
store=FakeStore(),
run_fn=run_fn,
deliverers_fn=lambda s: [FakeDeliverer("file")],
)
d.start()
assert d.next_run == (late_session, late_run_at)
scheduler.start()
try:
deadline = time.monotonic() + 5
while time.monotonic() < deadline and d.next_run != (next_session, next_run_at):
time.sleep(0.05)
assert d.next_run == (next_session, next_run_at)
job = scheduler.get_job(BRIEFING_JOB_ID)
assert job is not None and job.next_run_time == next_run_at
assert (run_fn.sessions == [late_session]) is expect_run
finally:
scheduler.shutdown(wait=True)
# --------------------------------------------------------------- shutdown during start-up catch-up
def test_signal_during_catchup_unwinds_when_scheduler_not_running(tmp_path):
class NotStarted(FakeScheduler):
def shutdown(self, wait=True):
super().shutdown(wait)
raise SchedulerNotRunningError # real APScheduler behaviour before start()
d, *_ = make_daemon(tmp_path)
d.scheduler = NotStarted()
old_term, old_int = signal.getsignal(signal.SIGTERM), signal.getsignal(signal.SIGINT)
try:
d.install_signal_handlers()
handler = signal.getsignal(signal.SIGTERM)
with pytest.raises(KeyboardInterrupt):
handler(signal.SIGTERM, None)
finally:
signal.signal(signal.SIGTERM, old_term)
signal.signal(signal.SIGINT, old_int)
assert d.stop_requested is True
assert d.scheduler.shutdown_calls == [False]
def test_shutdown_during_catchup_prevents_scheduling(tmp_path):
class NotStarted(FakeScheduler):
def shutdown(self, wait=True):
super().shutdown(wait)
raise SchedulerNotRunningError
holder: dict = {}
def run_fn(session): # simulates the signal handler firing mid catch-up (swallowed variant)
holder["d"].shutdown()
clock = FakeClock(catchup=date(2026, 8, 24))
d, *_ = make_daemon(tmp_path, clock=clock, run_fn=run_fn)
d.scheduler = NotStarted()
holder["d"] = d
d.start()
assert d.stop_requested is True
assert d.scheduler.jobs == {} # nothing scheduled; run_daemon skips scheduler.start()
def test_run_daemon_stops_cleanly_when_signal_arrives_during_catchup(monkeypatch, tmp_path):
handlers: dict = {}
started: dict = {}
fake_store = FakeStore()
class FakeBlocking(FakeScheduler):
def __init__(self, timezone=None):
super().__init__()
self.running = False
def start(self, paused=False):
started["started"] = True
def shutdown(self, wait=True):
super().shutdown(wait)
if not self.running:
raise SchedulerNotRunningError
def run_briefing(session, settings):
handlers[signal.SIGTERM](signal.SIGTERM, None) # SIGTERM while the catch-up briefing runs
raise AssertionError("unreachable: the handler must unwind the main thread")
monkeypatch.setattr(daemon_mod, "BlockingScheduler", FakeBlocking)
monkeypatch.setattr(daemon_mod, "MarketClock", lambda offset, **_kw: FakeClock(catchup=date(2026, 8, 24)))
monkeypatch.setattr(daemon_mod, "Store", lambda path: fake_store)
monkeypatch.setattr(daemon_mod, "run_briefing", run_briefing)
monkeypatch.setattr(
daemon_mod.signal, "signal", lambda signum, handler: handlers.__setitem__(signum, handler)
)
daemon_mod.run_daemon(make_settings(tmp_path)) # must return, not hang or raise
assert "started" not in started
assert fake_store.closed is True
def test_run_lock_serialises_catchup_and_job(tmp_path):
"""Sanity: the lock used by heartbeat recovery is the one held while run_fn executes."""
seen = {}
d, *_ = make_daemon(tmp_path)
def run_fn(session):
seen["locked"] = d._run_lock.locked()
d.run_fn = run_fn
d._briefing_job(date(2026, 8, 25), NOW)
assert seen["locked"] is True
assert isinstance(d._run_lock, type(threading.Lock()))