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,
28 SideEffectsUnknown,
32}
33
34impl fmt::Display for WriterTaskRequestState {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 f.write_str(match self {
37 Self::NotStarted => "not_started",
38 Self::TransactionRolledBack => "transaction_rolled_back",
39 Self::SideEffectsUnknown => "side_effects_unknown",
40 })
41 }
42}
43
44#[derive(Debug, Error)]
46pub enum StorageError {
47 #[error("{capability:?} resource not found: {resource} ({key})")]
48 NotFound {
49 capability: StorageCapability,
50 resource: &'static str,
51 key: String,
52 },
53
54 #[error("{capability:?} resource already exists: {resource} ({key})")]
55 AlreadyExists {
56 capability: StorageCapability,
57 resource: &'static str,
58 key: String,
59 },
60
61 #[error("conflict in {capability:?} during {operation}: {message}")]
62 Conflict {
63 capability: StorageCapability,
64 operation: Cow<'static, str>,
65 message: String,
66 },
67
68 #[error("invalid input for {capability:?} during {operation}: {message}")]
69 InvalidInput {
70 capability: StorageCapability,
71 operation: Cow<'static, str>,
72 message: String,
73 },
74
75 #[error("unsupported operation for {capability:?}: {operation} ({message})")]
76 Unsupported {
77 capability: StorageCapability,
78 operation: Cow<'static, str>,
79 message: String,
80 },
81
82 #[error(
87 "blob {content_ref} exceeds the {max_bytes}-byte read limit (observed at least {observed_at_least} bytes)"
88 )]
89 BlobTooLarge {
90 content_ref: ContentRef,
91 max_bytes: u64,
92 observed_at_least: u64,
93 },
94
95 #[error(
98 "blob {content_ref} metadata reports {metadata_bytes} bytes but the complete body contains {actual_bytes} bytes"
99 )]
100 BlobSizeMismatch {
101 content_ref: ContentRef,
102 metadata_bytes: u64,
103 actual_bytes: u64,
104 },
105
106 #[error("blob digest mismatch: expected {expected}, computed {actual}")]
109 BlobDigestMismatch {
110 expected: ContentRef,
111 actual: ContentRef,
112 },
113
114 #[error("pool failure during {operation}: {message}")]
115 Pool {
116 operation: Cow<'static, str>,
117 message: String,
118 },
119
120 #[error("timeout during {operation}")]
121 Timeout { operation: Cow<'static, str> },
122
123 #[error("admission timeout during {operation} after {timeout_ms}ms{pool}", pool = match .pool_identity {
129 Some(identity) => format!(" (pool: {identity})"),
130 None => String::new(),
131 })]
132 AdmissionTimeout {
133 operation: Cow<'static, str>,
134 timeout_ms: u64,
136 pool_identity: Option<String>,
138 },
139
140 #[error("sql transaction failure during {operation}: {message}")]
141 Transaction {
142 operation: Cow<'static, str>,
143 message: String,
144 },
145
146 #[error(
155 "cached read-only transaction exceeded the maximum read-transaction age \
156 ({max_age_secs}s) during {operation} and was rolled back; retry to open a fresh \
157 read snapshot"
158 )]
159 ReadTransactionAgeEvicted {
160 operation: Cow<'static, str>,
161 max_age_secs: u64,
162 },
163
164 #[error(
176 "cached read-only transaction exceeded the maximum read-transaction age \
177 ({max_age_secs}s) during {operation} but could not be cleanly rolled back \
178 ({message}); the connection was discarded, retry to open a fresh read snapshot"
179 )]
180 ReadTransactionAgeEvictionCleanupFailed {
181 operation: Cow<'static, str>,
182 max_age_secs: u64,
183 message: String,
184 },
185
186 #[error("serialization failure in {capability:?}: {message}")]
187 Serialization {
188 capability: StorageCapability,
189 message: String,
190 },
191
192 #[error("index maintenance failure in {capability:?}: {message}")]
193 IndexMaintenance {
194 capability: StorageCapability,
195 message: String,
196 },
197
198 #[error("backend driver error in {capability:?} during {operation}: {source}")]
199 Driver {
200 capability: StorageCapability,
201 operation: Cow<'static, str>,
202 #[source]
203 source: Box<dyn StdError + Send + Sync>,
204 },
205
206 #[error("write queue full: timed out after {timeout_ms}ms waiting for writer task capacity")]
211 WriteQueueFull { timeout_ms: u64 },
212
213 #[error(
218 "writer task could not begin within {timeout_ms}ms because SQLite remained busy; request was not executed"
219 )]
220 WriterTaskBusy { timeout_ms: u64 },
221
222 #[error("writer task request failed (request_state={request_state}): {source}")]
228 WriterTaskRequestFailed {
229 request_state: WriterTaskRequestState,
230 #[source]
231 source: Box<StorageError>,
232 },
233
234 #[error("writer task terminated (request_state={request_state})")]
241 WriterTaskTerminated {
242 request_state: WriterTaskRequestState,
243 },
244
245 #[error("internal storage error: {0}")]
248 Internal(String),
249
250 #[error(
255 "KHIVE_WRITE_QUEUE=1 but no Tokio runtime context is available to spawn the writer task"
256 )]
257 WriterTaskNoRuntime,
258
259 #[error(
263 "refusing write on {capability:?} at {volume}: {available_bytes} bytes available, \
264 below the {floor_bytes}-byte floor"
265 )]
266 CapacityFloor {
267 capability: StorageCapability,
268 volume: String,
269 available_bytes: u64,
270 floor_bytes: u64,
271 },
272}
273
274impl StorageError {
275 pub fn driver(
277 capability: StorageCapability,
278 operation: impl Into<Cow<'static, str>>,
279 source: impl StdError + Send + Sync + 'static,
280 ) -> Self {
281 Self::Driver {
282 capability,
283 operation: operation.into(),
284 source: Box::new(source),
285 }
286 }
287
288 pub fn capability(&self) -> Option<StorageCapability> {
290 match self {
291 Self::NotFound { capability, .. }
292 | Self::AlreadyExists { capability, .. }
293 | Self::Conflict { capability, .. }
294 | Self::InvalidInput { capability, .. }
295 | Self::Unsupported { capability, .. }
296 | Self::Serialization { capability, .. }
297 | Self::IndexMaintenance { capability, .. }
298 | Self::Driver { capability, .. }
299 | Self::CapacityFloor { capability, .. } => Some(*capability),
300 Self::BlobTooLarge { .. }
301 | Self::BlobSizeMismatch { .. }
302 | Self::BlobDigestMismatch { .. } => Some(StorageCapability::Blob),
303 Self::WriterTaskRequestFailed { source, .. } => source.capability(),
304 Self::Pool { .. }
305 | Self::Timeout { .. }
306 | Self::AdmissionTimeout { .. }
307 | Self::Transaction { .. }
308 | Self::ReadTransactionAgeEvicted { .. }
309 | Self::ReadTransactionAgeEvictionCleanupFailed { .. }
310 | Self::WriteQueueFull { .. }
311 | Self::WriterTaskBusy { .. }
312 | Self::WriterTaskTerminated { .. }
313 | Self::Internal(..)
314 | Self::WriterTaskNoRuntime => None,
315 }
316 }
317
318 pub fn is_retryable(&self) -> bool {
320 if let Self::WriterTaskRequestFailed { source, .. } = self {
321 return source.is_retryable();
322 }
323 matches!(
324 self,
325 Self::Pool { .. }
326 | Self::Timeout { .. }
327 | Self::AdmissionTimeout { .. }
328 | Self::Transaction { .. }
329 | Self::ReadTransactionAgeEvicted { .. }
330 | Self::ReadTransactionAgeEvictionCleanupFailed { .. }
331 | Self::WriteQueueFull { .. }
332 | Self::WriterTaskBusy { .. }
333 )
334 }
335
336 pub fn is_fts5_syntax_error(&self) -> bool {
352 if let Self::WriterTaskRequestFailed { source, .. } = self {
353 return source.is_fts5_syntax_error();
354 }
355 let Self::Driver {
356 capability,
357 operation,
358 source,
359 } = self
360 else {
361 return false;
362 };
363 if *capability != StorageCapability::Text || operation.as_ref() != "fts_search" {
364 return false;
365 }
366 let msg = source.to_string();
367 msg.contains("fts5: syntax error")
368 || msg.contains("fts5: parser stack overflow")
369 || msg.contains("fts5: column queries are not supported")
370 || msg.contains("fts5: phrase queries are not supported (detail")
371 || msg.contains("fts5: NEAR queries are not supported (detail")
372 }
373
374 pub fn is_unique_constraint_violation(&self) -> bool {
387 if let Self::WriterTaskRequestFailed { source, .. } = self {
388 return source.is_unique_constraint_violation();
389 }
390 let Self::Driver {
391 capability,
392 operation,
393 source,
394 } = self
395 else {
396 return false;
397 };
398 if *capability != StorageCapability::Sql {
399 return false;
400 }
401 if !matches!(
402 operation.as_ref(),
403 "execute" | "pool_writer.execute" | "tx.execute"
404 ) {
405 return false;
406 }
407 source.to_string().contains("UNIQUE constraint failed")
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use std::fmt;
415
416 #[derive(Debug)]
417 struct FakeSource(String);
418
419 impl fmt::Display for FakeSource {
420 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
421 write!(f, "{}", self.0)
422 }
423 }
424
425 impl StdError for FakeSource {}
426
427 fn driver_err(operation: &'static str, message: &str) -> StorageError {
428 StorageError::driver(
429 StorageCapability::Text,
430 operation,
431 FakeSource(message.into()),
432 )
433 }
434
435 #[test]
436 fn writer_task_request_state_display_is_stable() {
437 assert_eq!(
438 WriterTaskRequestState::NotStarted.to_string(),
439 "not_started"
440 );
441 assert_eq!(
442 WriterTaskRequestState::TransactionRolledBack.to_string(),
443 "transaction_rolled_back"
444 );
445 assert_eq!(
446 WriterTaskRequestState::SideEffectsUnknown.to_string(),
447 "side_effects_unknown"
448 );
449 }
450
451 #[test]
452 fn writer_task_busy_is_retryable_without_claiming_queue_rejection() {
453 let error = StorageError::WriterTaskBusy { timeout_ms: 175 };
454 assert!(error.is_retryable());
455 assert_eq!(
456 error.to_string(),
457 "writer task could not begin within 175ms because SQLite remained busy; request was not executed"
458 );
459 assert_eq!(error.capability(), None);
460 }
461
462 #[test]
463 fn writer_task_request_failure_preserves_source_policy_and_rollback_state() {
464 let error = StorageError::WriterTaskRequestFailed {
465 request_state: WriterTaskRequestState::TransactionRolledBack,
466 source: Box::new(StorageError::Pool {
467 operation: "writer_task_commit".into(),
468 message: "commit refused".into(),
469 }),
470 };
471
472 assert_eq!(error.capability(), None);
473 assert!(
474 error.is_retryable(),
475 "rollback finality must not discard the source error's retry policy"
476 );
477 assert_eq!(
478 error.to_string(),
479 "writer task request failed (request_state=transaction_rolled_back): pool failure during writer_task_commit: commit refused"
480 );
481 assert_eq!(
482 StdError::source(&error).map(ToString::to_string),
483 Some("pool failure during writer_task_commit: commit refused".to_string()),
484 "the original typed storage error must remain the public source"
485 );
486 }
487
488 #[test]
489 fn writer_task_request_failure_does_not_invent_retryability() {
490 let error = StorageError::WriterTaskRequestFailed {
491 request_state: WriterTaskRequestState::TransactionRolledBack,
492 source: Box::new(StorageError::InvalidInput {
493 capability: StorageCapability::Notes,
494 operation: "append_note".into(),
495 message: "deterministic refusal".into(),
496 }),
497 };
498
499 assert!(!error.is_retryable());
500 assert_eq!(error.capability(), Some(StorageCapability::Notes));
501 }
502
503 #[test]
504 fn writer_task_terminated_is_uncapability_scoped_and_not_retryable() {
505 for request_state in [
506 WriterTaskRequestState::NotStarted,
507 WriterTaskRequestState::TransactionRolledBack,
508 WriterTaskRequestState::SideEffectsUnknown,
509 ] {
510 let error = StorageError::WriterTaskTerminated { request_state };
511 assert_eq!(error.capability(), None);
512 assert!(!error.is_retryable());
513 assert_eq!(
514 error.to_string(),
515 format!("writer task terminated (request_state={request_state})")
516 );
517 }
518 }
519
520 #[test]
521 fn blob_integrity_errors_are_blob_scoped_and_not_retryable() {
522 let requested = crate::blob::ContentRef::from_hex("a".repeat(64)).unwrap();
523 let actual = crate::blob::ContentRef::from_hex("b".repeat(64)).unwrap();
524 let errors = [
525 StorageError::BlobTooLarge {
526 content_ref: requested.clone(),
527 max_bytes: 8,
528 observed_at_least: 9,
529 },
530 StorageError::BlobSizeMismatch {
531 content_ref: requested.clone(),
532 metadata_bytes: 7,
533 actual_bytes: 8,
534 },
535 StorageError::BlobDigestMismatch {
536 expected: requested,
537 actual,
538 },
539 ];
540
541 for error in errors {
542 assert_eq!(error.capability(), Some(StorageCapability::Blob));
543 assert!(!error.is_retryable());
544 }
545 }
546
547 #[test]
548 fn fts5_syntax_error_at_fts_search_is_classified_as_syntax_error() {
549 let e = driver_err("fts_search", "fts5: syntax error near \"@\"");
550 assert!(e.is_fts5_syntax_error());
551 }
552
553 #[test]
554 fn fts5_parser_stack_overflow_is_classified_as_syntax_error() {
555 let e = driver_err("fts_search", "fts5: parser stack overflow");
556 assert!(e.is_fts5_syntax_error());
557 }
558
559 #[test]
560 fn fts5_unsupported_column_query_is_classified_as_syntax_error() {
561 let e = driver_err(
562 "fts_search",
563 "fts5: column queries are not supported (detail=none)",
564 );
565 assert!(e.is_fts5_syntax_error());
566 }
567
568 #[test]
569 fn timeout_is_not_classified_as_syntax_error() {
570 let e = StorageError::Timeout {
571 operation: "fts_search".into(),
572 };
573 assert!(!e.is_fts5_syntax_error());
574 }
575
576 #[test]
577 fn pool_failure_is_not_classified_as_syntax_error() {
578 let e = StorageError::Pool {
579 operation: "fts_search".into(),
580 message: "pool exhausted".into(),
581 };
582 assert!(!e.is_fts5_syntax_error());
583 }
584
585 #[test]
586 fn driver_error_at_non_search_operation_is_not_classified_as_syntax_error() {
587 let e = driver_err("open_fts_reader", "fts5: syntax error near \"@\"");
588 assert!(!e.is_fts5_syntax_error());
589 }
590
591 #[test]
592 fn driver_error_with_unrelated_message_is_not_classified_as_syntax_error() {
593 let e = driver_err("fts_search", "disk I/O error");
594 assert!(!e.is_fts5_syntax_error());
595 }
596
597 #[test]
598 fn fts5_phrase_detail_query_is_classified_as_syntax_error() {
599 let e = driver_err(
600 "fts_search",
601 "fts5: phrase queries are not supported (detail!=full)",
602 );
603 assert!(e.is_fts5_syntax_error());
604 }
605
606 #[test]
607 fn fts5_near_detail_query_is_classified_as_syntax_error() {
608 let e = driver_err(
609 "fts_search",
610 "fts5: NEAR queries are not supported (detail!=full)",
611 );
612 assert!(e.is_fts5_syntax_error());
613 }
614
615 #[test]
616 fn unprefixed_detail_message_is_not_classified_as_syntax_error() {
617 let e = driver_err(
618 "fts_search",
619 "phrase queries are not supported (detail!=full)",
620 );
621 assert!(!e.is_fts5_syntax_error());
622 }
623
624 #[test]
625 fn fts5_shadow_table_corruption_is_not_classified_as_syntax_error() {
626 let e = driver_err(
627 "fts_search",
628 "fts5: error creating shadow table notes_content: no such table",
629 );
630 assert!(!e.is_fts5_syntax_error());
631 }
632
633 #[test]
634 fn non_text_capability_is_not_classified_as_syntax_error() {
635 let e = StorageError::Driver {
636 capability: StorageCapability::Vectors,
637 operation: "fts_search".into(),
638 source: Box::new(FakeSource("fts5: syntax error near \"@\"".into())),
639 };
640 assert!(!e.is_fts5_syntax_error());
641 }
642
643 fn driver_err_sql(operation: &'static str, message: &str) -> StorageError {
644 StorageError::driver(
645 StorageCapability::Sql,
646 operation,
647 FakeSource(message.into()),
648 )
649 }
650
651 #[test]
652 fn unique_constraint_failure_at_execute_sql_capability_is_classified() {
653 let e = driver_err_sql(
654 "execute",
655 "UNIQUE constraint failed: brain_serve_ledger.namespace, \
656 brain_serve_ledger.target_id, brain_serve_ledger.query_class, \
657 brain_serve_ledger.served_at",
658 );
659 assert!(e.is_unique_constraint_violation());
660 }
661
662 #[test]
663 fn unique_constraint_failure_at_pool_writer_execute_is_classified() {
664 let e = driver_err_sql("pool_writer.execute", "UNIQUE constraint failed: t.id");
665 assert!(e.is_unique_constraint_violation());
666 }
667
668 #[test]
669 fn unique_constraint_message_at_non_execute_operation_is_not_classified() {
670 let e = driver_err_sql("query_row", "UNIQUE constraint failed: t.id");
671 assert!(!e.is_unique_constraint_violation());
672 }
673
674 #[test]
675 fn non_unique_driver_error_at_execute_is_not_classified() {
676 let e = driver_err_sql("execute", "disk I/O error");
677 assert!(!e.is_unique_constraint_violation());
678 }
679
680 #[test]
681 fn non_sql_capability_is_not_classified_as_unique_violation() {
682 let e = driver_err("execute", "UNIQUE constraint failed: t.id");
683 assert!(!e.is_unique_constraint_violation());
684 }
685
686 #[test]
687 fn timeout_is_not_classified_as_unique_violation() {
688 let e = StorageError::Timeout {
689 operation: "execute".into(),
690 };
691 assert!(!e.is_unique_constraint_violation());
692 }
693}