prop/rust/prop_grid_rs/src/db.rs

429 lines
13 KiB
Rust

//! `grid_tasks` claim/complete + `propagation_ready` NOTIFY.
//!
//! Contract:
//! * Elixir seeds one row per `(run_time, forecast_hour)` with status=queued.
//! * Rust claims one queued, non-zero-forecast-hour row via
//! `SELECT ... FOR UPDATE SKIP LOCKED` and marks it running.
//! * On success: status=done, NOTIFY propagation_ready '<iso valid_time>'.
//! * On failure: status=failed, error=<string>. An hourly cron reseeds.
use chrono::{DateTime, NaiveDateTime, Utc};
use sqlx::postgres::{PgNotification, PgPool, PgPoolOptions};
use sqlx::Row;
use std::time::Duration;
use uuid::Uuid;
pub const NOTIFY_CHANNEL: &str = "propagation_ready";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskKind {
/// f01..f18 — fetch + decode + score the forecast-hour grid. This is
/// what Rust has owned since Phase 2 cutover.
Forecast,
/// f00 enrichment — adds native-level duct merge, NEXRAD composite,
/// and commercial-link degradation on top of the forecast pipeline,
/// and writes the ProfilesFile consumed by /weather. Introduced in
/// Phase 3 Stream A.
Analysis,
}
impl TaskKind {
fn from_str(s: &str) -> Self {
match s {
"analysis" => TaskKind::Analysis,
// Default to Forecast so older producers (which wrote rows
// before the kind column existed, defaulted to 'forecast'
// by migration) keep working.
_ => TaskKind::Forecast,
}
}
}
#[derive(Debug, Clone)]
pub struct ClaimedTask {
pub id: Uuid,
pub run_time: DateTime<Utc>,
pub forecast_hour: i16,
pub valid_time: DateTime<Utc>,
pub attempt: i32,
pub kind: TaskKind,
}
#[derive(Debug, thiserror::Error)]
pub enum DbError {
#[error(transparent)]
Sqlx(#[from] sqlx::Error),
}
pub async fn connect(url: &str, max_connections: u32) -> Result<PgPool, DbError> {
let pool = PgPoolOptions::new()
.max_connections(max_connections)
.acquire_timeout(Duration::from_secs(10))
.connect(url)
.await?;
Ok(pool)
}
/// Atomically claim one queued grid_tasks row with forecast_hour > 0.
/// Transitions it to status='running', bumps the attempt counter, and
/// stamps claimed_at. Returns `None` if the queue is empty.
pub async fn claim_next(pool: &PgPool) -> Result<Option<ClaimedTask>, DbError> {
let mut tx = pool.begin().await?;
// Forecast-only claim. Analysis rows (kind='analysis') go through
// `claim_next_analysis` once the Rust-side f00 pipeline is wired;
// keeping them out of the forecast lane means a half-deployed cluster
// (Elixir still owns f00, Rust pulls forecasts) doesn't deadlock on
// an unfulfillable analysis row.
//
// Order: newest run first, then smallest forecast_hour first. Users
// loading /map always want the most recent cycle's near-hours before
// anything else. A stale run whose tasks never drained (broken HRRR
// publish, etc.) shouldn't block the current hour — the hourly cron
// reseeds the new run and the prune cron clears rotted rows.
let row = sqlx::query(
r#"
SELECT id, run_time, forecast_hour, valid_time, attempt, kind
FROM grid_tasks
WHERE status = 'queued' AND forecast_hour > 0 AND kind = 'forecast'
ORDER BY run_time DESC, forecast_hour ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
"#,
)
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
tx.commit().await?;
return Ok(None);
};
let id: Uuid = row.try_get("id")?;
// Elixir writes run_time/valid_time via Ecto `:utc_datetime`, which
// lands in Postgres as TIMESTAMP WITHOUT TIME ZONE (values are UTC
// by Ecto contract). Read as NaiveDateTime and reattach UTC so the
// rest of the worker can keep its DateTime<Utc> signature.
let run_time_naive: NaiveDateTime = row.try_get("run_time")?;
let run_time = DateTime::<Utc>::from_naive_utc_and_offset(run_time_naive, Utc);
let forecast_hour: i32 = row.try_get("forecast_hour")?;
let forecast_hour = forecast_hour as i16;
let valid_time_naive: NaiveDateTime = row.try_get("valid_time")?;
let valid_time = DateTime::<Utc>::from_naive_utc_and_offset(valid_time_naive, Utc);
let attempt: i32 = row.try_get("attempt")?;
let kind_str: String = row.try_get("kind")?;
let kind = TaskKind::from_str(&kind_str);
sqlx::query(
r#"
UPDATE grid_tasks
SET status = 'running',
claimed_at = NOW(),
attempt = attempt + 1,
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Some(ClaimedTask {
id,
run_time,
forecast_hour,
valid_time,
attempt: attempt + 1,
kind,
}))
}
/// Claim one queued `kind='analysis'` row. Analysis tasks carry the
/// f00 enrichment (native-level duct merge, NEXRAD composite,
/// commercial-link degradation, ProfilesFile write) that Rust only
/// learned to do in Phase 3 Stream A. Kept on a separate lane from
/// `claim_next` so operators can gate the analysis path independently
/// of the forecast path during rollout.
pub async fn claim_next_analysis(pool: &PgPool) -> Result<Option<ClaimedTask>, DbError> {
let mut tx = pool.begin().await?;
let row = sqlx::query(
r#"
SELECT id, run_time, forecast_hour, valid_time, attempt, kind
FROM grid_tasks
WHERE status = 'queued' AND kind = 'analysis'
ORDER BY run_time DESC, forecast_hour ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
"#,
)
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
tx.commit().await?;
return Ok(None);
};
let id: Uuid = row.try_get("id")?;
let run_time_naive: NaiveDateTime = row.try_get("run_time")?;
let run_time = DateTime::<Utc>::from_naive_utc_and_offset(run_time_naive, Utc);
let forecast_hour: i32 = row.try_get("forecast_hour")?;
let forecast_hour = forecast_hour as i16;
let valid_time_naive: NaiveDateTime = row.try_get("valid_time")?;
let valid_time = DateTime::<Utc>::from_naive_utc_and_offset(valid_time_naive, Utc);
let attempt: i32 = row.try_get("attempt")?;
let kind_str: String = row.try_get("kind")?;
let kind = TaskKind::from_str(&kind_str);
sqlx::query(
r#"
UPDATE grid_tasks
SET status = 'running',
claimed_at = NOW(),
attempt = attempt + 1,
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Some(ClaimedTask {
id,
run_time,
forecast_hour,
valid_time,
attempt: attempt + 1,
kind,
}))
}
/// Mark a task done and emit NOTIFY propagation_ready '<iso valid_time>'.
/// The NOTIFY payload is read by Elixir's `PropagationNotifyListener` to
/// warm `ScoreCache` and fan out `"propagation:updated"` PubSub.
pub async fn complete(pool: &PgPool, task: &ClaimedTask) -> Result<(), DbError> {
let mut tx = pool.begin().await?;
sqlx::query(
r#"
UPDATE grid_tasks
SET status = 'done',
completed_at = NOW(),
error = NULL,
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(task.id)
.execute(&mut *tx)
.await?;
let payload = task.valid_time.format("%Y-%m-%dT%H:%M:%SZ").to_string();
let notify = format!("NOTIFY {}, '{}'", NOTIFY_CHANNEL, payload);
sqlx::query(&notify).execute(&mut *tx).await?;
tx.commit().await?;
Ok(())
}
pub async fn fail(pool: &PgPool, task_id: Uuid, error: &str) -> Result<(), DbError> {
sqlx::query(
r#"
UPDATE grid_tasks
SET status = 'failed',
completed_at = NOW(),
error = $2,
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(task_id)
.bind(error)
.execute(pool)
.await?;
Ok(())
}
/// Park the task back in the queue (e.g. SIGTERM received mid-work).
/// Resets status to 'queued' without incrementing attempt so a restart
/// doesn't chew through the retry budget.
pub async fn requeue(pool: &PgPool, task_id: Uuid) -> Result<(), DbError> {
sqlx::query(
r#"
UPDATE grid_tasks
SET status = 'queued',
claimed_at = NULL,
updated_at = NOW()
WHERE id = $1 AND status = 'running'
"#,
)
.bind(task_id)
.execute(pool)
.await?;
Ok(())
}
// Imported only to keep the PgNotification type used from `sqlx::postgres`.
const _: fn() = || {
fn assert_send<T: Send>() {}
assert_send::<PgNotification>();
};
/// Per-QSO HRRR fetch task. One row per (valid_time, JSONB array of
/// points). Consumed by `bin/hrrr_point_worker`.
#[derive(Debug, Clone)]
pub struct HrrrFetchTask {
pub id: Uuid,
pub valid_time: DateTime<Utc>,
pub points: Vec<(f64, f64)>,
pub attempt: i32,
}
pub async fn claim_next_hrrr_task(pool: &PgPool) -> Result<Option<HrrrFetchTask>, DbError> {
let mut tx = pool.begin().await?;
let row = sqlx::query(
r#"
SELECT id, valid_time, points, attempt
FROM hrrr_fetch_tasks
WHERE status = 'queued'
ORDER BY valid_time DESC
FOR UPDATE SKIP LOCKED
LIMIT 1
"#,
)
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
tx.commit().await?;
return Ok(None);
};
let id: Uuid = row.try_get("id")?;
let vt_naive: NaiveDateTime = row.try_get("valid_time")?;
let valid_time = DateTime::<Utc>::from_naive_utc_and_offset(vt_naive, Utc);
let attempt: i32 = row.try_get("attempt")?;
let points_json: serde_json::Value = row.try_get("points")?;
let points = points_from_json(&points_json);
sqlx::query(
r#"
UPDATE hrrr_fetch_tasks
SET status = 'running',
claimed_at = NOW(),
attempt = attempt + 1,
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Some(HrrrFetchTask {
id,
valid_time,
points,
attempt: attempt + 1,
}))
}
pub async fn complete_hrrr_task(pool: &PgPool, task_id: Uuid) -> Result<(), DbError> {
sqlx::query(
r#"
UPDATE hrrr_fetch_tasks
SET status = 'done',
completed_at = NOW(),
error = NULL,
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(task_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn fail_hrrr_task(pool: &PgPool, task_id: Uuid, error: &str) -> Result<(), DbError> {
sqlx::query(
r#"
UPDATE hrrr_fetch_tasks
SET status = 'failed',
completed_at = NOW(),
error = $2,
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(task_id)
.bind(error)
.execute(pool)
.await?;
Ok(())
}
fn points_from_json(v: &serde_json::Value) -> Vec<(f64, f64)> {
let Some(arr) = v.as_array() else {
return Vec::new();
};
arr.iter()
.filter_map(|p| {
let lat = p.get("lat")?.as_f64()?;
let lon = p.get("lon")?.as_f64()?;
Some((lat, lon))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// Live-DB integration tests — gated behind the `PROP_GRID_RS_TEST_DB`
/// env var so `cargo test` stays green without Postgres.
#[tokio::test]
async fn claim_and_complete_roundtrip() {
let Ok(url) = std::env::var("PROP_GRID_RS_TEST_DB") else {
eprintln!("skip: set PROP_GRID_RS_TEST_DB to run db tests");
return;
};
let pool = connect(&url, 4).await.unwrap();
// Seed a unique fh>0 row. run_time/valid_time bind as
// NaiveDateTime to match Ecto's :utc_datetime column type.
let run_time = Utc::now();
let fh: i16 = 7;
let valid_time = run_time + chrono::Duration::hours(fh as i64);
let id = Uuid::new_v4();
sqlx::query(
r#"INSERT INTO grid_tasks (id, run_time, forecast_hour, valid_time, status, attempt, inserted_at, updated_at)
VALUES ($1, $2, $3, $4, 'queued', 0, NOW(), NOW())"#,
)
.bind(id)
.bind(run_time.naive_utc())
.bind(fh as i32)
.bind(valid_time.naive_utc())
.execute(&pool)
.await
.unwrap();
let claimed = claim_next(&pool).await.unwrap().expect("had a row");
assert!(claimed.forecast_hour > 0);
assert_eq!(claimed.attempt, 1);
assert_eq!(claimed.kind, TaskKind::Forecast);
complete(&pool, &claimed).await.unwrap();
// Clean up.
sqlx::query("DELETE FROM grid_tasks WHERE id = $1")
.bind(claimed.id)
.execute(&pool)
.await
.unwrap();
}
}