fix: preserve legacy monitoring migration data

This commit is contained in:
2026-08-04 11:22:21 +08:00
parent 80a31b8dd0
commit fee707f42d
4 changed files with 270 additions and 35 deletions
@@ -5,7 +5,7 @@ from datetime import datetime
from sqlalchemy import Column, DateTime, MetaData, String, Table, inspect, select, text
from sqlalchemy.engine import Connection
from app.models import Base
from app.models import AlertEvent, Device, NotificationOutbox, PingRecord
REVISION = "20260803_reliability_monitoring"
@@ -23,6 +23,40 @@ _DEFAULTS_FOR_EXISTING_ROWS = {
"notification_attempts": "0",
}
_REVISION_COLUMNS = {
"devices": (
Device.__table__,
{
"probe_packets_per_round",
"offline_consecutive_rounds",
"degraded_window_rounds",
"degraded_loss_percent",
"recovery_consecutive_clean_rounds",
},
),
"ping_records": (
PingRecord.__table__,
{
"sent_count",
"received_count",
"packet_loss_percent",
"average_rtt_ms",
"is_valid",
"failure_reason",
},
),
"alert_events": (
AlertEvent.__table__,
{
"notification_attempts",
"last_notification_error",
"related_event_id",
"previous_status",
"current_status",
},
),
}
def _quote(connection: Connection, identifier: str) -> str:
"""Quote one database identifier through the active dialect."""
@@ -33,15 +67,15 @@ def _add_missing_columns(connection: Connection) -> None:
"""Add only newly required nullable/defaulted columns to existing tables."""
inspector = inspect(connection)
existing_tables = set(inspector.get_table_names())
for table in Base.metadata.sorted_tables:
if table.name not in existing_tables:
for table_name, (table, column_names) in _REVISION_COLUMNS.items():
if table_name not in existing_tables:
continue
existing_columns = {
column["name"] for column in inspector.get_columns(table.name)
column["name"] for column in inspector.get_columns(table_name)
}
for column in table.columns:
if column.name in existing_columns:
if column.name not in column_names or column.name in existing_columns:
continue
column_type = connection.dialect.type_compiler.process(column.type)
@@ -50,10 +84,13 @@ def _add_missing_columns(connection: Connection) -> None:
if default is not None:
if connection.dialect.name == "sqlite" and default == "TRUE":
default = "1"
definition = f"{definition} DEFAULT {default}"
if column.name == "is_valid":
definition = f"{definition} NOT NULL DEFAULT {default}"
else:
definition = f"{definition} DEFAULT {default}"
connection.execute(
text(
f"ALTER TABLE {_quote(connection, table.name)} "
f"ALTER TABLE {_quote(connection, table_name)} "
f"ADD COLUMN {definition}"
)
)
@@ -62,32 +99,40 @@ def _add_missing_columns(connection: Connection) -> None:
def _create_missing_indexes(connection: Connection) -> None:
"""Create new performance indexes for tables that predate this revision."""
inspector = inspect(connection)
for table in Base.metadata.sorted_tables:
existing_indexes = {
index["name"] for index in inspector.get_indexes(table.name)
}
for index in table.indexes:
if index.name not in existing_indexes:
index.create(bind=connection)
table = PingRecord.__table__
if table.name not in inspector.get_table_names():
return
existing_indexes = {
index["name"] for index in inspector.get_indexes(table.name)
}
for index in table.indexes:
if index.name not in existing_indexes:
index.create(bind=connection)
def _upgrade_postgresql_alert_type(connection: Connection) -> None:
"""Permit the degraded alert value for databases using PostgreSQL enums."""
if connection.dialect.name == "postgresql":
enum_name = AlertEvent.__table__.c.alert_type.type.name
connection.execute(
text("ALTER TYPE alerttypeenum ADD VALUE IF NOT EXISTS 'degraded'")
text(
f"ALTER TYPE {_quote(connection, enum_name)} "
"ADD VALUE IF NOT EXISTS 'degraded'"
)
)
def run_reliability_migration(connection: Connection) -> None:
"""Apply this revision once while retaining all existing monitoring data.
This runner is deliberately idempotent: it creates only absent tables,
columns, and indexes, then writes one immutable revision marker. It never
issues DROP, DELETE, UPDATE, or data-copy statements.
This runner is deliberately idempotent: it creates only this revision's
outbox table, missing revision columns, and indexes, then writes one
immutable revision marker. It never issues DROP, DELETE, UPDATE, or
data-copy statements. The application owns global metadata creation after
this function returns.
"""
_migration_metadata.create_all(bind=connection)
Base.metadata.create_all(bind=connection)
NotificationOutbox.__table__.create(bind=connection, checkfirst=True)
already_applied = connection.execute(
select(_schema_migrations.c.revision).where(