From e448741fe6a733a72f2e994b239c915dc4318ca0 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 13 Aug 2026 13:11:43 -0700 Subject: [PATCH] fix(db): repair EDR water views skipped by a stamped revision The staging database is stamped past z9a0b1c2d3e4 without that revision's DDL ever having run, so every OGC API - EDR query 500s with: psycopg2.errors.UndefinedTable: relation "ogc_waterlevels" does not exist The CD run carrying z9a0b1c2d3e4 to staging failed in the Alembic step with "Multiple head revisions are present for given argument 'head'". The graph was repaired afterwards (eb89d046, 8ae9fe18), but the database came out the other side with the revision recorded and its views absent. Downstream revisions applied normally, so nothing surfaced until an EDR request hit the missing relation. Re-running the revision is not possible (alembic_version already lists it) and downgrading to it would tear out every revision since, so this closes the hole from the front of the chain instead. The view SQL is imported from z9a0b1c2d3e4 rather than copied, so the repaired definition cannot drift from the definition of record. A view that is already present as a plain view is left untouched, making this a no-op on healthy databases; a name occupied by some other relation kind fails loudly rather than being replaced. downgrade() is deliberately a no-op, since the views belong to z9a0b1c2d3e4. Verified against ocotilloapi_test: no-op on a healthy database (viewdefs byte-identical), both views restored byte-identical after simulating the staging state, and a materialized view squatting the name raises. Co-Authored-By: Claude Opus 5 --- ...d9e0f1a2_repair_missing_edr_water_views.py | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 alembic/versions/b7c8d9e0f1a2_repair_missing_edr_water_views.py diff --git a/alembic/versions/b7c8d9e0f1a2_repair_missing_edr_water_views.py b/alembic/versions/b7c8d9e0f1a2_repair_missing_edr_water_views.py new file mode 100644 index 000000000..572c14b89 --- /dev/null +++ b/alembic/versions/b7c8d9e0f1a2_repair_missing_edr_water_views.py @@ -0,0 +1,126 @@ +"""repair missing EDR water views + +Recreates ogc_waterlevels / ogc_water_chemistry on any database whose +alembic_version claims z9a0b1c2d3e4 was applied while the views are in fact +absent. + +Why this is needed: the CD run that first carried z9a0b1c2d3e4 to staging +failed in the Alembic step with "Multiple head revisions are present for given +argument 'head'". The revision graph was then repaired in-tree (eb89d046, +8ae9fe18), but the staging database came out the other side stamped past +z9a0b1c2d3e4 without its DDL ever having executed. Downstream revisions applied +normally, so nothing surfaced until an EDR query hit the missing relation: + + psycopg2.errors.UndefinedTable: relation "ogc_waterlevels" does not exist + +Re-running z9a0b1c2d3e4 is not an option -- alembic_version already lists it, +and downgrading to it would tear out every revision since. This revision closes +the hole from the front of the chain instead. + +The view SQL is imported from z9a0b1c2d3e4 rather than copied so the repaired +definition cannot drift from the definition of record. + +Idempotent and safe on healthy databases: a view that is already present is +left untouched, so this is a no-op everywhere except the environments that +actually skipped the original revision. + +Revision ID: b7c8d9e0f1a2 +Revises: f3a1c2b4d5e6 +Create Date: 2026-08-13 13:20:00.000000 +""" + +import importlib.util +from pathlib import Path +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "b7c8d9e0f1a2" +down_revision: Union[str, Sequence[str], None] = "f3a1c2b4d5e6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_SOURCE_REVISION = "z9a0b1c2d3e4_add_edr_water_views.py" + + +def _load_source_revision(): + # The view definitions live in z9a0b1c2d3e4. Importing them keeps this + # repair honest: whatever that revision creates is exactly what a database + # that skipped it gets back. + path = Path(__file__).with_name(_SOURCE_REVISION) + if not path.exists(): + raise RuntimeError( + f"Cannot repair the EDR water views: {_SOURCE_REVISION} is missing " + "from alembic/versions, so the view definitions of record are " + "unavailable." + ) + spec = importlib.util.spec_from_file_location("_edr_water_views", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +VIEW_COMMENTS = { + "ogc_waterlevels": ( + "Public depth-to-water readings (manual + transducer) for EDR." + ), + "ogc_water_chemistry": "Public water-chemistry analyses (by analyte) for EDR.", +} + + +def _relkind(view_name: str) -> str | None: + bind = op.get_bind() + return bind.execute( + text("SELECT relkind FROM pg_class WHERE oid = to_regclass(:name)"), + {"name": view_name}, + ).scalar() + + +def _check_required_tables(required_tables: set[str]) -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing = set(inspector.get_table_names(schema="public")) + missing = required_tables - existing + if missing: + raise RuntimeError( + "Cannot repair the EDR water views. Missing required tables: " + f"{sorted(missing)}" + ) + + +def _repair_view(view_name: str, create_sql: str) -> None: + relkind = _relkind(view_name) + if relkind == "v": + # Already present and the right kind -- the database applied + # z9a0b1c2d3e4 for real. Leave it alone rather than churning DDL that + # other objects may depend on. + return + if relkind is not None: + # Present as something other than a plain view (materialized view, + # table). That is not a state z9a0b1c2d3e4 or its downstream revisions + # produce, so fail loudly instead of silently replacing it. + raise RuntimeError( + f"Cannot repair {view_name}: it already exists with relkind " + f"{relkind!r}, not a plain view. Inspect it by hand before " + "re-running this migration." + ) + + op.execute(text(create_sql)) + op.execute(text(f"COMMENT ON VIEW {view_name} IS '{VIEW_COMMENTS[view_name]}'")) + + +def upgrade() -> None: + source = _load_source_revision() + _check_required_tables(set(source.REQUIRED_TABLES)) + + _repair_view("ogc_waterlevels", source._create_waterlevels_view()) + _repair_view("ogc_water_chemistry", source._create_water_chemistry_view()) + + +def downgrade() -> None: + # Deliberately a no-op. These views belong to z9a0b1c2d3e4; dropping them + # here would break EDR on every database that applied that revision + # correctly. Downgrading past z9a0b1c2d3e4 removes them. + pass