Add a worker scheduler for city warming and IndexNow pings (#242)

warm_cities.py and indexnow.py are one-shot scripts run manually at deploy
today - nothing keeps the curated city set topped up, or pings IndexNow,
between deploys.

Add notifications/scheduler.py: an in-process APScheduler instance driving
two recurring jobs (city warming, daily; IndexNow --if-changed, every 6h by
default), gated by the same leader election as the notifier - only the
process that already won leadership starts it, so the jobs run in exactly
one place under multi-host Swarm rather than once per replica. Neither job
runs immediately on start: warm_cities already runs at deploy time, so an
immediate duplicate on every worker boot/restart would be wasted work.

Extract indexnow.submit_if_changed() from the CLI's --if-changed branch so
the scheduler and the command line share the exact same skip-when-unchanged
logic instead of two copies drifting apart. The CLI's plain (non-flag) path
is unchanged.

APScheduler over a Postgres-native queue: exactly one process ever runs
this, no fan-out/backpressure/dead-letter need, no new infrastructure.
This commit is contained in:
Emi Griffith 2026-07-20 18:26:39 -07:00 committed by GitHub
parent 1b4a607945
commit 913563788b
6 changed files with 240 additions and 7 deletions

View file

@ -133,6 +133,23 @@ def _write_state(sig: str) -> None:
log.warning("could not persist IndexNow state to %s", _STATE_PATH) log.warning("could not persist IndexNow state to %s", _STATE_PATH)
def submit_if_changed(base_url: str | None = None) -> dict | None:
"""Submit every indexable page, but only when the URL set changed since the
last successful submission the shared logic behind the CLI's --if-changed
flag and the worker scheduler's periodic ping (notifications/scheduler.py), so
a code-only deploy or an unremarkable scheduled tick never resubmits. Returns
the submit_all() result, or None when skipped (unchanged, or a partial/failed
submission that leaves the prior state in place so the next attempt retries)."""
base = base_url or os.environ.get("THERMOGRAPH_BASE_URL", "https://thermograph.org")
sig = url_signature()
if sig == _read_state():
return None
result = submit_all(base)
if result["total"] and result["submitted"] == result["total"]:
_write_state(sig) # only remember a fully-successful submission
return result
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
@ -145,14 +162,16 @@ if __name__ == "__main__":
base = (positional[0] if positional else "") or os.environ.get("THERMOGRAPH_BASE_URL", "https://thermograph.org") base = (positional[0] if positional else "") or os.environ.get("THERMOGRAPH_BASE_URL", "https://thermograph.org")
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
sig = url_signature() print(f"IndexNow key: {key()} (served at {base.rstrip('/')}/{key()}.txt)")
if if_changed and sig == _read_state(): if if_changed:
result = submit_if_changed(base)
if result is None:
print("IndexNow: URL set unchanged since last submission — skipping.") print("IndexNow: URL set unchanged since last submission — skipping.")
sys.exit(0) sys.exit(0)
else:
print(f"IndexNow key: {key()} (served at {base.rstrip('/')}/{key()}.txt)")
result = submit_all(base) result = submit_all(base)
print(f"Submitted {result['submitted']}/{result['total']} URLs " print(f"Submitted {result['submitted']}/{result['total']} URLs "
f"in {result['batches']} batch(es); statuses={result['statuses']}") f"in {result['batches']} batch(es); statuses={result['statuses']}")
if result["total"] and result["submitted"] == result["total"]: if result["total"] and result["submitted"] == result["total"] and not if_changed:
sig = url_signature()
_write_state(sig) # only remember a fully-successful submission _write_state(sig) # only remember a fully-successful submission

View file

@ -0,0 +1,68 @@
"""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

View file

@ -22,3 +22,6 @@ jinja2==3.1.6
# Structured SSR copy (glossary, static-page SEO meta) — see # Structured SSR copy (glossary, static-page SEO meta) — see
# backend/web/content_loader.py and content/. # backend/web/content_loader.py and content/.
PyYAML==6.0.3 PyYAML==6.0.3
# Recurring worker-tier jobs — city warming, IndexNow pings (see
# notifications/scheduler.py). In-process; no broker/queue needed at this scale.
apscheduler==3.11.3

View file

@ -0,0 +1,79 @@
"""Tests for the recurring worker-tier jobs (scheduler.py): job registration,
cadence from env, idempotent start/stop, and that each job calls the right
underlying function. No real hours-long wait inspects APScheduler's own job
registry rather than letting anything actually fire."""
import datetime
import pytest
from notifications import scheduler
@pytest.fixture(autouse=True)
def _clean_scheduler():
"""Every test starts from "not running" and leaves nothing behind, even on
failure scheduler._scheduler is module-global state shared across tests."""
scheduler.stop()
yield
scheduler.stop()
def test_start_registers_both_jobs_with_configured_intervals(monkeypatch):
monkeypatch.setattr(scheduler, "WARM_CITIES_INTERVAL_HOURS", 24)
monkeypatch.setattr(scheduler, "INDEXNOW_INTERVAL_HOURS", 6)
scheduler.start()
jobs = {j.id: j for j in scheduler._scheduler.get_jobs()}
assert set(jobs) == {"warm_cities", "indexnow"}
assert jobs["warm_cities"].trigger.interval == datetime.timedelta(hours=24)
assert jobs["indexnow"].trigger.interval == datetime.timedelta(hours=6)
assert jobs["warm_cities"].func is scheduler._warm_cities_job
assert jobs["indexnow"].func is scheduler._indexnow_job
def test_start_does_not_run_a_job_immediately():
"""warm_cities already runs at deploy time; an immediate duplicate run on
every worker boot/restart would just be wasted work the first scheduled
run must be one interval away, not "now"."""
scheduler.start()
now = datetime.datetime.now(datetime.timezone.utc)
for job in scheduler._scheduler.get_jobs():
assert job.next_run_time > now
def test_start_is_idempotent(monkeypatch):
scheduler.start()
first = scheduler._scheduler
calls = []
monkeypatch.setattr(scheduler.BackgroundScheduler, "__init__",
lambda self, **kw: calls.append(1) or None)
scheduler.start()
assert scheduler._scheduler is first
assert calls == [] # a second BackgroundScheduler was never constructed
def test_stop_shuts_down_and_clears_state():
scheduler.start()
assert scheduler._scheduler is not None
scheduler.stop()
assert scheduler._scheduler is None
def test_stop_without_start_is_a_safe_no_op():
scheduler.stop() # must not raise
assert scheduler._scheduler is None
def test_warm_cities_job_calls_warm_cities_main(monkeypatch):
import warm_cities
calls = []
monkeypatch.setattr(warm_cities, "main", lambda: calls.append(1))
scheduler._warm_cities_job()
assert calls == [1]
def test_indexnow_job_calls_submit_if_changed(monkeypatch):
import indexnow
calls = []
monkeypatch.setattr(indexnow, "submit_if_changed", lambda: calls.append(1))
scheduler._indexnow_job()
assert calls == [1]

59
tests/test_indexnow.py Normal file
View file

@ -0,0 +1,59 @@
"""submit_if_changed: the shared skip-when-unchanged logic behind the CLI's
--if-changed flag and the worker scheduler's periodic ping (no network —
submit_all/url_signature are stubbed)."""
import indexnow
def _ok(total=3):
return {"submitted": total, "total": total, "batches": 1, "statuses": [200]}
def test_skips_when_url_set_unchanged(monkeypatch, tmp_path):
monkeypatch.setattr(indexnow, "_STATE_PATH", str(tmp_path / "state.txt"))
monkeypatch.setattr(indexnow, "url_signature", lambda: "same-sig")
(tmp_path / "state.txt").write_text("same-sig")
def boom(*a, **k):
raise AssertionError("must not submit when the URL set is unchanged")
monkeypatch.setattr(indexnow, "submit_all", boom)
assert indexnow.submit_if_changed("https://example.org") is None
def test_submits_and_persists_state_when_changed(monkeypatch, tmp_path):
state = tmp_path / "state.txt"
monkeypatch.setattr(indexnow, "_STATE_PATH", str(state))
monkeypatch.setattr(indexnow, "url_signature", lambda: "new-sig")
state.write_text("old-sig")
calls = []
monkeypatch.setattr(indexnow, "submit_all", lambda base, **kw: calls.append(base) or _ok())
result = indexnow.submit_if_changed("https://example.org")
assert calls == ["https://example.org"]
assert result["submitted"] == result["total"] == 3
assert state.read_text() == "new-sig"
def test_partial_failure_does_not_persist_state(monkeypatch, tmp_path):
"""A partial/failed submission must leave the prior state in place so the
next attempt retries, rather than silently giving up on the missed URLs."""
state = tmp_path / "state.txt"
monkeypatch.setattr(indexnow, "_STATE_PATH", str(state))
monkeypatch.setattr(indexnow, "url_signature", lambda: "new-sig")
state.write_text("old-sig")
monkeypatch.setattr(indexnow, "submit_all",
lambda base, **kw: {"submitted": 1, "total": 3, "batches": 1, "statuses": [429]})
result = indexnow.submit_if_changed("https://example.org")
assert result["submitted"] < result["total"]
assert state.read_text() == "old-sig"
def test_default_base_url_falls_back_to_env(monkeypatch, tmp_path):
monkeypatch.setattr(indexnow, "_STATE_PATH", str(tmp_path / "state.txt"))
monkeypatch.setattr(indexnow, "url_signature", lambda: "sig")
monkeypatch.setenv("THERMOGRAPH_BASE_URL", "https://from-env.example")
calls = []
monkeypatch.setattr(indexnow, "submit_all", lambda base, **kw: calls.append(base) or _ok())
indexnow.submit_if_changed()
assert calls == ["https://from-env.example"]

View file

@ -27,6 +27,7 @@ from accounts import db
from data import grid from data import grid
from core import metrics from core import metrics
from notifications import notify from notifications import notify
from notifications import scheduler
import paths import paths
from data import places from data import places
from core import singleton from core import singleton
@ -159,8 +160,12 @@ async def _lifespan(app):
# N-replica web tier can scale without also scaling notifier instances. # N-replica web tier can scale without also scaling notifier instances.
if _should_run_notifier(): if _should_run_notifier():
notify.start() notify.start()
# Same leader gate as the notifier above — the recurring worker jobs
# (city warming, IndexNow) must likewise run in exactly one process.
scheduler.start()
yield yield
notify.stop() notify.stop()
scheduler.stop()
app = FastAPI(title="Thermograph", version="0.2.0", lifespan=_lifespan) app = FastAPI(title="Thermograph", version="0.2.0", lifespan=_lifespan)