feat(rust): ping-collector, phase 1 of the Rust rewrite
CI / Format & lint (push) Successful in 19s
CI / Test suite (push) Successful in 47s
Docs / Validate documentation (push) Successful in 19s
Rust / fmt, clippy, test, build (push) Successful in 7m35s
Security / Dependency vulnerabilities (push) Successful in 1m37s
Security / Static analysis (push) Successful in 17s
Security / License report (push) Successful in 1m30s

rust/collector replaces the collection half of the Python web app: RID hourly
gauges (with the late-report semantics: store everything, retry every 10 min
while RID is behind, re-fetch yesterday's holes, startup gap fill), HII rain +
water level, RID dams (today + yesterday) and Open-Meteo rain. Same tables and
upsert semantics, one UNNEST statement per table, Bangkok wall-clock time
regardless of the server time zone.

Python side: COLLECTION_ENABLED=false makes the API leader stop collecting and
instead re-forecast + notify when the newest RID reading in the database
advances (polled every FORECAST_POLL_SECONDS) and once per clock hour;
/scraping/status then serves the collector's status file; /scrape/trigger
answers 409.

Verified: 20 Rust tests (parsers, gap/schedule logic, database tests against
PostgreSQL); live parity run of both collectors into two empty databases:
all 9 tables row-for-row identical (scripts/collector_parity.py); the Python
API served health, measurements, stats, status and model forecasts from a
database filled only by ping-collector. 177 Python tests pass.

reqwest uses native-tls: app.rid.go.th offers only TLS 1.2 CBC suites, which
rustls refuses.

Deploy/cutover/rollback: scripts/install_collector.sh, scripts/ping-collector.service;
CI: .gitea/workflows/rust.yml (fmt, clippy, tests with a PostgreSQL service,
release artifact).
This commit is contained in:
grabowski committed 2026-10-17 22:20:02 +07:00
1 parent e3694d7591
commit 4e619aa2ae
26 files changed
+5836 -15

No files matched your search

+14 -1
View File
@@ -142,8 +142,20 @@ class Config:
# On startup the collection leader re-fetches RID reports with holes from
# the last N days (0 = off). Older history: `run.py --fill-gaps all`.
GAP_FILL_DAYS_ON_START = int(os.getenv("GAP_FILL_DAYS_ON_START", "14"))
# Leader -> other workers hand-off for /scraping/status (default: temp dir)
# Leader -> other workers hand-off for /scraping/status (default: temp dir).
# With ping-collector, point both services at the same file inside the app
# dir (the systemd units use PrivateTmp).
SCRAPING_STATUS_FILE = os.getenv("SCRAPING_STATUS_FILE", "")
# false: data collection runs in the Rust ping-collector service
# (rust/collector); this app then only serves, forecasts and notifies,
# re-forecasting when the newest RID reading advances (polled every
# FORECAST_POLL_SECONDS) and at least once per clock hour.
COLLECTION_ENABLED = os.getenv("COLLECTION_ENABLED", "true").lower() in (
"1",
"true",
"yes",
)
FORECAST_POLL_SECONDS = int(os.getenv("FORECAST_POLL_SECONDS", "60"))
# Logging settings
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
@@ -293,6 +305,7 @@ class Config:
"RID_POLL_OFFSET_MINUTES": cls.RID_POLL_OFFSET_MINUTES,
"RID_RETRY_MINUTES": cls.RID_RETRY_MINUTES,
"GAP_FILL_DAYS_ON_START": cls.GAP_FILL_DAYS_ON_START,
"COLLECTION_ENABLED": cls.COLLECTION_ENABLED,
"LOG_LEVEL": cls.LOG_LEVEL,
"LOG_FILE": cls.LOG_FILE,
"DATA_RETENTION_DAYS": cls.DATA_RETENTION_DAYS,
+68 -3
View File
@@ -218,8 +218,15 @@ async def lifespan(app: FastAPI):
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")
if Config.COLLECTION_ENABLED:
app_state["scraping_task"] = asyncio.create_task(background_scraping_task())
logger.info("This worker is the background-collection leader")
else:
app_state["scraping_task"] = asyncio.create_task(forecast_notify_task())
logger.info(
"COLLECTION_ENABLED=false: data collection runs in ping-collector "
"(rust/collector); this worker only refreshes forecasts and notifies"
)
else:
logger.info(
"Another worker holds collection leadership; "
@@ -587,6 +594,58 @@ async def background_scraping_task():
await asyncio.sleep(60) # Wait a minute before retrying
def _latest_rid_reading():
"""Newest RID reading in the database (collected by another process)."""
from sqlalchemy import text
engine = app_state["scraper"].db_adapter.engine
with engine.connect() as conn:
return conn.execute(
text("SELECT MAX(timestamp) FROM water_measurements")
).scalar()
async def forecast_notify_task():
"""Leader loop when COLLECTION_ENABLED=false (ping-collector collects).
Polls the newest RID reading every FORECAST_POLL_SECONDS; when it
advances, and once per clock hour regardless (so a stopped feed is still
noticed and reported), refreshes the forecast cache/history and pushes
ntfy transitions.
"""
last_hourly_slot = None
newest_reading = None
while True:
try:
try:
latest = await asyncio.to_thread(_latest_rid_reading)
except Exception as e:
logger.warning(f"Could not read the newest RID reading: {e}")
latest = None
new_readings = latest is not None and (
newest_reading is None or latest > newest_reading
)
if new_readings:
newest_reading = latest
slot = datetime.now().replace(minute=0, second=0, microsecond=0)
hourly_due = slot != last_hourly_slot
if hourly_due:
last_hourly_slot = slot
if new_readings or hourly_due:
await _precompute_forecasts()
await _notify_transitions()
await asyncio.sleep(Config.FORECAST_POLL_SECONDS)
except asyncio.CancelledError:
logger.info("Forecast/notify task cancelled")
break
except Exception as e:
logger.error(f"Error in forecast/notify task: {e}")
await asyncio.sleep(60)
# Umami server-side API tracking (fire-and-forget; never blocks a response)
_UMAMI_TRACK_PREFIXES = (
@@ -1658,6 +1717,11 @@ async def trigger_scraping(background_tasks: BackgroundTasks):
"""Trigger manual data scraping"""
increment_counter("api_requests", labels={"endpoint": "scrape_trigger"})
if not Config.COLLECTION_ENABLED:
raise HTTPException(
status_code=409,
detail="Collection runs in ping-collector (COLLECTION_ENABLED=false)",
)
if app_state["is_scraping"]:
raise HTTPException(status_code=409, detail="Scraping already in progress")
@@ -1696,7 +1760,8 @@ async def get_scraping_status():
increment_counter("api_requests", labels={"endpoint": "scraping_status"})
# Only the leader worker collects; the others report what it published.
if app_state.get("leader_lock"):
# With COLLECTION_ENABLED=false ping-collector writes the same file.
if Config.COLLECTION_ENABLED and app_state.get("leader_lock"):
stats = dict(app_state["scraping_stats"], is_running=app_state["is_scraping"])
else:
stats = _read_scraping_status() or {"is_running": False}