timeseries_table_format/table/operations/
create.rs1use 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#[derive(Debug, Snafu)]
19#[snafu(module, visibility(pub(crate)))]
20#[non_exhaustive]
21pub enum CreateTableError {
22 #[snafu(context(false), display("Table protocol error: {source}"))]
24 Protocol {
25 #[snafu(source)]
27 source: TableProtocolError,
28 backtrace: Backtrace,
30 },
31
32 #[snafu(display("Cannot create a time-series table from table kind {kind:?}"))]
34 NotTimeSeries {
35 kind: TableKind,
37 },
38
39 #[snafu(
41 context(false),
42 display("Invalid ordered-index specification: {source}")
43 )]
44 IndexSpecValidation {
45 #[snafu(source)]
47 source: IndexSpecError,
48 backtrace: Backtrace,
50 },
51
52 #[snafu(context(false), display("Table schema validation failed: {source}"))]
54 SchemaValidation {
55 #[snafu(source(from(SchemaCompatibilityError, Box::new)), backtrace)]
57 source: Box<SchemaCompatibilityError>,
58 },
59
60 #[snafu(display("Table already exists at transaction log version {current_version}"))]
62 AlreadyExists {
63 current_version: u64,
65 },
66
67 #[snafu(context(false), display("Table creation commit error: {source}"))]
69 Commit {
70 #[snafu(source, backtrace)]
72 source: CommitError,
73 },
74}
75
76impl TimeSeriesTable {
77 #[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}