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