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
@@ -0,0 +1,332 @@
|
||||
//! Database tests against a real PostgreSQL. Each test runs in its own
|
||||
//! throwaway schema. Set TEST_DATABASE_URL to run them, e.g.
|
||||
//! `postgres://postgres:ping@localhost:55432/ping`; without it they are
|
||||
//! skipped (reported as passed with a note on stderr).
|
||||
|
||||
use crate::dams::DamRecord;
|
||||
use crate::db;
|
||||
use crate::hii::{RainRecord, WaterLevelRecord};
|
||||
use crate::rain::RainRow;
|
||||
use crate::rid::{self, Reading, RidSource};
|
||||
use crate::stations::Stations;
|
||||
use anyhow::Result;
|
||||
use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
|
||||
use sqlx::PgPool;
|
||||
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct TestDb {
|
||||
pool: PgPool,
|
||||
admin: PgPool,
|
||||
schema: String,
|
||||
}
|
||||
|
||||
impl TestDb {
|
||||
async fn new() -> Option<Self> {
|
||||
let Ok(url) = std::env::var("TEST_DATABASE_URL") else {
|
||||
eprintln!("TEST_DATABASE_URL not set: skipping database test");
|
||||
return None;
|
||||
};
|
||||
let schema = format!(
|
||||
"t_{}_{}",
|
||||
std::process::id(),
|
||||
chrono::Utc::now().timestamp_nanos_opt().unwrap()
|
||||
);
|
||||
let admin = PgPool::connect(&url).await.expect("connect");
|
||||
sqlx::query(&format!("CREATE SCHEMA {schema}"))
|
||||
.execute(&admin)
|
||||
.await
|
||||
.unwrap();
|
||||
let opts = PgConnectOptions::from_str(&url)
|
||||
.unwrap()
|
||||
.options([("search_path", schema.as_str())]);
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect_with(opts)
|
||||
.await
|
||||
.unwrap();
|
||||
db::ensure_schema(&pool).await.expect("schema");
|
||||
Some(TestDb {
|
||||
pool,
|
||||
admin,
|
||||
schema,
|
||||
})
|
||||
}
|
||||
|
||||
async fn drop(self) {
|
||||
self.pool.close().await;
|
||||
sqlx::query(&format!("DROP SCHEMA {} CASCADE", self.schema))
|
||||
.execute(&self.admin)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
fn t(y: i32, m: u32, day: u32, h: u32, min: u32) -> NaiveDateTime {
|
||||
d(y, m, day).and_hms_opt(h, min, 0).unwrap()
|
||||
}
|
||||
|
||||
/// Canned RID: date -> last published hour (1..=24); P.1 (column 8) and P.20
|
||||
/// (column 1). Records which reports were requested.
|
||||
struct FakeRid {
|
||||
published: HashMap<NaiveDate, i64>,
|
||||
calls: Mutex<Vec<NaiveDate>>,
|
||||
}
|
||||
|
||||
impl FakeRid {
|
||||
fn new(published: &[(NaiveDate, i64)]) -> Self {
|
||||
FakeRid {
|
||||
published: published.iter().copied().collect(),
|
||||
calls: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
fn calls(&self) -> Vec<NaiveDate> {
|
||||
self.calls.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl RidSource for FakeRid {
|
||||
async fn fetch_report(&self, date: NaiveDate) -> Result<Vec<Reading>> {
|
||||
self.calls.lock().unwrap().push(date);
|
||||
let last = self.published.get(&date).copied().unwrap_or(0);
|
||||
Ok((1..=last)
|
||||
.flat_map(|h| {
|
||||
let ts = date.and_time(NaiveTime::MIN) + Duration::hours(h);
|
||||
[8u32, 1].map(|n| Reading {
|
||||
timestamp: ts,
|
||||
station_num: n,
|
||||
water_level: 1.0 + h as f64 / 100.0,
|
||||
discharge: Some(50.0),
|
||||
discharge_percent: Some(12.5),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
async fn stored_hours(pool: &PgPool, day: NaiveDate) -> Vec<u32> {
|
||||
let rec = db::recorded_hours_by_day(pool, day, day).await.unwrap();
|
||||
let mut h: Vec<u32> = rec
|
||||
.get(&day)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect();
|
||||
h.sort();
|
||||
h
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn late_report_is_stored_and_reported_behind() {
|
||||
let Some(db) = TestDb::new().await else {
|
||||
return;
|
||||
};
|
||||
let today = d(2026, 10, 17);
|
||||
let yesterday = today - Duration::days(1);
|
||||
let stations = Stations::bundled();
|
||||
// yesterday complete already
|
||||
let full = FakeRid::new(&[(yesterday, 24)]);
|
||||
rid::fill_gaps(&full, &db.pool, &stations, Some(1), t(2026, 10, 17, 0, 30))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 21:30 and RID has published up to 20:00
|
||||
let src = FakeRid::new(&[(today, 20)]);
|
||||
let o = rid::run_cycle(&src, &db.pool, &stations, t(2026, 10, 17, 21, 30)).await;
|
||||
assert!(!o.current);
|
||||
assert_eq!(o.latest, Some(t(2026, 10, 17, 20, 0)));
|
||||
assert_eq!(o.saved, 40);
|
||||
assert_eq!(src.calls(), vec![today]);
|
||||
assert_eq!(
|
||||
stored_hours(&db.pool, today).await,
|
||||
(0..=20).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// the 21:00 reading lands; the next poll is current
|
||||
let src = FakeRid::new(&[(today, 21)]);
|
||||
let o = rid::run_cycle(&src, &db.pool, &stations, t(2026, 10, 17, 21, 40)).await;
|
||||
assert!(o.current);
|
||||
db.drop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn yesterdays_late_hours_are_recovered_after_midnight() {
|
||||
let Some(db) = TestDb::new().await else {
|
||||
return;
|
||||
};
|
||||
let today = d(2026, 10, 17);
|
||||
let yesterday = today - Duration::days(1);
|
||||
let stations = Stations::bundled();
|
||||
// stored up to 21:00 yesterday (the evening RID published late)
|
||||
let partial = FakeRid::new(&[(yesterday, 21)]);
|
||||
rid::run_cycle(&partial, &db.pool, &stations, t(2026, 10, 16, 21, 5)).await;
|
||||
assert_eq!(
|
||||
stored_hours(&db.pool, yesterday).await,
|
||||
(1..=21).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// 02:10: RID now has all of yesterday and today up to 02:00
|
||||
let src = FakeRid::new(&[(yesterday, 24), (today, 2)]);
|
||||
let o = rid::run_cycle(&src, &db.pool, &stations, t(2026, 10, 17, 2, 10)).await;
|
||||
assert!(o.current);
|
||||
assert_eq!(src.calls(), vec![yesterday, today]);
|
||||
assert_eq!(
|
||||
stored_hours(&db.pool, yesterday).await,
|
||||
(1..=23).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(stored_hours(&db.pool, today).await, vec![0, 1, 2]);
|
||||
|
||||
// complete now: the next cycle fetches only today's report
|
||||
let src = FakeRid::new(&[(yesterday, 24), (today, 3)]);
|
||||
rid::run_cycle(&src, &db.pool, &stations, t(2026, 10, 17, 3, 5)).await;
|
||||
assert_eq!(src.calls(), vec![today]);
|
||||
db.drop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rid_upsert_revises_values_and_rounds_like_numeric() {
|
||||
let Some(db) = TestDb::new().await else {
|
||||
return;
|
||||
};
|
||||
let stations = Stations::bundled();
|
||||
let r = |level: f64| Reading {
|
||||
timestamp: t(2026, 10, 17, 9, 0),
|
||||
station_num: 8,
|
||||
water_level: level,
|
||||
discharge: None,
|
||||
discharge_percent: None,
|
||||
};
|
||||
db::save_rid(&db.pool, &[r(1.0700000524520874)], &stations)
|
||||
.await
|
||||
.unwrap();
|
||||
db::save_rid(&db.pool, &[r(1.0900000333786)], &stations)
|
||||
.await
|
||||
.unwrap();
|
||||
let rows: Vec<(String, f64, Option<f64>, String)> = sqlx::query_as(
|
||||
"SELECT s.station_code, w.water_level::float8, w.discharge::float8, w.status
|
||||
FROM water_measurements w JOIN stations s ON s.id = w.station_id",
|
||||
)
|
||||
.fetch_all(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, vec![("P.1".into(), 1.09, None, "active".into())]);
|
||||
db.drop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hii_dams_and_rain_round_trip() {
|
||||
let Some(db) = TestDb::new().await else {
|
||||
return;
|
||||
};
|
||||
let now = t(2026, 10, 17, 21, 5);
|
||||
let rain = RainRecord {
|
||||
station_id: 11,
|
||||
oldcode: Some("CHM001".into()),
|
||||
name_th: None,
|
||||
name_en: Some("Chiang Mai 1".into()),
|
||||
latitude: Some(18.79),
|
||||
longitude: Some(99.0),
|
||||
sub_basin_id: Some("601".into()),
|
||||
agency: Some("HII".into()),
|
||||
timestamp: t(2026, 10, 17, 21, 0),
|
||||
rain_1h: Some(1.234),
|
||||
rain_24h: Some(12.0),
|
||||
};
|
||||
assert_eq!(
|
||||
db::save_hii_rain(&db.pool, &[rain.clone(), rain.clone()], now)
|
||||
.await
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
let (r1h,): (f64,) = sqlx::query_as("SELECT rain_1h::float8 FROM hii_rainfall")
|
||||
.fetch_one(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r1h, 1.23);
|
||||
|
||||
let wl = WaterLevelRecord {
|
||||
station_id: 5,
|
||||
oldcode: Some("ridhydro_P.1".into()),
|
||||
rid_code: Some("P.1".into()),
|
||||
name_th: Some("สะพานนวรัฐ".into()),
|
||||
name_en: None,
|
||||
latitude: None,
|
||||
longitude: None,
|
||||
agency: None,
|
||||
river_name: None,
|
||||
offset_msl: Some(300.0),
|
||||
ground_level_msl: None,
|
||||
min_bank_msl: None,
|
||||
critical_level_msl: None,
|
||||
critical_level_m: None,
|
||||
qmax: None,
|
||||
is_key_station: true,
|
||||
timestamp: t(2026, 10, 17, 21, 0),
|
||||
wl_msl: Some(301.2),
|
||||
wl_m: None,
|
||||
discharge: None,
|
||||
flow_rate: None,
|
||||
storage_percent: None,
|
||||
situation_level: Some(2),
|
||||
diff_wl_bank: None,
|
||||
};
|
||||
db::save_hii_waterlevel(&db.pool, &[wl], now).await.unwrap();
|
||||
let (code, key, sit): (String, bool, i32) = sqlx::query_as(
|
||||
"SELECT s.rid_code, s.is_key_station, w.situation_level
|
||||
FROM hii_waterlevel w JOIN hii_wl_stations s ON s.id = w.station_id",
|
||||
)
|
||||
.fetch_one(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((code.as_str(), key, sit), ("P.1", true, 2));
|
||||
|
||||
let dam = |name: Option<&str>, level: Option<f64>| DamRecord {
|
||||
dam_id: "200103".into(),
|
||||
region: Some("north".into()),
|
||||
name_th: name.map(str::to_string),
|
||||
latitude: Some(19.17),
|
||||
longitude: None,
|
||||
capacity_max_mcm: Some(265.0),
|
||||
capacity_normal_mcm: None,
|
||||
date: d(2026, 10, 17),
|
||||
storage_mcm: Some(200.0),
|
||||
storage_pct: Some(75.5),
|
||||
inflow_mcm: None,
|
||||
outflow_mcm: Some(1.2),
|
||||
level_msl: level,
|
||||
};
|
||||
db::save_dams(&db.pool, &[dam(Some("แม่งัด"), Some(395.1))], now)
|
||||
.await
|
||||
.unwrap();
|
||||
// a later snapshot without name / level must not blank them
|
||||
db::save_dams(&db.pool, &[dam(None, None)], now)
|
||||
.await
|
||||
.unwrap();
|
||||
let (name, level): (Option<String>, Option<f64>) = sqlx::query_as(
|
||||
"SELECT d.name_th, r.level_msl::float8 FROM rid_reservoir_daily r JOIN rid_dams d USING (dam_id)",
|
||||
)
|
||||
.fetch_one(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(name.as_deref(), Some("แม่งัด"));
|
||||
assert_eq!(level, Some(395.1));
|
||||
|
||||
let rows = vec![RainRow {
|
||||
timestamp: t(2026, 10, 18, 3, 0),
|
||||
points: [Some(1.0), None, Some(2.0), None, Some(0.333)],
|
||||
catchment_mean: Some(1.111),
|
||||
}];
|
||||
db::save_rain(&db.pool, &rows).await.unwrap();
|
||||
let (mean, missing): (f64, Option<f64>) =
|
||||
sqlx::query_as("SELECT catchment_mean::float8, mae_taeng::float8 FROM openmeteo_rain")
|
||||
.fetch_one(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((mean, missing), (1.11, None));
|
||||
db.drop().await;
|
||||
}
|
||||
Reference in new issue
Block a user