Files
US2KR/us2kr/daemon.py
T
jsahnandClaude Fable 5 f5fa7ec70f Add a Monday-morning weekly recap briefing
No US session closes into Monday KST, so Mondays (default 07:00, cron
in the daemon with restart catch-up) now publish a weekly review built
entirely from stored data: the quotes table collapsed into week-over-
week changes and the week's daily briefing headlines as the narrative.
The recap is a BriefingResult kind="weekly" reusing MarketPayload, so
the store, verification pass, all delivery channels (Blogger title
"미국 주간 리뷰 <monday>") and the dashboard (주간 리뷰 badge) work
unchanged. Manual runs via `run-once --weekly [--date <monday>]`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-12 23:52:31 +09:00

398 lines
17 KiB
Python

"""Resident scheduler: catch-up on start, then one-shot briefing jobs computed from the exchange calendar."""
from __future__ import annotations
import logging
import signal
import threading
from collections.abc import Callable
from datetime import date, datetime, timedelta
from typing import Any, Protocol
from zoneinfo import ZoneInfo
from apscheduler.events import EVENT_JOB_MISSED, JobExecutionEvent
from apscheduler.schedulers import SchedulerNotRunningError
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger
from us2kr.config import KST, Settings
from us2kr.deliver import build_deliverers
from us2kr.market_time import MarketClock
from us2kr.pipeline import run_briefing
from us2kr.store import Store
from us2kr.weekly import run_weekly
log = logging.getLogger(__name__)
BRIEFING_JOB_ID = "briefing"
RECOVERY_JOB_ID = "briefing-recover"
PREMARKET_JOB_ID = "premarket"
WEEKLY_JOB_ID = "weekly"
HEARTBEAT_JOB_ID = "heartbeat"
# None => APScheduler runs the one-shot briefing job whenever it becomes due, even hours late (machine
# sleep, SIGSTOP, clock jump). With a finite grace APScheduler *drops* a late job instead of running it,
# and a dropped one-shot job never reaches _briefing_job, whose `finally: schedule_next()` is what keeps
# the chain of briefings alive. _briefing_job enforces the catch-up window itself.
BRIEFING_MISFIRE_GRACE: int | None = None
# A briefing whose run time is older than this and which is still recorded as "next" was either lost by
# the scheduler or is running right now; the heartbeat re-arms the schedule in both cases (idempotent).
HEARTBEAT_STALE_AFTER = timedelta(minutes=5)
class SchedulerLike(Protocol):
"""The subset of APScheduler's BaseScheduler the daemon relies on (fakeable in tests)."""
def add_job(self, func: Callable[..., Any], trigger: Any = None, **kwargs: Any) -> Any: ...
def add_listener(self, callback: Callable[[Any], None], mask: int = ...) -> None: ...
def shutdown(self, wait: bool = True) -> None: ...
class Daemon:
"""Wires clock, store and pipeline into a scheduler. Every collaborator is injectable."""
def __init__(
self,
settings: Settings,
*,
scheduler: SchedulerLike,
clock: MarketClock,
store: Store,
run_fn: Callable[[date], Any],
now_fn: Callable[[], datetime] | None = None,
deliverers_fn: Callable[[Settings], list[Any]] | None = None,
premarket_fn: Callable[[], Any] | None = None,
weekly_fn: Callable[[date], Any] | None = None,
) -> None:
self.settings = settings
self.scheduler = scheduler
self.clock = clock
self.store = store
self.run_fn = run_fn
self.now_fn = now_fn or (lambda: datetime.now(ZoneInfo(KST)))
self.deliverers_fn = deliverers_fn or build_deliverers
# Extension point: plug a real pre-market update here (PLAN.md P6).
self.premarket_fn = premarket_fn
# Monday weekly recap; None disables (run_daemon wires us2kr.weekly.run_weekly).
self.weekly_fn = weekly_fn
self.next_run: tuple[date, datetime] | None = None
self.stop_requested = False
# Serialises pipeline runs (scheduled job / catch-up / heartbeat recovery) across executor threads.
self._run_lock = threading.Lock()
# ----------------------------------------------------------------- lifecycle
def start(self) -> None:
"""Catch up a missed briefing, then schedule the next one plus auxiliary jobs."""
self.scheduler.add_listener(self._on_job_missed, EVENT_JOB_MISSED)
self.run_catchup()
if self.stop_requested: # signal arrived during catch-up, before the scheduler was running
log.info("shutdown requested during catch-up - not scheduling jobs")
return
self.run_weekly_catchup()
if self.stop_requested:
log.info("shutdown requested during weekly catch-up - not scheduling jobs")
return
self.schedule_next()
self.schedule_premarket()
self.schedule_weekly()
self.schedule_heartbeat()
def install_signal_handlers(self) -> None:
def _handler(signum: int, _frame: Any) -> None:
log.info("signal %s received - shutting down", signal.Signals(signum).name)
if not self.shutdown():
# The scheduler is not running yet (start-up catch-up in progress): the only way to stop
# the synchronous briefing is to unwind the main thread. run_catchup and the pipeline
# catch Exception only, so KeyboardInterrupt propagates up to run_daemon.
raise KeyboardInterrupt
try:
signal.signal(signal.SIGTERM, _handler)
signal.signal(signal.SIGINT, _handler)
except ValueError: # not in the main thread
log.warning("signal handlers not installed (not main thread)")
def shutdown(self) -> bool:
"""Request a stop. Returns True when a running scheduler was told to shut down, False otherwise."""
self.stop_requested = True
try:
self.scheduler.shutdown(wait=False)
except SchedulerNotRunningError:
log.debug("scheduler not running - nothing to shut down")
return False
except Exception: # shutting down twice must not raise
log.debug("scheduler shutdown raised", exc_info=True)
return False
return True
# ------------------------------------------------------------------ catch-up
def run_catchup(self) -> bool:
"""Run a briefing immediately if one was missed within the catch-up window. Returns True if run."""
now = self.now_fn()
candidate = self.clock.catchup_candidate(now, self.settings.catchup_window_hours)
if candidate is None:
log.info("no catch-up candidate at %s", now.isoformat(timespec="minutes"))
return False
krx_date = self.clock.krx_date_for(candidate)
if self.store.has_report(krx_date):
log.info("catch-up: briefing for %s (KRX %s) already sent", candidate, krx_date)
return False
if not self._run_lock.acquire(blocking=False):
log.info("catch-up: a briefing is already running - skipping")
return False
try:
log.info("catch-up: running missed briefing for session %s", candidate)
self.run_fn(candidate)
except Exception: # daemon must keep going
log.exception("catch-up briefing for %s failed", candidate)
self._notify_error("US2KR catch-up 브리핑 실패", f"session={candidate}")
finally:
self._run_lock.release()
return True
# ---------------------------------------------------------------- scheduling
def schedule_next(self) -> tuple[date, datetime] | None:
"""Compute the next briefing time from the calendar and (re)schedule the one-shot job."""
now = self.now_fn()
try:
session, run_at = self.clock.next_briefing(now)
except Exception: # calendar failure: retry via heartbeat rather than die
log.exception("could not compute next briefing time")
self.next_run = None
return None
self.scheduler.add_job(
self._briefing_job,
DateTrigger(run_date=run_at),
args=[session, run_at],
id=BRIEFING_JOB_ID,
name=f"briefing {session}",
replace_existing=True,
misfire_grace_time=BRIEFING_MISFIRE_GRACE,
)
self.next_run = (session, run_at)
log.info("next briefing: US session %s at %s", session, run_at.isoformat(timespec="minutes"))
return self.next_run
def schedule_premarket(self) -> None:
if not self.settings.premarket_update_enabled:
return
hour, minute = _parse_hhmm(self.settings.premarket_time_kst)
self.scheduler.add_job(
self._premarket_job,
CronTrigger(day_of_week="mon-fri", hour=hour, minute=minute, timezone=ZoneInfo(KST)),
id=PREMARKET_JOB_ID,
name="premarket update",
replace_existing=True,
misfire_grace_time=600,
)
log.info("premarket update scheduled Mon-Fri %02d:%02d KST", hour, minute)
def schedule_weekly(self) -> None:
if not (self.settings.weekly_enabled and self.weekly_fn):
return
hour, minute = _parse_hhmm(self.settings.weekly_time_kst)
self.scheduler.add_job(
self._weekly_job,
CronTrigger(day_of_week="mon", hour=hour, minute=minute, timezone=ZoneInfo(KST)),
id=WEEKLY_JOB_ID,
name="weekly recap",
replace_existing=True,
misfire_grace_time=3600,
)
log.info("weekly recap scheduled Mon %02d:%02d KST", hour, minute)
def run_weekly_catchup(self) -> bool:
"""On a Monday restart, run a missed weekly recap within the catch-up window. True if run."""
if not (self.settings.weekly_enabled and self.weekly_fn):
return False
now = self.now_fn()
if now.weekday() != 0:
return False
hour, minute = _parse_hhmm(self.settings.weekly_time_kst)
due = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if not (due <= now < due + timedelta(hours=self.settings.catchup_window_hours)):
return False
if self.store.has_report(now.date(), kind="weekly"):
log.info("weekly catch-up: recap for %s already sent", now.date())
return False
if not self._run_lock.acquire(blocking=False):
return False
try:
log.info("weekly catch-up: running missed recap for %s", now.date())
self.weekly_fn(now.date())
except Exception: # daemon must keep going
log.exception("weekly catch-up for %s failed", now.date())
self._notify_error("US2KR 주간 리뷰 실패", f"monday={now.date()}")
finally:
self._run_lock.release()
return True
def schedule_heartbeat(self) -> None:
self.scheduler.add_job(
self._heartbeat_job,
IntervalTrigger(hours=1),
id=HEARTBEAT_JOB_ID,
name="heartbeat",
replace_existing=True,
)
# --------------------------------------------------------------------- jobs
def _briefing_job(self, session: date, run_at: datetime | None = None) -> None:
"""Scheduled briefing. Late fires (sleep/wake) honour the catch-up window; ALWAYS reschedules."""
try:
if run_at is not None:
late = self.now_fn() - run_at
if late >= timedelta(hours=self.settings.catchup_window_hours):
log.warning(
"briefing for %s scheduled at %s fired %s late - outside catch-up window, skipping",
session,
run_at.isoformat(timespec="minutes"),
late,
)
return
if late > HEARTBEAT_STALE_AFTER:
log.warning("briefing for %s fired %s late - running (inside window)", session, late)
log.info("scheduled briefing starting for session %s", session)
with self._run_lock:
self.run_fn(session)
except Exception as exc: # never let a job kill the scheduler
log.exception("scheduled briefing for %s failed", session)
self._notify_error("US2KR 브리핑 실패", f"session={session}: {type(exc).__name__}: {exc}"[:500])
finally:
self.schedule_next()
def _premarket_job(self) -> None:
if self.premarket_fn is None:
log.info("premarket update not yet implemented")
return
try:
self.premarket_fn()
except Exception:
log.exception("premarket update failed")
def _weekly_job(self) -> None:
"""Monday recap. run_weekly itself is idempotent per Monday (reports kind='weekly')."""
monday = self.now_fn().date()
try:
assert self.weekly_fn is not None # scheduled only when set
log.info("scheduled weekly recap starting for %s", monday)
with self._run_lock:
self.weekly_fn(monday)
except Exception as exc: # never let a job kill the scheduler
log.exception("weekly recap for %s failed", monday)
self._notify_error("US2KR 주간 리뷰 실패", f"monday={monday}: {type(exc).__name__}: {exc}"[:500])
def _heartbeat_job(self) -> None:
"""Hourly liveness log; also the backstop that re-arms a missing or stale briefing schedule."""
now = self.now_fn()
if self.next_run is None:
log.warning("heartbeat: no briefing scheduled - retrying schedule")
self.schedule_next()
return
session, run_at = self.next_run
if now - run_at > HEARTBEAT_STALE_AFTER:
# The one-shot job should have fired by now. Either it is running (run_catchup then finds
# the lock held and does nothing) or the scheduler lost it: catch up inside the window and
# re-arm. schedule_next() is idempotent, so re-arming during a run is harmless.
log.warning(
"heartbeat: briefing for %s at %s is stale - recovering schedule",
session,
run_at.isoformat(timespec="minutes"),
)
self._recover_schedule()
return
log.info("heartbeat: next briefing for %s at %s", session, run_at.isoformat(timespec="minutes"))
def _on_job_missed(self, event: JobExecutionEvent) -> None:
"""EVENT_JOB_MISSED listener: a dropped one-shot briefing job must be replaced.
Cannot happen while BRIEFING_MISFIRE_GRACE is None, but kept as defence in depth. APScheduler
removes the exhausted one-shot job *after* dispatching this event (possibly on this very
thread, inside its job-processing loop), so re-adding a job with the same id here would be
deleted immediately. Defer the recovery to a separately named one-shot job instead.
"""
if getattr(event, "job_id", None) != BRIEFING_JOB_ID:
return
log.warning(
"briefing job scheduled at %s was missed - queuing schedule recovery",
getattr(event, "scheduled_run_time", "?"),
)
try:
self.scheduler.add_job(
self._recover_schedule,
DateTrigger(run_date=self.now_fn()),
id=RECOVERY_JOB_ID,
name="briefing schedule recovery",
replace_existing=True,
misfire_grace_time=None,
)
except Exception: # the heartbeat is the last backstop
log.exception("could not queue schedule recovery")
def _recover_schedule(self) -> None:
"""Catch-up policy (window + store idempotency), then reschedule. Never raises."""
try:
self.run_catchup()
except Exception: # clock/store failure must not kill the scheduler thread
log.exception("catch-up during schedule recovery failed")
finally:
self.schedule_next()
# ------------------------------------------------------------------ helpers
def _notify_error(self, subject: str, detail: str) -> None:
try:
for deliverer in self.deliverers_fn(self.settings):
try:
deliverer.notify_error(subject, detail)
except Exception:
log.exception("notify_error via %s failed", getattr(deliverer, "name", deliverer))
except Exception: # building deliverers itself may fail
log.exception("could not build deliverers for error notification")
def _parse_hhmm(value: str) -> tuple[int, int]:
try:
hh, mm = value.strip().split(":")
hour, minute = int(hh), int(mm)
except ValueError as exc:
raise ValueError(f"invalid HH:MM time: {value!r}") from exc
if not (0 <= hour <= 23 and 0 <= minute <= 59):
raise ValueError(f"invalid HH:MM time: {value!r}")
return hour, minute
def run_daemon(settings: Settings) -> None:
"""Build the real daemon and block until SIGTERM/SIGINT."""
scheduler = BlockingScheduler(timezone=ZoneInfo(KST))
clock = MarketClock(settings.run_offset_minutes, extra_krx_holidays=settings.krx_extra_holidays)
store = Store(settings.db_path)
daemon = Daemon(
settings,
scheduler=scheduler,
clock=clock,
store=store,
run_fn=lambda session: run_briefing(session, settings),
weekly_fn=lambda monday: run_weekly(monday, settings),
)
daemon.install_signal_handlers()
log.info(
"us2kr daemon starting (offset=%dmin, catch-up window=%dh)",
settings.run_offset_minutes,
settings.catchup_window_hours,
)
try:
daemon.start()
if daemon.stop_requested:
log.info("stop requested during start-up - not starting scheduler")
else:
scheduler.start()
except (KeyboardInterrupt, SystemExit):
daemon.shutdown()
finally:
store.close()
log.info("us2kr daemon stopped")