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
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:
+15
-8
@@ -136,14 +136,21 @@ class NotificationState:
|
||||
def _ensure(self) -> None:
|
||||
from sqlalchemy import text
|
||||
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE IF NOT EXISTS notification_state ("
|
||||
"key VARCHAR(64) PRIMARY KEY, state VARCHAR(16) NOT NULL, "
|
||||
"value NUMERIC(8,3), updated_at TIMESTAMP NOT NULL)"
|
||||
)
|
||||
)
|
||||
ddl = (
|
||||
"CREATE TABLE IF NOT EXISTS notification_state ("
|
||||
"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]:
|
||||
from sqlalchemy import text
|
||||
|
||||
+41
-31
@@ -175,37 +175,6 @@ async def lifespan(app: FastAPI):
|
||||
app_state["forecast_store"] = None
|
||||
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)
|
||||
try:
|
||||
from .hii_collector import create_collector_from_config
|
||||
@@ -241,7 +210,9 @@ async def lifespan(app: FastAPI):
|
||||
app_state["leader_lock"] = _acquire_collection_leadership(
|
||||
Config.COLLECTION_LEADER_PORT
|
||||
)
|
||||
app_state["notify"] = None
|
||||
if app_state["leader_lock"]:
|
||||
app_state["notify"] = _init_notifications()
|
||||
app_state["scraping_task"] = asyncio.create_task(background_scraping_task())
|
||||
logger.info("This worker is the background-collection leader")
|
||||
else:
|
||||
@@ -327,6 +298,45 @@ async def _persist_rain():
|
||||
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():
|
||||
"""Publish flood/outlook/feed transitions to ntfy (leader only, fail-safe)."""
|
||||
cfg = app_state.get("notify")
|
||||
|
||||
Reference in New Issue
Block a user