1use 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")]
20pub fn rebuild_table_state_count() -> usize {
22 REBUILD_TABLE_STATE_COUNT.with(|c| c.get())
23}
24
25#[cfg(feature = "test-counters")]
26pub 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#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct TableCoveragePointer {
66 pub index_kind: IndexKind,
68 pub coverage_path: String,
70 pub version: u64,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct TableState {
82 pub version: u64,
84 pub table_meta: TableMeta,
86 pub segments: HashMap<String, SegmentMeta>,
88
89 pub table_coverage: Option<TableCoveragePointer>,
91}
92
93impl TableState {
94 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 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 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 for v in 1..=current_version {
136 let commit = self.load_commit(v).await?;
137
138 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 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}