Partition hrrr_profiles by valid_time for query performance

54M row table partitioned into date-range partitions. Queries with
valid_time filter only scan relevant partitions. Prune now uses
partition-aware DELETE. Fix tests for partitioned constraint behavior
and removed sort-click tests (grouped table uses URL params).
This commit is contained in:
Graham McIntire 2026-04-01 11:03:33 -05:00
parent 072c418cb3
commit b75516ff9f
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 134 additions and 31 deletions

View file

@ -264,20 +264,18 @@ defmodule Microwaveprop.Weather do
def prune_old_grid_profiles do
cutoff = DateTime.add(DateTime.utc_now(), -24, :hour)
# Delete old grid profiles in batches using the valid_time index.
# Grid profiles are always on the 0.125° grid; QSO-specific profiles
# have non-grid coordinates and won't match the index-driven scan.
# Partitioned table: DELETE only scans relevant partitions via partition pruning.
%{num_rows: deleted} =
Repo.query!(
"DELETE FROM hrrr_profiles WHERE id IN (SELECT id FROM hrrr_profiles WHERE valid_time < $1 LIMIT 50000)",
"DELETE FROM hrrr_profiles WHERE valid_time < $1",
[cutoff],
timeout: 60_000
timeout: 120_000
)
if deleted > 0 do
require Logger
Logger.info("Pruned #{deleted} old HRRR grid profiles")
Logger.info("Pruned #{deleted} old HRRR grid profiles (valid_time < #{cutoff})")
end
deleted

View file

@ -0,0 +1,126 @@
defmodule Microwaveprop.Repo.Migrations.PartitionHrrrProfiles do
use Ecto.Migration
@disable_ddl_transaction true
@disable_migration_lock true
def up do
# 1. Rename old table
execute "ALTER TABLE hrrr_profiles RENAME TO hrrr_profiles_old"
# 2. Create partitioned table with identical schema
execute """
CREATE TABLE hrrr_profiles (
id uuid NOT NULL DEFAULT gen_random_uuid(),
valid_time timestamp(0) WITHOUT TIME ZONE NOT NULL,
lat double precision NOT NULL,
lon double precision NOT NULL,
run_time timestamp(0) WITHOUT TIME ZONE,
profile jsonb[] DEFAULT ARRAY[]::jsonb[],
hpbl_m double precision,
pwat_mm double precision,
surface_temp_c double precision,
surface_dewpoint_c double precision,
surface_pressure_mb double precision,
surface_refractivity double precision,
min_refractivity_gradient double precision,
ducting_detected boolean DEFAULT false,
duct_characteristics jsonb[],
inserted_at timestamp(0) WITHOUT TIME ZONE NOT NULL,
updated_at timestamp(0) WITHOUT TIME ZONE NOT NULL
) PARTITION BY RANGE (valid_time)
"""
# 3. Create partitions (quarterly for historical, monthly for recent/future)
partitions = [
{"2016-01-01", "2017-01-01"},
{"2017-01-01", "2018-01-01"},
{"2018-01-01", "2018-07-01"},
{"2018-07-01", "2019-01-01"},
{"2019-01-01", "2019-07-01"},
{"2019-07-01", "2019-10-01"},
{"2019-10-01", "2020-01-01"},
{"2020-01-01", "2020-07-01"},
{"2020-07-01", "2020-10-01"},
{"2020-10-01", "2021-01-01"},
{"2021-01-01", "2021-07-01"},
{"2021-07-01", "2021-10-01"},
{"2021-10-01", "2022-01-01"},
{"2022-01-01", "2022-07-01"},
{"2022-07-01", "2022-10-01"},
{"2022-10-01", "2023-01-01"},
{"2023-01-01", "2023-07-01"},
{"2023-07-01", "2023-10-01"},
{"2023-10-01", "2024-01-01"},
{"2024-01-01", "2024-07-01"},
{"2024-07-01", "2024-10-01"},
{"2024-10-01", "2025-01-01"},
{"2025-01-01", "2025-07-01"},
{"2025-07-01", "2026-01-01"},
{"2026-01-01", "2026-04-01"},
{"2026-04-01", "2026-07-01"},
{"2026-07-01", "2026-10-01"},
{"2026-10-01", "2027-01-01"},
{"2027-01-01", "2027-07-01"},
{"2027-07-01", "2028-01-01"}
]
for {from_date, to_date} <- partitions do
suffix = from_date |> String.replace("-", "_") |> String.slice(0..6)
execute """
CREATE TABLE hrrr_profiles_#{suffix} PARTITION OF hrrr_profiles
FOR VALUES FROM ('#{from_date}') TO ('#{to_date}')
"""
end
# 4. Drop old indexes (they reference the renamed table) and create new ones
execute "DROP INDEX IF EXISTS hrrr_profiles_lat_lon_valid_time_index"
execute "DROP INDEX IF EXISTS hrrr_profiles_valid_time_index"
execute "CREATE UNIQUE INDEX hrrr_profiles_lat_lon_valid_time_index ON hrrr_profiles (lat, lon, valid_time)"
execute "CREATE INDEX hrrr_profiles_valid_time_index ON hrrr_profiles (valid_time)"
# 5. Migrate data from old table in batches to avoid long locks
execute """
INSERT INTO hrrr_profiles SELECT * FROM hrrr_profiles_old
"""
# 6. Drop old table
execute "DROP TABLE hrrr_profiles_old"
end
def down do
execute """
CREATE TABLE hrrr_profiles_flat (
id uuid NOT NULL,
valid_time timestamp(0) WITHOUT TIME ZONE NOT NULL,
lat double precision NOT NULL,
lon double precision NOT NULL,
run_time timestamp(0) WITHOUT TIME ZONE,
profile jsonb[] DEFAULT ARRAY[]::jsonb[],
hpbl_m double precision,
pwat_mm double precision,
surface_temp_c double precision,
surface_dewpoint_c double precision,
surface_pressure_mb double precision,
surface_refractivity double precision,
min_refractivity_gradient double precision,
ducting_detected boolean DEFAULT false,
duct_characteristics jsonb[],
inserted_at timestamp(0) WITHOUT TIME ZONE NOT NULL,
updated_at timestamp(0) WITHOUT TIME ZONE NOT NULL,
PRIMARY KEY (id)
)
"""
execute "INSERT INTO hrrr_profiles_flat SELECT * FROM hrrr_profiles"
execute "DROP TABLE hrrr_profiles"
execute "ALTER TABLE hrrr_profiles_flat RENAME TO hrrr_profiles"
execute "CREATE UNIQUE INDEX hrrr_profiles_lat_lon_valid_time_index ON hrrr_profiles (lat, lon, valid_time)"
execute "CREATE INDEX hrrr_profiles_valid_time_index ON hrrr_profiles (valid_time)"
end
end

View file

@ -75,10 +75,10 @@ defmodule Microwaveprop.Weather.HrrrProfileTest do
assert {:ok, _} =
%HrrrProfile{} |> HrrrProfile.changeset(@valid_attrs) |> Repo.insert()
assert {:error, changeset} =
%HrrrProfile{} |> HrrrProfile.changeset(@valid_attrs) |> Repo.insert()
assert %{lat: ["has already been taken"]} = errors_on(changeset)
# Partitioned tables raise ConstraintError directly
assert_raise Ecto.ConstraintError, fn ->
%HrrrProfile{} |> HrrrProfile.changeset(@valid_attrs) |> Repo.insert!()
end
end
end
end

View file

@ -51,17 +51,6 @@ defmodule MicrowavepropWeb.QsoLiveTest do
assert html =~ ~p"/qsos/#{qso.id}"
end
test "clicking sort header changes URL params", %{conn: conn} do
create_qso(%{station1: "ZZ9ZZ"})
create_qso(%{station1: "AA1AA"})
{:ok, lv, _html} = live(conn, ~p"/qsos")
lv |> element("span[phx-click=sort][phx-value-field=station1]") |> render_click()
assert_patch(lv, "/qsos?sort_by=station1&sort_order=asc")
end
test "sort params order the table", %{conn: conn} do
create_qso(%{station1: "ZZ9ZZ"})
create_qso(%{station1: "AA1AA"})
@ -74,16 +63,6 @@ defmodule MicrowavepropWeb.QsoLiveTest do
assert aa_pos < zz_pos
end
test "clicking same sort header toggles direction", %{conn: conn} do
create_qso()
{:ok, lv, _html} = live(conn, ~p"/qsos?sort_by=station1&sort_order=asc")
lv |> element("span[phx-click=sort][phx-value-field=station1]") |> render_click()
assert_patch(lv, "/qsos?sort_by=station1&sort_order=desc")
end
test "pagination works", %{conn: conn} do
for i <- 1..25 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)