1pub mod actions;
66pub mod log_store;
67pub(crate) mod segments;
68pub mod table_state;
69
70#[cfg(test)]
71mod log_integration_tests;
72
73pub use crate::metadata::{
74 index::{IndexKind, IndexSpec, IndexValue, TimeIndexGranularity},
75 protocol::TableProtocolError,
76 table::{TableKind, TableMeta, TableMetaDelta},
77};
78pub use actions::{Commit, LogAction};
79pub use log_store::TransactionLogStore;
80pub use segments::{FileFormat, SegmentEntityLayout, SegmentError, SegmentMeta};
81pub use table_state::TableState;
82
83use snafu::{Backtrace, prelude::*};
84
85use crate::{
86 metadata::{
87 index::IndexSpecError, schema_compat::SchemaCompatibilityError, segments::SegmentMetaError,
88 },
89 storage::StorageError,
90};
91
92#[derive(Debug, Snafu)]
94#[non_exhaustive]
95pub enum CommitError {
96 #[snafu(display("Commit conflict: expected version {expected}, but CURRENT is {found}"))]
98 Conflict {
99 expected: u64,
101 found: u64,
103 backtrace: Backtrace,
105 },
106
107 #[snafu(display("Storage error while accessing commit log: {source}"))]
111 Storage {
112 #[snafu(backtrace)]
114 source: StorageError,
115 },
116
117 #[snafu(context(false), display("Table protocol error: {source}"))]
119 Protocol {
120 #[snafu(source)]
122 source: crate::metadata::protocol::TableProtocolError,
123 backtrace: Backtrace,
125 },
126
127 #[snafu(display("Failed to deserialize commit {version}: {source}"))]
129 CommitDeserialization {
130 version: u64,
132 source: serde_json::Error,
134 backtrace: Backtrace,
136 },
137
138 #[snafu(display("Failed to serialize commit {version}: {source}"))]
140 CommitSerialization {
141 version: u64,
143 source: serde_json::Error,
145 backtrace: Backtrace,
147 },
148
149 #[snafu(display("CURRENT has invalid content {contents:?}: {source}"))]
151 CurrentVersionParse {
152 contents: String,
154 source: std::num::ParseIntError,
156 backtrace: Backtrace,
158 },
159
160 #[snafu(display("Invalid persisted {description} {path:?}: {source}"))]
162 InvalidPersistedPath {
163 description: String,
165 path: String,
167 #[snafu(source(from(StorageError, Box::new)), backtrace)]
169 source: Box<StorageError>,
170 },
171
172 #[snafu(display("Invalid persisted ordered-index specification: {source}"))]
174 InvalidIndexSpec {
175 source: IndexSpecError,
177 backtrace: Backtrace,
179 },
180
181 #[snafu(display("Persisted table schema is incompatible with its ordered index: {source}"))]
183 TableSchemaCompatibility {
184 #[snafu(source(from(SchemaCompatibilityError, Box::new)), backtrace)]
186 source: Box<SchemaCompatibilityError>,
187 },
188
189 #[snafu(display("Invalid single-entity identity in segment at {path}: {source}"))]
191 SegmentEntityIdentitySchema {
192 path: String,
194 #[snafu(source(from(SchemaCompatibilityError, Box::new)), backtrace)]
196 source: Box<SchemaCompatibilityError>,
197 },
198
199 #[snafu(display("Invalid persisted segment metadata: {source}"))]
201 SegmentMetadata {
202 #[snafu(source(from(SegmentMetaError, Box::new)), backtrace)]
204 source: Box<SegmentMetaError>,
205 },
206
207 #[snafu(display("Cannot rebuild table state because CURRENT is 0"))]
209 UninitializedTableState {
210 backtrace: Backtrace,
212 },
213
214 #[snafu(display("Commit version mismatch: expected {expected}, found {found} in the payload"))]
216 CommitVersionMismatch {
217 expected: u64,
219 found: u64,
221 backtrace: Backtrace,
223 },
224
225 #[snafu(display("Duplicate live segment path: {path}"))]
227 DuplicateLiveSegmentPath {
228 path: String,
230 backtrace: Backtrace,
232 },
233
234 #[snafu(display("No table metadata found in commits up to version {current_version}"))]
236 MissingTableMetadata {
237 current_version: u64,
239 backtrace: Backtrace,
241 },
242
243 #[snafu(display(
245 "Table coverage index kind does not match the table index: expected {expected:?}, found {actual:?} in pointer from version {pointer_version}"
246 ))]
247 CoverageIndexKindMismatch {
248 expected: IndexKind,
250 actual: IndexKind,
252 pointer_version: u64,
254 backtrace: Box<Backtrace>,
256 },
257
258 #[snafu(display("Persisted segments require a logical schema"))]
260 MissingLogicalSchemaForSegments {
261 backtrace: Backtrace,
263 },
264
265 #[snafu(display(
267 "Invalid entity layout in segment at {path}: table has {entity_column_count} entity columns, layout is {layout:?}"
268 ))]
269 InvalidSegmentEntityLayout {
270 path: String,
272 entity_column_count: usize,
274 layout: SegmentEntityLayout,
276 backtrace: Backtrace,
278 },
279
280 #[snafu(display("Transaction-log version overflow at {current_version}"))]
282 VersionOverflow {
283 current_version: u64,
285 backtrace: Backtrace,
287 },
288
289 #[snafu(display("CURRENT has empty content at {path}"))]
291 EmptyCurrentPointer {
292 path: String,
294 backtrace: Backtrace,
296 },
297
298 #[snafu(display(
300 "Commit outcome is ambiguous at {commit_path}: {operation_error}; failed to remove the commit file: {cleanup_error}"
301 ))]
302 AmbiguousOutcome {
303 commit_path: String,
305 #[snafu(source, backtrace)]
307 operation_error: Box<StorageError>,
308 cleanup_error: Box<StorageError>,
310 },
311}
312
313pub(crate) fn checked_next_version(expected: u64) -> Result<u64, CommitError> {
314 expected
315 .checked_add(1)
316 .ok_or_else(|| CommitError::VersionOverflow {
317 current_version: expected,
318 backtrace: Backtrace::capture(),
319 })
320}
321
322#[cfg(test)]
323mod tests {
324 use crate::coverage::EntityIdentity;
325 use crate::metadata::logical_schema::{
326 LogicalDataType, LogicalField, LogicalSchema, LogicalSchemaValidationError,
327 LogicalTimestampUnit,
328 };
329 use crate::metadata::protocol::TABLE_PROTOCOL_VERSION;
330 use crate::transaction_log::*;
331
332 use chrono::{DateTime, TimeZone, Utc};
333 use serde_json;
334
335 fn utc_datetime(
338 year: i32,
339 month: u32,
340 day: u32,
341 hour: u32,
342 minute: u32,
343 second: u32,
344 ) -> DateTime<Utc> {
345 Utc.with_ymd_and_hms(year, month, day, hour, minute, second)
346 .single()
347 .expect("valid UTC timestamp")
348 }
349
350 #[test]
351 fn commit_json_roundtrip() {
352 let ts0 = utc_datetime(2025, 1, 1, 0, 0, 0);
353 let ts1 = utc_datetime(2025, 1, 1, 1, 0, 0);
354
355 let time_index = IndexSpec {
356 column: "ts".to_string(),
357 entity_columns: vec!["symbol".to_string()],
358 kind: IndexKind::Timestamp {
359 index_granularity: TimeIndexGranularity::Minutes(60),
360 timezone: Some("UTC".to_string()),
361 },
362 };
363
364 let table_meta = TableMeta {
365 kind: TableKind::TimeSeries(time_index),
366 logical_schema: Some(
367 LogicalSchema::new(vec![
368 LogicalField {
369 name: "ts".to_string(),
370 data_type: LogicalDataType::Timestamp {
371 unit: LogicalTimestampUnit::Micros,
372 timezone: None,
373 },
374 nullable: false,
375 },
376 LogicalField {
377 name: "symbol".to_string(),
378 data_type: LogicalDataType::Utf8,
379 nullable: false,
380 },
381 ])
382 .expect("valid logical schema"),
383 ),
384 created_at: ts0,
385 protocol_version: TABLE_PROTOCOL_VERSION,
386 required_reader_features: Default::default(),
387 required_writer_features: Default::default(),
388 };
389
390 let seg_meta = SegmentMeta {
391 path: "data/nvda_1h_0001.parquet".to_string(),
392 format: FileFormat::Parquet,
393 entity_layout: SegmentEntityLayout::Single(
394 EntityIdentity::try_new(vec!["NVDA".into()]).expect("valid identity"),
395 ),
396 index_min: (ts0).into(),
397 index_max: (ts1).into(),
398 row_count: 1024,
399 file_size: None,
400 coverage_path: None,
401 };
402
403 let commit = Commit {
404 version: 1,
405 base_version: 0,
406 timestamp: ts1,
407 actions: vec![
408 LogAction::UpdateTableMeta(table_meta),
409 LogAction::AddSegment(seg_meta),
410 ],
411 };
412
413 let json = serde_json::to_string_pretty(&commit).expect("serialize commit");
415 assert!(json.contains(&format!("\"protocol_version\": {TABLE_PROTOCOL_VERSION}")));
416 assert!(json.contains("\"required_reader_features\": []"));
417 assert!(json.contains("\"required_writer_features\": []"));
418 let decoded: Commit = serde_json::from_str(&json).expect("deserialize commit");
422
423 assert_eq!(commit, decoded);
425 }
426
427 #[test]
428 fn logical_schema_rejects_duplicate_columns() {
429 let dup = LogicalSchema::new(vec![
430 LogicalField {
431 name: "ts".to_string(),
432 data_type: LogicalDataType::Timestamp {
433 unit: LogicalTimestampUnit::Micros,
434 timezone: None,
435 },
436 nullable: false,
437 },
438 LogicalField {
439 name: "ts".to_string(),
440 data_type: LogicalDataType::Timestamp {
441 unit: LogicalTimestampUnit::Micros,
442 timezone: None,
443 },
444 nullable: false,
445 },
446 ]);
447
448 let err = dup.expect_err("duplicate columns should be rejected");
449 assert!(
450 matches!(err, LogicalSchemaValidationError::DuplicateColumn { column } if column == "ts")
451 );
452 }
453
454 #[test]
455 fn time_index_spec_defaults() {
456 let json = r#"{
458 "column": "ts",
459 "kind": {
460 "type": "timestamp",
461 "index_granularity": { "Hours": 1 }
462 }
463 }"#;
464
465 let spec: IndexSpec = serde_json::from_str(json).expect("deserialize");
466
467 assert_eq!(spec.column, "ts");
468 assert_eq!(spec.entity_columns, Vec::<String>::new()); assert_eq!(
470 spec.kind,
471 IndexKind::Timestamp {
472 index_granularity: TimeIndexGranularity::Hours(1),
473 timezone: None
474 }
475 );
476 }
477
478 #[test]
479 fn time_index_spec_skips_none_timezone_on_serialize() {
480 let spec = IndexSpec {
481 column: "ts".to_string(),
482 entity_columns: vec![],
483 kind: IndexKind::Timestamp {
484 index_granularity: TimeIndexGranularity::Seconds(30),
485 timezone: None,
486 },
487 };
488
489 let json = serde_json::to_string(&spec).expect("serialize");
490
491 assert!(!json.contains("timezone"));
493 }
494
495 #[test]
496 fn logical_column_nullable_requires_explicit_value() {
497 let json = r#"{ "name": "price", "data_type": "Float64" }"#;
498
499 let err = serde_json::from_str::<LogicalField>(json).unwrap_err();
500 assert!(
501 err.to_string().contains("missing field `nullable`"),
502 "unexpected error: {err}"
503 );
504 }
505
506 #[test]
507 fn table_kind_generic_roundtrip() {
508 let kind = TableKind::Generic;
509 let json = serde_json::to_string(&kind).expect("serialize");
510 let decoded: TableKind = serde_json::from_str(&json).expect("deserialize");
511
512 assert_eq!(kind, decoded);
513 assert_eq!(json, r#""Generic""#);
514 }
515
516 #[test]
517 fn all_time_index_granularity_variants_roundtrip() {
518 let granularities = vec![
519 TimeIndexGranularity::Seconds(15),
520 TimeIndexGranularity::Minutes(5),
521 TimeIndexGranularity::Hours(24),
522 TimeIndexGranularity::Days(7),
523 ];
524
525 for index_granularity in granularities {
526 let json = serde_json::to_string(&index_granularity).expect("serialize");
527 let decoded: TimeIndexGranularity = serde_json::from_str(&json).expect("deserialize");
528 assert_eq!(index_granularity, decoded);
529 }
530 }
531
532 #[test]
533 fn file_format_serializes_lowercase() {
534 let format = FileFormat::Parquet;
535 let json = serde_json::to_string(&format).expect("serialize");
536
537 assert_eq!(json, r#""parquet""#);
538
539 let decoded: FileFormat = serde_json::from_str(&json).expect("deserialize");
541 assert_eq!(format, decoded);
542 }
543
544 #[test]
545 fn file_format_default_is_parquet() {
546 assert_eq!(FileFormat::default(), FileFormat::Parquet);
547 }
548
549 #[test]
550 fn remove_segment_action_roundtrip() {
551 let action = LogAction::RemoveSegment {
552 path: "data/seg-to-remove.parquet".to_string(),
553 };
554
555 let json = serde_json::to_string(&action).expect("serialize");
556 let decoded: LogAction = serde_json::from_str(&json).expect("deserialize");
557
558 assert_eq!(action, decoded);
559 }
560
561 #[test]
562 fn commit_with_empty_actions() {
563 let ts = utc_datetime(2025, 6, 15, 12, 0, 0);
564
565 let commit = Commit {
566 version: 1,
567 base_version: 0,
568 timestamp: ts,
569 actions: vec![],
570 };
571
572 let json = serde_json::to_string(&commit).expect("serialize");
573 let decoded: Commit = serde_json::from_str(&json).expect("deserialize");
574
575 assert_eq!(commit, decoded);
576 assert!(decoded.actions.is_empty());
577 }
578}