From 86e88174f87fcd0432be7dde57a261baeb3e1437 Mon Sep 17 00:00:00 2001 From: Loakesh Indiran Date: Mon, 2 Nov 2026 23:35:13 +0530 Subject: [PATCH] feat(incidents): incident template, key and resolved message, stage 1d Add alert_incidents.episode_opened_at, set on create and on reopen. Every incident send carries the incident kind, the key {incident_id}:{episode_opened_at} and the event, with the candidates default_templates.incident then the alert's template. Resolving an incident writes the status and the resolve rows in one transaction and sends one resolved message per destination. A partial alert delivery now stores the episode key it minted, so the retry reuses it. --- src/api/management/src/request/ai/chat.rs | 1 + .../src/request/alerts/incidents.rs | 1 + src/config/src/config.rs | 2 +- src/core/src/alerts/alert.rs | 41 --- src/core/src/alerts/incidents.rs | 259 +++++++++++++++++- src/core/src/alerts/notifications/notify.rs | 3 + src/core/src/alerts/rca_runs.rs | 1 + src/core/src/alerts/scheduler/handlers.rs | 13 + src/infra/src/table/alert_incidents.rs | 7 + src/infra/src/table/entity/alert_incidents.rs | 4 + ...0_000003_add_incident_episode_opened_at.rs | 110 ++++++++ src/infra/src/table/migration/mod.rs | 3 + src/infra/src/table/rca_runs.rs | 1 + 13 files changed, 395 insertions(+), 51 deletions(-) create mode 100644 src/infra/src/table/migration/m20261110_000003_add_incident_episode_opened_at.rs diff --git a/src/api/management/src/request/ai/chat.rs b/src/api/management/src/request/ai/chat.rs index 83bd4d3ae5..49a8c3420f 100644 --- a/src/api/management/src/request/ai/chat.rs +++ b/src/api/management/src/request/ai/chat.rs @@ -2492,6 +2492,7 @@ mod tests { acknowledged_by: None, acknowledged_at: None, created_at: now, + episode_opened_at: now, updated_at: now, muted_by_downtime_id: None, rca_run_id: None, diff --git a/src/api/management/src/request/alerts/incidents.rs b/src/api/management/src/request/alerts/incidents.rs index d0da81d720..bc8374241e 100644 --- a/src/api/management/src/request/alerts/incidents.rs +++ b/src/api/management/src/request/alerts/incidents.rs @@ -3435,6 +3435,7 @@ mod tests { acknowledged_by: None, acknowledged_at: None, created_at: now, + episode_opened_at: now, updated_at: now, muted_by_downtime_id: None, rca_run_id: rca_run_id.map(|run_id| sid(org_id, run_id)), diff --git a/src/config/src/config.rs b/src/config/src/config.rs index 1fca503d81..eb8edd4ac8 100644 --- a/src/config/src/config.rs +++ b/src/config/src/config.rs @@ -130,7 +130,7 @@ pub type RwBTreeMap = tokio::sync::RwLock>; // 130: add suppress_repeats to alerts. // 131: org domain mapping migrations // 132: create oncall_chat_messages; add oncall_teams slack columns. -pub const DB_SCHEMA_VERSION: u64 = 134; +pub const DB_SCHEMA_VERSION: u64 = 135; pub const DB_SCHEMA_KEY: &str = "/db_schema_version/"; // global version variables diff --git a/src/core/src/alerts/alert.rs b/src/core/src/alerts/alert.rs index 28aa83522c..12fb29ffea 100644 --- a/src/core/src/alerts/alert.rs +++ b/src/core/src/alerts/alert.rs @@ -3418,47 +3418,6 @@ async fn send_to_destination_type( } } -/// Render `ctx` with the alert's template, else the destination's, else the org default, and send -/// it. -#[cfg(feature = "enterprise")] -pub(crate) async fn send_templated_to_destination( - org_id: &str, - sns_subject: &str, - dest_name: &str, - alert_template: Option<&Template>, - ctx: &mut NotificationContext, -) -> Result { - let (dest, dest_template) = destinations::get_with_template(org_id, dest_name).await?; - let Module::Alert { - destination_type, .. - } = dest.module - else { - anyhow::bail!("not an alert destination"); - }; - let explicit = choose_template(alert_template, dest_template.as_ref()).cloned(); - let effective = - crate::alerts::notifications::org_default::resolve_effective_template(org_id, explicit) - .await; - if let DestinationType::SlackApp(target) = &destination_type { - return crate::alerts::slack_publish::send_templated_post( - &crate::alerts::slack_publish::QueueSink, - org_id, - target, - effective.template(), - ctx, - ) - .await; - } - send_to_destination_type( - org_id, - sns_subject, - &destination_type, - effective.template(), - ctx, - ) - .await -} - /// Send a pre-built message string to a single destination type. /// /// Used by synthetics and downtime notifications, which build their own diff --git a/src/core/src/alerts/incidents.rs b/src/core/src/alerts/incidents.rs index 2a7225274f..a8147feaff 100644 --- a/src/core/src/alerts/incidents.rs +++ b/src/core/src/alerts/incidents.rs @@ -439,8 +439,8 @@ async fn send_incident_notifications_inner( return None; } - // Load incident to get severity, title and service_name. - let (severity, title, service_name) = + // Load incident to get severity, title, service_name and the episode it is in. + let (severity, title, service_name, episode_opened_at) = match infra::table::alert_incidents::get(org_id, incident_id).await { Ok(Some(model)) => { let gv: std::collections::HashMap = @@ -451,10 +451,12 @@ async fn send_incident_notifications_inner( .cloned() .unwrap_or_default(); let title_str = model.title.unwrap_or_default(); - (model.severity, title_str, svc) + (model.severity, title_str, svc, model.episode_opened_at) } - _ => ("P3".to_string(), String::new(), String::new()), + _ => ("P3".to_string(), String::new(), String::new(), 0), }; + let incident_template = incident_override(org_id).await; + let ledger_event = incident_ledger_event(event, chrono::Utc::now().timestamp_micros()); let cfg = config::get_config(); let incident_url = format!( @@ -476,17 +478,27 @@ async fn send_incident_notifications_inner( &service_name, &incident_url, ); + mark_incident_send(&mut send.ctx, incident_id, episode_opened_at, &ledger_event); + let candidates: Vec = incident_template + .iter() + .chain(send.alert_template) + .cloned() + .collect(); for dest_name in &send.dest_names { - match crate::alerts::alert::send_templated_to_destination( + let sent = match crate::alerts::notifications::notify::notify( org_id, - &subject, dest_name, - send.alert_template, + &candidates, + Some(&subject), &mut send.ctx, ) .await { - Ok(resp) => success_parts.push(format!("{dest_name}: {resp}")), + Ok(_) => Ok(()), + Err(e) => skip_is_not_an_error(e), + }; + match sent { + Ok(()) => success_parts.push(format!("{dest_name}: sent")), Err(e) => { log::error!( "[incidents] Failed to notify {dest_name} for incident {org_id}/{incident_id}: {e}" @@ -512,6 +524,50 @@ async fn send_incident_notifications_inner( } } +/// The org's `default_templates.incident`, loaded once per batch as precedence level 1a. +#[cfg(feature = "enterprise")] +async fn incident_override(org_id: &str) -> Option { + let defaults = crate::alerts::notifications::org_default::get_default_templates(org_id).await; + let name = defaults.get(config::meta::alerts::notification_kind::NotificationKind::Incident)?; + crate::db::alerts::templates::get(org_id, name).await.ok() +} + +/// `opened` for a new incident, else `updated:{incident_event}:{event_time_us}`. +fn incident_ledger_event(event: &str, event_time_us: i64) -> String { + match event { + "new_incident_created" => "opened".to_string(), + "resolved" => "resolved".to_string(), + other => format!("updated:{other}:{event_time_us}"), + } +} + +/// Kind, owner, episode key and ledger event of one incident send. +#[cfg(feature = "enterprise")] +fn mark_incident_send( + ctx: &mut crate::alerts::notifications::NotificationContext, + incident_id: &str, + episode_opened_at: i64, + ledger_event: &str, +) { + ctx.kind = config::meta::alerts::notification_kind::NotificationKind::Incident; + ctx.object_id = incident_id.to_string(); + ctx.episode_id = Some(format!("{incident_id}:{episode_opened_at}")); + ctx.event = ledger_event.to_string(); + ctx.cluster_name = config::get_cluster_name(); +} + +/// A skip the ledger handles, or one already delivered, is not a failed notification. +#[cfg(feature = "enterprise")] +fn skip_is_not_an_error( + e: crate::alerts::notifications::notify::NotifyError, +) -> Result<(), crate::alerts::notifications::notify::NotifyError> { + use crate::alerts::notifications::notify::{NotifyError, SkipOutcome}; + match e { + NotifyError::Skipped(reason) if reason.outcome() != SkipOutcome::Unsuccessful => Ok(()), + other => Err(other), + } +} + /// Point `ctx` at the incident: its link, and the `incident_*` template variables. #[cfg(feature = "enterprise")] fn apply_incident_to_context( @@ -3191,7 +3247,11 @@ pub async fn update_status_as( ) && infra::table::alert_incidents::get(org_id, incident_id) .await? .is_some_and(|m| m.muted_by_downtime_id.is_some()); - let updated = infra::table::alert_incidents::update_status(org_id, incident_id, status).await?; + let updated = if status == "resolved" { + resolve_status(org_id, incident_id, quiet || muted_before).await? + } else { + infra::table::alert_incidents::update_status(org_id, incident_id, status).await? + }; let muted = status_change_is_muted( quiet || muted_before, @@ -3248,6 +3308,186 @@ pub async fn update_status_as( model_to_incident(updated).await } +/// Write the resolve and close the episode in one transaction; a muted resolve sends nothing. +#[cfg(feature = "enterprise")] +async fn resolve_status( + org_id: &str, + incident_id: &str, + muted: bool, +) -> Result { + use config::meta::alerts::notification_kind::NotificationKind; + use infra::table::notification_deliveries::{CLOSED_BY_PRODUCER, CLOSED_BY_RESOLVE}; + + use crate::alerts::notifications::delivery; + + let Some(before) = infra::table::alert_incidents::get(org_id, incident_id).await? else { + anyhow::bail!("Incident not found"); + }; + let episode_id = format!("{incident_id}:{}", before.episode_opened_at); + let (current, recorded) = if muted { + (Vec::new(), Vec::new()) + } else { + incident_resolve_rows(org_id, incident_id, &before, &episode_id).await + }; + let written = std::sync::Arc::new(tokio::sync::Mutex::new(None)); + let slot = written.clone(); + let (org, id) = (org_id.to_string(), incident_id.to_string()); + let state_change: delivery::StateChange = Box::new(move |txn| { + Box::pin(async move { + let model = + infra::table::alert_incidents::update_status_with(txn, &org, &id, "resolved") + .await?; + *slot.lock().await = Some(model); + Ok(()) + }) + }); + let results = delivery::resolve_episode( + org_id, + NotificationKind::Incident, + &episode_id, + incident_id, + current, + recorded, + if muted { + CLOSED_BY_PRODUCER + } else { + CLOSED_BY_RESOLVE + }, + !muted, + state_change, + ) + .await?; + for failed in results.iter().filter_map(|r| r.as_ref().err()) { + log::warn!("[incidents] resolved notification for {org_id}/{incident_id}: {failed}"); + } + written + .lock() + .await + .take() + .ok_or_else(|| anyhow::anyhow!("Incident not found")) +} + +#[cfg(not(feature = "enterprise"))] +async fn resolve_status( + org_id: &str, + incident_id: &str, + _muted: bool, +) -> Result { + Ok(infra::table::alert_incidents::update_status(org_id, incident_id, "resolved").await?) +} + +/// The resolve rows: today's alert destination merge, then destinations only the episode recorded. +#[cfg(feature = "enterprise")] +async fn incident_resolve_rows( + org_id: &str, + incident_id: &str, + incident: &infra::table::entity::alert_incidents::Model, + episode_id: &str, +) -> ( + Vec, + Vec, +) { + use config::meta::alerts::notification_kind::NotificationKind; + + use crate::alerts::notifications::delivery; + + let first_alert = match infra::table::alert_incidents::get_incident_alerts(incident_id).await { + Ok(alerts) => match alerts.first() { + Some(ia) => crate::db::alerts::alert::get_alert_from_cache(org_id, &ia.alert_id) + .await + .map(|(_, alert)| alert), + None => None, + }, + Err(_) => None, + }; + let now = chrono::Utc::now().timestamp_micros(); + let (mut ctx, destinations, alert_template) = match &first_alert { + Some(alert) => { + let ctx = crate::alerts::alert::build_send_context( + alert, + &[], + now, + None, + now, + Some(config::meta::alerts::level::AlertLevel::Ok), + None, + None, + None, + true, + ) + .await; + let destinations = + collect_incident_destinations(org_id, incident_id, &alert.destinations).await; + let template = match alert.template.as_ref().filter(|n| !n.is_empty()) { + Some(name) => crate::db::alerts::templates::get(org_id, name).await.ok(), + None => None, + }; + (ctx, destinations, template) + } + None => ( + subject_notification_context(org_id, incident.title.as_deref().unwrap_or(""), now) + .await, + Vec::new(), + None, + ), + }; + let gv: std::collections::HashMap = + serde_json::from_value(incident.group_values.clone()).unwrap_or_default(); + let service = gv + .get("service_name") + .or_else(|| gv.get("service")) + .cloned() + .unwrap_or_default(); + let incident_url = format!( + "{}/web/alerts/incidents/{incident_id}?org_identifier={org_id}", + config::get_config().common.web_url + ); + let title = incident.title.clone().unwrap_or_default(); + apply_incident_to_context( + &mut ctx, + incident_id, + "resolved", + &title, + &incident.severity, + &service, + &incident_url, + ); + mark_incident_send( + &mut ctx, + incident_id, + incident.episode_opened_at, + "resolved", + ); + ctx.alert_status = crate::alerts::notifications::STATUS_RESOLVED.to_string(); + ctx.alert_description = "All linked alerts recovered".to_string(); + let candidates: Vec = incident_override(org_id) + .await + .into_iter() + .chain(alert_template) + .collect(); + let subject = format!("[resolved] {title}"); + let mut current = Vec::new(); + for dest in &destinations { + match delivery::fanout_destination(org_id, dest, &candidates, Some(&subject), &ctx).await { + Ok(d) => current.push(d), + Err(e) => log::warn!("[incidents] no resolve row for {dest} on {incident_id}: {e}"), + } + } + let mut recorded = Vec::new(); + for dest in delivery::recorded_destinations(org_id, NotificationKind::Incident, episode_id) + .await + .into_iter() + .filter(|d| !destinations.contains(d)) + { + if let Ok(d) = + delivery::fanout_destination(org_id, &dest, &candidates, Some(&subject), &ctx).await + { + recorded.push(d); + } + } + (current, recorded) +} + /// Runs the one rule when the status change is the one ack's (AK-2, D20). [strong] (reads) pub async fn authorize_status( org_id: &str, @@ -3663,6 +3903,7 @@ mod tests { acknowledged_by: None, acknowledged_at: None, created_at: 1, + episode_opened_at: 1, updated_at: 1, muted_by_downtime_id: muted_by.map(str::to_string), rca_run_id: None, diff --git a/src/core/src/alerts/notifications/notify.rs b/src/core/src/alerts/notifications/notify.rs index 43ca089b55..e9c227b518 100644 --- a/src/core/src/alerts/notifications/notify.rs +++ b/src/core/src/alerts/notifications/notify.rs @@ -229,6 +229,9 @@ pub async fn send( ctx: &mut NotificationContext, ) -> Result { apply_destination_metadata(&resolved.destination_type, ctx); + if let Some(sent) = send_outside_ledger(org_id, resolved, ctx).await { + return sent; + } super::delivery::send_on_ledger(org_id, resolved, subject, ctx).await } diff --git a/src/core/src/alerts/rca_runs.rs b/src/core/src/alerts/rca_runs.rs index 2e4fa6a44c..11fe938dca 100644 --- a/src/core/src/alerts/rca_runs.rs +++ b/src/core/src/alerts/rca_runs.rs @@ -662,6 +662,7 @@ pub(crate) mod tests { acknowledged_by: None, acknowledged_at: None, created_at: now, + episode_opened_at: now, updated_at: now, muted_by_downtime_id: None, rca_run_id: None, diff --git a/src/core/src/alerts/scheduler/handlers.rs b/src/core/src/alerts/scheduler/handlers.rs index 710fcf6dc4..ee3f16004f 100644 --- a/src/core/src/alerts/scheduler/handlers.rs +++ b/src/core/src/alerts/scheduler/handlers.rs @@ -2122,6 +2122,11 @@ pub(crate) fn anomaly_success_response( .to_string() } +/// A partial delivery opened the episode for the destinations that accepted it. +fn partial_delivery_stores_key(succeeded: &[String]) -> bool { + !succeeded.is_empty() +} + /// The model executed: anomalies found (delivered or not), none found, or muted by a downtime. #[cfg(feature = "enterprise")] fn detection_ran(outcome: &RunOutcome) -> bool { @@ -4466,6 +4471,8 @@ async fn handle_alert_triggers( // destinations already received. trigger_data.notified_destinations = merge_ledger(&trigger_data.notified_destinations, &succeeded); + // Store the key the delivered destinations saw, or the retry mints another. + episode_delivered |= partial_delivery_stores_key(&succeeded); log::warn!( "[SCHEDULER trace_id {scheduler_trace_id}] Alert {}/{}: {} of {} \ destinations failed, retrying only those; ledger now {:?}", @@ -9821,4 +9828,10 @@ mod tests { "{written:?}" ); } + + #[test] + fn a_partial_delivery_stores_the_minted_episode_key() { + assert!(partial_delivery_stores_key(&["slack".to_string()])); + assert!(!partial_delivery_stores_key(&[])); + } } diff --git a/src/infra/src/table/alert_incidents.rs b/src/infra/src/table/alert_incidents.rs index a770b3784c..aa3065fe07 100644 --- a/src/infra/src/table/alert_incidents.rs +++ b/src/infra/src/table/alert_incidents.rs @@ -83,6 +83,7 @@ fn new_incident( acknowledged_by: Set(None), acknowledged_at: Set(None), created_at: Set(now), + episode_opened_at: Set(now), updated_at: Set(now), muted_by_downtime_id: Set(muted_by_downtime_id), rca_run_id: Set(None), @@ -347,6 +348,7 @@ pub async fn update_status_with( if status == "acknowledged" && incident.status != "open" { return Ok(incident); } + let was_resolved = incident.status == "resolved"; let mut active: alert_incidents::ActiveModel = incident.into(); active.status = Set(status.to_string()); active.updated_at = Set(now); @@ -356,6 +358,10 @@ pub async fn update_status_with( // A manual reopen must run as a live incident, not stay muted forever. active.muted_by_downtime_id = Set(None); } else if status == "open" { + // A reopen starts a new notification episode, so its key differs from the resolved one. + if was_resolved { + active.episode_opened_at = Set(now); + } active.resolved_at = Set(None); active.acknowledged_by = Set(None); active.acknowledged_at = Set(None); @@ -636,6 +642,7 @@ pub async fn list_with( .column(alert_incidents::Column::AssignedTo) .column(alert_incidents::Column::CreatedAt) .column(alert_incidents::Column::UpdatedAt) + .column(alert_incidents::Column::EpisodeOpenedAt) .column(alert_incidents::Column::MutedByDowntimeId) .column(alert_incidents::Column::RcaRunId) .column(alert_incidents::Column::RcaState) diff --git a/src/infra/src/table/entity/alert_incidents.rs b/src/infra/src/table/entity/alert_incidents.rs index d6c9438264..e59e652bd9 100644 --- a/src/infra/src/table/entity/alert_incidents.rs +++ b/src/infra/src/table/entity/alert_incidents.rs @@ -34,6 +34,8 @@ pub struct Model { pub created_at: i64, pub updated_at: i64, + /// Start of the current notification episode: created_at, then each reopen; keys the ledger. + pub episode_opened_at: i64, /// Set while a downtime mutes this incident; nothing about it notifies. pub muted_by_downtime_id: Option, @@ -89,6 +91,7 @@ mod tests { acknowledged_by: None, acknowledged_at: None, created_at: 1000, + episode_opened_at: 1000, updated_at: 2000, muted_by_downtime_id: None, rca_run_id: None, @@ -129,6 +132,7 @@ mod tests { acknowledged_by: Some("user@example.com".to_string()), acknowledged_at: Some(1500), created_at: 1000, + episode_opened_at: 1000, updated_at: 1500, muted_by_downtime_id: Some("2f9K".to_string()), rca_run_id: None, diff --git a/src/infra/src/table/migration/m20261110_000003_add_incident_episode_opened_at.rs b/src/infra/src/table/migration/m20261110_000003_add_incident_episode_opened_at.rs new file mode 100644 index 0000000000..a478cc416c --- /dev/null +++ b/src/infra/src/table/migration/m20261110_000003_add_incident_episode_opened_at.rs @@ -0,0 +1,110 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! `episode_opened_at`: start of an incident's notification episode, filled from created_at. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Explicit guard because `add_column_if_not_exists` is not idempotent on SQLite. + if !manager.has_column(INCIDENTS, COLUMN).await? { + manager.alter_table(add_column_stmt()).await?; + } + manager + .get_connection() + .execute_unprepared(BACKFILL) + .await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + if !manager.has_column(INCIDENTS, COLUMN).await? { + return Ok(()); + } + manager + .alter_table( + Table::alter() + .table(Alias::new(INCIDENTS)) + .drop_column(Alias::new(COLUMN)) + .to_owned(), + ) + .await + } +} + +const INCIDENTS: &str = "alert_incidents"; +const COLUMN: &str = "episode_opened_at"; +const BACKFILL: &str = + "UPDATE alert_incidents SET episode_opened_at = created_at WHERE episode_opened_at = 0"; + +fn add_column_stmt() -> TableAlterStatement { + Table::alter() + .table(Alias::new(INCIDENTS)) + .add_column( + ColumnDef::new(Alias::new(COLUMN)) + .big_integer() + .not_null() + .default(0), + ) + .to_owned() +} + +#[cfg(test)] +mod tests { + use sea_orm::{ConnectionTrait, Database, Statement}; + use sea_orm_migration::MigrationName; + + use super::*; + + #[tokio::test] + async fn up_backfills_from_created_at_and_reruns() { + let db = Database::connect("sqlite::memory:").await.unwrap(); + db.execute_unprepared( + "CREATE TABLE alert_incidents (id TEXT PRIMARY KEY, created_at BIGINT NOT NULL)", + ) + .await + .unwrap(); + db.execute_unprepared("INSERT INTO alert_incidents VALUES ('i1', 42)") + .await + .unwrap(); + let manager = SchemaManager::new(&db); + Migration.up(&manager).await.unwrap(); + Migration.up(&manager).await.unwrap(); + let row = db + .query_one(Statement::from_string( + db.get_database_backend(), + "SELECT episode_opened_at FROM alert_incidents WHERE id = 'i1'", + )) + .await + .unwrap() + .unwrap(); + assert_eq!(row.try_get::("", "episode_opened_at").unwrap(), 42); + Migration.down(&manager).await.unwrap(); + assert!(!manager.has_column(INCIDENTS, COLUMN).await.unwrap()); + } + + #[test] + fn migration_name() { + assert_eq!( + Migration.name(), + "m20261110_000003_add_incident_episode_opened_at" + ); + } +} diff --git a/src/infra/src/table/migration/mod.rs b/src/infra/src/table/migration/mod.rs index 90de6a4cb1..083eff6bb9 100644 --- a/src/infra/src/table/migration/mod.rs +++ b/src/infra/src/table/migration/mod.rs @@ -250,6 +250,7 @@ mod m20261030_000002_create_org_domain_ownership_req_table; mod m20261101_000001_create_oncall_slack; mod m20261110_000001_create_notification_deliveries; mod m20261110_000002_add_anomaly_alert_episode; +mod m20261110_000003_add_incident_episode_opened_at; /// Shared body of the two `folder_id` migrations above; not a migration itself. mod workflow_folder_id; @@ -781,6 +782,7 @@ impl MigratorTrait for Migrator { Box::new(m20261101_000001_create_oncall_slack::Migration), Box::new(m20261110_000001_create_notification_deliveries::Migration), Box::new(m20261110_000002_add_anomaly_alert_episode::Migration), + Box::new(m20261110_000003_add_incident_episode_opened_at::Migration), ] } } @@ -883,6 +885,7 @@ mod tests { (132, "m20261101_000001_create_oncall_slack"), (133, "m20261110_000001_create_notification_deliveries"), (134, "m20261110_000002_add_anomaly_alert_episode"), + (135, "m20261110_000003_add_incident_episode_opened_at"), ]; // Two branches that each claim the next version merge cleanly, so a reused number must fail diff --git a/src/infra/src/table/rca_runs.rs b/src/infra/src/table/rca_runs.rs index 805e89bab7..72b74f014e 100644 --- a/src/infra/src/table/rca_runs.rs +++ b/src/infra/src/table/rca_runs.rs @@ -1447,6 +1447,7 @@ mod tests { acknowledged_by: Set(None), acknowledged_at: Set(None), created_at: Set(T0), + episode_opened_at: Set(T0), updated_at: Set(T0), muted_by_downtime_id: Set(None), rca_run_id: Set(None),