1pub mod actions;
66pub mod log_store;
67pub mod segments;
68pub mod table_state;
69
70#[cfg(test)]
71mod log_integration_tests;
72
73pub use crate::metadata::table_metadata::{
74 IndexKind, IndexSpec, IndexValue, TableKind, TableMeta, TableMetaDelta, TimeBucket,
75};
76pub use actions::{Commit, LogAction};
77pub use log_store::TransactionLogStore;
78pub use segments::{FileFormat, SegmentEntityLayout, SegmentMeta};
79pub use table_state::TableState;
80
81use snafu::{Backtrace, prelude::*};
82
83use crate::storage::StorageError;
84
85#[derive(Debug, Snafu)]
87pub enum CommitError {
88 #[snafu(display("Commit conflict: expected version {expected}, but CURRENT is {found}"))]
90 Conflict {
91 expected: u64,
93 found: u64,
95 backtrace: Backtrace,
97 },
98
99 #[snafu(display("Storage error while accessing commit log: {source}"))]
103 Storage {
104 #[snafu(backtrace)]
106 source: StorageError,
107 },
108
109 #[snafu(display("Unsupported table format version: expected {expected}, found {found}"))]
111 UnsupportedFormatVersion {
112 expected: u32,
114 found: u64,
116 },
117
118 #[snafu(display(
120 "Commit outcome is ambiguous at {commit_path}: {operation_error}; failed to remove the commit file: {cleanup_error}"
121 ))]
122 AmbiguousOutcome {
123 commit_path: String,
125 #[snafu(source)]
127 operation_error: Box<StorageError>,
128 cleanup_error: Box<StorageError>,
130 backtrace: Backtrace,
132 },
133
134 #[snafu(display("Corrupt log state: {msg}"))]
136 CorruptState {
137 msg: String,
139 backtrace: Backtrace,
141 },
142}
143
144#[cfg(test)]
145mod tests {
146 use crate::coverage::EntityIdentity;
147 use crate::metadata::logical_schema::{
148 LogicalDataType, LogicalField, LogicalSchema, LogicalSchemaError, LogicalTimestampUnit,
149 };
150 use crate::metadata::table_metadata::TABLE_FORMAT_VERSION;
151 use crate::transaction_log::*;
152
153 use chrono::{DateTime, TimeZone, Utc};
154 use serde_json;
155
156 fn utc_datetime(
159 year: i32,
160 month: u32,
161 day: u32,
162 hour: u32,
163 minute: u32,
164 second: u32,
165 ) -> DateTime<Utc> {
166 Utc.with_ymd_and_hms(year, month, day, hour, minute, second)
167 .single()
168 .expect("valid UTC timestamp")
169 }
170
171 #[test]
172 fn commit_json_roundtrip() {
173 let ts0 = utc_datetime(2025, 1, 1, 0, 0, 0);
174 let ts1 = utc_datetime(2025, 1, 1, 1, 0, 0);
175
176 let time_index = IndexSpec {
177 column: "ts".to_string(),
178 entity_columns: vec!["symbol".to_string()],
179 kind: IndexKind::Timestamp {
180 bucket: TimeBucket::Minutes(60),
181 timezone: Some("UTC".to_string()),
182 },
183 };
184
185 let table_meta = TableMeta {
186 kind: TableKind::TimeSeries(time_index),
187 logical_schema: Some(
188 LogicalSchema::new(vec![
189 LogicalField {
190 name: "ts".to_string(),
191 data_type: LogicalDataType::Timestamp {
192 unit: LogicalTimestampUnit::Micros,
193 timezone: None,
194 },
195 nullable: false,
196 },
197 LogicalField {
198 name: "symbol".to_string(),
199 data_type: LogicalDataType::Utf8,
200 nullable: false,
201 },
202 ])
203 .expect("valid logical schema"),
204 ),
205 created_at: ts0,
206 format_version: TABLE_FORMAT_VERSION,
207 };
208
209 let seg_meta = SegmentMeta {
210 path: "data/nvda_1h_0001.parquet".to_string(),
211 format: FileFormat::Parquet,
212 entity_layout: SegmentEntityLayout::Single(
213 EntityIdentity::try_new(vec!["NVDA".into()]).expect("valid identity"),
214 ),
215 index_min: (ts0).into(),
216 index_max: (ts1).into(),
217 row_count: 1024,
218 file_size: None,
219 coverage_path: None,
220 };
221
222 let commit = Commit {
223 version: 1,
224 base_version: 0,
225 timestamp: ts1,
226 actions: vec![
227 LogAction::UpdateTableMeta(table_meta),
228 LogAction::AddSegment(seg_meta),
229 ],
230 };
231
232 let json = serde_json::to_string_pretty(&commit).expect("serialize commit");
234 assert!(json.contains(&format!("\"format_version\": {TABLE_FORMAT_VERSION}")));
235 let decoded: Commit = serde_json::from_str(&json).expect("deserialize commit");
239
240 assert_eq!(commit, decoded);
242 }
243
244 #[test]
245 fn logical_schema_rejects_duplicate_columns() {
246 let dup = LogicalSchema::new(vec![
247 LogicalField {
248 name: "ts".to_string(),
249 data_type: LogicalDataType::Timestamp {
250 unit: LogicalTimestampUnit::Micros,
251 timezone: None,
252 },
253 nullable: false,
254 },
255 LogicalField {
256 name: "ts".to_string(),
257 data_type: LogicalDataType::Timestamp {
258 unit: LogicalTimestampUnit::Micros,
259 timezone: None,
260 },
261 nullable: false,
262 },
263 ]);
264
265 let err = dup.expect_err("duplicate columns should be rejected");
266 assert!(matches!(err, LogicalSchemaError::DuplicateColumn { column } if column == "ts"));
267 }
268
269 #[test]
270 fn time_index_spec_defaults() {
271 let json = r#"{
273 "column": "ts",
274 "kind": { "type": "timestamp", "bucket": { "Hours": 1 } }
275 }"#;
276
277 let spec: IndexSpec = serde_json::from_str(json).expect("deserialize");
278
279 assert_eq!(spec.column, "ts");
280 assert_eq!(spec.entity_columns, Vec::<String>::new()); assert_eq!(
282 spec.kind,
283 IndexKind::Timestamp {
284 bucket: TimeBucket::Hours(1),
285 timezone: None
286 }
287 );
288 }
289
290 #[test]
291 fn time_index_spec_skips_none_timezone_on_serialize() {
292 let spec = IndexSpec {
293 column: "ts".to_string(),
294 entity_columns: vec![],
295 kind: IndexKind::Timestamp {
296 bucket: TimeBucket::Seconds(30),
297 timezone: None,
298 },
299 };
300
301 let json = serde_json::to_string(&spec).expect("serialize");
302
303 assert!(!json.contains("timezone"));
305 }
306
307 #[test]
308 fn logical_column_nullable_requires_explicit_value() {
309 let json = r#"{ "name": "price", "data_type": "Float64" }"#;
310
311 let err = serde_json::from_str::<LogicalField>(json).unwrap_err();
312 assert!(
313 err.to_string().contains("missing field `nullable`"),
314 "unexpected error: {err}"
315 );
316 }
317
318 #[test]
319 fn table_kind_generic_roundtrip() {
320 let kind = TableKind::Generic;
321 let json = serde_json::to_string(&kind).expect("serialize");
322 let decoded: TableKind = serde_json::from_str(&json).expect("deserialize");
323
324 assert_eq!(kind, decoded);
325 assert_eq!(json, r#""Generic""#);
326 }
327
328 #[test]
329 fn all_time_bucket_variants_roundtrip() {
330 let buckets = vec![
331 TimeBucket::Seconds(15),
332 TimeBucket::Minutes(5),
333 TimeBucket::Hours(24),
334 TimeBucket::Days(7),
335 ];
336
337 for bucket in buckets {
338 let json = serde_json::to_string(&bucket).expect("serialize");
339 let decoded: TimeBucket = serde_json::from_str(&json).expect("deserialize");
340 assert_eq!(bucket, decoded);
341 }
342 }
343
344 #[test]
345 fn file_format_serializes_lowercase() {
346 let format = FileFormat::Parquet;
347 let json = serde_json::to_string(&format).expect("serialize");
348
349 assert_eq!(json, r#""parquet""#);
350
351 let decoded: FileFormat = serde_json::from_str(&json).expect("deserialize");
353 assert_eq!(format, decoded);
354 }
355
356 #[test]
357 fn file_format_default_is_parquet() {
358 assert_eq!(FileFormat::default(), FileFormat::Parquet);
359 }
360
361 #[test]
362 fn remove_segment_action_roundtrip() {
363 let action = LogAction::RemoveSegment {
364 path: "data/seg-to-remove.parquet".to_string(),
365 };
366
367 let json = serde_json::to_string(&action).expect("serialize");
368 let decoded: LogAction = serde_json::from_str(&json).expect("deserialize");
369
370 assert_eq!(action, decoded);
371 }
372
373 #[test]
374 fn commit_with_empty_actions() {
375 let ts = utc_datetime(2025, 6, 15, 12, 0, 0);
376
377 let commit = Commit {
378 version: 1,
379 base_version: 0,
380 timestamp: ts,
381 actions: vec![],
382 };
383
384 let json = serde_json::to_string(&commit).expect("serialize");
385 let decoded: Commit = serde_json::from_str(&json).expect("deserialize");
386
387 assert_eq!(commit, decoded);
388 assert!(decoded.actions.is_empty());
389 }
390}