Skip to main content

qdrant_edge/segment/common/
operation_error.rs

1use std::backtrace::Backtrace;
2use std::collections::TryReserveError;
3use std::io::{Error as IoError, ErrorKind};
4use std::path::PathBuf;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::time::Duration;
7
8use atomicwrites::Error as AtomicIoError;
9use crate::blobstore::error::BlobstoreError;
10use crate::common::bitpacking_ordered::DecompressionError;
11use crate::common::mmap::Error as MmapError;
12use crate::common::universal_io::{IsNotFound, UniversalIoError};
13use rayon::ThreadPoolBuildError;
14use thiserror::Error;
15
16use crate::segment::types::{PayloadKeyType, PointIdType, SeqNumberType, VectorNameBuf};
17use crate::segment::utils::mem::Mem;
18
19pub const PROCESS_CANCELLED_BY_SERVICE_MESSAGE: &str = "process cancelled by service";
20
21#[derive(Error, Debug, Clone, PartialEq)]
22#[error("{0}")]
23pub enum OperationError {
24    #[error("Vector dimension error: expected dim: {expected_dim}, got {received_dim}")]
25    WrongVectorDimension {
26        expected_dim: usize,
27        received_dim: usize,
28    },
29    /// A storage-native (raw byte) vector blob that is incompatible with the
30    /// target storage (wrong length, undecodable, or out-of-range contents).
31    /// Classified as user error (maps to `BadInput`), not `ServiceError`, so a
32    /// malformed blob that reached the WAL is skipped on replay instead of
33    /// crash-looping recovery.
34    #[error("{description}")]
35    MalformedVectorBlob { description: String },
36    #[error("Not existing vector name error: {received_name}")]
37    VectorNameNotExists { received_name: VectorNameBuf },
38    #[error("No point with id {missed_point_id}")]
39    PointIdError { missed_point_id: PointIdType },
40    #[error(
41        "Payload type does not match with previously given for field {field_name}. Expected: {expected_type}"
42    )]
43    TypeError {
44        field_name: PayloadKeyType,
45        expected_type: String,
46    },
47    #[error("Unable to infer type for the field '{field_name}'. Please specify `field_type`")]
48    TypeInferenceError { field_name: PayloadKeyType },
49    /// Service Error prevents further update of the collection until it is fixed.
50    /// Should only be used for hardware, data corruption, IO, or other unexpected internal errors.
51    #[error("Service runtime error: {description}")]
52    ServiceError {
53        description: String,
54        backtrace: Option<String>,
55    },
56    #[error("Inconsistent storage: {description}")]
57    InconsistentStorage { description: String },
58    /// An essential storage file is missing. Distinguished from `ServiceError` so a
59    /// read-only follower can tell "segment removed by the leader mid-reload"
60    /// (re-check the manifest) from real corruption (escalate).
61    #[error("Storage file not found: {}", path.display())]
62    FileNotFound { path: PathBuf },
63    #[error("Out of memory, free: {free}, {description}")]
64    OutOfMemory { description: String, free: u64 },
65    #[error("Operation cancelled: {description}")]
66    Cancelled { description: String },
67    #[error("Timeout error: {description}")]
68    Timeout { description: String },
69    #[error("Validation failed: {description}")]
70    ValidationError { description: String },
71    #[error("Wrong usage of sparse vectors")]
72    WrongSparse,
73    #[error("Wrong usage of multi vectors")]
74    WrongMulti,
75    #[error(
76        "No range index for `order_by` key: `{key}`. Please create one to use `order_by`. Check https://qdrant.tech/documentation/concepts/indexing/#payload-index to see which payload schemas support Range conditions"
77    )]
78    MissingRangeIndexForOrderBy { key: String },
79    #[error(
80        "No appropriate index for faceting: `{key}`. Please create one to facet on this field. Check https://qdrant.tech/documentation/concepts/indexing/#payload-index to see which payload schemas support Match conditions"
81    )]
82    MissingMapIndexForFacet { key: String },
83    #[error(
84        "Expected {expected_type} value for {field_name} in the payload and/or in the formula defaults. Error: {description}"
85    )]
86    VariableTypeError {
87        field_name: PayloadKeyType,
88        expected_type: String,
89        description: String,
90    },
91    #[error("The expression {expression} produced a non-finite number")]
92    NonFiniteNumber { expression: String },
93    /// All appendable segments reached `max_segment_size`, so there is no valid destination for
94    /// new or moved points. Recoverable by provisioning a fresh appendable segment and re-applying
95    /// the operation; already-applied points are skipped by their point version.
96    #[error(
97        "All appendable segments reached the maximum segment size of {max_segment_size_bytes} bytes"
98    )]
99    OutOfAppendableCapacity { max_segment_size_bytes: usize },
100}
101
102impl OperationError {
103    /// Create a new service error with a description and a backtrace
104    /// Warning: capturing a backtrace can be an expensive operation on some platforms, so this should be used with caution in performance-sensitive parts of code.
105    pub fn service_error(description: impl Into<String>) -> Self {
106        Self::ServiceError {
107            description: description.into(),
108            backtrace: Some(Backtrace::force_capture().to_string()),
109        }
110    }
111
112    /// Create a new service error with a description and no backtrace
113    pub fn service_error_light(description: impl Into<String>) -> Self {
114        Self::ServiceError {
115            description: description.into(),
116            backtrace: None,
117        }
118    }
119
120    pub fn validation_error(description: impl Into<String>) -> Self {
121        Self::ValidationError {
122            description: description.into(),
123        }
124    }
125
126    pub fn inconsistent_storage(description: impl Into<String>) -> Self {
127        Self::InconsistentStorage {
128            description: description.into(),
129        }
130    }
131
132    pub fn cancelled(description: impl Into<String>) -> Self {
133        Self::Cancelled {
134            description: description.into(),
135        }
136    }
137
138    pub fn vector_name_not_exists(vector_name: impl Into<String>) -> Self {
139        Self::VectorNameNotExists {
140            received_name: vector_name.into(),
141        }
142    }
143
144    pub fn malformed_vector_blob(description: impl Into<String>) -> Self {
145        Self::MalformedVectorBlob {
146            description: description.into(),
147        }
148    }
149
150    pub fn timeout(timeout: Duration, operation: impl Into<String>) -> Self {
151        Self::Timeout {
152            description: format!(
153                "Operation '{}' timed out after {timeout:?}",
154                operation.into(),
155            ),
156        }
157    }
158}
159
160/// `FileNotFound` only ever originates from sources that carry a structured path
161/// ([`UniversalIoError::NotFound`], [`MmapError::MissingFile`]); a raw io NotFound is a
162/// plain `ServiceError` — universal-io wraps not-found at the call site
163/// (`UniversalIoError::extract_not_found`), so classify there, not here.
164impl IsNotFound for OperationError {
165    fn is_not_found(&self) -> bool {
166        match self {
167            Self::FileNotFound { .. } => true,
168            Self::WrongVectorDimension { .. }
169            | Self::MalformedVectorBlob { .. }
170            | Self::VectorNameNotExists { .. }
171            | Self::PointIdError { .. }
172            | Self::TypeError { .. }
173            | Self::TypeInferenceError { .. }
174            | Self::ServiceError { .. }
175            | Self::InconsistentStorage { .. }
176            | Self::OutOfMemory { .. }
177            | Self::Cancelled { .. }
178            | Self::Timeout { .. }
179            | Self::ValidationError { .. }
180            | Self::WrongSparse
181            | Self::WrongMulti
182            | Self::MissingRangeIndexForOrderBy { .. }
183            | Self::MissingMapIndexForFacet { .. }
184            | Self::VariableTypeError { .. }
185            | Self::NonFiniteNumber { .. }
186            | Self::OutOfAppendableCapacity { .. } => false,
187        }
188    }
189}
190
191/// Contains information regarding last operation error, which should be fixed before next operation could be processed
192#[derive(Debug, Clone)]
193pub struct SegmentFailedState {
194    pub version: SeqNumberType,
195    pub point_id: Option<PointIdType>,
196    pub error: OperationError,
197}
198
199impl From<ThreadPoolBuildError> for OperationError {
200    fn from(error: ThreadPoolBuildError) -> Self {
201        Self::service_error(error.to_string())
202    }
203}
204
205impl From<DecompressionError> for OperationError {
206    fn from(err: DecompressionError) -> Self {
207        Self::service_error(err.to_string())
208    }
209}
210
211impl From<MmapError> for OperationError {
212    fn from(err: MmapError) -> Self {
213        match err {
214            // `MissingFile` is the only mmap error with a structured path; an io NotFound
215            // is deliberately left as a service error (see `IsNotFound for OperationError`).
216            MmapError::MissingFile(path) => Self::FileNotFound { path: path.into() },
217            err @ (MmapError::SizeExact(..)
218            | MmapError::SizeLess(..)
219            | MmapError::SizeMultiple(..)
220            | MmapError::Io(_)) => Self::service_error(err.to_string()),
221        }
222    }
223}
224
225impl From<UniversalIoError> for OperationError {
226    fn from(err: UniversalIoError) -> Self {
227        match err {
228            UniversalIoError::Io(err) => Self::from(err),
229            UniversalIoError::Mmap(err) => Self::from(err),
230
231            UniversalIoError::NotFound { path } => Self::FileNotFound { path },
232
233            UniversalIoError::Bincode(_)
234            | UniversalIoError::BytemuckCast(_)
235            | UniversalIoError::ZerocopySize(_)
236            | UniversalIoError::IoUringNotSupported(_)
237            | UniversalIoError::OutOfBounds { .. }
238            | UniversalIoError::InvalidFileIndex { .. }
239            | UniversalIoError::Uninitialized { .. }
240            | UniversalIoError::QueueIsFull
241            | UniversalIoError::AppendOffsetConflict { .. }
242            | UniversalIoError::S3(_)
243            | UniversalIoError::S3Config { .. }
244            | UniversalIoError::TaskPanicked(_) => Self::service_error(err.to_string()),
245        }
246    }
247}
248
249impl<Src, Dst: ?Sized> From<zerocopy::SizeError<Src, Dst>> for OperationError
250where
251    zerocopy::SizeError<Src, Dst>: std::fmt::Display,
252{
253    fn from(err: zerocopy::SizeError<Src, Dst>) -> Self {
254        Self::service_error(format!("Zerocopy size error: {err}"))
255    }
256}
257
258impl<A, S, V> From<zerocopy::ConvertError<A, S, V>> for OperationError
259where
260    zerocopy::ConvertError<A, S, V>: std::fmt::Display,
261{
262    fn from(err: zerocopy::ConvertError<A, S, V>) -> Self {
263        Self::service_error(format!("Zerocopy convert error: {err}"))
264    }
265}
266
267impl From<serde_cbor::Error> for OperationError {
268    fn from(err: serde_cbor::Error) -> Self {
269        Self::service_error(format!("Failed to parse data: {err}"))
270    }
271}
272
273impl<E> From<AtomicIoError<E>> for OperationError {
274    fn from(err: AtomicIoError<E>) -> Self {
275        match err {
276            AtomicIoError::Internal(io_err) => Self::from(io_err),
277            AtomicIoError::User(_user_err) => Self::service_error("Unknown atomic write error"),
278        }
279    }
280}
281
282impl From<IoError> for OperationError {
283    fn from(err: IoError) -> Self {
284        #[expect(clippy::wildcard_enum_match_arm, reason = "error handling")]
285        match err.kind() {
286            ErrorKind::OutOfMemory => {
287                let free_memory = Mem::new().available_memory_bytes();
288                Self::OutOfMemory {
289                    description: format!("IO Error: {err}"),
290                    free: free_memory,
291                }
292            }
293            _ => Self::service_error(format!("IO Error: {err}")),
294        }
295    }
296}
297
298impl From<serde_json::Error> for OperationError {
299    fn from(err: serde_json::Error) -> Self {
300        Self::service_error(format!("Json error: {err}"))
301    }
302}
303
304impl From<fs_extra::error::Error> for OperationError {
305    fn from(err: fs_extra::error::Error) -> Self {
306        Self::service_error(format!("File system error: {err}"))
307    }
308}
309
310impl From<geohash::GeohashError> for OperationError {
311    fn from(err: geohash::GeohashError) -> Self {
312        Self::service_error(format!("Geohash error: {err}"))
313    }
314}
315
316impl From<crate::quantization::EncodingError> for OperationError {
317    fn from(err: crate::quantization::EncodingError) -> Self {
318        match err {
319            crate::quantization::EncodingError::IOError(err)
320            | crate::quantization::EncodingError::EncodingError(err)
321            | crate::quantization::EncodingError::ArgumentsError(err) => {
322                Self::service_error(format!("Quantization encoding error: {err}"))
323            }
324            crate::quantization::EncodingError::Stopped => {
325                Self::cancelled(PROCESS_CANCELLED_BY_SERVICE_MESSAGE)
326            }
327        }
328    }
329}
330
331impl From<TryReserveError> for OperationError {
332    fn from(err: TryReserveError) -> Self {
333        let free_memory = Mem::new().available_memory_bytes();
334        Self::OutOfMemory {
335            description: format!("Failed to reserve memory: {err}"),
336            free: free_memory,
337        }
338    }
339}
340
341impl From<BlobstoreError> for OperationError {
342    fn from(err: BlobstoreError) -> Self {
343        match err {
344            BlobstoreError::ServiceError { description } => {
345                Self::service_error(format!("Blobstore error: {description}"))
346            }
347            BlobstoreError::FlushCancelled => Self::cancelled("Blobstore flushing was cancelled"),
348            BlobstoreError::Io(_) | BlobstoreError::Mmap(_) | BlobstoreError::SerdeJson(_) => {
349                Self::service_error(err.to_string())
350            }
351            BlobstoreError::ValidationError { message } => Self::validation_error(message),
352            BlobstoreError::UnsupportedOperation { .. } => Self::service_error(err.to_string()),
353            BlobstoreError::UniversalIo(err) => match err {
354                UniversalIoError::NotFound { path } => Self::FileNotFound { path },
355                err @ (UniversalIoError::Io(_)
356                | UniversalIoError::Mmap(_)
357                | UniversalIoError::Bincode(_)
358                | UniversalIoError::BytemuckCast(_)
359                | UniversalIoError::ZerocopySize(_)
360                | UniversalIoError::IoUringNotSupported(_)
361                | UniversalIoError::OutOfBounds { .. }
362                | UniversalIoError::InvalidFileIndex { .. }
363                | UniversalIoError::Uninitialized { .. }
364                | UniversalIoError::QueueIsFull
365                | UniversalIoError::AppendOffsetConflict { .. }
366                | UniversalIoError::S3(_)
367                | UniversalIoError::S3Config { .. }
368                | UniversalIoError::TaskPanicked(_)) => {
369                    Self::service_error(format!("Gridstore IO error: {err}"))
370                }
371            },
372            BlobstoreError::PageNotFound { .. } => Self::service_error(err.to_string()),
373            BlobstoreError::ValueNotFound { .. } => Self::service_error(err.to_string()),
374        }
375    }
376}
377
378#[cfg(feature = "gpu")]
379impl From<gpu::GpuError> for OperationError {
380    fn from(err: gpu::GpuError) -> Self {
381        Self::service_error(format!("GPU error: {err:?}"))
382    }
383}
384
385pub type OperationResult<T> = Result<T, OperationError>;
386
387pub fn get_service_error<T>(err: &OperationResult<T>) -> Option<OperationError> {
388    match err {
389        Ok(_) => None,
390        #[expect(clippy::wildcard_enum_match_arm, reason = "error handling")]
391        Err(error) => match error {
392            OperationError::ServiceError { .. } => Some(error.clone()),
393            _ => None,
394        },
395    }
396}
397
398#[derive(Debug, Copy, Clone)]
399pub struct CancelledError;
400
401pub type CancellableResult<T> = Result<T, CancelledError>;
402
403impl From<CancelledError> for OperationError {
404    fn from(_cancelled_error: CancelledError) -> Self {
405        Self::cancelled(PROCESS_CANCELLED_BY_SERVICE_MESSAGE)
406    }
407}
408
409pub fn check_process_stopped(stopped: &AtomicBool) -> CancellableResult<()> {
410    if stopped.load(Ordering::Relaxed) {
411        return Err(CancelledError);
412    }
413    Ok(())
414}
415
416#[cfg(test)]
417mod tests {
418    use std::time::Duration;
419
420    use super::*;
421
422    #[test]
423    fn test_not_found_classification() {
424        // Structured not-found sources classify as `FileNotFound` and keep the path.
425        let err = OperationError::from(UniversalIoError::NotFound {
426            path: "segments/0/deleted.bin".into(),
427        });
428        assert!(err.is_not_found());
429        assert!(err.to_string().contains("segments/0/deleted.bin"));
430
431        let err = OperationError::from(MmapError::MissingFile("matrix.dat".to_string()));
432        assert!(err.is_not_found());
433        assert!(err.to_string().contains("matrix.dat"));
434
435        let err = OperationError::from(BlobstoreError::UniversalIo(UniversalIoError::NotFound {
436            path: "page_0.dat".into(),
437        }));
438        assert!(err.is_not_found());
439        assert!(err.to_string().contains("page_0.dat"));
440
441        // A raw io NotFound has no structured path; it stays a service error —
442        // universal-io wraps not-found at the call site (`extract_not_found`).
443        let io_err = IoError::new(ErrorKind::NotFound, "no such file");
444        assert!(!OperationError::from(io_err).is_not_found());
445
446        // Non-not-found io errors are unaffected.
447        let io_err = IoError::new(ErrorKind::PermissionDenied, "denied");
448        let err = OperationError::from(UniversalIoError::Io(io_err));
449        assert!(!err.is_not_found());
450    }
451
452    #[test]
453    fn test_timeout_error_formatting() {
454        // Test sub-second timeout (500ms)
455        let timeout = Duration::from_millis(500);
456        let error = OperationError::timeout(timeout, "test operation");
457        let error_msg = error.to_string();
458        assert!(
459            error_msg.contains("500ms"),
460            "Expected '500ms' but got: {error_msg}"
461        );
462
463        // Test exact second timeout (1000ms = 1s)
464        let timeout = Duration::from_millis(1000);
465        let error = OperationError::timeout(timeout, "test operation");
466        let error_msg = error.to_string();
467        assert!(
468            error_msg.contains("1s"),
469            "Expected '1s' but got: {error_msg}"
470        );
471
472        // Test multi-second timeout with sub-second precision (2500ms = 2.5s)
473        let timeout = Duration::from_millis(2500);
474        let error = OperationError::timeout(timeout, "test operation");
475        let error_msg = error.to_string();
476        assert!(
477            error_msg.contains("2.5s"),
478            "Expected '2.5s' but got: {error_msg}"
479        );
480
481        // Test large timeout (60000ms = 60s)
482        let timeout = Duration::from_millis(60000);
483        let error = OperationError::timeout(timeout, "test operation");
484        let error_msg = error.to_string();
485        assert!(
486            error_msg.contains("60s"),
487            "Expected '60s' but got: {error_msg}"
488        );
489    }
490}