1use 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
75pub trait SchemaWriteFence: Send + Sync {
83 fn hold(&self) -> Result<(), MigrationError>;
86 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#[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 pub schema_fence: Option<&'a dyn SchemaWriteFence>,
116 pub backup_dir: Option<&'a Path>,
120 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 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 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 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 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 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 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 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 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 if let Some(dir) = checkpoint_dir {
552 let _ = crate::plan::MigrationCheckpoint::cleanup(&effective_plan, dir);
553 }
554
555 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
593fn 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
632pub 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;