siderite_migrations/executor/
recovery.rs1use super::progress::PROGRESS_TABLE;
4use super::{MigrationError as Error, MigrationIntent, Migrator, Value, quote_star};
5use serde::Serialize;
6use siderite_orm::Row;
7
8type Result<T> = std::result::Result<T, Error>;
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
12pub struct RecoveryEntry {
13 pub migration_id: String,
15 pub checksum: String,
17 pub direction: String,
19 pub completed_operations: usize,
21 pub completed_statements: usize,
23 pub total_operations: Option<usize>,
26 pub file_matches: Option<bool>,
28 pub history_applied: bool,
30}
31
32impl RecoveryEntry {
33 pub fn all_operations_recorded(&self) -> bool {
41 let done = Some(self.completed_operations);
42 self.total_operations == done && self.completed_statements == 0
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52pub struct RecoveryReport {
53 pub applied: Vec<String>,
55 pub partial: Vec<RecoveryEntry>,
57 pub uncertain_steps: Vec<MigrationIntent>,
59}
60
61impl Migrator<'_> {
62 pub async fn inspect_recovery(&self) -> Result<RecoveryReport> {
74 self.require_backend()?;
75 let history = self.load_history().await?;
76 let mut applied: Vec<_> = history.ids.iter().cloned().collect();
77 applied.sort();
78 let mut partial = Vec::new();
79 if self.table_exists_on(self.db, PROGRESS_TABLE).await? {
80 let result = self
81 .db
82 .raw_sql(
83 &format!(
84 "SELECT * FROM {} ORDER BY {}",
85 quote_star(self.kind(), PROGRESS_TABLE),
86 quote_star(self.kind(), "migration_id")
87 ),
88 Vec::new(),
89 )
90 .await?;
91 for row in result.rows {
92 let migration_id = text(&row, "migration_id")?;
93 let checksum = text(&row, "checksum")?;
94 let direction = text(&row, "direction")?;
95 if !matches!(direction.as_str(), "apply" | "unapply") {
96 return Err(Error::state("invalid progress direction"));
97 }
98 let known = self.graph.get(&migration_id);
99 let file_matches = known.map(|m| m.checksum == checksum);
100 let total_operations = known.map(|m| m.operations.len());
101 partial.push(RecoveryEntry {
102 history_applied: history.ids.contains(&migration_id),
103 migration_id,
104 checksum,
105 direction,
106 completed_operations: read_index(&row, "op_index")?,
107 completed_statements: read_index(&row, "stmt_index")?,
108 total_operations,
109 file_matches,
110 });
111 }
112 }
113 let uncertain_steps = super::intents::read(self.db, None).await?;
114 Ok(RecoveryReport {
115 applied,
116 partial,
117 uncertain_steps,
118 })
119 }
120}
121
122pub(super) fn text(row: &Row, key: &str) -> Result<String> {
123 match row.get(key) {
124 Some(Value::Text(value)) if !value.is_empty() => Ok(value.clone()),
125 _ => Err(Error::state(format!(
126 "migration recovery has an invalid `{key}`"
127 ))),
128 }
129}
130
131pub(super) fn read_index(row: &Row, key: &str) -> Result<usize> {
132 match row.get(key) {
133 Some(Value::Int(n)) => positive_index(*n),
134 _ => Err(Error::state(format!(
135 "migration recovery has an invalid `{key}`"
136 ))),
137 }
138}
139
140fn invalid_index() -> Error {
141 Error::state("invalid progress index")
142}
143
144fn positive_index(value: i64) -> Result<usize> {
145 usize::try_from(value).map_err(|_| invalid_index())
146}
147
148#[cfg(test)]
149mod tests {
150 use super::RecoveryEntry as Entry;
151
152 fn entry(done: usize, stmts: usize, total: Option<usize>) -> Entry {
153 Entry {
154 migration_id: "0001_x".into(),
155 checksum: "c".into(),
156 direction: "apply".into(),
157 completed_operations: done,
158 completed_statements: stmts,
159 total_operations: total,
160 file_matches: Some(true),
161 history_applied: false,
162 }
163 }
164
165 #[test]
166 fn all_operations_recorded_needs_the_full_count() {
167 assert!(entry(2, 0, Some(2)).all_operations_recorded());
168 assert!(!entry(1, 0, Some(2)).all_operations_recorded());
169 assert!(!entry(1, 1, Some(2)).all_operations_recorded());
170 assert!(!entry(2, 0, None).all_operations_recorded());
171 }
172}