Skip to main content

timeseries_table_format/
table.rs

1//! High-level time-series table abstraction.
2//!
3//! This module is the canonical home for the user-facing [`TimeSeriesTable`]
4//! API.
5//!
6//! In v0.1 this is intentionally read-heavy and write-light:
7//! - `open` reconstructs state from the transaction log,
8//! - `create` bootstraps a fresh table with an initial metadata commit,
9//! - append APIs handle schema enforcement, coverage sidecars, and OCC,
10//! - range scans stream filtered record batches.
11
12pub mod append;
13/// Append profiling report types used by CLI benchmarks.
14pub mod append_report;
15pub mod coverage;
16pub mod error;
17mod optimize;
18pub mod scan;
19
20#[cfg(test)]
21pub(crate) mod test_util;
22
23#[cfg(test)]
24mod latest_snapshot_tests;
25
26use std::pin::Pin;
27
28use arrow::array::RecordBatch;
29use futures::Stream;
30use snafu::prelude::*;
31
32use crate::table::error::{
33    AlreadyExistsSnafu, EmptyTableSnafu, IndexSpecSnafu, NotTimeSeriesSnafu,
34    SchemaCompatibilitySnafu, TransactionLogSnafu, UnsupportedFormatVersionSnafu,
35};
36
37use crate::{
38    metadata::{
39        schema_compat::ensure_index_spec_matches_schema, table_metadata::TABLE_FORMAT_VERSION,
40    },
41    storage::TableLocation,
42    transaction_log::{
43        IndexSpec, LogAction, TableKind, TableMeta, TableState, TransactionLogStore,
44    },
45};
46
47pub use error::TableError;
48pub use optimize::OptimizeReport;
49
50/// Stream of Arrow RecordBatch values from a time-series scan.
51///
52/// Batch and row order is unspecified.
53pub type TimeSeriesScan = Pin<Box<dyn Stream<Item = Result<RecordBatch, TableError>> + Send>>;
54
55/// High-level time-series table handle.
56///
57/// This is the main entry point for callers. It bundles:
58/// - where the table is,
59/// - how to talk to the transaction log,
60/// - what the current committed state is,
61/// - and the extracted time index spec.
62#[derive(Debug, Clone)]
63pub struct TimeSeriesTable {
64    log: TransactionLogStore,
65    state: TableState,
66    index: IndexSpec,
67}
68
69impl TimeSeriesTable {
70    /// Return the current committed table state.
71    pub fn state(&self) -> &TableState {
72        &self.state
73    }
74
75    /// Return a mutable reference to the current committed table state (crate-internal).
76    ///
77    /// This exists to support internal helpers (for example, tests) without
78    /// exposing mutation to library callers.
79    #[allow(dead_code)]
80    pub(crate) fn state_mut(&mut self) -> &mut TableState {
81        &mut self.state
82    }
83
84    /// Return the time index specification for this table.
85    pub fn index_spec(&self) -> &IndexSpec {
86        &self.index
87    }
88
89    /// Return the table location.
90    pub fn location(&self) -> &TableLocation {
91        self.log.location()
92    }
93
94    /// Open an existing time-series table at the given location.
95    ///
96    /// Steps:
97    /// - Build a `TransactionLogStore` for the location.
98    /// - Rebuild `TableState` from the transaction log.
99    /// - Reject empty tables (version == 0).
100    /// - Require `TableKind::TimeSeries` and extract `IndexSpec`.
101    pub async fn open(location: TableLocation) -> Result<Self, TableError> {
102        let log = TransactionLogStore::new(location.clone());
103
104        // Early return for tables with no commits so we surface TableError::EmptyTable
105        // instead of a lower-level corrupt state error.
106        let current_version = log
107            .load_current_version()
108            .await
109            .context(TransactionLogSnafu)?;
110
111        if current_version == 0 {
112            return EmptyTableSnafu.fail();
113        }
114
115        // Rebuild the snapshot of state from the log.
116        let state = log
117            .rebuild_table_state()
118            .await
119            .context(TransactionLogSnafu)?;
120
121        // Extract the time index spec from TableMeta.kind.
122        let index = match &state.table_meta.kind {
123            TableKind::TimeSeries(spec) => spec.clone(),
124            other => {
125                return NotTimeSeriesSnafu {
126                    kind: other.clone(),
127                }
128                .fail();
129            }
130        };
131
132        Ok(Self { log, state, index })
133    }
134
135    /// Create a new time-series table at the given location.
136    ///
137    /// This:
138    /// - Requires `table_meta.format_version` to match [`TABLE_FORMAT_VERSION`],
139    /// - Requires `table_meta.kind` to be `TableKind::TimeSeries`,
140    /// - Verifies that there are no existing commits (version must be 0),
141    /// - Writes an initial commit with `UpdateTableMeta(table_meta.clone())`,
142    /// - Returns a `TimeSeriesTable` with a fresh `TableState`.
143    pub async fn create(
144        location: TableLocation,
145        table_meta: TableMeta,
146    ) -> Result<Self, TableError> {
147        if table_meta.format_version() != TABLE_FORMAT_VERSION {
148            return UnsupportedFormatVersionSnafu {
149                expected: TABLE_FORMAT_VERSION,
150                found: table_meta.format_version(),
151            }
152            .fail();
153        }
154
155        // 1) Extract the time index spec from the provided metadata
156        // and ensure this is actually a time-series table.
157        let index = match &table_meta.kind {
158            TableKind::TimeSeries(spec) => spec.clone(),
159            other => {
160                return NotTimeSeriesSnafu {
161                    kind: other.clone(),
162                }
163                .fail();
164            }
165        };
166        index.validate().context(IndexSpecSnafu)?;
167        if let Some(schema) = &table_meta.logical_schema {
168            ensure_index_spec_matches_schema(schema, &index).context(SchemaCompatibilitySnafu)?;
169        }
170
171        let log = TransactionLogStore::new(location.clone());
172
173        // 2) Check that there are no existing commits. This keeps `create`
174        // from silently appending to a pre-existing table.
175        let current_version = log
176            .load_current_version()
177            .await
178            .context(TransactionLogSnafu)?;
179
180        if current_version != 0 {
181            return AlreadyExistsSnafu { current_version }.fail();
182        }
183
184        // 3) Write the initial metadata commit at version 1.
185        let actions = vec![LogAction::UpdateTableMeta(table_meta.clone())];
186
187        let new_version = log
188            .commit_with_expected_version(0, actions)
189            .await
190            .context(TransactionLogSnafu)?;
191
192        debug_assert_eq!(new_version, 1);
193
194        // 4) Rebuild state from the log so that `state` is guaranteed to be
195        // consistent with what is on disk.
196        let state = log
197            .rebuild_table_state()
198            .await
199            .context(TransactionLogSnafu)?;
200        Ok(Self { log, state, index })
201    }
202
203    /// Load the current log version from disk without mutating in-memory state.
204    pub async fn current_version(&self) -> Result<u64, TableError> {
205        self.log
206            .load_current_version()
207            .await
208            .context(TransactionLogSnafu)
209    }
210
211    /// Rebuild and return the latest table state from the transaction log.
212    pub async fn load_latest_state(&self) -> Result<TableState, TableError> {
213        self.log
214            .rebuild_table_state()
215            .await
216            .context(TransactionLogSnafu)
217    }
218
219    /// Refresh in-memory state if the log has advanced; returns true if updated.
220    pub async fn refresh(&mut self) -> Result<bool, TableError> {
221        let current = self
222            .log
223            .load_current_version()
224            .await
225            .context(TransactionLogSnafu)?;
226
227        if current == self.state.version {
228            return Ok(false);
229        }
230
231        let state = self
232            .log
233            .rebuild_table_state()
234            .await
235            .context(TransactionLogSnafu)?;
236
237        let index = match &state.table_meta.kind {
238            TableKind::TimeSeries(spec) => spec.clone(),
239            other => {
240                return NotTimeSeriesSnafu {
241                    kind: other.clone(),
242                }
243                .fail();
244            }
245        };
246
247        self.state = state;
248        self.index = index;
249        Ok(true)
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    use crate::storage::{StorageLocation, layout};
258    use crate::table::test_util::*;
259    use crate::transaction_log::{CommitError, IndexKind, TimeBucket, TransactionLogStore};
260
261    use tempfile::TempDir;
262
263    #[tokio::test]
264    async fn create_initializes_log_and_state() -> TestResult {
265        let tmp = TempDir::new()?;
266        let location = TableLocation::local(tmp.path());
267
268        let meta = make_basic_table_meta();
269        let table = TimeSeriesTable::create(location.clone(), meta).await?;
270
271        // State should be at version 1 with no segments.
272        assert_eq!(table.state().version, 1);
273        assert_eq!(TABLE_FORMAT_VERSION, 6);
274        assert_eq!(
275            table.state().table_meta.format_version(),
276            TABLE_FORMAT_VERSION
277        );
278        assert!(table.state().segments.is_empty());
279
280        // Verify that the log layout exists on disk.
281        let root = match table.location().storage() {
282            StorageLocation::Local(p) => p.clone(),
283        };
284
285        let log_dir = root.join(layout::log_rel_dir());
286        assert!(log_dir.is_dir());
287
288        let current_path = root.join(layout::current_rel_path());
289        let current_contents = tokio::fs::read_to_string(&current_path).await?;
290        assert_eq!(current_contents.trim(), "1");
291
292        Ok(())
293    }
294
295    #[tokio::test]
296    async fn create_rejects_unsupported_format_without_writing_log() -> TestResult {
297        let tmp = TempDir::new()?;
298        let location = TableLocation::local(tmp.path());
299
300        for found in [TABLE_FORMAT_VERSION - 1, TABLE_FORMAT_VERSION + 1] {
301            let mut meta = make_basic_table_meta();
302            meta.format_version = found;
303
304            let err = TimeSeriesTable::create(location.clone(), meta)
305                .await
306                .expect_err("unsupported format version should be rejected");
307            assert!(matches!(
308                err,
309                TableError::UnsupportedFormatVersion {
310                    expected: TABLE_FORMAT_VERSION,
311                    found: actual,
312                } if actual == found
313            ));
314            assert!(!tmp.path().join(layout::log_rel_dir()).exists());
315        }
316
317        Ok(())
318    }
319
320    #[tokio::test]
321    async fn open_round_trip_after_create() -> TestResult {
322        let tmp = TempDir::new()?;
323        let location = TableLocation::local(tmp.path());
324
325        let meta = make_basic_table_meta();
326        let created = TimeSeriesTable::create(location.clone(), meta).await?;
327
328        let reopened = TimeSeriesTable::open(location.clone()).await?;
329
330        assert_eq!(created.state().version, reopened.state().version);
331        assert_eq!(created.index_spec(), reopened.index_spec());
332        Ok(())
333    }
334
335    #[tokio::test]
336    async fn open_rejects_every_non_current_format_with_typed_error() -> TestResult {
337        for found in [TABLE_FORMAT_VERSION - 1, TABLE_FORMAT_VERSION + 1] {
338            let tmp = TempDir::new()?;
339            let location = TableLocation::local(tmp.path());
340            let log = TransactionLogStore::new(location.clone());
341            let mut meta = make_basic_table_meta();
342            meta.format_version = found;
343            log.commit_with_expected_version(0, vec![LogAction::UpdateTableMeta(meta)])
344                .await?;
345
346            let error = TimeSeriesTable::open(location)
347                .await
348                .expect_err("non-current table format must fail");
349
350            assert!(matches!(
351                error,
352                TableError::TransactionLog {
353                    source: CommitError::UnsupportedFormatVersion {
354                        expected: TABLE_FORMAT_VERSION,
355                        found: actual,
356                    },
357                } if actual == u64::from(found)
358            ));
359        }
360        Ok(())
361    }
362
363    #[tokio::test]
364    async fn open_empty_root_errors() -> TestResult {
365        let tmp = TempDir::new()?;
366        let location = TableLocation::local(tmp.path());
367
368        // There is no CURRENT and no commits, so opening should fail.
369        let result = TimeSeriesTable::open(location).await;
370        assert!(matches!(result, Err(TableError::EmptyTable)));
371        Ok(())
372    }
373
374    #[tokio::test]
375    async fn create_fails_if_table_already_exists() -> TestResult {
376        let tmp = TempDir::new()?;
377        let location = TableLocation::local(tmp.path());
378
379        let meta = make_basic_table_meta();
380        let _first = TimeSeriesTable::create(location.clone(), meta.clone()).await?;
381
382        // Second create should detect existing commits and fail.
383        let result = TimeSeriesTable::create(location.clone(), meta).await;
384        assert!(matches!(result, Err(TableError::AlreadyExists { .. })));
385        Ok(())
386    }
387
388    #[tokio::test]
389    async fn refresh_returns_false_when_no_new_commits() -> TestResult {
390        let tmp = TempDir::new()?;
391        let location = TableLocation::local(tmp.path());
392
393        let meta = make_basic_table_meta();
394        let mut table = TimeSeriesTable::create(location.clone(), meta).await?;
395
396        let refreshed = table.refresh().await?;
397        assert!(!refreshed);
398        assert_eq!(table.state().version, 1);
399        Ok(())
400    }
401
402    #[tokio::test]
403    async fn refresh_updates_state_and_index_on_change() -> TestResult {
404        let tmp = TempDir::new()?;
405        let location = TableLocation::local(tmp.path());
406
407        let meta = make_basic_table_meta();
408        let mut table = TimeSeriesTable::create(location.clone(), meta.clone()).await?;
409
410        let mut updated_meta = meta.clone();
411        if let TableKind::TimeSeries(spec) = &mut updated_meta.kind {
412            spec.kind = IndexKind::Timestamp {
413                bucket: TimeBucket::Minutes(5),
414                timezone: None,
415            };
416        }
417
418        let log = TransactionLogStore::new(location.clone());
419        let new_version = log
420            .commit_with_expected_version(1, vec![LogAction::UpdateTableMeta(updated_meta.clone())])
421            .await?;
422        assert_eq!(new_version, 2);
423
424        let refreshed = table.refresh().await?;
425        assert!(refreshed);
426        assert_eq!(table.state().version, 2);
427
428        match &table.state().table_meta.kind {
429            TableKind::TimeSeries(spec) => assert_eq!(
430                spec.kind,
431                IndexKind::Timestamp {
432                    bucket: TimeBucket::Minutes(5),
433                    timezone: None
434                }
435            ),
436            other => panic!("expected time series table kind, got {other:?}"),
437        }
438        assert_eq!(
439            table.index_spec().kind,
440            IndexKind::Timestamp {
441                bucket: TimeBucket::Minutes(5),
442                timezone: None
443            }
444        );
445        Ok(())
446    }
447}