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(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/// Errors that can occur while reading or writing the commit log.
93#[derive(Debug, Snafu)]
94#[non_exhaustive]
95pub enum CommitError {
96    /// The caller's expected_version does not match the CURRENT pointer.
97    #[snafu(display("Commit conflict: expected version {expected}, but CURRENT is {found}"))]
98    Conflict {
99        /// The version the caller expected to be current.
100        expected: u64,
101        /// The actual current version found.
102        found: u64,
103        /// Backtrace for debugging.
104        backtrace: Backtrace,
105    },
106
107    /// Underlying storage error while working with the log or CURRENT file.
108    ///
109    /// Backtraces are delegated to the inner StorageError.
110    #[snafu(display("Storage error while accessing commit log: {source}"))]
111    Storage {
112        /// Underlying storage error returned by the storage backend.
113        #[snafu(backtrace)]
114        source: StorageError,
115    },
116
117    /// The table protocol is incompatible with this operation.
118    #[snafu(context(false), display("Table protocol error: {source}"))]
119    Protocol {
120        /// Complete table protocol failure.
121        #[snafu(source)]
122        source: crate::metadata::protocol::TableProtocolError,
123        /// Backtrace captured at the transaction-log boundary.
124        backtrace: Backtrace,
125    },
126
127    /// A commit payload could not be decoded from JSON.
128    #[snafu(display("Failed to deserialize commit {version}: {source}"))]
129    CommitDeserialization {
130        /// Commit version being decoded.
131        version: u64,
132        /// JSON decoding failure.
133        source: serde_json::Error,
134        /// Backtrace captured at the transaction-log boundary.
135        backtrace: Backtrace,
136    },
137
138    /// A commit payload could not be encoded as JSON.
139    #[snafu(display("Failed to serialize commit {version}: {source}"))]
140    CommitSerialization {
141        /// Commit version being encoded.
142        version: u64,
143        /// JSON encoding failure.
144        source: serde_json::Error,
145        /// Backtrace captured at the transaction-log boundary.
146        backtrace: Backtrace,
147    },
148
149    /// The CURRENT pointer is not an unsigned transaction-log version.
150    #[snafu(display("CURRENT has invalid content {contents:?}: {source}"))]
151    CurrentVersionParse {
152        /// Invalid trimmed CURRENT contents.
153        contents: String,
154        /// Integer parsing failure.
155        source: std::num::ParseIntError,
156        /// Backtrace captured at the transaction-log boundary.
157        backtrace: Backtrace,
158    },
159
160    /// A persisted table-relative path is invalid.
161    #[snafu(display("Invalid persisted {description} {path:?}: {source}"))]
162    InvalidPersistedPath {
163        /// Kind of persisted path being validated.
164        description: String,
165        /// Rejected persisted path.
166        path: String,
167        /// Structured path validation failure.
168        #[snafu(source(from(StorageError, Box::new)), backtrace)]
169        source: Box<StorageError>,
170    },
171
172    /// A persisted ordered-index specification is invalid.
173    #[snafu(display("Invalid persisted ordered-index specification: {source}"))]
174    InvalidIndexSpec {
175        /// Ordered-index validation failure.
176        source: IndexSpecError,
177        /// Backtrace captured while rebuilding table state.
178        backtrace: Backtrace,
179    },
180
181    /// Persisted table schema and ordered-index metadata are incompatible.
182    #[snafu(display("Persisted table schema is incompatible with its ordered index: {source}"))]
183    TableSchemaCompatibility {
184        /// Complete schema compatibility failure.
185        #[snafu(source(from(SchemaCompatibilityError, Box::new)), backtrace)]
186        source: Box<SchemaCompatibilityError>,
187    },
188
189    /// A persisted single-entity segment identity is incompatible with the table schema.
190    #[snafu(display("Invalid single-entity identity in segment at {path}: {source}"))]
191    SegmentEntityIdentitySchema {
192        /// Persisted segment path.
193        path: String,
194        /// Complete entity identity compatibility failure.
195        #[snafu(source(from(SchemaCompatibilityError, Box::new)), backtrace)]
196        source: Box<SchemaCompatibilityError>,
197    },
198
199    /// Persisted segment metadata violates its registered ordered-index domain.
200    #[snafu(display("Invalid persisted segment metadata: {source}"))]
201    SegmentMetadata {
202        /// Complete segment metadata validation failure.
203        #[snafu(source(from(SegmentMetaError, Box::new)), backtrace)]
204        source: Box<SegmentMetaError>,
205    },
206
207    /// Rebuilding table state was requested before the first commit.
208    #[snafu(display("Cannot rebuild table state because CURRENT is 0"))]
209    UninitializedTableState {
210        /// Backtrace captured at the state rebuild boundary.
211        backtrace: Backtrace,
212    },
213
214    /// A commit file name and its payload disagree on the version.
215    #[snafu(display("Commit version mismatch: expected {expected}, found {found} in the payload"))]
216    CommitVersionMismatch {
217        /// Version selected by the commit file name.
218        expected: u64,
219        /// Version stored in the payload.
220        found: u64,
221        /// Backtrace captured while rebuilding table state.
222        backtrace: Backtrace,
223    },
224
225    /// More than one live AddSegment action uses the same path.
226    #[snafu(display("Duplicate live segment path: {path}"))]
227    DuplicateLiveSegmentPath {
228        /// Repeated live segment path.
229        path: String,
230        /// Backtrace captured while rebuilding table state.
231        backtrace: Backtrace,
232    },
233
234    /// No table metadata was found while replaying the selected commits.
235    #[snafu(display("No table metadata found in commits up to version {current_version}"))]
236    MissingTableMetadata {
237        /// Latest commit version included in the replay.
238        current_version: u64,
239        /// Backtrace captured while rebuilding table state.
240        backtrace: Backtrace,
241    },
242
243    /// A persisted coverage pointer describes a different ordered index.
244    #[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        /// Ordered-index kind from table metadata.
249        expected: IndexKind,
250        /// Ordered-index kind stored in the coverage pointer.
251        actual: IndexKind,
252        /// Commit version that supplied the pointer.
253        pointer_version: u64,
254        /// Backtrace captured while rebuilding table state.
255        backtrace: Box<Backtrace>,
256    },
257
258    /// Persisted segments exist without the logical schema needed to validate them.
259    #[snafu(display("Persisted segments require a logical schema"))]
260    MissingLogicalSchemaForSegments {
261        /// Backtrace captured while rebuilding table state.
262        backtrace: Backtrace,
263    },
264
265    /// A persisted segment entity layout is incompatible with the table metadata.
266    #[snafu(display(
267        "Invalid entity layout in segment at {path}: table has {entity_column_count} entity columns, layout is {layout:?}"
268    ))]
269    InvalidSegmentEntityLayout {
270        /// Persisted segment path.
271        path: String,
272        /// Number of entity columns configured by the table.
273        entity_column_count: usize,
274        /// Rejected persisted layout.
275        layout: SegmentEntityLayout,
276        /// Backtrace captured while rebuilding table state.
277        backtrace: Backtrace,
278    },
279
280    /// Incrementing the transaction-log version would overflow `u64`.
281    #[snafu(display("Transaction-log version overflow at {current_version}"))]
282    VersionOverflow {
283        /// Current version that cannot be incremented.
284        current_version: u64,
285        /// Backtrace captured at the version calculation boundary.
286        backtrace: Backtrace,
287    },
288
289    /// The CURRENT pointer contains no version.
290    #[snafu(display("CURRENT has empty content at {path}"))]
291    EmptyCurrentPointer {
292        /// Table-relative CURRENT path.
293        path: String,
294        /// Backtrace captured at the transaction-log boundary.
295        backtrace: Backtrace,
296    },
297
298    /// A commit operation failed and its newly-created commit file may remain.
299    #[snafu(display(
300        "Commit outcome is ambiguous at {commit_path}: {operation_error}; failed to remove the commit file: {cleanup_error}"
301    ))]
302    AmbiguousOutcome {
303        /// Path of the commit file that may remain unpublished.
304        commit_path: String,
305        /// Write, sync, or publish failure that triggered cleanup.
306        #[snafu(source, backtrace)]
307        operation_error: Box<StorageError>,
308        /// Failure encountered while removing the unpublished commit file.
309        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    // ==================== Serialization tests ====================
336
337    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        // Serialize to JSON.
414        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        // println!("{json}");
419
420        // Deserialize back.
421        let decoded: Commit = serde_json::from_str(&json).expect("deserialize commit");
422
423        // Round-trip equality.
424        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        // JSON with optional fields omitted.
457        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()); // default
469        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        // "timezone" key should be absent.
492        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        // Also verify round-trip.
540        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}