"""Alerts notice silence: per-rule sample tracking and a no-data mark Revision ID: 0029_alert_no_data Revises: 0028_alert_resolve_and_reset Create Date: 2026-10-06 """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa revision: str = "0029_alert_no_data" down_revision: Union[str, None] = "0028_alert_resolve_and_reset" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.add_column("alert_rules", sa.Column("notify_no_data", sa.Boolean, nullable=False, server_default=sa.true())) op.add_column("alert_states", sa.Column("last_sample_at", sa.DateTime(timezone=True), nullable=True)) op.add_column("alert_states", sa.Column("sample_interval_s", sa.Float, nullable=True)) op.add_column("alert_states", sa.Column("no_data_since", sa.DateTime(timezone=True), nullable=True)) # Seed each existing rule's last sample and usual gap from the last day of # its series, so the first silence check after the upgrade judges every # rule against what it has actually been receiving. op.execute(""" UPDATE alert_states s SET last_sample_at = k.last_at, sample_interval_s = k.gap FROM ( SELECT r.id AS rule_id, max(m.recorded_at) AS last_at, EXTRACT(EPOCH FROM max(m.recorded_at) - min(m.recorded_at)) / NULLIF(count(*) - 1, 0) AS gap FROM alert_rules r JOIN plugin_metrics m ON m.source_module = r.source_module AND m.resource_name = r.resource_name AND m.metric_name = r.metric_name AND m.recorded_at > now() - interval '1 day' GROUP BY r.id ) k WHERE s.rule_id = k.rule_id """) def downgrade() -> None: op.drop_column("alert_states", "no_data_since") op.drop_column("alert_states", "sample_interval_s") op.drop_column("alert_states", "last_sample_at") op.drop_column("alert_rules", "notify_no_data")