Skip to main content

timeseries_table_format/
transaction_log.rs

1//! Append-only metadata log and table state.
2//!
3//! This module implements the Delta-inspired metadata layer for
4//! `timeseries-table-format` and defines the logical metadata model
5//! written to and read from the `_timeseries_log/` directory.
6//!
7//! - A simple append-only commit log stored as JSON files under a
8//!   `_timeseries_log/` directory (for example, `_timeseries_log/0000000000.json`).
9//! - A `CURRENT` pointer that tracks the latest committed table version.
10//! - Strongly-typed metadata structures such as `TableMeta`,
11//!   `TableKind`, `IndexSpec`, `SegmentMeta`, and `LogAction`.
12//! - An optimistic concurrency model based on version guards, so that
13//!   commits fail cleanly with a conflict error when the expected
14//!   version does not match the current version.
15//! - A `TableState` representation materialized from the log, which
16//!   describes the current table version, metadata, and active segments.
17//!
18//! The log is designed to be:
19//!
20//! - **Append-only**: commits never mutate existing files.
21//! - **Monotonically versioned**: versions are `u64` values that only
22//!   increase, enforced by the commit API.
23//! - **Human-inspectable**: JSON commits and a small set of actions
24//!   make it easy to debug with basic tools.
25//!
26//! ## On-disk layout (high level)
27//!
28//! ```text
29//! table_root/
30//!   _timeseries_log/
31//!     CURRENT                  # latest committed version (e.g. "3\n")
32//!     0000000001.json          # Commit version 1
33//!     0000000002.json          # Commit version 2
34//!     0000000003.json          # Commit version 3
35//!   data/                      # Parquet segments live here (convention for now)
36//! ```
37//!
38//! Each `*.json` file contains a single [`Commit`] value, encoded as JSON. For
39//! example:
40//!
41//! ```json
42//! {
43//!   "version": 1,
44//!   "base_version": 0,
45//!   "timestamp": "2025-01-01T00:00:00Z",
46//!   "actions": [
47//!     {
48//!       "AddSegment": {
49//!         "path": "data/nvda_1h_0001.parquet",
50//!         "format": "parquet",
51//!         "entity_layout": {"Single": ["NVDA"]},
52//!         "index_min": {"type": "timestamp", "value": "2020-01-01T00:00:00Z"},
53//!         "index_max": {"type": "timestamp", "value": "2020-01-02T00:00:00Z"},
54//!         "row_count": 1024
55//!       }
56//!     }
57//!   ]
58//! }
59//! ```
60//!
61//! In v0.1 the log is strictly append-only, and table state is reconstructed by
62//! replaying every commit up to the version referenced by `CURRENT`. This module
63//! does not know about query engines; it only provides the persisted metadata
64//! and an API for committing changes safely.
65pub 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/// Errors that can occur while reading or writing the commit log.
86#[derive(Debug, Snafu)]
87pub enum CommitError {
88    /// The caller's expected_version does not match the CURRENT pointer.
89    #[snafu(display("Commit conflict: expected version {expected}, but CURRENT is {found}"))]
90    Conflict {
91        /// The version the caller expected to be current.
92        expected: u64,
93        /// The actual current version found.
94        found: u64,
95        /// Backtrace for debugging.
96        backtrace: Backtrace,
97    },
98
99    /// Underlying storage error while working with the log or CURRENT file.
100    ///
101    /// Backtraces are delegated to the inner StorageError.
102    #[snafu(display("Storage error while accessing commit log: {source}"))]
103    Storage {
104        /// Underlying storage error returned by the storage backend.
105        #[snafu(backtrace)]
106        source: StorageError,
107    },
108
109    /// Table metadata uses a format version this reader cannot interpret.
110    #[snafu(display("Unsupported table format version: expected {expected}, found {found}"))]
111    UnsupportedFormatVersion {
112        /// The only table format version this reader supports.
113        expected: u32,
114        /// Version found in persisted table metadata.
115        found: u64,
116    },
117
118    /// A commit operation failed and its newly-created commit file may remain.
119    #[snafu(display(
120        "Commit outcome is ambiguous at {commit_path}: {operation_error}; failed to remove the commit file: {cleanup_error}"
121    ))]
122    AmbiguousOutcome {
123        /// Path of the commit file that may remain unpublished.
124        commit_path: String,
125        /// Write, sync, or publish failure that triggered cleanup.
126        #[snafu(source)]
127        operation_error: Box<StorageError>,
128        /// Failure encountered while removing the unpublished commit file.
129        cleanup_error: Box<StorageError>,
130        /// Backtrace for debugging.
131        backtrace: Backtrace,
132    },
133
134    /// The log or CURRENT file is in an unexpected / malformed state.
135    #[snafu(display("Corrupt log state: {msg}"))]
136    CorruptState {
137        /// A description of the corrupt state.
138        msg: String,
139        /// Backtrace for debugging.
140        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    // ==================== Serialization tests ====================
157
158    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        // Serialize to JSON.
233        let json = serde_json::to_string_pretty(&commit).expect("serialize commit");
234        assert!(json.contains(&format!("\"format_version\": {TABLE_FORMAT_VERSION}")));
235        // println!("{json}");
236
237        // Deserialize back.
238        let decoded: Commit = serde_json::from_str(&json).expect("deserialize commit");
239
240        // Round-trip equality.
241        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        // JSON with optional fields omitted.
272        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()); // default
281        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        // "timezone" key should be absent.
304        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        // Also verify round-trip.
352        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}