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