Skip to main content

sloop/db/
mod.rs

1use std::fmt;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex, MutexGuard};
4
5use rusqlite::Connection;
6
7mod migrations;
8
9#[cfg(test)]
10pub(crate) use migrations::{LEGACY_STAGE_TABLE, REVERT_TRIGGER_RENAME};
11
12pub const SCHEMA_VERSION: u32 = 16;
13
14// `synchronous = NORMAL` is the standard WAL pairing: commits skip the
15// per-transaction fsync (durability moves to checkpoints), which keeps the
16// write lock short under contention. The busy timeout is generous because
17// SQLite's busy handler has no fairness queue: under sustained multi-writer
18// load a waiter can starve well past a "reasonable" wait before winning.
19const CONNECTION_PRAGMAS: &str = "
20PRAGMA foreign_keys = ON;
21PRAGMA journal_mode = WAL;
22PRAGMA synchronous = NORMAL;
23PRAGMA busy_timeout = 30000;
24";
25
26#[derive(Clone)]
27pub struct Db(Arc<Mutex<Connection>>);
28
29impl Db {
30    pub fn open(path: &Path, now_ms: i64) -> Result<Self, DbError> {
31        let mut connection = Connection::open(path).map_err(|source| DbError::Open {
32            path: path.to_path_buf(),
33            source,
34        })?;
35        connection.execute_batch(CONNECTION_PRAGMAS)?;
36        migrations::migrate(&mut connection, now_ms)?;
37        Ok(Self(Arc::new(Mutex::new(connection))))
38    }
39
40    pub(crate) fn lock(&self) -> MutexGuard<'_, Connection> {
41        self.0
42            .lock()
43            .unwrap_or_else(|poisoned| poisoned.into_inner())
44    }
45}
46
47#[derive(Debug)]
48pub enum DbError {
49    Open {
50        path: PathBuf,
51        source: rusqlite::Error,
52    },
53    Sqlite(rusqlite::Error),
54    UnsupportedSchemaVersion(u32),
55}
56
57impl From<rusqlite::Error> for DbError {
58    fn from(source: rusqlite::Error) -> Self {
59        Self::Sqlite(source)
60    }
61}
62
63impl fmt::Display for DbError {
64    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            Self::Open { path, source } => {
67                write!(formatter, "cannot open {}: {source}", path.display())
68            }
69            Self::Sqlite(source) => write!(formatter, "database error: {source}"),
70            Self::UnsupportedSchemaVersion(version) => {
71                write!(formatter, "unsupported database schema version {version}")
72            }
73        }
74    }
75}
76
77impl std::error::Error for DbError {}
78
79#[derive(Debug)]
80pub enum StoreError {
81    Open {
82        path: PathBuf,
83        source: rusqlite::Error,
84    },
85    Sqlite(rusqlite::Error),
86    UnsupportedSchemaVersion(u32),
87    TicketNotReady {
88        ticket_id: String,
89        state: Option<String>,
90    },
91    TicketNotFound {
92        ticket_id: String,
93    },
94    TicketStateConflict {
95        ticket_id: String,
96        state: String,
97        requested: String,
98    },
99    TriggerNotQueued {
100        trigger_id: String,
101    },
102    LeaseNotHeld {
103        ticket_id: String,
104        run_id: String,
105    },
106    RunNotFound {
107        run_id: String,
108    },
109    RunStateConflict {
110        run_id: String,
111        state: Option<String>,
112        requested: String,
113    },
114    UnknownRunState {
115        state: String,
116    },
117}
118
119impl StoreError {
120    pub fn is_disk_full(&self) -> bool {
121        let source = match self {
122            Self::Open { source, .. } | Self::Sqlite(source) => source,
123            _ => return false,
124        };
125        matches!(
126            source,
127            rusqlite::Error::SqliteFailure(error, _)
128                if error.code == rusqlite::ffi::ErrorCode::DiskFull
129        )
130    }
131}
132
133impl From<rusqlite::Error> for StoreError {
134    fn from(source: rusqlite::Error) -> Self {
135        Self::Sqlite(source)
136    }
137}
138
139impl From<DbError> for StoreError {
140    fn from(source: DbError) -> Self {
141        match source {
142            DbError::Open { path, source } => Self::Open { path, source },
143            DbError::Sqlite(source) => Self::Sqlite(source),
144            DbError::UnsupportedSchemaVersion(version) => Self::UnsupportedSchemaVersion(version),
145        }
146    }
147}
148
149impl fmt::Display for StoreError {
150    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
151        match self {
152            Self::Open { path, source } => {
153                write!(formatter, "cannot open {}: {source}", path.display())
154            }
155            Self::Sqlite(source) => write!(formatter, "database error: {source}"),
156            Self::UnsupportedSchemaVersion(version) => {
157                write!(formatter, "unsupported database schema version {version}")
158            }
159            Self::TicketNotReady { ticket_id, state } => match state {
160                Some(state) => write!(formatter, "ticket `{ticket_id}` is `{state}`, not `ready`"),
161                None => write!(formatter, "ticket `{ticket_id}` does not exist"),
162            },
163            Self::TicketNotFound { ticket_id } => {
164                write!(formatter, "ticket `{ticket_id}` does not exist")
165            }
166            Self::TicketStateConflict {
167                ticket_id,
168                state,
169                requested,
170            } => write!(
171                formatter,
172                "ticket `{ticket_id}` is `{state}` and cannot be changed to `{requested}`"
173            ),
174            Self::TriggerNotQueued { trigger_id } => write!(
175                formatter,
176                "trigger `{trigger_id}` is not queued for dispatch"
177            ),
178            Self::LeaseNotHeld { ticket_id, run_id } => write!(
179                formatter,
180                "run `{run_id}` does not hold the lease on ticket `{ticket_id}`"
181            ),
182            Self::RunNotFound { run_id } => write!(formatter, "run `{run_id}` does not exist"),
183            Self::RunStateConflict {
184                run_id,
185                state,
186                requested,
187            } => match state {
188                Some(state) => write!(
189                    formatter,
190                    "run `{run_id}` is `{state}` and cannot be changed to `{requested}`"
191                ),
192                None => write!(formatter, "run `{run_id}` does not exist"),
193            },
194            Self::UnknownRunState { state } => {
195                write!(formatter, "unrecognized run state `{state}`")
196            }
197        }
198    }
199}
200
201impl std::error::Error for StoreError {}
202
203#[cfg(test)]
204mod tests {
205    use super::StoreError;
206
207    #[test]
208    fn sqlite_full_errors_are_classified_for_backpressure() {
209        let sqlite = rusqlite::Error::SqliteFailure(
210            rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_FULL),
211            None,
212        );
213        assert!(StoreError::from(sqlite).is_disk_full());
214        assert!(
215            !StoreError::TicketNotFound {
216                ticket_id: "T1".into()
217            }
218            .is_disk_full()
219        );
220    }
221}