use std::collections::HashMap;
use async_trait::async_trait;
use sqlx::{PgPool, Row};
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)
}
}
fn phase_from_str(value: &str) -> Result<MigrationPhase, String> {
match value {
"prepare" => Ok(MigrationPhase::Prepare),
"backfill" => Ok(MigrationPhase::Backfill),
"validate" => Ok(MigrationPhase::Validate),
"switch" => Ok(MigrationPhase::Switch),
"cleanup" => Ok(MigrationPhase::Cleanup),
_ => Err(format!("unknown migration phase '{value}'")),
}
}
fn phase_status_from_str(value: &str) -> Result<PhaseStatus, String> {
match value {
"pending" => Ok(PhaseStatus::Pending),
"running" => Ok(PhaseStatus::Running),
"completed" => Ok(PhaseStatus::Completed),
"failed" => Ok(PhaseStatus::Failed),
"abandoned" => Ok(PhaseStatus::Abandoned),
_ => Err(format!("unknown migration phase status '{value}'")),
}
}
#[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>;
}
pub struct PostgresPhaseLedger {
pool: PgPool,
relation: String,
}
impl PostgresPhaseLedger {
pub fn new(pool: PgPool, relation: impl Into<String>) -> Self {
Self {
pool,
relation: relation.into(),
}
}
async fn ensure_table(&self) -> Result<(), String> {
let rel = &self.relation;
let ddl = format!(
"CREATE TABLE IF NOT EXISTS {rel} (
id BIGSERIAL PRIMARY KEY,
run_id TEXT NOT NULL,
phase TEXT NOT NULL CHECK (phase IN ('prepare','backfill','validate','switch','cleanup')),
status TEXT NOT NULL CHECK (status IN ('pending','running','completed','failed','abandoned')),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
error TEXT NOT NULL DEFAULT '',
attempt INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (run_id, phase)
)"
);
sqlx::query(&ddl)
.execute(&self.pool)
.await
.map_err(|err| format!("ensure phase ledger table failed: {err}"))?;
let idx = format!(
"CREATE INDEX IF NOT EXISTS \"idx_udb_migration_phase_ledger_run\"
ON {rel} (run_id, phase)"
);
sqlx::query(&idx)
.execute(&self.pool)
.await
.map_err(|err| format!("ensure phase ledger index failed: {err}"))?;
Ok(())
}
}
#[async_trait]
impl PhaseLedger for PostgresPhaseLedger {
async fn load(&self, run_id: &str) -> Result<Vec<PhaseRecord>, String> {
self.ensure_table().await?;
let rel = &self.relation;
let sql = format!(
"SELECT run_id, phase, status,
(EXTRACT(EPOCH FROM started_at) * 1000)::BIGINT AS started_at_unix_ms,
(EXTRACT(EPOCH FROM finished_at) * 1000)::BIGINT AS finished_at_unix_ms,
error, attempt
FROM {rel}
WHERE run_id = $1
ORDER BY CASE phase
WHEN 'prepare' THEN 1
WHEN 'backfill' THEN 2
WHEN 'validate' THEN 3
WHEN 'switch' THEN 4
WHEN 'cleanup' THEN 5
ELSE 99
END"
);
let rows = sqlx::query(&sql)
.bind(run_id)
.fetch_all(&self.pool)
.await
.map_err(|err| format!("load phase ledger failed: {err}"))?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let phase = phase_from_str(
row.try_get::<String, _>("phase")
.unwrap_or_default()
.as_str(),
)?;
let status = phase_status_from_str(
row.try_get::<String, _>("status")
.unwrap_or_default()
.as_str(),
)?;
out.push(PhaseRecord {
run_id: row.try_get::<String, _>("run_id").unwrap_or_default(),
phase,
status,
started_at_unix_ms: row
.try_get::<Option<i64>, _>("started_at_unix_ms")
.ok()
.flatten(),
finished_at_unix_ms: row
.try_get::<Option<i64>, _>("finished_at_unix_ms")
.ok()
.flatten(),
error: row.try_get::<String, _>("error").unwrap_or_default(),
attempt: row.try_get::<i32, _>("attempt").unwrap_or_default().max(0) as u32,
});
}
Ok(out)
}
async fn write(&self, record: PhaseRecord) -> Result<(), String> {
self.ensure_table().await?;
let rel = &self.relation;
let sql = format!(
"INSERT INTO {rel}
(run_id, phase, status, started_at, finished_at, error, attempt, updated_at)
VALUES (
$1, $2, $3,
CASE WHEN $4::BIGINT IS NULL THEN NULL ELSE to_timestamp(($4::BIGINT)::DOUBLE PRECISION / 1000.0) END,
CASE WHEN $5::BIGINT IS NULL THEN NULL ELSE to_timestamp(($5::BIGINT)::DOUBLE PRECISION / 1000.0) END,
$6, $7, NOW()
)
ON CONFLICT (run_id, phase) DO UPDATE
SET status = EXCLUDED.status,
started_at = EXCLUDED.started_at,
finished_at = EXCLUDED.finished_at,
error = EXCLUDED.error,
attempt = EXCLUDED.attempt,
updated_at = NOW()"
);
sqlx::query(&sql)
.bind(&record.run_id)
.bind(record.phase.as_str())
.bind(record.status.as_str())
.bind(record.started_at_unix_ms)
.bind(record.finished_at_unix_ms)
.bind(&record.error)
.bind(i32::try_from(record.attempt).unwrap_or(i32::MAX))
.execute(&self.pool)
.await
.map_err(|err| format!("write phase ledger failed: {err}"))?;
Ok(())
}
}
#[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");
}
}