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