thermograph/backend/notifications/scheduler.py
Emi Griffith a4be7066e5 Subtree-merge thermograph-backend (origin/main) into backend/
git-subtree-dir: backend
git-subtree-mainline: 6723fc0326
git-subtree-split: 83c2e05b96
2026-07-22 22:01:11 -07:00

68 lines
3 KiB
Python

"""Recurring worker-tier jobs: keep the curated city set warm and ping IndexNow
on a schedule, so neither needs a human to run it after every deploy anymore.
Only the process that already won leader election for the notifier runs this
too (gated by the same check in web/app.py's _lifespan) — APScheduler has no
cross-host coordination of its own, so running it unguarded on every worker
replica would repeat every job once per replica, same reason the notifier
itself needs a leader.
APScheduler, not a Postgres-native queue: there is exactly one process that
ever runs this, no fan-out/backpressure/dead-letter need, and it needs no new
infrastructure. procrastinate/pgqueuer are the documented upgrade path if that
changes — see docs/architecture/repo-topology-and-infrastructure.md §7.
"""
import os
from apscheduler.schedulers.background import BackgroundScheduler
# How often the curated city set is (re-)warmed. warm_cities.py's own docstring
# frames it as a deploy-time script ("Run at/after deploy") because a warm cache
# mostly just stays warm; scheduling it periodically instead catches a newly
# added city, or one that fell out of cache, without waiting for the next
# deploy. Daily is plenty — every already-cached city is a cheap skip.
WARM_CITIES_INTERVAL_HOURS = int(os.environ.get("THERMOGRAPH_WARM_CITIES_INTERVAL_HOURS", "24") or 24)
# How often to check whether the indexable URL set changed and, if so, ping
# IndexNow. submit_if_changed() is a cheap no-op when nothing changed, so this
# can run more often than warm_cities without spending anything on most ticks.
INDEXNOW_INTERVAL_HOURS = int(os.environ.get("THERMOGRAPH_INDEXNOW_INTERVAL_HOURS", "6") or 6)
_scheduler: "BackgroundScheduler | None" = None
def _warm_cities_job() -> None:
import warm_cities # local import: a script-style top-level module — only
# pay its import cost on the process that runs the job
warm_cities.main()
def _indexnow_job() -> None:
import indexnow
indexnow.submit_if_changed()
def start() -> None:
"""Start the scheduler (idempotent, mirroring notify.start()). The caller is
responsible for the leader-election gate — see web/app.py's _lifespan."""
global _scheduler
if _scheduler is not None:
return
sched = BackgroundScheduler(daemon=True)
# No next_run_time override: the default first run is one interval from now,
# not immediately — warm_cities already runs at deploy time (warm_cities.py /
# the deploy hook), so an immediate duplicate run on every worker boot/restart
# would just be wasted work.
sched.add_job(_warm_cities_job, "interval", hours=WARM_CITIES_INTERVAL_HOURS,
id="warm_cities")
sched.add_job(_indexnow_job, "interval", hours=INDEXNOW_INTERVAL_HOURS,
id="indexnow")
sched.start()
_scheduler = sched
def stop() -> None:
global _scheduler
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None