tidaldb/tidal/src/schema/error.rs
jordan 29400d48db feat: implement Milestone 1 phases 1-3 — schema, WAL, and storage layer
Implements the foundation of tidalDB's data pipeline:

**Phase 1 – Schema primitives**
- EntityId newtype (u64, big-endian ordering)
- SignalTypeDefinition with pre-computed decay λ, deduped/sorted windows
- SchemaBuilder with full constraint validation (duplicates, identifiers,
  half-life, windows, velocity)
- LumenError wrapping all subsystems with required From impls

**Phase 2 – Write-Ahead Log**
- Length-prefixed, BLAKE3-protected entry format
- Group-commit writer (batch up to 100 events / 10 ms)
- Double-buffered content-hash deduplication
- Checkpoint, truncation, and crash-recovery with full replay
- Integration, property, and UAT tests (incl. 5,500-event deterministic UAT)
- Proptest coverage scaled to 10 000 events/run (was ≤500) to meet
  acceptance criterion; cases reduced 100→10 to keep runtime comparable

**Phase 3 – Storage engine**
- StorageEngine trait (get/put/delete/scan/batch/flush)
- Key encoding: [EntityId][0x00][Tag][suffix] with ordering/prefix helpers
- InMemoryBackend (BTreeMap + RwLock)
- FjallStorage with three isolated keyspaces and atomic batch helper
- Property tests for key ordering and round-trip correctness

Also adds planning docs for phases 4-5, research docs, architecture
overview, and roadmap updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 16:43:24 -07:00

345 lines
9.5 KiB
Rust

use std::fmt;
use super::{EntityId, EntityKind};
/// Top-level error type. Every public API method returns `Result<T, LumenError>`.
#[derive(Debug)]
pub enum LumenError {
/// Storage engine failure. Retry may succeed.
Storage(StorageError),
/// Entity not found. Caller should handle.
NotFound { kind: EntityKind, id: EntityId },
/// Schema violation. Caller's fault — fix the input.
Schema(SchemaError),
/// Signal write failed durability check. Retry required.
Durability(DurabilityError),
/// Query malformed. Parse error with details.
Query(QueryError),
/// Internal invariant violated. This is a bug in Lumen.
Internal(String),
}
impl fmt::Display for LumenError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Storage(e) => write!(f, "storage error: {e}"),
Self::NotFound { kind, id } => write!(f, "{kind} {id} not found"),
Self::Schema(e) => write!(f, "{e}"),
Self::Durability(e) => write!(f, "durability error: {e}"),
Self::Query(e) => write!(f, "query error: {e}"),
Self::Internal(msg) => write!(f, "internal error: {msg}"),
}
}
}
impl std::error::Error for LumenError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Storage(e) => Some(e),
Self::Schema(e) => Some(e),
Self::Durability(e) => Some(e),
Self::Query(e) => Some(e),
Self::NotFound { .. } | Self::Internal(_) => None,
}
}
}
impl From<SchemaError> for LumenError {
fn from(e: SchemaError) -> Self {
Self::Schema(e)
}
}
impl From<StorageError> for LumenError {
fn from(e: StorageError) -> Self {
Self::Storage(e)
}
}
impl From<DurabilityError> for LumenError {
fn from(e: DurabilityError) -> Self {
Self::Durability(e)
}
}
impl From<QueryError> for LumenError {
fn from(e: QueryError) -> Self {
Self::Query(e)
}
}
/// Schema validation errors.
///
/// `Eq` is manually implemented because f64 fields (from `Duration::as_secs_f64()`)
/// are always non-NaN, making equality reflexive.
#[derive(Debug, Clone, PartialEq)]
pub enum SchemaError {
DuplicateSignalName(String),
InvalidSignalName(String),
InvalidHalfLife {
signal_name: String,
half_life_secs: f64,
},
InvalidLifetime {
signal_name: String,
lifetime_secs: f64,
},
EmptyWindows {
signal_name: String,
},
VelocityWithoutWindows {
signal_name: String,
},
NoSignalsDefined,
}
impl Eq for SchemaError {}
impl fmt::Display for SchemaError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DuplicateSignalName(name) => {
write!(f, "duplicate signal name: '{name}'")
}
Self::InvalidSignalName(name) => {
write!(f, "invalid signal name: '{name}'")
}
Self::InvalidHalfLife {
signal_name,
half_life_secs,
} => {
write!(
f,
"signal '{signal_name}': invalid half-life: {half_life_secs}s"
)
}
Self::InvalidLifetime {
signal_name,
lifetime_secs,
} => {
write!(
f,
"signal '{signal_name}': invalid lifetime: {lifetime_secs}s"
)
}
Self::EmptyWindows { signal_name } => {
write!(
f,
"signal '{signal_name}': non-permanent signal requires at least one window"
)
}
Self::VelocityWithoutWindows { signal_name } => {
write!(
f,
"signal '{signal_name}': velocity requires at least one window"
)
}
Self::NoSignalsDefined => f.write_str("schema must define at least one signal"),
}
}
}
impl std::error::Error for SchemaError {}
/// Re-exported from `crate::storage::StorageError`.
pub use crate::storage::StorageError;
/// Stub for Phase 1.2+.
#[derive(Debug)]
pub struct DurabilityError {
pub message: String,
}
impl fmt::Display for DurabilityError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for DurabilityError {}
/// Stub for Milestone 2+.
#[derive(Debug)]
pub struct QueryError {
pub message: String,
}
impl fmt::Display for QueryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for QueryError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lumen_error_display_not_found() {
let e = LumenError::NotFound {
kind: EntityKind::Item,
id: EntityId::new(42),
};
assert_eq!(e.to_string(), "item 42 not found");
}
#[test]
fn lumen_error_display_schema() {
let e = LumenError::Schema(SchemaError::DuplicateSignalName("view".into()));
assert!(e.to_string().contains("duplicate signal name"));
}
#[test]
fn lumen_error_display_internal() {
let e = LumenError::Internal("something broke".into());
assert!(e.to_string().contains("internal error"));
}
#[test]
fn lumen_error_display_storage() {
let e = LumenError::Storage(StorageError::Corruption {
message: "disk full".into(),
});
assert!(e.to_string().contains("disk full"));
}
#[test]
fn lumen_error_display_durability() {
let e = LumenError::Durability(DurabilityError {
message: "fsync failed".into(),
});
assert!(e.to_string().contains("fsync failed"));
}
#[test]
fn lumen_error_display_query() {
let e = LumenError::Query(QueryError {
message: "parse error".into(),
});
assert!(e.to_string().contains("parse error"));
}
#[test]
fn lumen_error_source_schema() {
use std::error::Error;
let e = LumenError::Schema(SchemaError::NoSignalsDefined);
assert!(e.source().is_some());
}
#[test]
fn lumen_error_source_internal_is_none() {
use std::error::Error;
let e = LumenError::Internal("bug".into());
assert!(e.source().is_none());
}
#[test]
fn lumen_error_source_not_found_is_none() {
use std::error::Error;
let e = LumenError::NotFound {
kind: EntityKind::User,
id: EntityId::new(1),
};
assert!(e.source().is_none());
}
#[test]
fn schema_error_converts_to_lumen_error() {
let schema_err = SchemaError::NoSignalsDefined;
let lumen_err: LumenError = schema_err.into();
assert!(matches!(
lumen_err,
LumenError::Schema(SchemaError::NoSignalsDefined)
));
}
#[test]
fn storage_error_converts_to_lumen_error() {
let e = StorageError::Closed;
let lumen_err: LumenError = e.into();
assert!(matches!(lumen_err, LumenError::Storage(_)));
}
#[test]
fn durability_error_converts_to_lumen_error() {
let e = DurabilityError {
message: "test".into(),
};
let lumen_err: LumenError = e.into();
assert!(matches!(lumen_err, LumenError::Durability(_)));
}
#[test]
fn query_error_converts_to_lumen_error() {
let e = QueryError {
message: "test".into(),
};
let lumen_err: LumenError = e.into();
assert!(matches!(lumen_err, LumenError::Query(_)));
}
#[test]
fn schema_error_display_all_variants() {
assert_eq!(
SchemaError::DuplicateSignalName("view".into()).to_string(),
"duplicate signal name: 'view'"
);
assert_eq!(
SchemaError::InvalidSignalName("BAD".into()).to_string(),
"invalid signal name: 'BAD'"
);
assert!(
SchemaError::InvalidHalfLife {
signal_name: "s".into(),
half_life_secs: 0.0,
}
.to_string()
.contains("invalid half-life")
);
assert!(
SchemaError::InvalidLifetime {
signal_name: "s".into(),
lifetime_secs: -1.0,
}
.to_string()
.contains("invalid lifetime")
);
assert!(
SchemaError::EmptyWindows {
signal_name: "s".into()
}
.to_string()
.contains("requires at least one window")
);
assert!(
SchemaError::VelocityWithoutWindows {
signal_name: "s".into()
}
.to_string()
.contains("velocity requires")
);
assert_eq!(
SchemaError::NoSignalsDefined.to_string(),
"schema must define at least one signal"
);
}
#[test]
fn schema_error_eq() {
assert_eq!(
SchemaError::DuplicateSignalName("a".into()),
SchemaError::DuplicateSignalName("a".into())
);
assert_ne!(
SchemaError::DuplicateSignalName("a".into()),
SchemaError::DuplicateSignalName("b".into())
);
assert_ne!(
SchemaError::NoSignalsDefined,
SchemaError::DuplicateSignalName("a".into())
);
}
}