From e95c21d0cb66bd6159baa41d5f8400938d5e75f3 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:04:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(alerts):=20V17=20migration=20=E2=80=94=20d?= =?UTF-8?q?rop=20ACKNOWLEDGED,=20add=20read=5Fat=20+=20deleted=5Fat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../V17__alerts_drop_acknowledged.sql | 53 +++++++++++++++++++ .../app/alerting/storage/V17MigrationIT.java | 48 +++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 cameleer-server-app/src/main/resources/db/migration/V17__alerts_drop_acknowledged.sql create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V17MigrationIT.java diff --git a/cameleer-server-app/src/main/resources/db/migration/V17__alerts_drop_acknowledged.sql b/cameleer-server-app/src/main/resources/db/migration/V17__alerts_drop_acknowledged.sql new file mode 100644 index 00000000..35a9e1dd --- /dev/null +++ b/cameleer-server-app/src/main/resources/db/migration/V17__alerts_drop_acknowledged.sql @@ -0,0 +1,53 @@ +-- V17 — Alerts: drop ACKNOWLEDGED state, add read_at/deleted_at, drop alert_reads, +-- rework open-rule unique index predicate to survive ack (acked no longer "closed"). + +-- 1. Coerce ACKNOWLEDGED rows → FIRING (acked_at already set on these rows) +UPDATE alert_instances SET state = 'FIRING' WHERE state = 'ACKNOWLEDGED'; + +-- 2. Swap alert_state_enum to remove ACKNOWLEDGED (Postgres can't drop enum values in place) +-- First drop all indexes that reference alert_state_enum so ALTER COLUMN can proceed. +DROP INDEX IF EXISTS alert_instances_open_rule_uq; +DROP INDEX IF EXISTS alert_instances_inbox_idx; +DROP INDEX IF EXISTS alert_instances_open_rule_idx; +DROP INDEX IF EXISTS alert_instances_resolved_idx; + +CREATE TYPE alert_state_enum_v2 AS ENUM ('PENDING','FIRING','RESOLVED'); +ALTER TABLE alert_instances + ALTER COLUMN state TYPE alert_state_enum_v2 + USING state::text::alert_state_enum_v2; +DROP TYPE alert_state_enum; +ALTER TYPE alert_state_enum_v2 RENAME TO alert_state_enum; + +-- Recreate the non-unique indexes that were dropped above +CREATE INDEX alert_instances_inbox_idx ON alert_instances (environment_id, state, fired_at DESC); +CREATE INDEX alert_instances_open_rule_idx ON alert_instances (rule_id, state) WHERE rule_id IS NOT NULL; +CREATE INDEX alert_instances_resolved_idx ON alert_instances (resolved_at) WHERE state = 'RESOLVED'; + +-- 3. New orthogonal flag columns +ALTER TABLE alert_instances + ADD COLUMN read_at timestamptz NULL, + ADD COLUMN deleted_at timestamptz NULL; + +CREATE INDEX alert_instances_unread_idx + ON alert_instances (environment_id, read_at) + WHERE read_at IS NULL AND deleted_at IS NULL; + +CREATE INDEX alert_instances_deleted_idx + ON alert_instances (deleted_at) + WHERE deleted_at IS NOT NULL; + +-- 4. Rework the V13/V15/V16 open-rule uniqueness index: +-- - drop ACKNOWLEDGED from the predicate (ack no longer "closes") +-- - add "AND deleted_at IS NULL" so a soft-deleted row frees the slot +DROP INDEX IF EXISTS alert_instances_open_rule_uq; +CREATE UNIQUE INDEX alert_instances_open_rule_uq + ON alert_instances (rule_id, (COALESCE( + context->>'_subjectFingerprint', + context->'exchange'->>'id', + ''))) + WHERE rule_id IS NOT NULL + AND state IN ('PENDING','FIRING') + AND deleted_at IS NULL; + +-- 5. Drop the per-user reads table — read is now global on alert_instances.read_at +DROP TABLE alert_reads; diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V17MigrationIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V17MigrationIT.java new file mode 100644 index 00000000..cfd0965e --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V17MigrationIT.java @@ -0,0 +1,48 @@ +package com.cameleer.server.app.alerting.storage; + +import com.cameleer.server.app.AbstractPostgresIT; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class V17MigrationIT extends AbstractPostgresIT { + + @Test + void alert_state_enum_drops_acknowledged() { + var values = jdbcTemplate.queryForList(""" + SELECT unnest(enum_range(NULL::alert_state_enum))::text AS v + """, String.class); + assertThat(values).containsExactlyInAnyOrder("PENDING", "FIRING", "RESOLVED"); + } + + @Test + void read_at_and_deleted_at_columns_exist() { + var cols = jdbcTemplate.queryForList(""" + SELECT column_name FROM information_schema.columns + WHERE table_name = 'alert_instances' + AND column_name IN ('read_at','deleted_at') + """, String.class); + assertThat(cols).containsExactlyInAnyOrder("read_at", "deleted_at"); + } + + @Test + void alert_reads_table_is_gone() { + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*)::int FROM information_schema.tables + WHERE table_name = 'alert_reads' + """, Integer.class); + assertThat(count).isZero(); + } + + @Test + void open_rule_index_predicate_is_reworked() { + String def = jdbcTemplate.queryForObject(""" + SELECT pg_get_indexdef(indexrelid) + FROM pg_index + JOIN pg_class ON pg_class.oid = pg_index.indexrelid + WHERE pg_class.relname = 'alert_instances_open_rule_uq' + """, String.class); + assertThat(def).contains("state = ANY (ARRAY['PENDING'::alert_state_enum, 'FIRING'::alert_state_enum])"); + assertThat(def).contains("deleted_at IS NULL"); + } +}