prop/rust/prop_grid_rs/src/bin/worker.rs

215 lines
8.4 KiB
Rust

//! `prop-grid-rs` main loop.
//!
//! Flow:
//! 1. Connect to Postgres (DATABASE_URL env).
//! 2. Spawn N concurrent worker tasks (`PROP_GRID_RS_PARALLELISM`, default
//! = num_cpus). Each worker independently claims one `grid_tasks` row
//! (fh>0, status='queued') via `FOR UPDATE SKIP LOCKED`, runs the
//! fetch → decode → score → write-scores-file chain, and marks done +
//! `NOTIFY propagation_ready '<valid_time iso>'`. On error marks
//! failed; hourly cron reseeds.
//! 3. Graceful shutdown on SIGTERM: workers exit after their current task.
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use prop_grid_rs::{db, fetcher::HrrrClient, metrics, pipeline, telemetry};
use tokio::task::JoinSet;
use tracing::{error, info, warn};
const IDLE_SLEEP: Duration = Duration::from_secs(5);
enum ChainOutcome {
Forecast(pipeline::ChainStepStats),
Analysis(pipeline::AnalysisStepStats),
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let _telemetry = telemetry::init("prop-grid-rs");
let database_url = std::env::var("DATABASE_URL")
.map_err(|_| "DATABASE_URL environment variable is required")?;
let scores_dir = std::env::var("PROP_SCORES_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/data/scores"));
let hrrr_base = std::env::var("HRRR_BASE_URL")
.unwrap_or_else(|_| prop_grid_rs::fetcher::HRRR_BASE_DEFAULT.to_string());
let parallelism: usize = std::env::var("PROP_GRID_RS_PARALLELISM")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_else(num_cpus::get)
.max(1);
// +2 slots over parallelism: each worker holds one connection during
// claim/complete transactions; the spare lets NOTIFY and an opportunistic
// retry share the pool without blocking.
let max_conn: u32 = std::env::var("PROP_GRID_RS_PG_CONNS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or((parallelism as u32) + 2);
let pool = db::connect(&database_url, max_conn).await?;
let client = Arc::new(HrrrClient::new(hrrr_base)?);
let shutdown = Arc::new(AtomicBool::new(false));
spawn_signal_handler(shutdown.clone());
// Metrics endpoint binds on a separate port so scrape traffic
// never interferes with the worker's NFS writes or Postgres calls.
let metrics_addr: SocketAddr = std::env::var("METRICS_ADDR")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| "0.0.0.0:9100".parse().expect("valid default addr"));
tokio::spawn(async move {
metrics::serve(metrics_addr).await;
});
info!(
scores_dir = %scores_dir.display(),
parallelism,
pg_conns = max_conn,
metrics_addr = %metrics_addr,
"prop-grid-rs started"
);
let mut set: JoinSet<()> = JoinSet::new();
for worker_id in 0..parallelism {
let pool = pool.clone();
let client = client.clone();
let scores_dir = scores_dir.clone();
let shutdown = shutdown.clone();
set.spawn(async move {
worker_loop(worker_id, pool, client, scores_dir, shutdown).await;
});
}
while set.join_next().await.is_some() {}
info!("prop-grid-rs shutting down");
Ok(())
}
async fn worker_loop(
worker_id: usize,
pool: sqlx::PgPool,
client: Arc<HrrrClient>,
scores_dir: PathBuf,
shutdown: Arc<AtomicBool>,
) {
while !shutdown.load(Ordering::Relaxed) {
// Analysis tasks (kind='analysis', f00) get priority — a /map
// user values the current hour more than +1..+18h. Forecast is
// only attempted when there is no analysis row ready.
let claimed = match db::claim_next_analysis(&pool).await {
Ok(Some(task)) => Ok(Some(task)),
Ok(None) => db::claim_next(&pool).await,
Err(e) => Err(e),
};
match claimed {
Ok(Some(task)) => {
let task_id = task.id;
let _guard = metrics::InFlightGuard::new();
let started = Instant::now();
let result = match task.kind {
db::TaskKind::Forecast => pipeline::run_chain_step(
&client,
&scores_dir,
&pipeline::ChainStepInput {
run_time: task.run_time,
forecast_hour: task.forecast_hour as u8,
},
)
.await
.map(ChainOutcome::Forecast),
db::TaskKind::Analysis => pipeline::run_analysis_step(
&client,
&pool,
&scores_dir,
&pipeline::AnalysisStepInput {
run_time: task.run_time,
},
)
.await
.map(ChainOutcome::Analysis),
};
match result {
Ok(outcome) => {
let elapsed = started.elapsed();
metrics::record_chain_step(elapsed, true);
match &outcome {
ChainOutcome::Forecast(stats) => info!(
worker_id,
task_id = %task.id,
kind = "forecast",
run_time = %task.run_time,
forecast_hour = task.forecast_hour,
files = stats.score_files_written,
points = stats.point_count,
duration_s = elapsed.as_secs_f64(),
"chain step done"
),
ChainOutcome::Analysis(stats) => info!(
worker_id,
task_id = %task.id,
kind = "analysis",
run_time = %task.run_time,
files = stats.score_files_written,
points = stats.point_count,
profile_cells = stats.profile_cells_written,
duct_cells = stats.duct_cells,
nexrad_cells = stats.nexrad_cells_with_echo,
commercial_cells = stats.commercial_cells_boosted,
duration_s = elapsed.as_secs_f64(),
"analysis step done"
),
}
if let Err(e) = db::complete(&pool, &task).await {
error!(worker_id, %task_id, error = %e, "failed to mark task done");
}
}
Err(e) => {
metrics::record_chain_step(started.elapsed(), false);
warn!(worker_id, %task_id, error = %e, "chain step failed");
if let Err(dbe) = db::fail(&pool, task_id, &e.to_string()).await {
error!(worker_id, %task_id, error = %dbe, "failed to mark task failed");
}
}
}
}
Ok(None) => {
tokio::time::sleep(IDLE_SLEEP).await;
}
Err(e) => {
error!(worker_id, error = %e, "claim_next failed; backing off");
tokio::time::sleep(IDLE_SLEEP).await;
}
}
}
}
#[cfg(unix)]
fn spawn_signal_handler(shutdown: Arc<AtomicBool>) {
use tokio::signal::unix::{signal, SignalKind};
tokio::spawn(async move {
let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler");
let mut intr = signal(SignalKind::interrupt()).expect("install SIGINT handler");
tokio::select! {
_ = term.recv() => info!("SIGTERM received"),
_ = intr.recv() => info!("SIGINT received"),
};
shutdown.store(true, Ordering::Relaxed);
});
}
#[cfg(not(unix))]
fn spawn_signal_handler(shutdown: Arc<AtomicBool>) {
tokio::spawn(async move {
let _ = tokio::signal::ctrl_c().await;
shutdown.store(true, Ordering::Relaxed);
});
}