Skip to main content

cli/data/import_v2/
coordinator.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
15use std::collections::BTreeSet;
16use std::path::{Path, PathBuf};
17use std::time::Instant;
18
19use async_trait::async_trait;
20use common_telemetry::{info, warn};
21use futures::StreamExt;
22use futures::stream::FuturesUnordered;
23
24use crate::data::export_v2::manifest::{ChunkMeta, ChunkStatus};
25use crate::data::import_v2::error::{
26    Error, ImportStateDdlIncompleteSnafu, ImportStateMismatchSnafu, Result,
27};
28use crate::data::import_v2::state::{
29    ImportState, ImportStateLockGuard, ImportTaskKey, ImportTaskStatus, canonical_schema_selection,
30    delete_import_state, load_import_state, save_import_state, try_acquire_import_state_lock,
31};
32use crate::data::path::data_dir_for_schema_chunk;
33#[cfg(test)]
34use crate::data::progress::NoopProgress;
35use crate::data::progress::{ProgressPhase, ProgressReporter};
36
37#[async_trait]
38pub(crate) trait ImportTaskExecutor {
39    async fn import_task(&self, task: &ImportTaskKey) -> Result<()>;
40}
41
42pub(crate) struct ImportResumeConfig {
43    pub(crate) snapshot_id: String,
44    pub(crate) target_addr: String,
45    pub(crate) catalog: String,
46    pub(crate) schemas: Vec<String>,
47    pub(crate) state_path: PathBuf,
48    pub(crate) tasks: Vec<ImportTaskKey>,
49    /// Number of data tasks to run concurrently. `1` preserves serial behavior.
50    pub(crate) task_parallelism: usize,
51}
52
53pub(crate) struct ImportResumeSession {
54    config: ImportResumeConfig,
55    state: ImportState,
56    lock: ImportStateLockGuard,
57}
58
59impl ImportResumeSession {
60    pub(crate) fn should_skip_ddl(&self) -> bool {
61        self.state.ddl_completed
62    }
63
64    /// Marks DDL as completed and persists the state. Must be called after a
65    /// successful DDL run on a fresh session, so that crashes after this point
66    /// resume into the data-import phase instead of replaying DDL.
67    pub(crate) async fn mark_ddl_completed(&mut self) -> Result<()> {
68        self.state.mark_ddl_completed();
69        save_import_state(&self.config.state_path, &self.state).await
70    }
71}
72
73pub(crate) fn chunk_has_schema_files(chunk: &ChunkMeta, schema: &str) -> bool {
74    let prefix = data_dir_for_schema_chunk(schema, chunk.id);
75    chunk.files.iter().any(|path| {
76        let normalized = path.trim_start_matches('/');
77        normalized.starts_with(&prefix)
78    })
79}
80
81pub(crate) fn build_import_tasks(chunks: &[ChunkMeta], schemas: &[String]) -> Vec<ImportTaskKey> {
82    let mut tasks = Vec::new();
83    for chunk in chunks {
84        if chunk.status == ChunkStatus::Skipped {
85            continue;
86        }
87        // TODO: build a per-chunk schema index if chunk file manifests become large.
88        for schema in schemas {
89            if chunk_has_schema_files(chunk, schema) {
90                tasks.push(ImportTaskKey::new(chunk.id, schema.clone()));
91            }
92        }
93    }
94    tasks
95}
96
97pub(crate) async fn prepare_import_resume(
98    config: ImportResumeConfig,
99) -> Result<ImportResumeSession> {
100    // Validate the request before touching the state file or acquiring the
101    // lock. Duplicate task keys would corrupt the resume bookkeeping because
102    // status lookups use linear `find()` and only ever see the first match.
103    validate_config_tasks(&config)?;
104
105    let lock = try_acquire_import_state_lock(&config.state_path)?;
106    let state = match load_import_state(&config.state_path).await? {
107        Some(loaded) => {
108            validate_state_matches(&loaded, &config)?;
109            loaded
110        }
111        None => {
112            // Persist a fresh state immediately so that any crash after this
113            // point is recoverable as a resume. `ddl_completed=false` on a
114            // loaded state therefore means a previous run reached this point
115            // but did not confirm DDL completion - DDL must be (re-)run before
116            // data import is allowed.
117            let fresh = ImportState::new(
118                &config.snapshot_id,
119                &config.target_addr,
120                &config.catalog,
121                &config.schemas,
122                config.tasks.clone(),
123            );
124            save_import_state(&config.state_path, &fresh).await?;
125            fresh
126        }
127    };
128
129    Ok(ImportResumeSession {
130        config,
131        state,
132        lock,
133    })
134}
135
136#[cfg(test)]
137pub(crate) async fn import_with_resume_session<E>(
138    session: ImportResumeSession,
139    executor: &E,
140) -> Result<()>
141where
142    E: ImportTaskExecutor + Sync,
143{
144    let progress = NoopProgress;
145    import_with_resume_session_with_progress(session, executor, &progress).await
146}
147
148pub(crate) async fn import_with_resume_session_with_progress<E>(
149    session: ImportResumeSession,
150    executor: &E,
151    progress: &dyn ProgressReporter,
152) -> Result<()>
153where
154    E: ImportTaskExecutor + Sync,
155{
156    let ImportResumeSession {
157        config,
158        mut state,
159        lock,
160    } = session;
161
162    // The state machine requires DDL to be explicitly marked completed before
163    // data import; otherwise a caller could import data and leave a state that
164    // replays DDL on the next resume. Surface the misuse instead of silently
165    // importing.
166    if !state.ddl_completed {
167        return ImportStateDdlIncompleteSnafu {
168            path: config.state_path.display().to_string(),
169        }
170        .fail();
171    }
172
173    let completed = state
174        .tasks
175        .iter()
176        .filter(|task| task.status == ImportTaskStatus::Completed)
177        .count();
178    info!(
179        "Import resume state: {} completed, {} pending, path: {}",
180        completed,
181        state.tasks.len().saturating_sub(completed),
182        config.state_path.display()
183    );
184
185    // One progress unit per import task. Already-completed tasks still count so
186    // the reported total matches the task plan regardless of resume position.
187    let progress_phase = ProgressPhase::start(
188        progress,
189        "Import data tasks",
190        Some(config.tasks.len() as u64),
191    );
192
193    let import_start = Instant::now();
194    let result = if config.task_parallelism <= 1 {
195        import_tasks_serial(&config, &mut state, executor, progress).await
196    } else {
197        import_tasks_concurrent(
198            &config,
199            &mut state,
200            executor,
201            config.task_parallelism,
202            progress,
203        )
204        .await
205    };
206    progress_phase.finish();
207
208    // On failure, leave the state file in place so a later run can resume.
209    result?;
210
211    delete_import_state(&config.state_path).await?;
212    info!("Data import finished in {:?}", import_start.elapsed());
213    drop(lock);
214    Ok(())
215}
216
217/// Imports data tasks one at a time, preserving the original serial behavior.
218async fn import_tasks_serial<E>(
219    config: &ImportResumeConfig,
220    state: &mut ImportState,
221    executor: &E,
222    progress: &dyn ProgressReporter,
223) -> Result<()>
224where
225    E: ImportTaskExecutor + Sync,
226{
227    for (idx, task) in config.tasks.iter().enumerate() {
228        if state.task_status(task.chunk_id, &task.schema) == Some(ImportTaskStatus::Completed) {
229            info!(
230                "[{}/{}] Chunk {} schema {}: already completed, skipped",
231                idx + 1,
232                config.tasks.len(),
233                task.chunk_id,
234                task.schema
235            );
236            progress.inc(1);
237            continue;
238        }
239
240        info!(
241            "[{}/{}] Chunk {} schema {}: importing...",
242            idx + 1,
243            config.tasks.len(),
244            task.chunk_id,
245            task.schema
246        );
247        state.set_task_status(
248            task.chunk_id,
249            &task.schema,
250            ImportTaskStatus::InProgress,
251            None,
252        )?;
253        save_import_state(&config.state_path, state).await?;
254
255        let task_start = Instant::now();
256        let result = executor.import_task(task).await;
257
258        match result {
259            Ok(()) => {
260                // The task itself succeeded. If we cannot persist the
261                // Completed marker, the next resume will replay it (potentially
262                // duplicating data depending on engine semantics), but we must
263                // not pretend the import as a whole failed - return the persist
264                // error so the operator notices, after logging the success.
265                update_status_and_save(config, state, task, ImportTaskStatus::Completed, None)
266                    .await?;
267                info!(
268                    "[{}/{}] Chunk {} schema {}: done in {:?}",
269                    idx + 1,
270                    config.tasks.len(),
271                    task.chunk_id,
272                    task.schema,
273                    task_start.elapsed()
274                );
275                progress.inc(1);
276            }
277            Err(task_error) => {
278                // Persist Failed best-effort, but always surface the original
279                // task error to the caller. State persistence problems are
280                // logged so they are not silently lost.
281                persist_failed_best_effort(config, state, task, &task_error).await;
282                return Err(task_error);
283            }
284        }
285    }
286
287    Ok(())
288}
289
290/// Imports up to `task_parallelism` data tasks concurrently on the client.
291///
292/// The coordinator owns all state mutation/persistence: it marks tasks
293/// `InProgress` and persists the state before polling their futures, then
294/// applies each task result and persists again on completion. The task futures
295/// only run the import; they never touch the state, so state writes stay
296/// serialized in this task.
297///
298/// On the first task failure we stop scheduling new tasks but let already
299/// in-flight tasks finish and persist their final status, then return the first
300/// error.
301async fn import_tasks_concurrent<E>(
302    config: &ImportResumeConfig,
303    state: &mut ImportState,
304    executor: &E,
305    task_parallelism: usize,
306    progress: &dyn ProgressReporter,
307) -> Result<()>
308where
309    E: ImportTaskExecutor + Sync,
310{
311    let mut pending = FuturesUnordered::new();
312    let mut next_idx = 0;
313    let mut first_error: Option<Error> = None;
314
315    loop {
316        let mut scheduled = false;
317
318        // Schedule eligible tasks in order up to the parallelism limit. Once a
319        // failure is seen, stop scheduling but keep draining in-flight tasks.
320        while first_error.is_none() && pending.len() < task_parallelism {
321            let Some(idx) = next_pending_task(config, state, &mut next_idx, progress) else {
322                break;
323            };
324
325            let task = &config.tasks[idx];
326            info!(
327                "[{}/{}] Chunk {} schema {}: importing...",
328                idx + 1,
329                config.tasks.len(),
330                task.chunk_id,
331                task.schema
332            );
333            state.set_task_status(
334                task.chunk_id,
335                &task.schema,
336                ImportTaskStatus::InProgress,
337                None,
338            )?;
339            scheduled = true;
340
341            pending.push(async move {
342                let result = executor.import_task(task).await;
343                (idx, result)
344            });
345        }
346
347        if scheduled {
348            save_import_state(&config.state_path, state).await?;
349        }
350
351        let Some((idx, task_result)) = pending.next().await else {
352            break;
353        };
354
355        let task = &config.tasks[idx];
356        match task_result {
357            Ok(()) => {
358                // The task itself succeeded. If we cannot persist the Completed
359                // marker, surface the persist error so the operator notices,
360                // after logging the success.
361                update_status_and_save(config, state, task, ImportTaskStatus::Completed, None)
362                    .await?;
363                info!(
364                    "[{}/{}] Chunk {} schema {}: done",
365                    idx + 1,
366                    config.tasks.len(),
367                    task.chunk_id,
368                    task.schema
369                );
370                progress.inc(1);
371            }
372            Err(task_error) => {
373                // Persist Failed best-effort, but stop scheduling and remember
374                // the first error to return after draining in-flight tasks.
375                persist_failed_best_effort(config, state, task, &task_error).await;
376                if first_error.is_none() {
377                    first_error = Some(task_error);
378                }
379            }
380        }
381    }
382
383    match first_error {
384        Some(err) => Err(err),
385        None => Ok(()),
386    }
387}
388
389/// Returns the index of the next task eligible for import, scanning forward from
390/// `next_idx` and skipping tasks already marked `Completed` (counting each
391/// skipped task once toward progress). Advances `next_idx` past the returned
392/// task so each task is scheduled at most once.
393fn next_pending_task(
394    config: &ImportResumeConfig,
395    state: &ImportState,
396    next_idx: &mut usize,
397    progress: &dyn ProgressReporter,
398) -> Option<usize> {
399    while *next_idx < config.tasks.len() {
400        let idx = *next_idx;
401        *next_idx += 1;
402        let task = &config.tasks[idx];
403        if state.task_status(task.chunk_id, &task.schema) == Some(ImportTaskStatus::Completed) {
404            info!(
405                "[{}/{}] Chunk {} schema {}: already completed, skipped",
406                idx + 1,
407                config.tasks.len(),
408                task.chunk_id,
409                task.schema
410            );
411            progress.inc(1);
412            continue;
413        }
414        return Some(idx);
415    }
416    None
417}
418
419async fn persist_failed_best_effort(
420    config: &ImportResumeConfig,
421    state: &mut ImportState,
422    task: &ImportTaskKey,
423    task_error: &Error,
424) {
425    if let Err(persist_error) = update_status_and_save(
426        config,
427        state,
428        task,
429        ImportTaskStatus::Failed,
430        Some(task_error.to_string()),
431    )
432    .await
433    {
434        warn!(
435            "Failed to persist Failed status for chunk {} schema {} after task error ({}); state file may be out of date: {}",
436            task.chunk_id, task.schema, task_error, persist_error
437        );
438    }
439}
440
441async fn update_status_and_save(
442    config: &ImportResumeConfig,
443    state: &mut ImportState,
444    task: &ImportTaskKey,
445    status: ImportTaskStatus,
446    error_message: Option<String>,
447) -> Result<()> {
448    // set_task_status only fails if the task isn't in the state; that would
449    // indicate a logic bug since `task` came from the same config. Surface it
450    // instead of swallowing.
451    state.set_task_status(task.chunk_id, &task.schema, status, error_message)?;
452    save_import_state(&config.state_path, state).await
453}
454
455fn validate_state_matches(state: &ImportState, config: &ImportResumeConfig) -> Result<()> {
456    if state.snapshot_id != config.snapshot_id {
457        return state_mismatch(
458            config,
459            format!(
460                "snapshot_id differs (state: {}, requested: {})",
461                state.snapshot_id, config.snapshot_id
462            ),
463        );
464    }
465    // Target addresses are compared literally; hostname normalization is left to the caller.
466    if state.target_addr != config.target_addr {
467        return state_mismatch(
468            config,
469            format!(
470                "target_addr differs (state: {}, requested: {})",
471                state.target_addr, config.target_addr
472            ),
473        );
474    }
475    if state.catalog != config.catalog {
476        return state_mismatch(
477            config,
478            format!(
479                "catalog differs (state: {}, requested: {})",
480                state.catalog, config.catalog
481            ),
482        );
483    }
484
485    let requested_schemas = canonical_schema_selection(&config.schemas);
486    if state.schemas != requested_schemas {
487        return state_mismatch(
488            config,
489            format!(
490                "schemas differ (state: {:?}, requested: {:?})",
491                state.schemas, requested_schemas
492            ),
493        );
494    }
495
496    if task_set_from_state(state, &config.state_path)? != task_set_from_config(config)? {
497        return state_mismatch(config, "task set differs".to_string());
498    }
499
500    Ok(())
501}
502
503fn state_mismatch(config: &ImportResumeConfig, reason: String) -> Result<()> {
504    ImportStateMismatchSnafu {
505        path: config.state_path.display().to_string(),
506        reason,
507    }
508    .fail()
509}
510
511fn task_set_from_state<'a>(
512    state: &'a ImportState,
513    state_path: &Path,
514) -> Result<BTreeSet<(u32, &'a str)>> {
515    let mut tasks = BTreeSet::new();
516    for task in &state.tasks {
517        if !tasks.insert((task.chunk_id, task.schema.as_str())) {
518            return ImportStateMismatchSnafu {
519                path: state_path.display().to_string(),
520                reason: format!(
521                    "duplicate task key in state (chunk_id: {}, schema: {})",
522                    task.chunk_id, task.schema
523                ),
524            }
525            .fail();
526        }
527    }
528    Ok(tasks)
529}
530
531fn task_set_from_config(config: &ImportResumeConfig) -> Result<BTreeSet<(u32, &str)>> {
532    let mut tasks = BTreeSet::new();
533    for task in &config.tasks {
534        if !tasks.insert((task.chunk_id, task.schema.as_str())) {
535            return ImportStateMismatchSnafu {
536                path: config.state_path.display().to_string(),
537                reason: format!(
538                    "duplicate task key in request (chunk_id: {}, schema: {})",
539                    task.chunk_id, task.schema
540                ),
541            }
542            .fail();
543        }
544    }
545    Ok(tasks)
546}
547
548fn validate_config_tasks(config: &ImportResumeConfig) -> Result<()> {
549    task_set_from_config(config).map(|_| ())
550}
551
552#[cfg(test)]
553mod tests {
554    use std::sync::atomic::{AtomicUsize, Ordering};
555    use std::sync::{Arc, Mutex};
556
557    use super::*;
558    use crate::data::export_v2::manifest::{ChunkMeta, TimeRange};
559    use crate::data::import_v2::error::TestTaskFailedSnafu;
560    use crate::data::progress::test_util::{ProgressEvent, RecordingProgress};
561
562    #[derive(Debug, Clone, Copy)]
563    enum FailureMode {
564        Fatal,
565        RetryableThenSuccess { failures: usize },
566    }
567
568    struct RecordingExecutor {
569        imported: Arc<Mutex<Vec<ImportTaskKey>>>,
570        fail_task: Option<ImportTaskKey>,
571        failure_mode: Option<FailureMode>,
572        attempts: Arc<AtomicUsize>,
573    }
574
575    #[async_trait]
576    impl ImportTaskExecutor for RecordingExecutor {
577        async fn import_task(&self, task: &ImportTaskKey) -> Result<()> {
578            let attempt = self.attempts.fetch_add(1, Ordering::SeqCst);
579            if self.fail_task.as_ref() == Some(task) {
580                match self.failure_mode {
581                    Some(FailureMode::Fatal) => {
582                        return TestTaskFailedSnafu {
583                            message: "fatal failure".to_string(),
584                            retryable: false,
585                        }
586                        .fail();
587                    }
588                    Some(FailureMode::RetryableThenSuccess { failures }) if attempt < failures => {
589                        return TestTaskFailedSnafu {
590                            message: "retryable failure".to_string(),
591                            retryable: true,
592                        }
593                        .fail();
594                    }
595                    _ => {}
596                }
597            }
598            self.imported.lock().unwrap().push(task.clone());
599            Ok(())
600        }
601    }
602
603    fn recording_executor(imported: Arc<Mutex<Vec<ImportTaskKey>>>) -> RecordingExecutor {
604        RecordingExecutor {
605            imported,
606            fail_task: None,
607            failure_mode: None,
608            attempts: Arc::new(AtomicUsize::new(0)),
609        }
610    }
611
612    fn config(path: PathBuf, tasks: Vec<ImportTaskKey>) -> ImportResumeConfig {
613        config_with_parallelism(path, tasks, 1)
614    }
615
616    fn config_with_parallelism(
617        path: PathBuf,
618        tasks: Vec<ImportTaskKey>,
619        task_parallelism: usize,
620    ) -> ImportResumeConfig {
621        ImportResumeConfig {
622            snapshot_id: "snapshot-1".to_string(),
623            target_addr: "127.0.0.1:4000".to_string(),
624            catalog: "greptime".to_string(),
625            schemas: vec!["public".to_string(), "analytics".to_string()],
626            state_path: path,
627            tasks,
628            task_parallelism,
629        }
630    }
631
632    async fn run_import_with_resume<E>(config: ImportResumeConfig, executor: &E) -> Result<()>
633    where
634        E: ImportTaskExecutor + Sync,
635    {
636        // Mirror the production caller: mark DDL completed for fresh sessions
637        // so the data-import guard is satisfied. Tests that want to exercise
638        // the unsafe path drive prepare/import directly.
639        let mut session = prepare_import_resume(config).await?;
640        if !session.should_skip_ddl() {
641            session.mark_ddl_completed().await?;
642        }
643        import_with_resume_session(session, executor).await
644    }
645
646    #[test]
647    fn test_build_import_tasks_skips_skipped_chunks_and_missing_schema_files() {
648        let mut completed = ChunkMeta::new(1, TimeRange::unbounded());
649        completed.status = ChunkStatus::Completed;
650        completed.files = vec!["data/public/1/file.parquet".to_string()];
651        let mut skipped = ChunkMeta::new(2, TimeRange::unbounded());
652        skipped.status = ChunkStatus::Skipped;
653        skipped.files = vec!["data/public/2/file.parquet".to_string()];
654
655        let tasks = build_import_tasks(
656            &[completed, skipped],
657            &["public".to_string(), "analytics".to_string()],
658        );
659
660        assert_eq!(tasks, vec![ImportTaskKey::new(1, "public")]);
661    }
662
663    #[tokio::test]
664    async fn test_import_with_resume_skips_completed_tasks() {
665        let dir = tempfile::tempdir().unwrap();
666        let path = dir.path().join("import_state.json");
667        let tasks = vec![
668            ImportTaskKey::new(1, "public"),
669            ImportTaskKey::new(2, "analytics"),
670        ];
671        let mut state = ImportState::new(
672            "snapshot-1",
673            "127.0.0.1:4000",
674            "greptime",
675            &["public".to_string(), "analytics".to_string()],
676            tasks.clone(),
677        );
678        state.mark_ddl_completed();
679        state
680            .set_task_status(1, "public", ImportTaskStatus::Completed, None)
681            .unwrap();
682        save_import_state(&path, &state).await.unwrap();
683
684        let imported = Arc::new(Mutex::new(Vec::new()));
685        let executor = recording_executor(imported.clone());
686
687        run_import_with_resume(config(path.clone(), tasks), &executor)
688            .await
689            .unwrap();
690
691        assert_eq!(
692            imported.lock().unwrap().clone(),
693            vec![ImportTaskKey::new(2, "analytics")]
694        );
695        assert!(load_import_state(&path).await.unwrap().is_none());
696    }
697
698    #[tokio::test]
699    async fn test_import_with_resume_persists_failed_task() {
700        let dir = tempfile::tempdir().unwrap();
701        let path = dir.path().join("import_state.json");
702        let failed_task = ImportTaskKey::new(1, "public");
703        let tasks = vec![failed_task.clone()];
704        let imported = Arc::new(Mutex::new(Vec::new()));
705        let executor = RecordingExecutor {
706            imported,
707            fail_task: Some(failed_task.clone()),
708            failure_mode: Some(FailureMode::Fatal),
709            attempts: Arc::new(AtomicUsize::new(0)),
710        };
711
712        let error = run_import_with_resume(config(path.clone(), tasks), &executor)
713            .await
714            .unwrap_err();
715        assert!(matches!(
716            error,
717            crate::data::import_v2::error::Error::TestTaskFailed {
718                retryable: false,
719                ..
720            }
721        ));
722
723        let state = load_import_state(&path).await.unwrap().unwrap();
724        assert_eq!(
725            state.task_status(failed_task.chunk_id, &failed_task.schema),
726            Some(ImportTaskStatus::Failed)
727        );
728    }
729
730    #[tokio::test]
731    async fn test_import_with_resume_rejects_mismatched_state_identity() {
732        let dir = tempfile::tempdir().unwrap();
733        let path = dir.path().join("import_state.json");
734        let tasks = vec![ImportTaskKey::new(1, "public")];
735        let state = ImportState::new(
736            "snapshot-1",
737            "127.0.0.1:4001",
738            "greptime",
739            &["public".to_string(), "analytics".to_string()],
740            tasks.clone(),
741        );
742        save_import_state(&path, &state).await.unwrap();
743
744        let imported = Arc::new(Mutex::new(Vec::new()));
745        let executor = recording_executor(imported);
746
747        let error = run_import_with_resume(config(path, tasks), &executor)
748            .await
749            .unwrap_err();
750
751        assert!(matches!(
752            error,
753            crate::data::import_v2::error::Error::ImportStateMismatch { .. }
754        ));
755    }
756
757    #[tokio::test]
758    async fn test_prepare_import_resume_reports_existing_state_before_ddl() {
759        let dir = tempfile::tempdir().unwrap();
760        let tasks = vec![ImportTaskKey::new(1, "public")];
761
762        let fresh_session =
763            prepare_import_resume(config(dir.path().join("fresh_state.json"), tasks.clone()))
764                .await
765                .unwrap();
766        assert!(!fresh_session.should_skip_ddl());
767        drop(fresh_session);
768
769        let existing_path = dir.path().join("existing_state.json");
770        let mut state = ImportState::new(
771            "snapshot-1",
772            "127.0.0.1:4000",
773            "greptime",
774            &["public".to_string(), "analytics".to_string()],
775            tasks.clone(),
776        );
777        state.mark_ddl_completed();
778        save_import_state(&existing_path, &state).await.unwrap();
779
780        let resume_session = prepare_import_resume(config(existing_path, tasks))
781            .await
782            .unwrap();
783        assert!(resume_session.should_skip_ddl());
784    }
785
786    #[tokio::test]
787    async fn test_import_with_resume_rejects_duplicate_state_tasks() {
788        let dir = tempfile::tempdir().unwrap();
789        let path = dir.path().join("import_state.json");
790        let tasks = vec![ImportTaskKey::new(1, "public")];
791        let mut state = ImportState::new(
792            "snapshot-1",
793            "127.0.0.1:4000",
794            "greptime",
795            &["public".to_string(), "analytics".to_string()],
796            tasks.clone(),
797        );
798        state.tasks.push(state.tasks[0].clone());
799        save_import_state(&path, &state).await.unwrap();
800
801        let imported = Arc::new(Mutex::new(Vec::new()));
802        let executor = recording_executor(imported);
803
804        let error = run_import_with_resume(config(path, tasks), &executor)
805            .await
806            .unwrap_err();
807
808        assert!(matches!(
809            error,
810            crate::data::import_v2::error::Error::ImportStateMismatch { .. }
811        ));
812    }
813
814    #[tokio::test]
815    async fn test_import_with_resume_rejects_data_import_when_ddl_incomplete() {
816        let dir = tempfile::tempdir().unwrap();
817        let path = dir.path().join("import_state.json");
818        let tasks = vec![ImportTaskKey::new(1, "public")];
819
820        // prepare creates fresh state with ddl_completed=false; calling
821        // import_with_resume_session directly (without mark_ddl_completed)
822        // must be rejected.
823        let session = prepare_import_resume(config(path, tasks)).await.unwrap();
824        let imported = Arc::new(Mutex::new(Vec::new()));
825        let executor = recording_executor(imported.clone());
826
827        let error = import_with_resume_session(session, &executor)
828            .await
829            .unwrap_err();
830
831        assert!(matches!(
832            error,
833            crate::data::import_v2::error::Error::ImportStateDdlIncomplete { .. }
834        ));
835        assert!(imported.lock().unwrap().is_empty());
836    }
837
838    #[tokio::test]
839    async fn test_prepare_import_resume_rejects_duplicate_request_tasks_on_fresh_state() {
840        let dir = tempfile::tempdir().unwrap();
841        let path = dir.path().join("import_state.json");
842        let task = ImportTaskKey::new(1, "public");
843        // No state file yet - duplicate detection must run before the fresh
844        // state is persisted, otherwise corrupted bookkeeping would be
845        // written to disk and observed only on a later resume.
846        let error =
847            match prepare_import_resume(config(path.clone(), vec![task.clone(), task])).await {
848                Ok(_) => panic!("duplicate request tasks should be rejected"),
849                Err(error) => error,
850            };
851
852        assert!(matches!(
853            error,
854            crate::data::import_v2::error::Error::ImportStateMismatch { .. }
855        ));
856        assert!(load_import_state(&path).await.unwrap().is_none());
857    }
858
859    #[tokio::test]
860    async fn test_import_with_resume_does_not_retry_retryable_task_error() {
861        let dir = tempfile::tempdir().unwrap();
862        let path = dir.path().join("import_state.json");
863        let failed_task = ImportTaskKey::new(1, "public");
864        let tasks = vec![failed_task.clone()];
865        let imported = Arc::new(Mutex::new(Vec::new()));
866        let attempts = Arc::new(AtomicUsize::new(0));
867        let executor = RecordingExecutor {
868            imported: imported.clone(),
869            fail_task: Some(failed_task.clone()),
870            // If task import were retried, the second attempt would succeed.
871            // COPY DATABASE FROM failures are ambiguous, so retryable errors
872            // must still stop immediately to avoid duplicate rows.
873            failure_mode: Some(FailureMode::RetryableThenSuccess { failures: 1 }),
874            attempts: attempts.clone(),
875        };
876
877        let error = run_import_with_resume(config(path.clone(), tasks), &executor)
878            .await
879            .unwrap_err();
880
881        assert!(matches!(
882            error,
883            crate::data::import_v2::error::Error::TestTaskFailed {
884                retryable: true,
885                ..
886            }
887        ));
888        assert_eq!(attempts.load(Ordering::SeqCst), 1);
889        assert!(imported.lock().unwrap().is_empty());
890
891        let state = load_import_state(&path).await.unwrap().unwrap();
892        assert_eq!(
893            state.task_status(failed_task.chunk_id, &failed_task.schema),
894            Some(ImportTaskStatus::Failed)
895        );
896    }
897
898    #[tokio::test]
899    async fn test_import_progress_counts_skipped_and_completed_tasks() {
900        let dir = tempfile::tempdir().unwrap();
901        let path = dir.path().join("import_state.json");
902        let tasks = vec![
903            ImportTaskKey::new(1, "public"),
904            ImportTaskKey::new(2, "analytics"),
905        ];
906        let mut state = ImportState::new(
907            "snapshot-1",
908            "127.0.0.1:4000",
909            "greptime",
910            &["public".to_string(), "analytics".to_string()],
911            tasks.clone(),
912        );
913        state.mark_ddl_completed();
914        // First task is already completed: it should still count as one progress
915        // unit so the reported total matches the task plan on resume.
916        state
917            .set_task_status(1, "public", ImportTaskStatus::Completed, None)
918            .unwrap();
919        save_import_state(&path, &state).await.unwrap();
920
921        let imported = Arc::new(Mutex::new(Vec::new()));
922        let executor = recording_executor(imported.clone());
923        let progress = RecordingProgress::default();
924
925        let session = prepare_import_resume(config(path, tasks)).await.unwrap();
926        import_with_resume_session_with_progress(session, &executor, &progress)
927            .await
928            .unwrap();
929
930        // The skipped-completed task is not re-imported.
931        assert_eq!(
932            imported.lock().unwrap().clone(),
933            vec![ImportTaskKey::new(2, "analytics")]
934        );
935
936        let events = progress.events();
937        assert_eq!(
938            events.first(),
939            Some(&ProgressEvent::StartPhase {
940                name: "Import data tasks".to_string(),
941                total: Some(2),
942            })
943        );
944        assert_eq!(events.last(), Some(&ProgressEvent::FinishPhase));
945        // Both the skipped-completed task and the newly imported task increment.
946        assert_eq!(progress.total_inc(), 2);
947    }
948
949    #[tokio::test]
950    async fn test_import_progress_stops_incrementing_on_task_failure() {
951        let dir = tempfile::tempdir().unwrap();
952        let path = dir.path().join("import_state.json");
953        let failed_task = ImportTaskKey::new(1, "public");
954        let tasks = vec![failed_task.clone(), ImportTaskKey::new(2, "analytics")];
955        let executor = RecordingExecutor {
956            imported: Arc::new(Mutex::new(Vec::new())),
957            fail_task: Some(failed_task.clone()),
958            failure_mode: Some(FailureMode::Fatal),
959            attempts: Arc::new(AtomicUsize::new(0)),
960        };
961
962        let mut state = ImportState::new(
963            "snapshot-1",
964            "127.0.0.1:4000",
965            "greptime",
966            &["public".to_string(), "analytics".to_string()],
967            tasks.clone(),
968        );
969        state.mark_ddl_completed();
970        save_import_state(&path, &state).await.unwrap();
971
972        let progress = RecordingProgress::default();
973        let session = prepare_import_resume(config(path, tasks)).await.unwrap();
974        import_with_resume_session_with_progress(session, &executor, &progress)
975            .await
976            .unwrap_err();
977
978        // The first task fails, so neither task increments. The phase is still
979        // finished before returning the task error so future UI reporters can
980        // clean up their state.
981        assert_eq!(progress.total_inc(), 0);
982        assert!(progress.events().contains(&ProgressEvent::FinishPhase));
983    }
984
985    /// Executor that records the maximum number of concurrently in-flight tasks
986    /// observed. Each task yields a few times so siblings get scheduled before
987    /// it completes, making the observed maximum a faithful proxy for the
988    /// coordinator's in-flight limit.
989    struct ConcurrencyTrackingExecutor {
990        imported: Arc<Mutex<Vec<ImportTaskKey>>>,
991        in_flight: Arc<AtomicUsize>,
992        max_in_flight: Arc<AtomicUsize>,
993    }
994
995    #[async_trait]
996    impl ImportTaskExecutor for ConcurrencyTrackingExecutor {
997        async fn import_task(&self, task: &ImportTaskKey) -> Result<()> {
998            let current = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
999            self.max_in_flight.fetch_max(current, Ordering::SeqCst);
1000            for _ in 0..8 {
1001                tokio::task::yield_now().await;
1002            }
1003            self.in_flight.fetch_sub(1, Ordering::SeqCst);
1004            self.imported.lock().unwrap().push(task.clone());
1005            Ok(())
1006        }
1007    }
1008
1009    fn ddl_completed_state(tasks: &[ImportTaskKey]) -> ImportState {
1010        let mut state = ImportState::new(
1011            "snapshot-1",
1012            "127.0.0.1:4000",
1013            "greptime",
1014            &["public".to_string(), "analytics".to_string()],
1015            tasks.to_vec(),
1016        );
1017        state.mark_ddl_completed();
1018        state
1019    }
1020
1021    #[tokio::test]
1022    async fn test_import_concurrent_caps_in_flight_tasks() {
1023        let dir = tempfile::tempdir().unwrap();
1024        let path = dir.path().join("import_state.json");
1025        let tasks: Vec<ImportTaskKey> = (0..8).map(|id| ImportTaskKey::new(id, "public")).collect();
1026        save_import_state(&path, &ddl_completed_state(&tasks))
1027            .await
1028            .unwrap();
1029
1030        let imported = Arc::new(Mutex::new(Vec::new()));
1031        let max_in_flight = Arc::new(AtomicUsize::new(0));
1032        let executor = ConcurrencyTrackingExecutor {
1033            imported: imported.clone(),
1034            in_flight: Arc::new(AtomicUsize::new(0)),
1035            max_in_flight: max_in_flight.clone(),
1036        };
1037
1038        let session =
1039            prepare_import_resume(config_with_parallelism(path.clone(), tasks.clone(), 3))
1040                .await
1041                .unwrap();
1042        import_with_resume_session(session, &executor)
1043            .await
1044            .unwrap();
1045
1046        // Never exceed the requested parallelism, but actually run concurrently.
1047        let observed = max_in_flight.load(Ordering::SeqCst);
1048        assert!(
1049            observed <= 3,
1050            "observed {observed} in-flight, expected <= 3"
1051        );
1052        assert!(
1053            observed >= 2,
1054            "observed {observed} in-flight, expected concurrency"
1055        );
1056        // All tasks imported and the state file is cleaned up on success.
1057        assert_eq!(imported.lock().unwrap().len(), tasks.len());
1058        assert!(load_import_state(&path).await.unwrap().is_none());
1059    }
1060
1061    #[tokio::test]
1062    async fn test_import_concurrent_skips_completed_and_counts_progress_once() {
1063        let dir = tempfile::tempdir().unwrap();
1064        let path = dir.path().join("import_state.json");
1065        let tasks = vec![
1066            ImportTaskKey::new(1, "public"),
1067            ImportTaskKey::new(2, "analytics"),
1068            ImportTaskKey::new(3, "public"),
1069        ];
1070        let mut state = ddl_completed_state(&tasks);
1071        state
1072            .set_task_status(1, "public", ImportTaskStatus::Completed, None)
1073            .unwrap();
1074        save_import_state(&path, &state).await.unwrap();
1075
1076        let imported = Arc::new(Mutex::new(Vec::new()));
1077        let executor = recording_executor(imported.clone());
1078        let progress = RecordingProgress::default();
1079
1080        let session = prepare_import_resume(config_with_parallelism(path.clone(), tasks, 4))
1081            .await
1082            .unwrap();
1083        import_with_resume_session_with_progress(session, &executor, &progress)
1084            .await
1085            .unwrap();
1086
1087        // The already-completed task is not re-imported.
1088        let mut imported = imported.lock().unwrap().clone();
1089        imported.sort_by_key(|task| task.chunk_id);
1090        assert_eq!(
1091            imported,
1092            vec![
1093                ImportTaskKey::new(2, "analytics"),
1094                ImportTaskKey::new(3, "public"),
1095            ]
1096        );
1097        // One unit for the skipped-completed task plus one per imported task.
1098        assert_eq!(progress.total_inc(), 3);
1099        assert!(load_import_state(&path).await.unwrap().is_none());
1100    }
1101
1102    #[tokio::test]
1103    async fn test_import_concurrent_persists_failed_and_returns_first_error() {
1104        let dir = tempfile::tempdir().unwrap();
1105        let path = dir.path().join("import_state.json");
1106        let failed_task = ImportTaskKey::new(1, "public");
1107        let tasks = vec![failed_task.clone(), ImportTaskKey::new(2, "analytics")];
1108        save_import_state(&path, &ddl_completed_state(&tasks))
1109            .await
1110            .unwrap();
1111
1112        let executor = RecordingExecutor {
1113            imported: Arc::new(Mutex::new(Vec::new())),
1114            fail_task: Some(failed_task.clone()),
1115            failure_mode: Some(FailureMode::Fatal),
1116            attempts: Arc::new(AtomicUsize::new(0)),
1117        };
1118
1119        let session = prepare_import_resume(config_with_parallelism(path.clone(), tasks, 4))
1120            .await
1121            .unwrap();
1122        let error = import_with_resume_session(session, &executor)
1123            .await
1124            .unwrap_err();
1125        assert!(matches!(
1126            error,
1127            crate::data::import_v2::error::Error::TestTaskFailed {
1128                retryable: false,
1129                ..
1130            }
1131        ));
1132
1133        // The state file is retained for resume, with the failed task recorded.
1134        let state = load_import_state(&path).await.unwrap().unwrap();
1135        assert_eq!(
1136            state.task_status(failed_task.chunk_id, &failed_task.schema),
1137            Some(ImportTaskStatus::Failed)
1138        );
1139    }
1140
1141    struct FailFastExecutor {
1142        imported: Arc<Mutex<Vec<ImportTaskKey>>>,
1143        fail_task: ImportTaskKey,
1144    }
1145
1146    #[async_trait]
1147    impl ImportTaskExecutor for FailFastExecutor {
1148        async fn import_task(&self, task: &ImportTaskKey) -> Result<()> {
1149            if task == &self.fail_task {
1150                return TestTaskFailedSnafu {
1151                    message: "fatal failure".to_string(),
1152                    retryable: false,
1153                }
1154                .fail();
1155            }
1156
1157            for _ in 0..8 {
1158                tokio::task::yield_now().await;
1159            }
1160            self.imported.lock().unwrap().push(task.clone());
1161            Ok(())
1162        }
1163    }
1164
1165    #[tokio::test]
1166    async fn test_import_concurrent_stops_scheduling_new_tasks_after_failure() {
1167        let dir = tempfile::tempdir().unwrap();
1168        let path = dir.path().join("import_state.json");
1169        let failed_task = ImportTaskKey::new(1, "public");
1170        let in_flight_task = ImportTaskKey::new(2, "analytics");
1171        let unscheduled_task_1 = ImportTaskKey::new(3, "public");
1172        let unscheduled_task_2 = ImportTaskKey::new(4, "analytics");
1173        let tasks = vec![
1174            failed_task.clone(),
1175            in_flight_task.clone(),
1176            unscheduled_task_1.clone(),
1177            unscheduled_task_2.clone(),
1178        ];
1179        save_import_state(&path, &ddl_completed_state(&tasks))
1180            .await
1181            .unwrap();
1182
1183        let imported = Arc::new(Mutex::new(Vec::new()));
1184        let executor = FailFastExecutor {
1185            imported: imported.clone(),
1186            fail_task: failed_task.clone(),
1187        };
1188
1189        let session = prepare_import_resume(config_with_parallelism(path.clone(), tasks, 2))
1190            .await
1191            .unwrap();
1192        import_with_resume_session(session, &executor)
1193            .await
1194            .unwrap_err();
1195
1196        // The already in-flight sibling is drained, but tasks beyond the
1197        // parallelism window are not scheduled after the first failure.
1198        assert_eq!(imported.lock().unwrap().clone(), vec![in_flight_task]);
1199        let state = load_import_state(&path).await.unwrap().unwrap();
1200        assert_eq!(
1201            state.task_status(failed_task.chunk_id, &failed_task.schema),
1202            Some(ImportTaskStatus::Failed)
1203        );
1204        assert_eq!(
1205            state.task_status(unscheduled_task_1.chunk_id, &unscheduled_task_1.schema),
1206            Some(ImportTaskStatus::Pending)
1207        );
1208        assert_eq!(
1209            state.task_status(unscheduled_task_2.chunk_id, &unscheduled_task_2.schema),
1210            Some(ImportTaskStatus::Pending)
1211        );
1212    }
1213}