mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-21 18:14:44 +02:00
runtime: NWS adapter, supervisor, archive consumer, systemd units
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
714971fe99
commit
31be17430d
8 changed files with 1480 additions and 0 deletions
|
|
@ -20,6 +20,10 @@ dependencies = [
|
||||||
"tenacity>=9.1.4",
|
"tenacity>=9.1.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
central-supervisor = "central.supervisor:main"
|
||||||
|
central-archive = "central.archive:main"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/central"]
|
packages = ["src/central"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
__version__ = "0.1.0"
|
||||||
453
src/central/adapters/nws.py
Normal file
453
src/central/adapters/nws.py
Normal file
|
|
@ -0,0 +1,453 @@
|
||||||
|
"""NWS (National Weather Service) alert adapter."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from aiolimiter import AsyncLimiter
|
||||||
|
from tenacity import (
|
||||||
|
retry,
|
||||||
|
stop_after_attempt,
|
||||||
|
wait_exponential_jitter,
|
||||||
|
retry_if_exception_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
from central import __version__
|
||||||
|
from central.adapter import SourceAdapter
|
||||||
|
from central.config import NWSAdapterConfig
|
||||||
|
from central.models import Event, Geo
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# FIPS state codes to postal abbreviations
|
||||||
|
FIPS_TO_STATE: dict[str, str] = {
|
||||||
|
"01": "AL", "02": "AK", "04": "AZ", "05": "AR", "06": "CA",
|
||||||
|
"08": "CO", "09": "CT", "10": "DE", "11": "DC", "12": "FL",
|
||||||
|
"13": "GA", "15": "HI", "16": "ID", "17": "IL", "18": "IN",
|
||||||
|
"19": "IA", "20": "KS", "21": "KY", "22": "LA", "23": "ME",
|
||||||
|
"24": "MD", "25": "MA", "26": "MI", "27": "MN", "28": "MS",
|
||||||
|
"29": "MO", "30": "MT", "31": "NE", "32": "NV", "33": "NH",
|
||||||
|
"34": "NJ", "35": "NM", "36": "NY", "37": "NC", "38": "ND",
|
||||||
|
"39": "OH", "40": "OK", "41": "OR", "42": "PA", "44": "RI",
|
||||||
|
"45": "SC", "46": "SD", "47": "TN", "48": "TX", "49": "UT",
|
||||||
|
"50": "VT", "51": "VA", "53": "WA", "54": "WV", "55": "WI",
|
||||||
|
"56": "WY", "60": "AS", "66": "GU", "69": "MP", "72": "PR",
|
||||||
|
"78": "VI",
|
||||||
|
}
|
||||||
|
|
||||||
|
SEVERITY_MAP: dict[str, int | None] = {
|
||||||
|
"Extreme": 4,
|
||||||
|
"Severe": 3,
|
||||||
|
"Moderate": 2,
|
||||||
|
"Minor": 1,
|
||||||
|
"Unknown": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
NWS_API_URL = "https://api.weather.gov/alerts/active"
|
||||||
|
|
||||||
|
|
||||||
|
def _snake_case(s: str) -> str:
|
||||||
|
"""Convert a string to snake_case."""
|
||||||
|
s = re.sub(r"[^a-zA-Z0-9\s]", "", s)
|
||||||
|
s = re.sub(r"\s+", "_", s.strip())
|
||||||
|
return s.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_datetime(s: str | None) -> datetime | None:
|
||||||
|
"""Parse an ISO datetime string to UTC datetime."""
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_centroid(geometry: dict[str, Any] | None) -> tuple[float, float] | None:
|
||||||
|
"""Compute centroid from GeoJSON geometry using arithmetic mean of vertices."""
|
||||||
|
if not geometry:
|
||||||
|
return None
|
||||||
|
|
||||||
|
geom_type = geometry.get("type")
|
||||||
|
coords = geometry.get("coordinates")
|
||||||
|
|
||||||
|
if not coords:
|
||||||
|
return None
|
||||||
|
|
||||||
|
all_points: list[tuple[float, float]] = []
|
||||||
|
|
||||||
|
if geom_type == "Point":
|
||||||
|
return (coords[0], coords[1])
|
||||||
|
elif geom_type == "Polygon":
|
||||||
|
for ring in coords:
|
||||||
|
for point in ring:
|
||||||
|
all_points.append((point[0], point[1]))
|
||||||
|
elif geom_type == "MultiPolygon":
|
||||||
|
for polygon in coords:
|
||||||
|
for ring in polygon:
|
||||||
|
for point in ring:
|
||||||
|
all_points.append((point[0], point[1]))
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not all_points:
|
||||||
|
return None
|
||||||
|
|
||||||
|
avg_lon = sum(p[0] for p in all_points) / len(all_points)
|
||||||
|
avg_lat = sum(p[1] for p in all_points) / len(all_points)
|
||||||
|
return (avg_lon, avg_lat)
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_bbox(
|
||||||
|
geometry: dict[str, Any] | None
|
||||||
|
) -> tuple[float, float, float, float] | None:
|
||||||
|
"""Compute bounding box from GeoJSON geometry."""
|
||||||
|
if not geometry:
|
||||||
|
return None
|
||||||
|
|
||||||
|
geom_type = geometry.get("type")
|
||||||
|
coords = geometry.get("coordinates")
|
||||||
|
|
||||||
|
if not coords:
|
||||||
|
return None
|
||||||
|
|
||||||
|
all_points: list[tuple[float, float]] = []
|
||||||
|
|
||||||
|
if geom_type == "Point":
|
||||||
|
return (coords[0], coords[1], coords[0], coords[1])
|
||||||
|
elif geom_type == "Polygon":
|
||||||
|
for ring in coords:
|
||||||
|
for point in ring:
|
||||||
|
all_points.append((point[0], point[1]))
|
||||||
|
elif geom_type == "MultiPolygon":
|
||||||
|
for polygon in coords:
|
||||||
|
for ring in polygon:
|
||||||
|
for point in ring:
|
||||||
|
all_points.append((point[0], point[1]))
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not all_points:
|
||||||
|
return None
|
||||||
|
|
||||||
|
min_lon = min(p[0] for p in all_points)
|
||||||
|
max_lon = max(p[0] for p in all_points)
|
||||||
|
min_lat = min(p[1] for p in all_points)
|
||||||
|
max_lat = max(p[1] for p in all_points)
|
||||||
|
return (min_lon, min_lat, max_lon, max_lat)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_states_from_codes(
|
||||||
|
same_codes: list[str], ugc_codes: list[str]
|
||||||
|
) -> set[str]:
|
||||||
|
"""Extract state abbreviations from SAME and UGC codes."""
|
||||||
|
states: set[str] = set()
|
||||||
|
|
||||||
|
for code in same_codes:
|
||||||
|
if len(code) >= 2:
|
||||||
|
fips_state = code[:2]
|
||||||
|
if fips_state in FIPS_TO_STATE:
|
||||||
|
states.add(FIPS_TO_STATE[fips_state])
|
||||||
|
|
||||||
|
for code in ugc_codes:
|
||||||
|
if len(code) >= 2 and code[:2].isalpha():
|
||||||
|
states.add(code[:2].upper())
|
||||||
|
|
||||||
|
return states
|
||||||
|
|
||||||
|
|
||||||
|
def _build_regions(same_codes: list[str], ugc_codes: list[str]) -> list[str]:
|
||||||
|
"""Build sorted list of region strings from geocodes."""
|
||||||
|
regions: set[str] = set()
|
||||||
|
|
||||||
|
for code in same_codes:
|
||||||
|
if len(code) >= 2:
|
||||||
|
fips_state = code[:2]
|
||||||
|
if fips_state in FIPS_TO_STATE:
|
||||||
|
state = FIPS_TO_STATE[fips_state]
|
||||||
|
regions.add(f"US-{state}-FIPS{code}")
|
||||||
|
|
||||||
|
for code in ugc_codes:
|
||||||
|
if len(code) >= 3 and code[:2].isalpha():
|
||||||
|
state = code[:2].upper()
|
||||||
|
rest = code[2:]
|
||||||
|
if rest.startswith("C"):
|
||||||
|
regions.add(f"US-{state}-C{rest[1:]}")
|
||||||
|
elif rest.startswith("Z"):
|
||||||
|
regions.add(f"US-{state}-Z{rest[1:]}")
|
||||||
|
else:
|
||||||
|
regions.add(f"US-{state}-{rest}")
|
||||||
|
|
||||||
|
return sorted(regions)
|
||||||
|
|
||||||
|
|
||||||
|
class NWSAdapter(SourceAdapter):
|
||||||
|
"""National Weather Service alerts adapter."""
|
||||||
|
|
||||||
|
name = "nws"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: NWSAdapterConfig,
|
||||||
|
cursor_db_path: Path,
|
||||||
|
) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.cadence_s = config.cadence_s
|
||||||
|
self.states = set(s.upper() for s in config.states)
|
||||||
|
self.cursor_db_path = cursor_db_path
|
||||||
|
self._session: aiohttp.ClientSession | None = None
|
||||||
|
self._limiter = AsyncLimiter(1, config.cadence_s)
|
||||||
|
self._db: sqlite3.Connection | None = None
|
||||||
|
|
||||||
|
async def startup(self) -> None:
|
||||||
|
"""Initialize HTTP session and cursor database."""
|
||||||
|
user_agent = f"Central/{__version__} ({self.config.contact_email})"
|
||||||
|
self._session = aiohttp.ClientSession(
|
||||||
|
headers={"User-Agent": user_agent},
|
||||||
|
timeout=aiohttp.ClientTimeout(total=30),
|
||||||
|
)
|
||||||
|
|
||||||
|
self._db = sqlite3.connect(str(self.cursor_db_path))
|
||||||
|
self._db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS adapter_cursors (
|
||||||
|
adapter TEXT PRIMARY KEY,
|
||||||
|
cursor_data TEXT NOT NULL,
|
||||||
|
updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
self._db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS published_ids (
|
||||||
|
adapter TEXT NOT NULL,
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
first_seen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (adapter, event_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
self._db.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS published_ids_last_seen
|
||||||
|
ON published_ids (last_seen)
|
||||||
|
""")
|
||||||
|
self._db.commit()
|
||||||
|
|
||||||
|
logger.info("NWS adapter started", extra={"states": list(self.states)})
|
||||||
|
|
||||||
|
async def shutdown(self) -> None:
|
||||||
|
"""Close HTTP session and database."""
|
||||||
|
if self._session:
|
||||||
|
await self._session.close()
|
||||||
|
self._session = None
|
||||||
|
if self._db:
|
||||||
|
self._db.close()
|
||||||
|
self._db = None
|
||||||
|
logger.info("NWS adapter shut down")
|
||||||
|
|
||||||
|
def _get_cursor(self) -> str | None:
|
||||||
|
"""Get the stored If-Modified-Since cursor."""
|
||||||
|
if not self._db:
|
||||||
|
return None
|
||||||
|
cur = self._db.execute(
|
||||||
|
"SELECT cursor_data FROM adapter_cursors WHERE adapter = ?",
|
||||||
|
(self.name,)
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
def _set_cursor(self, last_modified: str) -> None:
|
||||||
|
"""Store the Last-Modified header for next request."""
|
||||||
|
if not self._db:
|
||||||
|
return
|
||||||
|
self._db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO adapter_cursors (adapter, cursor_data, updated)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (adapter) DO UPDATE SET
|
||||||
|
cursor_data = excluded.cursor_data,
|
||||||
|
updated = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
(self.name, last_modified)
|
||||||
|
)
|
||||||
|
self._db.commit()
|
||||||
|
|
||||||
|
def is_published(self, event_id: str) -> bool:
|
||||||
|
"""Check if an event has already been published."""
|
||||||
|
if not self._db:
|
||||||
|
return False
|
||||||
|
cur = self._db.execute(
|
||||||
|
"SELECT 1 FROM published_ids WHERE adapter = ? AND event_id = ?",
|
||||||
|
(self.name, event_id)
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
def mark_published(self, event_id: str) -> None:
|
||||||
|
"""Mark an event as published."""
|
||||||
|
if not self._db:
|
||||||
|
return
|
||||||
|
self._db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO published_ids (adapter, event_id, first_seen, last_seen)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (adapter, event_id) DO UPDATE SET
|
||||||
|
last_seen = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
(self.name, event_id)
|
||||||
|
)
|
||||||
|
self._db.commit()
|
||||||
|
|
||||||
|
def bump_last_seen(self, event_id: str) -> None:
|
||||||
|
"""Bump the last_seen timestamp for an event."""
|
||||||
|
if not self._db:
|
||||||
|
return
|
||||||
|
self._db.execute(
|
||||||
|
"UPDATE published_ids SET last_seen = CURRENT_TIMESTAMP WHERE adapter = ? AND event_id = ?",
|
||||||
|
(self.name, event_id)
|
||||||
|
)
|
||||||
|
self._db.commit()
|
||||||
|
|
||||||
|
def sweep_old_ids(self) -> int:
|
||||||
|
"""Remove published_ids older than 8 days. Returns count deleted."""
|
||||||
|
if not self._db:
|
||||||
|
return 0
|
||||||
|
cur = self._db.execute(
|
||||||
|
"DELETE FROM published_ids WHERE last_seen < datetime('now', '-8 days')"
|
||||||
|
)
|
||||||
|
self._db.commit()
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
|
@retry(
|
||||||
|
stop=stop_after_attempt(5),
|
||||||
|
wait=wait_exponential_jitter(initial=1, max=60),
|
||||||
|
retry=retry_if_exception_type((aiohttp.ClientError, asyncio.TimeoutError)),
|
||||||
|
reraise=True,
|
||||||
|
)
|
||||||
|
async def _fetch_alerts(self) -> tuple[int, dict[str, Any] | None, str | None]:
|
||||||
|
"""Fetch alerts from NWS API with conditional request."""
|
||||||
|
async with self._limiter:
|
||||||
|
if not self._session:
|
||||||
|
raise RuntimeError("Session not initialized")
|
||||||
|
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
cursor = self._get_cursor()
|
||||||
|
if cursor:
|
||||||
|
headers["If-Modified-Since"] = cursor
|
||||||
|
|
||||||
|
async with self._session.get(NWS_API_URL, headers=headers) as resp:
|
||||||
|
if resp.status in (429, 403):
|
||||||
|
retry_after = resp.headers.get("Retry-After", "60")
|
||||||
|
try:
|
||||||
|
wait_time = int(retry_after)
|
||||||
|
except ValueError:
|
||||||
|
wait_time = 60
|
||||||
|
logger.warning(
|
||||||
|
"Rate limited by NWS",
|
||||||
|
extra={"status": resp.status, "retry_after": wait_time}
|
||||||
|
)
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
raise aiohttp.ClientError(f"Rate limited: {resp.status}")
|
||||||
|
|
||||||
|
if resp.status == 304:
|
||||||
|
return (304, None, None)
|
||||||
|
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
data = await resp.json()
|
||||||
|
last_modified = resp.headers.get("Last-Modified")
|
||||||
|
|
||||||
|
return (resp.status, data, last_modified)
|
||||||
|
|
||||||
|
def _normalize_feature(self, feature: dict[str, Any]) -> Event | None:
|
||||||
|
"""Normalize a GeoJSON feature to an Event."""
|
||||||
|
props = feature.get("properties", {})
|
||||||
|
geocode = props.get("geocode", {})
|
||||||
|
|
||||||
|
same_codes = geocode.get("SAME", [])
|
||||||
|
ugc_codes = geocode.get("UGC", [])
|
||||||
|
|
||||||
|
feature_states = _extract_states_from_codes(same_codes, ugc_codes)
|
||||||
|
if not feature_states.intersection(self.states):
|
||||||
|
return None
|
||||||
|
|
||||||
|
event_id = feature.get("id")
|
||||||
|
if not event_id:
|
||||||
|
logger.warning("Feature missing id", extra={"properties": props})
|
||||||
|
return None
|
||||||
|
|
||||||
|
event_type = props.get("event", "Unknown")
|
||||||
|
category = f"wx.alert.{_snake_case(event_type)}"
|
||||||
|
|
||||||
|
time = _parse_datetime(props.get("sent"))
|
||||||
|
if not time:
|
||||||
|
logger.warning("Feature missing sent time", extra={"id": event_id})
|
||||||
|
return None
|
||||||
|
|
||||||
|
expires = _parse_datetime(props.get("expires"))
|
||||||
|
|
||||||
|
severity_str = props.get("severity", "Unknown")
|
||||||
|
severity = SEVERITY_MAP.get(severity_str)
|
||||||
|
|
||||||
|
geometry = feature.get("geometry")
|
||||||
|
centroid = _compute_centroid(geometry)
|
||||||
|
bbox = _compute_bbox(geometry)
|
||||||
|
regions = _build_regions(same_codes, ugc_codes)
|
||||||
|
primary_region = regions[0] if regions else None
|
||||||
|
|
||||||
|
geo = Geo(
|
||||||
|
centroid=centroid,
|
||||||
|
bbox=bbox,
|
||||||
|
regions=regions,
|
||||||
|
primary_region=primary_region,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Event(
|
||||||
|
id=event_id,
|
||||||
|
source="central/adapters/nws",
|
||||||
|
category=category,
|
||||||
|
time=time,
|
||||||
|
expires=expires,
|
||||||
|
severity=severity,
|
||||||
|
geo=geo,
|
||||||
|
data=props,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def poll(self) -> AsyncIterator[Event]:
|
||||||
|
"""Poll NWS API for active alerts."""
|
||||||
|
try:
|
||||||
|
status, data, last_modified = await self._fetch_alerts()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to fetch NWS alerts", extra={"error": str(e)})
|
||||||
|
raise
|
||||||
|
|
||||||
|
if status == 304:
|
||||||
|
logger.info("NWS returned 304 Not Modified")
|
||||||
|
return
|
||||||
|
|
||||||
|
if last_modified:
|
||||||
|
self._set_cursor(last_modified)
|
||||||
|
|
||||||
|
features = data.get("features", []) if data else []
|
||||||
|
logger.info(
|
||||||
|
"NWS poll completed",
|
||||||
|
extra={"status": status, "feature_count": len(features)}
|
||||||
|
)
|
||||||
|
|
||||||
|
yielded = 0
|
||||||
|
for feature in features:
|
||||||
|
try:
|
||||||
|
event = self._normalize_feature(feature)
|
||||||
|
if event:
|
||||||
|
yield event
|
||||||
|
yielded += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to normalize feature",
|
||||||
|
extra={"error": str(e), "feature_id": feature.get("id")}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("NWS yielded events", extra={"count": yielded})
|
||||||
342
src/central/archive.py
Normal file
342
src/central/archive.py
Normal file
|
|
@ -0,0 +1,342 @@
|
||||||
|
"""Central archive consumer - JetStream to TimescaleDB."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
import nats
|
||||||
|
from nats.js import JetStreamContext
|
||||||
|
from nats.js.api import ConsumerConfig, DeliverPolicy, AckPolicy
|
||||||
|
|
||||||
|
from central.config import load_config, Config
|
||||||
|
|
||||||
|
CONFIG_PATH = "/etc/central/central.toml"
|
||||||
|
CONSUMER_NAME = "archive"
|
||||||
|
STREAM_NAME = "CENTRAL_WX"
|
||||||
|
SUBJECT_FILTER = "central.wx.>"
|
||||||
|
BATCH_SIZE = 100
|
||||||
|
FETCH_TIMEOUT = 5.0
|
||||||
|
ACK_WAIT = 30
|
||||||
|
|
||||||
|
|
||||||
|
class JsonFormatter(logging.Formatter):
|
||||||
|
"""JSON log formatter for structured logging."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
log_obj: dict[str, Any] = {
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"msg": record.getMessage(),
|
||||||
|
}
|
||||||
|
if record.exc_info:
|
||||||
|
log_obj["exc"] = self.formatException(record.exc_info)
|
||||||
|
for key in record.__dict__:
|
||||||
|
if key not in (
|
||||||
|
"name", "msg", "args", "created", "filename", "funcName",
|
||||||
|
"levelname", "levelno", "lineno", "module", "msecs",
|
||||||
|
"pathname", "process", "processName", "relativeCreated",
|
||||||
|
"stack_info", "exc_info", "exc_text", "thread", "threadName",
|
||||||
|
"taskName", "message",
|
||||||
|
):
|
||||||
|
log_obj[key] = record.__dict__[key]
|
||||||
|
return json.dumps(log_obj)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging() -> None:
|
||||||
|
"""Configure JSON logging to stdout."""
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setFormatter(JsonFormatter())
|
||||||
|
logging.root.handlers = [handler]
|
||||||
|
logging.root.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("central.archive")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_geom_sql(geo_data: dict[str, Any] | None) -> str | None:
|
||||||
|
"""Build PostGIS geometry from event geo data."""
|
||||||
|
if not geo_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
bbox = geo_data.get("bbox")
|
||||||
|
centroid = geo_data.get("centroid")
|
||||||
|
|
||||||
|
if bbox and len(bbox) == 4:
|
||||||
|
# Create polygon from bbox
|
||||||
|
min_lon, min_lat, max_lon, max_lat = bbox
|
||||||
|
return json.dumps({
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[
|
||||||
|
[min_lon, min_lat],
|
||||||
|
[max_lon, min_lat],
|
||||||
|
[max_lon, max_lat],
|
||||||
|
[min_lon, max_lat],
|
||||||
|
[min_lon, min_lat],
|
||||||
|
]]
|
||||||
|
})
|
||||||
|
elif centroid and len(centroid) == 2:
|
||||||
|
# Create point from centroid
|
||||||
|
return json.dumps({
|
||||||
|
"type": "Point",
|
||||||
|
"coordinates": centroid
|
||||||
|
})
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ArchiveConsumer:
|
||||||
|
"""Archive consumer process."""
|
||||||
|
|
||||||
|
def __init__(self, config: Config) -> None:
|
||||||
|
self.config = config
|
||||||
|
self._nc: nats.NATS | None = None
|
||||||
|
self._js: JetStreamContext | None = None
|
||||||
|
self._pool: asyncpg.Pool | None = None
|
||||||
|
self._shutdown_event = asyncio.Event()
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
"""Connect to NATS and PostgreSQL."""
|
||||||
|
self._nc = await nats.connect(self.config.nats.url)
|
||||||
|
self._js = self._nc.jetstream()
|
||||||
|
logger.info("Connected to NATS", extra={"url": self.config.nats.url})
|
||||||
|
|
||||||
|
self._pool = await asyncpg.create_pool(
|
||||||
|
self.config.postgres.dsn,
|
||||||
|
min_size=1,
|
||||||
|
max_size=5,
|
||||||
|
)
|
||||||
|
logger.info("Connected to PostgreSQL")
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
"""Disconnect from NATS and PostgreSQL."""
|
||||||
|
if self._pool:
|
||||||
|
await self._pool.close()
|
||||||
|
self._pool = None
|
||||||
|
if self._nc:
|
||||||
|
await self._nc.drain()
|
||||||
|
await self._nc.close()
|
||||||
|
self._nc = None
|
||||||
|
self._js = None
|
||||||
|
logger.info("Disconnected")
|
||||||
|
|
||||||
|
async def _ensure_consumer(self) -> None:
|
||||||
|
"""Ensure the durable consumer exists."""
|
||||||
|
if not self._js:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._js.consumer_info(STREAM_NAME, CONSUMER_NAME)
|
||||||
|
logger.info("Consumer exists", extra={"consumer": CONSUMER_NAME})
|
||||||
|
except nats.js.errors.NotFoundError:
|
||||||
|
consumer_config = ConsumerConfig(
|
||||||
|
durable_name=CONSUMER_NAME,
|
||||||
|
deliver_policy=DeliverPolicy.ALL,
|
||||||
|
ack_policy=AckPolicy.EXPLICIT,
|
||||||
|
ack_wait=ACK_WAIT,
|
||||||
|
filter_subject=SUBJECT_FILTER,
|
||||||
|
)
|
||||||
|
await self._js.add_consumer(STREAM_NAME, consumer_config)
|
||||||
|
logger.info("Consumer created", extra={"consumer": CONSUMER_NAME})
|
||||||
|
|
||||||
|
async def _process_message(self, msg: Any, conn: asyncpg.Connection) -> None:
|
||||||
|
"""Process a single message and insert into database."""
|
||||||
|
try:
|
||||||
|
envelope = json.loads(msg.data.decode())
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning("Invalid JSON in message", extra={"error": str(e)})
|
||||||
|
await msg.ack()
|
||||||
|
return
|
||||||
|
|
||||||
|
event_data = envelope.get("data", {})
|
||||||
|
geo_data = event_data.get("geo")
|
||||||
|
|
||||||
|
event_id = envelope.get("id")
|
||||||
|
source = event_data.get("source", "")
|
||||||
|
category = event_data.get("category", "")
|
||||||
|
time_str = event_data.get("time")
|
||||||
|
expires_str = event_data.get("expires")
|
||||||
|
severity = event_data.get("severity")
|
||||||
|
regions = event_data.get("geo", {}).get("regions", [])
|
||||||
|
primary_region = event_data.get("geo", {}).get("primary_region")
|
||||||
|
|
||||||
|
# Parse timestamps
|
||||||
|
event_time = None
|
||||||
|
if time_str:
|
||||||
|
try:
|
||||||
|
event_time = datetime.fromisoformat(time_str.replace("Z", "+00:00"))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
expires_time = None
|
||||||
|
if expires_str:
|
||||||
|
try:
|
||||||
|
expires_time = datetime.fromisoformat(expires_str.replace("Z", "+00:00"))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not event_id or not event_time:
|
||||||
|
logger.warning(
|
||||||
|
"Message missing required fields",
|
||||||
|
extra={"id": event_id, "time": time_str}
|
||||||
|
)
|
||||||
|
await msg.ack()
|
||||||
|
return
|
||||||
|
|
||||||
|
geom_json = _build_geom_sql(geo_data)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if geom_json:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO events (id, source, category, time, expires, severity,
|
||||||
|
geom, regions, primary_region, payload)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6,
|
||||||
|
ST_GeomFromGeoJSON($7), $8, $9, $10)
|
||||||
|
ON CONFLICT (id, time) DO UPDATE SET
|
||||||
|
source = EXCLUDED.source,
|
||||||
|
category = EXCLUDED.category,
|
||||||
|
expires = EXCLUDED.expires,
|
||||||
|
severity = EXCLUDED.severity,
|
||||||
|
geom = EXCLUDED.geom,
|
||||||
|
regions = EXCLUDED.regions,
|
||||||
|
primary_region = EXCLUDED.primary_region,
|
||||||
|
payload = EXCLUDED.payload
|
||||||
|
""",
|
||||||
|
event_id, source, category, event_time, expires_time, severity,
|
||||||
|
geom_json, regions, primary_region, json.dumps(envelope)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO events (id, source, category, time, expires, severity,
|
||||||
|
geom, regions, primary_region, payload)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, NULL, $7, $8, $9)
|
||||||
|
ON CONFLICT (id, time) DO UPDATE SET
|
||||||
|
source = EXCLUDED.source,
|
||||||
|
category = EXCLUDED.category,
|
||||||
|
expires = EXCLUDED.expires,
|
||||||
|
severity = EXCLUDED.severity,
|
||||||
|
geom = EXCLUDED.geom,
|
||||||
|
regions = EXCLUDED.regions,
|
||||||
|
primary_region = EXCLUDED.primary_region,
|
||||||
|
payload = EXCLUDED.payload
|
||||||
|
""",
|
||||||
|
event_id, source, category, event_time, expires_time, severity,
|
||||||
|
regions, primary_region, json.dumps(envelope)
|
||||||
|
)
|
||||||
|
|
||||||
|
await msg.ack()
|
||||||
|
logger.info("Archived event", extra={"id": event_id, "category": category})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to insert event",
|
||||||
|
extra={"id": event_id, "error": str(e)}
|
||||||
|
)
|
||||||
|
# Don't ack - let it be redelivered
|
||||||
|
|
||||||
|
async def _consume_loop(self) -> None:
|
||||||
|
"""Main consume loop."""
|
||||||
|
if not self._js or not self._pool:
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._ensure_consumer()
|
||||||
|
|
||||||
|
sub = await self._js.pull_subscribe(
|
||||||
|
SUBJECT_FILTER,
|
||||||
|
durable=CONSUMER_NAME,
|
||||||
|
stream=STREAM_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Subscribed to stream",
|
||||||
|
extra={"stream": STREAM_NAME, "filter": SUBJECT_FILTER}
|
||||||
|
)
|
||||||
|
|
||||||
|
while not self._shutdown_event.is_set():
|
||||||
|
try:
|
||||||
|
msgs = await sub.fetch(
|
||||||
|
batch=BATCH_SIZE,
|
||||||
|
timeout=FETCH_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
if msgs:
|
||||||
|
async with self._pool.acquire() as conn:
|
||||||
|
for msg in msgs:
|
||||||
|
await self._process_message(msg, conn)
|
||||||
|
|
||||||
|
except nats.errors.TimeoutError:
|
||||||
|
# No messages available, continue
|
||||||
|
pass
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error in consume loop", extra={"error": str(e)})
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
logger.info("Consume loop stopped")
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Start the consumer."""
|
||||||
|
await self.connect()
|
||||||
|
logger.info("Archive consumer ready")
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
"""Run the consume loop until shutdown."""
|
||||||
|
await self._consume_loop()
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop the consumer gracefully."""
|
||||||
|
logger.info("Archive consumer shutting down")
|
||||||
|
self._shutdown_event.set()
|
||||||
|
await self.disconnect()
|
||||||
|
logger.info("Archive consumer stopped")
|
||||||
|
|
||||||
|
|
||||||
|
async def async_main() -> None:
|
||||||
|
"""Async entry point."""
|
||||||
|
setup_logging()
|
||||||
|
|
||||||
|
config = load_config(CONFIG_PATH)
|
||||||
|
consumer = ArchiveConsumer(config)
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
shutdown_event = asyncio.Event()
|
||||||
|
|
||||||
|
def handle_signal() -> None:
|
||||||
|
shutdown_event.set()
|
||||||
|
|
||||||
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
|
loop.add_signal_handler(sig, handle_signal)
|
||||||
|
|
||||||
|
await consumer.start()
|
||||||
|
|
||||||
|
# Run consumer in background
|
||||||
|
consume_task = asyncio.create_task(consumer.run())
|
||||||
|
|
||||||
|
# Wait for shutdown signal
|
||||||
|
await shutdown_event.wait()
|
||||||
|
|
||||||
|
consumer._shutdown_event.set()
|
||||||
|
consume_task.cancel()
|
||||||
|
try:
|
||||||
|
await consume_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await consumer.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Entry point."""
|
||||||
|
asyncio.run(async_main())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
255
src/central/supervisor.py
Normal file
255
src/central/supervisor.py
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
"""Central supervisor - adapter scheduler and event publisher."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import nats
|
||||||
|
from nats.js import JetStreamContext
|
||||||
|
|
||||||
|
from central.adapters.nws import NWSAdapter
|
||||||
|
from central.cloudevents_wire import wrap_event
|
||||||
|
from central.config import load_config, Config
|
||||||
|
from central.models import subject_for_event
|
||||||
|
|
||||||
|
CURSOR_DB_PATH = Path("/var/lib/central/cursors.db")
|
||||||
|
CONFIG_PATH = "/etc/central/central.toml"
|
||||||
|
|
||||||
|
|
||||||
|
class JsonFormatter(logging.Formatter):
|
||||||
|
"""JSON log formatter for structured logging."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
log_obj: dict[str, Any] = {
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"msg": record.getMessage(),
|
||||||
|
}
|
||||||
|
if record.exc_info:
|
||||||
|
log_obj["exc"] = self.formatException(record.exc_info)
|
||||||
|
if hasattr(record, "extra"):
|
||||||
|
log_obj.update(record.extra)
|
||||||
|
# Include any extra fields passed via extra={}
|
||||||
|
for key in record.__dict__:
|
||||||
|
if key not in (
|
||||||
|
"name", "msg", "args", "created", "filename", "funcName",
|
||||||
|
"levelname", "levelno", "lineno", "module", "msecs",
|
||||||
|
"pathname", "process", "processName", "relativeCreated",
|
||||||
|
"stack_info", "exc_info", "exc_text", "thread", "threadName",
|
||||||
|
"taskName", "message",
|
||||||
|
):
|
||||||
|
log_obj[key] = record.__dict__[key]
|
||||||
|
return json.dumps(log_obj)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging() -> None:
|
||||||
|
"""Configure JSON logging to stdout."""
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setFormatter(JsonFormatter())
|
||||||
|
logging.root.handlers = [handler]
|
||||||
|
logging.root.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("central.supervisor")
|
||||||
|
|
||||||
|
|
||||||
|
class Supervisor:
|
||||||
|
"""Main supervisor process."""
|
||||||
|
|
||||||
|
def __init__(self, config: Config) -> None:
|
||||||
|
self.config = config
|
||||||
|
self._nc: nats.NATS | None = None
|
||||||
|
self._js: JetStreamContext | None = None
|
||||||
|
self._adapters: list[NWSAdapter] = []
|
||||||
|
self._tasks: list[asyncio.Task[None]] = []
|
||||||
|
self._shutdown_event = asyncio.Event()
|
||||||
|
self._start_time = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
"""Connect to NATS."""
|
||||||
|
self._nc = await nats.connect(self.config.nats.url)
|
||||||
|
self._js = self._nc.jetstream()
|
||||||
|
logger.info("Connected to NATS", extra={"url": self.config.nats.url})
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
"""Disconnect from NATS."""
|
||||||
|
if self._nc:
|
||||||
|
await self._nc.drain()
|
||||||
|
await self._nc.close()
|
||||||
|
self._nc = None
|
||||||
|
self._js = None
|
||||||
|
logger.info("Disconnected from NATS")
|
||||||
|
|
||||||
|
async def _publish_meta(self, subject: str, data: dict[str, Any]) -> None:
|
||||||
|
"""Publish a meta event (no Nats-Msg-Id)."""
|
||||||
|
if not self._nc:
|
||||||
|
return
|
||||||
|
payload = json.dumps(data).encode()
|
||||||
|
await self._nc.publish(subject, payload)
|
||||||
|
|
||||||
|
async def _publish_event(self, subject: str, envelope: dict[str, Any], msg_id: str) -> None:
|
||||||
|
"""Publish an event with dedup header."""
|
||||||
|
if not self._js:
|
||||||
|
return
|
||||||
|
payload = json.dumps(envelope).encode()
|
||||||
|
await self._js.publish(
|
||||||
|
subject,
|
||||||
|
payload,
|
||||||
|
headers={"Nats-Msg-Id": msg_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _run_adapter(self, adapter: NWSAdapter) -> None:
|
||||||
|
"""Run an adapter poll loop."""
|
||||||
|
while not self._shutdown_event.is_set():
|
||||||
|
poll_start = datetime.now(timezone.utc)
|
||||||
|
try:
|
||||||
|
async for event in adapter.poll():
|
||||||
|
# Dedup check
|
||||||
|
if adapter.is_published(event.id):
|
||||||
|
adapter.bump_last_seen(event.id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build CloudEvent
|
||||||
|
envelope, msg_id = wrap_event(event, self.config)
|
||||||
|
subject = subject_for_event(event)
|
||||||
|
|
||||||
|
# Publish
|
||||||
|
await self._publish_event(subject, envelope, msg_id)
|
||||||
|
adapter.mark_published(event.id)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Published event",
|
||||||
|
extra={"id": event.id, "subject": subject, "category": event.category}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Publish success status
|
||||||
|
await self._publish_meta(
|
||||||
|
f"central.meta.adapter.{adapter.name}.status",
|
||||||
|
{"ok": True, "ts": datetime.now(timezone.utc).isoformat()}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Adapter poll failed", extra={"adapter": adapter.name})
|
||||||
|
await self._publish_meta(
|
||||||
|
f"central.meta.adapter.{adapter.name}.status",
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": str(e),
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sweep old IDs
|
||||||
|
swept = adapter.sweep_old_ids()
|
||||||
|
if swept > 0:
|
||||||
|
logger.info("Swept old published IDs", extra={"count": swept})
|
||||||
|
|
||||||
|
# Sleep until next cadence
|
||||||
|
elapsed = (datetime.now(timezone.utc) - poll_start).total_seconds()
|
||||||
|
sleep_time = max(0, adapter.cadence_s - elapsed)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
self._shutdown_event.wait(),
|
||||||
|
timeout=sleep_time
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _heartbeat_loop(self) -> None:
|
||||||
|
"""Publish periodic heartbeats."""
|
||||||
|
while not self._shutdown_event.is_set():
|
||||||
|
uptime = (datetime.now(timezone.utc) - self._start_time).total_seconds()
|
||||||
|
await self._publish_meta(
|
||||||
|
"central.meta.heartbeat",
|
||||||
|
{"ts": datetime.now(timezone.utc).isoformat(), "uptime_s": uptime}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
self._shutdown_event.wait(),
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Start the supervisor."""
|
||||||
|
await self.connect()
|
||||||
|
|
||||||
|
# Initialize adapters
|
||||||
|
if self.config.adapters.get("nws") and self.config.adapters["nws"].enabled:
|
||||||
|
adapter = NWSAdapter(
|
||||||
|
config=self.config.adapters["nws"],
|
||||||
|
cursor_db_path=CURSOR_DB_PATH,
|
||||||
|
)
|
||||||
|
await adapter.startup()
|
||||||
|
self._adapters.append(adapter)
|
||||||
|
logger.info("NWS adapter initialized")
|
||||||
|
|
||||||
|
# Start adapter tasks
|
||||||
|
for adapter in self._adapters:
|
||||||
|
task = asyncio.create_task(self._run_adapter(adapter))
|
||||||
|
self._tasks.append(task)
|
||||||
|
|
||||||
|
# Start heartbeat
|
||||||
|
self._tasks.append(asyncio.create_task(self._heartbeat_loop()))
|
||||||
|
|
||||||
|
logger.info("Supervisor started", extra={"adapters": [a.name for a in self._adapters]})
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop the supervisor gracefully."""
|
||||||
|
logger.info("Supervisor shutting down")
|
||||||
|
self._shutdown_event.set()
|
||||||
|
|
||||||
|
# Cancel tasks
|
||||||
|
for task in self._tasks:
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Shutdown adapters
|
||||||
|
for adapter in self._adapters:
|
||||||
|
await adapter.shutdown()
|
||||||
|
|
||||||
|
await self.disconnect()
|
||||||
|
logger.info("Supervisor stopped")
|
||||||
|
|
||||||
|
|
||||||
|
async def async_main() -> None:
|
||||||
|
"""Async entry point."""
|
||||||
|
setup_logging()
|
||||||
|
|
||||||
|
config = load_config(CONFIG_PATH)
|
||||||
|
supervisor = Supervisor(config)
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
shutdown_event = asyncio.Event()
|
||||||
|
|
||||||
|
def handle_signal() -> None:
|
||||||
|
shutdown_event.set()
|
||||||
|
|
||||||
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
|
loop.add_signal_handler(sig, handle_signal)
|
||||||
|
|
||||||
|
await supervisor.start()
|
||||||
|
|
||||||
|
# Wait for shutdown signal
|
||||||
|
await shutdown_event.wait()
|
||||||
|
|
||||||
|
await supervisor.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Entry point."""
|
||||||
|
asyncio.run(async_main())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
26
systemd/central-archive.service
Normal file
26
systemd/central-archive.service
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Central archive consumer (JetStream -> TimescaleDB)
|
||||||
|
After=network-online.target nats-server.service postgresql@16-main.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Requires=nats-server.service postgresql@16-main.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=central
|
||||||
|
Group=central
|
||||||
|
WorkingDirectory=/opt/central
|
||||||
|
Environment=HOME=/opt/central
|
||||||
|
ExecStart=/opt/central/.venv/bin/central-archive
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
LimitNOFILE=65536
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths=/var/lib/central
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
26
systemd/central-supervisor.service
Normal file
26
systemd/central-supervisor.service
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Central supervisor (adapter scheduler + publisher)
|
||||||
|
After=network-online.target nats-server.service postgresql@16-main.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Requires=nats-server.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=central
|
||||||
|
Group=central
|
||||||
|
WorkingDirectory=/opt/central
|
||||||
|
Environment=HOME=/opt/central
|
||||||
|
ExecStart=/opt/central/.venv/bin/central-supervisor
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
LimitNOFILE=65536
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths=/var/lib/central
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
373
tests/test_nws_normalization.py
Normal file
373
tests/test_nws_normalization.py
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
"""Tests for NWS adapter normalization."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from central.adapters.nws import (
|
||||||
|
NWSAdapter,
|
||||||
|
_snake_case,
|
||||||
|
_parse_datetime,
|
||||||
|
_extract_states_from_codes,
|
||||||
|
_build_regions,
|
||||||
|
_compute_centroid,
|
||||||
|
_compute_bbox,
|
||||||
|
SEVERITY_MAP,
|
||||||
|
)
|
||||||
|
from central.config import NWSAdapterConfig
|
||||||
|
from central.models import subject_for_event
|
||||||
|
|
||||||
|
|
||||||
|
# Sample NWS GeoJSON features for testing
|
||||||
|
# SAME codes: 6 digits, first 2 are state FIPS (ID=16, OR=41, CA=06, WA=53)
|
||||||
|
SAMPLE_FEATURE_ID = {
|
||||||
|
"id": "urn:oid:2.49.0.1.840.0.a1b2c3d4e5f6",
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[
|
||||||
|
[-116.5, 43.5],
|
||||||
|
[-116.0, 43.5],
|
||||||
|
[-116.0, 44.0],
|
||||||
|
[-116.5, 44.0],
|
||||||
|
[-116.5, 43.5],
|
||||||
|
]]
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"id": "urn:oid:2.49.0.1.840.0.a1b2c3d4e5f6",
|
||||||
|
"event": "Severe Thunderstorm Warning",
|
||||||
|
"sent": "2026-05-15T12:00:00-06:00",
|
||||||
|
"expires": "2026-05-15T14:00:00-06:00",
|
||||||
|
"severity": "Severe",
|
||||||
|
"geocode": {
|
||||||
|
"SAME": ["160001"], # Idaho state FIPS 16
|
||||||
|
"UGC": ["IDC001", "IDZ033"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
SAMPLE_FEATURE_OR = {
|
||||||
|
"id": "urn:oid:2.49.0.1.840.0.x1y2z3w4",
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": None,
|
||||||
|
"properties": {
|
||||||
|
"id": "urn:oid:2.49.0.1.840.0.x1y2z3w4",
|
||||||
|
"event": "Winter Storm Warning",
|
||||||
|
"sent": "2026-05-15T08:00:00Z",
|
||||||
|
"expires": "2026-05-16T08:00:00Z",
|
||||||
|
"severity": "Moderate",
|
||||||
|
"geocode": {
|
||||||
|
"SAME": ["410051"], # Oregon state FIPS 41
|
||||||
|
"UGC": ["ORC051"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
SAMPLE_FEATURE_CA = {
|
||||||
|
"id": "urn:oid:2.49.0.1.840.0.ca1234",
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": {
|
||||||
|
"type": "Point",
|
||||||
|
"coordinates": [-118.25, 34.05],
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"id": "urn:oid:2.49.0.1.840.0.ca1234",
|
||||||
|
"event": "Fire Weather Watch",
|
||||||
|
"sent": "2026-05-15T10:00:00-07:00",
|
||||||
|
"expires": "2026-05-16T18:00:00-07:00",
|
||||||
|
"severity": "Minor",
|
||||||
|
"geocode": {
|
||||||
|
"SAME": ["060037"], # California state FIPS 06
|
||||||
|
"UGC": ["CAZ568"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
SAMPLE_FEATURE_UNKNOWN_SEVERITY = {
|
||||||
|
"id": "urn:oid:2.49.0.1.840.0.unk123",
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": None,
|
||||||
|
"properties": {
|
||||||
|
"id": "urn:oid:2.49.0.1.840.0.unk123",
|
||||||
|
"event": "Test Alert",
|
||||||
|
"sent": "2026-05-15T12:00:00Z",
|
||||||
|
"expires": None,
|
||||||
|
"severity": "Unknown",
|
||||||
|
"geocode": {
|
||||||
|
"SAME": ["530033"], # Washington state FIPS 53
|
||||||
|
"UGC": ["WAC033"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestSnakeCase:
|
||||||
|
"""Tests for snake_case conversion."""
|
||||||
|
|
||||||
|
def test_spaces_to_underscores(self) -> None:
|
||||||
|
assert _snake_case("Severe Thunderstorm Warning") == "severe_thunderstorm_warning"
|
||||||
|
|
||||||
|
def test_removes_special_chars(self) -> None:
|
||||||
|
assert _snake_case("Fire Weather (Red Flag)") == "fire_weather_red_flag"
|
||||||
|
|
||||||
|
def test_lowercase(self) -> None:
|
||||||
|
assert _snake_case("TORNADO WARNING") == "tornado_warning"
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseDatetime:
|
||||||
|
"""Tests for datetime parsing."""
|
||||||
|
|
||||||
|
def test_iso_with_offset(self) -> None:
|
||||||
|
result = _parse_datetime("2026-05-15T12:00:00-06:00")
|
||||||
|
assert result is not None
|
||||||
|
assert result.tzinfo == timezone.utc
|
||||||
|
assert result.hour == 18 # 12:00 MDT = 18:00 UTC
|
||||||
|
|
||||||
|
def test_iso_with_z(self) -> None:
|
||||||
|
result = _parse_datetime("2026-05-15T12:00:00Z")
|
||||||
|
assert result is not None
|
||||||
|
assert result.hour == 12
|
||||||
|
|
||||||
|
def test_none_input(self) -> None:
|
||||||
|
assert _parse_datetime(None) is None
|
||||||
|
|
||||||
|
def test_invalid_input(self) -> None:
|
||||||
|
assert _parse_datetime("not a date") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractStates:
|
||||||
|
"""Tests for state extraction from geocodes."""
|
||||||
|
|
||||||
|
def test_same_codes(self) -> None:
|
||||||
|
# Idaho FIPS is 16
|
||||||
|
states = _extract_states_from_codes(["160001", "160003"], [])
|
||||||
|
assert states == {"ID"}
|
||||||
|
|
||||||
|
def test_ugc_codes(self) -> None:
|
||||||
|
states = _extract_states_from_codes([], ["IDC001", "ORC051"])
|
||||||
|
assert states == {"ID", "OR"}
|
||||||
|
|
||||||
|
def test_combined(self) -> None:
|
||||||
|
# Idaho FIPS is 16
|
||||||
|
states = _extract_states_from_codes(["160001"], ["WAC033"])
|
||||||
|
assert states == {"ID", "WA"}
|
||||||
|
|
||||||
|
def test_empty(self) -> None:
|
||||||
|
states = _extract_states_from_codes([], [])
|
||||||
|
assert states == set()
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildRegions:
|
||||||
|
"""Tests for region string building."""
|
||||||
|
|
||||||
|
def test_same_to_fips_region(self) -> None:
|
||||||
|
# Idaho FIPS is 16
|
||||||
|
regions = _build_regions(["160001"], [])
|
||||||
|
assert "US-ID-FIPS160001" in regions
|
||||||
|
|
||||||
|
def test_ugc_county(self) -> None:
|
||||||
|
regions = _build_regions([], ["IDC001"])
|
||||||
|
assert "US-ID-C001" in regions
|
||||||
|
|
||||||
|
def test_ugc_zone(self) -> None:
|
||||||
|
regions = _build_regions([], ["IDZ033"])
|
||||||
|
assert "US-ID-Z033" in regions
|
||||||
|
|
||||||
|
def test_sorted_alphabetically(self) -> None:
|
||||||
|
regions = _build_regions(["160001"], ["IDC001", "IDZ033"])
|
||||||
|
assert regions == sorted(regions)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStateFilter:
|
||||||
|
"""Tests for state filtering."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def adapter(self, tmp_path: Path) -> NWSAdapter:
|
||||||
|
"""Create adapter with ID/OR/WA states."""
|
||||||
|
config = NWSAdapterConfig(
|
||||||
|
enabled=True,
|
||||||
|
cadence_s=60,
|
||||||
|
states=["ID", "OR", "WA", "MT", "WY", "UT", "NV"],
|
||||||
|
contact_email="test@example.com",
|
||||||
|
)
|
||||||
|
return NWSAdapter(config, tmp_path / "test.db")
|
||||||
|
|
||||||
|
def test_accepts_id_feature(self, adapter: NWSAdapter) -> None:
|
||||||
|
event = adapter._normalize_feature(SAMPLE_FEATURE_ID)
|
||||||
|
assert event is not None
|
||||||
|
assert event.id == SAMPLE_FEATURE_ID["id"]
|
||||||
|
|
||||||
|
def test_accepts_or_feature(self, adapter: NWSAdapter) -> None:
|
||||||
|
event = adapter._normalize_feature(SAMPLE_FEATURE_OR)
|
||||||
|
assert event is not None
|
||||||
|
assert event.id == SAMPLE_FEATURE_OR["id"]
|
||||||
|
|
||||||
|
def test_rejects_ca_feature(self, adapter: NWSAdapter) -> None:
|
||||||
|
event = adapter._normalize_feature(SAMPLE_FEATURE_CA)
|
||||||
|
assert event is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSeverityMapping:
|
||||||
|
"""Tests for severity mapping."""
|
||||||
|
|
||||||
|
def test_extreme(self) -> None:
|
||||||
|
assert SEVERITY_MAP["Extreme"] == 4
|
||||||
|
|
||||||
|
def test_severe(self) -> None:
|
||||||
|
assert SEVERITY_MAP["Severe"] == 3
|
||||||
|
|
||||||
|
def test_moderate(self) -> None:
|
||||||
|
assert SEVERITY_MAP["Moderate"] == 2
|
||||||
|
|
||||||
|
def test_minor(self) -> None:
|
||||||
|
assert SEVERITY_MAP["Minor"] == 1
|
||||||
|
|
||||||
|
def test_unknown(self) -> None:
|
||||||
|
assert SEVERITY_MAP["Unknown"] is None
|
||||||
|
|
||||||
|
def test_unknown_severity_in_feature(self, tmp_path: Path) -> None:
|
||||||
|
config = NWSAdapterConfig(
|
||||||
|
enabled=True,
|
||||||
|
cadence_s=60,
|
||||||
|
states=["WA"],
|
||||||
|
contact_email="test@example.com",
|
||||||
|
)
|
||||||
|
adapter = NWSAdapter(config, tmp_path / "test.db")
|
||||||
|
event = adapter._normalize_feature(SAMPLE_FEATURE_UNKNOWN_SEVERITY)
|
||||||
|
assert event is not None
|
||||||
|
assert event.severity is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubjectDerivation:
|
||||||
|
"""Tests for NATS subject derivation."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def adapter(self, tmp_path: Path) -> NWSAdapter:
|
||||||
|
config = NWSAdapterConfig(
|
||||||
|
enabled=True,
|
||||||
|
cadence_s=60,
|
||||||
|
states=["ID", "OR", "WA"],
|
||||||
|
contact_email="test@example.com",
|
||||||
|
)
|
||||||
|
return NWSAdapter(config, tmp_path / "test.db")
|
||||||
|
|
||||||
|
def test_county_subject(self, adapter: NWSAdapter) -> None:
|
||||||
|
event = adapter._normalize_feature(SAMPLE_FEATURE_ID)
|
||||||
|
assert event is not None
|
||||||
|
subject = subject_for_event(event)
|
||||||
|
# Primary region should be alphabetically first
|
||||||
|
# Could be county or zone depending on sort order
|
||||||
|
assert subject.startswith("central.wx.alert.us.id.")
|
||||||
|
|
||||||
|
def test_zone_subject(self, adapter: NWSAdapter) -> None:
|
||||||
|
# Create feature with only zone codes
|
||||||
|
feature = {
|
||||||
|
"id": "urn:test:zone",
|
||||||
|
"geometry": None,
|
||||||
|
"properties": {
|
||||||
|
"event": "Test Alert",
|
||||||
|
"sent": "2026-05-15T12:00:00Z",
|
||||||
|
"severity": "Minor",
|
||||||
|
"geocode": {
|
||||||
|
"SAME": [],
|
||||||
|
"UGC": ["IDZ033"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
event = adapter._normalize_feature(feature)
|
||||||
|
assert event is not None
|
||||||
|
subject = subject_for_event(event)
|
||||||
|
assert "zone" in subject
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegionsSorted:
|
||||||
|
"""Tests for regions list sorting."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def adapter(self, tmp_path: Path) -> NWSAdapter:
|
||||||
|
config = NWSAdapterConfig(
|
||||||
|
enabled=True,
|
||||||
|
cadence_s=60,
|
||||||
|
states=["ID"],
|
||||||
|
contact_email="test@example.com",
|
||||||
|
)
|
||||||
|
return NWSAdapter(config, tmp_path / "test.db")
|
||||||
|
|
||||||
|
def test_regions_alphabetically_sorted(self, adapter: NWSAdapter) -> None:
|
||||||
|
event = adapter._normalize_feature(SAMPLE_FEATURE_ID)
|
||||||
|
assert event is not None
|
||||||
|
assert event.geo.regions == sorted(event.geo.regions)
|
||||||
|
|
||||||
|
def test_primary_region_is_first(self, adapter: NWSAdapter) -> None:
|
||||||
|
event = adapter._normalize_feature(SAMPLE_FEATURE_ID)
|
||||||
|
assert event is not None
|
||||||
|
assert len(event.geo.regions) > 0
|
||||||
|
assert event.geo.primary_region == event.geo.regions[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeduplication:
|
||||||
|
"""Tests for event deduplication."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def adapter(self, tmp_path: Path) -> NWSAdapter:
|
||||||
|
config = NWSAdapterConfig(
|
||||||
|
enabled=True,
|
||||||
|
cadence_s=60,
|
||||||
|
states=["ID"],
|
||||||
|
contact_email="test@example.com",
|
||||||
|
)
|
||||||
|
return NWSAdapter(config, tmp_path / "test.db")
|
||||||
|
|
||||||
|
def test_same_feature_same_id(self, adapter: NWSAdapter) -> None:
|
||||||
|
"""Normalizing the same feature twice returns same Event.id."""
|
||||||
|
event1 = adapter._normalize_feature(SAMPLE_FEATURE_ID)
|
||||||
|
event2 = adapter._normalize_feature(SAMPLE_FEATURE_ID)
|
||||||
|
assert event1 is not None
|
||||||
|
assert event2 is not None
|
||||||
|
assert event1.id == event2.id
|
||||||
|
|
||||||
|
|
||||||
|
class TestGeometry:
|
||||||
|
"""Tests for geometry computation."""
|
||||||
|
|
||||||
|
def test_centroid_polygon(self) -> None:
|
||||||
|
geom = {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[
|
||||||
|
[-116.5, 43.5],
|
||||||
|
[-116.0, 43.5],
|
||||||
|
[-116.0, 44.0],
|
||||||
|
[-116.5, 44.0],
|
||||||
|
[-116.5, 43.5],
|
||||||
|
]]
|
||||||
|
}
|
||||||
|
centroid = _compute_centroid(geom)
|
||||||
|
assert centroid is not None
|
||||||
|
# Average of 5 vertices (including closing point)
|
||||||
|
# lon: (-116.5 + -116.0 + -116.0 + -116.5 + -116.5) / 5 = -116.3
|
||||||
|
# lat: (43.5 + 43.5 + 44.0 + 44.0 + 43.5) / 5 = 43.7
|
||||||
|
assert -116.4 < centroid[0] < -116.2
|
||||||
|
assert 43.6 < centroid[1] < 43.8
|
||||||
|
|
||||||
|
def test_bbox_polygon(self) -> None:
|
||||||
|
geom = {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[
|
||||||
|
[-116.5, 43.5],
|
||||||
|
[-116.0, 43.5],
|
||||||
|
[-116.0, 44.0],
|
||||||
|
[-116.5, 44.0],
|
||||||
|
[-116.5, 43.5],
|
||||||
|
]]
|
||||||
|
}
|
||||||
|
bbox = _compute_bbox(geom)
|
||||||
|
assert bbox is not None
|
||||||
|
assert bbox == (-116.5, 43.5, -116.0, 44.0)
|
||||||
|
|
||||||
|
def test_centroid_none_geometry(self) -> None:
|
||||||
|
assert _compute_centroid(None) is None
|
||||||
|
|
||||||
|
def test_bbox_none_geometry(self) -> None:
|
||||||
|
assert _compute_bbox(None) is None
|
||||||
Loading…
Add table
Add a link
Reference in a new issue