use std::fmt;
use thiserror::Error;
mod code;
mod context;
pub use code::{ErrorCategory, ErrorCode};
pub use context::ErrorContext;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum NavigationErrorCode {
UnknownRoot,
AmbiguousRoot,
NotAReference,
UnsupportedReferenceShape,
TargetColumnNotFound,
ReadOnly,
SchemaChanged,
TargetMissing,
TargetNotUnique,
}
impl NavigationErrorCode {
pub const fn as_str(self) -> &'static str {
match self {
Self::UnknownRoot => "NAVIGATION_UNKNOWN_ROOT",
Self::AmbiguousRoot => "NAVIGATION_AMBIGUOUS_ROOT",
Self::NotAReference => "NAVIGATION_NOT_A_REFERENCE",
Self::UnsupportedReferenceShape => "NAVIGATION_UNSUPPORTED_REFERENCE_SHAPE",
Self::TargetColumnNotFound => "NAVIGATION_TARGET_COLUMN_NOT_FOUND",
Self::ReadOnly => "NAVIGATION_READ_ONLY",
Self::SchemaChanged => "NAVIGATION_SCHEMA_CHANGED",
Self::TargetMissing => "REFERENCE_TARGET_MISSING",
Self::TargetNotUnique => "REFERENCE_TARGET_NOT_UNIQUE",
}
}
}
impl fmt::Display for NavigationErrorCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Error, Debug, Clone, PartialEq, Eq)]
pub enum Error {
#[error("table '{0}' not found")]
TableNotFound(String),
#[error("table '{0}' already exists")]
TableAlreadyExists(String),
#[error("table closed")]
TableClosed,
#[error("table columns don't match, expected {expected}, got {got}")]
TableColumnsNotMatch { expected: usize, got: usize },
#[error("cannot truncate table: active transactions have uncommitted changes")]
TableHasActiveTransactions,
#[error("column '{0}' not found")]
ColumnNotFound(String),
#[error("column '{0}' is ambiguous")]
AmbiguousColumn(String),
#[error("invalid column type")]
InvalidColumnType,
#[error("Vector dimension mismatch: expected {expected}, got {got}")]
VectorDimensionMismatch { expected: u16, got: u16 },
#[error("duplicate column")]
DuplicateColumn,
#[error("invalid value")]
InvalidValue,
#[error("invalid argument: {0}")]
InvalidArgument(String),
#[error("authorization denied: {0}")]
AuthorizationDenied(String),
#[error("value for column {column} is too long, max {max}, got {got}")]
ValueTooLong {
column: String,
max: usize,
got: usize,
},
#[error("not null constraint failed for column {column}")]
NotNullConstraint { column: String },
#[error("primary key constraint failed with {row_id} already exists in this table")]
PrimaryKeyConstraint { row_id: i64 },
#[error("unique constraint failed for index {index} on column {column} with value {value}")]
UniqueConstraint {
index: String,
column: String,
value: String,
row_id: i64,
},
#[error("CHECK constraint failed for column {column}: {expression}")]
CheckConstraintViolation { column: String, expression: String },
#[error("foreign key constraint violation: column '{column}' in table '{table}' references '{ref_table}({ref_column})' — {detail}")]
ForeignKeyViolation {
table: String,
column: String,
ref_table: String,
ref_column: String,
detail: String,
},
#[error("transaction not started")]
TransactionNotStarted,
#[error("transaction already started")]
TransactionAlreadyStarted,
#[error("transaction already ended")]
TransactionEnded,
#[error("transaction aborted")]
TransactionAborted,
#[error("transaction already committed")]
TransactionCommitted,
#[error("transaction already closed")]
TransactionClosed,
#[error("invalid transaction {txn_id} transition: expected {expected}, found {actual}")]
InvalidTransactionTransition {
txn_id: i64,
expected: String,
actual: String,
},
#[error(
"transaction serialization conflict while acquiring row {row_id}; retry the transaction"
)]
TransactionSerializationConflict { row_id: i64 },
#[error("transaction timed out while waiting to update row {row_id} after {timeout_ms} ms")]
RowLockTimeout { row_id: i64, timeout_ms: u64 },
#[error(
"COMPACTION_BACKPRESSURE: table '{table}' has L0 debt {segments} segments/{physical_bytes} bytes; hard limit {hard_segments} segments/{hard_bytes} bytes; retry after compaction"
)]
CompactionBackpressure {
table: String,
segments: u64,
physical_bytes: u64,
hard_segments: u64,
hard_bytes: u64,
},
#[error("index '{0}' not found")]
IndexNotFound(String),
#[error("index '{0}' already exists")]
IndexAlreadyExists(String),
#[error("index column not found")]
IndexColumnNotFound,
#[error("index is closed")]
IndexClosed,
#[error("engine is not open")]
EngineNotOpen,
#[error("engine is already open")]
EngineAlreadyOpen,
#[error("view '{0}' already exists")]
ViewAlreadyExists(String),
#[error("view '{0}' not found")]
ViewNotFound(String),
#[error("failed to acquire lock: {0}")]
LockAcquisitionFailed(String),
#[error("query returned no rows")]
NoRowsReturned,
#[error("no statements to execute")]
NoStatementsToExecute,
#[error("column index {index} out of bounds")]
ColumnIndexOutOfBounds { index: usize },
#[error("cursor is not positioned on a row")]
CursorNotPositioned,
#[error("WAL manager is not running")]
WalNotRunning,
#[error("WAL file is closed")]
WalFileClosed,
#[error("WAL durability outcome is uncertain: {detail}")]
WalDurabilityUncertain { detail: String },
#[error("{operation} failed after committing {committed_rows} rows: {cause}")]
PartialCommit {
operation: String,
committed_rows: i64,
cause: String,
},
#[error(
"COPY transaction memory budget exceeded at row {row}: limit {limit_bytes} bytes, attempted {attempted_bytes} bytes"
)]
CopyTransactionMemoryLimit {
row: i64,
limit_bytes: usize,
attempted_bytes: usize,
},
#[error("WAL not initialized")]
WalNotInitialized,
#[error("database is locked by another process")]
DatabaseLocked,
#[error("cannot drop primary key column")]
CannotDropPrimaryKey,
#[error("cannot compare NULL with non-NULL value")]
NullComparison,
#[error("cannot compare incompatible types")]
IncomparableTypes,
#[error("not supported: {0}")]
NotSupported(String),
#[error("native function '{function}' failed with plugin status {status}")]
NativeFunction { function: String, status: u32 },
#[error("{code}: {detail}")]
Navigation {
code: NavigationErrorCode,
detail: String,
},
#[error("segment not found")]
SegmentNotFound,
#[error("expression evaluation failed")]
ExpressionEvaluation,
#[error("expression evaluation failed: {message}")]
ExpressionEvaluationWithMessage { message: String },
#[error("type conversion error: cannot convert {from} to {to}")]
TypeConversion { from: String, to: String },
#[error("parse error: {0}")]
Parse(String),
#[error("IO error: {message}")]
Io { message: String },
#[error("{message}")]
Internal { message: String },
#[error("table or view '{0}' not found")]
TableOrViewNotFound(String),
#[error("type error: {0}")]
Type(String),
#[error("division by zero")]
DivisionByZero,
#[error("query cancelled")]
QueryCancelled,
}
impl Error {
pub fn code(&self) -> ErrorCode {
let code = match self {
Self::TableNotFound(_) => "TABLE_NOT_FOUND",
Self::TableAlreadyExists(_) => "TABLE_ALREADY_EXISTS",
Self::TableClosed => "TABLE_CLOSED",
Self::TableColumnsNotMatch { .. } => "TABLE_COLUMNS_NOT_MATCH",
Self::TableHasActiveTransactions => "TABLE_HAS_ACTIVE_TRANSACTIONS",
Self::ColumnNotFound(_) => "COLUMN_NOT_FOUND",
Self::AmbiguousColumn(_) => "AMBIGUOUS_COLUMN",
Self::InvalidColumnType => "INVALID_COLUMN_TYPE",
Self::VectorDimensionMismatch { .. } => "VECTOR_DIMENSION_MISMATCH",
Self::DuplicateColumn => "DUPLICATE_COLUMN",
Self::InvalidValue => "INVALID_VALUE",
Self::InvalidArgument(_) => "INVALID_ARGUMENT",
Self::AuthorizationDenied(_) => "AUTHORIZATION_DENIED",
Self::ValueTooLong { .. } => "VALUE_TOO_LONG",
Self::NotNullConstraint { .. } => "NOT_NULL_CONSTRAINT",
Self::PrimaryKeyConstraint { .. } => "PRIMARY_KEY_CONSTRAINT",
Self::UniqueConstraint { .. } => "UNIQUE_CONSTRAINT",
Self::CheckConstraintViolation { .. } => "CHECK_CONSTRAINT",
Self::ForeignKeyViolation { .. } => "FOREIGN_KEY_CONSTRAINT",
Self::TransactionNotStarted => "TRANSACTION_NOT_STARTED",
Self::TransactionAlreadyStarted => "TRANSACTION_ALREADY_STARTED",
Self::TransactionEnded => "TRANSACTION_ENDED",
Self::TransactionAborted => "TRANSACTION_ABORTED",
Self::TransactionCommitted => "TRANSACTION_COMMITTED",
Self::TransactionClosed => "TRANSACTION_CLOSED",
Self::InvalidTransactionTransition { .. } => "INVALID_TRANSACTION_TRANSITION",
Self::TransactionSerializationConflict { .. } => "TRANSACTION_SERIALIZATION_CONFLICT",
Self::RowLockTimeout { .. } => "ROW_LOCK_TIMEOUT",
Self::CompactionBackpressure { .. } => "COMPACTION_BACKPRESSURE",
Self::IndexNotFound(_) => "INDEX_NOT_FOUND",
Self::IndexAlreadyExists(_) => "INDEX_ALREADY_EXISTS",
Self::IndexColumnNotFound => "INDEX_COLUMN_NOT_FOUND",
Self::IndexClosed => "INDEX_CLOSED",
Self::EngineNotOpen => "ENGINE_NOT_OPEN",
Self::EngineAlreadyOpen => "ENGINE_ALREADY_OPEN",
Self::ViewAlreadyExists(_) => "VIEW_ALREADY_EXISTS",
Self::ViewNotFound(_) => "VIEW_NOT_FOUND",
Self::LockAcquisitionFailed(_) => "LOCK_ACQUISITION_FAILED",
Self::NoRowsReturned => "NO_ROWS_RETURNED",
Self::NoStatementsToExecute => "NO_STATEMENTS_TO_EXECUTE",
Self::ColumnIndexOutOfBounds { .. } => "COLUMN_INDEX_OUT_OF_BOUNDS",
Self::CursorNotPositioned => "CURSOR_NOT_POSITIONED",
Self::WalNotRunning => "WAL_NOT_RUNNING",
Self::WalFileClosed => "WAL_FILE_CLOSED",
Self::WalDurabilityUncertain { .. } => "WAL_DURABILITY_UNCERTAIN",
Self::PartialCommit { .. } => "PARTIAL_COMMIT",
Self::CopyTransactionMemoryLimit { .. } => "COPY_TRANSACTION_MEMORY_LIMIT",
Self::WalNotInitialized => "WAL_NOT_INITIALIZED",
Self::DatabaseLocked => "DATABASE_LOCKED",
Self::CannotDropPrimaryKey => "CANNOT_DROP_PRIMARY_KEY",
Self::NullComparison => "NULL_COMPARISON",
Self::IncomparableTypes => "INCOMPARABLE_TYPES",
Self::NotSupported(_) => "NOT_SUPPORTED",
Self::NativeFunction { .. } => "NATIVE_FUNCTION_ERROR",
Self::Navigation { code, .. } => code.as_str(),
Self::SegmentNotFound => "SEGMENT_NOT_FOUND",
Self::ExpressionEvaluation => "EXPRESSION_EVALUATION",
Self::ExpressionEvaluationWithMessage { .. } => "EXPRESSION_EVALUATION",
Self::TypeConversion { .. } => "TYPE_CONVERSION",
Self::Parse(_) => "PARSE_ERROR",
Self::Io { .. } => "IO_ERROR",
Self::Internal { .. } => "INTERNAL_ERROR",
Self::TableOrViewNotFound(_) => "TABLE_OR_VIEW_NOT_FOUND",
Self::Type(_) => "TYPE_ERROR",
Self::DivisionByZero => "DIVISION_BY_ZERO",
Self::QueryCancelled => "QUERY_CANCELLED",
};
ErrorCode::new(code)
}
pub fn context(&self) -> ErrorContext {
use ErrorCategory as Category;
let category = match self {
Self::TableNotFound(_)
| Self::TableAlreadyExists(_)
| Self::TableClosed
| Self::TableColumnsNotMatch { .. }
| Self::TableHasActiveTransactions
| Self::ColumnNotFound(_)
| Self::AmbiguousColumn(_)
| Self::DuplicateColumn
| Self::ViewAlreadyExists(_)
| Self::ViewNotFound(_)
| Self::CannotDropPrimaryKey
| Self::Navigation { .. }
| Self::TableOrViewNotFound(_) => Category::Catalog,
Self::InvalidColumnType
| Self::VectorDimensionMismatch { .. }
| Self::InvalidValue
| Self::InvalidArgument(_)
| Self::ValueTooLong { .. }
| Self::NullComparison
| Self::IncomparableTypes
| Self::TypeConversion { .. }
| Self::Type(_) => Category::Value,
Self::AuthorizationDenied(_) => Category::Security,
Self::NotNullConstraint { .. }
| Self::PrimaryKeyConstraint { .. }
| Self::UniqueConstraint { .. }
| Self::CheckConstraintViolation { .. }
| Self::ForeignKeyViolation { .. } => Category::Constraint,
Self::TransactionNotStarted
| Self::TransactionAlreadyStarted
| Self::TransactionEnded
| Self::TransactionAborted
| Self::TransactionCommitted
| Self::TransactionClosed
| Self::InvalidTransactionTransition { .. }
| Self::TransactionSerializationConflict { .. }
| Self::RowLockTimeout { .. }
| Self::CompactionBackpressure { .. }
| Self::PartialCommit { .. }
| Self::CopyTransactionMemoryLimit { .. } => Category::Transaction,
Self::IndexNotFound(_)
| Self::IndexAlreadyExists(_)
| Self::IndexColumnNotFound
| Self::IndexClosed => Category::Index,
Self::EngineNotOpen | Self::EngineAlreadyOpen | Self::LockAcquisitionFailed(_) => {
Category::Engine
}
Self::NoRowsReturned
| Self::NoStatementsToExecute
| Self::ColumnIndexOutOfBounds { .. }
| Self::CursorNotPositioned
| Self::NotSupported(_)
| Self::NativeFunction { .. }
| Self::QueryCancelled => Category::Query,
Self::WalNotRunning
| Self::WalFileClosed
| Self::WalDurabilityUncertain { .. }
| Self::WalNotInitialized
| Self::SegmentNotFound => Category::Durability,
Self::DatabaseLocked => Category::Database,
Self::ExpressionEvaluation
| Self::ExpressionEvaluationWithMessage { .. }
| Self::DivisionByZero => Category::Evaluation,
Self::Parse(_) => Category::Syntax,
Self::Io { .. } => Category::Io,
Self::Internal { .. } => Category::Internal,
};
ErrorContext::new(
self.code(),
category,
self.is_retryable(),
self.is_not_found(),
self.is_constraint_violation(),
)
}
pub fn table_columns_not_match(expected: usize, got: usize) -> Self {
Error::TableColumnsNotMatch { expected, got }
}
pub fn value_too_long(column: impl Into<String>, max: usize, got: usize) -> Self {
Error::ValueTooLong {
column: column.into(),
max,
got,
}
}
pub fn not_null_constraint(column: impl Into<String>) -> Self {
Error::NotNullConstraint {
column: column.into(),
}
}
pub fn primary_key_constraint(row_id: i64) -> Self {
Error::PrimaryKeyConstraint { row_id }
}
pub fn unique_constraint(
index: impl Into<String>,
column: impl Into<String>,
value: impl Into<String>,
) -> Self {
Error::UniqueConstraint {
index: index.into(),
column: column.into(),
value: value.into(),
row_id: -1,
}
}
pub fn foreign_key_violation(
table: impl Into<String>,
column: impl Into<String>,
ref_table: impl Into<String>,
ref_column: impl Into<String>,
detail: impl Into<String>,
) -> Self {
Error::ForeignKeyViolation {
table: table.into(),
column: column.into(),
ref_table: ref_table.into(),
ref_column: ref_column.into(),
detail: detail.into(),
}
}
pub fn type_conversion(from: impl Into<String>, to: impl Into<String>) -> Self {
Error::TypeConversion {
from: from.into(),
to: to.into(),
}
}
pub fn parse(message: impl Into<String>) -> Self {
Error::Parse(message.into())
}
pub fn io(message: impl Into<String>) -> Self {
Error::Io {
message: message.into(),
}
}
pub fn internal(message: impl Into<String>) -> Self {
Error::Internal {
message: message.into(),
}
}
pub fn expression_evaluation(message: impl Into<String>) -> Self {
Error::ExpressionEvaluationWithMessage {
message: message.into(),
}
}
pub fn invalid_argument(message: impl Into<String>) -> Self {
Error::InvalidArgument(message.into())
}
pub fn authorization_denied(message: impl Into<String>) -> Self {
Error::AuthorizationDenied(message.into())
}
pub fn navigation(code: NavigationErrorCode, detail: impl Into<String>) -> Self {
Error::Navigation {
code,
detail: detail.into(),
}
}
pub fn navigation_code(&self) -> Option<NavigationErrorCode> {
match self {
Error::Navigation { code, .. } => Some(*code),
_ => None,
}
}
pub fn is_not_found(&self) -> bool {
matches!(
self,
Error::TableNotFound(_)
| Error::ColumnNotFound(_)
| Error::IndexNotFound(_)
| Error::IndexColumnNotFound
| Error::SegmentNotFound
| Error::ViewNotFound(_)
| Error::TableOrViewNotFound(_)
)
}
pub fn is_constraint_violation(&self) -> bool {
matches!(
self,
Error::NotNullConstraint { .. }
| Error::PrimaryKeyConstraint { .. }
| Error::UniqueConstraint { .. }
| Error::CheckConstraintViolation { .. }
| Error::ForeignKeyViolation { .. }
)
}
pub fn is_pk_or_unique_violation(&self) -> bool {
matches!(
self,
Error::PrimaryKeyConstraint { .. } | Error::UniqueConstraint { .. }
)
}
pub fn is_transaction_error(&self) -> bool {
matches!(
self,
Error::TransactionNotStarted
| Error::TransactionAlreadyStarted
| Error::TransactionEnded
| Error::TransactionAborted
| Error::TransactionCommitted
| Error::TransactionClosed
| Error::TransactionSerializationConflict { .. }
| Error::RowLockTimeout { .. }
| Error::CompactionBackpressure { .. }
)
}
pub fn is_retryable(&self) -> bool {
matches!(
self,
Error::TransactionSerializationConflict { .. }
| Error::RowLockTimeout { .. }
| Error::CompactionBackpressure { .. }
)
}
}
impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Self {
Error::Io {
message: err.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_display() {
assert_eq!(
Error::TableNotFound("users".to_string()).to_string(),
"table 'users' not found"
);
assert_eq!(
Error::TableAlreadyExists("users".to_string()).to_string(),
"table 'users' already exists"
);
assert_eq!(
Error::ColumnNotFound("email".to_string()).to_string(),
"column 'email' not found"
);
assert_eq!(Error::InvalidValue.to_string(), "invalid value");
assert_eq!(
Error::TransactionNotStarted.to_string(),
"transaction not started"
);
assert_eq!(
Error::IndexNotFound("idx_email".to_string()).to_string(),
"index 'idx_email' not found"
);
assert_eq!(
Error::NullComparison.to_string(),
"cannot compare NULL with non-NULL value"
);
}
#[test]
fn test_structured_error_display() {
let err = Error::table_columns_not_match(5, 3);
assert_eq!(
err.to_string(),
"table columns don't match, expected 5, got 3"
);
let err = Error::value_too_long("name", 100, 150);
assert_eq!(
err.to_string(),
"value for column name is too long, max 100, got 150"
);
let err = Error::not_null_constraint("email");
assert_eq!(
err.to_string(),
"not null constraint failed for column email"
);
let err = Error::primary_key_constraint(42);
assert_eq!(
err.to_string(),
"primary key constraint failed with 42 already exists in this table"
);
let err = Error::unique_constraint("idx_email", "email", "test@example.com");
assert_eq!(
err.to_string(),
"unique constraint failed for index idx_email on column email with value test@example.com"
);
}
#[test]
fn test_error_classification() {
assert!(Error::TableNotFound("t".to_string()).is_not_found());
assert!(Error::ColumnNotFound("c".to_string()).is_not_found());
assert!(Error::IndexNotFound("i".to_string()).is_not_found());
assert!(!Error::InvalidValue.is_not_found());
assert!(Error::not_null_constraint("col").is_constraint_violation());
assert!(Error::primary_key_constraint(1).is_constraint_violation());
assert!(Error::unique_constraint("idx", "col", "val").is_constraint_violation());
assert!(Error::CheckConstraintViolation {
column: "<table:t>".to_string(),
expression: "a > b".to_string(),
}
.is_constraint_violation());
assert!(!Error::TableNotFound("t".to_string()).is_constraint_violation());
assert!(Error::TransactionNotStarted.is_transaction_error());
assert!(Error::TransactionCommitted.is_transaction_error());
assert!(!Error::TableNotFound("t".to_string()).is_transaction_error());
}
#[test]
fn test_transaction_classifier_covers_retryable_transaction_variants() {
let transaction_errors = [
Error::TransactionNotStarted,
Error::TransactionAlreadyStarted,
Error::TransactionEnded,
Error::TransactionAborted,
Error::TransactionCommitted,
Error::TransactionClosed,
Error::TransactionSerializationConflict { row_id: 7 },
Error::RowLockTimeout {
row_id: 7,
timeout_ms: 250,
},
Error::CompactionBackpressure {
table: "items".to_string(),
segments: 32,
physical_bytes: 1024,
hard_segments: 32,
hard_bytes: 2048,
},
];
for error in transaction_errors {
assert!(
error.is_transaction_error(),
"transaction classifier rejected {error:?}"
);
}
assert!(Error::CompactionBackpressure {
table: "items".to_string(),
segments: 32,
physical_bytes: 1024,
hard_segments: 32,
hard_bytes: 2048,
}
.is_retryable());
}
#[test]
fn test_error_equality() {
assert_eq!(
Error::TableNotFound("t".to_string()),
Error::TableNotFound("t".to_string())
);
assert_ne!(
Error::TableNotFound("t".to_string()),
Error::TableAlreadyExists("t".to_string())
);
let err1 = Error::table_columns_not_match(5, 3);
let err2 = Error::table_columns_not_match(5, 3);
let err3 = Error::table_columns_not_match(5, 4);
assert_eq!(err1, err2);
assert_ne!(err1, err3);
}
#[test]
fn navigation_errors_have_stable_machine_categories() {
let error = Error::navigation(
NavigationErrorCode::TargetColumnNotFound,
"target column 'profiles.missing' does not exist",
);
assert_eq!(
error.navigation_code(),
Some(NavigationErrorCode::TargetColumnNotFound)
);
assert_eq!(
error.to_string(),
"NAVIGATION_TARGET_COLUMN_NOT_FOUND: target column 'profiles.missing' does not exist"
);
}
#[test]
fn neutral_context_does_not_depend_on_presentation_text() {
let error = Error::RowLockTimeout {
row_id: 17,
timeout_ms: 250,
};
let context = error.context();
assert_eq!(context.code().as_str(), "ROW_LOCK_TIMEOUT");
assert_eq!(context.category(), ErrorCategory::Transaction);
assert!(context.retryable());
assert!(!context.not_found());
assert!(!context.constraint_violation());
let error = Error::ColumnNotFound("missing".to_owned());
let context = error.context();
assert_eq!(context.code().as_str(), "COLUMN_NOT_FOUND");
assert_eq!(context.category(), ErrorCategory::Catalog);
assert!(context.not_found());
}
#[test]
fn test_io_error_conversion() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let err: Error = io_err.into();
assert!(matches!(err, Error::Io { .. }));
assert!(err.to_string().contains("file not found"));
}
}