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
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:
1 parent
e3694d7591
commit
4e619aa2ae
26 files changed
+5836
-15
No files matched your search
+14
-1
@@ -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
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user