From bbb0f083b01cf88bfcb4e2d66f78fe7681158cf4 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Fri, 13 Mar 2026 23:26:28 +0100 Subject: [PATCH] vmm: api: add configurable downtime and timeout to VmSendMigrationData Management software needs fine-grained control over live migration to meet QoS requirements for VM guests. Add `downtime_ms`, `timeout_s`, and `timeout_strategy` fields to `VmSendMigrationData`, exposed via API. This commit contains the API changes only; the VMM does not yet act on these values. This follows in the next commit. For the JSON API, downtime and timeout are represented as plain integers (downtime_ms and timeout_s) to make the units explicit. Using Duration directly would require custom (de)serialization logic, so instead the internal raw integers are exposed as Duration via getters. This introduces minor conversion overhead but keeps the Rust API clear and unambiguous. Signed-off-by: Philipp Schuster On-behalf-of: SAP philipp.schuster@sap.com --- vmm/src/api/mod.rs | 165 +++++++++++++++++++++++++++++++++++++++++++-- vmm/src/lib.rs | 12 +++- 2 files changed, 170 insertions(+), 7 deletions(-) diff --git a/vmm/src/api/mod.rs b/vmm/src/api/mod.rs index fd290a8a7..4b51c7eb3 100644 --- a/vmm/src/api/mod.rs +++ b/vmm/src/api/mod.rs @@ -34,7 +34,10 @@ pub mod dbus; pub mod http; use std::io; +use std::num::NonZeroU64; +use std::str::FromStr; use std::sync::mpsc::{RecvError, SendError, Sender, channel}; +use std::time::Duration; use log::info; use micro_http::Body; @@ -267,27 +270,83 @@ pub struct VmReceiveMigrationData { pub receiver_url: String, } +#[derive(Copy, Clone, Default, Deserialize, Serialize, Debug, PartialEq, Eq)] +/// The migration timeout strategy. +/// +/// This strategy describes the behavior of the migration when the target +/// downtime can't be reached in the given timeout. +pub enum TimeoutStrategy { + #[default] + /// Cancel the migration and keep the VM running on the source. + Cancel, + /// Ignore the timeout and migrate anyway. + Ignore, +} + +impl FromStr for TimeoutStrategy { + type Err = String; + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "cancel" => Ok(TimeoutStrategy::Cancel), + "ignore" => Ok(TimeoutStrategy::Ignore), + _ => Err(format!("Invalid timeout strategy: {s}")), + } + } +} + #[derive(Debug, Error)] #[error("Error parsing send migration parameters")] pub struct VmSendMigrationParseError(#[source] OptionParserError); /// Configuration for an outgoing migration. #[derive(Clone, Deserialize, Serialize, Debug)] +#[cfg_attr(test, derive(PartialEq))] pub struct VmSendMigrationData { /// URL to migrate the VM to pub destination_url: String, /// Send memory across socket without copying #[serde(default)] pub local: bool, + /// The maximum downtime the migration aims for. + /// + /// Usually, on the order of a few hundred milliseconds. + #[serde(default = "VmSendMigrationData::default_downtime_ms")] + downtime_ms: NonZeroU64, + /// The timeout for the migration, i.e., the maximum duration. + #[serde(default = "VmSendMigrationData::default_timeout_s")] + timeout_s: NonZeroU64, + /// The timeout strategy for the migration. + #[serde(default)] + pub timeout_strategy: TimeoutStrategy, } impl VmSendMigrationData { pub const SYNTAX: &'static str = "VM send migration parameters \ - \"destination_url=[,local=on|off]\""; + \"destination_url=[,local=on|off,\ + downtime_ms=,timeout_s=,\ + timeout_strategy=cancel|ignore]\""; + + // Same as QEMU. + pub const DEFAULT_DOWNTIME: Duration = Duration::from_millis(300); + pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60 * 60 /* one hour */); + + fn default_downtime_ms() -> NonZeroU64 { + let ms_u64 = u64::try_from(Self::DEFAULT_DOWNTIME.as_millis()).unwrap(); + NonZeroU64::new(ms_u64).unwrap() + } + + fn default_timeout_s() -> NonZeroU64 { + NonZeroU64::new(Self::DEFAULT_TIMEOUT.as_secs()).unwrap() + } pub fn parse(migration: &str) -> Result { let mut parser = OptionParser::new(); - parser.add("destination_url").add("local"); + parser + .add("destination_url") + .add("local") + .add("downtime_ms") + .add("timeout_s") + .add("timeout_strategy"); parser.parse(migration).map_err(VmSendMigrationParseError)?; let destination_url = parser.get("destination_url").ok_or_else(|| { @@ -300,12 +359,49 @@ impl VmSendMigrationData { .map_err(VmSendMigrationParseError)? .unwrap_or(Toggle(false)) .0; + let downtime_ms = match parser + .convert::("downtime_ms") + .map_err(VmSendMigrationParseError)? + { + Some(v) => NonZeroU64::new(v).ok_or_else(|| { + VmSendMigrationParseError(OptionParserError::InvalidValue( + "downtime_ms must be non-zero".to_string(), + )) + })?, + None => Self::default_downtime_ms(), + }; + let timeout_s = match parser + .convert::("timeout_s") + .map_err(VmSendMigrationParseError)? + { + Some(v) => NonZeroU64::new(v).ok_or_else(|| { + VmSendMigrationParseError(OptionParserError::InvalidValue( + "timeout_s must be non-zero".to_string(), + )) + })?, + None => Self::default_timeout_s(), + }; + let timeout_strategy = parser + .convert("timeout_strategy") + .map_err(VmSendMigrationParseError)? + .unwrap_or_default(); Ok(Self { destination_url, local, + downtime_ms, + timeout_s, + timeout_strategy, }) } + + pub fn downtime(&self) -> Duration { + Duration::from_millis(self.downtime_ms.get()) + } + + pub fn timeout(&self) -> Duration { + Duration::from_secs(self.timeout_s.get()) + } } pub enum ApiResponsePayload { @@ -1582,15 +1678,76 @@ mod unit_tests { #[test] fn test_vm_send_migration_data_parse() { // Fully specified - let data = VmSendMigrationData::parse("destination_url=tcp://192.168.1.1:8080,local=on") - .expect("valid migration string should parse"); + let data = VmSendMigrationData::parse( + "destination_url=tcp://192.168.1.1:8080,local=on,downtime_ms=200,timeout_s=3600,timeout_strategy=cancel" + ).expect("valid migration string should parse"); assert_eq!(data.destination_url, "tcp://192.168.1.1:8080"); assert!(data.local); + assert_eq!(data.downtime_ms.get(), 200); + assert_eq!(data.timeout_s.get(), 3600); + assert_eq!(data.timeout_strategy, TimeoutStrategy::Cancel); + + // Defaults applied when optional fields are omitted + let data = VmSendMigrationData::parse("destination_url=tcp://192.168.1.1:8080") + .expect("minimal migration string should parse"); + assert_eq!(data.destination_url, "tcp://192.168.1.1:8080"); + assert!(!data.local); + assert_eq!(data.downtime_ms, VmSendMigrationData::default_downtime_ms()); + assert_eq!(data.timeout_s, VmSendMigrationData::default_timeout_s()); + assert_eq!(data.timeout_strategy, TimeoutStrategy::default()); + + // Missing destination_url is an error + VmSendMigrationData::parse("local=on,downtime_ms=200").unwrap_err(); + + // Zero downtime_ms is rejected + let _data = + VmSendMigrationData::parse("destination_url=tcp://192.168.1.1:8080,downtime_ms=0") + .expect_err("zero downtime_ms should be rejected"); + + // Zero timeout_s is rejected + let _data = VmSendMigrationData::parse("destination_url=unix:/tmp/sock,timeout_s=0") + .expect_err("zero timeout_s should be rejected"); // Unknown option is an error VmSendMigrationData::parse("destination_url=unix:/tmp/sock,unknown_field=foo").unwrap_err(); // Invalid toggle value is an error VmSendMigrationData::parse("destination_url=unix:/tmp/sock,local=yes").unwrap_err(); + + // Timeout strategy + let _data = VmSendMigrationData::parse( + "destination_url=tcp://192.168.1.1:8080,timeout_strategy=invalid", + ) + .expect_err("zero downtime_ms should be rejected"); + + // Happy path with some defaults + let data = + VmSendMigrationData::parse("destination_url=tcp://192.168.1.1:8080,downtime_ms=150") + .unwrap(); + assert_eq!( + data, + VmSendMigrationData { + destination_url: "tcp://192.168.1.1:8080".to_string(), + local: false, + downtime_ms: NonZeroU64::new(150).unwrap(), + timeout_s: VmSendMigrationData::default_timeout_s(), + timeout_strategy: Default::default(), + } + ); + + // Happy path, fully specified + let data = + VmSendMigrationData::parse("destination_url=tcp://192.168.1.1:8080,downtime_ms=150,timeout_s=900,timeout_strategy=ignore") + .unwrap(); + assert_eq!( + data, + VmSendMigrationData { + destination_url: "tcp://192.168.1.1:8080".to_string(), + local: false, + downtime_ms: NonZeroU64::new(150).unwrap(), + timeout_s: NonZeroU64::new(900).unwrap(), + timeout_strategy: TimeoutStrategy::Ignore, + } + ); } } diff --git a/vmm/src/lib.rs b/vmm/src/lib.rs index cb72d69d9..bb775390a 100644 --- a/vmm/src/lib.rs +++ b/vmm/src/lib.rs @@ -1308,6 +1308,8 @@ impl Vmm { fn do_memory_migration( vm: &mut Vm, socket: &mut SocketStream, + // Used in next commit + _send_data_migration: &VmSendMigrationData, ) -> result::Result<(), MigratableError> { const MAX_ITERATIONS: usize = 5; @@ -1424,7 +1426,7 @@ impl Vmm { // Now pause VM vm.pause()?; } else { - Self::do_memory_migration(vm, &mut socket)?; + Self::do_memory_migration(vm, &mut socket, send_data_migration)?; } // We release the locks early to enable locking them on the destination host. @@ -2438,8 +2440,12 @@ impl RequestHandler for Vmm { send_data_migration: VmSendMigrationData, ) -> result::Result<(), MigratableError> { info!( - "Sending migration: destination_url = {}, local = {}", - send_data_migration.destination_url, send_data_migration.local + "Sending migration: destination_url={},local={},downtime={}ms,timeout={}s,timeout_strategy={:?}", + send_data_migration.destination_url, + send_data_migration.local, + send_data_migration.downtime().as_millis(), + send_data_migration.timeout().as_secs(), + send_data_migration.timeout_strategy ); if !self