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