"""In-process cron scheduling for rule runs. A single background worker thread owns every Playwright run — both APScheduler-triggered runs and manual "Run Now" clicks are pushed onto one queue.Queue so exactly one browser session is ever active at a time. This must run in exactly one process; see gunicorn.conf.py's workers=1. """ import logging import os import queue import threading import pytz from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger import automation import storage logger = logging.getLogger("massprice.scheduler") _work_queue: "queue.Queue" = queue.Queue() # Resolved once and reused everywhere a timezone is needed. Must be # passed explicitly at construction time, never left to be inferred — # both BackgroundScheduler AND CronTrigger independently call tzlocal's # get_localzone() in their own __init__ if not given one, which fails # inside this container image for an arbitrary $TZ (no zoneinfo/tzdata # wiring). Passing it to the scheduler alone is NOT enough — a # CronTrigger built without its own explicit timezone still resolves # get_localzone() itself, regardless of what the scheduler is configured # with, and crashes the same way. Every CronTrigger(...) below must pass # timezone=_TZ explicitly. _TZ = pytz.timezone(os.environ.get("TZ", "UTC")) _scheduler = BackgroundScheduler(timezone=_TZ) _started = False VALID_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] def _worker_loop(): while True: rule_id, rule_name = _work_queue.get() try: automation.run_rule(rule_id, rule_name) except Exception: logger.exception("Unhandled error running rule %s", rule_id) finally: _work_queue.task_done() def enqueue_run(rule_id, rule_name=None): _work_queue.put((rule_id, rule_name)) def _job_id(rule_id, index): return f"rule-{rule_id}-{index}" def rebuild_jobs(): """Clear and re-add every APScheduler job from schedules.json.""" for job in _scheduler.get_jobs(): job.remove() schedules = storage.load("schedules.json", {}) for rule_id, rule in schedules.items(): if not rule.get("enabled", True): continue for idx, entry in enumerate(rule.get("schedules", [])): days = [d for d in entry.get("days", []) if d in VALID_DAYS] time_str = entry.get("time") if not days or not time_str: continue hour, minute = time_str.split(":") trigger = CronTrigger( day_of_week=",".join(days), hour=int(hour), minute=int(minute), timezone=_TZ, ) _scheduler.add_job( enqueue_run, trigger=trigger, args=[rule_id, rule.get("name")], id=_job_id(rule_id, idx), replace_existing=True, misfire_grace_time=300, ) logger.info("Rebuilt %d scheduled job(s)", len(_scheduler.get_jobs())) def start(tz=None): # tz is accepted for backwards-compat call sites but is a no-op now # that timezone is fixed at construction time above. global _started if _started: return rebuild_jobs() _scheduler.start() threading.Thread(target=_worker_loop, daemon=True).start() _started = True