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
14const 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}