fix: init ntfy only in the collection leader
CI / Test suite (push) Successful in 29s
Docs / Validate documentation (push) Successful in 13s
CI / Format & lint (push) Successful in 15s
Security / Dependency vulnerabilities (push) Successful in 42s
Security / License report (push) Successful in 48s
Security / Static analysis (push) Successful in 9s

Every uvicorn worker ran the notification_state DDL at startup; on
Postgres the losers of that race get UniqueViolation on pg_type and the
whole init was skipped (notifications off). Only the leader publishes, so
only the leader initialises, after election. The DDL also tolerates a
concurrent creator now: on failure it verifies the table exists instead
of giving up.
This commit is contained in:
2026-09-12 00:22:03 +02:00
parent 777b230baf
commit 039d24a5c3
2 changed files with 56 additions and 39 deletions
+15 -8
View File
@@ -136,14 +136,21 @@ class NotificationState:
def _ensure(self) -> None: def _ensure(self) -> None:
from sqlalchemy import text from sqlalchemy import text
with self.engine.begin() as conn: ddl = (
conn.execute( "CREATE TABLE IF NOT EXISTS notification_state ("
text( "key VARCHAR(64) PRIMARY KEY, state VARCHAR(16) NOT NULL, "
"CREATE TABLE IF NOT EXISTS notification_state (" "value NUMERIC(8,3), updated_at TIMESTAMP NOT NULL)"
"key VARCHAR(64) PRIMARY KEY, state VARCHAR(16) NOT NULL, " )
"value NUMERIC(8,3), updated_at TIMESTAMP NOT NULL)" try:
) with self.engine.begin() as conn:
) conn.execute(text(ddl))
except Exception as error:
# Postgres: two sessions racing CREATE TABLE IF NOT EXISTS can
# both pass the existence check; the loser fails with a unique
# violation on pg_type. The table exists either way; verify.
with self.engine.connect() as conn:
conn.execute(text("SELECT 1 FROM notification_state WHERE 1=0"))
logger.debug(f"notification_state DDL raced, table present: {error}")
def get(self, key: str) -> Optional[str]: def get(self, key: str) -> Optional[str]:
from sqlalchemy import text from sqlalchemy import text
+41 -31
View File
@@ -175,37 +175,6 @@ async def lifespan(app: FastAPI):
app_state["forecast_store"] = None app_state["forecast_store"] = None
logger.error(f"Forecast history store init failed: {e}") logger.error(f"Forecast history store init failed: {e}")
# Public flood notifications over ntfy (leader only, transitions only).
# Off unless NTFY_SERVER is set; then state lives in notification_state
# next to the measurements so a restart never re-sends.
app_state["notify"] = None
if Config.NTFY_SERVER:
try:
from . import notify as notify_mod
store = app_state.get("forecast_store")
if store and not store.engine:
store.connect()
state = (
notify_mod.NotificationState(store.engine, store.db_type)
if store and store.engine
else notify_mod.InMemoryState()
)
app_state["notify"] = (
notify_mod.NtfyPublisher(
Config.NTFY_SERVER,
prefix=Config.NTFY_TOPIC_PREFIX,
token=Config.NTFY_TOKEN or None,
dashboard_url=Config.PUBLIC_URL,
),
state,
)
logger.info(
f"ntfy notifications: {Config.NTFY_SERVER} topics {Config.NTFY_TOPIC_PREFIX}-*"
)
except Exception as e:
logger.error(f"ntfy init failed (notifications off): {e}")
# Initialize HII/ThaiWater collector (rainfall + backup water level) # Initialize HII/ThaiWater collector (rainfall + backup water level)
try: try:
from .hii_collector import create_collector_from_config from .hii_collector import create_collector_from_config
@@ -241,7 +210,9 @@ async def lifespan(app: FastAPI):
app_state["leader_lock"] = _acquire_collection_leadership( app_state["leader_lock"] = _acquire_collection_leadership(
Config.COLLECTION_LEADER_PORT Config.COLLECTION_LEADER_PORT
) )
app_state["notify"] = None
if app_state["leader_lock"]: if app_state["leader_lock"]:
app_state["notify"] = _init_notifications()
app_state["scraping_task"] = asyncio.create_task(background_scraping_task()) app_state["scraping_task"] = asyncio.create_task(background_scraping_task())
logger.info("This worker is the background-collection leader") logger.info("This worker is the background-collection leader")
else: else:
@@ -327,6 +298,45 @@ async def _persist_rain():
logger.warning(f"rain persistence failed: {e}") logger.warning(f"rain persistence failed: {e}")
def _init_notifications():
"""Publisher + persisted state for ntfy, or None if off/unavailable.
Called only by the collection leader: it is the one process that
publishes, so the notification_state DDL runs exactly once per host.
"""
if not Config.NTFY_SERVER:
return None
try:
from . import notify as notify_mod
store = app_state.get("forecast_store")
if store and not store.engine:
store.connect()
state = (
notify_mod.NotificationState(store.engine, store.db_type)
if store and store.engine
else notify_mod.InMemoryState()
)
if isinstance(state, notify_mod.InMemoryState):
logger.warning(
"ntfy: no SQL store; notification state is in-memory "
"(a restart may re-send the current level)"
)
publisher = notify_mod.NtfyPublisher(
Config.NTFY_SERVER,
prefix=Config.NTFY_TOPIC_PREFIX,
token=Config.NTFY_TOKEN or None,
dashboard_url=Config.PUBLIC_URL,
)
logger.info(
f"ntfy notifications: {Config.NTFY_SERVER} topics {Config.NTFY_TOPIC_PREFIX}-*"
)
return publisher, state
except Exception as e:
logger.error(f"ntfy init failed (notifications off): {e}")
return None
async def _notify_transitions(): async def _notify_transitions():
"""Publish flood/outlook/feed transitions to ntfy (leader only, fail-safe).""" """Publish flood/outlook/feed transitions to ntfy (leader only, fail-safe)."""
cfg = app_state.get("notify") cfg = app_state.get("notify")