Skip to main content

cli/data/export_v2/
command.rs

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