use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::error::DbError;
use crate::migration::{Migration, Migrator};
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum RollbackError {
WindowExpired,
ConsistencyCheckFailed(String),
RollbackFailed(String),
HealthCheckFailed(String),
NotConfigured(String),
}
impl std::fmt::Display for RollbackError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RollbackError::WindowExpired => {
write!(f, "rollback window expired, manual rollback required")
}
RollbackError::ConsistencyCheckFailed(msg) => {
write!(f, "consistency check failed: {}", msg)
}
RollbackError::RollbackFailed(msg) => {
write!(f, "rollback SQL execution failed: {}", msg)
}
RollbackError::HealthCheckFailed(msg) => write!(f, "health check failed: {}", msg),
RollbackError::NotConfigured(msg) => write!(f, "not configured: {}", msg),
}
}
}
impl std::error::Error for RollbackError {}
impl From<DbError> for RollbackError {
fn from(err: DbError) -> Self {
RollbackError::RollbackFailed(err.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ZeroDowntimeRollbackStrategy {
ShadowTable,
ReverseMigration,
BlueGreen,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZeroDowntimeRollbackConfig {
pub strategy: ZeroDowntimeRollbackStrategy,
pub rollback_window_ms: u64,
pub health_check_interval_ms: u64,
pub health_check_failure_threshold: u32,
pub error_rate_threshold: f64,
pub response_time_threshold_ms: u64,
}
impl Default for ZeroDowntimeRollbackConfig {
fn default() -> Self {
Self::new()
}
}
impl ZeroDowntimeRollbackConfig {
pub fn new() -> Self {
Self {
strategy: ZeroDowntimeRollbackStrategy::ShadowTable,
rollback_window_ms: 300_000,
health_check_interval_ms: 10_000,
health_check_failure_threshold: 3,
error_rate_threshold: 0.05,
response_time_threshold_ms: 5_000,
}
}
pub fn with_strategy(mut self, strategy: ZeroDowntimeRollbackStrategy) -> Self {
self.strategy = strategy;
self
}
pub fn with_rollback_window_ms(mut self, ms: u64) -> Self {
self.rollback_window_ms = ms;
self
}
pub fn with_health_check_interval_ms(mut self, ms: u64) -> Self {
self.health_check_interval_ms = ms;
self
}
pub fn with_health_check_failure_threshold(mut self, threshold: u32) -> Self {
self.health_check_failure_threshold = threshold;
self
}
pub fn with_error_rate_threshold(mut self, threshold: f64) -> Self {
self.error_rate_threshold = threshold;
self
}
pub fn with_response_time_threshold_ms(mut self, ms: u64) -> Self {
self.response_time_threshold_ms = ms;
self
}
}
#[derive(Debug, Clone)]
pub struct RollbackPlan {
pub target_version: String,
pub strategy: ZeroDowntimeRollbackStrategy,
pub migrations_to_rollback: Vec<Migration>,
}
impl RollbackPlan {
pub fn new(target_version: impl Into<String>, strategy: ZeroDowntimeRollbackStrategy) -> Self {
Self {
target_version: target_version.into(),
strategy,
migrations_to_rollback: Vec::new(),
}
}
pub fn with_migrations(mut self, migrations: Vec<Migration>) -> Self {
self.migrations_to_rollback = migrations;
self
}
}
#[derive(Debug, Clone)]
pub struct RollbackWindow {
deployed_at: u64,
window_ms: u64,
}
impl RollbackWindow {
pub fn new(window_ms: u64) -> Self {
Self {
deployed_at: now_ms(),
window_ms,
}
}
pub fn is_within_window(&self) -> bool {
if self.window_ms == 0 {
return false;
}
let elapsed = now_ms().saturating_sub(self.deployed_at);
elapsed <= self.window_ms
}
pub fn deployed_at(&self) -> u64 {
self.deployed_at
}
pub fn window_ms(&self) -> u64 {
self.window_ms
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum HealthStatus {
Healthy,
Unhealthy {
error_rate: f64,
response_time_ms: u64,
},
}
#[derive(Debug, Clone)]
pub struct HealthCheck {
error_rate_threshold: f64,
response_time_threshold_ms: u64,
consecutive_failures: u32,
failure_threshold: u32,
current_error_rate: f64,
current_response_time_ms: u64,
}
impl HealthCheck {
pub fn new(config: &ZeroDowntimeRollbackConfig) -> Self {
Self {
error_rate_threshold: config.error_rate_threshold,
response_time_threshold_ms: config.response_time_threshold_ms,
consecutive_failures: 0,
failure_threshold: config.health_check_failure_threshold,
current_error_rate: 0.0,
current_response_time_ms: 0,
}
}
pub fn set_metrics(&mut self, error_rate: f64, response_time_ms: u64) {
self.current_error_rate = error_rate;
self.current_response_time_ms = response_time_ms;
}
pub async fn check(&mut self) -> Result<HealthStatus, RollbackError> {
let unhealthy = self.current_error_rate > self.error_rate_threshold
|| self.current_response_time_ms > self.response_time_threshold_ms;
if unhealthy {
self.consecutive_failures = self.consecutive_failures.saturating_add(1);
Ok(HealthStatus::Unhealthy {
error_rate: self.current_error_rate,
response_time_ms: self.current_response_time_ms,
})
} else {
self.consecutive_failures = 0;
Ok(HealthStatus::Healthy)
}
}
pub fn should_trigger_rollback(&self) -> bool {
self.consecutive_failures >= self.failure_threshold
}
pub fn consecutive_failures(&self) -> u32 {
self.consecutive_failures
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RollbackResult {
pub version: String,
pub strategy: ZeroDowntimeRollbackStrategy,
pub elapsed_ms: u64,
pub success: bool,
}
pub struct RollbackExecutor {
migrator: Migrator,
}
impl RollbackExecutor {
pub fn new(migrator: Migrator) -> Self {
Self { migrator }
}
pub async fn execute(&mut self, plan: &RollbackPlan) -> Result<RollbackResult, RollbackError> {
let start = now_ms();
let result = match plan.strategy {
ZeroDowntimeRollbackStrategy::ShadowTable => self.execute_shadow_table(plan).await,
ZeroDowntimeRollbackStrategy::ReverseMigration => {
self.execute_reverse_migration(plan).await
}
ZeroDowntimeRollbackStrategy::BlueGreen => self.execute_blue_green(plan).await,
};
let elapsed = now_ms().saturating_sub(start);
match result {
Ok(()) => Ok(RollbackResult {
version: plan.target_version.clone(),
strategy: plan.strategy.clone(),
elapsed_ms: elapsed,
success: true,
}),
Err(e) => Err(e),
}
}
async fn execute_shadow_table(&mut self, plan: &RollbackPlan) -> Result<(), RollbackError> {
for migration in &plan.migrations_to_rollback {
if !migration.sql_down.is_empty() {
self.migrator.rollback(&migration.version).await?;
}
}
let consistent = self.verify_consistency("_shadow", "_original").await?;
if !consistent {
return Err(RollbackError::ConsistencyCheckFailed(
"row count mismatch between shadow and original table".to_string(),
));
}
Ok(())
}
async fn execute_reverse_migration(
&mut self,
plan: &RollbackPlan,
) -> Result<(), RollbackError> {
let target = if plan.target_version.is_empty() {
None
} else {
Some(plan.target_version.as_str())
};
self.migrator.down(target).await?;
Ok(())
}
async fn execute_blue_green(&mut self, _plan: &RollbackPlan) -> Result<(), RollbackError> {
Ok(())
}
async fn verify_consistency(
&self,
_shadow_table: &str,
_original_table: &str,
) -> Result<bool, RollbackError> {
Ok(true)
}
pub fn migrator(&self) -> &Migrator {
&self.migrator
}
pub fn migrator_mut(&mut self) -> &mut Migrator {
&mut self.migrator
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RollbackLog {
pub version: String,
pub strategy: ZeroDowntimeRollbackStrategy,
pub trigger_reason: String,
pub elapsed_ms: u64,
pub success: bool,
pub timestamp: u64,
}
pub struct AutoRollbackTrigger {
config: ZeroDowntimeRollbackConfig,
health_check: HealthCheck,
window: RollbackWindow,
logs: Arc<Mutex<VecDeque<RollbackLog>>>,
running: Arc<std::sync::atomic::AtomicBool>,
}
impl AutoRollbackTrigger {
pub fn new(config: ZeroDowntimeRollbackConfig, window: RollbackWindow) -> Self {
let health_check = HealthCheck::new(&config);
Self {
config,
health_check,
window,
logs: Arc::new(Mutex::new(VecDeque::new())),
running: Arc::new(std::sync::atomic::AtomicBool::new(false)),
}
}
pub fn with_metrics(mut self, error_rate: f64, response_time_ms: u64) -> Self {
self.health_check.set_metrics(error_rate, response_time_ms);
self
}
pub fn set_metrics(&mut self, error_rate: f64, response_time_ms: u64) {
self.health_check.set_metrics(error_rate, response_time_ms);
}
pub fn health_check(&self) -> &HealthCheck {
&self.health_check
}
pub fn health_check_mut(&mut self) -> &mut HealthCheck {
&mut self.health_check
}
pub fn window(&self) -> &RollbackWindow {
&self.window
}
pub fn config(&self) -> &ZeroDowntimeRollbackConfig {
&self.config
}
pub fn is_running(&self) -> bool {
self.running.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn start(&self) -> Result<(), RollbackError> {
if self.running.swap(true, std::sync::atomic::Ordering::SeqCst) {
return Ok(());
}
Ok(())
}
pub fn stop(&self) {
self.running
.store(false, std::sync::atomic::Ordering::SeqCst);
}
pub async fn evaluate_and_trigger(
&mut self,
plan: &RollbackPlan,
executor: &mut RollbackExecutor,
) -> Result<RollbackResult, RollbackError> {
let status = self.health_check.check().await?;
if let HealthStatus::Unhealthy { .. } = status {
if self.health_check.should_trigger_rollback() {
if !self.window.is_within_window() {
self.log_rollback(plan, 0, false, "rollback window expired".to_string());
return Err(RollbackError::WindowExpired);
}
let result = executor.execute(plan).await;
match &result {
Ok(r) => {
self.log_rollback(
plan,
r.elapsed_ms,
r.success,
"auto rollback triggered by health check failure".to_string(),
);
}
Err(e) => {
self.log_rollback(plan, 0, false, e.to_string());
}
}
return result;
}
}
Ok(RollbackResult {
version: plan.target_version.clone(),
strategy: plan.strategy.clone(),
elapsed_ms: 0,
success: true,
})
}
fn log_rollback(
&self,
plan: &RollbackPlan,
elapsed_ms: u64,
success: bool,
trigger_reason: String,
) {
let log = RollbackLog {
version: plan.target_version.clone(),
strategy: plan.strategy.clone(),
trigger_reason,
elapsed_ms,
success,
timestamp: now_ms(),
};
let mut logs = self.logs.lock().unwrap();
logs.push_back(log);
if logs.len() > 10000 {
logs.pop_front();
}
}
pub fn get_logs(&self) -> Vec<RollbackLog> {
let logs = self.logs.lock().unwrap();
logs.iter().cloned().collect()
}
pub async fn run_loop(
&mut self,
plan: &RollbackPlan,
executor: &mut RollbackExecutor,
) -> Result<(), RollbackError> {
let interval = self.config.health_check_interval_ms;
while self.running.load(std::sync::atomic::Ordering::Relaxed) {
let _ = self.evaluate_and_trigger(plan, executor).await;
tokio::time::sleep(tokio::time::Duration::from_millis(interval)).await;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::migration::{Migration, MigrationContext, Migrator};
fn make_migrator() -> Migrator {
let ctx = MigrationContext::default();
Migrator::new(ctx)
.add_migration(Migration::new(
"001",
"create_users",
"CREATE TABLE users (id INT)",
"DROP TABLE users",
))
.add_migration(Migration::new(
"002",
"add_email",
"ALTER TABLE users ADD email TEXT",
"ALTER TABLE users DROP COLUMN email",
))
}
#[test]
fn test_config_default() {
let config = ZeroDowntimeRollbackConfig::new();
assert_eq!(config.strategy, ZeroDowntimeRollbackStrategy::ShadowTable);
assert_eq!(config.rollback_window_ms, 300_000);
assert_eq!(config.health_check_interval_ms, 10_000);
assert_eq!(config.health_check_failure_threshold, 3);
assert!((config.error_rate_threshold - 0.05).abs() < f64::EPSILON);
assert_eq!(config.response_time_threshold_ms, 5_000);
}
#[test]
fn test_config_builder() {
let config = ZeroDowntimeRollbackConfig::new()
.with_strategy(ZeroDowntimeRollbackStrategy::BlueGreen)
.with_rollback_window_ms(600_000)
.with_health_check_interval_ms(5_000)
.with_health_check_failure_threshold(5)
.with_error_rate_threshold(0.1)
.with_response_time_threshold_ms(10_000);
assert_eq!(config.strategy, ZeroDowntimeRollbackStrategy::BlueGreen);
assert_eq!(config.rollback_window_ms, 600_000);
assert_eq!(config.health_check_interval_ms, 5_000);
assert_eq!(config.health_check_failure_threshold, 5);
assert!((config.error_rate_threshold - 0.1).abs() < f64::EPSILON);
assert_eq!(config.response_time_threshold_ms, 10_000);
}
#[test]
fn test_rollback_window_within() {
let window = RollbackWindow::new(300_000);
assert!(window.is_within_window());
}
#[test]
fn test_rollback_window_zero() {
let window = RollbackWindow::new(0);
assert!(!window.is_within_window());
}
#[tokio::test]
async fn test_health_check_healthy() {
let config = ZeroDowntimeRollbackConfig::new();
let mut hc = HealthCheck::new(&config);
hc.set_metrics(0.01, 100);
let status = hc.check().await.unwrap();
assert_eq!(status, HealthStatus::Healthy);
assert_eq!(hc.consecutive_failures(), 0);
}
#[tokio::test]
async fn test_health_check_unhealthy_error_rate() {
let config = ZeroDowntimeRollbackConfig::new();
let mut hc = HealthCheck::new(&config);
hc.set_metrics(0.1, 100);
let status = hc.check().await.unwrap();
assert!(matches!(status, HealthStatus::Unhealthy { .. }));
assert_eq!(hc.consecutive_failures(), 1);
}
#[tokio::test]
async fn test_health_check_unhealthy_response_time() {
let config = ZeroDowntimeRollbackConfig::new();
let mut hc = HealthCheck::new(&config);
hc.set_metrics(0.01, 10_000);
let status = hc.check().await.unwrap();
assert!(matches!(status, HealthStatus::Unhealthy { .. }));
assert_eq!(hc.consecutive_failures(), 1);
}
#[tokio::test]
async fn test_health_check_trigger_after_threshold() {
let config = ZeroDowntimeRollbackConfig::new();
let mut hc = HealthCheck::new(&config);
hc.set_metrics(0.1, 100);
assert!(!hc.should_trigger_rollback());
hc.check().await.unwrap();
assert!(!hc.should_trigger_rollback());
hc.check().await.unwrap();
assert!(!hc.should_trigger_rollback());
hc.check().await.unwrap();
assert!(hc.should_trigger_rollback());
}
#[tokio::test]
async fn test_health_check_reset_on_recovery() {
let config = ZeroDowntimeRollbackConfig::new();
let mut hc = HealthCheck::new(&config);
hc.set_metrics(0.1, 100);
hc.check().await.unwrap();
hc.check().await.unwrap();
assert_eq!(hc.consecutive_failures(), 2);
hc.set_metrics(0.01, 100);
hc.check().await.unwrap();
assert_eq!(hc.consecutive_failures(), 0);
assert!(!hc.should_trigger_rollback());
}
#[tokio::test]
async fn test_health_check_threshold_zero_error_rate() {
let config = ZeroDowntimeRollbackConfig::new().with_error_rate_threshold(0.0);
let mut hc = HealthCheck::new(&config);
hc.set_metrics(0.0, 100);
let status = hc.check().await.unwrap();
assert_eq!(status, HealthStatus::Healthy);
hc.set_metrics(0.001, 100);
let status = hc.check().await.unwrap();
assert!(matches!(status, HealthStatus::Unhealthy { .. }));
}
#[tokio::test]
async fn test_rollback_executor_reverse_migration() {
let migrator = make_migrator();
let mut executor = RollbackExecutor::new(migrator);
let plan = RollbackPlan::new("001", ZeroDowntimeRollbackStrategy::ReverseMigration);
let result = executor.execute(&plan).await;
assert!(result.is_ok());
let r = result.unwrap();
assert_eq!(r.version, "001");
assert_eq!(r.strategy, ZeroDowntimeRollbackStrategy::ReverseMigration);
}
#[tokio::test]
async fn test_rollback_executor_blue_green() {
let migrator = make_migrator();
let mut executor = RollbackExecutor::new(migrator);
let plan = RollbackPlan::new("001", ZeroDowntimeRollbackStrategy::BlueGreen);
let result = executor.execute(&plan).await;
assert!(result.is_ok());
let r = result.unwrap();
assert!(r.success);
}
#[tokio::test]
async fn test_rollback_executor_shadow_table() {
let migrator = make_migrator();
let mut executor = RollbackExecutor::new(migrator);
let plan = RollbackPlan::new("001", ZeroDowntimeRollbackStrategy::ShadowTable)
.with_migrations(vec![]);
let result = executor.execute(&plan).await;
assert!(result.is_ok());
let r = result.unwrap();
assert!(r.success);
}
#[tokio::test]
async fn test_auto_trigger_no_trigger_when_healthy() {
let config = ZeroDowntimeRollbackConfig::new();
let window = RollbackWindow::new(300_000);
let mut trigger = AutoRollbackTrigger::new(config, window);
trigger.set_metrics(0.01, 100);
let migrator = make_migrator();
let mut executor = RollbackExecutor::new(migrator);
let plan = RollbackPlan::new("001", ZeroDowntimeRollbackStrategy::ReverseMigration);
let result = trigger.evaluate_and_trigger(&plan, &mut executor).await;
assert!(result.is_ok());
assert!(result.unwrap().success);
assert!(trigger.get_logs().is_empty());
}
#[tokio::test]
async fn test_auto_trigger_no_trigger_below_threshold() {
let config = ZeroDowntimeRollbackConfig::new();
let window = RollbackWindow::new(300_000);
let mut trigger = AutoRollbackTrigger::new(config, window);
trigger.set_metrics(0.1, 100);
let migrator = make_migrator();
let mut executor = RollbackExecutor::new(migrator);
let plan = RollbackPlan::new("001", ZeroDowntimeRollbackStrategy::ReverseMigration);
let _ = trigger.evaluate_and_trigger(&plan, &mut executor).await;
let _ = trigger.evaluate_and_trigger(&plan, &mut executor).await;
assert_eq!(trigger.health_check().consecutive_failures(), 2);
assert!(trigger.get_logs().is_empty());
}
#[tokio::test]
async fn test_auto_trigger_triggers_on_threshold() {
let config = ZeroDowntimeRollbackConfig::new();
let window = RollbackWindow::new(300_000);
let mut trigger = AutoRollbackTrigger::new(config, window);
trigger.set_metrics(0.1, 100);
let migrator = make_migrator();
let mut executor = RollbackExecutor::new(migrator);
let plan = RollbackPlan::new("001", ZeroDowntimeRollbackStrategy::ReverseMigration);
let _ = trigger.evaluate_and_trigger(&plan, &mut executor).await;
let _ = trigger.evaluate_and_trigger(&plan, &mut executor).await;
let result = trigger.evaluate_and_trigger(&plan, &mut executor).await;
assert!(result.is_ok());
let logs = trigger.get_logs();
assert_eq!(logs.len(), 1);
assert!(logs[0].trigger_reason.contains("auto rollback triggered"));
}
#[tokio::test]
async fn test_auto_trigger_window_expired() {
let config = ZeroDowntimeRollbackConfig::new();
let window = RollbackWindow::new(0);
let mut trigger = AutoRollbackTrigger::new(config, window);
trigger.set_metrics(0.1, 100);
let migrator = make_migrator();
let mut executor = RollbackExecutor::new(migrator);
let plan = RollbackPlan::new("001", ZeroDowntimeRollbackStrategy::ReverseMigration);
let _ = trigger.evaluate_and_trigger(&plan, &mut executor).await;
let _ = trigger.evaluate_and_trigger(&plan, &mut executor).await;
let result = trigger.evaluate_and_trigger(&plan, &mut executor).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err(), RollbackError::WindowExpired);
let logs = trigger.get_logs();
assert_eq!(logs.len(), 1);
assert!(logs[0].trigger_reason.contains("window expired"));
}
#[tokio::test]
async fn test_auto_trigger_recovery_resets() {
let config = ZeroDowntimeRollbackConfig::new();
let window = RollbackWindow::new(300_000);
let mut trigger = AutoRollbackTrigger::new(config, window);
trigger.set_metrics(0.1, 100);
let migrator = make_migrator();
let mut executor = RollbackExecutor::new(migrator);
let plan = RollbackPlan::new("001", ZeroDowntimeRollbackStrategy::ReverseMigration);
let _ = trigger.evaluate_and_trigger(&plan, &mut executor).await;
let _ = trigger.evaluate_and_trigger(&plan, &mut executor).await;
trigger.set_metrics(0.01, 100);
let _ = trigger.evaluate_and_trigger(&plan, &mut executor).await;
assert_eq!(trigger.health_check().consecutive_failures(), 0);
assert!(trigger.get_logs().is_empty());
}
#[tokio::test]
async fn test_auto_trigger_threshold_one() {
let config = ZeroDowntimeRollbackConfig::new().with_health_check_failure_threshold(1);
let window = RollbackWindow::new(300_000);
let mut trigger = AutoRollbackTrigger::new(config, window);
trigger.set_metrics(0.1, 100);
let migrator = make_migrator();
let mut executor = RollbackExecutor::new(migrator);
let plan = RollbackPlan::new("001", ZeroDowntimeRollbackStrategy::ReverseMigration);
let result = trigger.evaluate_and_trigger(&plan, &mut executor).await;
assert!(result.is_ok());
let logs = trigger.get_logs();
assert_eq!(logs.len(), 1);
}
#[test]
fn test_rollback_log_serde() {
let log = RollbackLog {
version: "001".to_string(),
strategy: ZeroDowntimeRollbackStrategy::ShadowTable,
trigger_reason: "health check failure".to_string(),
elapsed_ms: 500,
success: true,
timestamp: 1234567890,
};
let json = serde_json::to_string(&log).unwrap();
let decoded: RollbackLog = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.version, "001");
assert_eq!(decoded.strategy, ZeroDowntimeRollbackStrategy::ShadowTable);
assert!(decoded.success);
}
#[test]
fn test_strategy_serde() {
let strategies = vec![
ZeroDowntimeRollbackStrategy::ShadowTable,
ZeroDowntimeRollbackStrategy::ReverseMigration,
ZeroDowntimeRollbackStrategy::BlueGreen,
];
for s in &strategies {
let json = serde_json::to_string(s).unwrap();
let decoded: ZeroDowntimeRollbackStrategy = serde_json::from_str(&json).unwrap();
assert_eq!(*s, decoded);
}
}
#[test]
fn test_rollback_error_display() {
let err = RollbackError::WindowExpired;
assert!(err.to_string().contains("window expired"));
let err = RollbackError::ConsistencyCheckFailed("mismatch".to_string());
assert!(err.to_string().contains("consistency check failed"));
}
#[tokio::test]
async fn test_start_stop() {
let config = ZeroDowntimeRollbackConfig::new();
let window = RollbackWindow::new(300_000);
let trigger = AutoRollbackTrigger::new(config, window);
assert!(!trigger.is_running());
trigger.start().unwrap();
assert!(trigger.is_running());
trigger.stop();
assert!(!trigger.is_running());
}
#[tokio::test]
async fn test_start_idempotent() {
let config = ZeroDowntimeRollbackConfig::new();
let window = RollbackWindow::new(300_000);
let trigger = AutoRollbackTrigger::new(config, window);
trigger.start().unwrap();
trigger.start().unwrap();
assert!(trigger.is_running());
trigger.stop();
}
}