Skip to main content

khive_db/
error.rs

1//! Error types for the SQLite storage layer.
2
3use std::time::Duration;
4
5use khive_storage::{StorageCapability, StorageError, WriterTaskRequestState};
6use thiserror::Error;
7
8/// Stable ADR-194 capacity stages. The refusal stage is reserved for the WAL
9/// I/O limiter; this configuration-only slice emits only unavailable.
10pub const SQLITE_WAL_CAPACITY_REFUSED_STAGE: &str = "sqlite_wal_capacity_refused";
11pub const SQLITE_WAL_CAPACITY_UNAVAILABLE_STAGE: &str = "sqlite_wal_capacity_unavailable";
12
13/// Errors produced by the SQLite storage backend.
14#[derive(Debug, Error)]
15pub enum SqliteError {
16    /// A request-scoped read or store acquisition stopped, or read cleanup failed.
17    #[error(transparent)]
18    RequestReadStopped(khive_storage::StorageError),
19
20    /// Underlying rusqlite driver error.
21    #[error("sqlite error: {0}")]
22    Rusqlite(#[from] rusqlite::Error),
23
24    /// Data invariant violation (corrupt row, unexpected schema state).
25    #[error("invalid data: {0}")]
26    InvalidData(String),
27
28    /// A pooled connection contained a transaction from an earlier owner.
29    /// Its prior side effects cannot be attributed to the new request.
30    #[error("pooled writer contains an inherited transaction; prior side effects are unknown")]
31    InheritedWriterTransaction,
32
33    /// The writer could not prove transaction settlement before retirement.
34    #[error("writer transaction settlement is unknown; connection retired")]
35    WriterSettlementUnknown,
36
37    /// An earlier write on this database could not prove its settlement, so
38    /// every later write is refused before it starts. Only the write whose
39    /// settlement failed reports an unknown outcome; this one never ran.
40    #[error("writer refused: an earlier write's settlement is unknown; this write did not start")]
41    WriterPoisoned,
42
43    /// The process-local writer mutex was not acquired within the pool's
44    /// configured finite checkout deadline. This stage happens before SQLite
45    /// executes, so callers must not conflate it with SQLite busy/locked or
46    /// checkpoint starvation.
47    ///
48    /// The display text intentionally retains the historical `InvalidData`
49    /// prefix for compatibility while the variant supplies stable structural
50    /// classification (ADR-135 F6).
51    #[error("invalid data: timed out after {timeout:?} waiting for sqlite writer connection")]
52    WriterPoolCheckoutTimeout {
53        /// Pool checkout deadline that elapsed.
54        timeout: Duration,
55    },
56
57    /// A file-backed writer was refused before SQLite began the operation
58    /// because the volume's free space had reached its configured reserve.
59    #[error(
60        "refusing sqlite write on {volume}: {available_bytes} bytes available, \
61         at or below the {floor_bytes}-byte free-space floor plus \
62         {required_headroom_bytes} bytes of operation headroom"
63    )]
64    CapacityFloor {
65        volume: String,
66        available_bytes: u64,
67        floor_bytes: u64,
68        required_headroom_bytes: u64,
69    },
70
71    /// A new logical write could not resolve its volume, acquire its lease,
72    /// or sample available space.
73    #[error("sqlite capacity admission unavailable in {phase} phase: {message}")]
74    CapacityUnavailable {
75        phase: khive_storage::CapacityUnavailablePhase,
76        message: String,
77    },
78
79    /// The thread asking for a volume's write lease already holds it, so
80    /// waiting could never succeed. This is a nested write, not lock
81    /// contention, and it is refused at once instead of at the deadline.
82    #[error(
83        "volume lease re-entry: this thread already holds the lease for this volume \
84         (held at {holder_site}, requested again at {requester_site})"
85    )]
86    VolumeLeaseReentry {
87        holder_site: String,
88        requester_site: String,
89    },
90
91    /// A configured WAL ceiling cannot be represented by SQLite's signed
92    /// file-offset arithmetic.
93    #[error("invalid WAL ceiling {bytes} bytes: exceeds supported SQLite file offsets")]
94    WalCeilingOffsetOverflow { bytes: u64 },
95
96    /// A WAL ceiling was enabled for a backend that cannot produce a WAL.
97    #[error("invalid WAL ceiling {bytes} bytes: {backend_kind} does not support WAL enforcement")]
98    WalCeilingUnsupported {
99        bytes: u64,
100        backend_kind: &'static str,
101    },
102
103    /// One committed WAL frame cannot fit, even immediately after reset.
104    #[error(
105        "invalid WAL ceiling {bytes} bytes: page size {page_size} requires at least {minimum_bytes} bytes for one WAL frame"
106    )]
107    WalCeilingBelowMinimum {
108        bytes: u64,
109        page_size: u64,
110        minimum_bytes: u64,
111    },
112
113    /// A valid enabled policy cannot run until its WAL I/O limiter exists.
114    #[error(
115        "{stage}: WAL ceiling {bytes} bytes cannot be enforced: missing {capability}",
116        stage = SQLITE_WAL_CAPACITY_UNAVAILABLE_STAGE
117    )]
118    WalCapacityUnavailable {
119        bytes: u64,
120        capability: &'static str,
121    },
122
123    /// A `PoolConfig` value violated a validated invariant at configuration
124    /// load time (e.g. ADR-131 Decision 2's `write_admission_deadline_ms`
125    /// range). Fires before any connection is opened, and is never silently
126    /// clamped into range.
127    #[error("invalid config: {0}")]
128    InvalidConfig(String),
129
130    /// Filesystem I/O error.
131    #[error("io error: {0}")]
132    Io(#[from] std::io::Error),
133
134    /// A versioned migration failed to apply.
135    #[error("migration v{version} failed: {error}")]
136    Migration {
137        /// The migration version number that failed.
138        version: u32,
139        /// Human-readable description of the failure.
140        error: String,
141    },
142}
143
144impl SqliteError {
145    /// Stable structured stage for an ADR-194 WAL-capacity failure.
146    pub fn wal_capacity_stage(&self) -> Option<&'static str> {
147        match self {
148            Self::WalCapacityUnavailable { .. } => Some(SQLITE_WAL_CAPACITY_UNAVAILABLE_STAGE),
149            _ => None,
150        }
151    }
152
153    /// Capacity admission is a property of the SQLite file, so its refusals
154    /// carry `StorageCapability::Sql` whichever store requested the write.
155    pub(crate) fn into_storage_error(
156        self,
157        capability: StorageCapability,
158        operation: &'static str,
159    ) -> StorageError {
160        match self {
161            Self::CapacityFloor {
162                volume,
163                available_bytes,
164                floor_bytes,
165                required_headroom_bytes,
166            } => StorageError::CapacityFloor {
167                capability: StorageCapability::Sql,
168                volume,
169                available_bytes,
170                floor_bytes,
171                required_headroom_bytes,
172            },
173            Self::CapacityUnavailable { phase, message } => StorageError::CapacityUnavailable {
174                capability: StorageCapability::Sql,
175                phase,
176                message,
177            },
178            Self::InheritedWriterTransaction | Self::WriterSettlementUnknown => {
179                StorageError::WriterTaskTerminated {
180                    request_state: WriterTaskRequestState::SideEffectsUnknown,
181                }
182            }
183            Self::WriterPoisoned => StorageError::WriterTaskTerminated {
184                request_state: WriterTaskRequestState::NotStarted,
185            },
186            other => StorageError::driver(capability, operation, other),
187        }
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use khive_storage::CapacityUnavailablePhase;
195
196    #[test]
197    fn capacity_and_settlement_errors_keep_their_meaning_from_any_store() {
198        for capability in [StorageCapability::Entities, StorageCapability::Sql] {
199            let refused = SqliteError::CapacityFloor {
200                volume: "/volume".to_string(),
201                available_bytes: 99,
202                floor_bytes: 100,
203                required_headroom_bytes: 12,
204            }
205            .into_storage_error(capability, "write");
206            assert!(
207                matches!(
208                    refused,
209                    StorageError::CapacityFloor {
210                        capability: StorageCapability::Sql,
211                        available_bytes: 99,
212                        floor_bytes: 100,
213                        required_headroom_bytes: 12,
214                        ..
215                    }
216                ),
217                "{capability:?}: {refused:?}"
218            );
219
220            let unavailable = SqliteError::CapacityUnavailable {
221                phase: CapacityUnavailablePhase::Lock,
222                message: "lease timed out".to_string(),
223            }
224            .into_storage_error(capability, "write");
225            assert!(
226                matches!(
227                    unavailable,
228                    StorageError::CapacityUnavailable {
229                        capability: StorageCapability::Sql,
230                        phase: CapacityUnavailablePhase::Lock,
231                        ..
232                    }
233                ),
234                "{capability:?}: {unavailable:?}"
235            );
236
237            for unsettled in [
238                SqliteError::InheritedWriterTransaction,
239                SqliteError::WriterSettlementUnknown,
240            ] {
241                let mapped = unsettled.into_storage_error(capability, "write");
242                assert!(
243                    matches!(
244                        mapped,
245                        StorageError::WriterTaskTerminated {
246                            request_state: WriterTaskRequestState::SideEffectsUnknown,
247                        }
248                    ),
249                    "{capability:?}: {mapped:?}"
250                );
251            }
252
253            let refused = SqliteError::WriterPoisoned.into_storage_error(capability, "write");
254            assert!(
255                matches!(
256                    refused,
257                    StorageError::WriterTaskTerminated {
258                        request_state: WriterTaskRequestState::NotStarted,
259                    }
260                ),
261                "{capability:?}: {refused:?}"
262            );
263        }
264    }
265}