1use crate::audit::AuditDb;
9use rusqlite::params;
10use std::sync::Arc;
11use thiserror::Error;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum JournalState {
15 Planned,
16 BackupCapturing,
17 BackupDurable,
18 MutationExecuting,
19 MutationCommitted,
20 AuditFinalized,
21 Failed,
22 Uncertain,
23}
24
25impl JournalState {
26 fn as_str(&self) -> &'static str {
27 match self {
28 JournalState::Planned => "planned",
29 JournalState::BackupCapturing => "backup_capturing",
30 JournalState::BackupDurable => "backup_durable",
31 JournalState::MutationExecuting => "mutation_executing",
32 JournalState::MutationCommitted => "mutation_committed",
33 JournalState::AuditFinalized => "audit_finalized",
34 JournalState::Failed => "failed",
35 JournalState::Uncertain => "uncertain",
36 }
37 }
38}
39
40#[derive(Debug, Error)]
41pub enum JournalError {
42 #[error("journal write failed: {0}")]
43 Db(#[from] rusqlite::Error),
44 #[error("transition {from} -> {to} is not allowed")]
45 IllegalTransition { from: String, to: String },
46}
47
48const TRANSITIONS: &[(&str, &[&str])] = &[
52 ("planned", &["backup_capturing", "failed"]),
53 (
54 "backup_capturing",
55 &["backup_durable", "mutation_executing", "failed"],
56 ),
57 ("backup_durable", &["mutation_executing", "failed"]),
58 (
59 "mutation_executing",
60 &["mutation_committed", "failed", "uncertain"],
61 ),
62 ("mutation_committed", &["audit_finalized", "uncertain"]),
63 ("audit_finalized", &[]),
64 ("failed", &[]),
65 ("uncertain", &[]),
66];
67
68pub struct Journal<'a> {
69 db: &'a Arc<AuditDb>,
70 id: i64,
71}
72
73pub fn ensure_table(db: &Arc<AuditDb>) -> rusqlite::Result<()> {
74 db.with(|c| {
75 c.execute_batch(
76 "CREATE TABLE IF NOT EXISTS operation_journal (
77 id INTEGER PRIMARY KEY AUTOINCREMENT,
78 ts TEXT NOT NULL,
79 request_id TEXT NOT NULL,
80 connection TEXT NOT NULL,
81 databases TEXT NOT NULL,
82 category TEXT NOT NULL,
83 state TEXT NOT NULL,
84 detail TEXT,
85 backup_id INTEGER,
86 audit_id INTEGER
87 );
88 CREATE INDEX IF NOT EXISTS idx_journal_state ON operation_journal(state, ts);",
89 )
90 })
91}
92
93impl<'a> Journal<'a> {
94 pub fn create(
95 db: &'a Arc<AuditDb>,
96 request_id: &str,
97 connection: &str,
98 databases: &[String],
99 category: &str,
100 ) -> Result<Journal<'a>, JournalError> {
101 let ts = now_iso();
102 let databases = serde_json::to_string(databases).unwrap_or_else(|_| "[]".into());
103 let id = db.with_tx(|c| {
104 c.execute(
105 "INSERT INTO operation_journal
106 (ts, request_id, connection, databases, category, state)
107 VALUES (?1, ?2, ?3, ?4, ?5, 'planned')",
108 params![ts, request_id, connection, databases, category],
109 )?;
110 Ok(c.last_insert_rowid())
111 })?;
112 Ok(Journal { db, id })
113 }
114
115 pub fn id(&self) -> i64 {
116 self.id
117 }
118
119 pub fn from_id(db: &'a Arc<AuditDb>, id: i64) -> Journal<'a> {
122 Journal { db, id }
123 }
124
125 pub fn transition(&self, to: JournalState, detail: Option<&str>) -> Result<(), JournalError> {
126 self.db.with_tx(|c| {
127 let from: String = c.query_row(
128 "SELECT state FROM operation_journal WHERE id = ?1",
129 params![self.id],
130 |r| r.get(0),
131 )?;
132 let allowed = TRANSITIONS
133 .iter()
134 .find(|(f, _)| *f == from.as_str())
135 .map(|(_, ts)| ts.contains(&to.as_str()))
136 .unwrap_or(false);
137 if !allowed {
138 return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
139 std::io::Error::other(format!("illegal transition {from} -> {}", to.as_str())),
140 )));
141 }
142 c.execute(
143 "UPDATE operation_journal
144 SET state = ?2, detail = COALESCE(?3, detail), ts = ?4
145 WHERE id = ?1",
146 params![self.id, to.as_str(), detail, now_iso()],
147 )?;
148 Ok(())
149 })?;
150 Ok(())
151 }
152
153 pub fn link_backup(&self, backup_id: i64) -> rusqlite::Result<()> {
154 self.db.with(|c| {
155 c.execute(
156 "UPDATE operation_journal SET backup_id = ?2 WHERE id = ?1",
157 params![self.id, backup_id],
158 )
159 })?;
160 Ok(())
161 }
162
163 pub fn link_audit(&self, audit_id: i64) -> rusqlite::Result<()> {
164 self.db.with(|c| {
165 c.execute(
166 "UPDATE operation_journal SET audit_id = ?2 WHERE id = ?1",
167 params![self.id, audit_id],
168 )
169 })?;
170 Ok(())
171 }
172
173 pub fn recoverable(db: &Arc<AuditDb>) -> rusqlite::Result<Vec<(i64, String, String)>> {
177 db.with(|c| {
178 let mut stmt = c.prepare(
179 "SELECT id, state, COALESCE(request_id, '') FROM operation_journal
180 WHERE state NOT IN ('audit_finalized', 'failed', 'uncertain')
181 OR (state = 'mutation_committed' AND audit_id IS NULL)",
182 )?;
183 let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
184 rows.collect()
185 })
186 }
187}
188
189fn now_iso() -> String {
190 time::OffsetDateTime::now_utc()
191 .format(&time::format_description::well_known::Rfc3339)
192 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".into())
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 fn db() -> (tempfile::TempDir, Arc<AuditDb>) {
200 let dir = tempfile::TempDir::new().unwrap();
201 let d = Arc::new(AuditDb::at_path(&dir.path().join("a.sqlite")).unwrap());
202 ensure_table(&d).unwrap();
203 (dir, d)
204 }
205
206 #[test]
207 fn happy_path_transitions() {
208 let (_dir, db) = db();
209 let j = Journal::create(&db, "req-1", "c1", &["app".into()], "write").unwrap();
210 j.transition(JournalState::BackupCapturing, None).unwrap();
211 j.transition(JournalState::BackupDurable, None).unwrap();
212 j.transition(JournalState::MutationExecuting, None).unwrap();
213 j.transition(JournalState::MutationCommitted, None).unwrap();
214 j.transition(JournalState::AuditFinalized, None).unwrap();
215 assert!(Journal::recoverable(&db).unwrap().is_empty());
216 }
217
218 #[test]
219 fn illegal_transitions_rejected() {
220 let (_dir, db) = db();
221 let j = Journal::create(&db, "req-2", "c1", &[], "ddl").unwrap();
222 assert!(j.transition(JournalState::MutationCommitted, None).is_err());
223 j.transition(JournalState::BackupCapturing, None).unwrap();
224 assert!(j.transition(JournalState::MutationCommitted, None).is_err());
227 }
228
229 #[test]
230 fn crash_before_audit_finalization_is_recoverable() {
231 let (_dir, db) = db();
232 let j = Journal::create(&db, "req-3", "c1", &[], "write").unwrap();
233 for s in [
234 JournalState::BackupCapturing,
235 JournalState::BackupDurable,
236 JournalState::MutationExecuting,
237 JournalState::MutationCommitted,
238 ] {
239 j.transition(s, None).unwrap();
240 }
241 let r = Journal::recoverable(&db).unwrap();
243 assert_eq!(r.len(), 1, "{r:?}");
244 assert_eq!(r[0].1, "mutation_committed");
245 }
246
247 #[test]
248 fn uncertain_is_terminal_and_not_recoverable() {
249 let (_dir, db) = db();
250 let j = Journal::create(&db, "req-4", "c1", &[], "write").unwrap();
251 j.transition(JournalState::BackupCapturing, None).unwrap();
252 j.transition(JournalState::BackupDurable, None).unwrap();
253 j.transition(JournalState::MutationExecuting, None).unwrap();
254 j.transition(JournalState::Uncertain, Some("cancellation inconclusive"))
255 .unwrap();
256 assert!(Journal::recoverable(&db).unwrap().is_empty());
257 }
258
259 #[test]
260 fn backup_failure_leads_to_failed_not_mutation() {
261 let (_dir, db) = db();
262 let j = Journal::create(&db, "req-5", "c1", &[], "write").unwrap();
263 j.transition(JournalState::BackupCapturing, None).unwrap();
264 j.transition(JournalState::Failed, Some("backup overflow"))
265 .unwrap();
266 assert!(Journal::recoverable(&db).unwrap().is_empty());
267 }
268}