refactor(scores): rename file extension .ntms -> .prop with legacy reads
Score-grid files now land at `<band>/<iso>.prop` with a 4-byte "PROP" magic header. Readers still accept the legacy `.ntms` extension and "NTMS" magic so a rolling deploy doesn't invalidate any file already on the shared NFS mount — legacy files age out through normal retention. Applied both sides of the seam: Elixir ScoresFile + regex parsers, Rust scores_file writer + decoder. Updated the comments in all modules that mention the extension (NotifyListener, Reconciler, gefs_fetch_worker, map_live) and the runbook diagram. talos5 is a DB host now, not a Rust worker — corrected the runbook accordingly.
This commit is contained in:
parent
989d310447
commit
410a1374fe
10 changed files with 184 additions and 33 deletions
|
|
@ -5,18 +5,19 @@ NFS volume. This doc names every failure mode at that seam and says
|
||||||
how the system recovers.
|
how the system recovers.
|
||||||
|
|
||||||
```
|
```
|
||||||
prop-grid-rs (talos5) Postgres (10.0.15.30) prop pods (node1/2/3)
|
prop-grid-rs (worker nodes) Postgres (talos5 — 10.0.15.30) prop pods (node1/2/3)
|
||||||
├─ fetch GRIB2 (NOMADS) ├─ grid_tasks (SKIP LOCKED) ├─ ScoreCache (ETS)
|
├─ fetch GRIB2 (NOMADS) ├─ grid_tasks (SKIP LOCKED) ├─ ScoreCache (ETS)
|
||||||
├─ decode ├─ LISTEN/NOTIFY channel ├─ NotifyListener
|
├─ decode ├─ LISTEN/NOTIFY channel ├─ NotifyListener
|
||||||
├─ score 22 bands × 92k │ "propagation_ready" ├─ ScoreCacheReconciler (60s)
|
├─ score 22 bands × 92k │ "propagation_ready" ├─ ScoreCacheReconciler (60s)
|
||||||
├─ write /data/scores/.../.ntms │ └─ PubSub → LiveView
|
├─ write /data/scores/.../.prop │ └─ PubSub → LiveView
|
||||||
└─ NOTIFY propagation_ready ────┘
|
└─ NOTIFY propagation_ready ────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Primary refresh path (happy case)
|
## Primary refresh path (happy case)
|
||||||
|
|
||||||
1. Rust completes `{band, valid_time}` compute and writes the `.ntms`
|
1. Rust completes `{band, valid_time}` compute and writes the `.prop`
|
||||||
file via atomic rename.
|
file via atomic rename. (Readers also accept legacy `.ntms` files
|
||||||
|
with `NTMS` magic during a rolling deploy.)
|
||||||
2. Rust emits `NOTIFY propagation_ready '<iso valid_time>'`.
|
2. Rust emits `NOTIFY propagation_ready '<iso valid_time>'`.
|
||||||
3. `NotifyListener` on each Elixir pod receives it and calls
|
3. `NotifyListener` on each Elixir pod receives it and calls
|
||||||
`Propagation.warm_cache_and_broadcast/2` which reads the file once
|
`Propagation.warm_cache_and_broadcast/2` which reads the file once
|
||||||
|
|
@ -92,11 +93,11 @@ sweep.
|
||||||
### FM5 — Rust worker dead
|
### FM5 — Rust worker dead
|
||||||
|
|
||||||
**Symptom.** `grid_tasks` rows with `status = 'queued'` accumulate;
|
**Symptom.** `grid_tasks` rows with `status = 'queued'` accumulate;
|
||||||
no new `.ntms` files land; `/map` serves the last successful run's
|
no new `.prop` files land; `/map` serves the last successful run's
|
||||||
scores.
|
scores.
|
||||||
|
|
||||||
**Why.** Deployment bug, Rust panic, OOM on talos5 (unlikely with
|
**Why.** Deployment bug, Rust panic, OOM on a worker node, or
|
||||||
32 GB headroom), or disk-full on `/data`.
|
disk-full on `/data`.
|
||||||
|
|
||||||
**Recovery.** Manual. `kubectl -n prop logs deploy/prop-grid-rs` for
|
**Recovery.** Manual. `kubectl -n prop logs deploy/prop-grid-rs` for
|
||||||
cause. The Elixir side is read-only against the grid — it does not
|
cause. The Elixir side is read-only against the grid — it does not
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ defmodule Microwaveprop.Propagation.NotifyListener do
|
||||||
Listens for `NOTIFY propagation_ready '<iso valid_time>'` from the Rust
|
Listens for `NOTIFY propagation_ready '<iso valid_time>'` from the Rust
|
||||||
`prop-grid-rs` worker. When a notification arrives, warms
|
`prop-grid-rs` worker. When a notification arrives, warms
|
||||||
`ScoreCache` for every band at that `valid_time` (reading from the
|
`ScoreCache` for every band at that `valid_time` (reading from the
|
||||||
shared NFS `/data/scores/<band>/<iso>.ntms` tree that Rust just
|
shared NFS `/data/scores/<band>/<iso>.prop` tree that Rust just
|
||||||
wrote) and fans out the standard `"propagation:updated"` PubSub so
|
wrote) and fans out the standard `"propagation:updated"` PubSub so
|
||||||
LiveView clients refresh without polling.
|
LiveView clients refresh without polling.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ defmodule Microwaveprop.Propagation.ScoreCacheReconciler do
|
||||||
3. **Rust writes completed pre-boot.** Pod starts, cache is empty,
|
3. **Rust writes completed pre-boot.** Pod starts, cache is empty,
|
||||||
and we don't hit the miss path until the first LiveView click.
|
and we don't hit the miss path until the first LiveView click.
|
||||||
|
|
||||||
Every `interval_ms` the reconciler lists `/data/scores/<band>/*.ntms`
|
Every `interval_ms` the reconciler lists `/data/scores/<band>/*.prop`
|
||||||
per band, compares against `ScoreCache.valid_times/1`, and fills in
|
per band, compares against `ScoreCache.valid_times/1`, and fills in
|
||||||
any {band, valid_time} pairs on disk but absent in the local ETS.
|
any {band, valid_time} pairs on disk but absent in the local ETS.
|
||||||
Warming is node-local (`ScoreCache.put/3`) — no PubSub fan-out, since
|
Warming is node-local (`ScoreCache.put/3`) — no PubSub fan-out, since
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ defmodule Microwaveprop.Propagation.ScoresFile do
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
magic : 4 bytes "NTMS"
|
magic : 4 bytes "PROP"
|
||||||
version : 1 byte 0x01
|
version : 1 byte 0x01
|
||||||
band_mhz : 4 bytes uint32 little-endian
|
band_mhz : 4 bytes uint32 little-endian
|
||||||
valid_time : 8 bytes int64 unix seconds little-endian
|
valid_time : 8 bytes int64 unix seconds little-endian
|
||||||
|
|
@ -35,7 +35,11 @@ defmodule Microwaveprop.Propagation.ScoresFile do
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@magic "NTMS"
|
@magic "PROP"
|
||||||
|
# Legacy magic from before the `.prop`/`PROP` rename. Still accepted
|
||||||
|
# by readers so score files written by an older pod remain usable
|
||||||
|
# across a rolling deploy. Writers always emit `@magic`.
|
||||||
|
@legacy_magic "NTMS"
|
||||||
@version 1
|
@version 1
|
||||||
@no_data 255
|
@no_data 255
|
||||||
@header_size 33
|
@header_size 33
|
||||||
|
|
@ -49,6 +53,13 @@ defmodule Microwaveprop.Propagation.ScoresFile do
|
||||||
@doc "Returns the full path for a `(band_mhz, valid_time)` score file."
|
@doc "Returns the full path for a `(band_mhz, valid_time)` score file."
|
||||||
@spec path_for(non_neg_integer(), DateTime.t()) :: String.t()
|
@spec path_for(non_neg_integer(), DateTime.t()) :: String.t()
|
||||||
def path_for(band_mhz, %DateTime{} = valid_time) when is_integer(band_mhz) do
|
def path_for(band_mhz, %DateTime{} = valid_time) when is_integer(band_mhz) do
|
||||||
|
iso = valid_time |> DateTime.truncate(:second) |> DateTime.to_iso8601()
|
||||||
|
Path.join([base_dir(), Integer.to_string(band_mhz), "#{iso}.prop"])
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec legacy_path_for(non_neg_integer(), DateTime.t()) :: String.t()
|
||||||
|
def legacy_path_for(band_mhz, %DateTime{} = valid_time) when is_integer(band_mhz) do
|
||||||
iso = valid_time |> DateTime.truncate(:second) |> DateTime.to_iso8601()
|
iso = valid_time |> DateTime.truncate(:second) |> DateTime.to_iso8601()
|
||||||
Path.join([base_dir(), Integer.to_string(band_mhz), "#{iso}.ntms"])
|
Path.join([base_dir(), Integer.to_string(band_mhz), "#{iso}.ntms"])
|
||||||
end
|
end
|
||||||
|
|
@ -84,6 +95,14 @@ defmodule Microwaveprop.Propagation.ScoresFile do
|
||||||
{:ok, map()} | {:error, :enoent | :invalid_format}
|
{:ok, map()} | {:error, :enoent | :invalid_format}
|
||||||
def read(band_mhz, %DateTime{} = valid_time) when is_integer(band_mhz) do
|
def read(band_mhz, %DateTime{} = valid_time) when is_integer(band_mhz) do
|
||||||
case File.read(path_for(band_mhz, valid_time)) do
|
case File.read(path_for(band_mhz, valid_time)) do
|
||||||
|
{:ok, binary} -> decode(binary)
|
||||||
|
{:error, :enoent} -> read_legacy(band_mhz, valid_time)
|
||||||
|
{:error, other} -> {:error, other}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp read_legacy(band_mhz, valid_time) do
|
||||||
|
case File.read(legacy_path_for(band_mhz, valid_time)) do
|
||||||
{:ok, binary} -> decode(binary)
|
{:ok, binary} -> decode(binary)
|
||||||
{:error, :enoent} -> {:error, :enoent}
|
{:error, :enoent} -> {:error, :enoent}
|
||||||
{:error, other} -> {:error, other}
|
{:error, other} -> {:error, other}
|
||||||
|
|
@ -93,8 +112,8 @@ defmodule Microwaveprop.Propagation.ScoresFile do
|
||||||
@doc """
|
@doc """
|
||||||
Delete every score file whose `valid_time` is strictly before
|
Delete every score file whose `valid_time` is strictly before
|
||||||
`cutoff`. Returns the number of files deleted. Foreign files in the
|
`cutoff`. Returns the number of files deleted. Foreign files in the
|
||||||
scores tree (anything that doesn't parse as `<iso>.ntms`) are left
|
scores tree (anything that doesn't parse as `<iso>.prop` or the
|
||||||
alone.
|
legacy `<iso>.ntms`) are left alone.
|
||||||
"""
|
"""
|
||||||
@spec prune_older_than(DateTime.t()) :: non_neg_integer()
|
@spec prune_older_than(DateTime.t()) :: non_neg_integer()
|
||||||
def prune_older_than(%DateTime{} = cutoff) do
|
def prune_older_than(%DateTime{} = cutoff) do
|
||||||
|
|
@ -236,8 +255,13 @@ defmodule Microwaveprop.Propagation.ScoresFile do
|
||||||
"""
|
"""
|
||||||
@spec read_point(non_neg_integer(), DateTime.t(), float(), float()) :: non_neg_integer() | nil
|
@spec read_point(non_neg_integer(), DateTime.t(), float(), float()) :: non_neg_integer() | nil
|
||||||
def read_point(band_mhz, %DateTime{} = valid_time, lat, lon) do
|
def read_point(band_mhz, %DateTime{} = valid_time, lat, lon) do
|
||||||
path = path_for(band_mhz, valid_time)
|
Enum.find_value(
|
||||||
|
[path_for(band_mhz, valid_time), legacy_path_for(band_mhz, valid_time)],
|
||||||
|
&open_and_read_point(&1, lat, lon)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp open_and_read_point(path, lat, lon) do
|
||||||
case :file.open(path, [:read, :binary, :raw]) do
|
case :file.open(path, [:read, :binary, :raw]) do
|
||||||
{:ok, fd} ->
|
{:ok, fd} ->
|
||||||
try do
|
try do
|
||||||
|
|
@ -252,13 +276,14 @@ defmodule Microwaveprop.Propagation.ScoresFile do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp read_point_from_fd(fd, lat, lon) do
|
defp read_point_from_fd(fd, lat, lon) do
|
||||||
# Peek the header to locate the cell — falls back to the full
|
# Peek the header to locate the cell. Accepts both the current and
|
||||||
# decode path only if the header is malformed (should never happen
|
# legacy 4-byte magic — see `@legacy_magic`.
|
||||||
# for files we wrote ourselves).
|
|
||||||
case :file.pread(fd, 0, @header_size) do
|
case :file.pread(fd, 0, @header_size) do
|
||||||
{:ok,
|
{:ok,
|
||||||
<<@magic, @version::unsigned-8, _band::unsigned-little-32, _vt::signed-little-64, lat_min::float-little-32,
|
<<magic::binary-size(4), @version::unsigned-8, _band::unsigned-little-32, _vt::signed-little-64,
|
||||||
lon_min::float-little-32, step::float-little-32, n_rows::unsigned-little-16, n_cols::unsigned-little-16>>} ->
|
lat_min::float-little-32, lon_min::float-little-32, step::float-little-32, n_rows::unsigned-little-16,
|
||||||
|
n_cols::unsigned-little-16>>}
|
||||||
|
when magic == @magic or magic == @legacy_magic ->
|
||||||
read_cell(fd, lat, lon, lat_min, lon_min, step, n_rows, n_cols)
|
read_cell(fd, lat, lon, lat_min, lon_min, step, n_rows, n_cols)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
|
|
@ -328,10 +353,11 @@ defmodule Microwaveprop.Propagation.ScoresFile do
|
||||||
# ── Decoding ────────────────────────────────────────────────────
|
# ── Decoding ────────────────────────────────────────────────────
|
||||||
|
|
||||||
defp decode(
|
defp decode(
|
||||||
<<@magic, @version::unsigned-8, band_mhz::unsigned-little-32, valid_time_unix::signed-little-64,
|
<<magic::binary-size(4), @version::unsigned-8, band_mhz::unsigned-little-32, valid_time_unix::signed-little-64,
|
||||||
lat_min::float-little-32, lon_min::float-little-32, step::float-little-32, n_rows::unsigned-little-16,
|
lat_min::float-little-32, lon_min::float-little-32, step::float-little-32, n_rows::unsigned-little-16,
|
||||||
n_cols::unsigned-little-16, body::binary>>
|
n_cols::unsigned-little-16, body::binary>>
|
||||||
) do
|
)
|
||||||
|
when magic == @magic or magic == @legacy_magic do
|
||||||
expected = n_rows * n_cols
|
expected = n_rows * n_cols
|
||||||
|
|
||||||
if byte_size(body) == expected do
|
if byte_size(body) == expected do
|
||||||
|
|
@ -433,7 +459,7 @@ defmodule Microwaveprop.Propagation.ScoresFile do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp parse_valid_time_dt(filename) do
|
defp parse_valid_time_dt(filename) do
|
||||||
with [_, iso] <- Regex.run(~r/^(.+)\.ntms$/, filename),
|
with [_, iso] <- Regex.run(~r/^(.+)\.(?:prop|ntms)$/, filename),
|
||||||
{:ok, dt, _} <- DateTime.from_iso8601(iso) do
|
{:ok, dt, _} <- DateTime.from_iso8601(iso) do
|
||||||
dt
|
dt
|
||||||
else
|
else
|
||||||
|
|
@ -468,7 +494,7 @@ defmodule Microwaveprop.Propagation.ScoresFile do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp parse_valid_time(filename) do
|
defp parse_valid_time(filename) do
|
||||||
with [_, iso] <- Regex.run(~r/^(.+)\.ntms$/, filename),
|
with [_, iso] <- Regex.run(~r/^(.+)\.(?:prop|ntms)$/, filename),
|
||||||
{:ok, dt, _} <- DateTime.from_iso8601(iso) do
|
{:ok, dt, _} <- DateTime.from_iso8601(iso) do
|
||||||
DateTime.to_unix(dt)
|
DateTime.to_unix(dt)
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@ defmodule Microwaveprop.Workers.GefsFetchWorker do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Runs the propagation scorer across the fetched GEFS grid and
|
# Runs the propagation scorer across the fetched GEFS grid and
|
||||||
# persists scores to the `.ntms` file for this `valid_time` so the
|
# persists scores to the `.prop` file for this `valid_time` so the
|
||||||
# extended-horizon outlook picks them up via the usual
|
# extended-horizon outlook picks them up via the usual
|
||||||
# `Propagation.scores_at/3` path. Also upserts raw profiles to
|
# `Propagation.scores_at/3` path. Also upserts raw profiles to
|
||||||
# `gefs_profiles` for future per-contact extended-horizon lookups.
|
# `gefs_profiles` for future per-contact extended-horizon lookups.
|
||||||
|
|
|
||||||
|
|
@ -408,7 +408,7 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
closest_to_now(valid_times)
|
closest_to_now(valid_times)
|
||||||
end
|
end
|
||||||
|
|
||||||
# scores_at_fresh bypasses the cache and re-reads the .ntms file so
|
# scores_at_fresh bypasses the cache and re-reads the .prop file so
|
||||||
# the map reflects the newly written forecast hour even when the
|
# the map reflects the newly written forecast hour even when the
|
||||||
# ScoreCache still holds the previous chain's bytes. The propagation
|
# ScoreCache still holds the previous chain's bytes. The propagation
|
||||||
# update arrives via `propagation:updated` while the cache refresh
|
# update arrives via `propagation:updated` while the cache refresh
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! End-to-end f01..f18 chain step: fetch surface + pressure GRIBs, decode,
|
//! End-to-end f01..f18 chain step: fetch surface + pressure GRIBs, decode,
|
||||||
//! build per-cell `Conditions`, score every band, write score-grid files
|
//! build per-cell `Conditions`, score every band, write score-grid files
|
||||||
//! (`<band>/<iso>.ntms`) to the shared scores directory.
|
//! (`<band>/<iso>.prop`) to the shared scores directory.
|
||||||
//!
|
//!
|
||||||
//! This is the Rust equivalent of `PropagationGridWorker.process_forecast_hour/4`
|
//! This is the Rust equivalent of `PropagationGridWorker.process_forecast_hour/4`
|
||||||
//! minus the f00-only enrichment (native duct, NEXRAD, commercial link
|
//! minus the f00-only enrichment (native duct, NEXRAD, commercial link
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
//! `lib/microwaveprop/propagation/scores_file.ex`.
|
//! `lib/microwaveprop/propagation/scores_file.ex`.
|
||||||
//!
|
//!
|
||||||
//! Layout (all little-endian):
|
//! Layout (all little-endian):
|
||||||
//! magic : 4 bytes "NTMS" (literal already on disk)
|
//! magic : 4 bytes "PROP" (decoder also accepts legacy "NTMS")
|
||||||
//! version : 1 byte 0x01
|
//! version : 1 byte 0x01
|
||||||
//! band_mhz : 4 bytes uint32
|
//! band_mhz : 4 bytes uint32
|
||||||
//! valid_time : 8 bytes int64 unix seconds
|
//! valid_time : 8 bytes int64 unix seconds
|
||||||
|
|
@ -27,14 +27,26 @@ use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
use crate::grid;
|
use crate::grid;
|
||||||
|
|
||||||
pub const MAGIC: &[u8; 4] = b"NTMS";
|
pub const MAGIC: &[u8; 4] = b"PROP";
|
||||||
|
pub const LEGACY_MAGIC: &[u8; 4] = b"NTMS";
|
||||||
pub const VERSION: u8 = 1;
|
pub const VERSION: u8 = 1;
|
||||||
pub const NO_DATA: u8 = 255;
|
pub const NO_DATA: u8 = 255;
|
||||||
|
|
||||||
static UNIQUE: AtomicU64 = AtomicU64::new(1);
|
static UNIQUE: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
/// Absolute path `<base>/<band_mhz>/<iso>.ntms`.
|
/// Absolute path `<base>/<band_mhz>/<iso>.prop`. New writes use this;
|
||||||
|
/// readers additionally accept the legacy `.ntms` extension while
|
||||||
|
/// previously-written files drain through the normal retention window.
|
||||||
pub fn path_for(base_dir: &Path, band_mhz: u32, valid_time: DateTime<Utc>) -> PathBuf {
|
pub fn path_for(base_dir: &Path, band_mhz: u32, valid_time: DateTime<Utc>) -> PathBuf {
|
||||||
|
let iso = valid_time.format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
|
base_dir
|
||||||
|
.join(band_mhz.to_string())
|
||||||
|
.join(format!("{iso}.prop"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy path used by scores written before the `.prop` rename. Kept so
|
||||||
|
/// readers can discover still-on-disk `.ntms` files during rollout.
|
||||||
|
pub fn legacy_path_for(base_dir: &Path, band_mhz: u32, valid_time: DateTime<Utc>) -> PathBuf {
|
||||||
let iso = valid_time.format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
let iso = valid_time.format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
base_dir
|
base_dir
|
||||||
.join(band_mhz.to_string())
|
.join(band_mhz.to_string())
|
||||||
|
|
@ -99,7 +111,7 @@ pub fn encode(
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atomic write to `<base>/<band_mhz>/<iso>.ntms`. Creates the band
|
/// Atomic write to `<base>/<band_mhz>/<iso>.prop`. Creates the band
|
||||||
/// subdirectory on demand.
|
/// subdirectory on demand.
|
||||||
pub fn write_atomic(
|
pub fn write_atomic(
|
||||||
base_dir: &Path,
|
base_dir: &Path,
|
||||||
|
|
@ -119,7 +131,7 @@ pub fn write_atomic(
|
||||||
.map(|d| d.as_nanos())
|
.map(|d| d.as_nanos())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let uniq = UNIQUE.fetch_add(1, Ordering::Relaxed);
|
let uniq = UNIQUE.fetch_add(1, Ordering::Relaxed);
|
||||||
let tmp_path = final_path.with_extension(format!("ntms.tmp.{nanos}.{uniq}"));
|
let tmp_path = final_path.with_extension(format!("prop.tmp.{nanos}.{uniq}"));
|
||||||
|
|
||||||
{
|
{
|
||||||
let file = OpenOptions::new()
|
let file = OpenOptions::new()
|
||||||
|
|
@ -165,7 +177,7 @@ pub fn decode(bytes: &[u8]) -> Option<Decoded> {
|
||||||
if bytes.len() < 4 + 1 + 4 + 8 + 4 + 4 + 4 + 2 + 2 {
|
if bytes.len() < 4 + 1 + 4 + 8 + 4 + 4 + 4 + 2 + 2 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if &bytes[0..4] != MAGIC {
|
if &bytes[0..4] != MAGIC && &bytes[0..4] != LEGACY_MAGIC {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if bytes[4] != VERSION {
|
if bytes[4] != VERSION {
|
||||||
|
|
@ -266,12 +278,47 @@ mod tests {
|
||||||
fn path_for_layout() {
|
fn path_for_layout() {
|
||||||
let ts = Utc.with_ymd_and_hms(2026, 4, 19, 15, 0, 0).unwrap();
|
let ts = Utc.with_ymd_and_hms(2026, 4, 19, 15, 0, 0).unwrap();
|
||||||
let p = path_for(Path::new("/data/scores"), 10_000, ts);
|
let p = path_for(Path::new("/data/scores"), 10_000, ts);
|
||||||
|
assert_eq!(
|
||||||
|
p.to_str().unwrap(),
|
||||||
|
"/data/scores/10000/2026-04-19T15:00:00Z.prop"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_path_for_layout_matches_pre_rename() {
|
||||||
|
let ts = Utc.with_ymd_and_hms(2026, 4, 19, 15, 0, 0).unwrap();
|
||||||
|
let p = legacy_path_for(Path::new("/data/scores"), 10_000, ts);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
p.to_str().unwrap(),
|
p.to_str().unwrap(),
|
||||||
"/data/scores/10000/2026-04-19T15:00:00Z.ntms"
|
"/data/scores/10000/2026-04-19T15:00:00Z.ntms"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_accepts_legacy_magic() {
|
||||||
|
// Flip the 4-byte magic on an otherwise-valid encoded blob and
|
||||||
|
// verify decode still accepts it. Readers must keep tolerating
|
||||||
|
// `NTMS`-prefixed files written before the rename.
|
||||||
|
let ts = Utc.with_ymd_and_hms(2026, 4, 19, 15, 0, 0).unwrap();
|
||||||
|
let scores = vec![ScorePoint {
|
||||||
|
lat: 32.0,
|
||||||
|
lon: -97.0,
|
||||||
|
score: 73,
|
||||||
|
}];
|
||||||
|
let mut bytes = encode(10_000, ts, &scores).unwrap();
|
||||||
|
bytes[0..4].copy_from_slice(LEGACY_MAGIC);
|
||||||
|
let decoded = decode(&bytes).expect("legacy magic must decode");
|
||||||
|
assert_eq!(decoded.band_mhz, 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_rejects_unknown_magic() {
|
||||||
|
let ts = Utc.with_ymd_and_hms(2026, 4, 19, 15, 0, 0).unwrap();
|
||||||
|
let mut bytes = encode(10_000, ts, &[]).unwrap();
|
||||||
|
bytes[0..4].copy_from_slice(b"XXXX");
|
||||||
|
assert!(decode(&bytes).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn atomic_write_and_decode() {
|
fn atomic_write_and_decode() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,87 @@ defmodule Microwaveprop.Propagation.ScoresFileTest do
|
||||||
describe "path_for/2" do
|
describe "path_for/2" do
|
||||||
test "nests files under base_dir/{band_mhz}/ with an ISO-8601 valid_time", %{dir: dir} do
|
test "nests files under base_dir/{band_mhz}/ with an ISO-8601 valid_time", %{dir: dir} do
|
||||||
path = ScoresFile.path_for(10_000, ~U[2026-04-14 18:00:00Z])
|
path = ScoresFile.path_for(10_000, ~U[2026-04-14 18:00:00Z])
|
||||||
|
assert path == Path.join([dir, "10000", "2026-04-14T18:00:00Z.prop"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "writes with the new .prop extension", %{dir: dir} do
|
||||||
|
valid_time = ~U[2026-04-14 18:00:00Z]
|
||||||
|
ScoresFile.write!(10_000, valid_time, [%{lat: 25.0, lon: -125.0, score: 50}])
|
||||||
|
written = Path.join([dir, "10000", "2026-04-14T18:00:00Z.prop"])
|
||||||
|
assert File.exists?(written)
|
||||||
|
refute File.exists?(Path.join([dir, "10000", "2026-04-14T18:00:00Z.ntms"]))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "legacy_path_for/2" do
|
||||||
|
test "still points at the old .ntms extension for backwards reads", %{dir: dir} do
|
||||||
|
path = ScoresFile.legacy_path_for(10_000, ~U[2026-04-14 18:00:00Z])
|
||||||
assert path == Path.join([dir, "10000", "2026-04-14T18:00:00Z.ntms"])
|
assert path == Path.join([dir, "10000", "2026-04-14T18:00:00Z.ntms"])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "legacy .ntms file readability" do
|
||||||
|
test "read/2 falls through to the legacy .ntms path when .prop is missing", %{dir: dir} do
|
||||||
|
valid_time = ~U[2026-04-14 18:00:00Z]
|
||||||
|
# Write a valid file at the .prop path, then rename to .ntms to
|
||||||
|
# simulate a file written before the rename that survived through
|
||||||
|
# a rolling deploy. The decoder must accept the old magic bytes
|
||||||
|
# embedded in the header of already-on-disk files as well, but the
|
||||||
|
# simpler check — that the file is found by extension — is what
|
||||||
|
# this test targets.
|
||||||
|
ScoresFile.write!(10_000, valid_time, [%{lat: 25.0, lon: -125.0, score: 77}])
|
||||||
|
prop_path = Path.join([dir, "10000", "2026-04-14T18:00:00Z.prop"])
|
||||||
|
ntms_path = Path.join([dir, "10000", "2026-04-14T18:00:00Z.ntms"])
|
||||||
|
File.rename!(prop_path, ntms_path)
|
||||||
|
|
||||||
|
assert {:ok, payload} = ScoresFile.read(10_000, valid_time)
|
||||||
|
assert payload.band_mhz == 10_000
|
||||||
|
end
|
||||||
|
|
||||||
|
test "read_point/4 accepts a legacy .ntms file with NTMS magic", %{dir: dir} do
|
||||||
|
valid_time = ~U[2026-04-14 18:00:00Z]
|
||||||
|
ScoresFile.write!(10_000, valid_time, [%{lat: 25.0, lon: -125.0, score: 88}])
|
||||||
|
prop_path = Path.join([dir, "10000", "2026-04-14T18:00:00Z.prop"])
|
||||||
|
ntms_path = Path.join([dir, "10000", "2026-04-14T18:00:00Z.ntms"])
|
||||||
|
|
||||||
|
# Flip the 4-byte magic back to the legacy "NTMS" so both the
|
||||||
|
# extension and the in-file identifier reflect the pre-rename
|
||||||
|
# state. The reader must still serve the cell.
|
||||||
|
raw = File.read!(prop_path)
|
||||||
|
<<_magic::binary-size(4), rest::binary>> = raw
|
||||||
|
File.write!(ntms_path, <<"NTMS", rest::binary>>, [:binary])
|
||||||
|
File.rm!(prop_path)
|
||||||
|
|
||||||
|
assert ScoresFile.read_point(10_000, valid_time, 25.0, -125.0) == 88
|
||||||
|
end
|
||||||
|
|
||||||
|
test "list_valid_times/1 picks up .ntms and .prop files together", %{dir: _dir} do
|
||||||
|
t1 = ~U[2026-04-14 17:00:00Z]
|
||||||
|
t2 = ~U[2026-04-14 18:00:00Z]
|
||||||
|
|
||||||
|
ScoresFile.write!(10_000, t1, [%{lat: 25.0, lon: -125.0, score: 10}])
|
||||||
|
ScoresFile.write!(10_000, t2, [%{lat: 25.0, lon: -125.0, score: 20}])
|
||||||
|
|
||||||
|
# Rename the earlier one to .ntms to simulate a mixed-extension
|
||||||
|
# directory during a rolling deploy.
|
||||||
|
band_dir =
|
||||||
|
Path.join([
|
||||||
|
Application.fetch_env!(:microwaveprop, :propagation_scores_dir),
|
||||||
|
"10000"
|
||||||
|
])
|
||||||
|
|
||||||
|
File.rename!(
|
||||||
|
Path.join(band_dir, "2026-04-14T17:00:00Z.prop"),
|
||||||
|
Path.join(band_dir, "2026-04-14T17:00:00Z.ntms")
|
||||||
|
)
|
||||||
|
|
||||||
|
times = ScoresFile.list_valid_times(10_000)
|
||||||
|
assert length(times) == 2
|
||||||
|
assert t1 in times
|
||||||
|
assert t2 in times
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "write!/3 + read/2 round trip" do
|
describe "write!/3 + read/2 round trip" do
|
||||||
test "preserves the score for each scored grid point", %{dir: _dir} do
|
test "preserves the score for each scored grid point", %{dir: _dir} do
|
||||||
valid_time = ~U[2026-04-14 18:00:00Z]
|
valid_time = ~U[2026-04-14 18:00:00Z]
|
||||||
|
|
|
||||||
|
|
@ -460,7 +460,7 @@ defmodule MicrowavepropWeb.MapLiveTest do
|
||||||
# assertion below observes the propagation_updated delivery only.
|
# assertion below observes the propagation_updated delivery only.
|
||||||
assert_push_event(lv, "update_scores", %{scores: _initial_scores})
|
assert_push_event(lv, "update_scores", %{scores: _initial_scores})
|
||||||
|
|
||||||
# Simulate a new forecast-hour compute: rewrite the .ntms file on
|
# Simulate a new forecast-hour compute: rewrite the .prop file on
|
||||||
# disk to the updated score, but do *not* touch the cache. This
|
# disk to the updated score, but do *not* touch the cache. This
|
||||||
# reproduces the race where the cache_refresh message has not yet
|
# reproduces the race where the cache_refresh message has not yet
|
||||||
# been applied by the time MapLive handles propagation_updated.
|
# been applied by the time MapLive handles propagation_updated.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue