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