Skip to main content

selene_graph/
error.rs

1//! Graph-layer error types and GQLSTATUS mappings.
2
3use selene_core::{CoreError, DbString, EdgeId, NodeId};
4use selene_persist::PersistError;
5use smallvec::SmallVec;
6
7use crate::index_provider::ProviderError;
8use crate::type_validator::TypeViolation;
9use crate::typed_index::TypedIndexKind;
10
11/// Result alias for graph operations.
12pub type GraphResult<T> = Result<T, GraphError>;
13
14/// Store-assignment data-exception family.
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16#[non_exhaustive]
17pub enum StoreAssignmentException {
18    /// String or byte-string assignment would truncate non-padding data.
19    StringDataRightTruncation,
20    /// Numeric assignment cannot be represented by the target type.
21    NumericValueOutOfRange,
22}
23
24impl StoreAssignmentException {
25    /// Map this store-assignment exception to its ISO GQLSTATUS code.
26    #[must_use]
27    pub const fn gqlstatus(self) -> &'static str {
28        match self {
29            Self::StringDataRightTruncation => "22001",
30            Self::NumericValueOutOfRange => "22003",
31        }
32    }
33}
34
35/// Error raised while applying ISO store-assignment conversion rules.
36#[derive(Debug, thiserror::Error, miette::Diagnostic)]
37#[error("store assignment to property {property} failed: {reason}")]
38#[diagnostic(code(SLENE_G_027))]
39pub struct StoreAssignmentError {
40    /// Property being assigned.
41    pub property: DbString,
42    /// Data-exception family.
43    pub exception: StoreAssignmentException,
44    /// Human-readable reason.
45    pub reason: String,
46}
47
48/// Error type for graph storage and mutation operations.
49#[derive(Debug, thiserror::Error, miette::Diagnostic)]
50#[non_exhaustive]
51pub enum GraphError {
52    /// The requested node row does not exist.
53    #[error("node not found: {id}")]
54    #[diagnostic(code(SLENE_G_001))]
55    NodeNotFound {
56        /// Missing node ID.
57        id: NodeId,
58    },
59
60    /// The requested edge row does not exist.
61    #[error("edge not found: {id}")]
62    #[diagnostic(code(SLENE_G_002))]
63    EdgeNotFound {
64        /// Missing edge ID.
65        id: EdgeId,
66    },
67
68    /// The requested node row exists but is not alive.
69    #[error("node {id} is not alive")]
70    #[diagnostic(code(SLENE_G_003))]
71    NodeNotAlive {
72        /// Dead node ID.
73        id: NodeId,
74    },
75
76    /// The requested edge row exists but is not alive.
77    #[error("edge {id} is not alive")]
78    #[diagnostic(code(SLENE_G_004))]
79    EdgeNotAlive {
80        /// Dead edge ID.
81        id: EdgeId,
82    },
83
84    /// The dense row store filled the v1 row-addressable range (max 2^32 rows).
85    ///
86    /// Post-4c the cap is a *row count*, not an id value: rows append at the
87    /// dense end and `u32::MAX` is reserved as `RowIndex::TOMBSTONE`, so the last
88    /// addressable row is `u32::MAX - 1`.
89    #[error("{kind} row store is full ({rows} rows; max {max_rows})")]
90    #[diagnostic(code(SLENE_G_005))]
91    RowSpaceExhausted {
92        /// `"node"` or `"edge"`.
93        kind: &'static str,
94        /// The current row count that hit the cap.
95        rows: u64,
96        /// The maximum addressable row count.
97        max_rows: u64,
98    },
99
100    /// The graph snapshot violates a structural invariant (e.g., row count
101    /// exceeds the addressable u32 range).
102    #[error("graph snapshot is inconsistent: {reason}")]
103    #[diagnostic(code(SLENE_G_006))]
104    Inconsistent {
105        /// Free-form description of the inconsistency.
106        reason: String,
107    },
108
109    /// A property index already exists for this `(label, property)`.
110    #[error("property index already exists for ({label}, {property})")]
111    #[diagnostic(code(SLENE_G_007))]
112    PropertyIndexAlreadyExists {
113        /// Indexed node label.
114        label: DbString,
115        /// Indexed property key.
116        property: DbString,
117    },
118
119    /// The named property index does not exist.
120    #[error("property index does not exist for ({label}, {property})")]
121    #[diagnostic(code(SLENE_G_008))]
122    PropertyIndexNotFound {
123        /// Indexed node label.
124        label: DbString,
125        /// Indexed property key.
126        property: DbString,
127    },
128
129    /// A value cannot be admitted to the declared property index kind.
130    #[error(
131        "property index ({label}, {property}) expected {expected_kind:?} but observed {observed}"
132    )]
133    #[diagnostic(code(SLENE_G_009))]
134    IndexValueRejected {
135        /// Indexed node label.
136        label: DbString,
137        /// Indexed property key.
138        property: DbString,
139        /// Registered index kind.
140        expected_kind: TypedIndexKind,
141        /// Observed value kind or `"NaN"`.
142        observed: &'static str,
143    },
144
145    /// A composite property index already exists for this `(label, properties...)`.
146    #[error("composite property index already exists for ({label}, {properties:?})")]
147    #[diagnostic(code(SLENE_G_020))]
148    CompositePropertyIndexAlreadyExists {
149        /// Indexed node label.
150        label: DbString,
151        /// Indexed property keys in declaration order.
152        ///
153        /// Boxed so this variant does not inflate `GraphError` past clippy's
154        /// `result_large_err` byte threshold: an inline `SmallVec<[DbString; 4]>`
155        /// is ~104 B (four owned string values plus header), and the variant
156        /// otherwise drives every `GraphResult<T>` stack slot over the limit.
157        /// The `Box` pushes the allocation onto the cold error-construction
158        /// path.
159        properties: Box<SmallVec<[DbString; 4]>>,
160    },
161
162    /// A vector property index already exists for this `(label, property)`.
163    #[error("vector index already exists for ({label}, {property})")]
164    #[diagnostic(code(SLENE_G_021))]
165    VectorIndexAlreadyExists {
166        /// Indexed node label.
167        label: DbString,
168        /// Indexed vector property key.
169        property: DbString,
170    },
171
172    /// A vector index was declared with an invalid dimensionality.
173    #[error("vector index dimension must be non-zero, observed {dimension}")]
174    #[diagnostic(code(SLENE_G_022))]
175    VectorIndexInvalidDimension {
176        /// Declared vector dimensionality.
177        dimension: u32,
178    },
179
180    /// A vector index was declared with invalid HNSW construction parameters.
181    #[error(
182        "invalid HNSW vector index config max_neighbors={max_neighbors}, ef_construction={ef_construction}: {reason}"
183    )]
184    #[diagnostic(code(SLENE_G_024))]
185    VectorIndexInvalidHnswConfig {
186        /// Declared HNSW `M` fanout.
187        max_neighbors: u16,
188        /// Declared HNSW construction beam width.
189        ef_construction: u16,
190        /// Reason the configuration is rejected.
191        reason: &'static str,
192    },
193
194    /// A vector index was declared with invalid IVF construction parameters.
195    #[error("invalid IVF vector index config target_centroids={target_centroids}: {reason}")]
196    #[diagnostic(code(SLENE_G_025))]
197    VectorIndexInvalidIvfConfig {
198        /// Declared IVF target centroid count.
199        target_centroids: u16,
200        /// Reason the configuration is rejected.
201        reason: &'static str,
202    },
203
204    /// A value cannot be admitted to a vector index.
205    #[error(
206        "vector index ({label}, {property}) expected VECTOR<{expected_dimension}> but observed {observed}"
207    )]
208    #[diagnostic(code(SLENE_G_023))]
209    VectorIndexValueRejected {
210        /// Indexed node label.
211        label: DbString,
212        /// Indexed vector property key.
213        property: DbString,
214        /// Registered vector dimensionality.
215        expected_dimension: u32,
216        /// Observed value kind or dimensionality.
217        observed: String,
218    },
219
220    /// A text index already exists for this `(label, property)`.
221    #[error("text index already exists for ({label}, {property})")]
222    #[diagnostic(code(SLENE_G_026))]
223    TextIndexAlreadyExists {
224        /// Indexed node label.
225        label: DbString,
226        /// Indexed string property key.
227        property: DbString,
228    },
229
230    /// A closed graph mutation violates its bound graph type.
231    #[error(transparent)]
232    #[diagnostic(transparent)]
233    TypeViolation(#[from] TypeViolation),
234
235    /// A store assignment failed before the graph mutation was applied.
236    #[error(transparent)]
237    #[diagnostic(transparent)]
238    StoreAssignment(Box<StoreAssignmentError>),
239
240    /// A commit-critical durable provider rejected or failed a write.
241    #[error("durable provider failed: {reason}")]
242    #[diagnostic(code(SLENE_G_015))]
243    Durable {
244        /// Human-readable durable provider failure reason.
245        reason: String,
246    },
247
248    /// The commit was cancelled at the pre-WAL cut-line (BRIEF-117): the
249    /// committer observed the cancellation token set before it appended the
250    /// commit to the WAL, so nothing was persisted or published. Past the WAL
251    /// append a commit is irrevocable and this is never returned.
252    #[error("commit cancelled before durable append")]
253    #[diagnostic(code(SLENE_G_019))]
254    Cancelled,
255
256    /// Error propagated from selene-core.
257    #[error(transparent)]
258    #[diagnostic(transparent)]
259    Core(#[from] CoreError),
260
261    /// Error propagated from an index provider.
262    #[error(transparent)]
263    #[diagnostic(transparent)]
264    Provider(#[from] ProviderError),
265
266    /// Error propagated from persistence recovery.
267    #[error(transparent)]
268    #[diagnostic(transparent)]
269    Persist(#[from] PersistError),
270}
271
272impl GraphError {
273    /// Map this error to its 5-character ISO GQLSTATUS code.
274    #[must_use]
275    pub const fn gqlstatus(&self) -> &'static str {
276        match self {
277            Self::NodeNotFound { .. }
278            | Self::EdgeNotFound { .. }
279            | Self::NodeNotAlive { .. }
280            | Self::EdgeNotAlive { .. } => "22G03",
281            Self::RowSpaceExhausted { .. } => "53000",
282            Self::Inconsistent { .. } => "5GQL0",
283            Self::PropertyIndexAlreadyExists { .. }
284            | Self::PropertyIndexNotFound { .. }
285            | Self::IndexValueRejected { .. }
286            | Self::CompositePropertyIndexAlreadyExists { .. }
287            | Self::VectorIndexAlreadyExists { .. }
288            | Self::VectorIndexInvalidDimension { .. }
289            | Self::VectorIndexInvalidHnswConfig { .. }
290            | Self::VectorIndexInvalidIvfConfig { .. }
291            | Self::VectorIndexValueRejected { .. }
292            | Self::TextIndexAlreadyExists { .. } => "22G03",
293            Self::TypeViolation(_) => "G2000",
294            Self::StoreAssignment(source) => source.exception.gqlstatus(),
295            Self::Core(source) => source.gqlstatus(),
296            Self::Durable { .. } => "5GQL0",
297            Self::Cancelled => "5GQL2",
298            Self::Provider(_) | Self::Persist(_) => "5GQL0",
299        }
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use rstest::rstest;
306    use selene_core::db_string;
307
308    use super::*;
309    use crate::ProviderError;
310
311    #[rstest]
312    #[case(GraphError::NodeNotFound { id: NodeId::new(1) }, "22G03")]
313    #[case(GraphError::EdgeNotFound { id: EdgeId::new(1) }, "22G03")]
314    #[case(GraphError::NodeNotAlive { id: NodeId::new(1) }, "22G03")]
315    #[case(GraphError::EdgeNotAlive { id: EdgeId::new(1) }, "22G03")]
316    #[case(
317        GraphError::RowSpaceExhausted { kind: "node", rows: 4_294_967_295, max_rows: 4_294_967_295 },
318        "53000"
319    )]
320    #[case(
321        GraphError::Inconsistent { reason: "row index exceeds u32::MAX".to_owned() },
322        "5GQL0"
323    )]
324    #[case(
325        GraphError::PropertyIndexAlreadyExists {
326            label: db_string("err.label").unwrap(),
327            property: db_string("err.property").unwrap(),
328        },
329        "22G03"
330    )]
331    #[case(
332        GraphError::PropertyIndexNotFound {
333            label: db_string("err.label.missing").unwrap(),
334            property: db_string("err.property.missing").unwrap(),
335        },
336        "22G03"
337    )]
338    #[case(
339        GraphError::IndexValueRejected {
340            label: db_string("err.label.rejected").unwrap(),
341            property: db_string("err.property.rejected").unwrap(),
342            expected_kind: TypedIndexKind::I64,
343            observed: "String",
344        },
345        "22G03"
346    )]
347    #[case(
348        GraphError::VectorIndexAlreadyExists {
349            label: db_string("err.label.vector.exists").unwrap(),
350            property: db_string("err.property.vector.exists").unwrap(),
351        },
352        "22G03"
353    )]
354    #[case(GraphError::VectorIndexInvalidDimension { dimension: 0 }, "22G03")]
355    #[case(
356        GraphError::VectorIndexInvalidHnswConfig {
357            max_neighbors: 24,
358            ef_construction: 8,
359            reason: "ef_construction must be at least max_neighbors",
360        },
361        "22G03"
362    )]
363    #[case(
364        GraphError::VectorIndexInvalidIvfConfig {
365            target_centroids: 0,
366            reason: "target_centroids must be greater than zero",
367        },
368        "22G03"
369    )]
370    #[case(
371        GraphError::VectorIndexValueRejected {
372            label: db_string("err.label.vector.rejected").unwrap(),
373            property: db_string("err.property.vector.rejected").unwrap(),
374            expected_dimension: 3,
375            observed: "VECTOR<4>".to_owned(),
376        },
377        "22G03"
378    )]
379    #[case(
380        GraphError::TextIndexAlreadyExists {
381            label: db_string("err.label.text.exists").unwrap(),
382            property: db_string("err.property.text.exists").unwrap(),
383        },
384        "22G03"
385    )]
386    #[case(
387        GraphError::TypeViolation(TypeViolation::UnknownEdgeLabel {
388            id: EdgeId::new(1),
389            label: db_string("err.edge.label").unwrap(),
390        }),
391        "G2000"
392    )]
393    #[case(
394        GraphError::StoreAssignment(Box::new(StoreAssignmentError {
395            property: db_string("err.assignment.property").unwrap(),
396            exception: StoreAssignmentException::StringDataRightTruncation,
397            reason: "right truncation".to_owned(),
398        })),
399        "22001"
400    )]
401    #[case(GraphError::Core(CoreError::ZeroIdentifier), "0G003")]
402    #[case(GraphError::Durable { reason: "wal unavailable".to_owned() }, "5GQL0")]
403    #[case(GraphError::Cancelled, "5GQL2")]
404    #[case(
405        GraphError::Provider(ProviderError::Inconsistent { reason: "duplicate provider tag DEMO".to_owned() }),
406        "5GQL0"
407    )]
408    #[case(GraphError::Persist(PersistError::MalformedSnapshotFilename), "5GQL0")]
409    fn gqlstatus_for_each_variant(#[case] error: GraphError, #[case] status: &str) {
410        assert_eq!(error.gqlstatus(), status);
411        assert!(
412            selene_core::gqlstatus_name(status).is_some(),
413            "GQLSTATUS code {status} emitted by GraphError but not in ALL_GQLSTATUS_NAMES"
414        );
415    }
416
417    #[test]
418    fn core_error_variant_propagates() {
419        fn inner() -> Result<(), CoreError> {
420            Err(CoreError::ZeroIdentifier)
421        }
422        fn outer() -> GraphResult<()> {
423            inner()?;
424            Ok(())
425        }
426        assert!(matches!(
427            outer(),
428            Err(GraphError::Core(CoreError::ZeroIdentifier))
429        ));
430    }
431
432    /// Internal miette `SLENE_G_*` diagnostic codes must be unique across the
433    /// graph-crate diagnostic enums (`GraphError`, `TypeViolation`,
434    /// `ProviderError`). A reused code makes two semantically different errors
435    /// indistinguishable in diagnostics. (GRAPH-42 regression guard.)
436    #[test]
437    fn internal_diagnostic_codes_are_unique() {
438        use miette::Diagnostic;
439        use selene_core::{LabelSet, PropertyValueType};
440
441        use crate::graph_types::EdgeEndpointDef;
442        use crate::type_validator::EntityId;
443
444        let lbl = db_string("codes.label").unwrap();
445        let prop = db_string("codes.property").unwrap();
446
447        // One representative of every code-carrying GraphError variant (the
448        // transparent `TypeViolation`/`Core`/`Persist`/`Provider` wrappers carry
449        // no own code).
450        let graph_errors: Vec<GraphError> = vec![
451            GraphError::NodeNotFound { id: NodeId::new(1) },
452            GraphError::EdgeNotFound { id: EdgeId::new(1) },
453            GraphError::NodeNotAlive { id: NodeId::new(1) },
454            GraphError::EdgeNotAlive { id: EdgeId::new(1) },
455            GraphError::RowSpaceExhausted {
456                kind: "node",
457                rows: 1,
458                max_rows: 1,
459            },
460            GraphError::Inconsistent {
461                reason: "x".to_owned(),
462            },
463            GraphError::PropertyIndexAlreadyExists {
464                label: lbl.clone(),
465                property: prop.clone(),
466            },
467            GraphError::PropertyIndexNotFound {
468                label: lbl.clone(),
469                property: prop.clone(),
470            },
471            GraphError::IndexValueRejected {
472                label: lbl.clone(),
473                property: prop.clone(),
474                expected_kind: TypedIndexKind::I64,
475                observed: "String",
476            },
477            GraphError::CompositePropertyIndexAlreadyExists {
478                label: lbl.clone(),
479                properties: Box::default(),
480            },
481            GraphError::VectorIndexAlreadyExists {
482                label: lbl.clone(),
483                property: prop.clone(),
484            },
485            GraphError::VectorIndexInvalidDimension { dimension: 0 },
486            GraphError::VectorIndexInvalidHnswConfig {
487                max_neighbors: 24,
488                ef_construction: 8,
489                reason: "ef_construction must be at least max_neighbors",
490            },
491            GraphError::VectorIndexInvalidIvfConfig {
492                target_centroids: 0,
493                reason: "target_centroids must be greater than zero",
494            },
495            GraphError::VectorIndexValueRejected {
496                label: lbl.clone(),
497                property: prop.clone(),
498                expected_dimension: 3,
499                observed: "VECTOR<4>".to_owned(),
500            },
501            GraphError::TextIndexAlreadyExists {
502                label: lbl.clone(),
503                property: prop.clone(),
504            },
505            GraphError::StoreAssignment(Box::new(StoreAssignmentError {
506                property: prop.clone(),
507                exception: StoreAssignmentException::StringDataRightTruncation,
508                reason: "right truncation".to_owned(),
509            })),
510            GraphError::Durable {
511                reason: "x".to_owned(),
512            },
513            GraphError::Cancelled,
514        ];
515
516        let type_violations: Vec<TypeViolation> = vec![
517            TypeViolation::UnknownNodeLabel {
518                id: NodeId::new(1),
519                labels: LabelSet::new(),
520            },
521            TypeViolation::UnknownEdgeLabel {
522                id: EdgeId::new(1),
523                label: lbl.clone(),
524            },
525            TypeViolation::EdgeEndpointTypeMismatch {
526                id: EdgeId::new(1),
527                label: lbl.clone(),
528                expected_source_type: EdgeEndpointDef::Any,
529                observed_source_type: 0,
530                expected_target_type: EdgeEndpointDef::Any,
531                observed_target_type: 0,
532            },
533            TypeViolation::MissingRequiredProperty {
534                entity_id: EntityId::Node(NodeId::new(1)),
535                property: prop.clone(),
536                declared_in: lbl.clone(),
537            },
538            TypeViolation::PropertyTypeMismatch {
539                entity_id: EntityId::Node(NodeId::new(1)),
540                property: prop.clone(),
541                expected: PropertyValueType::Int,
542                observed: "String",
543            },
544            TypeViolation::ExtensionValueRejected {
545                entity_id: EntityId::Node(NodeId::new(1)),
546                property: prop.clone(),
547            },
548            TypeViolation::UndeclaredProperty {
549                entity_id: EntityId::Node(NodeId::new(1)),
550                property: prop.clone(),
551            },
552            TypeViolation::ImmutablePropertyUpdate {
553                entity_id: EntityId::Node(NodeId::new(1)),
554                property: prop,
555                declared_in: lbl,
556            },
557        ];
558
559        let provider_errors: Vec<ProviderError> = vec![
560            ProviderError::InvalidPayload {
561                reason: "x".to_owned(),
562            },
563            ProviderError::SerializationFailed {
564                reason: "x".to_owned(),
565            },
566            ProviderError::Inconsistent {
567                reason: "x".to_owned(),
568            },
569        ];
570
571        let mut codes: Vec<String> = Vec::new();
572        codes.extend(
573            graph_errors
574                .iter()
575                .filter_map(|e| e.code().map(|c| c.to_string())),
576        );
577        codes.extend(
578            type_violations
579                .iter()
580                .filter_map(|e| e.code().map(|c| c.to_string())),
581        );
582        codes.extend(
583            provider_errors
584                .iter()
585                .filter_map(|e| e.code().map(|c| c.to_string())),
586        );
587
588        let mut seen = std::collections::HashSet::new();
589        for code in &codes {
590            assert!(
591                seen.insert(code.clone()),
592                "duplicate internal diagnostic code {code} across graph-crate error enums"
593            );
594        }
595    }
596}