feat(schema): foreign keys for every alerts relation (changesets 30-53, after #32) #35

Merged
KofTwentyTwo merged 5 commits from feature/foreign-keys into develop 2026-10-14 23:00:43 +00:00
KofTwentyTwo commented 2026-10-14 21:19:05 +00:00 (Migrated from github.com)

What

Referential integrity for every qbit-alerts relation: 23 foreign keys, each with an ON DELETE rule chosen from how the qbit deletes, plus indexes for the FK columns that lacked one.

Depends on #32. This branch is stacked on feature/alerts-join-indexes (changeset qbit-alerts:30 after develop's :29, the monitor_id / alert_type_id / group_id indexes); these changesets start at qbit-alerts:31. Merge #32 first; this PR's diff then shrinks to its own two commits.

Changesets

  • qbit-alerts:31: CREATE INDEX IF NOT EXISTS for the FK columns no index led with (alerts_alert.group_id / assigned_team_id, alerts_type and alerts_group escalation_policy_id, alerts_group parent_group_id / owner_team_id, alerts_delivery alert_event_id / subscription_id, alerts_subscription team_id / channel_id, alerts_escalation_step team_id / channel_id).
  • qbit-alerts:32-54 (dbms:postgresql, so H2 skips them): one constraint each, fk_<child_table>_<child_column>, guarded by precondition-sql-check expectedResult:0 on the name, so a host that already added it marks it ran (kof22 ships the same names in its own changelog). Each changeset first records orphans in qqq_fk_quarantine (same DDL in every qbit, CREATE TABLE IF NOT EXISTS), then nulls a nullable column (NULLED) or deletes a row whose column is NOT NULL (DELETED, full row in row_data). Orphan order is dependency-safe: monitors before alerts.monitor_id, events before delivery.alert_event_id. Every changeset has a -- rollback.
child.column parent ON DELETE why
alerts_type.group_id alerts_group SET NULL AlertTypeSync re-groups a type with a null group at boot (AlertTypeSync.java:203)
alerts_type.escalation_policy_id alerts_escalation_policy SET NULL falls back to the group's policy (EscalationAndReminder.java:184)
alerts_group.parent_group_id alerts_group SET NULL child groups become top-level
alerts_group.owner_team_id alerts_team SET NULL optional owner
alerts_group.escalation_policy_id alerts_escalation_policy SET NULL optional
alerts_monitor.alert_type_id alerts_type RESTRICT types are never deleted (TABLE_DELETE off; sync only adds/updates)
alerts_alert.alert_type_id alerts_type RESTRICT same
alerts_alert.monitor_id alerts_monitor SET NULL monitors are deletable; alert history stays
alerts_alert.group_id alerts_group SET NULL groups are deletable
alerts_alert.assigned_team_id alerts_team SET NULL unassign
alerts_record_ref.alert_id alerts_alert CASCADE owned detail
alerts_event.alert_id alerts_alert CASCADE the alert's own timeline
alerts_delivery.alert_id alerts_alert CASCADE the alert's delivery log
alerts_delivery.alert_event_id alerts_event SET NULL code-only relation (AlertRouter.java:329)
alerts_delivery.subscription_id alerts_subscription SET NULL users delete subscriptions; history stays
alerts_delivery.channel_id alerts_channel RESTRICT a channel with history is disabled, not deleted
alerts_subscription.team_id / channel_id alerts_team / alerts_channel RESTRICT TEAM/CHANNEL subscriptions require them (AlertsSubscriptionCustomizer.java:117-121)
alerts_team_member.team_id alerts_team CASCADE owned
alerts_notification_recipient.notification_id alerts_notification CASCADE owned; NotificationService.purge/deleteWithRecipients already deletes recipients first, so retention is unaffected
alerts_escalation_step.policy_id alerts_escalation_policy CASCADE owned
alerts_escalation_step.team_id / channel_id alerts_team / alerts_channel RESTRICT ck_alerts_escalation_step_target forbids NULL, so SET NULL is impossible; orphan steps are deleted (quarantined)

Behaviour change: deleting a team or channel that a subscription, escalation step or (channel) delivery row still uses now fails with an FK error instead of leaving dangling ids. Deleting a group, monitor, subscription, policy or notification works as before.

Not FK-able (polymorphic or lists, left to a host Data Health check): alerts_alert (subject_table_name, subject_record_id), alerts_record_ref (table_name, record_id), alerts_notification (source_table_name, source_record_id), alerts_subscription and alerts_mute (scope_type, scope_value, scope_record_id), channel_override_ids / default_channel_ids (id lists), alerts_delivery.digest_last_item_id (a watermark).

Tests

  • New AlertsForeignKeysTest (PostgreSQL 17 via Testcontainers, the real Liquibase 4.33 run of the shipped changelog; skipped without Docker locally, required under CI): every join and table possible-value field has its FK with the decided rule (or an EXEMPT entry with a reason), every FK column leads an index, orphans are quarantined then nulled/deleted with valid rows untouched, a second run is a no-op, a constraint a host already added is skipped, and the rules behave (cascade, set null, restrict).
  • Red/green: with changesets 31-54 removed the guardrail fails (3 failures, 1 error: "a foreign key (or an EXEMPT entry with its reason) for alerts_alert.alert_type_id -> alerts_type"); with them, ./mvnw verify is green (178 tests).
  • The H2 schema carries the same constraints (DROPs now CASCADE); the H2 digest-migration test stops before the PostgreSQL-only FK changesets.
  • log4j2-test.xml (as in qbit-worm-audit) keeps Testcontainers' debug output out of the build log.

🤖 Generated with Claude Code

## What Referential integrity for every qbit-alerts relation: 23 foreign keys, each with an ON DELETE rule chosen from how the qbit deletes, plus indexes for the FK columns that lacked one. **Depends on #32.** This branch is stacked on `feature/alerts-join-indexes` (changeset `qbit-alerts:30` after develop's `:29`, the monitor_id / alert_type_id / group_id indexes); these changesets start at `qbit-alerts:31`. Merge #32 first; this PR's diff then shrinks to its own two commits. ## Changesets - `qbit-alerts:31`: `CREATE INDEX IF NOT EXISTS` for the FK columns no index led with (alerts_alert.group_id / assigned_team_id, alerts_type and alerts_group escalation_policy_id, alerts_group parent_group_id / owner_team_id, alerts_delivery alert_event_id / subscription_id, alerts_subscription team_id / channel_id, alerts_escalation_step team_id / channel_id). - `qbit-alerts:32-54` (`dbms:postgresql`, so H2 skips them): one constraint each, `fk_<child_table>_<child_column>`, guarded by `precondition-sql-check expectedResult:0` on the name, so a host that already added it marks it ran (kof22 ships the same names in its own changelog). Each changeset first records orphans in `qqq_fk_quarantine` (same DDL in every qbit, `CREATE TABLE IF NOT EXISTS`), then nulls a nullable column (`NULLED`) or deletes a row whose column is NOT NULL (`DELETED`, full row in `row_data`). Orphan order is dependency-safe: monitors before alerts.monitor_id, events before delivery.alert_event_id. Every changeset has a `-- rollback`. | child.column | parent | ON DELETE | why | |---|---|---|---| | alerts_type.group_id | alerts_group | SET NULL | AlertTypeSync re-groups a type with a null group at boot (AlertTypeSync.java:203) | | alerts_type.escalation_policy_id | alerts_escalation_policy | SET NULL | falls back to the group's policy (EscalationAndReminder.java:184) | | alerts_group.parent_group_id | alerts_group | SET NULL | child groups become top-level | | alerts_group.owner_team_id | alerts_team | SET NULL | optional owner | | alerts_group.escalation_policy_id | alerts_escalation_policy | SET NULL | optional | | alerts_monitor.alert_type_id | alerts_type | RESTRICT | types are never deleted (TABLE_DELETE off; sync only adds/updates) | | alerts_alert.alert_type_id | alerts_type | RESTRICT | same | | alerts_alert.monitor_id | alerts_monitor | SET NULL | monitors are deletable; alert history stays | | alerts_alert.group_id | alerts_group | SET NULL | groups are deletable | | alerts_alert.assigned_team_id | alerts_team | SET NULL | unassign | | alerts_record_ref.alert_id | alerts_alert | CASCADE | owned detail | | alerts_event.alert_id | alerts_alert | CASCADE | the alert's own timeline | | alerts_delivery.alert_id | alerts_alert | CASCADE | the alert's delivery log | | alerts_delivery.alert_event_id | alerts_event | SET NULL | code-only relation (AlertRouter.java:329) | | alerts_delivery.subscription_id | alerts_subscription | SET NULL | users delete subscriptions; history stays | | alerts_delivery.channel_id | alerts_channel | RESTRICT | a channel with history is disabled, not deleted | | alerts_subscription.team_id / channel_id | alerts_team / alerts_channel | RESTRICT | TEAM/CHANNEL subscriptions require them (AlertsSubscriptionCustomizer.java:117-121) | | alerts_team_member.team_id | alerts_team | CASCADE | owned | | alerts_notification_recipient.notification_id | alerts_notification | CASCADE | owned; NotificationService.purge/deleteWithRecipients already deletes recipients first, so retention is unaffected | | alerts_escalation_step.policy_id | alerts_escalation_policy | CASCADE | owned | | alerts_escalation_step.team_id / channel_id | alerts_team / alerts_channel | RESTRICT | ck_alerts_escalation_step_target forbids NULL, so SET NULL is impossible; orphan steps are deleted (quarantined) | Behaviour change: deleting a team or channel that a subscription, escalation step or (channel) delivery row still uses now fails with an FK error instead of leaving dangling ids. Deleting a group, monitor, subscription, policy or notification works as before. Not FK-able (polymorphic or lists, left to a host Data Health check): alerts_alert (subject_table_name, subject_record_id), alerts_record_ref (table_name, record_id), alerts_notification (source_table_name, source_record_id), alerts_subscription and alerts_mute (scope_type, scope_value, scope_record_id), channel_override_ids / default_channel_ids (id lists), alerts_delivery.digest_last_item_id (a watermark). ## Tests - New `AlertsForeignKeysTest` (PostgreSQL 17 via Testcontainers, the real Liquibase 4.33 run of the shipped changelog; skipped without Docker locally, required under `CI`): every join and table possible-value field has its FK with the decided rule (or an `EXEMPT` entry with a reason), every FK column leads an index, orphans are quarantined then nulled/deleted with valid rows untouched, a second run is a no-op, a constraint a host already added is skipped, and the rules behave (cascade, set null, restrict). - Red/green: with changesets 31-54 removed the guardrail fails (3 failures, 1 error: "a foreign key (or an EXEMPT entry with its reason) for alerts_alert.alert_type_id -> alerts_type"); with them, `./mvnw verify` is green (178 tests). - The H2 schema carries the same constraints (DROPs now `CASCADE`); the H2 digest-migration test stops before the PostgreSQL-only FK changesets. - `log4j2-test.xml` (as in qbit-worm-audit) keeps Testcontainers' debug output out of the build log. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.