Skip to main content

timeseries_table_format/transaction_log/
table_state.rs

1//! Reconstructing the current table state by replaying log commits.
2//!
3//! `TableState` materializes the metadata stored in `_timeseries_log/` and the
4//! [`TransactionLogStore::rebuild_table_state`] helper walks all commits from version 1 up
5//! to the `CURRENT` pointer, applying their actions in order. This keeps read
6//! logic isolated from the append-only write path and documents the invariant
7//! that table readers must see a state consistent with the latest committed
8//! version.
9use std::{collections::HashMap, path::Path};
10
11#[cfg(feature = "test-counters")]
12use std::cell::Cell;
13
14#[cfg(feature = "test-counters")]
15thread_local! {
16    static REBUILD_TABLE_STATE_COUNT: Cell<usize> = const { Cell::new(0) };
17}
18
19#[cfg(feature = "test-counters")]
20/// Return the number of rebuilds invoked on the current thread (test-only).
21pub fn rebuild_table_state_count() -> usize {
22    REBUILD_TABLE_STATE_COUNT.with(|c| c.get())
23}
24
25#[cfg(feature = "test-counters")]
26/// Reset the rebuild counter to zero (test-only).
27pub fn reset_rebuild_table_state_count() {
28    REBUILD_TABLE_STATE_COUNT.with(|c| c.set(0));
29}
30
31use crate::{
32    metadata::{
33        schema_compat::{ensure_entity_identity_matches_schema, ensure_index_spec_matches_schema},
34        segments::sort_segment_meta_by_index,
35    },
36    storage::normalize_relative_storage_path,
37    transaction_log::*,
38};
39
40fn validate_persisted_storage_path(path: &str, description: &str) -> Result<(), CommitError> {
41    let (canonical, _) = match normalize_relative_storage_path(Path::new(path)) {
42        Ok(path) => path,
43        Err(source) => {
44            return CorruptStateSnafu {
45                msg: format!("Invalid persisted {description} {path:?}: {source}"),
46            }
47            .fail();
48        }
49    };
50
51    if canonical != path {
52        return CorruptStateSnafu {
53            msg: format!(
54                "Non-canonical persisted {description} {path:?}; canonical form is {canonical:?}"
55            ),
56        }
57        .fail();
58    }
59
60    Ok(())
61}
62
63/// Pointer to table coverage metadata including index descriptor, path, and version.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct TableCoveragePointer {
66    /// Canonical ordered-index coverage descriptor.
67    pub index_kind: IndexKind,
68    /// Path to the coverage metadata file.
69    pub coverage_path: String,
70    /// Version number associated with this coverage pointer.
71    pub version: u64,
72}
73
74/// In-memory view of table metadata and live segments, reconstructed from the log.
75///
76/// Invariant:
77/// - `version` matches the CURRENT pointer.
78/// - `table_meta` and `segments` are the result of applying all commits from
79///   version 1 through `version` in order.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct TableState {
82    /// Latest committed version recorded in CURRENT.
83    pub version: u64,
84    /// Table-level metadata reconstructed from the log.
85    pub table_meta: TableMeta,
86    /// Current live segments keyed by canonical table-relative path.
87    pub segments: HashMap<String, SegmentMeta>,
88
89    /// Optional pointer to the latest table coverage metadata.
90    pub table_coverage: Option<TableCoveragePointer>,
91}
92
93impl TableState {
94    /// Return live segments sorted deterministically by ordered-index bounds.
95    ///
96    /// Ordering is by `index_min`, then `index_max`, and finally `path` as a
97    /// stable tie-breaker.
98    pub fn segments_sorted_by_index(
99        &self,
100    ) -> Result<Vec<&SegmentMeta>, crate::metadata::table_metadata::IndexValueError> {
101        let mut v: Vec<&SegmentMeta> = self.segments.values().collect();
102        sort_segment_meta_by_index(&mut v)?;
103        Ok(v)
104    }
105}
106
107impl TransactionLogStore {
108    /// Rebuild the current TableState by replaying all commits up to CURRENT.
109    ///
110    /// v0.1 behavior:
111    /// - If CURRENT == 0 (no commits), this returns CommitError::CorruptState.
112    /// - The first commit must include at least one UpdateTableMeta action
113    ///   to bootstrap TableMeta; the last UpdateTableMeta wins.
114    pub async fn rebuild_table_state(&self) -> Result<TableState, CommitError> {
115        #[cfg(feature = "test-counters")]
116        REBUILD_TABLE_STATE_COUNT.with(|c| c.set(c.get() + 1));
117
118        let current_version = self.load_current_version().await?;
119
120        if current_version == 0 {
121            // v0.1: treat "no commits" as an uninitialized / corrupt table.
122            return CorruptStateSnafu {
123                msg: "Cannot rebuild TableState: CURRENT is 0 (no commits)".to_string(),
124            }
125            .fail();
126        }
127
128        let mut table_meta: Option<TableMeta> = None;
129        let mut segments: HashMap<String, SegmentMeta> = HashMap::new();
130        let mut persisted_segment_layouts = Vec::new();
131
132        let mut table_coverage: Option<TableCoveragePointer> = None;
133
134        // Replay all commits from 1..=current_version in order
135        for v in 1..=current_version {
136            let commit = self.load_commit(v).await?;
137
138            // Defensive: file name version should match payload
139            if commit.version != v {
140                return CorruptStateSnafu {
141                    msg: format!(
142                        "Commit version mismatch: expected {v}, found {} in payload",
143                        commit.version
144                    ),
145                }
146                .fail();
147            }
148
149            for action in commit.actions {
150                match action {
151                    LogAction::AddSegment(meta) => {
152                        validate_persisted_storage_path(&meta.path, "segment path")?;
153                        if let Some(coverage_path) = &meta.coverage_path {
154                            validate_persisted_storage_path(
155                                coverage_path,
156                                "segment coverage path",
157                            )?;
158                        }
159                        if segments.contains_key(&meta.path) {
160                            return CorruptStateSnafu {
161                                msg: format!("Duplicate live segment path: {}", meta.path),
162                            }
163                            .fail();
164                        }
165                        persisted_segment_layouts
166                            .push((meta.path.clone(), meta.entity_layout.clone()));
167                        segments.insert(meta.path.clone(), meta);
168                    }
169                    LogAction::RemoveSegment { path } => {
170                        validate_persisted_storage_path(&path, "segment path")?;
171                        segments.remove(&path);
172                    }
173                    LogAction::UpdateTableMeta(delta) => {
174                        // v0.1: full replacement of TableMeta
175                        table_meta = Some(delta);
176                    }
177                    LogAction::UpdateTableCoverage {
178                        index_kind,
179                        coverage_path,
180                    } => {
181                        validate_persisted_storage_path(&coverage_path, "table coverage path")?;
182                        table_coverage = Some(TableCoveragePointer {
183                            index_kind,
184                            coverage_path,
185                            version: v,
186                        })
187                    }
188                }
189            }
190        }
191
192        let table_meta = table_meta.context(CorruptStateSnafu {
193            msg: format!("No TableMeta found in commits up to version {current_version}",),
194        })?;
195
196        let index = match &table_meta.kind {
197            TableKind::TimeSeries(index) => index,
198            TableKind::Generic => {
199                return CorruptStateSnafu {
200                    msg: "Generic tables are not supported by the current format".to_string(),
201                }
202                .fail();
203            }
204        };
205        index
206            .validate()
207            .map_err(|source| CommitError::CorruptState {
208                msg: format!("Invalid ordered index specification: {source}"),
209                backtrace: snafu::Backtrace::capture(),
210            })?;
211        if let Some(pointer) = &table_coverage
212            && pointer.index_kind != index.kind
213        {
214            return CorruptStateSnafu {
215                msg: format!(
216                    "Table coverage index kind does not match table index: expected {:?}, found {:?} in pointer from version {}",
217                    index.kind, pointer.index_kind, pointer.version
218                ),
219            }
220            .fail();
221        }
222        let schema = table_meta.logical_schema.as_ref();
223        if let Some(schema) = schema {
224            ensure_index_spec_matches_schema(schema, index).map_err(|source| {
225                CommitError::CorruptState {
226                    msg: format!("Index specification does not match logical schema: {source}"),
227                    backtrace: snafu::Backtrace::capture(),
228                }
229            })?;
230        }
231        if schema.is_none() && !persisted_segment_layouts.is_empty() {
232            return CorruptStateSnafu {
233                msg: "Persisted segments require a logical schema".to_string(),
234            }
235            .fail();
236        }
237        let entity_column_count = index.entity_columns.len();
238        for (path, layout) in persisted_segment_layouts {
239            match (&layout, entity_column_count) {
240                (SegmentEntityLayout::NotApplicable, 0) | (SegmentEntityLayout::Mixed, 1..) => {}
241                (SegmentEntityLayout::Single(identity), 1..) => {
242                    let Some(schema) = schema else {
243                        return CorruptStateSnafu {
244                            msg: "Persisted segments require a logical schema".to_string(),
245                        }
246                        .fail();
247                    };
248                    ensure_entity_identity_matches_schema(schema, index, identity).map_err(
249                        |source| CommitError::CorruptState {
250                            msg: format!(
251                                "Invalid single-entity identity in segment at {path}: {source}"
252                            ),
253                            backtrace: snafu::Backtrace::capture(),
254                        },
255                    )?;
256                }
257                _ => {
258                    return CorruptStateSnafu {
259                        msg: format!(
260                            "Invalid entity layout in segment at {path}: table has {entity_column_count} entity columns, layout is {layout:?}"
261                        ),
262                    }
263                    .fail();
264                }
265            }
266        }
267        for segment in segments.values() {
268            segment
269                .validate_bounds(&index.kind)
270                .map_err(|source| CommitError::CorruptState {
271                    msg: source.to_string(),
272                    backtrace: snafu::Backtrace::capture(),
273                })?;
274        }
275
276        Ok(TableState {
277            version: current_version,
278            table_meta,
279            segments,
280            table_coverage,
281        })
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::coverage::EntityIdentity;
289    use crate::metadata::{
290        logical_schema::{LogicalDataType, LogicalField, LogicalSchema, LogicalTimestampUnit},
291        table_metadata::TABLE_FORMAT_VERSION,
292    };
293    use crate::storage::layout;
294    use crate::storage::{StorageError, TableLocation};
295    use crate::transaction_log::{
296        FileFormat, IndexKind, IndexSpec, LogAction, SegmentEntityLayout, SegmentMeta, TableKind,
297        TableMeta, TimeBucket, TransactionLogStore,
298    };
299    use chrono::TimeZone;
300    use tempfile::TempDir;
301
302    type TestResult = Result<(), Box<dyn std::error::Error>>;
303
304    fn create_test_log_store() -> (TempDir, TransactionLogStore) {
305        let tmp = TempDir::new().expect("create temp dir");
306        let location = TableLocation::local(tmp.path());
307        let store = TransactionLogStore::new(location);
308        (tmp, store)
309    }
310
311    fn sample_table_meta() -> TableMeta {
312        let entity_columns = vec!["symbol".to_string()];
313        TableMeta {
314            kind: TableKind::TimeSeries(IndexSpec {
315                column: "ts".to_string(),
316                entity_columns: entity_columns.clone(),
317                kind: IndexKind::Timestamp {
318                    bucket: TimeBucket::Minutes(1),
319                    timezone: None,
320                },
321            }),
322            logical_schema: Some(schema_for_entities(&entity_columns)),
323            created_at: chrono::Utc
324                .with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
325                .single()
326                .expect("valid sample table metadata timestamp"),
327            format_version: TABLE_FORMAT_VERSION,
328        }
329    }
330
331    fn schema_for_entities(entity_columns: &[String]) -> LogicalSchema {
332        let mut fields = vec![LogicalField {
333            name: "ts".to_string(),
334            data_type: LogicalDataType::Timestamp {
335                unit: LogicalTimestampUnit::Millis,
336                timezone: None,
337            },
338            nullable: false,
339        }];
340        fields.extend(entity_columns.iter().map(|column| LogicalField {
341            name: column.clone(),
342            data_type: LogicalDataType::Utf8,
343            nullable: false,
344        }));
345        LogicalSchema::new(fields).expect("valid test schema")
346    }
347
348    fn sample_segment(id: &str) -> SegmentMeta {
349        SegmentMeta {
350            path: format!("data/{id}.parquet"),
351            format: FileFormat::Parquet,
352            entity_layout: SegmentEntityLayout::Single(
353                EntityIdentity::try_new(vec!["A".into()]).expect("valid sample identity"),
354            ),
355            index_min: IndexValue::Timestamp(
356                chrono::Utc
357                    .with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
358                    .single()
359                    .expect("valid sample segment index_min"),
360            ),
361            index_max: IndexValue::Timestamp(
362                chrono::Utc
363                    .with_ymd_and_hms(2025, 1, 1, 1, 0, 0)
364                    .single()
365                    .expect("valid sample segment index_max"),
366            ),
367            row_count: 42,
368            file_size: None,
369            coverage_path: None,
370        }
371    }
372
373    fn segment_with_ts(id: &str, ts_min: i64, ts_max: i64) -> SegmentMeta {
374        SegmentMeta {
375            path: format!("data/{id}.parquet"),
376            format: FileFormat::Parquet,
377            entity_layout: SegmentEntityLayout::Single(
378                EntityIdentity::try_new(vec!["A".into()]).expect("valid sample identity"),
379            ),
380            index_min: (chrono::Utc.timestamp_opt(ts_min, 0).single().unwrap()).into(),
381            index_max: (chrono::Utc.timestamp_opt(ts_max, 0).single().unwrap()).into(),
382            row_count: 1,
383            file_size: None,
384            coverage_path: None,
385        }
386    }
387
388    #[test]
389    fn segments_sorted_by_index_orders_hashmap_deterministically() {
390        let mut segments = HashMap::new();
391        let seg_c = segment_with_ts("c", 10, 30);
392        let seg_a = segment_with_ts("a", 10, 20);
393        let seg_d = segment_with_ts("d", 5, 7);
394        let seg_b = segment_with_ts("b", 10, 20);
395
396        segments.insert(seg_c.path.clone(), seg_c);
397        segments.insert(seg_a.path.clone(), seg_a);
398        segments.insert(seg_d.path.clone(), seg_d);
399        segments.insert(seg_b.path.clone(), seg_b);
400
401        let state = TableState {
402            version: 3,
403            table_meta: sample_table_meta(),
404            segments,
405            table_coverage: None,
406        };
407
408        let ordered: Vec<(i64, i64, String)> = state
409            .segments_sorted_by_index()
410            .unwrap()
411            .iter()
412            .map(|seg| match (&seg.index_min, &seg.index_max) {
413                (IndexValue::Timestamp(min), IndexValue::Timestamp(max)) => {
414                    (min.timestamp(), max.timestamp(), seg.path.clone())
415                }
416                _ => panic!("expected timestamp test bounds"),
417            })
418            .collect();
419
420        let mut expected = ordered.clone();
421        expected.sort();
422        assert_eq!(ordered, expected);
423    }
424
425    #[tokio::test]
426    async fn rebuild_table_state_happy_path() -> TestResult {
427        let (_tmp, store) = create_test_log_store();
428        let meta = sample_table_meta();
429        let seg1 = sample_segment("seg1");
430        let seg2 = sample_segment("seg2");
431
432        let v1 = store
433            .commit_with_expected_version(0, vec![LogAction::UpdateTableMeta(meta.clone())])
434            .await?;
435        let v2 = store
436            .commit_with_expected_version(
437                v1,
438                vec![
439                    LogAction::AddSegment(seg1.clone()),
440                    LogAction::AddSegment(seg2.clone()),
441                ],
442            )
443            .await?;
444        let v3 = store
445            .commit_with_expected_version(
446                v2,
447                vec![LogAction::RemoveSegment {
448                    path: seg1.path.clone(),
449                }],
450            )
451            .await?;
452
453        let state = store.rebuild_table_state().await?;
454        assert_eq!(state.version, v3);
455        assert_eq!(state.table_meta, meta);
456        assert!(state.segments.contains_key(&seg2.path));
457        assert!(!state.segments.contains_key(&seg1.path));
458        Ok(())
459    }
460
461    #[tokio::test]
462    async fn rebuild_table_state_errors_when_current_zero() {
463        let (_tmp, store) = create_test_log_store();
464
465        let err = store
466            .rebuild_table_state()
467            .await
468            .expect_err("expected error");
469        assert!(matches!(err, CommitError::CorruptState { .. }));
470    }
471
472    #[tokio::test]
473    async fn rebuild_table_state_errors_when_no_table_meta() -> TestResult {
474        let (_tmp, store) = create_test_log_store();
475        let seg = sample_segment("seg");
476
477        store
478            .commit_with_expected_version(0, vec![LogAction::AddSegment(seg.clone())])
479            .await?;
480
481        let err = store
482            .rebuild_table_state()
483            .await
484            .expect_err("expected error");
485        assert!(matches!(err, CommitError::CorruptState { .. }));
486        Ok(())
487    }
488
489    #[tokio::test]
490    async fn rebuild_table_state_rejects_unsupported_format_version() -> TestResult {
491        let (tmp, store) = create_test_log_store();
492        let log_dir = tmp.path().join(layout::LOG_DIR_NAME);
493        tokio::fs::create_dir_all(&log_dir).await?;
494        tokio::fs::write(
495            tmp.path().join(layout::commit_rel_path(1)),
496            r#"{
497                "version": 1,
498                "base_version": 0,
499                "timestamp": "2025-01-01T00:00:00Z",
500                "actions": [{
501                    "UpdateTableMeta": {
502                        "kind": {"TimeSeries": {
503                            "timestamp_column": "ts",
504                            "entity_columns": ["symbol"],
505                            "bucket": {"Minutes": 1}
506                        }},
507                        "logical_schema": null,
508                        "created_at": "2025-01-01T00:00:00Z",
509                        "format_version": 2
510                    }
511                }]
512            }"#,
513        )
514        .await?;
515        tokio::fs::write(tmp.path().join(layout::current_rel_path()), "1\n").await?;
516
517        let err = store
518            .rebuild_table_state()
519            .await
520            .expect_err("old format version should be rejected");
521        assert!(matches!(
522            err,
523            CommitError::UnsupportedFormatVersion {
524                expected: TABLE_FORMAT_VERSION,
525                found: 2,
526            }
527        ));
528        Ok(())
529    }
530
531    #[tokio::test]
532    async fn rebuild_table_state_rejects_invalid_persisted_segment_bounds() -> TestResult {
533        let (_tmp, store) = create_test_log_store();
534        let mut segment = sample_segment("reversed");
535        segment.index_min =
536            IndexValue::Timestamp(chrono::Utc.timestamp_opt(2, 0).single().unwrap());
537        segment.index_max =
538            IndexValue::Timestamp(chrono::Utc.timestamp_opt(1, 0).single().unwrap());
539
540        store
541            .commit_with_expected_version(
542                0,
543                vec![
544                    LogAction::UpdateTableMeta(sample_table_meta()),
545                    LogAction::AddSegment(segment),
546                ],
547            )
548            .await?;
549
550        let error = store.rebuild_table_state().await.unwrap_err();
551        assert!(matches!(error, CommitError::CorruptState { .. }));
552        assert!(error.to_string().contains("Invalid ordered-index bounds"));
553        Ok(())
554    }
555
556    #[tokio::test]
557    async fn rebuild_table_state_rejects_inapplicable_entity_layouts() -> TestResult {
558        let single = SegmentEntityLayout::Single(EntityIdentity::try_new(vec!["A".into()])?);
559        let cases = [
560            (
561                vec!["symbol".to_string()],
562                SegmentEntityLayout::NotApplicable,
563                "Invalid entity layout",
564            ),
565            (
566                Vec::new(),
567                SegmentEntityLayout::Mixed,
568                "Invalid entity layout",
569            ),
570            (Vec::new(), single.clone(), "Invalid entity layout"),
571            (
572                vec!["site".to_string(), "device".to_string()],
573                single,
574                "has 1 components, but the table configures 2",
575            ),
576        ];
577
578        for (entity_columns, entity_layout, expected_message) in cases {
579            let (_tmp, store) = create_test_log_store();
580            let mut table_meta = sample_table_meta();
581            let TableKind::TimeSeries(index) = &mut table_meta.kind else {
582                unreachable!("sample metadata is time-series");
583            };
584            index.entity_columns = entity_columns.clone();
585            table_meta.logical_schema = Some(schema_for_entities(&entity_columns));
586
587            let mut segment = sample_segment("invalid-layout");
588            segment.entity_layout = entity_layout;
589            store
590                .commit_with_expected_version(
591                    0,
592                    vec![
593                        LogAction::UpdateTableMeta(table_meta),
594                        LogAction::AddSegment(segment),
595                    ],
596                )
597                .await?;
598
599            let error = store
600                .rebuild_table_state()
601                .await
602                .expect_err("inapplicable entity layout should be rejected");
603            assert!(matches!(error, CommitError::CorruptState { .. }));
604            assert!(error.to_string().contains(expected_message), "{error}");
605        }
606
607        Ok(())
608    }
609
610    #[tokio::test]
611    async fn rebuild_table_state_validates_persisted_entity_component_types() -> TestResult {
612        let typed_schema = LogicalSchema::new(vec![
613            LogicalField {
614                name: "ts".to_string(),
615                data_type: LogicalDataType::Timestamp {
616                    unit: LogicalTimestampUnit::Millis,
617                    timezone: None,
618                },
619                nullable: false,
620            },
621            LogicalField {
622                name: "symbol".to_string(),
623                data_type: LogicalDataType::Int32,
624                nullable: false,
625            },
626        ])?;
627        let mut typed_meta = sample_table_meta();
628        typed_meta.logical_schema = Some(typed_schema);
629        let mut typed_segment = sample_segment("typed-layout");
630        typed_segment.entity_layout = SegmentEntityLayout::Single(EntityIdentity::try_new(vec![
631            crate::coverage::EntityValue::Int32(-1),
632        ])?);
633
634        let (_valid_tmp, valid_store) = create_test_log_store();
635        valid_store
636            .commit_with_expected_version(
637                0,
638                vec![
639                    LogAction::UpdateTableMeta(typed_meta.clone()),
640                    LogAction::AddSegment(typed_segment.clone()),
641                ],
642            )
643            .await?;
644        valid_store.rebuild_table_state().await?;
645
646        let (_invalid_tmp, invalid_store) = create_test_log_store();
647        let mut string_meta = typed_meta;
648        string_meta.logical_schema = Some(schema_for_entities(&["symbol".to_string()]));
649        invalid_store
650            .commit_with_expected_version(
651                0,
652                vec![
653                    LogAction::UpdateTableMeta(string_meta),
654                    LogAction::AddSegment(typed_segment),
655                ],
656            )
657            .await?;
658        let error = invalid_store
659            .rebuild_table_state()
660            .await
661            .expect_err("persisted component type must match the logical schema");
662        assert!(matches!(error, CommitError::CorruptState { .. }));
663        assert!(
664            error
665                .to_string()
666                .contains("column symbol has type int32; expected utf8"),
667            "{error}"
668        );
669        Ok(())
670    }
671
672    #[tokio::test]
673    async fn rebuild_table_state_validates_removed_segment_layouts() -> TestResult {
674        let (_tmp, store) = create_test_log_store();
675        let mut segment = sample_segment("removed-invalid-layout");
676        segment.entity_layout = SegmentEntityLayout::NotApplicable;
677        let path = segment.path.clone();
678
679        store
680            .commit_with_expected_version(
681                0,
682                vec![
683                    LogAction::UpdateTableMeta(sample_table_meta()),
684                    LogAction::AddSegment(segment),
685                    LogAction::RemoveSegment { path },
686                ],
687            )
688            .await?;
689
690        let error = store
691            .rebuild_table_state()
692            .await
693            .expect_err("removed segment metadata should still be validated");
694        assert!(matches!(error, CommitError::CorruptState { .. }));
695        assert!(error.to_string().contains("Invalid entity layout"));
696        Ok(())
697    }
698
699    #[tokio::test]
700    async fn rebuild_table_state_requires_valid_entity_layout_json() -> TestResult {
701        for (replacement, expected_message) in [
702            (None, "entity_layout"),
703            (
704                Some(serde_json::json!({"Single": []})),
705                "at least one component",
706            ),
707        ] {
708            let (tmp, store) = create_test_log_store();
709            store
710                .commit_with_expected_version(
711                    0,
712                    vec![
713                        LogAction::UpdateTableMeta(sample_table_meta()),
714                        LogAction::AddSegment(sample_segment("invalid-json")),
715                    ],
716                )
717                .await?;
718
719            let commit_path = tmp.path().join(layout::commit_rel_path(1));
720            let mut commit: serde_json::Value =
721                serde_json::from_slice(&tokio::fs::read(&commit_path).await?)?;
722            let segment = commit["actions"][1]["AddSegment"]
723                .as_object_mut()
724                .expect("valid committed AddSegment action");
725            match replacement {
726                Some(layout) => {
727                    segment.insert("entity_layout".to_string(), layout);
728                }
729                None => {
730                    segment.remove("entity_layout");
731                }
732            }
733            tokio::fs::write(&commit_path, serde_json::to_vec(&commit)?).await?;
734
735            let error = store
736                .rebuild_table_state()
737                .await
738                .expect_err("missing or malformed entity layout should be rejected");
739            assert!(matches!(error, CommitError::CorruptState { .. }));
740            assert!(error.to_string().contains(expected_message), "{error}");
741        }
742
743        Ok(())
744    }
745
746    #[tokio::test]
747    async fn rebuild_table_state_rejects_noncanonical_segment_action_paths() -> TestResult {
748        for path in [
749            "",
750            "/data/seg.parquet",
751            "../data/seg.parquet",
752            "data/../seg.parquet",
753            r"data\seg.parquet",
754            "data//seg.parquet",
755            r"C:\data\seg.parquet",
756            "data/C:/seg.parquet",
757            "data/C:seg.parquet",
758        ] {
759            let mut segment = sample_segment("seg");
760            segment.path = path.to_owned();
761
762            for action in [
763                LogAction::AddSegment(segment.clone()),
764                LogAction::RemoveSegment {
765                    path: path.to_owned(),
766                },
767            ] {
768                let (_tmp, store) = create_test_log_store();
769                store
770                    .commit_with_expected_version(
771                        0,
772                        vec![LogAction::UpdateTableMeta(sample_table_meta()), action],
773                    )
774                    .await?;
775
776                let err = store
777                    .rebuild_table_state()
778                    .await
779                    .expect_err("noncanonical segment action path should be rejected");
780                assert!(matches!(err, CommitError::CorruptState { .. }));
781                assert!(err.to_string().contains("segment path"), "{err}");
782            }
783        }
784
785        Ok(())
786    }
787
788    #[tokio::test]
789    async fn rebuild_table_state_rejects_noncanonical_coverage_paths() -> TestResult {
790        for path in [
791            "",
792            "/tmp/coverage.roar",
793            "../coverage.roar",
794            "_coverage/../coverage.roar",
795            r"_coverage\segments\coverage.roar",
796            "_coverage//segments/coverage.roar",
797            r"C:\coverage.roar",
798        ] {
799            let mut segment = sample_segment("seg");
800            segment.coverage_path = Some(path.to_owned());
801
802            let index_kind = match sample_table_meta().kind {
803                TableKind::TimeSeries(index) => index.kind,
804                TableKind::Generic => unreachable!("sample metadata is time-series"),
805            };
806            for (description, action) in [
807                ("segment coverage path", LogAction::AddSegment(segment)),
808                (
809                    "table coverage path",
810                    LogAction::UpdateTableCoverage {
811                        index_kind,
812                        coverage_path: path.to_owned(),
813                    },
814                ),
815            ] {
816                let (_tmp, store) = create_test_log_store();
817                store
818                    .commit_with_expected_version(
819                        0,
820                        vec![LogAction::UpdateTableMeta(sample_table_meta()), action],
821                    )
822                    .await?;
823
824                let err = store
825                    .rebuild_table_state()
826                    .await
827                    .expect_err("noncanonical coverage path should be rejected");
828                assert!(matches!(err, CommitError::CorruptState { .. }));
829                assert!(err.to_string().contains(description), "{err}");
830            }
831        }
832
833        Ok(())
834    }
835
836    #[tokio::test]
837    async fn rebuild_table_state_rejects_mismatched_table_coverage_index() -> TestResult {
838        let (_tmp, store) = create_test_log_store();
839        store
840            .commit_with_expected_version(
841                0,
842                vec![
843                    LogAction::UpdateTableMeta(sample_table_meta()),
844                    LogAction::UpdateTableCoverage {
845                        index_kind: IndexKind::Int64 {
846                            bucket_width: std::num::NonZeroU64::new(1).unwrap(),
847                        },
848                        coverage_path: "_coverage/table/1-mismatched.roar".to_string(),
849                    },
850                ],
851            )
852            .await?;
853
854        let err = store
855            .rebuild_table_state()
856            .await
857            .expect_err("mismatched coverage index should be rejected during replay");
858        assert!(matches!(err, CommitError::CorruptState { .. }));
859        assert!(err.to_string().contains("Table coverage index kind"));
860        Ok(())
861    }
862
863    #[tokio::test]
864    async fn rebuild_table_state_fails_on_corrupt_commit_payload() -> TestResult {
865        let (tmp, store) = create_test_log_store();
866        let meta = sample_table_meta();
867
868        store
869            .commit_with_expected_version(0, vec![LogAction::UpdateTableMeta(meta)])
870            .await?;
871
872        let commit_path = tmp.path().join(layout::commit_rel_path(1));
873        tokio::fs::write(&commit_path, b"not-json").await?;
874
875        let err = store
876            .rebuild_table_state()
877            .await
878            .expect_err("expected error");
879        assert!(matches!(err, CommitError::CorruptState { .. }));
880        Ok(())
881    }
882
883    #[tokio::test]
884    async fn rebuild_table_state_fails_when_commit_missing() -> TestResult {
885        let (tmp, store) = create_test_log_store();
886        let meta = sample_table_meta();
887
888        store
889            .commit_with_expected_version(0, vec![LogAction::UpdateTableMeta(meta)])
890            .await?;
891
892        let commit_path = tmp.path().join(layout::commit_rel_path(1));
893        tokio::fs::remove_file(&commit_path).await?;
894
895        let err = store
896            .rebuild_table_state()
897            .await
898            .expect_err("expected error");
899        match err {
900            CommitError::Storage { source } => match source {
901                StorageError::NotFound { .. } => {}
902                other => panic!("unexpected storage error: {other:?}"),
903            },
904            other => panic!("expected storage error, got {other:?}"),
905        }
906        Ok(())
907    }
908}