"""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