1use 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#[derive(Debug, Parser)]
49pub struct ImportV2Command {
50 #[clap(long)]
52 addr: String,
53
54 #[clap(long)]
56 from: String,
57
58 #[clap(long, default_value = "greptime")]
60 catalog: String,
61
62 #[clap(long, value_delimiter = ',')]
65 schemas: Vec<String>,
66
67 #[clap(long)]
69 dry_run: bool,
70
71 #[clap(long, value_enum, default_value_t = ProgressMode::Auto)]
73 progress: ProgressMode,
74
75 #[clap(long, default_value = "1", value_parser = parse_task_parallelism)]
77 task_parallelism: usize,
78
79 #[clap(long)]
83 state_path: Option<PathBuf>,
84
85 #[clap(long)]
87 auth_basic: Option<String>,
88
89 #[clap(long, value_parser = humantime::parse_duration)]
91 timeout: Option<Duration>,
92
93 #[clap(long)]
98 proxy: Option<String>,
99
100 #[clap(long)]
104 no_proxy: bool,
105
106 #[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(&self.from)
115 .context(SnapshotStorageSnafu)
116 .map_err(BoxedError::new)?;
117
118 let schemas = if self.schemas.is_empty() {
120 None
121 } else {
122 Some(self.schemas.clone())
123 };
124
125 let storage = OpenDalStorage::from_uri(&self.from, &self.storage)
127 .context(SnapshotStorageSnafu)
128 .map_err(BoxedError::new)?;
129
130 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
156fn 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(¶llelism) {
181 Ok(parallelism)
182 } else {
183 Err("task parallelism must be between 1 and 64".to_string())
184 }
185}
186
187pub 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 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 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 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 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 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}