1use crate::adapter::net::state::snapshot::StateSnapshot;
7
8pub const SUBPROTOCOL_MIGRATION: u16 = 0x0500;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum MigrationPhase {
14 Snapshot,
16 Transfer,
18 Restore,
20 Replay,
22 Cutover,
24 Complete,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum MigrationFailureReason {
40 NotReady,
45 FactoryNotFound,
51 ComputeNotSupported,
55 StateFailed(String),
58 AlreadyMigrating,
63 IdentityTransportFailed(String),
67 NotReadyTimeout {
71 attempts: u8,
74 },
75}
76
77impl MigrationFailureReason {
78 pub fn is_retriable(&self) -> bool {
82 matches!(self, MigrationFailureReason::NotReady)
83 }
84
85 pub fn code(&self) -> u16 {
89 match self {
90 MigrationFailureReason::NotReady => 0,
91 MigrationFailureReason::FactoryNotFound => 1,
92 MigrationFailureReason::ComputeNotSupported => 2,
93 MigrationFailureReason::StateFailed(_) => 3,
94 MigrationFailureReason::AlreadyMigrating => 4,
95 MigrationFailureReason::IdentityTransportFailed(_) => 5,
96 MigrationFailureReason::NotReadyTimeout { .. } => 6,
97 }
98 }
99}
100
101impl std::fmt::Display for MigrationFailureReason {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 match self {
104 Self::NotReady => write!(f, "target runtime not ready yet"),
105 Self::FactoryNotFound => {
106 write!(f, "no factory registered on target for this daemon")
107 }
108 Self::ComputeNotSupported => {
109 write!(f, "target does not run a compute runtime")
110 }
111 Self::StateFailed(msg) => write!(f, "state failed: {msg}"),
112 Self::AlreadyMigrating => write!(f, "daemon is already migrating"),
113 Self::IdentityTransportFailed(msg) => {
114 write!(f, "identity envelope transport failed: {msg}")
115 }
116 Self::NotReadyTimeout { attempts } => {
117 write!(f, "source gave up after {attempts} NotReady retries")
118 }
119 }
120 }
121}
122
123pub struct MigrationState {
125 daemon_origin: u64,
127 source_node: u64,
129 target_node: u64,
131 phase: MigrationPhase,
133 snapshot: Option<StateSnapshot>,
135 started_at: std::time::Instant,
144 phase_entered_at: std::time::Instant,
150 retry_count: u32,
157}
158
159impl MigrationState {
160 pub fn new(daemon_origin: u64, source_node: u64, target_node: u64) -> Self {
162 let now = std::time::Instant::now();
163 Self {
164 daemon_origin,
165 source_node,
166 target_node,
167 phase: MigrationPhase::Snapshot,
168 snapshot: None,
169 started_at: now,
170 phase_entered_at: now,
171 retry_count: 0,
172 }
173 }
174
175 pub fn set_snapshot(&mut self, snapshot: StateSnapshot) -> Result<(), MigrationError> {
177 if self.phase != MigrationPhase::Snapshot {
178 return Err(MigrationError::WrongPhase {
179 expected: MigrationPhase::Snapshot,
180 got: self.phase,
181 });
182 }
183 if snapshot.entity_id.origin_hash() != self.daemon_origin {
185 return Err(MigrationError::StateFailed(format!(
186 "snapshot origin {:#x} does not match daemon {:#x}",
187 snapshot.entity_id.origin_hash(),
188 self.daemon_origin,
189 )));
190 }
191 self.snapshot = Some(snapshot);
192 self.phase = MigrationPhase::Transfer;
193 self.phase_entered_at = std::time::Instant::now();
194 Ok(())
195 }
196
197 pub fn transfer_complete(&mut self) -> Result<(), MigrationError> {
199 if self.phase != MigrationPhase::Transfer {
200 return Err(MigrationError::WrongPhase {
201 expected: MigrationPhase::Transfer,
202 got: self.phase,
203 });
204 }
205 self.phase = MigrationPhase::Restore;
206 self.phase_entered_at = std::time::Instant::now();
207 Ok(())
208 }
209
210 pub fn restore_complete(&mut self) -> Result<(), MigrationError> {
212 if self.phase != MigrationPhase::Restore {
213 return Err(MigrationError::WrongPhase {
214 expected: MigrationPhase::Restore,
215 got: self.phase,
216 });
217 }
218 self.phase = MigrationPhase::Replay;
219 self.phase_entered_at = std::time::Instant::now();
220 Ok(())
221 }
222
223 pub fn replay_complete(&mut self) -> Result<(), MigrationError> {
225 if self.phase != MigrationPhase::Replay {
226 return Err(MigrationError::WrongPhase {
227 expected: MigrationPhase::Replay,
228 got: self.phase,
229 });
230 }
231 self.phase = MigrationPhase::Cutover;
232 self.phase_entered_at = std::time::Instant::now();
233 Ok(())
234 }
235
236 pub fn cutover_complete(&mut self) -> Result<(), MigrationError> {
238 if self.phase != MigrationPhase::Cutover {
239 return Err(MigrationError::WrongPhase {
240 expected: MigrationPhase::Cutover,
241 got: self.phase,
242 });
243 }
244 self.phase = MigrationPhase::Complete;
245 self.phase_entered_at = std::time::Instant::now();
246 Ok(())
247 }
248
249 pub(crate) fn force_phase(&mut self, phase: MigrationPhase) {
255 self.phase = phase;
256 self.phase_entered_at = std::time::Instant::now();
257 }
258
259 pub fn is_complete(&self) -> bool {
261 self.phase == MigrationPhase::Complete
262 }
263
264 pub fn elapsed_ms(&self) -> u64 {
270 u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX)
271 }
272
273 pub fn age_in_phase_ms(&self) -> u64 {
277 u64::try_from(self.phase_entered_at.elapsed().as_millis()).unwrap_or(u64::MAX)
278 }
279
280 #[inline]
282 pub fn retry_count(&self) -> u32 {
283 self.retry_count
284 }
285
286 pub fn bump_retry(&mut self) {
290 self.retry_count = self.retry_count.saturating_add(1);
291 }
292
293 pub fn snapshot_size_bytes(&self) -> Option<u64> {
298 self.snapshot.as_ref().map(|s| s.state.len() as u64)
299 }
300
301 #[inline]
303 pub fn daemon_origin(&self) -> u64 {
304 self.daemon_origin
305 }
306
307 #[inline]
309 pub fn source_node(&self) -> u64 {
310 self.source_node
311 }
312
313 #[inline]
315 pub fn target_node(&self) -> u64 {
316 self.target_node
317 }
318
319 #[inline]
321 pub fn phase(&self) -> MigrationPhase {
322 self.phase
323 }
324
325 #[inline]
327 pub fn snapshot(&self) -> Option<&StateSnapshot> {
328 self.snapshot.as_ref()
329 }
330}
331
332impl std::fmt::Debug for MigrationState {
333 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334 f.debug_struct("MigrationState")
335 .field("daemon", &format!("{:#x}", self.daemon_origin))
336 .field("source", &format!("{:#x}", self.source_node))
337 .field("target", &format!("{:#x}", self.target_node))
338 .field("phase", &self.phase)
339 .field("has_snapshot", &self.snapshot.is_some())
340 .finish()
341 }
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
346pub enum MigrationError {
347 DaemonNotFound(u64),
349 TargetUnavailable(u64),
351 NoTargetAvailable,
359 StateFailed(String),
361 AlreadyMigrating(u64),
363 WrongPhase {
365 expected: MigrationPhase,
367 got: MigrationPhase,
369 },
370 SnapshotTooLarge {
372 size: usize,
374 max: usize,
376 },
377 BufferFull {
382 events: usize,
384 bytes: usize,
386 },
387 WrongPeer {
394 daemon_origin: u64,
396 from: u64,
398 expected: u64,
400 },
401}
402
403impl std::fmt::Display for MigrationError {
404 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405 match self {
406 Self::DaemonNotFound(id) => write!(f, "daemon {:#x} not found", id),
407 Self::TargetUnavailable(id) => write!(f, "target node {:#x} unavailable", id),
408 Self::NoTargetAvailable => {
409 write!(
410 f,
411 "no candidate node satisfies the daemon's capability requirements"
412 )
413 }
414 Self::StateFailed(msg) => write!(f, "state operation failed: {}", msg),
415 Self::AlreadyMigrating(id) => write!(f, "daemon {:#x} already migrating", id),
416 Self::WrongPhase { expected, got } => {
417 write!(
418 f,
419 "wrong migration phase: expected {:?}, got {:?}",
420 expected, got
421 )
422 }
423 Self::SnapshotTooLarge { size, max } => {
424 write!(
425 f,
426 "snapshot too large: {} bytes exceeds max {} bytes",
427 size, max
428 )
429 }
430 Self::BufferFull { events, bytes } => {
431 write!(
432 f,
433 "migration buffer full: {} events / {} bytes",
434 events, bytes
435 )
436 }
437 Self::WrongPeer {
438 daemon_origin,
439 from,
440 expected,
441 } => {
442 write!(
443 f,
444 "migration {:#x}: peer {:#x} not the recorded principal {:#x}",
445 daemon_origin, from, expected
446 )
447 }
448 }
449 }
450}
451
452impl std::error::Error for MigrationError {}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use crate::adapter::net::state::causal::CausalLink;
458 use bytes::Bytes;
459
460 #[test]
461 fn test_migration_phase_progression() {
462 let kp = crate::adapter::net::identity::EntityKeypair::generate();
463 let origin = kp.origin_hash();
464 let mut state = MigrationState::new(origin, 0x1111, 0x2222);
465 assert_eq!(state.phase(), MigrationPhase::Snapshot);
466
467 assert!(state.transfer_complete().is_err());
469
470 let snapshot = StateSnapshot::new(
472 kp.entity_id().clone(),
473 CausalLink::genesis(origin, 0),
474 Bytes::from_static(b"state"),
475 crate::adapter::net::state::horizon::ObservedHorizon::new(),
476 );
477
478 state.set_snapshot(snapshot).unwrap();
479 assert_eq!(state.phase(), MigrationPhase::Transfer);
480
481 state.transfer_complete().unwrap();
482 assert_eq!(state.phase(), MigrationPhase::Restore);
483
484 state.restore_complete().unwrap();
485 assert_eq!(state.phase(), MigrationPhase::Replay);
486
487 state.replay_complete().unwrap();
488 assert_eq!(state.phase(), MigrationPhase::Cutover);
489
490 state.cutover_complete().unwrap();
491 assert_eq!(state.phase(), MigrationPhase::Complete);
492 assert!(state.is_complete());
493 }
494
495 #[test]
496 fn test_wrong_phase_error() {
497 let mut state = MigrationState::new(0xAAAA, 0x1111, 0x2222);
498
499 let err = state.restore_complete().unwrap_err();
500 assert_eq!(
501 err,
502 MigrationError::WrongPhase {
503 expected: MigrationPhase::Restore,
504 got: MigrationPhase::Snapshot,
505 }
506 );
507 }
508
509 #[test]
515 fn migration_failure_reason_is_retriable_only_for_not_ready() {
516 assert!(MigrationFailureReason::NotReady.is_retriable());
517
518 assert!(!MigrationFailureReason::FactoryNotFound.is_retriable());
520 assert!(!MigrationFailureReason::ComputeNotSupported.is_retriable());
521 assert!(!MigrationFailureReason::StateFailed("x".into()).is_retriable());
522 assert!(!MigrationFailureReason::AlreadyMigrating.is_retriable());
523 assert!(!MigrationFailureReason::IdentityTransportFailed("x".into()).is_retriable());
524 assert!(!MigrationFailureReason::NotReadyTimeout { attempts: 5 }.is_retriable());
525 }
526
527 #[test]
535 fn migration_failure_reason_code_is_stable_and_unique() {
536 let variants = [
537 (MigrationFailureReason::NotReady, 0u16),
538 (MigrationFailureReason::FactoryNotFound, 1),
539 (MigrationFailureReason::ComputeNotSupported, 2),
540 (MigrationFailureReason::StateFailed("x".into()), 3),
541 (MigrationFailureReason::AlreadyMigrating, 4),
542 (
543 MigrationFailureReason::IdentityTransportFailed("y".into()),
544 5,
545 ),
546 (MigrationFailureReason::NotReadyTimeout { attempts: 1 }, 6),
547 ];
548 for (reason, expected_code) in &variants {
549 assert_eq!(
550 reason.code(),
551 *expected_code,
552 "wire code drift for {reason:?}",
553 );
554 }
555 let codes: Vec<u16> = variants.iter().map(|(r, _)| r.code()).collect();
557 let mut sorted = codes.clone();
558 sorted.sort_unstable();
559 sorted.dedup();
560 assert_eq!(codes.len(), sorted.len(), "wire-code collision: {codes:?}",);
561 }
562
563 #[test]
569 fn migration_failure_reason_display_covers_every_variant() {
570 assert_eq!(
571 format!("{}", MigrationFailureReason::NotReady),
572 "target runtime not ready yet"
573 );
574 assert_eq!(
575 format!("{}", MigrationFailureReason::FactoryNotFound),
576 "no factory registered on target for this daemon"
577 );
578 assert_eq!(
579 format!("{}", MigrationFailureReason::ComputeNotSupported),
580 "target does not run a compute runtime"
581 );
582 assert_eq!(
583 format!("{}", MigrationFailureReason::StateFailed("boom".into())),
584 "state failed: boom"
585 );
586 assert_eq!(
587 format!("{}", MigrationFailureReason::AlreadyMigrating),
588 "daemon is already migrating"
589 );
590 assert_eq!(
591 format!(
592 "{}",
593 MigrationFailureReason::IdentityTransportFailed("seal failed".into())
594 ),
595 "identity envelope transport failed: seal failed"
596 );
597 assert_eq!(
598 format!(
599 "{}",
600 MigrationFailureReason::NotReadyTimeout { attempts: 7 }
601 ),
602 "source gave up after 7 NotReady retries"
603 );
604 }
605
606 #[test]
615 fn phase_advance_methods_reject_when_not_in_prerequisite_phase() {
616 let mut state = MigrationState::new(0xAAAA, 0x1111, 0x2222);
617
618 assert!(matches!(
620 state.transfer_complete(),
621 Err(MigrationError::WrongPhase {
622 expected: MigrationPhase::Transfer,
623 got: MigrationPhase::Snapshot
624 })
625 ));
626 assert!(matches!(
627 state.replay_complete(),
628 Err(MigrationError::WrongPhase {
629 expected: MigrationPhase::Replay,
630 got: MigrationPhase::Snapshot
631 })
632 ));
633 assert!(matches!(
634 state.cutover_complete(),
635 Err(MigrationError::WrongPhase {
636 expected: MigrationPhase::Cutover,
637 got: MigrationPhase::Snapshot
638 })
639 ));
640 }
641
642 #[test]
645 fn test_regression_set_snapshot_rejects_wrong_origin() {
646 let kp = crate::adapter::net::identity::EntityKeypair::generate();
649 let wrong_origin = kp.origin_hash();
650
651 let mut state = MigrationState::new(0xBBBB, 0x1111, 0x2222);
653
654 let snapshot = StateSnapshot::new(
655 kp.entity_id().clone(),
656 CausalLink::genesis(wrong_origin, 0),
657 Bytes::from_static(b"state"),
658 crate::adapter::net::state::horizon::ObservedHorizon::new(),
659 );
660
661 assert!(
662 state.set_snapshot(snapshot).is_err(),
663 "set_snapshot must reject snapshot from a different daemon"
664 );
665 }
666
667 #[test]
684 fn started_at_must_be_monotonic_instant_not_wall_clock_u64() {
685 let src = include_str!("migration.rs");
686
687 let struct_marker = "pub struct MigrationState";
690 let struct_start = src
691 .find(struct_marker)
692 .expect("MigrationState struct must exist");
693 let struct_end_offset = src[struct_start..]
696 .find("\n}\n")
697 .expect("struct body must terminate with `}`")
698 + struct_start;
699 let struct_body = &src[struct_start..struct_end_offset];
700
701 let body_no_comments: String = struct_body
702 .lines()
703 .map(|l| match l.find("//") {
704 Some(idx) => &l[..idx],
705 None => l,
706 })
707 .collect::<Vec<_>>()
708 .join("\n");
709
710 assert!(
711 body_no_comments.contains("started_at: std::time::Instant"),
712 "regression: MigrationState.started_at must be a \
713 monotonic `std::time::Instant`. A `u64` of wall-clock \
714 nanoseconds is unsafe — a system clock that steps \
715 backward (NTP / VM resume / manual `date`) saturates \
716 elapsed_ms to 0 and masks long-running stalls."
717 );
718 assert!(
719 !body_no_comments.contains("started_at: u64"),
720 "regression: MigrationState.started_at must not be a \
721 `u64` wall-clock timestamp."
722 );
723
724 let elapsed_marker = "pub fn elapsed_ms(";
727 let elapsed_start = src.find(elapsed_marker).expect("elapsed_ms must exist");
728 let elapsed_end_offset = src[elapsed_start..]
729 .find("\n }")
730 .expect("elapsed_ms body must terminate")
731 + elapsed_start;
732 let elapsed_body = &src[elapsed_start..elapsed_end_offset];
733
734 let elapsed_no_comments: String = elapsed_body
735 .lines()
736 .map(|l| match l.find("//") {
737 Some(idx) => &l[..idx],
738 None => l,
739 })
740 .collect::<Vec<_>>()
741 .join("\n");
742
743 assert!(
744 elapsed_no_comments.contains("self.started_at.elapsed()"),
745 "regression: elapsed_ms must derive from \
746 `self.started_at.elapsed()` to stay monotonic. \
747 Using `current_timestamp().saturating_sub(self.started_at)` \
748 reintroduces the wall-clock-jump-saturates-to-zero hazard."
749 );
750 assert!(
751 !elapsed_no_comments.contains("current_timestamp()"),
752 "regression: elapsed_ms must not call \
753 `current_timestamp()` — that's the wall-clock path \
754 with the saturating-on-jump-backward bug."
755 );
756 }
757}