Skip to main content

linkrs_migration/
executor.rs

1//! Migration plan entry points: locking, checkpoint resume orchestration,
2//! history recording, and rollback.
3//!
4//! Step-level execution lives in [`plan_runner`]; row-level step application
5//! lives in [`data_apply`].
6
7use std::collections::HashSet;
8use std::path::Path;
9use std::sync::{Arc, Mutex, OnceLock};
10
11use linkrs_core::error::storage::StorageErrorKind;
12use linkrs_core::event_dispatch::EventSubscriptions;
13use linkrs_storage::{
14    AutoCommitBatchOps, AutoCommitGroupOps, MigrationHistoryManager, MigrationHistoryRecord,
15    MigrationStatus, StorageReader, StorageSchemaOps, StorageWriter,
16};
17
18use crate::config::MigrationConfig;
19use crate::error::MigrationError;
20use crate::event::MigrationEvent;
21use crate::lock::MigrationFileLock;
22use crate::metrics::global_migration_metrics;
23use crate::plan::{
24    irreversible_guidance, MigrationPlan, MigrationReport, MigrationTarget, SafetyLevel,
25};
26use crate::progress::{MigrationProgress, NoopProgress};
27
28mod data_apply;
29mod plan_runner;
30
31use plan_runner::{
32    execute_dry_run, execute_edge_plan_with_progress, execute_schema_steps,
33    execute_vertex_plan_with_progress, record_migration_history,
34};
35
36fn in_progress_targets() -> &'static Mutex<HashSet<String>> {
37    static TARGETS: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
38    TARGETS.get_or_init(|| Mutex::new(HashSet::new()))
39}
40
41fn migration_target_key(target: &MigrationTarget) -> String {
42    format!("{}\0{}\0{}", target.space, target.label, target.is_edge)
43}
44
45#[derive(Debug)]
46struct MigrationLockGuard {
47    key: String,
48}
49
50impl MigrationLockGuard {
51    fn try_acquire(target: &MigrationTarget) -> Result<Self, MigrationError> {
52        let key = migration_target_key(target);
53        let mut held = in_progress_targets()
54            .lock()
55            .map_err(|e| MigrationError::Lock(format!("migration lock poisoned: {e}")))?;
56        if held.contains(&key) {
57            return Err(MigrationError::Lock(format!(
58                "migration in progress for {}/{} (is_edge={})",
59                target.space, target.label, target.is_edge
60            )));
61        }
62        held.insert(key.clone());
63        Ok(Self { key })
64    }
65}
66
67impl Drop for MigrationLockGuard {
68    fn drop(&mut self) {
69        if let Ok(mut held) = in_progress_targets().lock() {
70            held.remove(&self.key);
71        }
72    }
73}
74
75/// Write fence held across schema-modifying steps of a migration.
76///
77/// The migration engine never touches transaction internals directly: the
78/// layer that owns the write gate (embedded API, server) supplies an
79/// implementation backed by its own gate, typically a checkpoint drain fence
80/// built from `linkrs_transaction::issue_request_drain`. This keeps the
81/// dependency direction intact while making the online protocol reachable.
82pub trait SchemaWriteFence: Send + Sync {
83    /// Pause new writes and drain in-flight ones before schema steps run.
84    /// A timeout error aborts the migration instead of stretching the stall.
85    fn hold(&self) -> Result<(), MigrationError>;
86    /// Resume writes after schema steps complete or fail. Always called.
87    fn release(&self);
88}
89
90struct SchemaFenceHold<'a> {
91    fence: &'a dyn SchemaWriteFence,
92}
93
94impl Drop for SchemaFenceHold<'_> {
95    fn drop(&mut self) {
96        self.fence.release();
97    }
98}
99
100/// Optional collaborators for [`execute_migration_plan_with_options`].
101///
102/// `config` supplies batch size overrides plus default lock and checkpoint
103/// paths; explicit `lock_path` / `checkpoint_dir` fields take precedence over
104/// the ones embedded in `config`.
105#[derive(Default)]
106pub struct ExecuteOptions<'a> {
107    pub config: Option<&'a MigrationConfig>,
108    pub progress: Option<&'a dyn MigrationProgress>,
109    pub event_registry: Option<&'a Arc<EventSubscriptions<MigrationEvent>>>,
110    pub lock_path: Option<&'a Path>,
111    pub checkpoint_dir: Option<&'a Path>,
112    /// Optional write fence held only across schema-modifying steps so the
113    /// long data backfill phase stays online while the switch window is
114    /// bounded. Ignored for dry runs, which never touch storage.
115    pub schema_fence: Option<&'a dyn SchemaWriteFence>,
116    /// Directory for pre-migration backups of destructive steps. When set
117    /// and the plan drops data, rows are snapshotted before execution so
118    /// rollback can restore them.
119    pub backup_dir: Option<&'a Path>,
120    /// Skip the linear version-chain check. Set for rollback plans, which
121    /// intentionally move against the applied history.
122    pub skip_chain_check: bool,
123}
124
125pub fn execute_migration_plan<S>(
126    storage: &mut S,
127    plan: &MigrationPlan,
128) -> Result<MigrationReport, MigrationError>
129where
130    S: StorageReader
131        + StorageWriter
132        + StorageSchemaOps
133        + AutoCommitGroupOps
134        + AutoCommitBatchOps
135        + ?Sized,
136{
137    execute_migration_plan_with_options(storage, plan, ExecuteOptions::default())
138}
139
140pub fn execute_migration_plan_with_options<S>(
141    storage: &mut S,
142    plan: &MigrationPlan,
143    options: ExecuteOptions<'_>,
144) -> Result<MigrationReport, MigrationError>
145where
146    S: StorageReader
147        + StorageWriter
148        + StorageSchemaOps
149        + AutoCommitGroupOps
150        + AutoCommitBatchOps
151        + ?Sized,
152{
153    let mut effective_plan = plan.clone();
154    let mut lock_path = options.lock_path;
155    let mut checkpoint_dir = options.checkpoint_dir;
156    let mut backup_dir = options.backup_dir;
157    if let Some(config) = options.config {
158        if config.batch_size != 0 {
159            effective_plan.batch_size = config.batch_size;
160        }
161        lock_path = lock_path.or(config.lock_path.as_deref());
162        checkpoint_dir = checkpoint_dir.or(config.checkpoint_dir.as_deref());
163        backup_dir = backup_dir.or(config.backup_dir.as_deref());
164    }
165
166    if let Some(dir) = checkpoint_dir {
167        let min_free = options.config.map_or(0, |c| c.min_free_bytes);
168        preflight_check(dir, min_free)?;
169    }
170
171    let progress = options.progress.unwrap_or(&NoopProgress);
172    let registry = options.event_registry;
173
174    let _in_process_lock = MigrationLockGuard::try_acquire(&effective_plan.target)?;
175    let _file_lock: Option<MigrationFileLock> = if let Some(path) = lock_path {
176        Some(MigrationFileLock::try_acquire(path)?)
177    } else {
178        None
179    };
180    let notify = |event: &MigrationEvent| {
181        if let Some(registry) = registry {
182            registry.dispatch("migration", event);
183        }
184    };
185    let start = std::time::Instant::now();
186    // --- checkpoint resume handling ---
187    let mut checkpoint_completed: Vec<usize> = Vec::new();
188    let mut checkpoint_rows: u64 = 0;
189    if let Some(dir) = checkpoint_dir {
190        match crate::plan::MigrationCheckpoint::load(&effective_plan, dir) {
191            Ok(Some(cp)) => {
192                log::info!(
193                    "Resuming migration from checkpoint at step {} with completed {:?}",
194                    cp.completed_step_index,
195                    cp.completed_steps
196                );
197                checkpoint_completed = cp.completed_steps.clone();
198                if checkpoint_completed.is_empty() && cp.completed_step_index < plan.steps.len() {
199                    checkpoint_completed.push(cp.completed_step_index);
200                }
201                checkpoint_rows = cp.rows_migrated_after;
202            }
203            Ok(None) => {}
204            Err(e) => {
205                log::warn!("Failed to load checkpoint: {}", e);
206            }
207        }
208    }
209
210    notify(&MigrationEvent::Started {
211        plan: effective_plan.clone(),
212    });
213    progress.on_plan_start(&effective_plan);
214
215    if let Some(guidance) = irreversible_guidance(&effective_plan) {
216        log::warn!("Migration plan contains irreversible steps: {guidance}");
217    }
218
219    if effective_plan.dry_run {
220        let report = execute_dry_run(storage, &effective_plan)?;
221        if report.success {
222            notify(&MigrationEvent::Completed {
223                report: report.clone(),
224            });
225        } else {
226            notify(&MigrationEvent::Failed {
227                error: report.errors.join("; "),
228            });
229        }
230        progress.on_plan_complete(&effective_plan, report.rows_migrated);
231        return Ok(report);
232    }
233
234    if !effective_plan.plan_hash.is_empty() {
235        if let Ok(existing) = storage.list_migration_history(
236            &effective_plan.target.space,
237            &effective_plan.target.label,
238            effective_plan.target.is_edge,
239        ) {
240            for rec in existing {
241                if rec.to_version == effective_plan.version_range.to
242                    && rec.plan_hash != effective_plan.plan_hash
243                {
244                    let err = format!(
245                        "Checksum mismatch for version {}: stored hash {} != plan hash {}",
246                        rec.to_version, rec.plan_hash, effective_plan.plan_hash
247                    );
248                    notify(&MigrationEvent::Failed { error: err.clone() });
249                    global_migration_metrics().record_failure(start.elapsed().as_millis() as u64);
250                    return Err(MigrationError::Plan(err));
251                }
252            }
253        }
254    }
255
256    let effective_remaining: Vec<usize> = (0..effective_plan.steps.len())
257        .filter(|i| {
258            !effective_plan.completed_steps.contains(i) && !checkpoint_completed.contains(i)
259        })
260        .collect();
261
262    if effective_remaining.is_empty() {
263        let mut all_done = effective_plan.completed_steps.clone();
264        for c in &checkpoint_completed {
265            if !all_done.contains(c) {
266                all_done.push(*c);
267            }
268        }
269        all_done.sort_unstable();
270        let report = MigrationReport {
271            success: true,
272            steps_completed: all_done.len(),
273            rows_migrated: 0,
274            errors: vec![],
275            completed_step_indices: all_done.clone(),
276        };
277        notify(&MigrationEvent::Completed {
278            report: report.clone(),
279        });
280        progress.on_plan_complete(&effective_plan, 0);
281        if let Some(dir) = checkpoint_dir {
282            let _ = crate::plan::MigrationCheckpoint::cleanup(&effective_plan, dir);
283        }
284        global_migration_metrics()
285            .record_success(report.rows_migrated, start.elapsed().as_millis() as u64);
286        return Ok(report);
287    }
288
289    // Enforce the linear version chain: a plan may only start where the
290    // applied history left off, and may not re-apply a target version.
291    // Backends without history support fail open with a warning.
292    // Rollback plans move against history and opt out via skip_chain_check.
293    if !options.skip_chain_check {
294        match storage.get_applied_versions(
295            &effective_plan.target.space,
296            &effective_plan.target.label,
297            effective_plan.target.is_edge,
298        ) {
299            Ok(applied) => {
300                if let Err(e) = MigrationHistoryManager::check_chain(
301                    &applied,
302                    &effective_plan.target.space,
303                    &effective_plan.target.label,
304                    effective_plan.target.is_edge,
305                    effective_plan.version_range.from,
306                    effective_plan.version_range.to,
307                ) {
308                    let err = format!("version chain rejected: {e}");
309                    notify(&MigrationEvent::Failed { error: err.clone() });
310                    global_migration_metrics().record_failure(start.elapsed().as_millis() as u64);
311                    return Err(MigrationError::Plan(err));
312                }
313            }
314            Err(e) if e.kind() == StorageErrorKind::NotSupported => {
315                log::warn!("Migration history not supported; skipping version chain check");
316            }
317            Err(e) => {
318                let err = format!("cannot verify migration chain: {e}");
319                notify(&MigrationEvent::Failed { error: err.clone() });
320                global_migration_metrics().record_failure(start.elapsed().as_millis() as u64);
321                return Err(MigrationError::Plan(err));
322            }
323        }
324    }
325
326    // Snapshot rows before destructive steps run. Without a backup directory
327    // the plan still executes, but rollback of drops stays unavailable.
328    if effective_plan.requires_backup() {
329        match backup_dir {
330            Some(dir) => {
331                match crate::backup::write_backup(&*storage, &effective_plan, dir) {
332                    Ok(rows) => log::info!(
333                        "Backed up {rows} row(s) for destructive migration {}/{}",
334                        effective_plan.target.space,
335                        effective_plan.target.label
336                    ),
337                    Err(e) => {
338                        let err = format!("pre-migration backup failed: {e}");
339                        notify(&MigrationEvent::Failed { error: err.clone() });
340                        global_migration_metrics()
341                            .record_failure(start.elapsed().as_millis() as u64);
342                        return Err(MigrationError::Plan(err));
343                    }
344                }
345            }
346            None => log::warn!(
347                "Destructive migration plan for {}/{} has no backup_dir; drops cannot be rolled back",
348                effective_plan.target.space,
349                effective_plan.target.label
350            ),
351        }
352    }
353
354    // Handle schema-modifying steps first. When the caller supplied a write
355    // fence, hold it only across this bounded switch window; the guard drops
356    // at the end of the block so the data backfill below stays online.
357    let schema_executed = {
358        let needs_fence = effective_remaining
359            .iter()
360            .any(|i| effective_plan.steps[*i].is_schema_modifying());
361        let _fence_hold: Option<SchemaFenceHold<'_>> = if needs_fence {
362            match options.schema_fence {
363                Some(fence) => {
364                    fence.hold()?;
365                    Some(SchemaFenceHold { fence })
366                }
367                None => None,
368            }
369        } else {
370            None
371        };
372        execute_schema_steps(storage, &effective_plan, &effective_remaining, progress).inspect_err(
373            |_| {
374                global_migration_metrics().record_failure(start.elapsed().as_millis() as u64);
375            },
376        )?
377    };
378    let data_remaining: Vec<usize> = effective_remaining
379        .into_iter()
380        .filter(|idx| {
381            !schema_executed.contains(idx) && !effective_plan.steps[*idx].is_schema_modifying()
382        })
383        .collect();
384
385    if data_remaining.is_empty() {
386        let mut all_completed = effective_plan.completed_steps.clone();
387        for c in &checkpoint_completed {
388            if !all_completed.contains(c) {
389                all_completed.push(*c);
390            }
391        }
392        for idx in &schema_executed {
393            if !all_completed.contains(idx) {
394                all_completed.push(*idx);
395            }
396        }
397        all_completed.sort_unstable();
398        if let Some(dir) = checkpoint_dir {
399            if !schema_executed.is_empty() {
400                let cp = crate::plan::MigrationCheckpoint {
401                    completed_step_index: *schema_executed.last().unwrap_or(&0),
402                    rows_migrated_before: 0,
403                    rows_migrated_after: 0,
404                    timestamp: crate::plan::checkpoint_now_millis(),
405                    step_result: crate::plan::StepResult::Success,
406                    completed_steps: all_completed.clone(),
407                };
408                let _ = cp.save(&effective_plan, dir);
409            }
410            let _ = crate::plan::MigrationCheckpoint::cleanup(&effective_plan, dir);
411        }
412        let report = MigrationReport {
413            success: true,
414            steps_completed: all_completed.len(),
415            rows_migrated: 0,
416            errors: vec![],
417            completed_step_indices: all_completed.clone(),
418        };
419        record_migration_history(storage, &effective_plan, 0, MigrationStatus::Applied, None);
420        notify(&MigrationEvent::Completed {
421            report: report.clone(),
422        });
423        progress.on_plan_complete(&effective_plan, 0);
424        global_migration_metrics()
425            .record_success(report.rows_migrated, start.elapsed().as_millis() as u64);
426        return Ok(report);
427    }
428
429    // Prepare combined completed set including schema
430    let mut all_completed: Vec<usize> = {
431        let mut v = effective_plan.completed_steps.clone();
432        for c in &checkpoint_completed {
433            if !v.contains(c) {
434                v.push(*c);
435            }
436        }
437        for idx in &schema_executed {
438            if !v.contains(idx) {
439                v.push(*idx);
440            }
441        }
442        v.sort_unstable();
443        v
444    };
445
446    // Save a checkpoint after schema stage if applicable
447    if let Some(dir) = checkpoint_dir {
448        if !schema_executed.is_empty() {
449            let cp = crate::plan::MigrationCheckpoint {
450                completed_step_index: *schema_executed.last().unwrap(),
451                rows_migrated_before: 0,
452                rows_migrated_after: checkpoint_rows,
453                timestamp: crate::plan::checkpoint_now_millis(),
454                step_result: crate::plan::StepResult::Success,
455                completed_steps: all_completed.clone(),
456            };
457            if let Err(e) = cp.save(&effective_plan, dir) {
458                log::warn!("Failed to save checkpoint after schema steps: {}", e);
459            }
460        }
461    }
462
463    let mut overall_rows: u64 = 0;
464    // Per-step loop with checkpoint save after each step
465    for &idx in &data_remaining {
466        let step = &effective_plan.steps[idx];
467        progress.on_step_start(idx, step);
468        notify(&MigrationEvent::StepStarted { step_idx: idx });
469
470        let single_slice = vec![idx];
471        let step_report = if effective_plan.target.is_edge {
472            execute_edge_plan_with_progress(storage, &effective_plan, &single_slice, progress)
473                .inspect_err(|_| {
474                    global_migration_metrics().record_failure(start.elapsed().as_millis() as u64);
475                })?
476        } else {
477            execute_vertex_plan_with_progress(storage, &effective_plan, &single_slice, progress)
478                .inspect_err(|_| {
479                    global_migration_metrics().record_failure(start.elapsed().as_millis() as u64);
480                })?
481        };
482
483        if !step_report.success {
484            let cp = crate::plan::MigrationCheckpoint {
485                completed_step_index: idx,
486                rows_migrated_before: checkpoint_rows + overall_rows,
487                rows_migrated_after: checkpoint_rows + overall_rows,
488                timestamp: crate::plan::checkpoint_now_millis(),
489                step_result: crate::plan::StepResult::Failed(step_report.errors.join("; ")),
490                completed_steps: all_completed.clone(),
491            };
492            if let Some(dir) = checkpoint_dir {
493                let _ = cp.save(&effective_plan, dir);
494            }
495            record_migration_history(
496                storage,
497                &effective_plan,
498                0,
499                MigrationStatus::Failed,
500                Some(step_report.errors.join("; ")),
501            );
502            notify(&MigrationEvent::Failed {
503                error: step_report.errors.join("; "),
504            });
505            for err in &step_report.errors {
506                progress.on_error(err);
507            }
508            progress.on_plan_complete(&effective_plan, 0);
509            let report = MigrationReport {
510                success: false,
511                steps_completed: all_completed.len(),
512                rows_migrated: 0,
513                errors: step_report.errors.clone(),
514                completed_step_indices: all_completed.clone(),
515            };
516            global_migration_metrics().record_failure(start.elapsed().as_millis() as u64);
517            return Ok(report);
518        }
519
520        // Track rows: use max to avoid double counting same vertices across steps
521        if step_report.rows_migrated > overall_rows {
522            overall_rows = step_report.rows_migrated;
523        }
524
525        all_completed.push(idx);
526        all_completed.sort_unstable();
527
528        progress.on_step_complete(idx, step);
529        notify(&MigrationEvent::StepCompleted {
530            step_idx: idx,
531            rows: step_report.rows_migrated,
532        });
533
534        let cp = crate::plan::MigrationCheckpoint {
535            completed_step_index: idx,
536            rows_migrated_before: checkpoint_rows
537                + overall_rows.saturating_sub(step_report.rows_migrated),
538            rows_migrated_after: checkpoint_rows + overall_rows,
539            timestamp: crate::plan::checkpoint_now_millis(),
540            step_result: crate::plan::StepResult::Success,
541            completed_steps: all_completed.clone(),
542        };
543        if let Some(dir) = checkpoint_dir {
544            if let Err(e) = cp.save(&effective_plan, dir) {
545                log::warn!("Failed to save checkpoint for step {}: {}", idx, e);
546            }
547        }
548    }
549
550    // All data steps succeeded
551    if let Some(dir) = checkpoint_dir {
552        let _ = crate::plan::MigrationCheckpoint::cleanup(&effective_plan, dir);
553    }
554
555    // Keep max: resumed checkpoint rows already represent the previous
556    // total and overall_rows counts the remaining steps over the same set.
557    let final_rows = checkpoint_rows.max(overall_rows);
558    let report = MigrationReport {
559        success: true,
560        steps_completed: all_completed.len(),
561        rows_migrated: final_rows,
562        errors: vec![],
563        completed_step_indices: all_completed.clone(),
564    };
565    record_migration_history(
566        storage,
567        &effective_plan,
568        final_rows,
569        MigrationStatus::Applied,
570        None,
571    );
572    notify(&MigrationEvent::Completed {
573        report: report.clone(),
574    });
575    progress.on_plan_complete(&effective_plan, final_rows);
576    global_migration_metrics().record_success(final_rows, start.elapsed().as_millis() as u64);
577    Ok(report)
578}
579
580fn no_rollback_error(plan: &MigrationPlan) -> MigrationError {
581    let guidance = irreversible_guidance(plan);
582    match plan.overall_safety {
583        SafetyLevel::Dangerous => {
584            let hint = guidance.unwrap_or_default();
585            MigrationError::Plan(format!(
586                "Cannot rollback a dangerous migration (data loss). {hint}"
587            ))
588        }
589        _ => MigrationError::Plan("No rollback plan available".to_string()),
590    }
591}
592
593/// Verify the checkpoint directory is writable and, when `min_free_bytes` is
594/// set, that its filesystem has enough space for checkpoint files.
595fn preflight_check(checkpoint_dir: &Path, min_free_bytes: u64) -> Result<(), MigrationError> {
596    std::fs::create_dir_all(checkpoint_dir).map_err(|e| {
597        MigrationError::Preflight(format!(
598            "cannot create checkpoint directory {}: {e}",
599            checkpoint_dir.display()
600        ))
601    })?;
602    if min_free_bytes > 0 {
603        let free = fs4::available_space(checkpoint_dir)
604            .map_err(|e| MigrationError::Preflight(format!("cannot read free space: {e}")))?;
605        if free < min_free_bytes {
606            return Err(MigrationError::Preflight(format!(
607                "insufficient disk space at {}: need {} bytes, {} available",
608                checkpoint_dir.display(),
609                min_free_bytes,
610                free
611            )));
612        }
613    }
614    Ok(())
615}
616
617pub fn rollback_migration<S>(
618    storage: &mut S,
619    plan: &MigrationPlan,
620) -> Result<MigrationReport, MigrationError>
621where
622    S: StorageReader
623        + StorageWriter
624        + StorageSchemaOps
625        + AutoCommitGroupOps
626        + AutoCommitBatchOps
627        + ?Sized,
628{
629    rollback_migration_with_options(storage, plan, ExecuteOptions::default())
630}
631
632/// Rollback reusing [`ExecuteOptions`]: event subscriptions, progress,
633/// config (including `backup_dir` and `checkpoint_dir`) and
634/// `skip_chain_check` flow into the generated rollback plan; the backup
635/// restore branch dispatches `RolledBack` on success and `Failed` on error.
636pub fn rollback_migration_with_options<S>(
637    storage: &mut S,
638    plan: &MigrationPlan,
639    options: ExecuteOptions<'_>,
640) -> Result<MigrationReport, MigrationError>
641where
642    S: StorageReader
643        + StorageWriter
644        + StorageSchemaOps
645        + AutoCommitGroupOps
646        + AutoCommitBatchOps
647        + ?Sized,
648{
649    let notify = |event: &MigrationEvent| {
650        if let Some(registry) = options.event_registry {
651            registry.dispatch("migration", event);
652        }
653    };
654    let result = match &plan.rollback_plan {
655        Some(rollback) => execute_migration_plan_with_options(
656            storage,
657            rollback,
658            ExecuteOptions {
659                config: options.config,
660                progress: options.progress,
661                event_registry: options.event_registry,
662                lock_path: options.lock_path,
663                checkpoint_dir: options.checkpoint_dir,
664                schema_fence: options.schema_fence,
665                backup_dir: options.backup_dir,
666                skip_chain_check: true,
667            },
668        )
669        .map(|report| {
670            if report.success {
671                notify(&MigrationEvent::RolledBack {
672                    report: report.clone(),
673                });
674            }
675            report
676        }),
677        None => {
678            let backup_dir = options
679                .backup_dir
680                .or_else(|| options.config.and_then(|c| c.backup_dir.as_deref()));
681            if let Some(dir) = backup_dir {
682                let _space_lock = MigrationLockGuard::try_acquire(&plan.target)?;
683                let restore = crate::backup::restore_backup(storage, plan, dir);
684                match restore {
685                    Ok(Some(report)) => {
686                        notify(&MigrationEvent::RolledBack {
687                            report: report.clone(),
688                        });
689                        Ok(report)
690                    }
691                    Ok(None) => {
692                        let err = no_rollback_error(plan);
693                        notify(&MigrationEvent::Failed {
694                            error: err.to_string(),
695                        });
696                        Err(err)
697                    }
698                    Err(e) => {
699                        notify(&MigrationEvent::Failed {
700                            error: e.to_string(),
701                        });
702                        Err(e)
703                    }
704                }
705            } else {
706                let err = no_rollback_error(plan);
707                notify(&MigrationEvent::Failed {
708                    error: err.to_string(),
709                });
710                Err(err)
711            }
712        }
713    };
714    if let Ok(ref report) = result {
715        if report.success {
716            let now = std::time::SystemTime::now()
717                .duration_since(std::time::UNIX_EPOCH)
718                .map(|d| d.as_millis() as u64)
719                .unwrap_or(0);
720            let rollback_record = MigrationHistoryRecord {
721                id: 0,
722                space: plan.target.space.clone(),
723                label: plan.target.label.clone(),
724                is_edge: plan.target.is_edge,
725                from_version: plan.version_range.to,
726                to_version: plan.version_range.from,
727                plan_hash: plan.plan_hash.clone(),
728                safety_level: format!("{:?}", plan.overall_safety),
729                steps_count: plan.steps.len(),
730                rows_migrated: report.rows_migrated,
731                status: MigrationStatus::RolledBack,
732                applied_at: now,
733                completed_at: Some(now),
734                error_message: None,
735            };
736            let _ = storage.record_migration_history(rollback_record);
737        }
738    }
739    result
740}
741
742#[cfg(test)]
743mod tests;