Skip to main content

cli/data/import_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//! Import V2 CLI command.
16
17use std::collections::HashSet;
18use std::path::{Path, PathBuf};
19use std::time::Duration;
20
21use async_trait::async_trait;
22use clap::Parser;
23use common_error::ext::BoxedError;
24use common_telemetry::info;
25use snafu::{OptionExt, ResultExt};
26
27use crate::Tool;
28use crate::common::ObjectStoreConfig;
29use crate::data::export_v2::data::{build_copy_source, execute_copy_database_from};
30use crate::data::export_v2::manifest::{ChunkMeta, ChunkStatus, DataFormat};
31use crate::data::import_v2::coordinator::{
32    ImportResumeConfig, ImportTaskExecutor, build_import_tasks, chunk_has_schema_files,
33    import_with_resume_session_with_progress, prepare_import_resume,
34};
35use crate::data::import_v2::error::{
36    ChunkImportFailedSnafu, EmptyChunkManifestSnafu, ImportStatePathUnavailableSnafu,
37    IncompleteSnapshotSnafu, MissingChunkDataSnafu, Result, SchemaNotInSnapshotSnafu,
38    SnapshotStorageSnafu,
39};
40use crate::data::import_v2::executor::{DdlExecutor, DdlStatement};
41use crate::data::import_v2::state::{ImportTaskKey, default_state_path};
42use crate::data::path::{data_dir_for_schema_chunk, ddl_path_for_schema};
43use crate::data::progress::{ProgressMode, build_progress_reporter};
44use crate::data::snapshot_storage::{OpenDalStorage, SnapshotStorage, validate_uri};
45use crate::database::{DatabaseClient, parse_proxy_opts};
46
47/// Import from a snapshot.
48#[derive(Debug, Parser)]
49pub struct ImportV2Command {
50    /// Server address to connect (e.g., 127.0.0.1:4000).
51    #[clap(long)]
52    addr: String,
53
54    /// Source snapshot location (e.g., s3://bucket/path, file:///tmp/backup).
55    #[clap(long)]
56    from: String,
57
58    /// Target catalog name.
59    #[clap(long, default_value = "greptime")]
60    catalog: String,
61
62    /// Schema list to import (default: all in snapshot).
63    /// Can be specified multiple times or comma-separated.
64    #[clap(long, value_delimiter = ',')]
65    schemas: Vec<String>,
66
67    /// Verify without importing (dry-run).
68    #[clap(long)]
69    dry_run: bool,
70
71    /// Progress reporting mode.
72    #[clap(long, value_enum, default_value_t = ProgressMode::Auto)]
73    progress: ProgressMode,
74
75    /// Number of import data tasks to run concurrently on the client (1..=64).
76    #[clap(long, default_value = "1", value_parser = parse_task_parallelism)]
77    task_parallelism: usize,
78
79    /// Override the import resume state file path.
80    ///
81    /// Defaults to a stable path under `~/.greptime/import_state`.
82    #[clap(long)]
83    state_path: Option<PathBuf>,
84
85    /// Basic authentication (user:password).
86    #[clap(long)]
87    auth_basic: Option<String>,
88
89    /// Request timeout.
90    #[clap(long, value_parser = humantime::parse_duration)]
91    timeout: Option<Duration>,
92
93    /// Proxy server address.
94    ///
95    /// If set, it overrides the system proxy unless `--no-proxy` is specified.
96    /// If neither `--proxy` nor `--no-proxy` is set, system proxy (env) may be used.
97    #[clap(long)]
98    proxy: Option<String>,
99
100    /// Disable all proxy usage (ignores `--proxy` and system proxy).
101    ///
102    /// When set and `--proxy` is not provided, this explicitly disables system proxy.
103    #[clap(long)]
104    no_proxy: bool,
105
106    /// Object store configuration for remote storage backends.
107    #[clap(flatten)]
108    storage: ObjectStoreConfig,
109}
110
111impl ImportV2Command {
112    pub async fn build(&self) -> std::result::Result<Box<dyn Tool>, BoxedError> {
113        // Validate URI format
114        validate_uri(&self.from)
115            .context(SnapshotStorageSnafu)
116            .map_err(BoxedError::new)?;
117
118        // Parse schemas (empty vec means all schemas)
119        let schemas = if self.schemas.is_empty() {
120            None
121        } else {
122            Some(self.schemas.clone())
123        };
124
125        // Build storage
126        let storage = OpenDalStorage::from_uri(&self.from, &self.storage)
127            .context(SnapshotStorageSnafu)
128            .map_err(BoxedError::new)?;
129
130        // Build database client
131        let proxy = parse_proxy_opts(self.proxy.clone(), self.no_proxy)?;
132        let database_client = DatabaseClient::new(
133            self.addr.clone(),
134            self.catalog.clone(),
135            self.auth_basic.clone(),
136            self.timeout.unwrap_or(Duration::from_secs(60)),
137            proxy,
138            self.no_proxy,
139        );
140
141        Ok(Box::new(Import {
142            catalog: self.catalog.clone(),
143            schemas,
144            dry_run: self.dry_run,
145            progress: self.progress,
146            task_parallelism: self.task_parallelism,
147            state_path: self.state_path.clone(),
148            snapshot_uri: self.from.clone(),
149            storage_config: self.storage.clone(),
150            storage: Box::new(storage),
151            database_client,
152        }))
153    }
154}
155
156/// Resolves the import resume state file path. When `override_path` is set it is
157/// used verbatim; otherwise the stable default under `~/.greptime/import_state`
158/// is derived from the import identity.
159fn resolve_state_path(
160    override_path: Option<&Path>,
161    snapshot_id: &str,
162    target_addr: &str,
163    catalog: &str,
164    schemas: &[String],
165) -> Result<PathBuf> {
166    if let Some(path) = override_path {
167        return Ok(path.to_path_buf());
168    }
169    default_state_path(snapshot_id, target_addr, catalog, schemas).context(
170        ImportStatePathUnavailableSnafu {
171            snapshot_id: snapshot_id.to_string(),
172        },
173    )
174}
175
176fn parse_task_parallelism(value: &str) -> std::result::Result<usize, String> {
177    let parallelism = value
178        .parse::<usize>()
179        .map_err(|_| "task parallelism must be an integer between 1 and 64".to_string())?;
180    if (1..=64).contains(&parallelism) {
181        Ok(parallelism)
182    } else {
183        Err("task parallelism must be between 1 and 64".to_string())
184    }
185}
186
187/// Import tool implementation.
188pub struct Import {
189    catalog: String,
190    schemas: Option<Vec<String>>,
191    dry_run: bool,
192    progress: ProgressMode,
193    task_parallelism: usize,
194    state_path: Option<PathBuf>,
195    snapshot_uri: String,
196    storage_config: ObjectStoreConfig,
197    storage: Box<dyn SnapshotStorage>,
198    database_client: DatabaseClient,
199}
200
201#[async_trait]
202impl Tool for Import {
203    async fn do_work(&self) -> std::result::Result<(), BoxedError> {
204        self.run().await.map_err(BoxedError::new)
205    }
206}
207
208impl Import {
209    async fn run(&self) -> Result<()> {
210        // 1. Read manifest
211        let manifest = self
212            .storage
213            .read_manifest()
214            .await
215            .context(SnapshotStorageSnafu)?;
216
217        info!(
218            "Loading snapshot: {} (version: {}, schema_only: {})",
219            manifest.snapshot_id, manifest.version, manifest.schema_only
220        );
221
222        manifest.validate_layout().map_err(|reason| {
223            crate::data::import_v2::error::InvalidPackedSnapshotSnafu { reason }.build()
224        })?;
225        if manifest.is_packed() {
226            self.database_client
227                .require_packed_import()
228                .await
229                .context(crate::data::import_v2::error::DatabaseSnafu)?;
230        }
231
232        info!("Snapshot contains {} schema(s)", manifest.schemas.len());
233
234        // 2. Determine schemas to import
235        let schemas_to_import = match &self.schemas {
236            Some(filter) => canonicalize_schema_filter(filter, &manifest.schemas)?,
237            None => manifest.schemas.clone(),
238        };
239
240        info!("Importing schemas: {:?}", schemas_to_import);
241
242        // 3. Read DDL statements
243        let ddl_statements = self.read_ddl_statements(&schemas_to_import).await?;
244
245        info!("Generated {} DDL statements", ddl_statements.len());
246
247        let data_tasks = if !manifest.schema_only && !manifest.chunks.is_empty() {
248            validate_data_snapshot(self.storage.as_ref(), &manifest.chunks, &schemas_to_import)
249                .await?;
250            build_import_tasks(&manifest.chunks, &schemas_to_import)
251        } else {
252            Vec::new()
253        };
254
255        if manifest.is_packed() {
256            crate::data::import_v2::packed::validate_snapshot(
257                self.storage.as_ref(),
258                &manifest,
259                &schemas_to_import,
260            )
261            .await?;
262        }
263
264        // 4. Dry-run mode: print DDL and exit
265        if self.dry_run {
266            info!("Dry-run mode - DDL statements to execute:");
267            println!();
268            for (i, stmt) in ddl_statements.iter().enumerate() {
269                println!("-- Statement {}", i + 1);
270                println!("{};", stmt.sql);
271                println!();
272            }
273            if !manifest.schema_only && !manifest.chunks.is_empty() {
274                for line in format_data_import_plan(&manifest.chunks, &schemas_to_import) {
275                    println!("{line}");
276                }
277                println!();
278            }
279            return Ok(());
280        }
281
282        let mut resume_session = if !data_tasks.is_empty() {
283            let state_path = resolve_state_path(
284                self.state_path.as_deref(),
285                &manifest.snapshot_id.to_string(),
286                self.database_client.addr(),
287                &self.catalog,
288                &schemas_to_import,
289            )?;
290            Some(
291                prepare_import_resume(ImportResumeConfig {
292                    snapshot_id: manifest.snapshot_id.to_string(),
293                    target_addr: self.database_client.addr().to_string(),
294                    catalog: self.catalog.clone(),
295                    schemas: schemas_to_import.clone(),
296                    state_path,
297                    tasks: data_tasks,
298                    task_parallelism: self.task_parallelism,
299                })
300                .await?,
301            )
302        } else {
303            None
304        };
305
306        let skip_ddl = resume_session
307            .as_ref()
308            .map(|session| session.should_skip_ddl())
309            .unwrap_or(false);
310
311        // 5. Execute DDL unless a previous run already completed it.
312        let ddl_executed = if skip_ddl {
313            info!(
314                "Existing import state has DDL marked completed; skipping DDL execution and resuming data import"
315            );
316            false
317        } else {
318            let executor = DdlExecutor::new(&self.database_client);
319            executor.execute_strict(&ddl_statements).await?;
320            if let Some(session) = resume_session.as_mut() {
321                session.mark_ddl_completed().await?;
322            }
323            true
324        };
325
326        if let Some(resume_session) = resume_session {
327            let executor = CopyDatabaseImportTaskExecutor {
328                import: self,
329                format: manifest.format,
330                packed: manifest.is_packed(),
331            };
332            let progress = build_progress_reporter(self.progress);
333            import_with_resume_session_with_progress(resume_session, &executor, progress.as_ref())
334                .await?;
335        }
336
337        if ddl_executed {
338            info!(
339                "Import completed: {} DDL statements executed",
340                ddl_statements.len()
341            );
342        } else {
343            info!("Import completed: DDL execution skipped");
344        }
345
346        Ok(())
347    }
348
349    async fn read_ddl_statements(&self, schemas: &[String]) -> Result<Vec<DdlStatement>> {
350        let mut statements = Vec::new();
351        for schema in schemas {
352            let path = ddl_path_for_schema(schema);
353            let content = self
354                .storage
355                .read_text(&path)
356                .await
357                .context(SnapshotStorageSnafu)?;
358            statements.extend(
359                parse_ddl_statements(&content)
360                    .into_iter()
361                    .map(|sql| ddl_statement_for_schema(schema, sql)),
362            );
363        }
364
365        Ok(statements)
366    }
367}
368
369struct CopyDatabaseImportTaskExecutor<'a> {
370    import: &'a Import,
371    format: DataFormat,
372    packed: bool,
373}
374
375#[async_trait]
376impl ImportTaskExecutor for CopyDatabaseImportTaskExecutor<'_> {
377    async fn import_task(&self, task: &ImportTaskKey) -> Result<()> {
378        let source = build_copy_source(
379            &self.import.snapshot_uri,
380            &self.import.storage_config,
381            &task.schema,
382            task.chunk_id,
383        )
384        .context(ChunkImportFailedSnafu {
385            chunk_id: task.chunk_id,
386            schema: task.schema.clone(),
387        })?;
388
389        execute_copy_database_from(
390            &self.import.database_client,
391            &self.import.catalog,
392            &task.schema,
393            &source,
394            self.format,
395            self.packed,
396        )
397        .await
398        .context(ChunkImportFailedSnafu {
399            chunk_id: task.chunk_id,
400            schema: task.schema.clone(),
401        })
402    }
403}
404
405fn parse_ddl_statements(content: &str) -> Vec<String> {
406    let mut statements = Vec::new();
407    let mut current = String::new();
408    let mut chars = content.chars().peekable();
409    let mut in_single_quote = false;
410    let mut in_double_quote = false;
411    let mut in_line_comment = false;
412    let mut in_block_comment = false;
413
414    while let Some(ch) = chars.next() {
415        if in_line_comment {
416            if ch == '\n' {
417                in_line_comment = false;
418                current.push('\n');
419            }
420            continue;
421        }
422
423        if in_block_comment {
424            if ch == '*' && chars.peek() == Some(&'/') {
425                chars.next();
426                in_block_comment = false;
427            }
428            continue;
429        }
430
431        if in_single_quote {
432            current.push(ch);
433            if ch == '\'' {
434                if chars.peek() == Some(&'\'') {
435                    current.push(chars.next().expect("peeked quote must exist"));
436                } else {
437                    in_single_quote = false;
438                }
439            }
440            continue;
441        }
442
443        if in_double_quote {
444            current.push(ch);
445            if ch == '"' {
446                if chars.peek() == Some(&'"') {
447                    current.push(chars.next().expect("peeked quote must exist"));
448                } else {
449                    in_double_quote = false;
450                }
451            }
452            continue;
453        }
454
455        match ch {
456            '-' if chars.peek() == Some(&'-') => {
457                chars.next();
458                in_line_comment = true;
459            }
460            '/' if chars.peek() == Some(&'*') => {
461                chars.next();
462                in_block_comment = true;
463            }
464            '\'' => {
465                in_single_quote = true;
466                current.push(ch);
467            }
468            '"' => {
469                in_double_quote = true;
470                current.push(ch);
471            }
472            ';' => {
473                let statement = current.trim();
474                if !statement.is_empty() {
475                    statements.push(statement.to_string());
476                }
477                current.clear();
478            }
479            _ => current.push(ch),
480        }
481    }
482
483    let statement = current.trim();
484    if !statement.is_empty() {
485        statements.push(statement.to_string());
486    }
487
488    statements
489}
490
491fn ddl_statement_for_schema(schema: &str, sql: String) -> DdlStatement {
492    if is_schema_scoped_statement(&sql) {
493        DdlStatement::with_execution_schema(sql, schema.to_string())
494    } else {
495        DdlStatement::new(sql)
496    }
497}
498
499fn is_schema_scoped_statement(sql: &str) -> bool {
500    let trimmed = sql.trim_start();
501    if !starts_with_keyword(trimmed, "CREATE") {
502        return false;
503    }
504
505    let Some(rest) = trimmed.get("CREATE".len()..) else {
506        return false;
507    };
508    let mut rest = rest.trim_start();
509    if starts_with_keyword(rest, "OR") {
510        let Some(next) = rest.get("OR".len()..) else {
511            return false;
512        };
513        rest = next.trim_start();
514        if !starts_with_keyword(rest, "REPLACE") {
515            return false;
516        }
517        let Some(next) = rest.get("REPLACE".len()..) else {
518            return false;
519        };
520        rest = next.trim_start();
521    }
522
523    if starts_with_keyword(rest, "EXTERNAL") {
524        let Some(next) = rest.get("EXTERNAL".len()..) else {
525            return false;
526        };
527        rest = next.trim_start();
528    }
529
530    starts_with_keyword(rest, "TABLE") || starts_with_keyword(rest, "VIEW")
531}
532
533fn starts_with_keyword(input: &str, keyword: &str) -> bool {
534    input
535        .get(0..keyword.len())
536        .map(|s| s.eq_ignore_ascii_case(keyword))
537        .unwrap_or(false)
538        && input
539            .as_bytes()
540            .get(keyword.len())
541            .map(|b| !b.is_ascii_alphanumeric() && *b != b'_')
542            .unwrap_or(true)
543}
544
545fn canonicalize_schema_filter(
546    filter: &[String],
547    manifest_schemas: &[String],
548) -> Result<Vec<String>> {
549    let mut canonicalized = Vec::new();
550    let mut seen = HashSet::new();
551
552    for schema in filter {
553        let canonical = manifest_schemas
554            .iter()
555            .find(|candidate| candidate.eq_ignore_ascii_case(schema))
556            .cloned()
557            .ok_or_else(|| {
558                SchemaNotInSnapshotSnafu {
559                    schema: schema.clone(),
560                }
561                .build()
562            })?;
563
564        if seen.insert(canonical.to_ascii_lowercase()) {
565            canonicalized.push(canonical);
566        }
567    }
568
569    Ok(canonicalized)
570}
571
572fn validate_chunk_statuses(chunks: &[ChunkMeta]) -> Result<()> {
573    let invalid_chunk = chunks
574        .iter()
575        .find(|chunk| !matches!(chunk.status, ChunkStatus::Completed | ChunkStatus::Skipped));
576
577    if let Some(chunk) = invalid_chunk {
578        return IncompleteSnapshotSnafu {
579            chunk_id: chunk.id,
580            status: chunk.status,
581        }
582        .fail();
583    }
584
585    Ok(())
586}
587
588fn format_data_import_plan(chunks: &[ChunkMeta], schemas: &[String]) -> Vec<String> {
589    let mut lines = vec!["-- Data import plan:".to_string()];
590    for chunk in chunks {
591        lines.push(format!("-- Chunk {}: {:?}", chunk.id, chunk.status));
592        for schema in schemas {
593            if chunk_has_schema_files(chunk, schema) {
594                lines.push(format!("--   {} -> COPY DATABASE FROM", schema));
595            }
596        }
597    }
598    lines
599}
600
601async fn validate_data_snapshot(
602    storage: &dyn SnapshotStorage,
603    chunks: &[ChunkMeta],
604    schemas: &[String],
605) -> Result<()> {
606    validate_chunk_statuses(chunks)?;
607    let actual_prefixes = collect_chunk_data_prefixes(storage).await?;
608
609    for chunk in chunks {
610        if chunk.status == ChunkStatus::Skipped {
611            continue;
612        }
613        if chunk.files.is_empty() {
614            return EmptyChunkManifestSnafu { chunk_id: chunk.id }.fail();
615        }
616        for schema in schemas {
617            validate_chunk_schema_files(chunk, schema, &actual_prefixes)?;
618        }
619    }
620
621    Ok(())
622}
623
624async fn collect_chunk_data_prefixes(storage: &dyn SnapshotStorage) -> Result<HashSet<String>> {
625    let files = storage
626        .list_files_recursive("data/")
627        .await
628        .context(SnapshotStorageSnafu)?;
629    let mut prefixes = HashSet::new();
630
631    for path in files {
632        let normalized = path.trim_start_matches('/');
633        let mut parts = normalized.splitn(4, '/');
634        let Some(root) = parts.next() else {
635            continue;
636        };
637        let Some(schema) = parts.next() else {
638            continue;
639        };
640        let Some(chunk_id) = parts.next() else {
641            continue;
642        };
643        if root != "data" {
644            continue;
645        }
646        prefixes.insert(format!("data/{schema}/{chunk_id}/"));
647    }
648
649    Ok(prefixes)
650}
651
652fn validate_chunk_schema_files(
653    chunk: &ChunkMeta,
654    schema: &str,
655    actual_prefixes: &HashSet<String>,
656) -> Result<bool> {
657    if !chunk_has_schema_files(chunk, schema) {
658        return Ok(false);
659    }
660
661    let prefix = data_dir_for_schema_chunk(schema, chunk.id);
662    if !actual_prefixes.contains(&prefix) {
663        return MissingChunkDataSnafu {
664            chunk_id: chunk.id,
665            schema: schema.to_string(),
666            path: prefix,
667        }
668        .fail();
669    }
670
671    Ok(true)
672}
673
674#[cfg(test)]
675mod tests {
676    use std::collections::{HashMap, HashSet};
677
678    use async_trait::async_trait;
679
680    use super::*;
681    use crate::data::export_v2::manifest::{ChunkMeta, ChunkStatus, Manifest, TimeRange};
682    use crate::data::export_v2::schema::SchemaSnapshot;
683    use crate::data::snapshot_storage::SnapshotStorage;
684
685    struct StubStorage {
686        manifest: Manifest,
687        files_by_prefix: HashMap<String, Vec<String>>,
688    }
689
690    #[async_trait]
691    impl SnapshotStorage for StubStorage {
692        async fn exists(&self) -> crate::data::export_v2::error::Result<bool> {
693            Ok(true)
694        }
695
696        async fn read_manifest(&self) -> crate::data::export_v2::error::Result<Manifest> {
697            Ok(self.manifest.clone())
698        }
699
700        async fn file_size(
701            &self,
702            _path: &str,
703        ) -> crate::data::export_v2::error::Result<Option<u64>> {
704            unimplemented!("not needed in import_v2::command tests")
705        }
706
707        async fn write_manifest(
708            &self,
709            _manifest: &Manifest,
710        ) -> crate::data::export_v2::error::Result<()> {
711            unimplemented!("not needed in import_v2::command tests")
712        }
713
714        async fn read_text(&self, _path: &str) -> crate::data::export_v2::error::Result<String> {
715            unimplemented!("not needed in import_v2::command tests")
716        }
717
718        async fn write_text(
719            &self,
720            _path: &str,
721            _content: &str,
722        ) -> crate::data::export_v2::error::Result<()> {
723            unimplemented!("not needed in import_v2::command tests")
724        }
725
726        async fn write_schema(
727            &self,
728            _snapshot: &SchemaSnapshot,
729        ) -> crate::data::export_v2::error::Result<()> {
730            unimplemented!("not needed in import_v2::command tests")
731        }
732
733        async fn create_dir_all(&self, _path: &str) -> crate::data::export_v2::error::Result<()> {
734            unimplemented!("not needed in import_v2::command tests")
735        }
736
737        async fn list_files_recursive(
738            &self,
739            prefix: &str,
740        ) -> crate::data::export_v2::error::Result<Vec<String>> {
741            Ok(self
742                .files_by_prefix
743                .iter()
744                .filter(|(candidate, _)| candidate.starts_with(prefix))
745                .flat_map(|(_, files)| files.clone())
746                .collect())
747        }
748
749        async fn delete_snapshot(&self) -> crate::data::export_v2::error::Result<()> {
750            unimplemented!("not needed in import_v2::command tests")
751        }
752    }
753
754    #[tokio::test]
755    async fn packed_capability_failure_precedes_ddl_reads_and_state_writes() {
756        use tokio::io::{AsyncReadExt, AsyncWriteExt};
757        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
758        let address = listener.local_addr().unwrap();
759        let server = tokio::spawn(async move {
760            let (mut socket, _) = listener.accept().await.unwrap();
761            let mut request = vec![0; 4096];
762            let n = socket.read(&mut request).await.unwrap();
763            assert!(String::from_utf8_lossy(&request[..n]).starts_with("GET /v1/capabilities "));
764            socket
765                .write_all(
766                    b"HTTP/1.1 404 Not Found\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}",
767                )
768                .await
769                .unwrap();
770        });
771        let dir = tempfile::tempdir().unwrap();
772        let state_path = dir.path().join("state.json");
773        let mut manifest = Manifest::new_schema_only("greptime".into(), vec!["public".into()]);
774        manifest.version = 2;
775        manifest.data_layout = Some("metric-parquet-packs".into());
776        let import = Import {
777            catalog: "greptime".into(),
778            schemas: None,
779            dry_run: false,
780            progress: ProgressMode::default(),
781            task_parallelism: 1,
782            state_path: Some(state_path.clone()),
783            snapshot_uri: "file:///unused".into(),
784            storage_config: Default::default(),
785            storage: Box::new(StubStorage {
786                manifest,
787                files_by_prefix: Default::default(),
788            }),
789            database_client: DatabaseClient::new(
790                address.to_string(),
791                "greptime".into(),
792                None,
793                std::time::Duration::from_secs(5),
794                None,
795                true,
796            ),
797        };
798        assert!(import.run().await.is_err());
799        assert!(!state_path.exists());
800        server.await.unwrap();
801    }
802
803    fn parse_command(extra: &[&str]) -> ImportV2Command {
804        let mut args = vec![
805            "import-v2",
806            "--addr",
807            "127.0.0.1:4000",
808            "--from",
809            "file:///tmp/snapshot",
810        ];
811        args.extend_from_slice(extra);
812        ImportV2Command::try_parse_from(args).expect("command should parse")
813    }
814
815    #[test]
816    fn test_progress_mode_defaults_to_auto() {
817        assert_eq!(parse_command(&[]).progress, ProgressMode::Auto);
818    }
819
820    #[test]
821    fn test_progress_mode_parses_explicit_values() {
822        assert_eq!(
823            parse_command(&["--progress", "always"]).progress,
824            ProgressMode::Always
825        );
826        assert_eq!(
827            parse_command(&["--progress", "never"]).progress,
828            ProgressMode::Never
829        );
830        assert_eq!(
831            parse_command(&["--progress", "auto"]).progress,
832            ProgressMode::Auto
833        );
834    }
835
836    #[test]
837    fn test_progress_mode_rejects_unknown_value() {
838        assert!(
839            ImportV2Command::try_parse_from([
840                "import-v2",
841                "--addr",
842                "127.0.0.1:4000",
843                "--from",
844                "file:///tmp/snapshot",
845                "--progress",
846                "bogus",
847            ])
848            .is_err()
849        );
850    }
851
852    #[test]
853    fn test_task_parallelism_defaults_to_one() {
854        assert_eq!(parse_command(&[]).task_parallelism, 1);
855    }
856
857    #[test]
858    fn test_task_parallelism_parses_valid_values() {
859        assert_eq!(
860            parse_command(&["--task-parallelism", "2"]).task_parallelism,
861            2
862        );
863        assert_eq!(
864            parse_command(&["--task-parallelism", "64"]).task_parallelism,
865            64
866        );
867    }
868
869    #[test]
870    fn test_state_path_defaults_to_none() {
871        assert_eq!(parse_command(&[]).state_path, None);
872    }
873
874    #[test]
875    fn test_state_path_parses_explicit_value() {
876        assert_eq!(
877            parse_command(&["--state-path", "/tmp/import_state.json"]).state_path,
878            Some(PathBuf::from("/tmp/import_state.json"))
879        );
880    }
881
882    #[test]
883    fn test_resolve_state_path_prefers_override() {
884        let override_path = PathBuf::from("/tmp/custom_import_state.json");
885        let resolved = resolve_state_path(
886            Some(override_path.as_path()),
887            "snapshot-1",
888            "127.0.0.1:4000",
889            "greptime",
890            &["public".to_string()],
891        )
892        .unwrap();
893        assert_eq!(resolved, override_path);
894    }
895
896    #[test]
897    fn test_resolve_state_path_uses_default_when_absent() {
898        let resolved = resolve_state_path(
899            None,
900            "snapshot-1",
901            "127.0.0.1:4000",
902            "greptime",
903            &["public".to_string()],
904        )
905        .unwrap();
906        let expected = default_state_path(
907            "snapshot-1",
908            "127.0.0.1:4000",
909            "greptime",
910            &["public".to_string()],
911        )
912        .unwrap();
913        assert_eq!(resolved, expected);
914    }
915
916    #[test]
917    fn test_task_parallelism_rejects_invalid_values() {
918        for value in ["0", "65", "abc"] {
919            assert!(
920                ImportV2Command::try_parse_from([
921                    "import-v2",
922                    "--addr",
923                    "127.0.0.1:4000",
924                    "--from",
925                    "file:///tmp/snapshot",
926                    "--task-parallelism",
927                    value,
928                ])
929                .is_err(),
930                "value {value} should be rejected"
931            );
932        }
933    }
934
935    #[test]
936    fn test_parse_ddl_statements() {
937        let content = r#"
938-- Schema: public
939CREATE DATABASE public;
940CREATE TABLE t (ts TIMESTAMP TIME INDEX, host STRING, PRIMARY KEY (host)) ENGINE=mito;
941
942-- comment
943CREATE VIEW v AS SELECT * FROM t;
944"#;
945        let statements = parse_ddl_statements(content);
946        assert_eq!(statements.len(), 3);
947        assert!(statements[0].starts_with("CREATE DATABASE public"));
948        assert!(statements[1].starts_with("CREATE TABLE t"));
949        assert!(statements[2].starts_with("CREATE VIEW v"));
950    }
951
952    #[test]
953    fn test_parse_ddl_statements_preserves_semicolons_in_string_literals() {
954        let content = r#"
955CREATE TABLE t (
956    host STRING DEFAULT 'a;b'
957);
958CREATE VIEW v AS SELECT ';' AS marker;
959"#;
960
961        let statements = parse_ddl_statements(content);
962
963        assert_eq!(statements.len(), 2);
964        assert!(statements[0].contains("'a;b'"));
965        assert!(statements[1].contains("';' AS marker"));
966    }
967
968    #[test]
969    fn test_parse_ddl_statements_handles_comments_without_splitting() {
970        let content = r#"
971-- leading comment
972CREATE TABLE t (ts TIMESTAMP TIME INDEX); /* block; comment */
973CREATE VIEW v AS SELECT 1;
974"#;
975
976        let statements = parse_ddl_statements(content);
977
978        assert_eq!(statements.len(), 2);
979        assert!(statements[0].starts_with("CREATE TABLE t"));
980        assert!(statements[1].starts_with("CREATE VIEW v"));
981    }
982
983    #[test]
984    fn test_canonicalize_schema_filter_uses_manifest_casing() {
985        let filter = vec!["TEST_DB".to_string(), "PUBLIC".to_string()];
986        let manifest_schemas = vec!["test_db".to_string(), "public".to_string()];
987
988        let canonicalized = canonicalize_schema_filter(&filter, &manifest_schemas).unwrap();
989
990        assert_eq!(canonicalized, vec!["test_db", "public"]);
991    }
992
993    #[test]
994    fn test_canonicalize_schema_filter_dedupes_case_insensitive_matches() {
995        let filter = vec![
996            "TEST_DB".to_string(),
997            "test_db".to_string(),
998            "PUBLIC".to_string(),
999            "public".to_string(),
1000        ];
1001        let manifest_schemas = vec!["test_db".to_string(), "public".to_string()];
1002
1003        let canonicalized = canonicalize_schema_filter(&filter, &manifest_schemas).unwrap();
1004
1005        assert_eq!(canonicalized, vec!["test_db", "public"]);
1006    }
1007
1008    #[test]
1009    fn test_canonicalize_schema_filter_rejects_missing_schema() {
1010        let filter = vec!["missing".to_string()];
1011        let manifest_schemas = vec!["test_db".to_string()];
1012
1013        let error = canonicalize_schema_filter(&filter, &manifest_schemas)
1014            .expect_err("missing schema should fail")
1015            .to_string();
1016
1017        assert!(error.contains("missing"));
1018    }
1019
1020    #[test]
1021    fn test_ddl_statement_for_schema_create_table_uses_execution_schema() {
1022        let stmt = ddl_statement_for_schema(
1023            "test_db",
1024            "CREATE TABLE metrics (ts TIMESTAMP TIME INDEX) ENGINE=mito".to_string(),
1025        );
1026        assert_eq!(stmt.execution_schema.as_deref(), Some("test_db"));
1027    }
1028
1029    #[test]
1030    fn test_ddl_statement_for_schema_create_view_uses_execution_schema() {
1031        let stmt = ddl_statement_for_schema(
1032            "test_db",
1033            "CREATE VIEW metrics_view AS SELECT * FROM metrics".to_string(),
1034        );
1035        assert_eq!(stmt.execution_schema.as_deref(), Some("test_db"));
1036    }
1037
1038    #[test]
1039    fn test_ddl_statement_for_schema_create_or_replace_view_uses_execution_schema() {
1040        let stmt = ddl_statement_for_schema(
1041            "test_db",
1042            "CREATE OR REPLACE VIEW metrics_view AS SELECT * FROM metrics".to_string(),
1043        );
1044        assert_eq!(stmt.execution_schema.as_deref(), Some("test_db"));
1045    }
1046
1047    #[test]
1048    fn test_ddl_statement_for_schema_create_external_table_uses_execution_schema() {
1049        let stmt = ddl_statement_for_schema(
1050            "test_db",
1051            "CREATE EXTERNAL TABLE IF NOT EXISTS ext_metrics (ts TIMESTAMP TIME INDEX) ENGINE=file"
1052                .to_string(),
1053        );
1054        assert_eq!(stmt.execution_schema.as_deref(), Some("test_db"));
1055    }
1056
1057    #[test]
1058    fn test_ddl_statement_for_schema_create_database_uses_public_context() {
1059        let stmt = ddl_statement_for_schema("test_db", "CREATE DATABASE test_db".to_string());
1060        assert_eq!(stmt.execution_schema, None);
1061    }
1062
1063    #[test]
1064    fn test_starts_with_keyword_requires_word_boundary() {
1065        assert!(starts_with_keyword("CREATE TABLE t", "CREATE"));
1066        assert!(!starts_with_keyword("CREATED TABLE t", "CREATE"));
1067        assert!(!starts_with_keyword("TABLESPACE foo", "TABLE"));
1068    }
1069
1070    #[test]
1071    fn test_validate_chunk_statuses_rejects_failed_chunk() {
1072        let mut failed = ChunkMeta::new(3, TimeRange::unbounded());
1073        failed.status = ChunkStatus::Failed;
1074
1075        let error = validate_chunk_statuses(&[failed]).expect_err("failed chunk should error");
1076        assert!(error.to_string().contains("Incomplete snapshot"));
1077    }
1078
1079    #[test]
1080    fn test_validate_chunk_statuses_accepts_completed_and_skipped_chunks() {
1081        let mut completed = ChunkMeta::new(1, TimeRange::unbounded());
1082        completed.status = ChunkStatus::Completed;
1083        let skipped = ChunkMeta::skipped(2, TimeRange::unbounded());
1084
1085        assert!(validate_chunk_statuses(&[completed, skipped]).is_ok());
1086    }
1087
1088    #[test]
1089    fn test_chunk_has_schema_files_matches_encoded_schema_prefix() {
1090        let mut chunk = ChunkMeta::new(7, TimeRange::unbounded());
1091        chunk.files = vec![
1092            "data/public/7/a.parquet".to_string(),
1093            "data/%E6%B5%8B%E8%AF%95/7/b.parquet".to_string(),
1094        ];
1095
1096        assert!(chunk_has_schema_files(&chunk, "public"));
1097        assert!(chunk_has_schema_files(&chunk, "测试"));
1098        assert!(!chunk_has_schema_files(&chunk, "metrics"));
1099    }
1100
1101    #[test]
1102    fn test_format_data_import_plan_includes_matching_schemas_only() {
1103        let mut completed = ChunkMeta::new(1, TimeRange::unbounded());
1104        completed.status = ChunkStatus::Completed;
1105        completed.files = vec![
1106            "data/public/1/a.parquet".to_string(),
1107            "data/%E6%B5%8B%E8%AF%95/1/b.parquet".to_string(),
1108        ];
1109        let skipped = ChunkMeta::skipped(2, TimeRange::unbounded());
1110
1111        let lines = format_data_import_plan(
1112            &[completed, skipped],
1113            &[
1114                "public".to_string(),
1115                "测试".to_string(),
1116                "metrics".to_string(),
1117            ],
1118        );
1119
1120        assert_eq!(lines[0], "-- Data import plan:");
1121        assert!(lines.contains(&"-- Chunk 1: Completed".to_string()));
1122        assert!(lines.contains(&"--   public -> COPY DATABASE FROM".to_string()));
1123        assert!(lines.contains(&"--   测试 -> COPY DATABASE FROM".to_string()));
1124        assert!(!lines.contains(&"--   metrics -> COPY DATABASE FROM".to_string()));
1125        assert!(lines.contains(&"-- Chunk 2: Skipped".to_string()));
1126    }
1127
1128    #[tokio::test]
1129    async fn test_collect_chunk_data_prefixes_indexes_present_prefixes() {
1130        let storage = StubStorage {
1131            manifest: Manifest::new_schema_only("greptime".to_string(), vec!["public".to_string()]),
1132            files_by_prefix: HashMap::from([
1133                (
1134                    "data/public/7/".to_string(),
1135                    vec!["data/public/7/a.parquet".to_string()],
1136                ),
1137                (
1138                    "data/%E6%B5%8B%E8%AF%95/9/".to_string(),
1139                    vec!["data/%E6%B5%8B%E8%AF%95/9/b.parquet".to_string()],
1140                ),
1141            ]),
1142        };
1143
1144        let prefixes = collect_chunk_data_prefixes(&storage).await.unwrap();
1145
1146        assert!(prefixes.contains("data/public/7/"));
1147        assert!(prefixes.contains("data/%E6%B5%8B%E8%AF%95/9/"));
1148    }
1149
1150    #[test]
1151    fn test_validate_chunk_schema_files_accepts_present_prefix() {
1152        let mut chunk = ChunkMeta::new(7, TimeRange::unbounded());
1153        chunk.files = vec!["data/public/7/a.parquet".to_string()];
1154        let actual_prefixes = HashSet::from(["data/public/7/".to_string()]);
1155
1156        assert!(validate_chunk_schema_files(&chunk, "public", &actual_prefixes).unwrap());
1157    }
1158
1159    #[test]
1160    fn test_validate_chunk_schema_files_rejects_missing_prefix() {
1161        let mut chunk = ChunkMeta::new(7, TimeRange::unbounded());
1162        chunk.files = vec!["data/public/7/a.parquet".to_string()];
1163
1164        let error = validate_chunk_schema_files(&chunk, "public", &HashSet::new())
1165            .expect_err("missing chunk prefix should fail")
1166            .to_string();
1167        assert!(error.contains("marked completed but no files were found"));
1168    }
1169
1170    #[test]
1171    fn test_validate_chunk_schema_files_skips_absent_schema() {
1172        let mut chunk = ChunkMeta::new(7, TimeRange::unbounded());
1173        chunk.files = vec!["data/public/7/a.parquet".to_string()];
1174
1175        assert!(!validate_chunk_schema_files(&chunk, "metrics", &HashSet::new()).unwrap());
1176    }
1177
1178    #[tokio::test]
1179    async fn test_validate_data_snapshot_rejects_failed_chunk_before_dry_run() {
1180        let mut failed = ChunkMeta::new(3, TimeRange::unbounded());
1181        failed.status = ChunkStatus::Failed;
1182
1183        let storage = StubStorage {
1184            manifest: Manifest::new_schema_only("greptime".to_string(), vec!["public".to_string()]),
1185            files_by_prefix: HashMap::new(),
1186        };
1187
1188        let error = validate_data_snapshot(&storage, &[failed], &["public".to_string()])
1189            .await
1190            .expect_err("failed chunk should reject dry-run validation")
1191            .to_string();
1192        assert!(error.contains("Incomplete snapshot"));
1193    }
1194
1195    #[tokio::test]
1196    async fn test_validate_data_snapshot_rejects_missing_chunk_prefix_before_dry_run() {
1197        let mut completed = ChunkMeta::new(7, TimeRange::unbounded());
1198        completed.status = ChunkStatus::Completed;
1199        completed.files = vec!["data/public/7/a.parquet".to_string()];
1200
1201        let storage = StubStorage {
1202            manifest: Manifest::new_schema_only("greptime".to_string(), vec!["public".to_string()]),
1203            files_by_prefix: HashMap::new(),
1204        };
1205
1206        let error = validate_data_snapshot(&storage, &[completed], &["public".to_string()])
1207            .await
1208            .expect_err("missing chunk prefix should reject dry-run validation")
1209            .to_string();
1210        assert!(error.contains("marked completed but no files were found"));
1211    }
1212
1213    #[tokio::test]
1214    async fn test_validate_data_snapshot_rejects_completed_chunk_with_empty_manifest() {
1215        let mut completed = ChunkMeta::new(7, TimeRange::unbounded());
1216        completed.status = ChunkStatus::Completed;
1217
1218        let storage = StubStorage {
1219            manifest: Manifest::new_schema_only("greptime".to_string(), vec!["public".to_string()]),
1220            files_by_prefix: HashMap::new(),
1221        };
1222
1223        let error = validate_data_snapshot(&storage, &[completed], &["public".to_string()])
1224            .await
1225            .expect_err("empty completed chunk should reject validation")
1226            .to_string();
1227        assert!(error.contains("file manifest is empty"));
1228    }
1229}