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>
345 lines
9.5 KiB
Rust
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())
|
|
);
|
|
}
|
|
}
|