All checks were successful
Deploy / deploy (push) Successful in 22s
The America/Phoenix TZ fix earlier only passed timezone= to BackgroundScheduler's constructor. That wasn't enough: APScheduler's CronTrigger independently calls tzlocal's get_localzone() in its own __init__ when not given an explicit timezone, regardless of what the scheduler itself is configured with. This never surfaced in testing because schedules.json was always empty — the rebuild_jobs() loop that constructs CronTrigger never executed. It crashed for real once a real schedule existed (confirmed live: the already-configured "Pokemon Singles >10" 4x/day schedule crash-looped the container on every boot). Fixed by passing the same resolved timezone to every CronTrigger call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H6TgffxTvYhqxykAbVQg2v
101 lines
3.3 KiB
Python
101 lines
3.3 KiB
Python
"""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
|