use std::collections::HashMap;
use async_trait::async_trait;
use crate::migration::diff_backends::MigrationPhase;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PhaseStatus {
Pending,
Running,
Completed,
Failed,
Abandoned,
}
impl PhaseStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Running => "running",
Self::Completed => "completed",
Self::Failed => "failed",
Self::Abandoned => "abandoned",
}
}
pub fn terminal(self) -> bool {
matches!(self, Self::Failed | Self::Abandoned | Self::Completed)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PhaseRecord {
pub run_id: String,
pub phase: MigrationPhase,
pub status: PhaseStatus,
pub started_at_unix_ms: Option<i64>,
pub finished_at_unix_ms: Option<i64>,
pub error: String,
pub attempt: u32,
}
#[async_trait]
pub trait PhaseLedger: Send + Sync {
async fn load(&self, run_id: &str) -> Result<Vec<PhaseRecord>, String>;
async fn write(&self, record: PhaseRecord) -> Result<(), String>;
}
#[async_trait]
pub trait MigrationPhaseHook: Send + Sync {
async fn run(&self, phase: MigrationPhase) -> Result<(), String>;
fn capable_of(&self, _phase: MigrationPhase) -> bool {
true
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RunnerOutcome {
Completed { run_id: String },
Paused {
run_id: String,
phase: MigrationPhase,
error: String,
},
Refused {
run_id: String,
phase: MigrationPhase,
},
}
pub async fn run_to_completion(
run_id: &str,
ledger: &dyn PhaseLedger,
hook: &dyn MigrationPhaseHook,
) -> Result<RunnerOutcome, String> {
let existing = ledger.load(run_id).await?;
let by_phase: HashMap<MigrationPhase, PhaseRecord> =
existing.into_iter().map(|r| (r.phase, r)).collect();
for phase in MigrationPhase::all().iter().copied() {
let prev = by_phase.get(&phase);
match prev.map(|r| r.status) {
Some(PhaseStatus::Completed) => continue,
Some(PhaseStatus::Abandoned) => {
return Ok(RunnerOutcome::Refused {
run_id: run_id.to_string(),
phase,
});
}
_ => {}
}
if !hook.capable_of(phase) {
let record = PhaseRecord {
run_id: run_id.to_string(),
phase,
status: PhaseStatus::Failed,
started_at_unix_ms: None,
finished_at_unix_ms: Some(now_unix_ms()),
error: format!("capability check refused phase {}", phase.as_str()),
attempt: prev.map(|p| p.attempt).unwrap_or(0).saturating_add(1),
};
ledger.write(record).await?;
return Ok(RunnerOutcome::Refused {
run_id: run_id.to_string(),
phase,
});
}
let attempt = prev.map(|p| p.attempt).unwrap_or(0).saturating_add(1);
let started = now_unix_ms();
ledger
.write(PhaseRecord {
run_id: run_id.to_string(),
phase,
status: PhaseStatus::Running,
started_at_unix_ms: Some(started),
finished_at_unix_ms: None,
error: String::new(),
attempt,
})
.await?;
match hook.run(phase).await {
Ok(()) => {
ledger
.write(PhaseRecord {
run_id: run_id.to_string(),
phase,
status: PhaseStatus::Completed,
started_at_unix_ms: Some(started),
finished_at_unix_ms: Some(now_unix_ms()),
error: String::new(),
attempt,
})
.await?;
}
Err(reason) => {
ledger
.write(PhaseRecord {
run_id: run_id.to_string(),
phase,
status: PhaseStatus::Failed,
started_at_unix_ms: Some(started),
finished_at_unix_ms: Some(now_unix_ms()),
error: reason.clone(),
attempt,
})
.await?;
return Ok(RunnerOutcome::Paused {
run_id: run_id.to_string(),
phase,
error: reason,
});
}
}
}
Ok(RunnerOutcome::Completed {
run_id: run_id.to_string(),
})
}
#[cfg(test)]
#[derive(Default, Debug)]
pub struct MemoryPhaseLedger {
rows: std::sync::Mutex<Vec<PhaseRecord>>,
}
#[cfg(test)]
#[async_trait]
impl PhaseLedger for MemoryPhaseLedger {
async fn load(&self, run_id: &str) -> Result<Vec<PhaseRecord>, String> {
Ok(self
.rows
.lock()
.map_err(|e| format!("MemoryPhaseLedger.rows poisoned: {e}"))?
.iter()
.filter(|r| r.run_id == run_id)
.cloned()
.collect())
}
async fn write(&self, record: PhaseRecord) -> Result<(), String> {
let mut rows = self
.rows
.lock()
.map_err(|e| format!("MemoryPhaseLedger.rows poisoned: {e}"))?;
rows.retain(|r| !(r.run_id == record.run_id && r.phase == record.phase));
rows.push(record);
Ok(())
}
}
fn now_unix_ms() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
type MemoryLedger = MemoryPhaseLedger;
struct OkHook {
seen: Mutex<Vec<MigrationPhase>>,
}
impl OkHook {
fn new() -> Self {
Self {
seen: Mutex::new(Vec::new()),
}
}
}
#[async_trait]
impl MigrationPhaseHook for OkHook {
async fn run(&self, phase: MigrationPhase) -> Result<(), String> {
self.seen.lock().unwrap().push(phase);
Ok(())
}
}
struct FailAt {
target: MigrationPhase,
}
#[async_trait]
impl MigrationPhaseHook for FailAt {
async fn run(&self, phase: MigrationPhase) -> Result<(), String> {
if phase == self.target {
Err(format!("synthetic failure at {}", phase.as_str()))
} else {
Ok(())
}
}
}
struct RefuseSwitch;
#[async_trait]
impl MigrationPhaseHook for RefuseSwitch {
async fn run(&self, _phase: MigrationPhase) -> Result<(), String> {
Ok(())
}
fn capable_of(&self, phase: MigrationPhase) -> bool {
phase != MigrationPhase::Switch
}
}
#[tokio::test]
async fn run_to_completion_advances_through_every_phase() {
let ledger = MemoryLedger::default();
let hook = OkHook::new();
let outcome = run_to_completion("run-1", &ledger, &hook).await.unwrap();
assert_eq!(
outcome,
RunnerOutcome::Completed {
run_id: "run-1".to_string()
}
);
let seen = hook.seen.lock().unwrap().clone();
assert_eq!(seen, MigrationPhase::all().to_vec());
let rows = ledger.load("run-1").await.unwrap();
assert_eq!(rows.len(), 5);
for row in &rows {
assert_eq!(row.status, PhaseStatus::Completed, "phase {:?}", row.phase);
assert!(row.finished_at_unix_ms.is_some());
}
}
#[tokio::test]
async fn failure_pauses_then_resume_continues_without_replay() {
let ledger = MemoryLedger::default();
let outcome = run_to_completion(
"run-r",
&ledger,
&FailAt {
target: MigrationPhase::Backfill,
},
)
.await
.unwrap();
match outcome {
RunnerOutcome::Paused { phase, error, .. } => {
assert_eq!(phase, MigrationPhase::Backfill);
assert!(error.contains("synthetic failure at backfill"));
}
other => panic!("expected Paused, got {:?}", other),
}
let rows = ledger.load("run-r").await.unwrap();
let by: HashMap<MigrationPhase, PhaseRecord> =
rows.into_iter().map(|r| (r.phase, r)).collect();
assert_eq!(
by.get(&MigrationPhase::Prepare).map(|r| r.status),
Some(PhaseStatus::Completed)
);
assert_eq!(
by.get(&MigrationPhase::Backfill).map(|r| r.status),
Some(PhaseStatus::Failed)
);
assert!(by.get(&MigrationPhase::Validate).is_none());
let resume_hook = OkHook::new();
let outcome = run_to_completion("run-r", &ledger, &resume_hook)
.await
.unwrap();
assert!(matches!(outcome, RunnerOutcome::Completed { .. }));
let seen = resume_hook.seen.lock().unwrap().clone();
assert_eq!(
seen,
vec![
MigrationPhase::Backfill,
MigrationPhase::Validate,
MigrationPhase::Switch,
MigrationPhase::Cleanup,
]
);
let rows = ledger.load("run-r").await.unwrap();
let backfill = rows
.iter()
.find(|r| r.phase == MigrationPhase::Backfill)
.unwrap();
assert_eq!(backfill.status, PhaseStatus::Completed);
assert_eq!(backfill.attempt, 2);
}
#[tokio::test]
async fn capability_refusal_blocks_before_side_effects() {
let ledger = MemoryLedger::default();
let outcome = run_to_completion("run-c", &ledger, &RefuseSwitch)
.await
.unwrap();
match outcome {
RunnerOutcome::Refused { phase, .. } => {
assert_eq!(phase, MigrationPhase::Switch);
}
other => panic!("expected Refused at Switch, got {:?}", other),
}
let rows = ledger.load("run-c").await.unwrap();
let by: HashMap<MigrationPhase, PhaseRecord> =
rows.into_iter().map(|r| (r.phase, r)).collect();
assert_eq!(
by.get(&MigrationPhase::Validate).map(|r| r.status),
Some(PhaseStatus::Completed)
);
let switch = by.get(&MigrationPhase::Switch).unwrap();
assert_eq!(switch.status, PhaseStatus::Failed);
assert!(
switch.error.contains("capability check refused"),
"got: {}",
switch.error
);
assert_eq!(
switch.started_at_unix_ms, None,
"no started timestamp = no side effect"
);
assert!(by.get(&MigrationPhase::Cleanup).is_none());
}
#[tokio::test]
async fn abandoned_phase_refuses_to_advance() {
let ledger = MemoryLedger::default();
ledger
.write(PhaseRecord {
run_id: "run-a".to_string(),
phase: MigrationPhase::Switch,
status: PhaseStatus::Abandoned,
started_at_unix_ms: None,
finished_at_unix_ms: Some(now_unix_ms()),
error: "operator chose to stop".to_string(),
attempt: 1,
})
.await
.unwrap();
let outcome = run_to_completion("run-a", &ledger, &OkHook::new())
.await
.unwrap();
match outcome {
RunnerOutcome::Refused { phase, .. } => {
assert_eq!(phase, MigrationPhase::Switch);
}
other => panic!("expected Refused, got {:?}", other),
}
}
#[test]
fn phase_status_tokens_are_pinned() {
assert_eq!(PhaseStatus::Pending.as_str(), "pending");
assert_eq!(PhaseStatus::Running.as_str(), "running");
assert_eq!(PhaseStatus::Completed.as_str(), "completed");
assert_eq!(PhaseStatus::Failed.as_str(), "failed");
assert_eq!(PhaseStatus::Abandoned.as_str(), "abandoned");
}
}