Massprice/scheduler.py
Barely Removable 8b58dadc5a
All checks were successful
Deploy / deploy (push) Successful in 57s
Add two-stage run verification and 24-hour Activity filter
Status now reads "Sent" when the confirmation click lands, since that
only means MassPrice accepted the run request, not that it finished —
their own processing is async and takes anywhere from ~1 to ~22
minutes in observed practice. A new "Complete" column tracks that
separately: run_rule() captures a watermark (the target rule's current
top data-activitylogid on /Activity/List, not the displayed timestamp
string, to sidestep fragile date/timezone parsing) before submitting,
and scheduler.py schedules a one-off verify_run() job
VERIFY_DELAY_MINUTES later to check whether a new "Complete" entry
appeared past that watermark.

rebuild_jobs() now only clears recurring "rule-*" jobs, not the one-off
"verify-*" jobs, so editing a schedule can't cancel a verification
that's already in flight for an earlier run.

Also scopes the Activity page to the last 24 hours per request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H6TgffxTvYhqxykAbVQg2v
2026-08-31 21:49:45 -07:00

148 lines
5.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.
The same worker also handles delayed "verify_run" tasks: after a rule
run is confirmed sent, a one-off APScheduler job (not a recurring cron
job) fires VERIFY_DELAY_MINUTES later and enqueues a check of whether it
actually completed on MassPrice's side — see automation.verify_run().
"""
import logging
import os
import queue
import threading
from datetime import datetime, timedelta
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 trigger built 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"]
# How long to wait after a run is confirmed sent before checking whether
# it actually completed on MassPrice's side. MassPrice's own processing
# time varies a lot in practice (observed anywhere from ~1 to ~22
# minutes) — this is a reasonable middle ground, not a guarantee; a
# "not_confirmed" result can mean "still processing", not just "failed".
VERIFY_DELAY_MINUTES = int(os.environ.get("VERIFY_DELAY_MINUTES", "10"))
def _worker_loop():
while True:
item = _work_queue.get()
kind = item[0]
try:
if kind == "run":
_, rule_id, rule_name = item
result = automation.run_rule(rule_id, rule_name)
if result.get("will_verify"):
schedule_verification(
result["entry_id"], rule_name, result["watermark_activity_id"]
)
elif kind == "verify":
_, entry_id, rule_name, watermark_id = item
automation.verify_run(entry_id, rule_name, watermark_id)
except Exception:
logger.exception("Unhandled error processing %r", item)
finally:
_work_queue.task_done()
def enqueue_run(rule_id, rule_name=None):
_work_queue.put(("run", rule_id, rule_name))
def enqueue_verify(entry_id, rule_name, watermark_id):
_work_queue.put(("verify", entry_id, rule_name, watermark_id))
def schedule_verification(entry_id, rule_name, watermark_id, delay_minutes=None):
delay = VERIFY_DELAY_MINUTES if delay_minutes is None else delay_minutes
run_date = datetime.now(_TZ) + timedelta(minutes=delay)
_scheduler.add_job(
enqueue_verify,
trigger="date",
run_date=run_date,
args=[entry_id, rule_name, watermark_id],
id=f"verify-{entry_id}",
misfire_grace_time=600,
)
def _job_id(rule_id, index):
return f"rule-{rule_id}-{index}"
def rebuild_jobs():
"""Clear and re-add every recurring cron job from schedules.json.
Only touches "rule-*" jobs — one-off "verify-*" jobs (see
schedule_verification()) must survive a schedule edit elsewhere,
since they're tracking an already-in-flight run, not a recurring
schedule.
"""
for job in _scheduler.get_jobs():
if job.id.startswith("rule-"):
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