1use 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
11pub type GraphResult<T> = Result<T, GraphError>;
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16#[non_exhaustive]
17pub enum StoreAssignmentException {
18 StringDataRightTruncation,
20 NumericValueOutOfRange,
22}
23
24impl StoreAssignmentException {
25 #[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#[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 pub property: DbString,
42 pub exception: StoreAssignmentException,
44 pub reason: String,
46}
47
48#[derive(Debug, thiserror::Error, miette::Diagnostic)]
50#[non_exhaustive]
51pub enum GraphError {
52 #[error("node not found: {id}")]
54 #[diagnostic(code(SLENE_G_001))]
55 NodeNotFound {
56 id: NodeId,
58 },
59
60 #[error("edge not found: {id}")]
62 #[diagnostic(code(SLENE_G_002))]
63 EdgeNotFound {
64 id: EdgeId,
66 },
67
68 #[error("node {id} is not alive")]
70 #[diagnostic(code(SLENE_G_003))]
71 NodeNotAlive {
72 id: NodeId,
74 },
75
76 #[error("edge {id} is not alive")]
78 #[diagnostic(code(SLENE_G_004))]
79 EdgeNotAlive {
80 id: EdgeId,
82 },
83
84 #[error("{kind} row store is full ({rows} rows; max {max_rows})")]
90 #[diagnostic(code(SLENE_G_005))]
91 RowSpaceExhausted {
92 kind: &'static str,
94 rows: u64,
96 max_rows: u64,
98 },
99
100 #[error("graph snapshot is inconsistent: {reason}")]
103 #[diagnostic(code(SLENE_G_006))]
104 Inconsistent {
105 reason: String,
107 },
108
109 #[error("property index already exists for ({label}, {property})")]
111 #[diagnostic(code(SLENE_G_007))]
112 PropertyIndexAlreadyExists {
113 label: DbString,
115 property: DbString,
117 },
118
119 #[error("property index does not exist for ({label}, {property})")]
121 #[diagnostic(code(SLENE_G_008))]
122 PropertyIndexNotFound {
123 label: DbString,
125 property: DbString,
127 },
128
129 #[error(
131 "property index ({label}, {property}) expected {expected_kind:?} but observed {observed}"
132 )]
133 #[diagnostic(code(SLENE_G_009))]
134 IndexValueRejected {
135 label: DbString,
137 property: DbString,
139 expected_kind: TypedIndexKind,
141 observed: &'static str,
143 },
144
145 #[error("composite property index already exists for ({label}, {properties:?})")]
147 #[diagnostic(code(SLENE_G_020))]
148 CompositePropertyIndexAlreadyExists {
149 label: DbString,
151 properties: Box<SmallVec<[DbString; 4]>>,
160 },
161
162 #[error("vector index already exists for ({label}, {property})")]
164 #[diagnostic(code(SLENE_G_021))]
165 VectorIndexAlreadyExists {
166 label: DbString,
168 property: DbString,
170 },
171
172 #[error("vector index dimension must be non-zero, observed {dimension}")]
174 #[diagnostic(code(SLENE_G_022))]
175 VectorIndexInvalidDimension {
176 dimension: u32,
178 },
179
180 #[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 max_neighbors: u16,
188 ef_construction: u16,
190 reason: &'static str,
192 },
193
194 #[error("invalid IVF vector index config target_centroids={target_centroids}: {reason}")]
196 #[diagnostic(code(SLENE_G_025))]
197 VectorIndexInvalidIvfConfig {
198 target_centroids: u16,
200 reason: &'static str,
202 },
203
204 #[error(
206 "vector index ({label}, {property}) expected VECTOR<{expected_dimension}> but observed {observed}"
207 )]
208 #[diagnostic(code(SLENE_G_023))]
209 VectorIndexValueRejected {
210 label: DbString,
212 property: DbString,
214 expected_dimension: u32,
216 observed: String,
218 },
219
220 #[error("text index already exists for ({label}, {property})")]
222 #[diagnostic(code(SLENE_G_026))]
223 TextIndexAlreadyExists {
224 label: DbString,
226 property: DbString,
228 },
229
230 #[error(transparent)]
232 #[diagnostic(transparent)]
233 TypeViolation(#[from] TypeViolation),
234
235 #[error(transparent)]
237 #[diagnostic(transparent)]
238 StoreAssignment(Box<StoreAssignmentError>),
239
240 #[error("durable provider failed: {reason}")]
242 #[diagnostic(code(SLENE_G_015))]
243 Durable {
244 reason: String,
246 },
247
248 #[error("commit cancelled before durable append")]
253 #[diagnostic(code(SLENE_G_019))]
254 Cancelled,
255
256 #[error(transparent)]
258 #[diagnostic(transparent)]
259 Core(#[from] CoreError),
260
261 #[error(transparent)]
263 #[diagnostic(transparent)]
264 Provider(#[from] ProviderError),
265
266 #[error(transparent)]
268 #[diagnostic(transparent)]
269 Persist(#[from] PersistError),
270}
271
272impl GraphError {
273 #[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 #[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 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}