dpm_store/error.rs
1use dpm_engine::EngineError;
2use dpm_model::ValidationError;
3use std::path::PathBuf;
4use thiserror::Error;
5
6/// A persistence operation that could not be completed safely.
7#[derive(Debug, Error)]
8pub enum StoreError {
9 /// SQLite failed while accessing the named database.
10 #[cfg(feature = "sqlite")]
11 #[error("{action} in {path}: {source}")]
12 Database {
13 /// Database location or the in-memory label.
14 path: PathBuf,
15 /// Failed persistence action.
16 action: &'static str,
17 /// Original database error.
18 #[source]
19 source: rusqlite::Error,
20 },
21 /// Snapshot or operation serialization failed.
22 #[error(transparent)]
23 Json(#[from] serde_json::Error),
24 /// The supplied graph violates model invariants.
25 #[error(transparent)]
26 Validation(#[from] ValidationError),
27 /// The supplied operation cannot produce a valid state transition.
28 #[error(transparent)]
29 Engine(#[from] EngineError),
30 /// A caller attempted to replace existing history during initialization.
31 #[error("workspace already exists at {0}")]
32 AlreadyInitialized(PathBuf),
33 /// Persistence requires an existing initial snapshot.
34 #[error("workspace is not initialized at {0}")]
35 NotInitialized(PathBuf),
36 /// The caller's base revision is stale.
37 #[error("revision conflict: store has {actual}, operation expected {expected}")]
38 RevisionConflict {
39 /// Revision required by the caller.
40 expected: u64,
41 /// Current stored revision.
42 actual: u64,
43 },
44 /// Snapshot revisions do not describe one consecutive wrapping operation.
45 #[error("invalid operation revision: base {base}, result {result}, plan {plan}")]
46 InvalidOperationRevision {
47 /// Revision before the operation.
48 base: u64,
49 /// Claimed resulting revision.
50 result: u64,
51 /// Supplied snapshot revision.
52 plan: u64,
53 },
54 /// Snapshot contents differ from the actual result of the supplied command.
55 #[error("snapshot does not match operation {0}")]
56 SnapshotMismatch(dpm_model::OperationId),
57 /// Stored revision metadata disagrees with the serialized plan.
58 #[error("corrupt {record} at {path}: row revision {row}, JSON revision {json}")]
59 CorruptSnapshot {
60 /// Database containing the inconsistent plan.
61 path: PathBuf,
62 /// Snapshot or genesis plan.
63 record: StoredRecord,
64 /// Revision in the SQLite row.
65 row: u64,
66 /// Revision inside the plan JSON.
67 json: u64,
68 },
69 /// The store was written by a binary with a different, unsupported layout version.
70 #[error(
71 "store at {path} has schema version {found}, but this dpm supports up to {supported}; \
72 nothing was written: open it with the dpm release that wrote it, or back it up with that \
73 release before upgrading"
74 )]
75 UnsupportedSchemaVersion {
76 /// Database carrying the version.
77 path: PathBuf,
78 /// Version recorded in the store header.
79 found: i64,
80 /// Newest version this binary understands.
81 supported: i64,
82 },
83 /// The store was written in a retired layout whose operation log cannot be replayed.
84 #[error(
85 "store at {path} has schema version {found}, which this dpm no longer opens (it writes \
86 version {current}); nothing was written. Back it up and keep it as the archive of its \
87 history, run `dpm export` with the dpm release that wrote it, and import that export \
88 into a new store with `dpm import <file>`"
89 )]
90 RetiredSchemaVersion {
91 /// Database carrying the version.
92 path: PathBuf,
93 /// Version recorded in the store header; 0 for DPM tables without a header.
94 found: i64,
95 /// Version this binary writes.
96 current: i64,
97 },
98 /// The file is SQLite but its tables are not a DPM store layout.
99 #[error("{path} is not a DPM store: {detail}")]
100 UnrecognizedSchema {
101 /// Inspected database.
102 path: PathBuf,
103 /// First layout difference found.
104 detail: String,
105 },
106 /// Backup and restore only ever create new files.
107 #[error("{path} already exists; choose a new path, existing files are never overwritten")]
108 TargetExists {
109 /// Existing file or SQLite side file at the target location.
110 path: PathBuf,
111 },
112 /// A filesystem operation outside SQLite failed.
113 #[error("{action} {path}: {source}")]
114 Io {
115 /// Failed filesystem action.
116 action: &'static str,
117 /// Affected location.
118 path: PathBuf,
119 /// Original I/O error.
120 #[source]
121 source: std::io::Error,
122 },
123 /// The online backup did not copy every page in one step.
124 #[error("backup into {path} did not complete: {state}")]
125 BackupIncomplete {
126 /// Destination being written.
127 path: PathBuf,
128 /// SQLite step result that ended the copy.
129 state: String,
130 },
131 /// SQLite's page-level integrity check reported damage.
132 #[error("integrity check failed for {path}: {}", problems.join("; "))]
133 IntegrityCheckFailed {
134 /// Damaged database.
135 path: PathBuf,
136 /// First reported problems.
137 problems: Vec<String>,
138 },
139 /// Operation revisions do not form one consecutive chain.
140 #[error(
141 "history of {path} breaks at sequence {sequence}: expected revision {expected}, found {found}"
142 )]
143 HistoryDiscontinuity {
144 /// Inspected database.
145 path: PathBuf,
146 /// Local append sequence of the offending operation.
147 sequence: u64,
148 /// Revision the chain requires.
149 expected: u64,
150 /// Revision the operation records.
151 found: u64,
152 },
153 /// The snapshot is not the state produced by the last recorded operation.
154 #[error(
155 "snapshot of {path} is at revision {snapshot}, but the last operation produced {last_operation}"
156 )]
157 SnapshotRevisionMismatch {
158 /// Inspected database.
159 path: PathBuf,
160 /// Revision of the stored snapshot.
161 snapshot: u64,
162 /// Resulting revision of the last operation.
163 last_operation: u64,
164 },
165 /// A stored JSON field no longer decodes.
166 #[error("corrupt {record} {field} in {path}: {source}")]
167 CorruptJson {
168 /// Database containing the record.
169 path: PathBuf,
170 /// Snapshot or operation holding the field.
171 record: StoredRecord,
172 /// Column holding the damaged JSON.
173 field: &'static str,
174 /// Decoding failure.
175 #[source]
176 source: serde_json::Error,
177 },
178 /// A stored column holds a value of the wrong type.
179 #[cfg(feature = "sqlite")]
180 #[error("corrupt {record} {field} in {path}: {source}")]
181 CorruptColumn {
182 /// Database containing the record.
183 path: PathBuf,
184 /// Snapshot or operation holding the field.
185 record: StoredRecord,
186 /// Damaged column.
187 field: &'static str,
188 /// Column decoding failure.
189 #[source]
190 source: rusqlite::Error,
191 },
192 /// Local append sequences skip a value, so an operation row was removed.
193 #[error("history of {path} skips from sequence {previous} to {sequence}")]
194 SequenceGap {
195 /// Inspected database.
196 path: PathBuf,
197 /// Sequence of the preceding operation.
198 previous: u64,
199 /// Next sequence found.
200 sequence: u64,
201 },
202 /// No operation leads from the genesis plan to the snapshot.
203 #[error(
204 "history of {path} is empty, but the snapshot moved from genesis revision {origin} to {snapshot}"
205 )]
206 MissingHistory {
207 /// Inspected database.
208 path: PathBuf,
209 /// Revision the history must start from.
210 origin: u64,
211 /// Revision of the stored snapshot.
212 snapshot: u64,
213 },
214 /// A store has a snapshot but no genesis plan to replay its history from.
215 #[error("{path} has a snapshot but no genesis plan")]
216 MissingGenesis {
217 /// Inspected database.
218 path: PathBuf,
219 },
220 /// The engine refuses a recorded operation when the history is replayed from genesis.
221 #[error("replaying the history of {path} fails at sequence {sequence}: {source}")]
222 ReplayRefused {
223 /// Inspected database.
224 path: PathBuf,
225 /// Local append sequence of the refused operation.
226 sequence: u64,
227 /// Engine refusal.
228 #[source]
229 source: EngineError,
230 },
231 /// Replaying genesis and every operation does not reproduce the stored snapshot.
232 #[error(
233 "replaying the history of {path} to revision {revision} does not reproduce the snapshot; \
234 differing: {}", differing.join(", ")
235 )]
236 ReplayDiverged {
237 /// Inspected database.
238 path: PathBuf,
239 /// Revision of the stored snapshot.
240 revision: u64,
241 /// Top-level plan fields whose replayed value differs from the snapshot.
242 differing: Vec<String>,
243 },
244 /// The store's lineage is missing, archived, or not the one the caller observed.
245 #[error(transparent)]
246 Lineage(#[from] LineageError),
247 /// An operation belongs to a different workspace than the store's genesis plan.
248 #[error("operation sequence {sequence} in {path} belongs to workspace {found}, not {expected}")]
249 ForeignOperation {
250 /// Inspected database.
251 path: PathBuf,
252 /// Local append sequence of the offending operation.
253 sequence: u64,
254 /// Workspace the operation records.
255 found: dpm_model::WorkspaceId,
256 /// Workspace of the genesis plan.
257 expected: dpm_model::WorkspaceId,
258 },
259 /// The operation identity is already recorded; the caller decides whether the resend matches.
260 #[error("operation {} is already recorded at sequence {}", .recorded.operation.operation.id, .recorded.sequence)]
261 DuplicateOperation {
262 /// The recorded operation with this identity.
263 recorded: Box<crate::HistoryEntry>,
264 },
265 /// Backup and restore targets must be a plain database file name in an existing directory.
266 #[error("cannot write a store to {path}: {reason}")]
267 InvalidTarget {
268 /// Requested destination.
269 path: PathBuf,
270 /// Why the destination is unusable.
271 reason: &'static str,
272 },
273}
274
275/// Why a store's lineage does not permit a read or write.
276#[derive(Debug, Error)]
277pub enum LineageError {
278 /// A store has no lineage row, so its writable history cannot be identified.
279 #[error("{path} records no lineage")]
280 Missing {
281 /// Inspected database.
282 path: PathBuf,
283 },
284 /// A restore forks a new writable lineage, so its source must be a sealed backup archive: an
285 /// interrupted copy of an archive stays sealed, while one of a live store would be a writable
286 /// file sharing that store's lineage.
287 #[error(
288 "{path} is a live store, not a backup archive; run `dpm backup` first and restore the backup"
289 )]
290 NotAnArchive {
291 /// Live store named as the restore source.
292 path: PathBuf,
293 },
294 /// A backup archive is read and verified, never written.
295 #[error(
296 "{path} is a backup archive; restore it into a new store with `dpm restore` to continue \
297 writing"
298 )]
299 Archived {
300 /// Archive the write targeted.
301 path: PathBuf,
302 },
303 /// The store continues a different writable history than the caller observed.
304 #[error(
305 "lineage conflict: store continues lineage {actual}, operation expected {expected}; \
306 reload before writing, histories are never merged"
307 )]
308 Mismatch {
309 /// Lineage the caller observed.
310 expected: dpm_model::LineageId,
311 /// Lineage of the store.
312 actual: dpm_model::LineageId,
313 },
314}
315
316/// Which stored record a corruption report refers to.
317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
318pub enum StoredRecord {
319 /// The authoritative plan snapshot.
320 Snapshot,
321 /// One operation, addressed by its local append sequence.
322 Operation {
323 /// Local append sequence.
324 sequence: u64,
325 },
326 /// The plan the operation history replays from.
327 Genesis,
328 /// The lineage row of the file.
329 Lineage,
330 /// A record of the run store.
331 Run,
332}
333
334impl std::fmt::Display for StoredRecord {
335 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336 match self {
337 Self::Snapshot => formatter.write_str("snapshot"),
338 Self::Operation { sequence } => write!(formatter, "operation sequence {sequence}"),
339 Self::Genesis => formatter.write_str("genesis plan"),
340 Self::Lineage => formatter.write_str("lineage"),
341 Self::Run => formatter.write_str("run store record"),
342 }
343 }
344}
345
346impl StoreError {
347 /// Whether the stored data itself is damaged or inconsistent, as opposed to a refused request.
348 pub fn is_corruption(&self) -> bool {
349 match self {
350 Self::IntegrityCheckFailed { .. }
351 | Self::HistoryDiscontinuity { .. }
352 | Self::SnapshotRevisionMismatch { .. }
353 | Self::CorruptSnapshot { .. }
354 | Self::CorruptJson { .. }
355 | Self::SequenceGap { .. }
356 | Self::MissingHistory { .. }
357 | Self::MissingGenesis { .. }
358 | Self::Lineage(LineageError::Missing { .. })
359 | Self::ForeignOperation { .. }
360 | Self::ReplayRefused { .. }
361 | Self::ReplayDiverged { .. }
362 | Self::UnrecognizedSchema { .. } => true,
363 #[cfg(feature = "sqlite")]
364 Self::CorruptColumn { .. } => true,
365 #[cfg(feature = "sqlite")]
366 Self::Database { source, .. } => matches!(
367 source.sqlite_error_code(),
368 Some(rusqlite::ErrorCode::DatabaseCorrupt | rusqlite::ErrorCode::NotADatabase)
369 ),
370 _ => false,
371 }
372 }
373
374 /// Whether another connection held the database lock past the busy timeout; retrying later
375 /// may succeed, and nothing was written.
376 pub fn is_busy(&self) -> bool {
377 #[cfg(feature = "sqlite")]
378 if let Self::Database { source, .. } = self {
379 return matches!(
380 source.sqlite_error_code(),
381 Some(rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked)
382 );
383 }
384 false
385 }
386}
387
388#[cfg(feature = "sqlite")]
389pub(crate) fn database_error(
390 path: &std::path::Path,
391 action: &'static str,
392) -> impl FnOnce(rusqlite::Error) -> StoreError {
393 let path = path.to_path_buf();
394 move |source| StoreError::Database {
395 path,
396 action,
397 source,
398 }
399}