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