pub mod actions;
pub mod log_store;
pub(crate) mod segments;
pub mod table_state;
#[cfg(test)]
mod log_integration_tests;
pub use crate::metadata::{
index::{IndexKind, IndexSpec, IndexValue, TimeIndexGranularity},
protocol::TableProtocolError,
table::{TableKind, TableMeta, TableMetaDelta},
};
pub use actions::{Commit, LogAction};
pub use log_store::TransactionLogStore;
pub use segments::{FileFormat, SegmentEntityLayout, SegmentError, SegmentMeta};
pub use table_state::TableState;
use snafu::{Backtrace, prelude::*};
use crate::{
metadata::{
index::IndexSpecError, schema_compat::SchemaCompatibilityError, segments::SegmentMetaError,
},
storage::StorageError,
};
#[derive(Debug, Snafu)]
#[non_exhaustive]
pub enum CommitError {
#[snafu(display("Commit conflict: expected version {expected}, but CURRENT is {found}"))]
Conflict {
expected: u64,
found: u64,
backtrace: Backtrace,
},
#[snafu(display("Storage error while accessing commit log: {source}"))]
Storage {
#[snafu(backtrace)]
source: StorageError,
},
#[snafu(context(false), display("Table protocol error: {source}"))]
Protocol {
#[snafu(source)]
source: crate::metadata::protocol::TableProtocolError,
backtrace: Backtrace,
},
#[snafu(display("Failed to deserialize commit {version}: {source}"))]
CommitDeserialization {
version: u64,
source: serde_json::Error,
backtrace: Backtrace,
},
#[snafu(display("Failed to serialize commit {version}: {source}"))]
CommitSerialization {
version: u64,
source: serde_json::Error,
backtrace: Backtrace,
},
#[snafu(display("CURRENT has invalid content {contents:?}: {source}"))]
CurrentVersionParse {
contents: String,
source: std::num::ParseIntError,
backtrace: Backtrace,
},
#[snafu(display("Invalid persisted {description} {path:?}: {source}"))]
InvalidPersistedPath {
description: String,
path: String,
#[snafu(source(from(StorageError, Box::new)), backtrace)]
source: Box<StorageError>,
},
#[snafu(display("Invalid persisted ordered-index specification: {source}"))]
InvalidIndexSpec {
source: IndexSpecError,
backtrace: Backtrace,
},
#[snafu(display("Persisted table schema is incompatible with its ordered index: {source}"))]
TableSchemaCompatibility {
#[snafu(source(from(SchemaCompatibilityError, Box::new)), backtrace)]
source: Box<SchemaCompatibilityError>,
},
#[snafu(display("Invalid single-entity identity in segment at {path}: {source}"))]
SegmentEntityIdentitySchema {
path: String,
#[snafu(source(from(SchemaCompatibilityError, Box::new)), backtrace)]
source: Box<SchemaCompatibilityError>,
},
#[snafu(display("Invalid persisted segment metadata: {source}"))]
SegmentMetadata {
#[snafu(source(from(SegmentMetaError, Box::new)), backtrace)]
source: Box<SegmentMetaError>,
},
#[snafu(display("Cannot rebuild table state because CURRENT is 0"))]
UninitializedTableState {
backtrace: Backtrace,
},
#[snafu(display("Commit version mismatch: expected {expected}, found {found} in the payload"))]
CommitVersionMismatch {
expected: u64,
found: u64,
backtrace: Backtrace,
},
#[snafu(display("Duplicate live segment path: {path}"))]
DuplicateLiveSegmentPath {
path: String,
backtrace: Backtrace,
},
#[snafu(display("No table metadata found in commits up to version {current_version}"))]
MissingTableMetadata {
current_version: u64,
backtrace: Backtrace,
},
#[snafu(display(
"Table coverage index kind does not match the table index: expected {expected:?}, found {actual:?} in pointer from version {pointer_version}"
))]
CoverageIndexKindMismatch {
expected: IndexKind,
actual: IndexKind,
pointer_version: u64,
backtrace: Box<Backtrace>,
},
#[snafu(display("Persisted segments require a logical schema"))]
MissingLogicalSchemaForSegments {
backtrace: Backtrace,
},
#[snafu(display(
"Invalid entity layout in segment at {path}: table has {entity_column_count} entity columns, layout is {layout:?}"
))]
InvalidSegmentEntityLayout {
path: String,
entity_column_count: usize,
layout: SegmentEntityLayout,
backtrace: Backtrace,
},
#[snafu(display("Transaction-log version overflow at {current_version}"))]
VersionOverflow {
current_version: u64,
backtrace: Backtrace,
},
#[snafu(display("CURRENT has empty content at {path}"))]
EmptyCurrentPointer {
path: String,
backtrace: Backtrace,
},
#[snafu(display(
"Commit outcome is ambiguous at {commit_path}: {operation_error}; failed to remove the commit file: {cleanup_error}"
))]
AmbiguousOutcome {
commit_path: String,
#[snafu(source, backtrace)]
operation_error: Box<StorageError>,
cleanup_error: Box<StorageError>,
},
}
pub(crate) fn checked_next_version(expected: u64) -> Result<u64, CommitError> {
expected
.checked_add(1)
.ok_or_else(|| CommitError::VersionOverflow {
current_version: expected,
backtrace: Backtrace::capture(),
})
}
#[cfg(test)]
mod tests {
use crate::coverage::EntityIdentity;
use crate::metadata::logical_schema::{
LogicalDataType, LogicalField, LogicalSchema, LogicalSchemaValidationError,
LogicalTimestampUnit,
};
use crate::metadata::protocol::TABLE_PROTOCOL_VERSION;
use crate::transaction_log::*;
use chrono::{DateTime, TimeZone, Utc};
use serde_json;
fn utc_datetime(
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
) -> DateTime<Utc> {
Utc.with_ymd_and_hms(year, month, day, hour, minute, second)
.single()
.expect("valid UTC timestamp")
}
#[test]
fn commit_json_roundtrip() {
let ts0 = utc_datetime(2025, 1, 1, 0, 0, 0);
let ts1 = utc_datetime(2025, 1, 1, 1, 0, 0);
let time_index = IndexSpec {
column: "ts".to_string(),
entity_columns: vec!["symbol".to_string()],
kind: IndexKind::Timestamp {
index_granularity: TimeIndexGranularity::Minutes(60),
timezone: Some("UTC".to_string()),
},
};
let table_meta = TableMeta {
kind: TableKind::TimeSeries(time_index),
logical_schema: Some(
LogicalSchema::new(vec![
LogicalField {
name: "ts".to_string(),
data_type: LogicalDataType::Timestamp {
unit: LogicalTimestampUnit::Micros,
timezone: None,
},
nullable: false,
},
LogicalField {
name: "symbol".to_string(),
data_type: LogicalDataType::Utf8,
nullable: false,
},
])
.expect("valid logical schema"),
),
created_at: ts0,
protocol_version: TABLE_PROTOCOL_VERSION,
required_reader_features: Default::default(),
required_writer_features: Default::default(),
};
let seg_meta = SegmentMeta {
path: "data/nvda_1h_0001.parquet".to_string(),
format: FileFormat::Parquet,
entity_layout: SegmentEntityLayout::Single(
EntityIdentity::try_new(vec!["NVDA".into()]).expect("valid identity"),
),
index_min: (ts0).into(),
index_max: (ts1).into(),
row_count: 1024,
file_size: None,
coverage_path: None,
};
let commit = Commit {
version: 1,
base_version: 0,
timestamp: ts1,
actions: vec![
LogAction::UpdateTableMeta(table_meta),
LogAction::AddSegment(seg_meta),
],
};
let json = serde_json::to_string_pretty(&commit).expect("serialize commit");
assert!(json.contains(&format!("\"protocol_version\": {TABLE_PROTOCOL_VERSION}")));
assert!(json.contains("\"required_reader_features\": []"));
assert!(json.contains("\"required_writer_features\": []"));
let decoded: Commit = serde_json::from_str(&json).expect("deserialize commit");
assert_eq!(commit, decoded);
}
#[test]
fn logical_schema_rejects_duplicate_columns() {
let dup = LogicalSchema::new(vec![
LogicalField {
name: "ts".to_string(),
data_type: LogicalDataType::Timestamp {
unit: LogicalTimestampUnit::Micros,
timezone: None,
},
nullable: false,
},
LogicalField {
name: "ts".to_string(),
data_type: LogicalDataType::Timestamp {
unit: LogicalTimestampUnit::Micros,
timezone: None,
},
nullable: false,
},
]);
let err = dup.expect_err("duplicate columns should be rejected");
assert!(
matches!(err, LogicalSchemaValidationError::DuplicateColumn { column } if column == "ts")
);
}
#[test]
fn time_index_spec_defaults() {
let json = r#"{
"column": "ts",
"kind": {
"type": "timestamp",
"index_granularity": { "Hours": 1 }
}
}"#;
let spec: IndexSpec = serde_json::from_str(json).expect("deserialize");
assert_eq!(spec.column, "ts");
assert_eq!(spec.entity_columns, Vec::<String>::new()); assert_eq!(
spec.kind,
IndexKind::Timestamp {
index_granularity: TimeIndexGranularity::Hours(1),
timezone: None
}
);
}
#[test]
fn time_index_spec_skips_none_timezone_on_serialize() {
let spec = IndexSpec {
column: "ts".to_string(),
entity_columns: vec![],
kind: IndexKind::Timestamp {
index_granularity: TimeIndexGranularity::Seconds(30),
timezone: None,
},
};
let json = serde_json::to_string(&spec).expect("serialize");
assert!(!json.contains("timezone"));
}
#[test]
fn logical_column_nullable_requires_explicit_value() {
let json = r#"{ "name": "price", "data_type": "Float64" }"#;
let err = serde_json::from_str::<LogicalField>(json).unwrap_err();
assert!(
err.to_string().contains("missing field `nullable`"),
"unexpected error: {err}"
);
}
#[test]
fn table_kind_generic_roundtrip() {
let kind = TableKind::Generic;
let json = serde_json::to_string(&kind).expect("serialize");
let decoded: TableKind = serde_json::from_str(&json).expect("deserialize");
assert_eq!(kind, decoded);
assert_eq!(json, r#""Generic""#);
}
#[test]
fn all_time_index_granularity_variants_roundtrip() {
let granularities = vec![
TimeIndexGranularity::Seconds(15),
TimeIndexGranularity::Minutes(5),
TimeIndexGranularity::Hours(24),
TimeIndexGranularity::Days(7),
];
for index_granularity in granularities {
let json = serde_json::to_string(&index_granularity).expect("serialize");
let decoded: TimeIndexGranularity = serde_json::from_str(&json).expect("deserialize");
assert_eq!(index_granularity, decoded);
}
}
#[test]
fn file_format_serializes_lowercase() {
let format = FileFormat::Parquet;
let json = serde_json::to_string(&format).expect("serialize");
assert_eq!(json, r#""parquet""#);
let decoded: FileFormat = serde_json::from_str(&json).expect("deserialize");
assert_eq!(format, decoded);
}
#[test]
fn file_format_default_is_parquet() {
assert_eq!(FileFormat::default(), FileFormat::Parquet);
}
#[test]
fn remove_segment_action_roundtrip() {
let action = LogAction::RemoveSegment {
path: "data/seg-to-remove.parquet".to_string(),
};
let json = serde_json::to_string(&action).expect("serialize");
let decoded: LogAction = serde_json::from_str(&json).expect("deserialize");
assert_eq!(action, decoded);
}
#[test]
fn commit_with_empty_actions() {
let ts = utc_datetime(2025, 6, 15, 12, 0, 0);
let commit = Commit {
version: 1,
base_version: 0,
timestamp: ts,
actions: vec![],
};
let json = serde_json::to_string(&commit).expect("serialize");
let decoded: Commit = serde_json::from_str(&json).expect("deserialize");
assert_eq!(commit, decoded);
assert!(decoded.actions.is_empty());
}
}