Skip to main content

siderite_migrations/executor/
recovery.rs

1//! Read-only inspection of recorded partial migration work.
2
3use 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/// Recorded recovery state; catalogs must be reconciled before any repair.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
12pub struct RecoveryEntry {
13    /// Migration whose statements were recorded.
14    pub migration_id: String,
15    /// Original migration file checksum.
16    pub checksum: String,
17    /// `apply` or `unapply`.
18    pub direction: String,
19    /// Number of completed operations recorded by the executor.
20    pub completed_operations: usize,
21    /// Completed statements within the next operation.
22    pub completed_statements: usize,
23    /// Operation count of the migration in the supplied graph; `None`
24    /// when the graph does not contain the id.
25    pub total_operations: Option<usize>,
26    /// Whether the supplied graph has the same file; None means unknown id.
27    pub file_matches: Option<bool>,
28    /// Whether this migration also has an applied history row.
29    pub history_applied: bool,
30}
31
32impl RecoveryEntry {
33    /// Whether progress records every operation of the migration as
34    /// complete, so only the history write can be outstanding.
35    ///
36    /// Returns:
37    ///   `true` when `completed_operations` equals `total_operations`
38    ///   and no statement of a further operation is recorded; `false`
39    ///   when operations remain or the migration is not in the graph.
40    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/// Snapshot of applied ids and recorded partial work, without modifying SQL.
47///
48/// A crash can occur after DDL commits but before its progress write. Entries
49/// are bookkeeping, never proof that the live schema matches those indices.
50/// Reads take no migration lock and may race with an active migrator.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52pub struct RecoveryReport {
53    /// Applied history ids, sorted lexically.
54    pub applied: Vec<String>,
55    /// Recorded partial work, sorted by migration id.
56    pub partial: Vec<RecoveryEntry>,
57    /// Explicit scripts or callbacks with unconfirmed completion.
58    pub uncertain_steps: Vec<MigrationIntent>,
59}
60
61impl Migrator<'_> {
62    /// Inspect recovery bookkeeping using SELECT statements only.
63    ///
64    /// Args:
65    ///     self: Migrator with the database and current migration graph.
66    ///
67    /// Returns:
68    ///     Recorded history/progress and file-checksum match information.
69    ///     Missing tables produce empty lists and are never created here.
70    ///
71    /// # Errors
72    /// Unsupported backend, unreadable catalogs/tables or malformed rows.
73    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}