- Replace deprecated xref:exclude with elixirc_options in vendor/oban_pro - Remove unused require Logger from 8 files - Pin bitstring size variables with ^ in simple_packing, wgrib2, complex_packing, section, nexrad_client, mqtt, and radar worker - Remove dead defp clauses (format/1 nil, depression/2 nil) - Rename Buildings.Parser type record/0 -> building_record/0 to avoid overriding built-in type - Remove redundant catch-all in candidate_detail.ex - Simplify contact_live/show.ex conditional based on type narrowing - Fix DateTime.from_iso8601 return pattern in vendor/oban_web - Upgrade phoenix_live_view to 1.1.31 - Drop --warnings-as-errors from precommit alias; known false positives from @after_verify-generated code in Elixir 1.20.0-rc.6 + PLV 1.1.x - Add credo --strict to precommit as replacement static-analysis gate
153 lines
6 KiB
Elixir
153 lines
6 KiB
Elixir
defmodule Microwaveprop.PartitionManager do
|
||
@moduledoc """
|
||
Ensures rolling quarterly partitions exist on the time-partitioned
|
||
tables (`hrrr_profiles`, `hrdps_profiles`).
|
||
|
||
Historically the partition list was hand-edited in `priv/repo/structure.sql`
|
||
whenever someone remembered. When that fell behind, writes for the
|
||
next quarter would start failing with `no partition of relation "..."
|
||
found for row` — silent at the worker level (the Rust hrrr-point-worker
|
||
marked tasks as failed) but blocking the calibration pipeline.
|
||
|
||
This module is the runtime safety net. The companion
|
||
`Microwaveprop.Workers.PartitionMaintenanceWorker` calls it daily so
|
||
the parent table always has at least `lookahead_quarters` quarters of
|
||
runway.
|
||
|
||
Design notes:
|
||
|
||
* Partitions are quarterly. `MM` in the partition name is the
|
||
starting month of the quarter (`01`, `04`, `07`, `10`) — matches
|
||
what `priv/repo/structure.sql` already uses for recent quarters.
|
||
* Some older partitions in the parent table cover wider ranges
|
||
(e.g. `hrrr_profiles_2027_01` covers 2027-01 → 2027-07). To
|
||
avoid colliding with those, every CREATE is gated by a
|
||
pg_inherits coverage check: if any existing child already
|
||
contains the target range, the call is a no-op. Postgres still
|
||
raises on partial overlap — that's a structural mismatch the
|
||
operator should resolve manually rather than have a worker
|
||
paper over.
|
||
* Postgres auto-attaches the parent's indexes on the new child,
|
||
so we only need to issue the `CREATE TABLE ... PARTITION OF`
|
||
DDL — no per-index follow-up.
|
||
* Quarter math is done on a single integer index
|
||
(`year * 4 + (month-1) ÷ 3`) to avoid month-rollover bugs.
|
||
"""
|
||
|
||
alias Microwaveprop.Repo
|
||
|
||
@default_parents ~w(hrrr_profiles hrdps_profiles)
|
||
|
||
@typedoc """
|
||
Per-partition result. `parent` is the parent table name, `name` is
|
||
the resolved child partition name, and the status flags whether
|
||
this call performed the CREATE or found an existing partition that
|
||
already covered the range.
|
||
"""
|
||
@type result :: {parent :: String.t(), name :: String.t(), :created | :exists}
|
||
|
||
@doc """
|
||
For each configured parent table, ensure partitions covering the
|
||
current quarter and the next `lookahead_quarters` quarters exist.
|
||
|
||
Returns a list of `{parent, partition_name, :created | :exists}`
|
||
entries so the worker can log a single summary line.
|
||
"""
|
||
@spec ensure_quarterly_partitions(non_neg_integer(), [String.t()]) :: [result()]
|
||
def ensure_quarterly_partitions(lookahead_quarters \\ 4, parents \\ @default_parents)
|
||
when is_integer(lookahead_quarters) and lookahead_quarters >= 0 and is_list(parents) do
|
||
today = Date.utc_today()
|
||
quarters = quarter_starts(today, lookahead_quarters)
|
||
|
||
# One pg_inherits scan per parent (not per parent × lookahead).
|
||
# The result is a list of {lo, hi} NaiveDateTime tuples; coverage
|
||
# checks then run in-memory.
|
||
for parent <- parents, existing = existing_bounds(parent), {q_start, q_end} <- quarters do
|
||
ensure_partition_with_bounds(parent, q_start, q_end, existing)
|
||
end
|
||
end
|
||
|
||
# Public for direct access from tests / mix tasks. Issues its own
|
||
# pg_inherits scan; prefer the bulk path through
|
||
# `ensure_quarterly_partitions/2` to amortize it.
|
||
@spec ensure_partition(String.t(), Date.t(), Date.t()) :: result()
|
||
def ensure_partition(parent, %Date{} = q_start, %Date{} = q_end) do
|
||
ensure_partition_with_bounds(parent, q_start, q_end, existing_bounds(parent))
|
||
end
|
||
|
||
defp ensure_partition_with_bounds(parent, q_start, q_end, existing) do
|
||
name = partition_name(parent, q_start)
|
||
q_start_dt = NaiveDateTime.new!(q_start, ~T[00:00:00])
|
||
q_end_dt = NaiveDateTime.new!(q_end, ~T[00:00:00])
|
||
|
||
if covered_in_memory?(existing, q_start_dt, q_end_dt) do
|
||
{parent, name, :exists}
|
||
else
|
||
Repo.query!("""
|
||
CREATE TABLE public."#{name}"
|
||
PARTITION OF public."#{parent}"
|
||
FOR VALUES FROM ('#{Date.to_iso8601(q_start)} 00:00:00')
|
||
TO ('#{Date.to_iso8601(q_end)} 00:00:00')
|
||
""")
|
||
|
||
{parent, name, :created}
|
||
end
|
||
end
|
||
|
||
# Pull every child partition's bound expression for `parent`. Each
|
||
# row that matches the standard `FROM ('iso') TO ('iso')` shape is
|
||
# parsed into a `{lo_naive, hi_naive}` tuple; rows that don't match
|
||
# (DEFAULT partitions, MINVALUE/MAXVALUE, multi-column boundaries,
|
||
# etc.) are dropped — they can't usefully participate in a
|
||
# range-contains check anyway.
|
||
@spec existing_bounds(String.t()) :: [{NaiveDateTime.t(), NaiveDateTime.t()}]
|
||
defp existing_bounds(parent) do
|
||
%Postgrex.Result{rows: rows} =
|
||
Repo.query!(
|
||
"""
|
||
SELECT pg_get_expr(child.relpartbound, child.oid)
|
||
FROM pg_inherits
|
||
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
|
||
JOIN pg_class child ON pg_inherits.inhrelid = child.oid
|
||
WHERE parent.relname = $1
|
||
""",
|
||
[parent]
|
||
)
|
||
|
||
Enum.flat_map(rows, fn [bound] ->
|
||
case Regex.run(~r/FROM \('([^']+)'\) TO \('([^']+)'\)/, bound) do
|
||
[_, lo, hi] -> [{NaiveDateTime.from_iso8601!(lo), NaiveDateTime.from_iso8601!(hi)}]
|
||
_ -> []
|
||
end
|
||
end)
|
||
end
|
||
|
||
defp covered_in_memory?(existing, q_start_dt, q_end_dt) do
|
||
Enum.any?(existing, fn {lo, hi} ->
|
||
NaiveDateTime.compare(lo, q_start_dt) != :gt and
|
||
NaiveDateTime.compare(hi, q_end_dt) != :lt
|
||
end)
|
||
end
|
||
|
||
defp partition_name(parent, %Date{year: y, month: m}) do
|
||
"#{parent}_#{y}_#{String.pad_leading(Integer.to_string(m), 2, "0")}"
|
||
end
|
||
|
||
# Quarter index = year * 4 + zero-based-quarter-of-year. Going
|
||
# through this single integer makes "next quarter" trivial and
|
||
# year-rollover free.
|
||
defp quarter_starts(%Date{} = today, lookahead) do
|
||
current = quarter_index(today)
|
||
|
||
Enum.map(0..lookahead, fn n ->
|
||
q = current + n
|
||
{quarter_to_date(q), quarter_to_date(q + 1)}
|
||
end)
|
||
end
|
||
|
||
defp quarter_index(%Date{year: y, month: m}), do: y * 4 + div(m - 1, 3)
|
||
|
||
defp quarter_to_date(qi) do
|
||
%Date{year: div(qi, 4), month: rem(qi, 4) * 3 + 1, day: 1}
|
||
end
|
||
end
|