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