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        let capabilities = if manifest.is_packed() {
226            let capabilities = self
227                .database_client
228                .capabilities()
229                .await
230                .context(crate::data::import_v2::error::DatabaseSnafu)?;
231            capabilities
232                .require("metric_packed_import")
233                .context(crate::data::import_v2::error::DatabaseSnafu)?;
234            Some(capabilities)
235        } else {
236            None
237        };
238
239        info!("Snapshot contains {} schema(s)", manifest.schemas.len());
240
241        // 2. Determine schemas to import
242        let schemas_to_import = match &self.schemas {
243            Some(filter) => canonicalize_schema_filter(filter, &manifest.schemas)?,
244            None => manifest.schemas.clone(),
245        };
246
247        info!("Importing schemas: {:?}", schemas_to_import);
248
249        // 3. Read DDL statements
250        let ddl_statements = self.read_ddl_statements(&schemas_to_import).await?;
251
252        info!("Generated {} DDL statements", ddl_statements.len());
253
254        let data_tasks = if !manifest.schema_only && !manifest.chunks.is_empty() {
255            validate_data_snapshot(self.storage.as_ref(), &manifest.chunks, &schemas_to_import)
256                .await?;
257            build_import_tasks(&manifest.chunks, &schemas_to_import)
258        } else {
259            Vec::new()
260        };
261
262        if manifest.is_packed() {
263            crate::data::import_v2::packed::validate_snapshot(
264                self.storage.as_ref(),
265                &manifest,
266                &schemas_to_import,
267            )
268            .await?;
269        }
270
271        // 4. Dry-run mode: print DDL and exit
272        if self.dry_run {
273            info!("Dry-run mode - DDL statements to execute:");
274            println!();
275            for (i, stmt) in ddl_statements.iter().enumerate() {
276                println!("-- Statement {}", i + 1);
277                println!("{};", stmt.sql);
278                println!();
279            }
280            if !manifest.schema_only && !manifest.chunks.is_empty() {
281                for line in format_data_import_plan(&manifest.chunks, &schemas_to_import) {
282                    println!("{line}");
283                }
284                println!();
285            }
286            return Ok(());
287        }
288
289        let capabilities = match capabilities {
290            Some(capabilities) => capabilities,
291            None => self
292                .database_client
293                .capabilities()
294                .await
295                .context(crate::data::import_v2::error::DatabaseSnafu)?,
296        };
297
298        let mut resume_session = if !data_tasks.is_empty() {
299            let state_path = resolve_state_path(
300                self.state_path.as_deref(),
301                &manifest.snapshot_id.to_string(),
302                self.database_client.addr(),
303                &self.catalog,
304                &schemas_to_import,
305            )?;
306            Some(
307                prepare_import_resume(ImportResumeConfig {
308                    snapshot_id: manifest.snapshot_id.to_string(),
309                    target_addr: self.database_client.addr().to_string(),
310                    catalog: self.catalog.clone(),
311                    schemas: schemas_to_import.clone(),
312                    state_path,
313                    tasks: data_tasks,
314                    task_parallelism: self.task_parallelism,
315                })
316                .await?,
317            )
318        } else {
319            None
320        };
321
322        let skip_ddl = resume_session
323            .as_ref()
324            .map(|session| session.should_skip_ddl())
325            .unwrap_or(false);
326
327        // 5. Execute DDL unless a previous run already completed it.
328        let ddl_executed = if skip_ddl {
329            info!(
330                "Existing import state has DDL marked completed; skipping DDL execution and resuming data import"
331            );
332            false
333        } else {
334            let executor = DdlExecutor::new(&self.database_client);
335            executor
336                .execute_strict(&ddl_statements, capabilities.supports("metric_batch_ddl"))
337                .await?;
338            if let Some(session) = resume_session.as_mut() {
339                session.mark_ddl_completed().await?;
340            }
341            true
342        };
343
344        if let Some(resume_session) = resume_session {
345            let executor = CopyDatabaseImportTaskExecutor {
346                import: self,
347                format: manifest.format,
348                packed: manifest.is_packed(),
349            };
350            let progress = build_progress_reporter(self.progress);
351            import_with_resume_session_with_progress(resume_session, &executor, progress.as_ref())
352                .await?;
353        }
354
355        if ddl_executed {
356            info!(
357                "Import completed: {} DDL statements executed",
358                ddl_statements.len()
359            );
360        } else {
361            info!("Import completed: DDL execution skipped");
362        }
363
364        Ok(())
365    }
366
367    async fn read_ddl_statements(&self, schemas: &[String]) -> Result<Vec<DdlStatement>> {
368        let mut statements = Vec::new();
369        for schema in schemas {
370            let path = ddl_path_for_schema(schema);
371            let content = self
372                .storage
373                .read_text(&path)
374                .await
375                .context(SnapshotStorageSnafu)?;
376            statements.extend(
377                iter_ddl_statements(&content).map(|sql| ddl_statement_for_schema(schema, sql)),
378            );
379        }
380
381        Ok(statements)
382    }
383}
384
385struct CopyDatabaseImportTaskExecutor<'a> {
386    import: &'a Import,
387    format: DataFormat,
388    packed: bool,
389}
390
391#[async_trait]
392impl ImportTaskExecutor for CopyDatabaseImportTaskExecutor<'_> {
393    async fn import_task(&self, task: &ImportTaskKey) -> Result<()> {
394        let source = build_copy_source(
395            &self.import.snapshot_uri,
396            &self.import.storage_config,
397            &task.schema,
398            task.chunk_id,
399        )
400        .context(ChunkImportFailedSnafu {
401            chunk_id: task.chunk_id,
402            schema: task.schema.clone(),
403        })?;
404
405        execute_copy_database_from(
406            &self.import.database_client,
407            &self.import.catalog,
408            &task.schema,
409            &source,
410            self.format,
411            self.packed,
412        )
413        .await
414        .context(ChunkImportFailedSnafu {
415            chunk_id: task.chunk_id,
416            schema: task.schema.clone(),
417        })
418    }
419}
420
421#[cfg(test)]
422fn parse_ddl_statements(content: &str) -> Vec<String> {
423    iter_ddl_statements(content).collect()
424}
425
426pub(crate) fn iter_ddl_statements(content: &str) -> impl Iterator<Item = String> + '_ {
427    let mut chars = content.chars().peekable();
428    let mut in_single_quote = false;
429    let mut in_double_quote = false;
430    let mut in_line_comment = false;
431    let mut in_block_comment = false;
432
433    std::iter::from_fn(move || {
434        let mut current = String::new();
435        while let Some(ch) = chars.next() {
436            if in_line_comment {
437                if ch == '\n' {
438                    in_line_comment = false;
439                    current.push('\n');
440                }
441                continue;
442            }
443
444            if in_block_comment {
445                if ch == '*' && chars.peek() == Some(&'/') {
446                    chars.next();
447                    in_block_comment = false;
448                }
449                continue;
450            }
451
452            if in_single_quote {
453                current.push(ch);
454                if ch == '\'' {
455                    if chars.peek() == Some(&'\'') {
456                        current.push(chars.next().expect("peeked quote must exist"));
457                    } else {
458                        in_single_quote = false;
459                    }
460                }
461                continue;
462            }
463
464            if in_double_quote {
465                current.push(ch);
466                if ch == '"' {
467                    if chars.peek() == Some(&'"') {
468                        current.push(chars.next().expect("peeked quote must exist"));
469                    } else {
470                        in_double_quote = false;
471                    }
472                }
473                continue;
474            }
475
476            match ch {
477                '-' if chars.peek() == Some(&'-') => {
478                    chars.next();
479                    in_line_comment = true;
480                }
481                '/' if chars.peek() == Some(&'*') => {
482                    chars.next();
483                    in_block_comment = true;
484                }
485                '\'' => {
486                    in_single_quote = true;
487                    current.push(ch);
488                }
489                '"' => {
490                    in_double_quote = true;
491                    current.push(ch);
492                }
493                ';' => {
494                    let statement = current.trim();
495                    if !statement.is_empty() {
496                        return Some(statement.to_string());
497                    }
498                    current.clear();
499                }
500                _ => current.push(ch),
501            }
502        }
503
504        let statement = current.trim();
505        (!statement.is_empty()).then(|| statement.to_string())
506    })
507}
508
509fn ddl_statement_for_schema(schema: &str, sql: String) -> DdlStatement {
510    if is_schema_scoped_statement(&sql) {
511        DdlStatement::with_execution_schema(sql, schema.to_string())
512    } else {
513        DdlStatement::new(sql)
514    }
515}
516
517fn is_schema_scoped_statement(sql: &str) -> bool {
518    let trimmed = sql.trim_start();
519    if !starts_with_keyword(trimmed, "CREATE") {
520        return false;
521    }
522
523    let Some(rest) = trimmed.get("CREATE".len()..) else {
524        return false;
525    };
526    let mut rest = rest.trim_start();
527    if starts_with_keyword(rest, "OR") {
528        let Some(next) = rest.get("OR".len()..) else {
529            return false;
530        };
531        rest = next.trim_start();
532        if !starts_with_keyword(rest, "REPLACE") {
533            return false;
534        }
535        let Some(next) = rest.get("REPLACE".len()..) else {
536            return false;
537        };
538        rest = next.trim_start();
539    }
540
541    if starts_with_keyword(rest, "EXTERNAL") {
542        let Some(next) = rest.get("EXTERNAL".len()..) else {
543            return false;
544        };
545        rest = next.trim_start();
546    }
547
548    starts_with_keyword(rest, "TABLE") || starts_with_keyword(rest, "VIEW")
549}
550
551fn starts_with_keyword(input: &str, keyword: &str) -> bool {
552    input
553        .get(0..keyword.len())
554        .map(|s| s.eq_ignore_ascii_case(keyword))
555        .unwrap_or(false)
556        && input
557            .as_bytes()
558            .get(keyword.len())
559            .map(|b| !b.is_ascii_alphanumeric() && *b != b'_')
560            .unwrap_or(true)
561}
562
563fn canonicalize_schema_filter(
564    filter: &[String],
565    manifest_schemas: &[String],
566) -> Result<Vec<String>> {
567    let mut canonicalized = Vec::new();
568    let mut seen = HashSet::new();
569
570    for schema in filter {
571        let canonical = manifest_schemas
572            .iter()
573            .find(|candidate| candidate.eq_ignore_ascii_case(schema))
574            .cloned()
575            .ok_or_else(|| {
576                SchemaNotInSnapshotSnafu {
577                    schema: schema.clone(),
578                }
579                .build()
580            })?;
581
582        if seen.insert(canonical.to_ascii_lowercase()) {
583            canonicalized.push(canonical);
584        }
585    }
586
587    Ok(canonicalized)
588}
589
590fn validate_chunk_statuses(chunks: &[ChunkMeta]) -> Result<()> {
591    let invalid_chunk = chunks
592        .iter()
593        .find(|chunk| !matches!(chunk.status, ChunkStatus::Completed | ChunkStatus::Skipped));
594
595    if let Some(chunk) = invalid_chunk {
596        return IncompleteSnapshotSnafu {
597            chunk_id: chunk.id,
598            status: chunk.status,
599        }
600        .fail();
601    }
602
603    Ok(())
604}
605
606fn format_data_import_plan(chunks: &[ChunkMeta], schemas: &[String]) -> Vec<String> {
607    let mut lines = vec!["-- Data import plan:".to_string()];
608    for chunk in chunks {
609        lines.push(format!("-- Chunk {}: {:?}", chunk.id, chunk.status));
610        for schema in schemas {
611            if chunk_has_schema_files(chunk, schema) {
612                lines.push(format!("--   {} -> COPY DATABASE FROM", schema));
613            }
614        }
615    }
616    lines
617}
618
619async fn validate_data_snapshot(
620    storage: &dyn SnapshotStorage,
621    chunks: &[ChunkMeta],
622    schemas: &[String],
623) -> Result<()> {
624    validate_chunk_statuses(chunks)?;
625    let actual_prefixes = collect_chunk_data_prefixes(storage).await?;
626
627    for chunk in chunks {
628        if chunk.status == ChunkStatus::Skipped {
629            continue;
630        }
631        if chunk.files.is_empty() {
632            return EmptyChunkManifestSnafu { chunk_id: chunk.id }.fail();
633        }
634        for schema in schemas {
635            validate_chunk_schema_files(chunk, schema, &actual_prefixes)?;
636        }
637    }
638
639    Ok(())
640}
641
642async fn collect_chunk_data_prefixes(storage: &dyn SnapshotStorage) -> Result<HashSet<String>> {
643    let files = storage
644        .list_files_recursive("data/")
645        .await
646        .context(SnapshotStorageSnafu)?;
647    let mut prefixes = HashSet::new();
648
649    for path in files {
650        let normalized = path.trim_start_matches('/');
651        let mut parts = normalized.splitn(4, '/');
652        let Some(root) = parts.next() else {
653            continue;
654        };
655        let Some(schema) = parts.next() else {
656            continue;
657        };
658        let Some(chunk_id) = parts.next() else {
659            continue;
660        };
661        if root != "data" {
662            continue;
663        }
664        prefixes.insert(format!("data/{schema}/{chunk_id}/"));
665    }
666
667    Ok(prefixes)
668}
669
670fn validate_chunk_schema_files(
671    chunk: &ChunkMeta,
672    schema: &str,
673    actual_prefixes: &HashSet<String>,
674) -> Result<bool> {
675    if !chunk_has_schema_files(chunk, schema) {
676        return Ok(false);
677    }
678
679    let prefix = data_dir_for_schema_chunk(schema, chunk.id);
680    if !actual_prefixes.contains(&prefix) {
681        return MissingChunkDataSnafu {
682            chunk_id: chunk.id,
683            schema: schema.to_string(),
684            path: prefix,
685        }
686        .fail();
687    }
688
689    Ok(true)
690}
691
692#[cfg(test)]
693mod tests {
694    use std::collections::{HashMap, HashSet};
695
696    use async_trait::async_trait;
697
698    use super::*;
699    use crate::data::export_v2::manifest::{ChunkMeta, ChunkStatus, Manifest, TimeRange};
700    use crate::data::export_v2::schema::SchemaSnapshot;
701    use crate::data::snapshot_storage::SnapshotStorage;
702
703    struct StubStorage {
704        manifest: Manifest,
705        files_by_prefix: HashMap<String, Vec<String>>,
706    }
707
708    #[async_trait]
709    impl SnapshotStorage for StubStorage {
710        async fn exists(&self) -> crate::data::export_v2::error::Result<bool> {
711            Ok(true)
712        }
713
714        async fn read_manifest(&self) -> crate::data::export_v2::error::Result<Manifest> {
715            Ok(self.manifest.clone())
716        }
717
718        async fn file_size(
719            &self,
720            _path: &str,
721        ) -> crate::data::export_v2::error::Result<Option<u64>> {
722            unimplemented!("not needed in import_v2::command tests")
723        }
724
725        async fn write_manifest(
726            &self,
727            _manifest: &Manifest,
728        ) -> crate::data::export_v2::error::Result<()> {
729            unimplemented!("not needed in import_v2::command tests")
730        }
731
732        async fn read_text(&self, _path: &str) -> crate::data::export_v2::error::Result<String> {
733            unimplemented!("not needed in import_v2::command tests")
734        }
735
736        async fn write_text(
737            &self,
738            _path: &str,
739            _content: &str,
740        ) -> crate::data::export_v2::error::Result<()> {
741            unimplemented!("not needed in import_v2::command tests")
742        }
743
744        async fn write_schema(
745            &self,
746            _snapshot: &SchemaSnapshot,
747        ) -> crate::data::export_v2::error::Result<()> {
748            unimplemented!("not needed in import_v2::command tests")
749        }
750
751        async fn create_dir_all(&self, _path: &str) -> crate::data::export_v2::error::Result<()> {
752            unimplemented!("not needed in import_v2::command tests")
753        }
754
755        async fn list_files_recursive(
756            &self,
757            prefix: &str,
758        ) -> crate::data::export_v2::error::Result<Vec<String>> {
759            Ok(self
760                .files_by_prefix
761                .iter()
762                .filter(|(candidate, _)| candidate.starts_with(prefix))
763                .flat_map(|(_, files)| files.clone())
764                .collect())
765        }
766
767        async fn delete_snapshot(&self) -> crate::data::export_v2::error::Result<()> {
768            unimplemented!("not needed in import_v2::command tests")
769        }
770    }
771
772    #[tokio::test]
773    async fn packed_capability_failure_precedes_ddl_reads_and_state_writes() {
774        use tokio::io::{AsyncReadExt, AsyncWriteExt};
775        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
776        let address = listener.local_addr().unwrap();
777        let server = tokio::spawn(async move {
778            let (mut socket, _) = listener.accept().await.unwrap();
779            let mut request = vec![0; 4096];
780            let n = socket.read(&mut request).await.unwrap();
781            assert!(String::from_utf8_lossy(&request[..n]).starts_with("GET /v1/capabilities "));
782            socket
783                .write_all(
784                    b"HTTP/1.1 404 Not Found\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}",
785                )
786                .await
787                .unwrap();
788        });
789        let dir = tempfile::tempdir().unwrap();
790        let state_path = dir.path().join("state.json");
791        let mut manifest = Manifest::new_schema_only("greptime".into(), vec!["public".into()]);
792        manifest.version = 2;
793        manifest.data_layout = Some("metric-parquet-packs".into());
794        let import = Import {
795            catalog: "greptime".into(),
796            schemas: None,
797            dry_run: false,
798            progress: ProgressMode::default(),
799            task_parallelism: 1,
800            state_path: Some(state_path.clone()),
801            snapshot_uri: "file:///unused".into(),
802            storage_config: Default::default(),
803            storage: Box::new(StubStorage {
804                manifest,
805                files_by_prefix: Default::default(),
806            }),
807            database_client: DatabaseClient::new(
808                address.to_string(),
809                "greptime".into(),
810                None,
811                std::time::Duration::from_secs(5),
812                None,
813                true,
814            ),
815        };
816        assert!(import.run().await.is_err());
817        assert!(!state_path.exists());
818        server.await.unwrap();
819    }
820
821    fn parse_command(extra: &[&str]) -> ImportV2Command {
822        let mut args = vec![
823            "import-v2",
824            "--addr",
825            "127.0.0.1:4000",
826            "--from",
827            "file:///tmp/snapshot",
828        ];
829        args.extend_from_slice(extra);
830        ImportV2Command::try_parse_from(args).expect("command should parse")
831    }
832
833    #[test]
834    fn test_progress_mode_defaults_to_auto() {
835        assert_eq!(parse_command(&[]).progress, ProgressMode::Auto);
836    }
837
838    #[test]
839    fn test_progress_mode_parses_explicit_values() {
840        assert_eq!(
841            parse_command(&["--progress", "always"]).progress,
842            ProgressMode::Always
843        );
844        assert_eq!(
845            parse_command(&["--progress", "never"]).progress,
846            ProgressMode::Never
847        );
848        assert_eq!(
849            parse_command(&["--progress", "auto"]).progress,
850            ProgressMode::Auto
851        );
852    }
853
854    #[test]
855    fn test_progress_mode_rejects_unknown_value() {
856        assert!(
857            ImportV2Command::try_parse_from([
858                "import-v2",
859                "--addr",
860                "127.0.0.1:4000",
861                "--from",
862                "file:///tmp/snapshot",
863                "--progress",
864                "bogus",
865            ])
866            .is_err()
867        );
868    }
869
870    #[test]
871    fn test_task_parallelism_defaults_to_one() {
872        assert_eq!(parse_command(&[]).task_parallelism, 1);
873    }
874
875    #[test]
876    fn test_task_parallelism_parses_valid_values() {
877        assert_eq!(
878            parse_command(&["--task-parallelism", "2"]).task_parallelism,
879            2
880        );
881        assert_eq!(
882            parse_command(&["--task-parallelism", "64"]).task_parallelism,
883            64
884        );
885    }
886
887    #[test]
888    fn test_state_path_defaults_to_none() {
889        assert_eq!(parse_command(&[]).state_path, None);
890    }
891
892    #[test]
893    fn test_state_path_parses_explicit_value() {
894        assert_eq!(
895            parse_command(&["--state-path", "/tmp/import_state.json"]).state_path,
896            Some(PathBuf::from("/tmp/import_state.json"))
897        );
898    }
899
900    #[test]
901    fn test_resolve_state_path_prefers_override() {
902        let override_path = PathBuf::from("/tmp/custom_import_state.json");
903        let resolved = resolve_state_path(
904            Some(override_path.as_path()),
905            "snapshot-1",
906            "127.0.0.1:4000",
907            "greptime",
908            &["public".to_string()],
909        )
910        .unwrap();
911        assert_eq!(resolved, override_path);
912    }
913
914    #[test]
915    fn test_resolve_state_path_uses_default_when_absent() {
916        let resolved = resolve_state_path(
917            None,
918            "snapshot-1",
919            "127.0.0.1:4000",
920            "greptime",
921            &["public".to_string()],
922        )
923        .unwrap();
924        let expected = default_state_path(
925            "snapshot-1",
926            "127.0.0.1:4000",
927            "greptime",
928            &["public".to_string()],
929        )
930        .unwrap();
931        assert_eq!(resolved, expected);
932    }
933
934    #[test]
935    fn test_task_parallelism_rejects_invalid_values() {
936        for value in ["0", "65", "abc"] {
937            assert!(
938                ImportV2Command::try_parse_from([
939                    "import-v2",
940                    "--addr",
941                    "127.0.0.1:4000",
942                    "--from",
943                    "file:///tmp/snapshot",
944                    "--task-parallelism",
945                    value,
946                ])
947                .is_err(),
948                "value {value} should be rejected"
949            );
950        }
951    }
952
953    #[test]
954    fn test_parse_ddl_statements() {
955        let content = r#"
956-- Schema: public
957CREATE DATABASE public;
958CREATE TABLE t (ts TIMESTAMP TIME INDEX, host STRING, PRIMARY KEY (host)) ENGINE=mito;
959
960-- comment
961CREATE VIEW v AS SELECT * FROM t;
962"#;
963        let statements = parse_ddl_statements(content);
964        assert_eq!(statements.len(), 3);
965        assert!(statements[0].starts_with("CREATE DATABASE public"));
966        assert!(statements[1].starts_with("CREATE TABLE t"));
967        assert!(statements[2].starts_with("CREATE VIEW v"));
968    }
969
970    #[test]
971    fn test_parse_ddl_statements_preserves_semicolons_in_string_literals() {
972        let content = r#"
973CREATE TABLE t (
974    host STRING DEFAULT 'a;b'
975);
976CREATE VIEW v AS SELECT ';' AS marker;
977"#;
978
979        let statements = parse_ddl_statements(content);
980
981        assert_eq!(statements.len(), 2);
982        assert!(statements[0].contains("'a;b'"));
983        assert!(statements[1].contains("';' AS marker"));
984    }
985
986    #[test]
987    fn test_parse_ddl_statements_handles_comments_without_splitting() {
988        let content = r#"
989-- leading comment
990CREATE TABLE t (ts TIMESTAMP TIME INDEX); /* block; comment */
991CREATE VIEW v AS SELECT 1;
992"#;
993
994        let statements = parse_ddl_statements(content);
995
996        assert_eq!(statements.len(), 2);
997        assert!(statements[0].starts_with("CREATE TABLE t"));
998        assert!(statements[1].starts_with("CREATE VIEW v"));
999    }
1000
1001    #[test]
1002    fn test_canonicalize_schema_filter_uses_manifest_casing() {
1003        let filter = vec!["TEST_DB".to_string(), "PUBLIC".to_string()];
1004        let manifest_schemas = vec!["test_db".to_string(), "public".to_string()];
1005
1006        let canonicalized = canonicalize_schema_filter(&filter, &manifest_schemas).unwrap();
1007
1008        assert_eq!(canonicalized, vec!["test_db", "public"]);
1009    }
1010
1011    #[test]
1012    fn test_canonicalize_schema_filter_dedupes_case_insensitive_matches() {
1013        let filter = vec![
1014            "TEST_DB".to_string(),
1015            "test_db".to_string(),
1016            "PUBLIC".to_string(),
1017            "public".to_string(),
1018        ];
1019        let manifest_schemas = vec!["test_db".to_string(), "public".to_string()];
1020
1021        let canonicalized = canonicalize_schema_filter(&filter, &manifest_schemas).unwrap();
1022
1023        assert_eq!(canonicalized, vec!["test_db", "public"]);
1024    }
1025
1026    #[test]
1027    fn test_canonicalize_schema_filter_rejects_missing_schema() {
1028        let filter = vec!["missing".to_string()];
1029        let manifest_schemas = vec!["test_db".to_string()];
1030
1031        let error = canonicalize_schema_filter(&filter, &manifest_schemas)
1032            .expect_err("missing schema should fail")
1033            .to_string();
1034
1035        assert!(error.contains("missing"));
1036    }
1037
1038    #[test]
1039    fn test_ddl_statement_for_schema_create_table_uses_execution_schema() {
1040        let stmt = ddl_statement_for_schema(
1041            "test_db",
1042            "CREATE TABLE metrics (ts TIMESTAMP TIME INDEX) ENGINE=mito".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_view_uses_execution_schema() {
1049        let stmt = ddl_statement_for_schema(
1050            "test_db",
1051            "CREATE VIEW metrics_view AS SELECT * FROM metrics".to_string(),
1052        );
1053        assert_eq!(stmt.execution_schema.as_deref(), Some("test_db"));
1054    }
1055
1056    #[test]
1057    fn test_ddl_statement_for_schema_create_or_replace_view_uses_execution_schema() {
1058        let stmt = ddl_statement_for_schema(
1059            "test_db",
1060            "CREATE OR REPLACE VIEW metrics_view AS SELECT * FROM metrics".to_string(),
1061        );
1062        assert_eq!(stmt.execution_schema.as_deref(), Some("test_db"));
1063    }
1064
1065    #[test]
1066    fn test_ddl_statement_for_schema_create_external_table_uses_execution_schema() {
1067        let stmt = ddl_statement_for_schema(
1068            "test_db",
1069            "CREATE EXTERNAL TABLE IF NOT EXISTS ext_metrics (ts TIMESTAMP TIME INDEX) ENGINE=file"
1070                .to_string(),
1071        );
1072        assert_eq!(stmt.execution_schema.as_deref(), Some("test_db"));
1073    }
1074
1075    #[test]
1076    fn test_ddl_statement_for_schema_create_database_uses_public_context() {
1077        let stmt = ddl_statement_for_schema("test_db", "CREATE DATABASE test_db".to_string());
1078        assert_eq!(stmt.execution_schema, None);
1079    }
1080
1081    #[test]
1082    fn test_starts_with_keyword_requires_word_boundary() {
1083        assert!(starts_with_keyword("CREATE TABLE t", "CREATE"));
1084        assert!(!starts_with_keyword("CREATED TABLE t", "CREATE"));
1085        assert!(!starts_with_keyword("TABLESPACE foo", "TABLE"));
1086    }
1087
1088    #[test]
1089    fn test_validate_chunk_statuses_rejects_failed_chunk() {
1090        let mut failed = ChunkMeta::new(3, TimeRange::unbounded());
1091        failed.status = ChunkStatus::Failed;
1092
1093        let error = validate_chunk_statuses(&[failed]).expect_err("failed chunk should error");
1094        assert!(error.to_string().contains("Incomplete snapshot"));
1095    }
1096
1097    #[test]
1098    fn test_validate_chunk_statuses_accepts_completed_and_skipped_chunks() {
1099        let mut completed = ChunkMeta::new(1, TimeRange::unbounded());
1100        completed.status = ChunkStatus::Completed;
1101        let skipped = ChunkMeta::skipped(2, TimeRange::unbounded());
1102
1103        assert!(validate_chunk_statuses(&[completed, skipped]).is_ok());
1104    }
1105
1106    #[test]
1107    fn test_chunk_has_schema_files_matches_encoded_schema_prefix() {
1108        let mut chunk = ChunkMeta::new(7, TimeRange::unbounded());
1109        chunk.files = vec![
1110            "data/public/7/a.parquet".to_string(),
1111            "data/%E6%B5%8B%E8%AF%95/7/b.parquet".to_string(),
1112        ];
1113
1114        assert!(chunk_has_schema_files(&chunk, "public"));
1115        assert!(chunk_has_schema_files(&chunk, "测试"));
1116        assert!(!chunk_has_schema_files(&chunk, "metrics"));
1117    }
1118
1119    #[test]
1120    fn test_format_data_import_plan_includes_matching_schemas_only() {
1121        let mut completed = ChunkMeta::new(1, TimeRange::unbounded());
1122        completed.status = ChunkStatus::Completed;
1123        completed.files = vec![
1124            "data/public/1/a.parquet".to_string(),
1125            "data/%E6%B5%8B%E8%AF%95/1/b.parquet".to_string(),
1126        ];
1127        let skipped = ChunkMeta::skipped(2, TimeRange::unbounded());
1128
1129        let lines = format_data_import_plan(
1130            &[completed, skipped],
1131            &[
1132                "public".to_string(),
1133                "测试".to_string(),
1134                "metrics".to_string(),
1135            ],
1136        );
1137
1138        assert_eq!(lines[0], "-- Data import plan:");
1139        assert!(lines.contains(&"-- Chunk 1: Completed".to_string()));
1140        assert!(lines.contains(&"--   public -> COPY DATABASE FROM".to_string()));
1141        assert!(lines.contains(&"--   测试 -> COPY DATABASE FROM".to_string()));
1142        assert!(!lines.contains(&"--   metrics -> COPY DATABASE FROM".to_string()));
1143        assert!(lines.contains(&"-- Chunk 2: Skipped".to_string()));
1144    }
1145
1146    #[tokio::test]
1147    async fn test_collect_chunk_data_prefixes_indexes_present_prefixes() {
1148        let storage = StubStorage {
1149            manifest: Manifest::new_schema_only("greptime".to_string(), vec!["public".to_string()]),
1150            files_by_prefix: HashMap::from([
1151                (
1152                    "data/public/7/".to_string(),
1153                    vec!["data/public/7/a.parquet".to_string()],
1154                ),
1155                (
1156                    "data/%E6%B5%8B%E8%AF%95/9/".to_string(),
1157                    vec!["data/%E6%B5%8B%E8%AF%95/9/b.parquet".to_string()],
1158                ),
1159            ]),
1160        };
1161
1162        let prefixes = collect_chunk_data_prefixes(&storage).await.unwrap();
1163
1164        assert!(prefixes.contains("data/public/7/"));
1165        assert!(prefixes.contains("data/%E6%B5%8B%E8%AF%95/9/"));
1166    }
1167
1168    #[test]
1169    fn test_validate_chunk_schema_files_accepts_present_prefix() {
1170        let mut chunk = ChunkMeta::new(7, TimeRange::unbounded());
1171        chunk.files = vec!["data/public/7/a.parquet".to_string()];
1172        let actual_prefixes = HashSet::from(["data/public/7/".to_string()]);
1173
1174        assert!(validate_chunk_schema_files(&chunk, "public", &actual_prefixes).unwrap());
1175    }
1176
1177    #[test]
1178    fn test_validate_chunk_schema_files_rejects_missing_prefix() {
1179        let mut chunk = ChunkMeta::new(7, TimeRange::unbounded());
1180        chunk.files = vec!["data/public/7/a.parquet".to_string()];
1181
1182        let error = validate_chunk_schema_files(&chunk, "public", &HashSet::new())
1183            .expect_err("missing chunk prefix should fail")
1184            .to_string();
1185        assert!(error.contains("marked completed but no files were found"));
1186    }
1187
1188    #[test]
1189    fn test_validate_chunk_schema_files_skips_absent_schema() {
1190        let mut chunk = ChunkMeta::new(7, TimeRange::unbounded());
1191        chunk.files = vec!["data/public/7/a.parquet".to_string()];
1192
1193        assert!(!validate_chunk_schema_files(&chunk, "metrics", &HashSet::new()).unwrap());
1194    }
1195
1196    #[tokio::test]
1197    async fn test_validate_data_snapshot_rejects_failed_chunk_before_dry_run() {
1198        let mut failed = ChunkMeta::new(3, TimeRange::unbounded());
1199        failed.status = ChunkStatus::Failed;
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, &[failed], &["public".to_string()])
1207            .await
1208            .expect_err("failed chunk should reject dry-run validation")
1209            .to_string();
1210        assert!(error.contains("Incomplete snapshot"));
1211    }
1212
1213    #[tokio::test]
1214    async fn test_validate_data_snapshot_rejects_missing_chunk_prefix_before_dry_run() {
1215        let mut completed = ChunkMeta::new(7, TimeRange::unbounded());
1216        completed.status = ChunkStatus::Completed;
1217        completed.files = vec!["data/public/7/a.parquet".to_string()];
1218
1219        let storage = StubStorage {
1220            manifest: Manifest::new_schema_only("greptime".to_string(), vec!["public".to_string()]),
1221            files_by_prefix: HashMap::new(),
1222        };
1223
1224        let error = validate_data_snapshot(&storage, &[completed], &["public".to_string()])
1225            .await
1226            .expect_err("missing chunk prefix should reject dry-run validation")
1227            .to_string();
1228        assert!(error.contains("marked completed but no files were found"));
1229    }
1230
1231    #[tokio::test]
1232    async fn test_validate_data_snapshot_rejects_completed_chunk_with_empty_manifest() {
1233        let mut completed = ChunkMeta::new(7, TimeRange::unbounded());
1234        completed.status = ChunkStatus::Completed;
1235
1236        let storage = StubStorage {
1237            manifest: Manifest::new_schema_only("greptime".to_string(), vec!["public".to_string()]),
1238            files_by_prefix: HashMap::new(),
1239        };
1240
1241        let error = validate_data_snapshot(&storage, &[completed], &["public".to_string()])
1242            .await
1243            .expect_err("empty completed chunk should reject validation")
1244            .to_string();
1245        assert!(error.contains("file manifest is empty"));
1246    }
1247}