use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
pub const MAX_RETRIES: u32 = 3;
pub const PG_ADVISORY_LOCK_KEY: i64 = 0x7072_6973_6d76_6973;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum FsmState {
#[default]
Idle,
Initialising,
LoadProtoState,
ProtoChecksumLint,
PlanProtoDiff,
GenerateSql,
ChecksumLint,
Applying,
Linting,
AutoAltering,
Verifying,
Recovering,
Completed,
Error,
}
impl std::fmt::Display for FsmState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl FsmState {
pub const ALL: [FsmState; 14] = [
FsmState::Idle,
FsmState::Initialising,
FsmState::LoadProtoState,
FsmState::ProtoChecksumLint,
FsmState::PlanProtoDiff,
FsmState::GenerateSql,
FsmState::ChecksumLint,
FsmState::Applying,
FsmState::Linting,
FsmState::AutoAltering,
FsmState::Verifying,
FsmState::Recovering,
FsmState::Completed,
FsmState::Error,
];
pub const VARIANT_COUNT: usize = Self::ALL.len();
pub fn as_str(&self) -> &'static str {
match self {
Self::Idle => "IDLE",
Self::Initialising => "INITIALISING",
Self::LoadProtoState => "LOAD_PROTO_STATE",
Self::ProtoChecksumLint => "PROTO_CHECKSUM_LINT",
Self::PlanProtoDiff => "PLAN_PROTO_DIFF",
Self::GenerateSql => "GENERATE_SQL",
Self::ChecksumLint => "CHECKSUM_LINT",
Self::Applying => "APPLYING",
Self::Linting => "LINTING",
Self::AutoAltering => "AUTO_ALTERING",
Self::Verifying => "VERIFYING",
Self::Recovering => "RECOVERING",
Self::Completed => "COMPLETED",
Self::Error => "ERROR",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
Some(match s {
"IDLE" => Self::Idle,
"INITIALISING" => Self::Initialising,
"LOAD_PROTO_STATE" => Self::LoadProtoState,
"PROTO_CHECKSUM_LINT" => Self::ProtoChecksumLint,
"PLAN_PROTO_DIFF" => Self::PlanProtoDiff,
"GENERATE_SQL" => Self::GenerateSql,
"CHECKSUM_LINT" => Self::ChecksumLint,
"APPLYING" => Self::Applying,
"LINTING" => Self::Linting,
"AUTO_ALTERING" => Self::AutoAltering,
"VERIFYING" => Self::Verifying,
"RECOVERING" => Self::Recovering,
"COMPLETED" => Self::Completed,
"ERROR" => Self::Error,
_ => return None,
})
}
pub fn valid_transitions(&self) -> Vec<FsmState> {
use FsmState::*;
match self {
Idle => vec![Initialising, Recovering],
Initialising => vec![LoadProtoState, ChecksumLint, Error],
LoadProtoState => vec![ProtoChecksumLint, Error],
ProtoChecksumLint => vec![PlanProtoDiff, ChecksumLint, Error],
PlanProtoDiff => vec![GenerateSql, ChecksumLint, Error],
GenerateSql => vec![ChecksumLint, Error],
ChecksumLint => vec![Applying, Error],
Applying => vec![Verifying, Linting, Error],
Verifying => vec![Completed, AutoAltering, Error],
Linting => vec![AutoAltering, Completed, Error],
AutoAltering => vec![Completed, Error],
Recovering => vec![Idle, Error],
Completed => vec![Idle],
Error => vec![Recovering, Idle],
}
}
pub fn can_transition_to(&self, next: &FsmState) -> bool {
self.valid_transitions().contains(next)
}
pub fn is_terminal(&self) -> bool {
matches!(self, FsmState::Completed | FsmState::Error)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RuntimeStateSnapshot {
pub run_id: String,
pub state: FsmState,
pub active_file: String,
pub retry_count: u32,
pub updated_at_unix: u64,
pub active_backends: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct EngineError {
pub run_id: String,
pub fsm_state: String,
pub filename: String,
pub message: String,
pub failed_at_unix: u64,
pub resolved: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Engine {
pub run_id: String,
pub state: FsmState,
pub active_file: String,
pub retry_count: u32,
pub error: Option<EngineError>,
pub tracker_ready: bool,
pub active_backends: Vec<String>,
}
impl Engine {
pub fn new(run_id: impl Into<String>) -> Self {
Self {
run_id: run_id.into(),
state: FsmState::Idle,
active_file: String::new(),
retry_count: 0,
error: None,
tracker_ready: false,
active_backends: vec!["postgres".to_string()], }
}
pub fn new_auto_id() -> Self {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default();
Self::new(format!("{ts:020}"))
}
pub fn transition(&mut self, next: FsmState) -> Result<(), String> {
if self.state.can_transition_to(&next) {
self.state = next;
Ok(())
} else {
Err(format!(
"[fsm] illegal transition {} → {} (run={})",
self.state, next, self.run_id
))
}
}
pub fn fail(&mut self, filename: impl Into<String>, message: impl Into<String>) {
self.error = Some(EngineError {
run_id: self.run_id.clone(),
fsm_state: self.state.as_str().to_string(),
filename: filename.into(),
message: message.into(),
failed_at_unix: unix_now(),
resolved: false,
});
self.state = FsmState::Error;
}
pub fn recover(&mut self) -> Result<(), String> {
if self.retry_count >= MAX_RETRIES {
return Err(format!(
"[fsm] max retries ({MAX_RETRIES}) exceeded — manual intervention required (run={})",
self.run_id
));
}
self.retry_count += 1;
self.transition(FsmState::Recovering)
}
pub fn reset_to_idle(&mut self) {
if let Some(err) = &mut self.error {
err.resolved = true;
}
self.state = FsmState::Idle;
self.active_file = String::new();
}
pub fn snapshot(&self) -> RuntimeStateSnapshot {
RuntimeStateSnapshot {
run_id: self.run_id.clone(),
state: self.state.clone(),
active_file: self.active_file.clone(),
retry_count: self.retry_count,
updated_at_unix: unix_now(),
active_backends: self.active_backends.clone(),
}
}
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn idle_can_transition_to_initialising() {
let mut engine = Engine::new("test-run-1");
engine.transition(FsmState::Initialising).unwrap();
assert_eq!(engine.state, FsmState::Initialising);
}
#[test]
fn illegal_transition_returns_err() {
let mut engine = Engine::new("test-run-2");
let result = engine.transition(FsmState::Completed);
assert!(result.is_err(), "IDLE → COMPLETED must be illegal");
}
#[test]
fn fail_sets_error_state() {
let mut engine = Engine::new("test-run-3");
engine.transition(FsmState::Initialising).unwrap();
engine.fail("001_init.sql", "syntax error at line 42");
assert_eq!(engine.state, FsmState::Error);
assert!(engine.error.is_some());
let err = engine.error.as_ref().unwrap();
assert_eq!(err.fsm_state, "INITIALISING");
assert!(!err.resolved);
}
#[test]
fn recover_increments_retry_count() {
let mut engine = Engine::new("test-run-4");
engine.state = FsmState::Error; engine.recover().unwrap();
assert_eq!(engine.state, FsmState::Recovering);
assert_eq!(engine.retry_count, 1);
}
#[test]
fn max_retries_exceeded_returns_err() {
let mut engine = Engine::new("test-run-5");
engine.retry_count = MAX_RETRIES;
engine.state = FsmState::Error;
let result = engine.recover();
assert!(result.is_err());
}
#[test]
fn from_str_round_trip() {
assert_eq!(FsmState::VARIANT_COUNT, 14);
for state in FsmState::ALL {
let s = state.as_str();
assert_eq!(
FsmState::from_str(s).unwrap(),
state,
"round-trip failed for {s}"
);
}
}
#[test]
fn full_happy_path_transitions() {
let mut e = Engine::new("happy-run");
for next in [
FsmState::Initialising,
FsmState::LoadProtoState,
FsmState::ProtoChecksumLint,
FsmState::PlanProtoDiff,
FsmState::GenerateSql,
FsmState::ChecksumLint,
FsmState::Applying,
FsmState::Verifying,
FsmState::Completed,
FsmState::Idle,
] {
e.transition(next).expect("unexpected illegal transition");
}
}
}