thermograph/tests/test_singleton.py

65 lines
2 KiB
Python
Raw Normal View History

Gate the subscription notifier to one worker (#161) Prod runs 3 uvicorn workers, but the in-process subscription notifier was started in every worker's lifespan (it was written assuming a single-worker deploy). Its sweep fetches recent-forecast/archive data from Open-Meteo on a 15-min timer independent of any request, so running it 3× tripled the background upstream load against a shared quota — the source of the overnight 429/503 rate-limit errors in logs/errors, and 3× redundant subscription scans. Elect one worker to own the notifier via a non-blocking exclusive flock on a lockfile (THERMOGRAPH_SINGLETON_LOCK): the first worker wins and holds the fd for its lifetime; others stand down; the OS releases the lock if the leader dies, so a restart re-elects cleanly. Unset (single worker / dev / tests) always wins, so behavior there is unchanged. Mirrors the shared-metrics store's env-selected cross-worker coordination. The neighbor warmer stays per-worker on purpose: each worker drains its own request-fed queue, so gating it would leave non-leader queues undrained. - backend/singleton.py: flock-based leader election (claim()). - backend/app.py: gate notify.start() on singleton.claim(). - deploy/thermograph.service: default THERMOGRAPH_SINGLETON_LOCK to data/notifier.lock (needs the unit reinstalled on prod to take effect). - deploy/thermograph.env.example: document it alongside WORKERS. - Tests: leader election — single-worker default, first-wins, idempotent re-claim, second-holder stands down, re-election after release. 183 passed. Co-authored-by: root <root@vmi3417050.contaboserver.net>
2026-07-17 12:54:55 +00:00
"""Unit tests for the single-instance leader election (singleton.py)."""
import fcntl
import importlib
import os
import singleton
def _fresh():
# _held is module-level state; reload for an isolated starting point per test.
return importlib.reload(singleton)
def test_no_lock_path_is_always_leader():
s = _fresh()
assert s.claim(None) is True
assert s.claim("") is True
def test_first_claimer_wins_and_holds_the_lock(tmp_path):
s = _fresh()
lock = str(tmp_path / "notifier.lock")
assert s.claim(lock) is True
# Held for the process lifetime: the fd stays open and flock'd.
assert lock in s._held
def test_reclaiming_same_path_is_idempotent(tmp_path):
s = _fresh()
lock = str(tmp_path / "notifier.lock")
assert s.claim(lock) is True
fd = s._held[lock]
# Re-claiming must not re-open or drop the lock — same fd, still True.
assert s.claim(lock) is True
assert s._held[lock] == fd
def test_second_holder_stands_down(tmp_path):
"""When another holder already owns the lock, claim() returns False and leaks no
fd. Simulate the other worker by taking the exclusive flock on a separate open
file description (a fresh os.open, as a distinct process would)."""
s = _fresh()
lock = str(tmp_path / "notifier.lock")
other = os.open(lock, os.O_RDWR | os.O_CREAT, 0o644)
fcntl.flock(other, fcntl.LOCK_EX | fcntl.LOCK_NB)
try:
assert s.claim(lock) is False
assert lock not in s._held # did not record a role it doesn't own
finally:
fcntl.flock(other, fcntl.LOCK_UN)
os.close(other)
def test_reelection_after_holder_releases(tmp_path):
"""A restart drops the OS lock; the next claim() then wins."""
s = _fresh()
lock = str(tmp_path / "notifier.lock")
other = os.open(lock, os.O_RDWR | os.O_CREAT, 0o644)
fcntl.flock(other, fcntl.LOCK_EX | fcntl.LOCK_NB)
assert s.claim(lock) is False
# Prior leader exits -> OS releases the lock.
fcntl.flock(other, fcntl.LOCK_UN)
os.close(other)
assert s.claim(lock) is True