1use std::collections::HashSet;
18use std::io::{self, Write};
19use std::time::Duration;
20
21use async_trait::async_trait;
22use clap::{Parser, Subcommand};
23use common_error::ext::BoxedError;
24use common_telemetry::info;
25use serde_json::Value;
26use snafu::ResultExt;
27
28use crate::Tool;
29use crate::common::ObjectStoreConfig;
30use crate::data::export_v2::coordinator::{ExportDataOptions, export_data};
31use crate::data::export_v2::error::{
32 ChunkTimeWindowRequiresBoundsSnafu, DatabaseSnafu, IoSnafu, ManifestVersionMismatchSnafu,
33 MetricExportRequiresParquetSnafu, Result, ResumeConfigMismatchSnafu,
34 SchemaOnlyArgsNotAllowedSnafu, SchemaOnlyModeMismatchSnafu, SnapshotVerifyFailedSnafu,
35 UnexpectedValueTypeSnafu,
36};
37use crate::data::export_v2::extractor::SchemaExtractor;
38use crate::data::export_v2::manifest::{
39 ChunkMeta, ChunkStatus, DataFormat, MANIFEST_FILE, MANIFEST_VERSION, Manifest, TimeRange,
40};
41use crate::data::export_v2::schema::{DDL_DIR, SCHEMA_DIR, SCHEMAS_FILE};
42use crate::data::path::{data_dir_for_schema_chunk, ddl_path_for_schema};
43use crate::data::progress::{ProgressMode, build_progress_reporter};
44use crate::data::schema_export::append_schema_ddl;
45use crate::data::snapshot_storage::{
46 OpenDalStorage, SnapshotStorage, validate_snapshot_uri, validate_uri,
47};
48use crate::data::sql::escape_sql_literal;
49use crate::database::{DatabaseClient, parse_proxy_opts};
50
51#[derive(Debug, Subcommand)]
53pub enum ExportV2Command {
54 Create(ExportCreateCommand),
56 List(ExportListCommand),
58 Verify(ExportVerifyCommand),
60 Delete(ExportDeleteCommand),
62}
63
64impl ExportV2Command {
65 pub async fn build(&self) -> std::result::Result<Box<dyn Tool>, BoxedError> {
66 match self {
67 ExportV2Command::Create(cmd) => cmd.build().await,
68 ExportV2Command::List(cmd) => cmd.build().await,
69 ExportV2Command::Verify(cmd) => cmd.build().await,
70 ExportV2Command::Delete(cmd) => cmd.build().await,
71 }
72 }
73}
74
75#[derive(Debug, Parser)]
77pub struct ExportListCommand {
78 #[clap(long)]
80 location: String,
81
82 #[clap(flatten)]
84 storage: ObjectStoreConfig,
85}
86
87impl ExportListCommand {
88 pub async fn build(&self) -> std::result::Result<Box<dyn Tool>, BoxedError> {
89 validate_uri(&self.location).map_err(BoxedError::new)?;
90 let storage = OpenDalStorage::from_parent_uri(&self.location, &self.storage)
91 .map_err(BoxedError::new)?;
92
93 Ok(Box::new(ExportList {
94 location: self.location.clone(),
95 storage,
96 }))
97 }
98}
99
100pub struct ExportList {
102 location: String,
103 storage: OpenDalStorage,
104}
105
106#[async_trait]
107impl Tool for ExportList {
108 async fn do_work(&self) -> std::result::Result<(), BoxedError> {
109 self.run().await.map_err(BoxedError::new)
110 }
111}
112
113impl ExportList {
114 async fn run(&self) -> Result<()> {
115 let result = scan_snapshots(&self.storage).await?;
116
117 println!("Scanning: {}", self.location);
118 if result.snapshots.is_empty() {
119 println!("No snapshots found.");
120 } else {
121 print_snapshot_list(&result.snapshots, result.unreadable.len());
122 }
123 print_unreadable_warnings(&result.unreadable);
124
125 Ok(())
126 }
127}
128
129#[derive(Debug, Parser)]
131pub struct ExportVerifyCommand {
132 #[clap(long)]
134 snapshot: String,
135
136 #[clap(flatten)]
138 storage: ObjectStoreConfig,
139}
140
141impl ExportVerifyCommand {
142 pub async fn build(&self) -> std::result::Result<Box<dyn Tool>, BoxedError> {
143 validate_uri(&self.snapshot).map_err(BoxedError::new)?;
144 let storage =
145 OpenDalStorage::from_uri(&self.snapshot, &self.storage).map_err(BoxedError::new)?;
146
147 Ok(Box::new(ExportVerify {
148 snapshot: self.snapshot.clone(),
149 storage,
150 }))
151 }
152}
153
154pub struct ExportVerify {
156 snapshot: String,
157 storage: OpenDalStorage,
158}
159
160#[async_trait]
161impl Tool for ExportVerify {
162 async fn do_work(&self) -> std::result::Result<(), BoxedError> {
163 self.run().await.map_err(BoxedError::new)
164 }
165}
166
167impl ExportVerify {
168 async fn run(&self) -> Result<()> {
169 let report = verify_snapshot(&self.storage).await?;
170 print_verify_report(&self.snapshot, &report);
171
172 if report.has_problems() {
173 return SnapshotVerifyFailedSnafu {
174 errors: report.error_count(),
175 warnings: report.warning_count(),
176 }
177 .fail();
178 }
179
180 Ok(())
181 }
182}
183
184#[derive(Debug, Parser)]
186pub struct ExportDeleteCommand {
187 #[clap(long)]
189 snapshot: String,
190
191 #[clap(long = "no-confirm", alias = "yes")]
193 skip_confirmation: bool,
194
195 #[clap(flatten)]
197 storage: ObjectStoreConfig,
198}
199
200impl ExportDeleteCommand {
201 pub async fn build(&self) -> std::result::Result<Box<dyn Tool>, BoxedError> {
202 validate_snapshot_uri(&self.snapshot).map_err(BoxedError::new)?;
203 let storage =
204 OpenDalStorage::from_uri(&self.snapshot, &self.storage).map_err(BoxedError::new)?;
205
206 Ok(Box::new(ExportDelete {
207 snapshot: self.snapshot.clone(),
208 skip_confirmation: self.skip_confirmation,
209 storage,
210 }))
211 }
212}
213
214pub struct ExportDelete {
216 snapshot: String,
217 skip_confirmation: bool,
218 storage: OpenDalStorage,
219}
220
221#[async_trait]
222impl Tool for ExportDelete {
223 async fn do_work(&self) -> std::result::Result<(), BoxedError> {
224 self.run().await.map_err(BoxedError::new)
225 }
226}
227
228impl ExportDelete {
229 async fn run(&self) -> Result<()> {
230 self.run_with_confirmation(confirm_delete).await
231 }
232
233 async fn run_with_confirmation<F>(&self, confirm: F) -> Result<()>
234 where
235 F: FnOnce(&str) -> Result<bool>,
236 {
237 let manifest = self.storage.read_manifest().await?;
238 print_delete_summary(&self.snapshot, &manifest);
239
240 if !self.skip_confirmation && !confirm(&self.snapshot)? {
241 println!("Deletion cancelled.");
242 return Ok(());
243 }
244
245 println!("Deleting snapshot...");
246 self.storage.delete_snapshot().await?;
247 println!("Snapshot deleted successfully.");
248
249 Ok(())
250 }
251}
252
253#[derive(Debug, Parser)]
255pub struct ExportCreateCommand {
256 #[clap(long)]
258 addr: String,
259
260 #[clap(long)]
262 to: String,
263
264 #[clap(long, default_value = "greptime")]
266 catalog: String,
267
268 #[clap(long, value_delimiter = ',')]
271 schemas: Vec<String>,
272
273 #[clap(long)]
275 schema_only: bool,
276
277 #[clap(long)]
279 start_time: Option<String>,
280
281 #[clap(long)]
283 end_time: Option<String>,
284
285 #[clap(long, value_parser = humantime::parse_duration)]
288 chunk_time_window: Option<Duration>,
289
290 #[clap(long, value_enum, default_value = "parquet")]
292 format: DataFormat,
293
294 #[clap(long)]
298 pack_metric_data: bool,
299
300 #[clap(long)]
302 force: bool,
303
304 #[clap(long, default_value = "1")]
306 parallelism: usize,
307
308 #[clap(long, default_value = "1", value_parser = parse_chunk_parallelism)]
310 chunk_parallelism: usize,
311
312 #[clap(long)]
314 auth_basic: Option<String>,
315
316 #[clap(long, value_parser = humantime::parse_duration)]
318 timeout: Option<Duration>,
319
320 #[clap(long)]
325 proxy: Option<String>,
326
327 #[clap(long)]
331 no_proxy: bool,
332
333 #[clap(long, value_enum, default_value_t = ProgressMode::Auto)]
335 progress: ProgressMode,
336
337 #[clap(flatten)]
339 storage: ObjectStoreConfig,
340}
341
342impl ExportCreateCommand {
343 pub async fn build(&self) -> std::result::Result<Box<dyn Tool>, BoxedError> {
344 validate_uri(&self.to).map_err(BoxedError::new)?;
346
347 let time_range = TimeRange::parse(self.start_time.as_deref(), self.end_time.as_deref())
348 .map_err(BoxedError::new)?;
349 if self.pack_metric_data && time_range.is_bounded() && time_range.start == time_range.end {
350 return crate::error::InvalidArgumentsSnafu {
351 msg: "Packed export requires --start-time to be earlier than --end-time",
352 }
353 .fail()
354 .map_err(BoxedError::new);
355 }
356 if self.chunk_time_window.is_some() && !time_range.is_bounded() {
357 return ChunkTimeWindowRequiresBoundsSnafu
358 .fail()
359 .map_err(BoxedError::new);
360 }
361 if self.schema_only {
362 let mut invalid_args = Vec::new();
363 if self.start_time.is_some() {
364 invalid_args.push("--start-time");
365 }
366 if self.end_time.is_some() {
367 invalid_args.push("--end-time");
368 }
369 if self.chunk_time_window.is_some() {
370 invalid_args.push("--chunk-time-window");
371 }
372 if self.format != DataFormat::Parquet {
373 invalid_args.push("--format");
374 }
375 if self.parallelism != 1 {
376 invalid_args.push("--parallelism");
377 }
378 if self.chunk_parallelism != 1 {
379 invalid_args.push("--chunk-parallelism");
380 }
381 if !invalid_args.is_empty() {
382 return SchemaOnlyArgsNotAllowedSnafu {
383 args: invalid_args.join(", "),
384 }
385 .fail()
386 .map_err(BoxedError::new);
387 }
388 }
389
390 let schemas = if self.schemas.is_empty() {
392 None
393 } else {
394 Some(self.schemas.clone())
395 };
396
397 let proxy = parse_proxy_opts(self.proxy.clone(), self.no_proxy)?;
399 let database_client = DatabaseClient::new(
400 self.addr.clone(),
401 self.catalog.clone(),
402 self.auth_basic.clone(),
403 self.timeout.unwrap_or(Duration::from_secs(60)),
404 proxy,
405 self.no_proxy,
406 );
407
408 if self.pack_metric_data {
410 require_metric_export_format(self.format).map_err(BoxedError::new)?;
411 if !self.schema_only {
412 database_client
413 .require_packed_export()
414 .await
415 .context(DatabaseSnafu)
416 .map_err(BoxedError::new)?;
417 }
418 }
419 let storage = OpenDalStorage::from_uri(&self.to, &self.storage).map_err(BoxedError::new)?;
420
421 Ok(Box::new(ExportCreate {
422 config: ExportConfig {
423 catalog: self.catalog.clone(),
424 schemas,
425 schema_only: self.schema_only,
426 format: self.format,
427 packed: self.pack_metric_data,
428 force: self.force,
429 time_range,
430 chunk_time_window: self.chunk_time_window,
431 parallelism: self.parallelism,
432 chunk_parallelism: self.chunk_parallelism,
433 progress: self.progress,
434 snapshot_uri: self.to.clone(),
435 storage_config: self.storage.clone(),
436 },
437 storage: Box::new(storage),
438 database_client,
439 }))
440 }
441}
442
443pub struct ExportCreate {
445 config: ExportConfig,
446 storage: Box<dyn SnapshotStorage>,
447 database_client: DatabaseClient,
448}
449
450struct ExportConfig {
451 catalog: String,
452 schemas: Option<Vec<String>>,
453 schema_only: bool,
454 format: DataFormat,
455 packed: bool,
456 force: bool,
457 time_range: TimeRange,
458 chunk_time_window: Option<Duration>,
459 parallelism: usize,
460 chunk_parallelism: usize,
461 progress: ProgressMode,
462 snapshot_uri: String,
463 storage_config: ObjectStoreConfig,
464}
465
466fn parse_chunk_parallelism(value: &str) -> std::result::Result<usize, String> {
467 let parallelism = value
468 .parse::<usize>()
469 .map_err(|_| "chunk parallelism must be an integer between 1 and 64".to_string())?;
470 if (1..=64).contains(¶llelism) {
471 Ok(parallelism)
472 } else {
473 Err("chunk parallelism must be between 1 and 64".to_string())
474 }
475}
476
477#[async_trait]
478impl Tool for ExportCreate {
479 async fn do_work(&self) -> std::result::Result<(), BoxedError> {
480 self.run().await.map_err(BoxedError::new)
481 }
482}
483
484impl ExportCreate {
485 async fn run(&self) -> Result<()> {
486 let exists = self.storage.exists().await?;
488
489 if exists {
490 if self.config.force {
491 info!("Deleting existing snapshot (--force)");
492 self.storage.delete_snapshot().await?;
493 } else {
494 let mut manifest = self.storage.read_manifest().await?;
496
497 if manifest.validate_layout().is_err() {
499 return ManifestVersionMismatchSnafu {
500 expected: MANIFEST_VERSION,
501 found: manifest.version,
502 }
503 .fail();
504 }
505
506 validate_resume_config(&manifest, &self.config)?;
507 if manifest.is_packed() {
508 require_metric_export_format(manifest.format)?;
509 self.database_client
510 .require_packed_export()
511 .await
512 .context(DatabaseSnafu)?;
513 }
514
515 info!(
516 "Resuming existing snapshot: {} (completed: {}/{} chunks)",
517 manifest.snapshot_id,
518 manifest.completed_count(),
519 manifest.chunks.len()
520 );
521
522 if manifest.is_complete() {
523 info!("Snapshot is already complete");
524 return Ok(());
525 }
526
527 if manifest.schema_only {
528 return Ok(());
529 }
530
531 let progress = build_progress_reporter(self.config.progress);
532 export_data(
533 self.storage.as_ref(),
534 &self.database_client,
535 &mut manifest,
536 ExportDataOptions {
537 snapshot_uri: &self.config.snapshot_uri,
538 storage_config: &self.config.storage_config,
539 parallelism: self.config.parallelism,
540 chunk_parallelism: self.config.chunk_parallelism,
541 resume: true,
542 },
543 progress.as_ref(),
544 )
545 .await?;
546 return Ok(());
547 }
548 }
549
550 let extractor = SchemaExtractor::new(&self.database_client, &self.config.catalog);
552 let schema_snapshot = extractor.extract(self.config.schemas.as_deref()).await?;
553
554 let schema_names: Vec<String> = schema_snapshot
555 .schemas
556 .iter()
557 .map(|s| s.name.clone())
558 .collect();
559 info!("Exporting schemas: {:?}", schema_names);
560
561 let mut manifest = Manifest::new_for_export(
563 self.config.catalog.clone(),
564 schema_names.clone(),
565 self.config.schema_only,
566 self.config.time_range.clone(),
567 self.config.format,
568 self.config.chunk_time_window,
569 )?;
570
571 if self.config.packed && !self.config.schema_only {
572 manifest.version = 2;
573 manifest.data_layout = Some(common_datasource::packed_snapshot::PACKED_LAYOUT.into());
574 }
575 if manifest.is_packed() {
576 for chunk in &manifest.chunks {
577 self.storage
578 .prepare_export_chunk(&schema_names, chunk.id, false, manifest.is_packed())
579 .await?;
580 }
581 }
582
583 self.storage.write_schema(&schema_snapshot).await?;
585 info!("Exported {} schemas", schema_snapshot.schemas.len());
586
587 let mut sorted_schemas = schema_names;
589 sorted_schemas.sort();
590 for schema in sorted_schemas {
591 let ddl = self.build_schema_ddl(&schema).await?;
592 let ddl_path = ddl_path_for_schema(&schema);
593 self.storage.write_text(&ddl_path, &ddl).await?;
594 info!("Exported DDL for schema {} to {}", schema, ddl_path);
595 }
596
597 self.storage.write_manifest(&manifest).await?;
605 info!("Snapshot created: {}", manifest.snapshot_id);
606
607 if !self.config.schema_only {
608 let progress = build_progress_reporter(self.config.progress);
609 export_data(
610 self.storage.as_ref(),
611 &self.database_client,
612 &mut manifest,
613 ExportDataOptions {
614 snapshot_uri: &self.config.snapshot_uri,
615 storage_config: &self.config.storage_config,
616 parallelism: self.config.parallelism,
617 chunk_parallelism: self.config.chunk_parallelism,
618 resume: false,
619 },
620 progress.as_ref(),
621 )
622 .await?;
623 }
624
625 Ok(())
626 }
627
628 async fn build_schema_ddl(&self, schema: &str) -> Result<String> {
629 let (mut physical_tables, mut tables, mut views) = self.get_schema_objects(schema).await?;
630 physical_tables.sort();
631 tables.sort();
632 views.sort();
633 let objects = std::iter::once(("DATABASE", None))
634 .chain(
635 physical_tables
636 .iter()
637 .chain(&tables)
638 .map(|table| ("TABLE", Some(table.as_str()))),
639 )
640 .chain(views.iter().map(|view| ("VIEW", Some(view.as_str()))));
641 let mut ddl = format!("-- Schema: {schema}\n");
642 append_schema_ddl(
643 &self.database_client,
644 &self.config.catalog,
645 schema,
646 objects,
647 &mut ddl,
648 )
649 .await
650 .context(DatabaseSnafu)?;
651 ddl.push('\n');
652 Ok(ddl)
653 }
654
655 async fn get_schema_objects(
656 &self,
657 schema: &str,
658 ) -> Result<(Vec<String>, Vec<String>, Vec<String>)> {
659 let physical_tables = self.get_metric_physical_tables(schema).await?;
660 let physical_set: HashSet<&str> = physical_tables.iter().map(String::as_str).collect();
661 let sql = format!(
662 "SELECT table_name, table_type FROM information_schema.tables \
663 WHERE table_catalog = '{}' AND table_schema = '{}' \
664 AND (table_type = 'BASE TABLE' OR table_type = 'VIEW')",
665 escape_sql_literal(&self.config.catalog),
666 escape_sql_literal(schema)
667 );
668 let records: Option<Vec<Vec<Value>>> = self
669 .database_client
670 .sql_in_public(&sql)
671 .await
672 .context(DatabaseSnafu)?;
673
674 let mut tables = Vec::new();
675 let mut views = Vec::new();
676 if let Some(rows) = records {
677 for row in rows {
678 let name = match row.first() {
679 Some(Value::String(name)) => name.clone(),
680 _ => return UnexpectedValueTypeSnafu.fail(),
681 };
682 let table_type = match row.get(1) {
683 Some(Value::String(table_type)) => table_type.as_str(),
684 _ => return UnexpectedValueTypeSnafu.fail(),
685 };
686 if !physical_set.contains(name.as_str()) {
687 if table_type == "VIEW" {
688 views.push(name);
689 } else {
690 tables.push(name);
691 }
692 }
693 }
694 }
695
696 Ok((physical_tables, tables, views))
697 }
698
699 async fn get_metric_physical_tables(&self, schema: &str) -> Result<Vec<String>> {
700 let sql = format!(
701 "SELECT DISTINCT table_name FROM information_schema.columns \
702 WHERE table_catalog = '{}' AND table_schema = '{}' AND column_name = '__tsid'",
703 escape_sql_literal(&self.config.catalog),
704 escape_sql_literal(schema)
705 );
706 let records: Option<Vec<Vec<Value>>> = self
707 .database_client
708 .sql_in_public(&sql)
709 .await
710 .context(DatabaseSnafu)?;
711
712 let mut tables = HashSet::new();
713 if let Some(rows) = records {
714 for row in rows {
715 let name = match row.first() {
716 Some(Value::String(name)) => name.clone(),
717 _ => return UnexpectedValueTypeSnafu.fail(),
718 };
719 tables.insert(name);
720 }
721 }
722
723 Ok(tables.into_iter().collect())
724 }
725}
726
727fn require_metric_export_format(format: DataFormat) -> Result<()> {
728 if format != DataFormat::Parquet {
729 return MetricExportRequiresParquetSnafu.fail();
730 }
731 Ok(())
732}
733
734fn validate_resume_config(manifest: &Manifest, config: &ExportConfig) -> Result<()> {
735 if config.packed && !manifest.schema_only && !manifest.is_packed() {
736 return ResumeConfigMismatchSnafu {
737 field: "data_layout",
738 existing: "standalone".to_string(),
739 requested: "packed".to_string(),
740 }
741 .fail();
742 }
743 if manifest.schema_only != config.schema_only {
744 return SchemaOnlyModeMismatchSnafu {
745 existing_schema_only: manifest.schema_only,
746 requested_schema_only: config.schema_only,
747 }
748 .fail();
749 }
750
751 if manifest.catalog != config.catalog {
752 return ResumeConfigMismatchSnafu {
753 field: "catalog",
754 existing: manifest.catalog.clone(),
755 requested: config.catalog.clone(),
756 }
757 .fail();
758 }
759
760 if let Some(requested_schemas) = &config.schemas
763 && !schema_selection_matches(&manifest.schemas, requested_schemas)
764 {
765 return ResumeConfigMismatchSnafu {
766 field: "schemas",
767 existing: format_schema_selection(&manifest.schemas),
768 requested: format_schema_selection(requested_schemas),
769 }
770 .fail();
771 }
772
773 if manifest.time_range != config.time_range {
774 return ResumeConfigMismatchSnafu {
775 field: "time_range",
776 existing: format!("{:?}", manifest.time_range),
777 requested: format!("{:?}", config.time_range),
778 }
779 .fail();
780 }
781
782 if manifest.format != config.format {
783 return ResumeConfigMismatchSnafu {
784 field: "format",
785 existing: manifest.format.to_string(),
786 requested: config.format.to_string(),
787 }
788 .fail();
789 }
790
791 let expected_plan = Manifest::new_for_export(
792 manifest.catalog.clone(),
793 manifest.schemas.clone(),
794 config.schema_only,
795 config.time_range.clone(),
796 config.format,
797 config.chunk_time_window,
798 )?;
799 if !chunk_plan_matches(manifest, &expected_plan) {
800 return ResumeConfigMismatchSnafu {
801 field: "chunk plan",
802 existing: format_chunk_plan(&manifest.chunks),
803 requested: format_chunk_plan(&expected_plan.chunks),
804 }
805 .fail();
806 }
807
808 Ok(())
809}
810
811fn schema_selection_matches(existing: &[String], requested: &[String]) -> bool {
812 canonical_schema_selection(existing) == canonical_schema_selection(requested)
813}
814
815fn canonical_schema_selection(schemas: &[String]) -> Vec<String> {
816 let mut canonicalized = Vec::new();
817 let mut seen = HashSet::new();
818
819 for schema in schemas {
820 let normalized = schema.to_ascii_lowercase();
821 if seen.insert(normalized.clone()) {
822 canonicalized.push(normalized);
823 }
824 }
825
826 canonicalized.sort();
827 canonicalized
828}
829
830fn format_schema_selection(schemas: &[String]) -> String {
831 format!("[{}]", schemas.join(", "))
832}
833
834fn chunk_plan_matches(existing: &Manifest, expected: &Manifest) -> bool {
835 existing.chunks.len() == expected.chunks.len()
836 && existing
837 .chunks
838 .iter()
839 .zip(&expected.chunks)
840 .all(|(left, right)| left.id == right.id && left.time_range == right.time_range)
841}
842
843fn format_chunk_plan(chunks: &[ChunkMeta]) -> String {
844 let items = chunks
845 .iter()
846 .map(|chunk| format!("#{}:{:?}", chunk.id, chunk.time_range))
847 .collect::<Vec<_>>();
848 format!("[{}]", items.join(", "))
849}
850
851#[derive(Debug)]
852struct SnapshotListEntry {
853 path: String,
854 manifest: Manifest,
855}
856
857#[derive(Debug, Default)]
858struct SnapshotScanResult {
859 snapshots: Vec<SnapshotListEntry>,
860 unreadable: Vec<String>,
861}
862
863async fn scan_snapshots(storage: &OpenDalStorage) -> Result<SnapshotScanResult> {
864 let mut result = SnapshotScanResult::default();
865 for dir in storage.list_direct_child_dirs().await? {
866 let manifest_path = format!("{}/{}", dir.trim_matches('/'), MANIFEST_FILE);
867 let Some(data) = storage.read_file_if_exists(&manifest_path).await? else {
868 continue;
869 };
870
871 match serde_json::from_slice::<Manifest>(&data) {
872 Ok(manifest) => result.snapshots.push(SnapshotListEntry {
873 path: format!("{}/", dir.trim_matches('/')),
874 manifest,
875 }),
876 Err(_) => result
877 .unreadable
878 .push(format!("{}/", dir.trim_matches('/'))),
879 }
880 }
881
882 result
883 .snapshots
884 .sort_by_key(|entry| std::cmp::Reverse(entry.manifest.created_at));
885 result.unreadable.sort();
886 Ok(result)
887}
888
889fn print_snapshot_list(snapshots: &[SnapshotListEntry], unreadable_count: usize) {
890 if unreadable_count == 0 {
891 println!("Found {} snapshots:", snapshots.len());
892 } else {
893 println!(
894 "Found {} snapshots ({} {} skipped: unreadable manifest):",
895 snapshots.len(),
896 unreadable_count,
897 directory_word(unreadable_count)
898 );
899 }
900 println!();
901 println!(
902 " {:<24} {:<36} {:<19} {:<9} {:<7} {:<6} Status",
903 "Path", "ID", "Created", "Catalog", "Schemas", "Chunks"
904 );
905 println!(
906 " {:<24} {:<36} {:<19} {:<9} {:<7} {:<6} {:<10}",
907 "-".repeat(24),
908 "-".repeat(36),
909 "-".repeat(19),
910 "-".repeat(9),
911 "-".repeat(7),
912 "-".repeat(6),
913 "-".repeat(10)
914 );
915 for entry in snapshots {
916 let manifest = &entry.manifest;
917 println!(
918 " {:<24} {:<36} {:<19} {:<9} {:<7} {:<6} {}",
919 entry.path,
920 manifest.snapshot_id,
921 manifest.created_at.format("%Y-%m-%d %H:%M:%S"),
922 manifest.catalog,
923 manifest.schemas.len(),
924 format_list_chunks(manifest),
925 snapshot_status(manifest)
926 );
927 }
928}
929
930fn print_unreadable_warnings(unreadable: &[String]) {
931 if unreadable.is_empty() {
932 return;
933 }
934
935 println!();
936 println!(
937 "Warning: {} {} had corrupt/unreadable manifest.json:",
938 unreadable.len(),
939 directory_word(unreadable.len())
940 );
941 for path in unreadable {
942 println!(" - {}", path);
943 }
944}
945
946fn directory_word(count: usize) -> &'static str {
947 if count == 1 {
948 "directory"
949 } else {
950 "directories"
951 }
952}
953
954fn snapshot_status(manifest: &Manifest) -> &'static str {
955 if manifest.validate_layout().is_err() {
956 "unsupported"
957 } else if manifest.schema_only {
958 "schema-only"
959 } else if manifest.is_complete() {
960 "complete"
961 } else {
962 "incomplete"
963 }
964}
965
966fn format_list_chunks(manifest: &Manifest) -> String {
967 let total = manifest.chunks.len();
968 if total == 0 {
969 return "0".to_string();
970 }
971
972 format!(
973 "{}/{}",
974 manifest.completed_count() + manifest.skipped_count(),
975 total
976 )
977}
978
979#[derive(Debug, Clone, Copy, PartialEq, Eq)]
980enum VerifySeverity {
981 Error,
982 Warn,
983}
984
985impl VerifySeverity {
986 fn as_str(self) -> &'static str {
987 match self {
988 VerifySeverity::Error => "ERROR",
989 VerifySeverity::Warn => "WARN",
990 }
991 }
992}
993
994#[derive(Debug)]
995struct VerifyProblem {
996 severity: VerifySeverity,
997 message: String,
998}
999
1000#[derive(Debug, Default)]
1001struct VerifyChunkSummary {
1002 total: usize,
1003 completed: usize,
1004 skipped: usize,
1005 pending: usize,
1006 in_progress: usize,
1007 failed: usize,
1008}
1009
1010#[derive(Debug)]
1011struct VerifyReport {
1012 manifest: Manifest,
1013 schema_index_exists: bool,
1014 ddl_file_count: usize,
1015 chunk_summary: VerifyChunkSummary,
1016 data_files_total: usize,
1017 data_files_verified: usize,
1018 problems: Vec<VerifyProblem>,
1019}
1020
1021impl VerifyReport {
1022 fn error_count(&self) -> usize {
1023 self.problems
1024 .iter()
1025 .filter(|problem| problem.severity == VerifySeverity::Error)
1026 .count()
1027 }
1028
1029 fn warning_count(&self) -> usize {
1030 self.problems
1031 .iter()
1032 .filter(|problem| problem.severity == VerifySeverity::Warn)
1033 .count()
1034 }
1035
1036 fn has_problems(&self) -> bool {
1037 !self.problems.is_empty()
1038 }
1039
1040 fn push_error(&mut self, message: impl Into<String>) {
1041 self.problems.push(VerifyProblem {
1042 severity: VerifySeverity::Error,
1043 message: message.into(),
1044 });
1045 }
1046
1047 fn push_warn(&mut self, message: impl Into<String>) {
1048 self.problems.push(VerifyProblem {
1049 severity: VerifySeverity::Warn,
1050 message: message.into(),
1051 });
1052 }
1053}
1054
1055async fn verify_snapshot(storage: &OpenDalStorage) -> Result<VerifyReport> {
1056 let manifest = storage.read_manifest().await?;
1057 let schema_index_path = format!("{}/{}", SCHEMA_DIR, SCHEMAS_FILE);
1058 let ddl_prefix = format!("{}/{}/", SCHEMA_DIR, DDL_DIR);
1059 let schema_index_exists = storage.file_exists(&schema_index_path).await?;
1060 let ddl_files: HashSet<_> = storage
1061 .list_files_recursive(&ddl_prefix)
1062 .await?
1063 .into_iter()
1064 .collect();
1065 let ddl_file_count = ddl_files
1066 .iter()
1067 .filter(|path| path.ends_with(".sql"))
1068 .count();
1069
1070 let mut report = VerifyReport {
1071 manifest,
1072 schema_index_exists,
1073 ddl_file_count,
1074 chunk_summary: VerifyChunkSummary::default(),
1075 data_files_total: 0,
1076 data_files_verified: 0,
1077 problems: Vec::new(),
1078 };
1079
1080 if let Err(reason) = report.manifest.validate_layout() {
1081 report.push_error(reason);
1082 } else if report.manifest.is_packed()
1083 && let Err(error) = crate::data::import_v2::packed::validate_snapshot(
1084 storage,
1085 &report.manifest,
1086 &report.manifest.schemas,
1087 )
1088 .await
1089 {
1090 report.push_error(error.to_string());
1091 }
1092
1093 if !report.schema_index_exists {
1094 report.push_warn(format!("Missing schema index '{}'", schema_index_path));
1095 }
1096
1097 for schema in &report.manifest.schemas {
1098 let ddl_path = ddl_path_for_schema(schema);
1099 if !ddl_files.contains(ddl_path.as_str()) {
1100 report.problems.push(VerifyProblem {
1101 severity: VerifySeverity::Error,
1102 message: format!("Schema '{}': missing DDL file '{}'", schema, ddl_path),
1103 });
1104 }
1105 }
1106
1107 report.chunk_summary = summarize_chunks(&report.manifest);
1108 if report.manifest.schema_only {
1109 let chunk_count = report.manifest.chunks.len();
1110 if chunk_count > 0 {
1111 report.push_error(format!(
1112 "Schema-only snapshot should not contain data chunks (found {})",
1113 chunk_count
1114 ));
1115 }
1116 let mut first_data_file: Option<String> = None;
1117 storage
1118 .for_each_file_recursive("data/", |path| {
1119 let should_update = match &first_data_file {
1120 Some(current) => path.as_str() < current.as_str(),
1121 None => true,
1122 };
1123 if should_update {
1124 first_data_file = Some(path);
1125 }
1126 Ok(())
1127 })
1128 .await?;
1129 if let Some(path) = first_data_file {
1130 report.push_error(format!(
1131 "Schema-only snapshot should not contain data files (found '{}')",
1132 path
1133 ));
1134 }
1135 } else if report.manifest.chunks.is_empty() {
1136 report.push_error("Full snapshot should contain at least one data chunk");
1137 } else {
1138 verify_chunks_and_data_files(storage, &mut report).await?;
1139 }
1140
1141 Ok(report)
1142}
1143
1144fn summarize_chunks(manifest: &Manifest) -> VerifyChunkSummary {
1145 VerifyChunkSummary {
1146 total: manifest.chunks.len(),
1147 completed: manifest.completed_count(),
1148 skipped: manifest.skipped_count(),
1149 pending: manifest.pending_count(),
1150 in_progress: manifest.in_progress_count(),
1151 failed: manifest.failed_count(),
1152 }
1153}
1154
1155#[derive(Debug)]
1157struct ChunkFile {
1158 chunk_id: u32,
1159 path: String,
1160}
1161
1162#[derive(Debug, Default)]
1167struct VerifyPlan {
1168 files_to_check: Vec<ChunkFile>,
1170 claimed_data_files: HashSet<String>,
1174 data_files_total: usize,
1176 problems: Vec<VerifyProblem>,
1178}
1179
1180#[derive(Debug)]
1183struct VerifyDataScan {
1184 existing_claimed_data_files: HashSet<String>,
1185 unexpected_data_files: Vec<String>,
1186}
1187
1188#[derive(Debug, Default)]
1190struct VerifyOutcome {
1191 data_files_total: usize,
1192 data_files_verified: usize,
1193 problems: Vec<VerifyProblem>,
1194}
1195
1196async fn verify_chunks_and_data_files(
1197 storage: &OpenDalStorage,
1198 report: &mut VerifyReport,
1199) -> Result<()> {
1200 let plan = build_verify_plan(&report.manifest);
1201 let scan = scan_data_files(storage, &plan).await?;
1202 let outcome = reconcile_plan_with_scan(plan, scan);
1203
1204 report.data_files_total = outcome.data_files_total;
1205 report.data_files_verified = outcome.data_files_verified;
1206 report.problems.extend(outcome.problems);
1207
1208 Ok(())
1209}
1210
1211fn build_verify_plan(manifest: &Manifest) -> VerifyPlan {
1213 let mut plan = VerifyPlan::default();
1214 let mut seen_chunk_ids = HashSet::new();
1215
1216 for chunk in &manifest.chunks {
1217 if !seen_chunk_ids.insert(chunk.id) {
1218 plan.problems.push(VerifyProblem {
1219 severity: VerifySeverity::Error,
1220 message: format!("Chunk {}: duplicate chunk id", chunk.id),
1221 });
1222 }
1223 for file in &chunk.files {
1224 if let Some(path) = safe_manifest_data_file_path(file) {
1225 plan.claimed_data_files.insert(path.to_string());
1226 }
1227 }
1228
1229 match chunk.status {
1230 ChunkStatus::Completed => {
1231 if chunk.files.is_empty() {
1232 plan.problems.push(VerifyProblem {
1233 severity: VerifySeverity::Error,
1234 message: format!("Chunk {}: completed chunk has no data files", chunk.id),
1235 });
1236 continue;
1237 }
1238 let allowed_prefixes = manifest
1239 .schemas
1240 .iter()
1241 .map(|schema| data_dir_for_schema_chunk(schema, chunk.id))
1242 .collect::<Vec<_>>();
1243 for file in &chunk.files {
1244 plan.data_files_total += 1;
1245 match valid_manifest_data_file_path(file, &allowed_prefixes) {
1246 Some(path) => plan.files_to_check.push(ChunkFile {
1247 chunk_id: chunk.id,
1248 path: path.to_string(),
1249 }),
1250 None => plan.problems.push(VerifyProblem {
1251 severity: VerifySeverity::Error,
1252 message: format!(
1253 "Chunk {}: invalid data file path '{}'",
1254 chunk.id, file
1255 ),
1256 }),
1257 }
1258 }
1259 }
1260 ChunkStatus::Skipped => {
1261 if !chunk.files.is_empty() {
1262 plan.problems.push(VerifyProblem {
1263 severity: VerifySeverity::Error,
1264 message: format!(
1265 "Chunk {}: skipped chunk should not list data files",
1266 chunk.id
1267 ),
1268 });
1269 }
1270 }
1271 ChunkStatus::Pending => {
1272 plan.problems.push(VerifyProblem {
1273 severity: VerifySeverity::Error,
1274 message: format!("Chunk {}: status is 'pending'", chunk.id),
1275 });
1276 }
1277 ChunkStatus::InProgress => {
1278 plan.problems.push(VerifyProblem {
1279 severity: VerifySeverity::Error,
1280 message: format!("Chunk {}: status is 'in_progress'", chunk.id),
1281 });
1282 }
1283 ChunkStatus::Failed => {
1284 let reason = chunk.error.as_deref().unwrap_or("unknown error");
1285 plan.problems.push(VerifyProblem {
1286 severity: VerifySeverity::Error,
1287 message: format!("Chunk {}: status is 'failed' (error: {})", chunk.id, reason),
1288 });
1289 }
1290 }
1291 }
1292
1293 plan
1294}
1295
1296async fn scan_data_files(storage: &OpenDalStorage, plan: &VerifyPlan) -> Result<VerifyDataScan> {
1298 let mut scan = VerifyDataScan {
1299 existing_claimed_data_files: HashSet::new(),
1300 unexpected_data_files: Vec::new(),
1301 };
1302
1303 storage
1304 .for_each_file_recursive("data/", |path| {
1305 if plan.claimed_data_files.contains(&path) {
1306 scan.existing_claimed_data_files.insert(path);
1307 } else {
1308 scan.unexpected_data_files.push(path);
1309 }
1310 Ok(())
1311 })
1312 .await?;
1313
1314 Ok(scan)
1315}
1316
1317fn reconcile_plan_with_scan(plan: VerifyPlan, mut scan: VerifyDataScan) -> VerifyOutcome {
1323 let mut problems = plan.problems;
1324 let mut data_files_verified = 0;
1325
1326 for file in &plan.files_to_check {
1327 if scan.existing_claimed_data_files.contains(&file.path) {
1328 data_files_verified += 1;
1329 } else {
1330 problems.push(VerifyProblem {
1331 severity: VerifySeverity::Error,
1332 message: format!("Chunk {}: missing file '{}'", file.chunk_id, file.path),
1333 });
1334 }
1335 }
1336
1337 scan.unexpected_data_files.sort();
1338 for path in scan.unexpected_data_files {
1339 problems.push(VerifyProblem {
1340 severity: VerifySeverity::Error,
1341 message: format!("Unexpected data file '{}' is not listed in manifest", path),
1342 });
1343 }
1344
1345 VerifyOutcome {
1346 data_files_total: plan.data_files_total,
1347 data_files_verified,
1348 problems,
1349 }
1350}
1351
1352fn valid_manifest_data_file_path<'a>(
1353 path: &'a str,
1354 allowed_prefixes: &[String],
1355) -> Option<&'a str> {
1356 let normalized = safe_manifest_data_file_path(path)?;
1357
1358 if !allowed_prefixes
1359 .iter()
1360 .any(|prefix| normalized.starts_with(prefix))
1361 {
1362 return None;
1363 }
1364
1365 Some(normalized)
1366}
1367
1368fn safe_manifest_data_file_path(path: &str) -> Option<&str> {
1369 let normalized = path.trim_start_matches('/');
1370 if normalized.is_empty() || !normalized.starts_with("data/") {
1371 return None;
1372 }
1373
1374 if normalized
1375 .split('/')
1376 .any(|segment| segment.is_empty() || segment == "." || segment == "..")
1377 {
1378 return None;
1379 }
1380
1381 Some(normalized)
1382}
1383
1384fn print_verify_report(snapshot: &str, report: &VerifyReport) {
1385 println!("Verifying snapshot: {}", report.manifest.snapshot_id);
1386 println!(" Location: {}", snapshot);
1387 if report.manifest.validate_layout().is_ok() {
1388 println!(" Manifest: OK (version {})", report.manifest.version);
1389 } else {
1390 println!(
1391 " Manifest: ERROR (version {}, expected {})",
1392 report.manifest.version, MANIFEST_VERSION
1393 );
1394 }
1395 println!(
1396 " Schema files: {}",
1397 if report.schema_index_exists {
1398 format!("OK ({})", SCHEMAS_FILE)
1399 } else {
1400 format!("WARN (missing {})", SCHEMAS_FILE)
1401 }
1402 );
1403 if report.ddl_file_count > 0 {
1404 println!(" DDL files: {} file(s) found", report.ddl_file_count);
1405 } else {
1406 println!(" DDL files: not present");
1407 }
1408
1409 let chunks = &report.chunk_summary;
1410 println!(
1411 " Chunks: {} total ({} completed, {} skipped, {} pending, {} in_progress, {} failed)",
1412 chunks.total,
1413 chunks.completed,
1414 chunks.skipped,
1415 chunks.pending,
1416 chunks.in_progress,
1417 chunks.failed
1418 );
1419
1420 if report.manifest.schema_only {
1421 println!(" Data files: skipped (schema-only)");
1422 } else {
1423 println!(
1424 " Data files: {}/{} files verified",
1425 report.data_files_verified, report.data_files_total
1426 );
1427 }
1428
1429 if report.problems.is_empty() {
1430 println!();
1431 println!("Snapshot is valid.");
1432 return;
1433 }
1434
1435 println!();
1436 println!("Problems found:");
1437 for problem in &report.problems {
1438 println!(" [{}] {}", problem.severity.as_str(), problem.message);
1439 }
1440 println!();
1441 println!(
1442 "Snapshot has {} error(s), {} warning(s).",
1443 report.error_count(),
1444 report.warning_count()
1445 );
1446}
1447
1448fn print_delete_summary(snapshot: &str, manifest: &Manifest) {
1449 println!("Snapshot: {}", manifest.snapshot_id);
1450 println!(" Location: {}", snapshot);
1451 println!(
1452 " Created: {} UTC",
1453 manifest.created_at.format("%Y-%m-%d %H:%M:%S")
1454 );
1455 println!(" Catalog: {}", manifest.catalog);
1456 println!(" Schemas: {}", manifest.schemas.join(", "));
1457 println!(" Chunks: {}", format_delete_chunks(manifest));
1458}
1459
1460fn format_delete_chunks(manifest: &Manifest) -> String {
1461 if manifest.schema_only {
1462 return "0 (schema-only)".to_string();
1463 }
1464
1465 let summary = summarize_chunks(manifest);
1466 if manifest.is_complete() {
1467 format!("{} (all processed)", summary.total)
1468 } else {
1469 format!(
1470 "{} ({} completed, {} skipped, {} pending, {} in_progress, {} failed)",
1471 summary.total,
1472 summary.completed,
1473 summary.skipped,
1474 summary.pending,
1475 summary.in_progress,
1476 summary.failed
1477 )
1478 }
1479}
1480
1481fn confirm_delete(snapshot: &str) -> Result<bool> {
1482 println!();
1483 println!(
1484 "Warning: this removes the entire snapshot directory/prefix, not only files listed in manifest."
1485 );
1486 println!("This will permanently delete all data under:");
1487 println!(" {}", display_snapshot_prefix(snapshot));
1488 print!("Type 'yes' to confirm deletion: ");
1489 io::stdout().flush().map_err(|error| {
1490 IoSnafu {
1491 operation: "flushing delete confirmation prompt",
1492 error,
1493 }
1494 .build()
1495 })?;
1496
1497 let mut input = String::new();
1498 io::stdin().read_line(&mut input).map_err(|error| {
1499 IoSnafu {
1500 operation: "reading delete confirmation",
1501 error,
1502 }
1503 .build()
1504 })?;
1505
1506 Ok(delete_confirmation_matches(&input))
1507}
1508
1509fn delete_confirmation_matches(input: &str) -> bool {
1510 input.trim() == "yes"
1511}
1512
1513fn display_snapshot_prefix(snapshot: &str) -> String {
1514 if snapshot.ends_with('/') {
1515 snapshot.to_string()
1516 } else {
1517 format!("{}/", snapshot)
1518 }
1519}
1520
1521#[cfg(test)]
1522mod tests {
1523 use chrono::TimeZone;
1524 use clap::Parser;
1525 use tempfile::tempdir;
1526 use url::Url;
1527
1528 use super::*;
1529 use crate::data::path::ddl_path_for_schema;
1530
1531 #[test]
1532 fn test_ddl_path_for_schema() {
1533 assert_eq!(ddl_path_for_schema("public"), "schema/ddl/public.sql");
1534 assert_eq!(
1535 ddl_path_for_schema("../evil"),
1536 "schema/ddl/%2E%2E%2Fevil.sql"
1537 );
1538 }
1539
1540 #[tokio::test]
1541 async fn test_build_rejects_chunk_window_without_bounds() {
1542 let cmd = ExportCreateCommand::parse_from([
1543 "export-v2-create",
1544 "--addr",
1545 "127.0.0.1:4000",
1546 "--to",
1547 "file:///tmp/export-v2-test",
1548 "--chunk-time-window",
1549 "1h",
1550 ]);
1551
1552 let result = cmd.build().await;
1553 assert!(result.is_err());
1554 let error = result.err().unwrap().to_string();
1555
1556 assert!(error.contains("chunk_time_window requires both --start-time and --end-time"));
1557 }
1558
1559 #[tokio::test]
1560 async fn test_build_rejects_data_export_args_in_schema_only_mode() {
1561 let cmd = ExportCreateCommand::parse_from([
1562 "export-v2-create",
1563 "--addr",
1564 "127.0.0.1:4000",
1565 "--to",
1566 "file:///tmp/export-v2-test",
1567 "--schema-only",
1568 "--start-time",
1569 "2024-01-01T00:00:00Z",
1570 "--end-time",
1571 "2024-01-02T00:00:00Z",
1572 "--chunk-time-window",
1573 "1h",
1574 "--format",
1575 "csv",
1576 "--parallelism",
1577 "2",
1578 "--chunk-parallelism",
1579 "2",
1580 ]);
1581
1582 let error = cmd.build().await.err().unwrap().to_string();
1583
1584 assert!(error.contains("--schema-only cannot be used with data export arguments"));
1585 assert!(error.contains("--start-time"));
1586 assert!(error.contains("--end-time"));
1587 assert!(error.contains("--chunk-time-window"));
1588 assert!(error.contains("--format"));
1589 assert!(error.contains("--parallelism"));
1590 assert!(error.contains("--chunk-parallelism"));
1591 }
1592
1593 #[test]
1594 fn test_chunk_parallelism_defaults_to_one() {
1595 let cmd = ExportCreateCommand::parse_from([
1596 "export-v2-create",
1597 "--addr",
1598 "127.0.0.1:4000",
1599 "--to",
1600 "file:///tmp/export-v2-test",
1601 ]);
1602
1603 assert_eq!(1, cmd.chunk_parallelism);
1604 }
1605
1606 #[test]
1607 fn test_progress_mode_defaults_to_auto() {
1608 let cmd = ExportCreateCommand::parse_from([
1609 "export-v2-create",
1610 "--addr",
1611 "127.0.0.1:4000",
1612 "--to",
1613 "file:///tmp/export-v2-test",
1614 ]);
1615
1616 assert_eq!(ProgressMode::Auto, cmd.progress);
1617 }
1618
1619 #[test]
1620 fn test_progress_mode_parses_explicit_values() {
1621 for (value, expected) in [
1622 ("auto", ProgressMode::Auto),
1623 ("always", ProgressMode::Always),
1624 ("never", ProgressMode::Never),
1625 ] {
1626 let cmd = ExportCreateCommand::parse_from([
1627 "export-v2-create",
1628 "--addr",
1629 "127.0.0.1:4000",
1630 "--to",
1631 "file:///tmp/export-v2-test",
1632 "--progress",
1633 value,
1634 ]);
1635
1636 assert_eq!(expected, cmd.progress);
1637 }
1638 }
1639
1640 #[test]
1641 fn test_progress_mode_rejects_unknown_value() {
1642 assert!(
1643 ExportCreateCommand::try_parse_from([
1644 "export-v2-create",
1645 "--addr",
1646 "127.0.0.1:4000",
1647 "--to",
1648 "file:///tmp/export-v2-test",
1649 "--progress",
1650 "bogus",
1651 ])
1652 .is_err()
1653 );
1654 }
1655
1656 #[tokio::test]
1657 async fn test_pack_metric_data_arguments() {
1658 let directory = tempfile::tempdir().unwrap();
1659 let snapshot = directory.path().join("snapshot");
1660 let uri = url::Url::from_file_path(&snapshot).unwrap();
1661 let args = [
1662 "export-v2-create",
1663 "--addr",
1664 "127.0.0.1:1",
1665 "--to",
1666 uri.as_str(),
1667 ];
1668 assert!(!ExportCreateCommand::parse_from(args).pack_metric_data);
1669 let packed =
1670 ExportCreateCommand::parse_from(args.into_iter().chain(["--pack-metric-data"]));
1671 assert!(packed.pack_metric_data);
1672 for removed in [
1673 &["--experimental-metric-export"][..],
1674 &["--metric-data-layout", "packed"][..],
1675 ] {
1676 let error = ExportCreateCommand::try_parse_from(
1677 args.into_iter().chain(removed.iter().copied()),
1678 )
1679 .unwrap_err();
1680 assert_eq!(error.kind(), clap::error::ErrorKind::UnknownArgument);
1681 }
1682 for format in ["csv", "json"] {
1683 let command = ExportCreateCommand::parse_from(args.into_iter().chain([
1684 "--pack-metric-data",
1685 "--format",
1686 format,
1687 ]));
1688 let error = command.build().await.err().unwrap();
1689 assert!(error.to_string().contains("Metric export requires Parquet"));
1690 assert!(!snapshot.exists());
1691 }
1692 }
1693
1694 #[test]
1695 fn test_chunk_parallelism_parses_valid_value() {
1696 let cmd = ExportCreateCommand::parse_from([
1697 "export-v2-create",
1698 "--addr",
1699 "127.0.0.1:4000",
1700 "--to",
1701 "file:///tmp/export-v2-test",
1702 "--chunk-parallelism",
1703 "64",
1704 ]);
1705
1706 assert_eq!(64, cmd.chunk_parallelism);
1707 }
1708
1709 #[test]
1710 fn test_chunk_parallelism_rejects_out_of_range_values() {
1711 assert!(
1712 ExportCreateCommand::try_parse_from([
1713 "export-v2-create",
1714 "--addr",
1715 "127.0.0.1:4000",
1716 "--to",
1717 "file:///tmp/export-v2-test",
1718 "--chunk-parallelism",
1719 "0",
1720 ])
1721 .is_err()
1722 );
1723 assert!(
1724 ExportCreateCommand::try_parse_from([
1725 "export-v2-create",
1726 "--addr",
1727 "127.0.0.1:4000",
1728 "--to",
1729 "file:///tmp/export-v2-test",
1730 "--chunk-parallelism",
1731 "65",
1732 ])
1733 .is_err()
1734 );
1735 }
1736
1737 #[test]
1738 fn test_schema_only_mode_mismatch_error_message() {
1739 let error = SchemaOnlyModeMismatchSnafu {
1740 existing_schema_only: false,
1741 requested_schema_only: true,
1742 }
1743 .build()
1744 .to_string();
1745
1746 assert!(error.contains("existing: false"));
1747 assert!(error.contains("requested: true"));
1748 }
1749
1750 #[test]
1751 fn test_validate_resume_config_rejects_catalog_mismatch() {
1752 let manifest = Manifest::new_for_export(
1753 "greptime".to_string(),
1754 vec!["public".to_string()],
1755 false,
1756 TimeRange::unbounded(),
1757 DataFormat::Parquet,
1758 None,
1759 )
1760 .unwrap();
1761 let config = ExportConfig {
1762 catalog: "other".to_string(),
1763 schemas: None,
1764 schema_only: false,
1765 format: DataFormat::Parquet,
1766 packed: false,
1767 force: false,
1768 time_range: TimeRange::unbounded(),
1769 chunk_time_window: None,
1770 parallelism: 1,
1771 chunk_parallelism: 1,
1772 progress: ProgressMode::Auto,
1773 snapshot_uri: "file:///tmp/snapshot".to_string(),
1774 storage_config: ObjectStoreConfig::default(),
1775 };
1776
1777 let error = validate_resume_config(&manifest, &config)
1778 .err()
1779 .unwrap()
1780 .to_string();
1781 assert!(error.contains("catalog"));
1782 }
1783
1784 #[test]
1785 fn test_validate_resume_config_accepts_schema_selection_with_different_case_and_order() {
1786 let mut manifest = Manifest::new_for_export(
1787 "greptime".to_string(),
1788 vec!["public".to_string(), "analytics".to_string()],
1789 false,
1790 TimeRange::unbounded(),
1791 DataFormat::Parquet,
1792 None,
1793 )
1794 .unwrap();
1795 let mut config = ExportConfig {
1796 catalog: "greptime".to_string(),
1797 schemas: Some(vec![
1798 "ANALYTICS".to_string(),
1799 "PUBLIC".to_string(),
1800 "public".to_string(),
1801 ]),
1802 schema_only: false,
1803 format: DataFormat::Parquet,
1804 packed: false,
1805 force: false,
1806 time_range: TimeRange::unbounded(),
1807 chunk_time_window: None,
1808 parallelism: 1,
1809 chunk_parallelism: 1,
1810 progress: ProgressMode::Auto,
1811 snapshot_uri: "file:///tmp/snapshot".to_string(),
1812 storage_config: ObjectStoreConfig::default(),
1813 };
1814
1815 assert!(validate_resume_config(&manifest, &config).is_ok());
1816 config.packed = true;
1817 assert!(
1818 validate_resume_config(&manifest, &config)
1819 .unwrap_err()
1820 .to_string()
1821 .contains("data_layout")
1822 );
1823 manifest.version = 2;
1824 manifest.data_layout = Some(common_datasource::packed_snapshot::PACKED_LAYOUT.into());
1825 assert!(validate_resume_config(&manifest, &config).is_ok());
1826 config.packed = false;
1827 assert!(validate_resume_config(&manifest, &config).is_ok());
1828 }
1829
1830 #[test]
1831 fn test_validate_resume_config_rejects_chunk_plan_mismatch() {
1832 let start = chrono::Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
1833 let end = chrono::Utc.with_ymd_and_hms(2025, 1, 1, 2, 0, 0).unwrap();
1834 let time_range = TimeRange::new(Some(start), Some(end));
1835 let manifest = Manifest::new_for_export(
1836 "greptime".to_string(),
1837 vec!["public".to_string()],
1838 false,
1839 time_range.clone(),
1840 DataFormat::Parquet,
1841 None,
1842 )
1843 .unwrap();
1844 let config = ExportConfig {
1845 catalog: "greptime".to_string(),
1846 schemas: None,
1847 schema_only: false,
1848 format: DataFormat::Parquet,
1849 packed: false,
1850 force: false,
1851 time_range,
1852 chunk_time_window: Some(Duration::from_secs(3600)),
1853 parallelism: 1,
1854 chunk_parallelism: 1,
1855 progress: ProgressMode::Auto,
1856 snapshot_uri: "file:///tmp/snapshot".to_string(),
1857 storage_config: ObjectStoreConfig::default(),
1858 };
1859
1860 let error = validate_resume_config(&manifest, &config)
1861 .err()
1862 .unwrap()
1863 .to_string();
1864 assert!(error.contains("chunk plan"));
1865 }
1866
1867 #[test]
1868 fn test_validate_resume_config_rejects_format_mismatch() {
1869 let manifest = Manifest::new_for_export(
1870 "greptime".to_string(),
1871 vec!["public".to_string()],
1872 false,
1873 TimeRange::unbounded(),
1874 DataFormat::Parquet,
1875 None,
1876 )
1877 .unwrap();
1878 let config = ExportConfig {
1879 catalog: "greptime".to_string(),
1880 schemas: None,
1881 schema_only: false,
1882 format: DataFormat::Csv,
1883 packed: false,
1884 force: false,
1885 time_range: TimeRange::unbounded(),
1886 chunk_time_window: None,
1887 parallelism: 1,
1888 chunk_parallelism: 1,
1889 progress: ProgressMode::Auto,
1890 snapshot_uri: "file:///tmp/snapshot".to_string(),
1891 storage_config: ObjectStoreConfig::default(),
1892 };
1893
1894 let error = validate_resume_config(&manifest, &config)
1895 .err()
1896 .unwrap()
1897 .to_string();
1898 assert!(error.contains("format"));
1899 }
1900
1901 #[test]
1902 fn test_validate_resume_config_rejects_time_range_mismatch() {
1903 let start = chrono::Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
1904 let end = chrono::Utc.with_ymd_and_hms(2025, 1, 1, 1, 0, 0).unwrap();
1905 let manifest = Manifest::new_for_export(
1906 "greptime".to_string(),
1907 vec!["public".to_string()],
1908 false,
1909 TimeRange::new(Some(start), Some(end)),
1910 DataFormat::Parquet,
1911 None,
1912 )
1913 .unwrap();
1914 let config = ExportConfig {
1915 catalog: "greptime".to_string(),
1916 schemas: None,
1917 schema_only: false,
1918 format: DataFormat::Parquet,
1919 packed: false,
1920 force: false,
1921 time_range: TimeRange::new(Some(start), Some(start)),
1922 chunk_time_window: None,
1923 parallelism: 1,
1924 chunk_parallelism: 1,
1925 progress: ProgressMode::Auto,
1926 snapshot_uri: "file:///tmp/snapshot".to_string(),
1927 storage_config: ObjectStoreConfig::default(),
1928 };
1929
1930 let error = validate_resume_config(&manifest, &config)
1931 .err()
1932 .unwrap()
1933 .to_string();
1934 assert!(error.contains("time_range"));
1935 }
1936
1937 #[tokio::test]
1938 async fn test_scan_snapshots_sorts_and_tracks_unreadable_manifests() {
1939 let dir = tempdir().unwrap();
1940 write_test_manifest(
1941 dir.path(),
1942 "older",
1943 test_manifest(
1944 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
1945 false,
1946 true,
1947 ),
1948 );
1949 write_test_manifest(
1950 dir.path(),
1951 "newer",
1952 test_manifest(
1953 chrono::Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap(),
1954 false,
1955 true,
1956 ),
1957 );
1958
1959 std::fs::create_dir_all(dir.path().join("empty-dir")).unwrap();
1960 std::fs::create_dir_all(dir.path().join("not-snapshot")).unwrap();
1961 std::fs::write(dir.path().join("not-snapshot").join("data.txt"), "x").unwrap();
1962 std::fs::create_dir_all(dir.path().join("broken")).unwrap();
1963 std::fs::write(dir.path().join("broken").join(MANIFEST_FILE), "{not-json").unwrap();
1964
1965 let uri = Url::from_directory_path(dir.path()).unwrap().to_string();
1966 let storage = OpenDalStorage::from_file_uri(&uri).unwrap();
1967 let result = scan_snapshots(&storage).await.unwrap();
1968
1969 assert_eq!(result.snapshots.len(), 2);
1970 assert_eq!(
1971 result.snapshots[0].manifest.created_at,
1972 chrono::Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap()
1973 );
1974 assert_eq!(
1975 result.snapshots[1].manifest.created_at,
1976 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap()
1977 );
1978 assert_eq!(result.unreadable, vec!["broken/".to_string()]);
1979 assert_eq!(result.snapshots[0].path, "newer/");
1980 assert_eq!(result.snapshots[1].path, "older/");
1981 }
1982
1983 #[test]
1984 fn test_snapshot_list_status_and_chunk_summary() {
1985 let schema_only = test_manifest(
1986 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
1987 true,
1988 true,
1989 );
1990 assert_eq!(snapshot_status(&schema_only), "schema-only");
1991 assert_eq!(format_list_chunks(&schema_only), "0");
1992
1993 let complete = test_manifest(
1994 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
1995 false,
1996 true,
1997 );
1998 assert_eq!(snapshot_status(&complete), "complete");
1999 assert_eq!(format_list_chunks(&complete), "2/2");
2000 assert_eq!(format_delete_chunks(&complete), "2 (all processed)");
2001
2002 let incomplete = test_manifest(
2003 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2004 false,
2005 false,
2006 );
2007 assert_eq!(snapshot_status(&incomplete), "incomplete");
2008 assert_eq!(format_list_chunks(&incomplete), "1/2");
2009 assert_eq!(
2010 format_delete_chunks(&incomplete),
2011 "2 (1 completed, 0 skipped, 1 pending, 0 in_progress, 0 failed)"
2012 );
2013 }
2014
2015 #[tokio::test]
2016 async fn test_delete_build_rejects_bucket_root_uri() {
2017 let cmd = ExportDeleteCommand::parse_from([
2018 "export-v2-delete",
2019 "--snapshot",
2020 "s3://bucket",
2021 "--no-confirm",
2022 ]);
2023
2024 let error = cmd.build().await.err().unwrap().to_string();
2025 assert!(error.contains("non-empty path"));
2026 }
2027
2028 #[test]
2029 fn test_delete_skip_confirmation_aliases() {
2030 let no_confirm = ExportDeleteCommand::parse_from([
2031 "export-v2-delete",
2032 "--snapshot",
2033 "s3://bucket/snapshot",
2034 "--no-confirm",
2035 ]);
2036 assert!(no_confirm.skip_confirmation);
2037
2038 let yes = ExportDeleteCommand::parse_from([
2039 "export-v2-delete",
2040 "--snapshot",
2041 "s3://bucket/snapshot",
2042 "--yes",
2043 ]);
2044 assert!(yes.skip_confirmation);
2045 }
2046
2047 #[tokio::test]
2048 async fn test_delete_snapshot_with_no_confirm_removes_snapshot_contents() {
2049 let parent = tempdir().unwrap();
2050 let snapshot = parent.path().join("snapshot");
2051 let sibling = parent.path().join("sibling");
2052 std::fs::create_dir_all(&snapshot).unwrap();
2053 std::fs::create_dir_all(&sibling).unwrap();
2054 std::fs::write(sibling.join("keep.txt"), b"keep").unwrap();
2055 write_root_manifest(
2056 &snapshot,
2057 test_manifest(
2058 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2059 true,
2060 true,
2061 ),
2062 );
2063 write_snapshot_file(&snapshot, "schema/schemas.json", b"[]");
2064
2065 let uri = Url::from_directory_path(&snapshot).unwrap().to_string();
2066 let delete = ExportDelete {
2067 snapshot: uri,
2068 skip_confirmation: true,
2069 storage: file_storage_for_dir(&snapshot),
2070 };
2071
2072 delete
2073 .run_with_confirmation(|_| unreachable!())
2074 .await
2075 .unwrap();
2076
2077 assert!(!snapshot.join(MANIFEST_FILE).exists());
2078 assert!(!snapshot.join("schema/schemas.json").exists());
2079 assert!(sibling.join("keep.txt").exists());
2080 }
2081
2082 #[tokio::test]
2083 async fn test_delete_snapshot_requires_manifest() {
2084 let dir = tempdir().unwrap();
2085 let uri = Url::from_directory_path(dir.path()).unwrap().to_string();
2086 let delete = ExportDelete {
2087 snapshot: uri,
2088 skip_confirmation: true,
2089 storage: file_storage_for_dir(dir.path()),
2090 };
2091
2092 let error = delete
2093 .run_with_confirmation(|_| unreachable!())
2094 .await
2095 .err()
2096 .unwrap()
2097 .to_string();
2098
2099 assert!(error.contains("Snapshot not found"));
2100 assert!(dir.path().exists());
2101 }
2102
2103 #[tokio::test]
2104 async fn test_delete_snapshot_cancels_without_exact_confirmation() {
2105 let dir = tempdir().unwrap();
2106 write_root_manifest(
2107 dir.path(),
2108 test_manifest(
2109 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2110 true,
2111 true,
2112 ),
2113 );
2114 write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2115 let uri = Url::from_directory_path(dir.path()).unwrap().to_string();
2116 let delete = ExportDelete {
2117 snapshot: uri.clone(),
2118 skip_confirmation: false,
2119 storage: file_storage_for_dir(dir.path()),
2120 };
2121
2122 delete
2123 .run_with_confirmation(|snapshot| {
2124 assert_eq!(snapshot, uri);
2125 Ok(false)
2126 })
2127 .await
2128 .unwrap();
2129
2130 assert!(dir.path().join(MANIFEST_FILE).exists());
2131 assert!(dir.path().join("schema/schemas.json").exists());
2132 }
2133
2134 #[test]
2135 fn test_delete_confirmation_requires_exact_yes() {
2136 assert!(delete_confirmation_matches("yes"));
2137 assert!(delete_confirmation_matches(" yes\n"));
2138 assert!(!delete_confirmation_matches("YES"));
2139 assert!(!delete_confirmation_matches("y"));
2140 assert!(!delete_confirmation_matches("yes please"));
2141 }
2142
2143 #[test]
2144 fn test_display_snapshot_prefix_adds_trailing_slash() {
2145 assert_eq!(
2146 display_snapshot_prefix("s3://bucket/snapshot"),
2147 "s3://bucket/snapshot/"
2148 );
2149 assert_eq!(
2150 display_snapshot_prefix("s3://bucket/snapshot/"),
2151 "s3://bucket/snapshot/"
2152 );
2153 }
2154
2155 #[tokio::test]
2156 async fn test_verify_snapshot_accepts_valid_full_snapshot() {
2157 let dir = tempdir().unwrap();
2158 let manifest = test_manifest(
2159 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2160 false,
2161 true,
2162 );
2163 write_root_manifest(dir.path(), manifest);
2164 write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2165 write_default_ddl_files(dir.path());
2166 write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2167
2168 let storage = file_storage_for_dir(dir.path());
2169 let report = verify_snapshot(&storage).await.unwrap();
2170
2171 assert_eq!(report.error_count(), 0);
2172 assert_eq!(report.warning_count(), 0);
2173 assert_eq!(report.data_files_total, 1);
2174 assert_eq!(report.data_files_verified, 1);
2175 }
2176
2177 #[tokio::test]
2178 async fn test_verify_snapshot_reports_missing_data_file_and_failed_chunk() {
2179 let dir = tempdir().unwrap();
2180 let mut manifest = test_manifest(
2181 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2182 false,
2183 true,
2184 );
2185 manifest.chunks[1].mark_failed("copy failed".to_string());
2186 write_root_manifest(dir.path(), manifest);
2187 write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2188 write_default_ddl_files(dir.path());
2189
2190 let storage = file_storage_for_dir(dir.path());
2191 let report = verify_snapshot(&storage).await.unwrap();
2192
2193 assert_eq!(report.error_count(), 2);
2194 assert!(
2195 report
2196 .problems
2197 .iter()
2198 .any(|problem| problem.message.contains("missing file"))
2199 );
2200 assert!(
2201 report
2202 .problems
2203 .iter()
2204 .any(|problem| problem.message.contains("status is 'failed'"))
2205 );
2206 }
2207
2208 #[tokio::test]
2209 async fn test_verify_snapshot_reports_missing_schema_index_as_warning() {
2210 let dir = tempdir().unwrap();
2211 let manifest = test_manifest(
2212 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2213 false,
2214 true,
2215 );
2216 write_root_manifest(dir.path(), manifest);
2217 write_default_ddl_files(dir.path());
2218 write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2219
2220 let storage = file_storage_for_dir(dir.path());
2221 let report = verify_snapshot(&storage).await.unwrap();
2222
2223 assert_eq!(report.error_count(), 0);
2224 assert_eq!(report.warning_count(), 1);
2225 assert!(
2226 report
2227 .problems
2228 .iter()
2229 .any(|problem| problem.message.contains("Missing schema index"))
2230 );
2231 }
2232
2233 #[tokio::test]
2234 async fn test_verify_snapshot_rejects_schema_only_snapshot_with_chunks() {
2235 let dir = tempdir().unwrap();
2236 let mut manifest = test_manifest(
2237 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2238 true,
2239 true,
2240 );
2241 let mut chunk = ChunkMeta::new(1, TimeRange::unbounded());
2242 chunk.mark_completed(vec!["data/public/1/file.parquet".to_string()], None);
2243 manifest.chunks.push(chunk);
2244 write_root_manifest(dir.path(), manifest);
2245 write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2246 write_default_ddl_files(dir.path());
2247
2248 let storage = file_storage_for_dir(dir.path());
2249 let report = verify_snapshot(&storage).await.unwrap();
2250
2251 assert_eq!(report.error_count(), 1);
2252 assert_eq!(report.data_files_total, 0);
2253 assert!(
2254 report
2255 .problems
2256 .iter()
2257 .any(|problem| problem.message.contains("should not contain data chunks"))
2258 );
2259 }
2260
2261 #[tokio::test]
2262 async fn test_verify_snapshot_rejects_schema_only_snapshot_with_data_files() {
2263 let dir = tempdir().unwrap();
2264 let manifest = test_manifest(
2265 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2266 true,
2267 true,
2268 );
2269 write_root_manifest(dir.path(), manifest);
2270 write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2271 write_default_ddl_files(dir.path());
2272 write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2273
2274 let storage = file_storage_for_dir(dir.path());
2275 let report = verify_snapshot(&storage).await.unwrap();
2276
2277 assert_eq!(report.error_count(), 1);
2278 assert_eq!(report.data_files_total, 0);
2279 assert!(
2280 report
2281 .problems
2282 .iter()
2283 .any(|problem| problem.message.contains("should not contain data files"))
2284 );
2285 }
2286
2287 #[tokio::test]
2288 async fn test_verify_snapshot_rejects_full_snapshot_without_chunks() {
2289 let dir = tempdir().unwrap();
2290 let mut manifest = test_manifest(
2291 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2292 false,
2293 true,
2294 );
2295 manifest.chunks.clear();
2296 write_root_manifest(dir.path(), manifest);
2297 write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2298 write_default_ddl_files(dir.path());
2299
2300 let storage = file_storage_for_dir(dir.path());
2301 let report = verify_snapshot(&storage).await.unwrap();
2302
2303 assert_eq!(report.error_count(), 1);
2304 assert_eq!(report.data_files_total, 0);
2305 assert!(
2306 report
2307 .problems
2308 .iter()
2309 .any(|problem| problem.message.contains("at least one data chunk"))
2310 );
2311 }
2312
2313 #[tokio::test]
2314 async fn test_verify_snapshot_rejects_skipped_chunk_data_files() {
2315 let dir = tempdir().unwrap();
2316 let manifest = test_manifest(
2317 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2318 false,
2319 true,
2320 );
2321 write_root_manifest(dir.path(), manifest);
2322 write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2323 write_default_ddl_files(dir.path());
2324 write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2325 write_snapshot_file(dir.path(), "data/public/2/file.parquet", b"data");
2326
2327 let storage = file_storage_for_dir(dir.path());
2328 let report = verify_snapshot(&storage).await.unwrap();
2329
2330 assert_eq!(report.error_count(), 1);
2331 assert!(
2332 report
2333 .problems
2334 .iter()
2335 .any(|problem| { problem.message.contains("Unexpected data file") })
2336 );
2337 }
2338
2339 #[tokio::test]
2340 async fn test_verify_snapshot_rejects_duplicate_chunk_ids() {
2341 let dir = tempdir().unwrap();
2342 let mut manifest = test_manifest(
2343 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2344 false,
2345 true,
2346 );
2347 let mut duplicate = ChunkMeta::new(1, TimeRange::unbounded());
2348 duplicate.mark_completed(vec!["data/public/1/file.parquet".to_string()], None);
2349 manifest.chunks.push(duplicate);
2350 write_root_manifest(dir.path(), manifest);
2351 write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2352 write_default_ddl_files(dir.path());
2353 write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2354
2355 let storage = file_storage_for_dir(dir.path());
2356 let report = verify_snapshot(&storage).await.unwrap();
2357
2358 assert_eq!(report.error_count(), 1);
2359 assert!(
2360 report
2361 .problems
2362 .iter()
2363 .any(|problem| problem.message.contains("duplicate chunk id"))
2364 );
2365 }
2366
2367 #[tokio::test]
2368 async fn test_verify_snapshot_requires_all_schema_ddl() {
2369 let dir = tempdir().unwrap();
2370 let manifest = test_manifest(
2371 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2372 true,
2373 true,
2374 );
2375 write_root_manifest(dir.path(), manifest);
2376 write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2377 write_snapshot_file(
2378 dir.path(),
2379 "schema/ddl/public.sql",
2380 b"CREATE DATABASE public;",
2381 );
2382
2383 let storage = file_storage_for_dir(dir.path());
2384 let report = verify_snapshot(&storage).await.unwrap();
2385
2386 assert_eq!(report.error_count(), 1);
2387 assert!(
2388 report
2389 .problems
2390 .iter()
2391 .any(|problem| problem.message.contains("analytics"))
2392 );
2393 }
2394
2395 #[tokio::test]
2396 async fn test_verify_snapshot_reports_missing_ddl_dir() {
2397 let dir = tempdir().unwrap();
2398 let manifest = test_manifest(
2399 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2400 false,
2401 true,
2402 );
2403 write_root_manifest(dir.path(), manifest);
2404 write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2405 write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2406
2407 let storage = file_storage_for_dir(dir.path());
2408 let report = verify_snapshot(&storage).await.unwrap();
2409
2410 assert_eq!(report.error_count(), 2);
2411 assert!(
2412 report
2413 .problems
2414 .iter()
2415 .any(|problem| problem.message.contains("schema/ddl/public.sql"))
2416 );
2417 assert!(
2418 report
2419 .problems
2420 .iter()
2421 .any(|problem| problem.message.contains("schema/ddl/analytics.sql"))
2422 );
2423 }
2424
2425 #[tokio::test]
2426 async fn test_verify_snapshot_reports_manifest_version_mismatch() {
2427 let dir = tempdir().unwrap();
2428 let mut manifest = test_manifest(
2429 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2430 false,
2431 true,
2432 );
2433 manifest.version = MANIFEST_VERSION + 1;
2434 write_root_manifest(dir.path(), manifest);
2435 write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2436 write_default_ddl_files(dir.path());
2437 write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2438
2439 let storage = file_storage_for_dir(dir.path());
2440 let report = verify_snapshot(&storage).await.unwrap();
2441
2442 assert_eq!(report.error_count(), 1);
2443 assert!(
2444 report
2445 .problems
2446 .iter()
2447 .any(|problem| problem.message.contains("Manifest version mismatch"))
2448 );
2449 }
2450
2451 #[tokio::test]
2452 async fn test_verify_snapshot_rejects_invalid_data_file_paths() {
2453 let dir = tempdir().unwrap();
2454 let mut manifest = test_manifest(
2455 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2456 false,
2457 true,
2458 );
2459 manifest.chunks[0].files = vec!["data/public/1/../file.parquet".to_string()];
2460 write_root_manifest(dir.path(), manifest);
2461 write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2462 write_default_ddl_files(dir.path());
2463
2464 let storage = file_storage_for_dir(dir.path());
2465 let report = verify_snapshot(&storage).await.unwrap();
2466
2467 assert_eq!(report.error_count(), 1);
2468 assert!(
2469 report
2470 .problems
2471 .iter()
2472 .any(|problem| problem.message.contains("invalid data file path"))
2473 );
2474 assert_eq!(report.data_files_verified, 0);
2475 }
2476
2477 #[tokio::test]
2478 async fn test_verify_snapshot_accepts_leading_slash_manifest_data_paths() {
2479 let dir = tempdir().unwrap();
2480 let mut manifest = test_manifest(
2481 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2482 false,
2483 true,
2484 );
2485 manifest.chunks[0].files = vec!["/data/public/1/file.parquet".to_string()];
2486 write_root_manifest(dir.path(), manifest);
2487 write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2488 write_default_ddl_files(dir.path());
2489 write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2490
2491 let storage = file_storage_for_dir(dir.path());
2492 let report = verify_snapshot(&storage).await.unwrap();
2493
2494 assert_eq!(report.error_count(), 0);
2495 assert_eq!(report.data_files_verified, 1);
2496 }
2497
2498 #[tokio::test]
2499 async fn test_verify_snapshot_rejects_unlisted_files_under_completed_chunk_prefix() {
2500 let dir = tempdir().unwrap();
2501 let manifest = test_manifest(
2502 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2503 false,
2504 true,
2505 );
2506 write_root_manifest(dir.path(), manifest);
2507 write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2508 write_default_ddl_files(dir.path());
2509 write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2510 write_snapshot_file(dir.path(), "data/public/1/extra.parquet", b"data");
2511
2512 let storage = file_storage_for_dir(dir.path());
2513 let report = verify_snapshot(&storage).await.unwrap();
2514
2515 assert_eq!(report.error_count(), 1);
2516 assert!(
2517 report
2518 .problems
2519 .iter()
2520 .any(|problem| problem.message.contains("Unexpected data file"))
2521 );
2522 assert_eq!(report.data_files_verified, 1);
2523 }
2524
2525 #[tokio::test]
2526 async fn test_verify_snapshot_rejects_orphan_data_files_outside_known_chunk_prefixes() {
2527 let dir = tempdir().unwrap();
2528 let manifest = test_manifest(
2529 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2530 false,
2531 true,
2532 );
2533 write_root_manifest(dir.path(), manifest);
2534 write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2535 write_default_ddl_files(dir.path());
2536 write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2537 write_snapshot_file(dir.path(), "data/public/99/file.parquet", b"data");
2538
2539 let storage = file_storage_for_dir(dir.path());
2540 let report = verify_snapshot(&storage).await.unwrap();
2541
2542 assert_eq!(report.error_count(), 1);
2543 assert!(
2544 report
2545 .problems
2546 .iter()
2547 .any(|problem| problem.message.contains("Unexpected data file"))
2548 );
2549 assert_eq!(report.data_files_verified, 1);
2550 }
2551
2552 #[tokio::test]
2553 async fn test_verify_snapshot_rejects_data_files_under_wrong_chunk_or_schema() {
2554 let dir = tempdir().unwrap();
2555 let mut manifest = test_manifest(
2556 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2557 false,
2558 true,
2559 );
2560 manifest.chunks[0].files = vec![
2561 "data/public/99/file.parquet".to_string(),
2562 "data/metrics/1/file.parquet".to_string(),
2563 ];
2564 write_root_manifest(dir.path(), manifest);
2565 write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2566 write_default_ddl_files(dir.path());
2567 write_snapshot_file(dir.path(), "data/public/99/file.parquet", b"data");
2568 write_snapshot_file(dir.path(), "data/metrics/1/file.parquet", b"data");
2569
2570 let storage = file_storage_for_dir(dir.path());
2571 let report = verify_snapshot(&storage).await.unwrap();
2572
2573 assert_eq!(report.error_count(), 2);
2574 assert_eq!(report.data_files_verified, 0);
2575 assert!(
2576 report
2577 .problems
2578 .iter()
2579 .all(|problem| problem.message.contains("invalid data file path"))
2580 );
2581 }
2582
2583 #[test]
2584 fn test_build_verify_plan_classifies_chunks_without_io() {
2585 let mut manifest = test_manifest(
2586 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2587 false,
2588 true,
2589 );
2590 let mut failed = ChunkMeta::new(3, TimeRange::unbounded());
2592 failed.mark_failed("boom".to_string());
2593 manifest.chunks.push(failed);
2594 manifest
2595 .chunks
2596 .push(ChunkMeta::new(4, TimeRange::unbounded()));
2597
2598 let plan = build_verify_plan(&manifest);
2599
2600 assert_eq!(plan.files_to_check.len(), 1);
2601 assert_eq!(plan.files_to_check[0].chunk_id, 1);
2602 assert_eq!(plan.files_to_check[0].path, "data/public/1/file.parquet");
2603 assert_eq!(plan.data_files_total, 1);
2604 assert!(
2605 plan.claimed_data_files
2606 .contains("data/public/1/file.parquet")
2607 );
2608 assert_eq!(plan.problems.len(), 2);
2609 assert!(
2610 plan.problems
2611 .iter()
2612 .any(|problem| problem.message.contains("status is 'failed'"))
2613 );
2614 assert!(
2615 plan.problems
2616 .iter()
2617 .any(|problem| problem.message.contains("status is 'pending'"))
2618 );
2619 }
2620
2621 #[tokio::test]
2622 async fn test_verify_snapshot_produces_deterministic_problem_output() {
2623 let dir = tempdir().unwrap();
2624 let manifest = test_manifest(
2625 chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2626 false,
2627 true,
2628 );
2629 write_root_manifest(dir.path(), manifest);
2630 write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2631 write_default_ddl_files(dir.path());
2632 write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2633 for i in 0..50 {
2635 write_snapshot_file(
2636 dir.path(),
2637 &format!("data/public/1/orphan_{:02}.parquet", i),
2638 b"x",
2639 );
2640 }
2641
2642 let storage = file_storage_for_dir(dir.path());
2643 let messages = |report: &VerifyReport| {
2644 report
2645 .problems
2646 .iter()
2647 .map(|problem| problem.message.clone())
2648 .collect::<Vec<_>>()
2649 };
2650 let first = messages(&verify_snapshot(&storage).await.unwrap());
2651 let second = messages(&verify_snapshot(&storage).await.unwrap());
2652
2653 assert_eq!(first, second);
2655
2656 let orphans = first
2657 .iter()
2658 .filter(|message| message.contains("Unexpected data file"))
2659 .cloned()
2660 .collect::<Vec<_>>();
2661 assert_eq!(orphans.len(), 50);
2662 let mut sorted = orphans.clone();
2663 sorted.sort();
2664 assert_eq!(orphans, sorted);
2665 }
2666
2667 fn write_test_manifest(root: &std::path::Path, dir: &str, manifest: Manifest) {
2668 let snapshot_dir = root.join(dir);
2669 std::fs::create_dir_all(&snapshot_dir).unwrap();
2670 std::fs::write(
2671 snapshot_dir.join(MANIFEST_FILE),
2672 serde_json::to_vec_pretty(&manifest).unwrap(),
2673 )
2674 .unwrap();
2675 }
2676
2677 fn write_root_manifest(root: &std::path::Path, manifest: Manifest) {
2678 std::fs::write(
2679 root.join(MANIFEST_FILE),
2680 serde_json::to_vec_pretty(&manifest).unwrap(),
2681 )
2682 .unwrap();
2683 }
2684
2685 fn write_snapshot_file(root: &std::path::Path, relative_path: &str, content: &[u8]) {
2686 let mut path = root.to_path_buf();
2687 for segment in relative_path.split('/') {
2688 path.push(segment);
2689 }
2690 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2691 std::fs::write(path, content).unwrap();
2692 }
2693
2694 fn write_default_ddl_files(root: &std::path::Path) {
2695 write_snapshot_file(root, "schema/ddl/public.sql", b"CREATE DATABASE public;");
2696 write_snapshot_file(
2697 root,
2698 "schema/ddl/analytics.sql",
2699 b"CREATE DATABASE analytics;",
2700 );
2701 }
2702
2703 fn file_storage_for_dir(root: &std::path::Path) -> OpenDalStorage {
2704 let uri = Url::from_directory_path(root).unwrap().to_string();
2705 OpenDalStorage::from_file_uri(&uri).unwrap()
2706 }
2707
2708 fn test_manifest(
2709 created_at: chrono::DateTime<chrono::Utc>,
2710 schema_only: bool,
2711 complete: bool,
2712 ) -> Manifest {
2713 let mut manifest = Manifest::new_for_export(
2714 "greptime".to_string(),
2715 vec!["public".to_string(), "analytics".to_string()],
2716 schema_only,
2717 TimeRange::unbounded(),
2718 DataFormat::Parquet,
2719 None,
2720 )
2721 .unwrap();
2722 manifest.created_at = created_at;
2723 manifest.updated_at = created_at;
2724
2725 if !schema_only {
2726 manifest.chunks.clear();
2727 let mut first = ChunkMeta::new(1, TimeRange::unbounded());
2728 first.mark_completed(vec!["data/public/1/file.parquet".to_string()], None);
2729 manifest.chunks.push(first);
2730
2731 if complete {
2732 manifest
2733 .chunks
2734 .push(ChunkMeta::skipped(2, TimeRange::unbounded()));
2735 } else {
2736 manifest
2737 .chunks
2738 .push(ChunkMeta::new(2, TimeRange::unbounded()));
2739 }
2740 }
2741
2742 manifest
2743 }
2744}