1use std::borrow::Cow;
4use std::error::Error as StdError;
5use std::fmt;
6
7use thiserror::Error;
8
9use crate::blob::ContentRef;
10use crate::capability::StorageCapability;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum WriterTaskRequestState {
21 NotStarted,
23 TransactionRolledBack,
26 SideEffectsUnknown,
30}
31
32impl fmt::Display for WriterTaskRequestState {
33 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34 f.write_str(match self {
35 Self::NotStarted => "not_started",
36 Self::TransactionRolledBack => "transaction_rolled_back",
37 Self::SideEffectsUnknown => "side_effects_unknown",
38 })
39 }
40}
41
42#[derive(Debug, Error)]
44pub enum StorageError {
45 #[error("{capability:?} resource not found: {resource} ({key})")]
46 NotFound {
47 capability: StorageCapability,
48 resource: &'static str,
49 key: String,
50 },
51
52 #[error("{capability:?} resource already exists: {resource} ({key})")]
53 AlreadyExists {
54 capability: StorageCapability,
55 resource: &'static str,
56 key: String,
57 },
58
59 #[error("conflict in {capability:?} during {operation}: {message}")]
60 Conflict {
61 capability: StorageCapability,
62 operation: Cow<'static, str>,
63 message: String,
64 },
65
66 #[error("invalid input for {capability:?} during {operation}: {message}")]
67 InvalidInput {
68 capability: StorageCapability,
69 operation: Cow<'static, str>,
70 message: String,
71 },
72
73 #[error("unsupported operation for {capability:?}: {operation} ({message})")]
74 Unsupported {
75 capability: StorageCapability,
76 operation: Cow<'static, str>,
77 message: String,
78 },
79
80 #[error(
85 "blob {content_ref} exceeds the {max_bytes}-byte read limit (observed at least {observed_at_least} bytes)"
86 )]
87 BlobTooLarge {
88 content_ref: ContentRef,
89 max_bytes: u64,
90 observed_at_least: u64,
91 },
92
93 #[error(
96 "blob {content_ref} metadata reports {metadata_bytes} bytes but the complete body contains {actual_bytes} bytes"
97 )]
98 BlobSizeMismatch {
99 content_ref: ContentRef,
100 metadata_bytes: u64,
101 actual_bytes: u64,
102 },
103
104 #[error("blob digest mismatch: expected {expected}, computed {actual}")]
107 BlobDigestMismatch {
108 expected: ContentRef,
109 actual: ContentRef,
110 },
111
112 #[error("pool failure during {operation}: {message}")]
113 Pool {
114 operation: Cow<'static, str>,
115 message: String,
116 },
117
118 #[error("timeout during {operation}")]
119 Timeout { operation: Cow<'static, str> },
120
121 #[error("admission timeout during {operation} after {timeout_ms}ms")]
127 AdmissionTimeout {
128 operation: Cow<'static, str>,
129 timeout_ms: u64,
131 },
132
133 #[error("sql transaction failure during {operation}: {message}")]
134 Transaction {
135 operation: Cow<'static, str>,
136 message: String,
137 },
138
139 #[error(
148 "cached read-only transaction exceeded the maximum read-transaction age \
149 ({max_age_secs}s) during {operation} and was rolled back; retry to open a fresh \
150 read snapshot"
151 )]
152 ReadTransactionAgeEvicted {
153 operation: Cow<'static, str>,
154 max_age_secs: u64,
155 },
156
157 #[error(
169 "cached read-only transaction exceeded the maximum read-transaction age \
170 ({max_age_secs}s) during {operation} but could not be cleanly rolled back \
171 ({message}); the connection was discarded, retry to open a fresh read snapshot"
172 )]
173 ReadTransactionAgeEvictionCleanupFailed {
174 operation: Cow<'static, str>,
175 max_age_secs: u64,
176 message: String,
177 },
178
179 #[error("serialization failure in {capability:?}: {message}")]
180 Serialization {
181 capability: StorageCapability,
182 message: String,
183 },
184
185 #[error("index maintenance failure in {capability:?}: {message}")]
186 IndexMaintenance {
187 capability: StorageCapability,
188 message: String,
189 },
190
191 #[error("backend driver error in {capability:?} during {operation}: {source}")]
192 Driver {
193 capability: StorageCapability,
194 operation: Cow<'static, str>,
195 #[source]
196 source: Box<dyn StdError + Send + Sync>,
197 },
198
199 #[error("write queue full: timed out after {timeout_ms}ms waiting for writer task capacity")]
204 WriteQueueFull { timeout_ms: u64 },
205
206 #[error(
211 "writer task could not begin within {timeout_ms}ms because SQLite remained busy; request was not executed"
212 )]
213 WriterTaskBusy { timeout_ms: u64 },
214
215 #[error("writer task terminated (request_state={request_state})")]
222 WriterTaskTerminated {
223 request_state: WriterTaskRequestState,
224 },
225
226 #[error("internal storage error: {0}")]
229 Internal(String),
230
231 #[error(
236 "KHIVE_WRITE_QUEUE=1 but no Tokio runtime context is available to spawn the writer task"
237 )]
238 WriterTaskNoRuntime,
239
240 #[error(
244 "refusing write on {capability:?} at {volume}: {available_bytes} bytes available, \
245 below the {floor_bytes}-byte floor"
246 )]
247 CapacityFloor {
248 capability: StorageCapability,
249 volume: String,
250 available_bytes: u64,
251 floor_bytes: u64,
252 },
253}
254
255impl StorageError {
256 pub fn driver(
258 capability: StorageCapability,
259 operation: impl Into<Cow<'static, str>>,
260 source: impl StdError + Send + Sync + 'static,
261 ) -> Self {
262 Self::Driver {
263 capability,
264 operation: operation.into(),
265 source: Box::new(source),
266 }
267 }
268
269 pub fn capability(&self) -> Option<StorageCapability> {
271 match self {
272 Self::NotFound { capability, .. }
273 | Self::AlreadyExists { capability, .. }
274 | Self::Conflict { capability, .. }
275 | Self::InvalidInput { capability, .. }
276 | Self::Unsupported { capability, .. }
277 | Self::Serialization { capability, .. }
278 | Self::IndexMaintenance { capability, .. }
279 | Self::Driver { capability, .. }
280 | Self::CapacityFloor { capability, .. } => Some(*capability),
281 Self::BlobTooLarge { .. }
282 | Self::BlobSizeMismatch { .. }
283 | Self::BlobDigestMismatch { .. } => Some(StorageCapability::Blob),
284 Self::Pool { .. }
285 | Self::Timeout { .. }
286 | Self::AdmissionTimeout { .. }
287 | Self::Transaction { .. }
288 | Self::ReadTransactionAgeEvicted { .. }
289 | Self::ReadTransactionAgeEvictionCleanupFailed { .. }
290 | Self::WriteQueueFull { .. }
291 | Self::WriterTaskBusy { .. }
292 | Self::WriterTaskTerminated { .. }
293 | Self::Internal(..)
294 | Self::WriterTaskNoRuntime => None,
295 }
296 }
297
298 pub fn is_retryable(&self) -> bool {
300 matches!(
301 self,
302 Self::Pool { .. }
303 | Self::Timeout { .. }
304 | Self::AdmissionTimeout { .. }
305 | Self::Transaction { .. }
306 | Self::ReadTransactionAgeEvicted { .. }
307 | Self::ReadTransactionAgeEvictionCleanupFailed { .. }
308 | Self::WriteQueueFull { .. }
309 | Self::WriterTaskBusy { .. }
310 )
311 }
312
313 pub fn is_fts5_syntax_error(&self) -> bool {
329 let Self::Driver {
330 capability,
331 operation,
332 source,
333 } = self
334 else {
335 return false;
336 };
337 if *capability != StorageCapability::Text || operation.as_ref() != "fts_search" {
338 return false;
339 }
340 let msg = source.to_string();
341 msg.contains("fts5: syntax error")
342 || msg.contains("fts5: parser stack overflow")
343 || msg.contains("fts5: column queries are not supported")
344 || msg.contains("fts5: phrase queries are not supported (detail")
345 || msg.contains("fts5: NEAR queries are not supported (detail")
346 }
347
348 pub fn is_unique_constraint_violation(&self) -> bool {
361 let Self::Driver {
362 capability,
363 operation,
364 source,
365 } = self
366 else {
367 return false;
368 };
369 if *capability != StorageCapability::Sql {
370 return false;
371 }
372 if !matches!(
373 operation.as_ref(),
374 "execute" | "pool_writer.execute" | "tx.execute"
375 ) {
376 return false;
377 }
378 source.to_string().contains("UNIQUE constraint failed")
379 }
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use std::fmt;
386
387 #[derive(Debug)]
388 struct FakeSource(String);
389
390 impl fmt::Display for FakeSource {
391 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
392 write!(f, "{}", self.0)
393 }
394 }
395
396 impl StdError for FakeSource {}
397
398 fn driver_err(operation: &'static str, message: &str) -> StorageError {
399 StorageError::driver(
400 StorageCapability::Text,
401 operation,
402 FakeSource(message.into()),
403 )
404 }
405
406 #[test]
407 fn writer_task_request_state_display_is_stable() {
408 assert_eq!(
409 WriterTaskRequestState::NotStarted.to_string(),
410 "not_started"
411 );
412 assert_eq!(
413 WriterTaskRequestState::TransactionRolledBack.to_string(),
414 "transaction_rolled_back"
415 );
416 assert_eq!(
417 WriterTaskRequestState::SideEffectsUnknown.to_string(),
418 "side_effects_unknown"
419 );
420 }
421
422 #[test]
423 fn writer_task_busy_is_retryable_without_claiming_queue_rejection() {
424 let error = StorageError::WriterTaskBusy { timeout_ms: 175 };
425 assert!(error.is_retryable());
426 assert_eq!(
427 error.to_string(),
428 "writer task could not begin within 175ms because SQLite remained busy; request was not executed"
429 );
430 assert_eq!(error.capability(), None);
431 }
432
433 #[test]
434 fn writer_task_terminated_is_uncapability_scoped_and_not_retryable() {
435 for request_state in [
436 WriterTaskRequestState::NotStarted,
437 WriterTaskRequestState::TransactionRolledBack,
438 WriterTaskRequestState::SideEffectsUnknown,
439 ] {
440 let error = StorageError::WriterTaskTerminated { request_state };
441 assert_eq!(error.capability(), None);
442 assert!(!error.is_retryable());
443 assert_eq!(
444 error.to_string(),
445 format!("writer task terminated (request_state={request_state})")
446 );
447 }
448 }
449
450 #[test]
451 fn blob_integrity_errors_are_blob_scoped_and_not_retryable() {
452 let requested = crate::blob::ContentRef::from_hex("a".repeat(64)).unwrap();
453 let actual = crate::blob::ContentRef::from_hex("b".repeat(64)).unwrap();
454 let errors = [
455 StorageError::BlobTooLarge {
456 content_ref: requested.clone(),
457 max_bytes: 8,
458 observed_at_least: 9,
459 },
460 StorageError::BlobSizeMismatch {
461 content_ref: requested.clone(),
462 metadata_bytes: 7,
463 actual_bytes: 8,
464 },
465 StorageError::BlobDigestMismatch {
466 expected: requested,
467 actual,
468 },
469 ];
470
471 for error in errors {
472 assert_eq!(error.capability(), Some(StorageCapability::Blob));
473 assert!(!error.is_retryable());
474 }
475 }
476
477 #[test]
478 fn fts5_syntax_error_at_fts_search_is_classified_as_syntax_error() {
479 let e = driver_err("fts_search", "fts5: syntax error near \"@\"");
480 assert!(e.is_fts5_syntax_error());
481 }
482
483 #[test]
484 fn fts5_parser_stack_overflow_is_classified_as_syntax_error() {
485 let e = driver_err("fts_search", "fts5: parser stack overflow");
486 assert!(e.is_fts5_syntax_error());
487 }
488
489 #[test]
490 fn fts5_unsupported_column_query_is_classified_as_syntax_error() {
491 let e = driver_err(
492 "fts_search",
493 "fts5: column queries are not supported (detail=none)",
494 );
495 assert!(e.is_fts5_syntax_error());
496 }
497
498 #[test]
499 fn timeout_is_not_classified_as_syntax_error() {
500 let e = StorageError::Timeout {
501 operation: "fts_search".into(),
502 };
503 assert!(!e.is_fts5_syntax_error());
504 }
505
506 #[test]
507 fn pool_failure_is_not_classified_as_syntax_error() {
508 let e = StorageError::Pool {
509 operation: "fts_search".into(),
510 message: "pool exhausted".into(),
511 };
512 assert!(!e.is_fts5_syntax_error());
513 }
514
515 #[test]
516 fn driver_error_at_non_search_operation_is_not_classified_as_syntax_error() {
517 let e = driver_err("open_fts_reader", "fts5: syntax error near \"@\"");
518 assert!(!e.is_fts5_syntax_error());
519 }
520
521 #[test]
522 fn driver_error_with_unrelated_message_is_not_classified_as_syntax_error() {
523 let e = driver_err("fts_search", "disk I/O error");
524 assert!(!e.is_fts5_syntax_error());
525 }
526
527 #[test]
528 fn fts5_phrase_detail_query_is_classified_as_syntax_error() {
529 let e = driver_err(
530 "fts_search",
531 "fts5: phrase queries are not supported (detail!=full)",
532 );
533 assert!(e.is_fts5_syntax_error());
534 }
535
536 #[test]
537 fn fts5_near_detail_query_is_classified_as_syntax_error() {
538 let e = driver_err(
539 "fts_search",
540 "fts5: NEAR queries are not supported (detail!=full)",
541 );
542 assert!(e.is_fts5_syntax_error());
543 }
544
545 #[test]
546 fn unprefixed_detail_message_is_not_classified_as_syntax_error() {
547 let e = driver_err(
548 "fts_search",
549 "phrase queries are not supported (detail!=full)",
550 );
551 assert!(!e.is_fts5_syntax_error());
552 }
553
554 #[test]
555 fn fts5_shadow_table_corruption_is_not_classified_as_syntax_error() {
556 let e = driver_err(
557 "fts_search",
558 "fts5: error creating shadow table notes_content: no such table",
559 );
560 assert!(!e.is_fts5_syntax_error());
561 }
562
563 #[test]
564 fn non_text_capability_is_not_classified_as_syntax_error() {
565 let e = StorageError::Driver {
566 capability: StorageCapability::Vectors,
567 operation: "fts_search".into(),
568 source: Box::new(FakeSource("fts5: syntax error near \"@\"".into())),
569 };
570 assert!(!e.is_fts5_syntax_error());
571 }
572
573 fn driver_err_sql(operation: &'static str, message: &str) -> StorageError {
574 StorageError::driver(
575 StorageCapability::Sql,
576 operation,
577 FakeSource(message.into()),
578 )
579 }
580
581 #[test]
582 fn unique_constraint_failure_at_execute_sql_capability_is_classified() {
583 let e = driver_err_sql(
584 "execute",
585 "UNIQUE constraint failed: brain_serve_ledger.namespace, \
586 brain_serve_ledger.target_id, brain_serve_ledger.query_class, \
587 brain_serve_ledger.served_at",
588 );
589 assert!(e.is_unique_constraint_violation());
590 }
591
592 #[test]
593 fn unique_constraint_failure_at_pool_writer_execute_is_classified() {
594 let e = driver_err_sql("pool_writer.execute", "UNIQUE constraint failed: t.id");
595 assert!(e.is_unique_constraint_violation());
596 }
597
598 #[test]
599 fn unique_constraint_message_at_non_execute_operation_is_not_classified() {
600 let e = driver_err_sql("query_row", "UNIQUE constraint failed: t.id");
601 assert!(!e.is_unique_constraint_violation());
602 }
603
604 #[test]
605 fn non_unique_driver_error_at_execute_is_not_classified() {
606 let e = driver_err_sql("execute", "disk I/O error");
607 assert!(!e.is_unique_constraint_violation());
608 }
609
610 #[test]
611 fn non_sql_capability_is_not_classified_as_unique_violation() {
612 let e = driver_err("execute", "UNIQUE constraint failed: t.id");
613 assert!(!e.is_unique_constraint_violation());
614 }
615
616 #[test]
617 fn timeout_is_not_classified_as_unique_violation() {
618 let e = StorageError::Timeout {
619 operation: "execute".into(),
620 };
621 assert!(!e.is_unique_constraint_violation());
622 }
623}