use selene_core::{CoreError, DbString, EdgeId, NodeId};
use selene_persist::PersistError;
use smallvec::SmallVec;
use crate::index_provider::ProviderError;
use crate::type_validator::TypeViolation;
use crate::typed_index::TypedIndexKind;
pub type GraphResult<T> = Result<T, GraphError>;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum StoreAssignmentException {
StringDataRightTruncation,
NumericValueOutOfRange,
}
impl StoreAssignmentException {
#[must_use]
pub const fn gqlstatus(self) -> &'static str {
match self {
Self::StringDataRightTruncation => "22001",
Self::NumericValueOutOfRange => "22003",
}
}
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[error("store assignment to property {property} failed: {reason}")]
#[diagnostic(code(SLENE_G_027))]
pub struct StoreAssignmentError {
pub property: DbString,
pub exception: StoreAssignmentException,
pub reason: String,
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[non_exhaustive]
pub enum GraphError {
#[error("node not found: {id}")]
#[diagnostic(code(SLENE_G_001))]
NodeNotFound {
id: NodeId,
},
#[error("edge not found: {id}")]
#[diagnostic(code(SLENE_G_002))]
EdgeNotFound {
id: EdgeId,
},
#[error("node {id} is not alive")]
#[diagnostic(code(SLENE_G_003))]
NodeNotAlive {
id: NodeId,
},
#[error("edge {id} is not alive")]
#[diagnostic(code(SLENE_G_004))]
EdgeNotAlive {
id: EdgeId,
},
#[error("{kind} row store is full ({rows} rows; max {max_rows})")]
#[diagnostic(code(SLENE_G_005))]
RowSpaceExhausted {
kind: &'static str,
rows: u64,
max_rows: u64,
},
#[error("graph snapshot is inconsistent: {reason}")]
#[diagnostic(code(SLENE_G_006))]
Inconsistent {
reason: String,
},
#[error("property index already exists for ({label}, {property})")]
#[diagnostic(code(SLENE_G_007))]
PropertyIndexAlreadyExists {
label: DbString,
property: DbString,
},
#[error("property index does not exist for ({label}, {property})")]
#[diagnostic(code(SLENE_G_008))]
PropertyIndexNotFound {
label: DbString,
property: DbString,
},
#[error(
"property index ({label}, {property}) expected {expected_kind:?} but observed {observed}"
)]
#[diagnostic(code(SLENE_G_009))]
IndexValueRejected {
label: DbString,
property: DbString,
expected_kind: TypedIndexKind,
observed: &'static str,
},
#[error("composite property index already exists for ({label}, {properties:?})")]
#[diagnostic(code(SLENE_G_020))]
CompositePropertyIndexAlreadyExists {
label: DbString,
properties: Box<SmallVec<[DbString; 4]>>,
},
#[error("vector index already exists for ({label}, {property})")]
#[diagnostic(code(SLENE_G_021))]
VectorIndexAlreadyExists {
label: DbString,
property: DbString,
},
#[error("vector index dimension must be non-zero, observed {dimension}")]
#[diagnostic(code(SLENE_G_022))]
VectorIndexInvalidDimension {
dimension: u32,
},
#[error(
"invalid HNSW vector index config max_neighbors={max_neighbors}, ef_construction={ef_construction}: {reason}"
)]
#[diagnostic(code(SLENE_G_024))]
VectorIndexInvalidHnswConfig {
max_neighbors: u16,
ef_construction: u16,
reason: &'static str,
},
#[error("invalid IVF vector index config target_centroids={target_centroids}: {reason}")]
#[diagnostic(code(SLENE_G_025))]
VectorIndexInvalidIvfConfig {
target_centroids: u16,
reason: &'static str,
},
#[error(
"vector index ({label}, {property}) expected VECTOR<{expected_dimension}> but observed {observed}"
)]
#[diagnostic(code(SLENE_G_023))]
VectorIndexValueRejected {
label: DbString,
property: DbString,
expected_dimension: u32,
observed: String,
},
#[error("text index already exists for ({label}, {property})")]
#[diagnostic(code(SLENE_G_026))]
TextIndexAlreadyExists {
label: DbString,
property: DbString,
},
#[error(transparent)]
#[diagnostic(transparent)]
TypeViolation(#[from] TypeViolation),
#[error(transparent)]
#[diagnostic(transparent)]
StoreAssignment(Box<StoreAssignmentError>),
#[error("durable provider failed: {reason}")]
#[diagnostic(code(SLENE_G_015))]
Durable {
reason: String,
},
#[error("commit cancelled before durable append")]
#[diagnostic(code(SLENE_G_019))]
Cancelled,
#[error(transparent)]
#[diagnostic(transparent)]
Core(#[from] CoreError),
#[error(transparent)]
#[diagnostic(transparent)]
Provider(#[from] ProviderError),
#[error(transparent)]
#[diagnostic(transparent)]
Persist(#[from] PersistError),
}
impl GraphError {
#[must_use]
pub const fn gqlstatus(&self) -> &'static str {
match self {
Self::NodeNotFound { .. }
| Self::EdgeNotFound { .. }
| Self::NodeNotAlive { .. }
| Self::EdgeNotAlive { .. } => "22G03",
Self::RowSpaceExhausted { .. } => "53000",
Self::Inconsistent { .. } => "5GQL0",
Self::PropertyIndexAlreadyExists { .. }
| Self::PropertyIndexNotFound { .. }
| Self::IndexValueRejected { .. }
| Self::CompositePropertyIndexAlreadyExists { .. }
| Self::VectorIndexAlreadyExists { .. }
| Self::VectorIndexInvalidDimension { .. }
| Self::VectorIndexInvalidHnswConfig { .. }
| Self::VectorIndexInvalidIvfConfig { .. }
| Self::VectorIndexValueRejected { .. }
| Self::TextIndexAlreadyExists { .. } => "22G03",
Self::TypeViolation(_) => "G2000",
Self::StoreAssignment(source) => source.exception.gqlstatus(),
Self::Core(source) => source.gqlstatus(),
Self::Durable { .. } => "5GQL0",
Self::Cancelled => "5GQL2",
Self::Provider(_) | Self::Persist(_) => "5GQL0",
}
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use selene_core::db_string;
use super::*;
use crate::ProviderError;
#[rstest]
#[case(GraphError::NodeNotFound { id: NodeId::new(1) }, "22G03")]
#[case(GraphError::EdgeNotFound { id: EdgeId::new(1) }, "22G03")]
#[case(GraphError::NodeNotAlive { id: NodeId::new(1) }, "22G03")]
#[case(GraphError::EdgeNotAlive { id: EdgeId::new(1) }, "22G03")]
#[case(
GraphError::RowSpaceExhausted { kind: "node", rows: 4_294_967_295, max_rows: 4_294_967_295 },
"53000"
)]
#[case(
GraphError::Inconsistent { reason: "row index exceeds u32::MAX".to_owned() },
"5GQL0"
)]
#[case(
GraphError::PropertyIndexAlreadyExists {
label: db_string("err.label").unwrap(),
property: db_string("err.property").unwrap(),
},
"22G03"
)]
#[case(
GraphError::PropertyIndexNotFound {
label: db_string("err.label.missing").unwrap(),
property: db_string("err.property.missing").unwrap(),
},
"22G03"
)]
#[case(
GraphError::IndexValueRejected {
label: db_string("err.label.rejected").unwrap(),
property: db_string("err.property.rejected").unwrap(),
expected_kind: TypedIndexKind::I64,
observed: "String",
},
"22G03"
)]
#[case(
GraphError::VectorIndexAlreadyExists {
label: db_string("err.label.vector.exists").unwrap(),
property: db_string("err.property.vector.exists").unwrap(),
},
"22G03"
)]
#[case(GraphError::VectorIndexInvalidDimension { dimension: 0 }, "22G03")]
#[case(
GraphError::VectorIndexInvalidHnswConfig {
max_neighbors: 24,
ef_construction: 8,
reason: "ef_construction must be at least max_neighbors",
},
"22G03"
)]
#[case(
GraphError::VectorIndexInvalidIvfConfig {
target_centroids: 0,
reason: "target_centroids must be greater than zero",
},
"22G03"
)]
#[case(
GraphError::VectorIndexValueRejected {
label: db_string("err.label.vector.rejected").unwrap(),
property: db_string("err.property.vector.rejected").unwrap(),
expected_dimension: 3,
observed: "VECTOR<4>".to_owned(),
},
"22G03"
)]
#[case(
GraphError::TextIndexAlreadyExists {
label: db_string("err.label.text.exists").unwrap(),
property: db_string("err.property.text.exists").unwrap(),
},
"22G03"
)]
#[case(
GraphError::TypeViolation(TypeViolation::UnknownEdgeLabel {
id: EdgeId::new(1),
label: db_string("err.edge.label").unwrap(),
}),
"G2000"
)]
#[case(
GraphError::StoreAssignment(Box::new(StoreAssignmentError {
property: db_string("err.assignment.property").unwrap(),
exception: StoreAssignmentException::StringDataRightTruncation,
reason: "right truncation".to_owned(),
})),
"22001"
)]
#[case(GraphError::Core(CoreError::ZeroIdentifier), "0G003")]
#[case(GraphError::Durable { reason: "wal unavailable".to_owned() }, "5GQL0")]
#[case(GraphError::Cancelled, "5GQL2")]
#[case(
GraphError::Provider(ProviderError::Inconsistent { reason: "duplicate provider tag DEMO".to_owned() }),
"5GQL0"
)]
#[case(GraphError::Persist(PersistError::MalformedSnapshotFilename), "5GQL0")]
fn gqlstatus_for_each_variant(#[case] error: GraphError, #[case] status: &str) {
assert_eq!(error.gqlstatus(), status);
assert!(
selene_core::gqlstatus_name(status).is_some(),
"GQLSTATUS code {status} emitted by GraphError but not in ALL_GQLSTATUS_NAMES"
);
}
#[test]
fn core_error_variant_propagates() {
fn inner() -> Result<(), CoreError> {
Err(CoreError::ZeroIdentifier)
}
fn outer() -> GraphResult<()> {
inner()?;
Ok(())
}
assert!(matches!(
outer(),
Err(GraphError::Core(CoreError::ZeroIdentifier))
));
}
#[test]
fn internal_diagnostic_codes_are_unique() {
use miette::Diagnostic;
use selene_core::{LabelSet, PropertyValueType};
use crate::graph_types::EdgeEndpointDef;
use crate::type_validator::EntityId;
let lbl = db_string("codes.label").unwrap();
let prop = db_string("codes.property").unwrap();
let graph_errors: Vec<GraphError> = vec![
GraphError::NodeNotFound { id: NodeId::new(1) },
GraphError::EdgeNotFound { id: EdgeId::new(1) },
GraphError::NodeNotAlive { id: NodeId::new(1) },
GraphError::EdgeNotAlive { id: EdgeId::new(1) },
GraphError::RowSpaceExhausted {
kind: "node",
rows: 1,
max_rows: 1,
},
GraphError::Inconsistent {
reason: "x".to_owned(),
},
GraphError::PropertyIndexAlreadyExists {
label: lbl.clone(),
property: prop.clone(),
},
GraphError::PropertyIndexNotFound {
label: lbl.clone(),
property: prop.clone(),
},
GraphError::IndexValueRejected {
label: lbl.clone(),
property: prop.clone(),
expected_kind: TypedIndexKind::I64,
observed: "String",
},
GraphError::CompositePropertyIndexAlreadyExists {
label: lbl.clone(),
properties: Box::default(),
},
GraphError::VectorIndexAlreadyExists {
label: lbl.clone(),
property: prop.clone(),
},
GraphError::VectorIndexInvalidDimension { dimension: 0 },
GraphError::VectorIndexInvalidHnswConfig {
max_neighbors: 24,
ef_construction: 8,
reason: "ef_construction must be at least max_neighbors",
},
GraphError::VectorIndexInvalidIvfConfig {
target_centroids: 0,
reason: "target_centroids must be greater than zero",
},
GraphError::VectorIndexValueRejected {
label: lbl.clone(),
property: prop.clone(),
expected_dimension: 3,
observed: "VECTOR<4>".to_owned(),
},
GraphError::TextIndexAlreadyExists {
label: lbl.clone(),
property: prop.clone(),
},
GraphError::StoreAssignment(Box::new(StoreAssignmentError {
property: prop.clone(),
exception: StoreAssignmentException::StringDataRightTruncation,
reason: "right truncation".to_owned(),
})),
GraphError::Durable {
reason: "x".to_owned(),
},
GraphError::Cancelled,
];
let type_violations: Vec<TypeViolation> = vec![
TypeViolation::UnknownNodeLabel {
id: NodeId::new(1),
labels: LabelSet::new(),
},
TypeViolation::UnknownEdgeLabel {
id: EdgeId::new(1),
label: lbl.clone(),
},
TypeViolation::EdgeEndpointTypeMismatch {
id: EdgeId::new(1),
label: lbl.clone(),
expected_source_type: EdgeEndpointDef::Any,
observed_source_type: 0,
expected_target_type: EdgeEndpointDef::Any,
observed_target_type: 0,
},
TypeViolation::MissingRequiredProperty {
entity_id: EntityId::Node(NodeId::new(1)),
property: prop.clone(),
declared_in: lbl.clone(),
},
TypeViolation::PropertyTypeMismatch {
entity_id: EntityId::Node(NodeId::new(1)),
property: prop.clone(),
expected: PropertyValueType::Int,
observed: "String",
},
TypeViolation::ExtensionValueRejected {
entity_id: EntityId::Node(NodeId::new(1)),
property: prop.clone(),
},
TypeViolation::UndeclaredProperty {
entity_id: EntityId::Node(NodeId::new(1)),
property: prop.clone(),
},
TypeViolation::ImmutablePropertyUpdate {
entity_id: EntityId::Node(NodeId::new(1)),
property: prop,
declared_in: lbl,
},
];
let provider_errors: Vec<ProviderError> = vec![
ProviderError::InvalidPayload {
reason: "x".to_owned(),
},
ProviderError::SerializationFailed {
reason: "x".to_owned(),
},
ProviderError::Inconsistent {
reason: "x".to_owned(),
},
];
let mut codes: Vec<String> = Vec::new();
codes.extend(
graph_errors
.iter()
.filter_map(|e| e.code().map(|c| c.to_string())),
);
codes.extend(
type_violations
.iter()
.filter_map(|e| e.code().map(|c| c.to_string())),
);
codes.extend(
provider_errors
.iter()
.filter_map(|e| e.code().map(|c| c.to_string())),
);
let mut seen = std::collections::HashSet::new();
for code in &codes {
assert!(
seen.insert(code.clone()),
"duplicate internal diagnostic code {code} across graph-crate error enums"
);
}
}
}