Skip to main content

timeseries_table_format/table/operations/
create.rs

1//! Creating a time-series table.
2
3use snafu::{Backtrace, ResultExt, Snafu};
4
5use crate::{
6    metadata::{
7        index::IndexSpecError,
8        schema_compat::{SchemaCompatibilityError, ensure_index_spec_matches_schema},
9    },
10    storage::TableLocation,
11    table::{TableError, TimeSeriesTable},
12    transaction_log::{
13        CommitError, LogAction, TableKind, TableMeta, TableProtocolError, TransactionLogStore,
14    },
15};
16
17/// Errors owned by a table creation operation.
18#[derive(Debug, Snafu)]
19#[snafu(module, visibility(pub(crate)))]
20#[non_exhaustive]
21pub enum CreateTableError {
22    /// The requested metadata is incompatible with this writer.
23    #[snafu(context(false), display("Table protocol error: {source}"))]
24    Protocol {
25        /// Complete table protocol failure.
26        #[snafu(source)]
27        source: TableProtocolError,
28        /// Backtrace captured at the table-creation boundary.
29        backtrace: Backtrace,
30    },
31
32    /// The supplied metadata does not describe a time-series table.
33    #[snafu(display("Cannot create a time-series table from table kind {kind:?}"))]
34    NotTimeSeries {
35        /// Rejected table kind.
36        kind: TableKind,
37    },
38
39    /// The supplied ordered-index specification is invalid.
40    #[snafu(
41        context(false),
42        display("Invalid ordered-index specification: {source}")
43    )]
44    IndexSpecValidation {
45        /// Complete ordered-index validation failure.
46        #[snafu(source)]
47        source: IndexSpecError,
48        /// Backtrace captured because index specification errors do not own one.
49        backtrace: Backtrace,
50    },
51
52    /// The supplied schema is incompatible with its ordered-index specification.
53    #[snafu(context(false), display("Table schema validation failed: {source}"))]
54    SchemaValidation {
55        /// Complete schema compatibility failure.
56        #[snafu(source(from(SchemaCompatibilityError, Box::new)), backtrace)]
57        source: Box<SchemaCompatibilityError>,
58    },
59
60    /// The target already contains a committed table.
61    #[snafu(display("Table already exists at transaction log version {current_version}"))]
62    AlreadyExists {
63        /// Existing transaction log version.
64        current_version: u64,
65    },
66
67    /// Accessing or publishing the transaction log failed.
68    #[snafu(context(false), display("Table creation commit error: {source}"))]
69    Commit {
70        /// Complete transaction-log failure.
71        #[snafu(source, backtrace)]
72        source: CommitError,
73    },
74}
75
76impl TimeSeriesTable {
77    /// Create a new time-series table at the given location.
78    ///
79    /// This validates the requested metadata, verifies that the target has no
80    /// commits, publishes the initial metadata commit, and rebuilds the state
81    /// returned to the caller.
82    #[tracing::instrument(
83        name = "table.create",
84        target = "timeseries_table_format::table",
85        level = "debug",
86        skip_all,
87        fields(
88            starting_version = tracing::field::Empty,
89            committed_version = tracing::field::Empty,
90            index_kind = tracing::field::Empty,
91            outcome = tracing::field::Empty
92        )
93    )]
94    pub async fn create(
95        location: TableLocation,
96        table_meta: TableMeta,
97    ) -> Result<Self, TableError> {
98        let result: Result<Self, CreateTableError> = async {
99            table_meta
100                .ensure_write_compatible()
101                .map_err(CreateTableError::from)?;
102
103            let index = match &table_meta.kind {
104                TableKind::TimeSeries(index) => index.clone(),
105                kind => {
106                    return Err(CreateTableError::NotTimeSeries { kind: kind.clone() });
107                }
108            };
109            index
110                .validate()
111                .map_err(|source| CreateTableError::IndexSpecValidation {
112                    source,
113                    backtrace: Backtrace::capture(),
114                })?;
115            if let Some(schema) = &table_meta.logical_schema {
116                ensure_index_spec_matches_schema(schema, &index).map_err(CreateTableError::from)?;
117            }
118            tracing::Span::current().record("index_kind", index.kind.name());
119
120            let log = TransactionLogStore::new(location);
121            let current_version = log
122                .load_current_version()
123                .await
124                .map_err(CreateTableError::from)?;
125            tracing::Span::current().record("starting_version", current_version);
126            if current_version != 0 {
127                return Err(CreateTableError::AlreadyExists { current_version });
128            }
129
130            let new_version = log
131                .commit_with_expected_version(
132                    0,
133                    vec![LogAction::UpdateTableMeta(table_meta.clone())],
134                )
135                .await
136                .map_err(CreateTableError::from)?;
137            tracing::Span::current().record("committed_version", new_version);
138            debug_assert_eq!(new_version, 1);
139
140            let state = log
141                .rebuild_table_state()
142                .await
143                .map_err(CreateTableError::from)?;
144            let table = Self { log, state, index };
145            tracing::info!(
146                name: "table.create",
147                target: "timeseries_table_format::table",
148                starting_version = current_version,
149                committed_version = new_version,
150                index_kind = table.index.kind.name(),
151                outcome = "succeeded",
152                "Created time-series table"
153            );
154            Ok(table)
155        }
156        .await;
157        tracing::Span::current().record(
158            "outcome",
159            if result.is_ok() {
160                "succeeded"
161            } else {
162                "failed"
163            },
164        );
165        result.context(crate::table::error::CreateSnafu)
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::{
173        metadata::protocol::TABLE_PROTOCOL_VERSION,
174        storage::{StorageLocation, layout},
175        table::test_util::{
176            TestResult, TraceCapture, assert_capture_excludes, assert_debug_span, captured_span,
177            make_basic_table_meta,
178        },
179    };
180    use tempfile::TempDir;
181
182    #[tokio::test]
183    async fn create_initializes_log_and_state() -> TestResult {
184        let tmp = TempDir::new()?;
185        let location = TableLocation::local(tmp.path());
186        let capture = TraceCapture::default();
187        let table = capture
188            .run(TimeSeriesTable::create(location, make_basic_table_meta()))
189            .await?;
190
191        assert_debug_span(
192            &capture,
193            "table.create",
194            &[
195                ("starting_version", Some("0")),
196                ("committed_version", Some("1")),
197                ("index_kind", Some("timestamp")),
198                ("outcome", Some("succeeded")),
199            ],
200        );
201        assert_eq!(
202            captured_span(&capture, "table.create").target,
203            "timeseries_table_format::table"
204        );
205        let events: Vec<_> = capture
206            .events()
207            .into_iter()
208            .filter(|event| event.name == "table.create")
209            .collect();
210        assert_eq!(events.len(), 1);
211        assert_eq!(events[0].target, "timeseries_table_format::table");
212        assert_eq!(events[0].level, tracing::Level::INFO);
213        for (field, expected) in [
214            ("starting_version", "0"),
215            ("committed_version", "1"),
216            ("index_kind", "timestamp"),
217            ("outcome", "succeeded"),
218        ] {
219            assert_eq!(
220                events[0].fields.get(field).map(String::as_str),
221                Some(expected)
222            );
223        }
224        assert!(
225            events[0]
226                .fields
227                .get("message")
228                .is_some_and(|message| message.contains("Created time-series table"))
229        );
230        assert_capture_excludes(&capture, &[&tmp.path().display().to_string()]);
231
232        assert_eq!(table.state().version, 1);
233        assert_eq!(
234            table.state().table_meta.protocol_version(),
235            TABLE_PROTOCOL_VERSION
236        );
237        assert!(table.state().segments.is_empty());
238        let StorageLocation::Local(root) = table.location().storage();
239        assert!(root.join(layout::log_rel_dir()).is_dir());
240        assert_eq!(
241            tokio::fs::read_to_string(root.join(layout::current_rel_path()))
242                .await?
243                .trim(),
244            "1"
245        );
246        Ok(())
247    }
248
249    #[tokio::test]
250    async fn create_rejects_invalid_metadata_without_writing_log() -> TestResult {
251        let tmp = TempDir::new()?;
252        let location = TableLocation::local(tmp.path());
253
254        for found in [TABLE_PROTOCOL_VERSION - 1, TABLE_PROTOCOL_VERSION + 1] {
255            let mut meta = make_basic_table_meta();
256            meta.protocol_version = found;
257            let error = TimeSeriesTable::create(location.clone(), meta)
258                .await
259                .expect_err("unsupported protocol version must fail");
260            assert!(matches!(
261                error,
262                TableError::Create {
263                    source: CreateTableError::Protocol {
264                        source: TableProtocolError::UnsupportedVersion {
265                            expected: TABLE_PROTOCOL_VERSION,
266                            found: actual,
267                        },
268                        ..
269                    }
270                } if actual == u64::from(found)
271            ));
272        }
273
274        let mut unsupported = make_basic_table_meta();
275        unsupported
276            .required_writer_features
277            .insert("future_writer".to_string());
278        let error = TimeSeriesTable::create(location.clone(), unsupported)
279            .await
280            .expect_err("unsupported writer feature must fail");
281        assert!(matches!(
282            error,
283            TableError::Create {
284                source: CreateTableError::Protocol {
285                    source: TableProtocolError::UnsupportedWriterFeatures { features },
286                    ..
287                }
288            } if features == ["future_writer"]
289        ));
290
291        let mut invalid_index = make_basic_table_meta();
292        let TableKind::TimeSeries(index) = &mut invalid_index.kind else {
293            unreachable!("test metadata is time-series");
294        };
295        index.entity_columns = vec![index.column.clone()];
296        assert!(matches!(
297            TimeSeriesTable::create(location.clone(), invalid_index)
298                .await
299                .expect_err("invalid ordered index must fail"),
300            TableError::Create {
301                source: CreateTableError::IndexSpecValidation {
302                    source: IndexSpecError::EntityColumnMatchesIndex { .. },
303                    ..
304                }
305            }
306        ));
307
308        let mut invalid_schema = make_basic_table_meta();
309        let TableKind::TimeSeries(index) = &mut invalid_schema.kind else {
310            unreachable!("test metadata is time-series");
311        };
312        index.entity_columns = vec!["price".to_string()];
313        assert!(matches!(
314            TimeSeriesTable::create(location.clone(), invalid_schema)
315                .await
316                .expect_err("unsupported entity type must fail"),
317            TableError::Create {
318                source: CreateTableError::SchemaValidation { source, .. }
319            } if matches!(
320                *source,
321                SchemaCompatibilityError::UnsupportedEntityColumnType { .. }
322            )
323        ));
324
325        let mut generic = make_basic_table_meta();
326        generic.kind = TableKind::Generic;
327        assert!(matches!(
328            TimeSeriesTable::create(location, generic)
329                .await
330                .expect_err("generic metadata must fail"),
331            TableError::Create {
332                source: CreateTableError::NotTimeSeries {
333                    kind: TableKind::Generic
334                }
335            }
336        ));
337        assert!(!tmp.path().join(layout::log_rel_dir()).exists());
338        Ok(())
339    }
340
341    #[tokio::test]
342    async fn create_rejects_an_existing_table() -> TestResult {
343        let tmp = TempDir::new()?;
344        let location = TableLocation::local(tmp.path());
345        let meta = make_basic_table_meta();
346        TimeSeriesTable::create(location.clone(), meta.clone()).await?;
347
348        assert!(matches!(
349            TimeSeriesTable::create(location, meta)
350                .await
351                .expect_err("existing table must fail"),
352            TableError::Create {
353                source: CreateTableError::AlreadyExists { current_version: 1 }
354            }
355        ));
356        Ok(())
357    }
358}