129 lines
6 KiB
Python
129 lines
6 KiB
Python
"""Single-instance leader election for in-process background jobs.
|
|
|
|
With multiple uvicorn workers each worker process runs its own app lifespan, so a
|
|
background thread started there runs once *per worker*. That is correct for the
|
|
neighbor warmer (each worker drains its own request-fed queue) but wrong for the
|
|
subscription notifier: its periodic sweep scans every subscription and issues
|
|
upstream forecast/archive fetches on a timer, independent of any request. Running
|
|
it in N workers multiplies the Open-Meteo quota use N-fold (the source of the
|
|
overnight 429/503 rate-limit errors after prod went to 3 workers) and does the
|
|
same DB scan N times.
|
|
|
|
``claim`` elects one worker to own such jobs via a non-blocking exclusive advisory
|
|
lock (``flock``) on a lockfile:
|
|
|
|
* the first worker to call it acquires the lock and holds the fd for the process
|
|
lifetime, so the lock is never dropped while it lives -> returns ``True``;
|
|
* other workers fail the non-blocking lock and stand down -> return ``False``;
|
|
* the OS releases the lock when the leader exits, so a restart (or a crash) lets a
|
|
surviving/new worker win on its next call -> clean re-election, no stale state.
|
|
|
|
No lockfile configured (single-worker deploy, dev, tests) => always the leader.
|
|
This mirrors the metrics store's env-selected coordination: unset keeps the
|
|
zero-dependency single-worker behavior; a path opts into cross-worker arbitration.
|
|
|
|
``claim_pg`` is the multi-*host* analogue: ``claim``'s flock only arbitrates workers
|
|
on one machine, so under multi-host Swarm every host would independently elect its
|
|
own leader and multiply the Open-Meteo quota use N-fold again. A Postgres advisory
|
|
lock is visible to every host talking to the same database, so it elects exactly one
|
|
leader cluster-wide. ``claim_leader()`` picks whichever mechanism the deployment has
|
|
configured (see its docstring) so callers never choose between them directly.
|
|
"""
|
|
import fcntl
|
|
import os
|
|
|
|
# lock_path -> held fd. Kept open for the whole process lifetime: closing the fd (or
|
|
# letting it be GC'd) would drop the flock and let a second worker also become leader.
|
|
_held: dict[str, int] = {}
|
|
|
|
# Local copy of the Postgres DSN, mirroring core/metrics.py's identical helper
|
|
# rather than importing data/climate_store.py: core/ is a lower-level package that
|
|
# data/ depends on, never the reverse (see CLAUDE.md's package layering).
|
|
DATABASE_URL = os.environ.get("THERMOGRAPH_DATABASE_URL", "").strip()
|
|
IS_POSTGRES = DATABASE_URL.startswith("postgresql")
|
|
|
|
|
|
def _libpq_dsn(url: str) -> str:
|
|
"""A plain libpq URL psycopg accepts: drop the SQLAlchemy driver suffix
|
|
(``postgresql+asyncpg://`` / ``+psycopg://`` -> ``postgresql://``)."""
|
|
return url.replace("+asyncpg", "").replace("+psycopg", "")
|
|
|
|
|
|
_PG_DSN = _libpq_dsn(DATABASE_URL) if IS_POSTGRES else ""
|
|
|
|
# key -> the connection holding its advisory lock. pg_try_advisory_lock is
|
|
# session-scoped: released the moment its connection closes, so — like _held above
|
|
# — the connection must live for the process lifetime, on its own dedicated
|
|
# connection (never a pooled/shared one, which could close or be reused mid-hold).
|
|
_pg_held: dict[int, object] = {}
|
|
|
|
# The one lock this module currently arbitrates (the subscription notifier). Any
|
|
# int works as an advisory-lock key; bump/extend if a second named lock is added.
|
|
NOTIFIER_LOCK_KEY = 1
|
|
|
|
|
|
def claim(lock_path: "str | None") -> bool:
|
|
"""Return True if this process should own the single-instance job guarded by
|
|
``lock_path``. ``None``/empty (no multi-worker coordination configured) is always
|
|
the leader. Idempotent: re-claiming a path this process already holds returns True
|
|
without touching the lock."""
|
|
if not lock_path:
|
|
return True
|
|
if lock_path in _held:
|
|
return True
|
|
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644)
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except OSError:
|
|
# Another process already holds it — we're not the leader.
|
|
os.close(fd)
|
|
return False
|
|
_held[lock_path] = fd
|
|
return True
|
|
|
|
|
|
def claim_pg(key: int) -> bool:
|
|
"""Return True if this process holds the cluster-wide Postgres advisory lock
|
|
``key``. Requires ``THERMOGRAPH_DATABASE_URL`` to be a Postgres URL; returns
|
|
False (never raises) if Postgres is unreachable — the caller treats "not
|
|
leader" and "unavailable" the same way: stand down.
|
|
|
|
Idempotent like ``claim``: a process already holding ``key`` returns True
|
|
without opening a second connection. A dead held connection (Postgres restart,
|
|
network blip) is dropped and re-election is attempted on the next call — the
|
|
advisory lock it held was already released the moment the connection died."""
|
|
conn = _pg_held.get(key)
|
|
if conn is not None:
|
|
if not conn.closed:
|
|
return True
|
|
del _pg_held[key]
|
|
|
|
if not IS_POSTGRES:
|
|
return False
|
|
try:
|
|
import psycopg # local import: only the Postgres path needs it
|
|
conn = psycopg.connect(_PG_DSN, autocommit=True)
|
|
won = conn.execute("SELECT pg_try_advisory_lock(%s)", (key,)).fetchone()[0]
|
|
except Exception: # noqa: BLE001 - unreachable Postgres => not leader, not fatal
|
|
return False
|
|
if not won:
|
|
conn.close()
|
|
return False
|
|
_pg_held[key] = conn
|
|
return True
|
|
|
|
|
|
def claim_leader() -> bool:
|
|
"""Elect the leader for single-instance background jobs (today: the
|
|
subscription notifier), picking the coordination mechanism from env:
|
|
|
|
* ``THERMOGRAPH_SINGLETON_PG`` set + the climate record on Postgres -> a
|
|
cluster-wide advisory lock (``claim_pg``) — the only mechanism that elects
|
|
one leader across *every host* under multi-host Swarm.
|
|
* else ``THERMOGRAPH_SINGLETON_LOCK`` -> the host-local flock (``claim``),
|
|
today's single-host-multi-worker behavior.
|
|
* else -> always the leader (single-worker deploy, dev, tests) — unchanged.
|
|
"""
|
|
if os.environ.get("THERMOGRAPH_SINGLETON_PG") and IS_POSTGRES:
|
|
return claim_pg(NOTIFIER_LOCK_KEY)
|
|
return claim(os.environ.get("THERMOGRAPH_SINGLETON_LOCK"))
|