fix(grid-rs): fall back to NOAA S3 when the LAN HRRR proxy is down

Root cause of the blank /map analysis breakdown: the skippy.w5isp.com
caching proxy was unreachable from the cluster, so every f00 analysis
step failed at the idx fetch and Rust never wrote a ProfilesFile. The
/map point-detail panel silently degraded to empty factors because
no profile ever lands on `/data/scores/profiles/`.

HrrrClient now takes an optional fallback base. When the primary base
exhausts its retries (5× exponential backoff), fetches retry once
against the fallback — defaulted to the NOAA S3 public bucket. Forecast,
analysis, and native-duct paths all go through the same
fetch_blob_with_fallback helper so a proxy outage degrades to direct
S3 instead of stalling.

Moved the native-URL builder out of native_duct into fetcher so the
single helper can build both primary and fallback URLs for the native
hybrid-sigma product.
This commit is contained in:
Graham McIntire 2026-04-21 15:54:02 -05:00
parent d0dbb5c5b4
commit 989d310447
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
6 changed files with 162 additions and 50 deletions

View file

@ -64,6 +64,12 @@ spec:
# reads it directly (sqlx connects with the same URL). # reads it directly (sqlx connects with the same URL).
- name: HRRR_BASE_URL - name: HRRR_BASE_URL
value: "http://skippy.w5isp.com:8080" value: "http://skippy.w5isp.com:8080"
# When the LAN caching proxy at skippy is unreachable, fetches
# retry once against the NOAA S3 public bucket so a proxy
# outage degrades to direct S3 reads instead of stalling the
# hourly pipeline.
- name: HRRR_FALLBACK_BASE_URL
value: "https://noaa-hrrr-bdp-pds.s3.amazonaws.com"
- name: PROP_SCORES_DIR - name: PROP_SCORES_DIR
value: "/data/scores" value: "/data/scores"
# Shared idx-file cache on NFS. All grid-rs replicas read/write # Shared idx-file cache on NFS. All grid-rs replicas read/write

View file

@ -57,6 +57,8 @@ spec:
env: env:
- name: HRRR_BASE_URL - name: HRRR_BASE_URL
value: "http://skippy.w5isp.com:8080" value: "http://skippy.w5isp.com:8080"
- name: HRRR_FALLBACK_BASE_URL
value: "https://noaa-hrrr-bdp-pds.s3.amazonaws.com"
- name: RUST_LOG - name: RUST_LOG
value: "info" value: "info"
- name: PROP_GRID_RS_PG_CONNS - name: PROP_GRID_RS_PG_CONNS

View file

@ -22,6 +22,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.map_err(|_| "DATABASE_URL environment variable is required")?; .map_err(|_| "DATABASE_URL environment variable is required")?;
let hrrr_base = std::env::var("HRRR_BASE_URL") let hrrr_base = std::env::var("HRRR_BASE_URL")
.unwrap_or_else(|_| prop_grid_rs::fetcher::HRRR_BASE_DEFAULT.to_string()); .unwrap_or_else(|_| prop_grid_rs::fetcher::HRRR_BASE_DEFAULT.to_string());
let hrrr_fallback = std::env::var("HRRR_FALLBACK_BASE_URL")
.unwrap_or_else(|_| prop_grid_rs::fetcher::HRRR_BASE_DEFAULT.to_string());
let max_conn: u32 = std::env::var("PROP_GRID_RS_PG_CONNS") let max_conn: u32 = std::env::var("PROP_GRID_RS_PG_CONNS")
.ok() .ok()
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
@ -29,7 +31,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let tmp_dir = PathBuf::from(std::env::var("TMP_DIR").unwrap_or_else(|_| "/tmp".to_string())); let tmp_dir = PathBuf::from(std::env::var("TMP_DIR").unwrap_or_else(|_| "/tmp".to_string()));
let pool = db::connect(&database_url, max_conn).await?; let pool = db::connect(&database_url, max_conn).await?;
let client = Arc::new(HrrrClient::new(hrrr_base)?); let client = Arc::new(HrrrClient::new_with_fallback(&hrrr_base, &hrrr_fallback)?);
let shutdown = Arc::new(AtomicBool::new(false)); let shutdown = Arc::new(AtomicBool::new(false));
spawn_signal_handler(shutdown.clone()); spawn_signal_handler(shutdown.clone());

View file

@ -38,6 +38,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.unwrap_or_else(|_| PathBuf::from("/data/scores")); .unwrap_or_else(|_| PathBuf::from("/data/scores"));
let hrrr_base = std::env::var("HRRR_BASE_URL") let hrrr_base = std::env::var("HRRR_BASE_URL")
.unwrap_or_else(|_| prop_grid_rs::fetcher::HRRR_BASE_DEFAULT.to_string()); .unwrap_or_else(|_| prop_grid_rs::fetcher::HRRR_BASE_DEFAULT.to_string());
// When the primary HRRR base is a LAN caching proxy, an outage on
// that box would otherwise stall every hourly cycle. The fallback
// defaults to the NOAA S3 public bucket so the pipeline degrades
// gracefully instead.
let hrrr_fallback = std::env::var("HRRR_FALLBACK_BASE_URL")
.unwrap_or_else(|_| prop_grid_rs::fetcher::HRRR_BASE_DEFAULT.to_string());
let parallelism: usize = std::env::var("PROP_GRID_RS_PARALLELISM") let parallelism: usize = std::env::var("PROP_GRID_RS_PARALLELISM")
.ok() .ok()
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
@ -52,7 +58,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.unwrap_or((parallelism as u32) + 2); .unwrap_or((parallelism as u32) + 2);
let pool = db::connect(&database_url, max_conn).await?; let pool = db::connect(&database_url, max_conn).await?;
let client = Arc::new(HrrrClient::new(hrrr_base)?); let client = Arc::new(HrrrClient::new_with_fallback(&hrrr_base, &hrrr_fallback)?);
let shutdown = Arc::new(AtomicBool::new(false)); let shutdown = Arc::new(AtomicBool::new(false));
spawn_signal_handler(shutdown.clone()); spawn_signal_handler(shutdown.clone());

View file

@ -84,7 +84,16 @@ pub fn hrrr_url(
Product::Surface => format!("hrrr.t{hh}z.wrfsfcf{fh}.grib2"), Product::Surface => format!("hrrr.t{hh}z.wrfsfcf{fh}.grib2"),
Product::Pressure => format!("hrrr.t{hh}z.wrfprsf{fh}.grib2"), Product::Pressure => format!("hrrr.t{hh}z.wrfprsf{fh}.grib2"),
}; };
format!("{base}/hrrr.{date_str}/conus/{file}") let trimmed = base.trim_end_matches('/');
format!("{trimmed}/hrrr.{date_str}/conus/{file}")
}
pub fn hrrr_native_url(base: &str, date: NaiveDate, hour: u8, forecast_hour: u8) -> String {
let date_str = date.format("%Y%m%d").to_string();
let hh = format!("{hour:02}");
let fh = format!("{forecast_hour:02}");
let trimmed = base.trim_end_matches('/');
format!("{trimmed}/hrrr.{date_str}/conus/hrrr.t{hh}z.wrfnatf{fh}.grib2")
} }
pub fn parse_idx(text: &str) -> Vec<IdxEntry> { pub fn parse_idx(text: &str) -> Vec<IdxEntry> {
@ -301,25 +310,40 @@ impl IdxCache {
pub struct HrrrClient { pub struct HrrrClient {
http: reqwest::Client, http: reqwest::Client,
base: String, base: String,
// When the primary base fails every retry (e.g. an on-cluster
// caching proxy is unreachable), fetches retry once against this
// base. Production sets it to the NOAA S3 bucket so a proxy outage
// degrades to direct S3 reads instead of a hung pipeline.
fallback_base: Option<String>,
idx_cache: IdxCache, idx_cache: IdxCache,
} }
impl HrrrClient { impl HrrrClient {
pub fn new(base: impl Into<String>) -> Result<Self, FetchError> { pub fn new(base: impl Into<String>) -> Result<Self, FetchError> {
Self::build(base.into(), None)
}
pub fn new_with_fallback(
base: impl Into<String>,
fallback: impl Into<String>,
) -> Result<Self, FetchError> {
Self::build(base.into(), Some(fallback.into()))
}
fn build(base: String, fallback: Option<String>) -> Result<Self, FetchError> {
let http = reqwest::Client::builder() let http = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT) .timeout(REQUEST_TIMEOUT)
.pool_max_idle_per_host(MAX_PARALLEL_RANGES) .pool_max_idle_per_host(MAX_PARALLEL_RANGES)
.build()?; .build()?;
// HRRR_IDX_CACHE_DIR opts into a disk-backed idx cache shared
// across replicas via NFS (e.g. `/data/hrrr_idx`). Unset leaves
// the in-memory-only behaviour intact.
let idx_cache = match std::env::var("HRRR_IDX_CACHE_DIR") { let idx_cache = match std::env::var("HRRR_IDX_CACHE_DIR") {
Ok(dir) if !dir.is_empty() => IdxCache::with_disk(dir), Ok(dir) if !dir.is_empty() => IdxCache::with_disk(dir),
_ => IdxCache::new(), _ => IdxCache::new(),
}; };
let fallback = fallback.filter(|f| f != &base);
Ok(Self { Ok(Self {
http, http,
base: base.into(), base,
fallback_base: fallback,
idx_cache, idx_cache,
}) })
} }
@ -332,6 +356,10 @@ impl HrrrClient {
&self.base &self.base
} }
pub fn fallback_base(&self) -> Option<&str> {
self.fallback_base.as_deref()
}
pub async fn fetch_idx(&self, url: &str) -> Result<String, FetchError> { pub async fn fetch_idx(&self, url: &str) -> Result<String, FetchError> {
if let Some(body) = self.idx_cache.get(url) { if let Some(body) = self.idx_cache.get(url) {
return Ok(body); return Ok(body);
@ -383,6 +411,8 @@ impl HrrrClient {
} }
/// End-to-end: fetch idx, compute ranges for `wanted`, download grib. /// End-to-end: fetch idx, compute ranges for `wanted`, download grib.
/// Tries the primary base first, then the fallback base (if configured)
/// when the primary exhausts its retries.
pub async fn fetch_product_blob( pub async fn fetch_product_blob(
&self, &self,
date: NaiveDate, date: NaiveDate,
@ -391,12 +421,67 @@ impl HrrrClient {
forecast_hour: u8, forecast_hour: u8,
wanted: &[(String, String)], wanted: &[(String, String)],
) -> Result<Vec<u8>, FetchError> { ) -> Result<Vec<u8>, FetchError> {
let url = hrrr_url(&self.base, date, hour, product, forecast_hour); let primary = hrrr_url(&self.base, date, hour, product, forecast_hour);
let fallback = self
.fallback_base
.as_deref()
.map(|fb| hrrr_url(fb, date, hour, product, forecast_hour));
self.fetch_blob_with_fallback(&primary, fallback.as_deref(), wanted)
.await
}
/// End-to-end native-level fetch (hrrr.tHHz.wrfnatfFH.grib2) with the
/// same primary-then-fallback logic as `fetch_product_blob`. Callers
/// (native_duct) supply the duct-variable `wanted` list.
pub async fn fetch_native_blob(
&self,
date: NaiveDate,
hour: u8,
forecast_hour: u8,
wanted: &[(String, String)],
) -> Result<Vec<u8>, FetchError> {
let primary = hrrr_native_url(&self.base, date, hour, forecast_hour);
let fallback = self
.fallback_base
.as_deref()
.map(|fb| hrrr_native_url(fb, date, hour, forecast_hour));
self.fetch_blob_with_fallback(&primary, fallback.as_deref(), wanted)
.await
}
async fn fetch_blob_with_fallback(
&self,
primary_url: &str,
fallback_url: Option<&str>,
wanted: &[(String, String)],
) -> Result<Vec<u8>, FetchError> {
match self.fetch_blob_at(primary_url, wanted).await {
Ok(blob) => Ok(blob),
Err(primary_err) => match fallback_url {
Some(fb) => {
tracing::warn!(
error = %primary_err,
primary = primary_url,
fallback = fb,
"hrrr primary failed; retrying against fallback"
);
self.fetch_blob_at(fb, wanted).await
}
None => Err(primary_err),
},
}
}
async fn fetch_blob_at(
&self,
url: &str,
wanted: &[(String, String)],
) -> Result<Vec<u8>, FetchError> {
let idx_url = format!("{url}.idx"); let idx_url = format!("{url}.idx");
let idx_body = self.fetch_idx(&idx_url).await?; let idx_body = self.fetch_idx(&idx_url).await?;
let entries = parse_idx(&idx_body); let entries = parse_idx(&idx_body);
let ranges = byte_ranges_for_messages(&entries, wanted); let ranges = byte_ranges_for_messages(&entries, wanted);
self.download_grib_ranges(&url, ranges).await self.download_grib_ranges(url, ranges).await
} }
} }
@ -489,6 +574,51 @@ mod tests {
); );
} }
#[test]
fn hrrr_url_trims_trailing_slash() {
let d = NaiveDate::from_ymd_opt(2026, 4, 19).unwrap();
assert_eq!(
hrrr_url("https://x.y/", d, 14, Product::Surface, 0),
"https://x.y/hrrr.20260419/conus/hrrr.t14z.wrfsfcf00.grib2"
);
}
#[test]
fn hrrr_native_url_format() {
let d = NaiveDate::from_ymd_opt(2026, 4, 19).unwrap();
assert_eq!(
hrrr_native_url("https://x.y", d, 14, 0),
"https://x.y/hrrr.20260419/conus/hrrr.t14z.wrfnatf00.grib2"
);
assert_eq!(
hrrr_native_url("https://x.y/", d, 4, 7),
"https://x.y/hrrr.20260419/conus/hrrr.t04z.wrfnatf07.grib2"
);
}
#[test]
fn new_stores_no_fallback() {
let c = HrrrClient::new("https://primary").unwrap();
assert_eq!(c.base(), "https://primary");
assert!(c.fallback_base().is_none());
}
#[test]
fn new_with_fallback_stores_fallback() {
let c = HrrrClient::new_with_fallback("http://primary", "https://fallback").unwrap();
assert_eq!(c.base(), "http://primary");
assert_eq!(c.fallback_base(), Some("https://fallback"));
}
#[test]
fn fallback_equal_to_primary_is_dropped() {
// Pointing fallback at the same base adds no resilience and
// would double the wait on every failure. Treat it as "no
// fallback" so retries stop after the primary exhausts.
let c = HrrrClient::new_with_fallback("https://same", "https://same").unwrap();
assert!(c.fallback_base().is_none());
}
#[test] #[test]
fn idx_parsing_drops_bad_lines() { fn idx_parsing_drops_bad_lines() {
let idx = "1:0:d=2024:TMP:2 m above ground:anl:\n<html>bad</html>\n2:12345:d=2024:DPT:2 m above ground:anl:"; let idx = "1:0:d=2024:TMP:2 m above ground:anl:\n<html>bad</html>\n2:12345:d=2024:DPT:2 m above ground:anl:";

View file

@ -20,7 +20,7 @@ use chrono::NaiveDate;
use crate::decoder::{self, CellValues, DecodeError, PointGrid}; use crate::decoder::{self, CellValues, DecodeError, PointGrid};
use crate::duct::{self, NativeProfile}; use crate::duct::{self, NativeProfile};
use crate::fetcher::{self, byte_ranges_for_messages, parse_idx, HrrrClient}; use crate::fetcher::{self, HrrrClient};
use crate::grid::GridSpec; use crate::grid::GridSpec;
/// The 4 variables needed for duct detection. UGRD/VGRD/TKE were /// The 4 variables needed for duct detection. UGRD/VGRD/TKE were
@ -66,27 +66,9 @@ pub fn match_pattern() -> String {
format!(":({}):.*hybrid level:", DUCT_VARIABLES.join("|")) format!(":({}):.*hybrid level:", DUCT_VARIABLES.join("|"))
} }
/// URL builder for native-level HRRR files. /// URL builder for native-level HRRR files. Re-export of
/// /// `fetcher::hrrr_native_url` kept for external callers.
/// ``` pub use crate::fetcher::hrrr_native_url;
/// use chrono::NaiveDate;
/// use prop_grid_rs::native_duct::hrrr_native_url;
/// assert_eq!(
/// hrrr_native_url("https://example", NaiveDate::from_ymd_opt(2026, 4, 19).unwrap(), 14, 0),
/// "https://example/hrrr.20260419/conus/hrrr.t14z.wrfnatf00.grib2"
/// );
/// ```
pub fn hrrr_native_url(base: &str, date: NaiveDate, hour: u8, forecast_hour: u8) -> String {
format!(
"{}/hrrr.{:04}{:02}{:02}/conus/hrrr.t{:02}z.wrfnatf{:02}.grib2",
base.trim_end_matches('/'),
date.format("%Y").to_string().parse::<u32>().unwrap_or(0),
date.format("%m").to_string().parse::<u32>().unwrap_or(0),
date.format("%d").to_string().parse::<u32>().unwrap_or(0),
hour,
forecast_hour
)
}
/// Fetch the native-level duct grid for a (date, hour, forecast_hour) /// Fetch the native-level duct grid for a (date, hour, forecast_hour)
/// triple. Downloads the byte-range subset, decodes via wgrib2, and /// triple. Downloads the byte-range subset, decodes via wgrib2, and
@ -98,13 +80,10 @@ pub async fn fetch_native_duct_grid(
forecast_hour: u8, forecast_hour: u8,
grid_spec: GridSpec, grid_spec: GridSpec,
) -> Result<HashMap<(i32, i32), DuctMetrics>, NativeDuctError> { ) -> Result<HashMap<(i32, i32), DuctMetrics>, NativeDuctError> {
let url = hrrr_native_url(client.base(), date, hour, forecast_hour);
let idx_url = format!("{url}.idx");
let idx_body = client.fetch_idx(&idx_url).await?;
let entries = parse_idx(&idx_body);
let messages = duct_messages(); let messages = duct_messages();
let ranges = byte_ranges_for_messages(&entries, &messages); let blob = client
let blob = client.download_grib_ranges(&url, ranges).await?; .fetch_native_blob(date, hour, forecast_hour, &messages)
.await?;
// wgrib2 decode must run on the blocking pool — it forks a // wgrib2 decode must run on the blocking pool — it forks a
// subprocess and the stdout read is synchronous IO. // subprocess and the stdout read is synchronous IO.
@ -239,19 +218,6 @@ mod tests {
assert_eq!(p, ":(TMP|SPFH|HGT|PRES):.*hybrid level:"); assert_eq!(p, ":(TMP|SPFH|HGT|PRES):.*hybrid level:");
} }
#[test]
fn hrrr_native_url_format() {
let d = NaiveDate::from_ymd_opt(2026, 4, 19).unwrap();
assert_eq!(
hrrr_native_url("https://x.y", d, 14, 0),
"https://x.y/hrrr.20260419/conus/hrrr.t14z.wrfnatf00.grib2"
);
assert_eq!(
hrrr_native_url("https://x.y/", d, 4, 7),
"https://x.y/hrrr.20260419/conus/hrrr.t04z.wrfnatf07.grib2"
);
}
fn cell_with_levels(count: usize) -> CellValues { fn cell_with_levels(count: usize) -> CellValues {
let mut c = CellValues::new(); let mut c = CellValues::new();
for i in 1..=count { for i in 1..=count {