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 <philipp.schuster@cyberus-technology.de>
On-behalf-of: SAP philipp.schuster@sap.com
This commit is contained in:
Philipp Schuster
2026-03-13 23:26:28 +01:00
committed by Rob Bradford
parent 040fcaed92
commit bbb0f083b0
2 changed files with 170 additions and 7 deletions

View File

@@ -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<Self, Self::Err> {
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=<url>[,local=on|off]\"";
\"destination_url=<url>[,local=on|off,\
downtime_ms=<milliseconds>,timeout_s=<seconds>,\
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<Self, VmSendMigrationParseError> {
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::<u64>("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::<u64>("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,
}
);
}
}

View File

@@ -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