mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-09 16:24:45 +00:00
546 lines
18 KiB
Rust
546 lines
18 KiB
Rust
//! Metrics Measurement Protocol (MMP) — link-layer instantiation.
|
||
//!
|
||
//! Measures link quality between adjacent peers: RTT, loss, jitter,
|
||
//! throughput, one-way delay trend, and ETX. Operates on the per-frame
|
||
//! hooks (counter, timestamp, flags) introduced by the FMP wire format
|
||
//! revision.
|
||
//!
|
||
//! Three operating modes trade measurement fidelity for overhead:
|
||
//! - **Full**: sender + receiver reports at RTT-adaptive intervals
|
||
//! - **Lightweight**: receiver reports only (infer loss from counters)
|
||
//! - **Minimal**: spin bit + CE echo only, no reports
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use std::fmt::{self, Debug};
|
||
use std::time::{Duration, Instant};
|
||
|
||
// Sub-modules
|
||
pub mod algorithms;
|
||
pub mod metrics;
|
||
pub mod receiver;
|
||
pub mod report;
|
||
pub mod sender;
|
||
|
||
// Re-exports
|
||
pub use algorithms::{DualEwma, JitterEstimator, OwdTrendDetector, SrttEstimator, compute_etx};
|
||
pub use metrics::MmpMetrics;
|
||
pub use receiver::ReceiverState;
|
||
pub use report::{ReceiverReport, SenderReport};
|
||
pub use sender::SenderState;
|
||
|
||
// Session-layer re-exports
|
||
// MmpSessionState and PathMtuState are defined in this file
|
||
|
||
// ============================================================================
|
||
// Constants
|
||
// ============================================================================
|
||
|
||
/// SenderReport body size (after msg_type byte): 3 reserved + 44 payload = 47.
|
||
pub const SENDER_REPORT_BODY_SIZE: usize = 47;
|
||
|
||
/// ReceiverReport body size (after msg_type byte): 3 reserved + 64 payload = 67.
|
||
pub const RECEIVER_REPORT_BODY_SIZE: usize = 67;
|
||
|
||
/// SenderReport total wire size including inner header: 5 + 47 = 52.
|
||
pub const SENDER_REPORT_WIRE_SIZE: usize = 52;
|
||
|
||
/// ReceiverReport total wire size including inner header: 5 + 67 = 72.
|
||
pub const RECEIVER_REPORT_WIRE_SIZE: usize = 72;
|
||
|
||
// --- EWMA parameters (as shift amounts for integer arithmetic) ---
|
||
|
||
/// Jitter EWMA: α = 1/16 (RFC 3550 §6.4.1).
|
||
pub const JITTER_ALPHA_SHIFT: u32 = 4;
|
||
|
||
/// SRTT: α = 1/8 (Jacobson, RFC 6298).
|
||
pub const SRTT_ALPHA_SHIFT: u32 = 3;
|
||
|
||
/// RTTVAR: β = 1/4 (Jacobson, RFC 6298).
|
||
pub const RTTVAR_BETA_SHIFT: u32 = 2;
|
||
|
||
/// Dual EWMA short-term: α = 1/4.
|
||
pub const EWMA_SHORT_ALPHA: f64 = 0.25;
|
||
|
||
/// Dual EWMA long-term: α = 1/32.
|
||
pub const EWMA_LONG_ALPHA: f64 = 1.0 / 32.0;
|
||
|
||
// --- Timing defaults (milliseconds) ---
|
||
|
||
/// Default report interval before SRTT is available (cold start).
|
||
pub const DEFAULT_COLD_START_INTERVAL_MS: u64 = 200;
|
||
|
||
/// Minimum report interval (SRTT clamp floor).
|
||
///
|
||
/// Raised from 100ms to 1000ms: parent re-evaluation runs every 60s,
|
||
/// so 60 samples/cycle is more than sufficient for EWMA convergence (~10).
|
||
/// The cold-start phase uses `DEFAULT_COLD_START_INTERVAL_MS` (200ms) for
|
||
/// fast initial SRTT convergence before transitioning to this floor.
|
||
pub const MIN_REPORT_INTERVAL_MS: u64 = 1_000;
|
||
|
||
/// Maximum report interval (SRTT clamp ceiling).
|
||
pub const MAX_REPORT_INTERVAL_MS: u64 = 5_000;
|
||
|
||
/// Number of SRTT samples before transitioning from cold-start to normal floor.
|
||
///
|
||
/// During cold-start, report intervals use `DEFAULT_COLD_START_INTERVAL_MS` as
|
||
/// the floor to gather SRTT samples quickly. After this many updates, the floor
|
||
/// switches to `MIN_REPORT_INTERVAL_MS`.
|
||
pub const COLD_START_SAMPLES: u32 = 5;
|
||
|
||
/// Default OWD ring buffer capacity.
|
||
pub const DEFAULT_OWD_WINDOW_SIZE: usize = 32;
|
||
|
||
/// Default operator log interval in seconds.
|
||
pub const DEFAULT_LOG_INTERVAL_SECS: u64 = 30;
|
||
|
||
// --- Session-layer timing defaults ---
|
||
// Session reports are routed end-to-end (bandwidth cost on every transit link),
|
||
// so intervals are higher than link-layer.
|
||
|
||
/// Session-layer minimum report interval.
|
||
pub const MIN_SESSION_REPORT_INTERVAL_MS: u64 = 500;
|
||
|
||
/// Session-layer maximum report interval.
|
||
pub const MAX_SESSION_REPORT_INTERVAL_MS: u64 = 10_000;
|
||
|
||
/// Session-layer cold-start report interval (before SRTT is available).
|
||
pub const SESSION_COLD_START_INTERVAL_MS: u64 = 1_000;
|
||
|
||
// ============================================================================
|
||
// Operating Mode
|
||
// ============================================================================
|
||
|
||
/// MMP operating mode.
|
||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum MmpMode {
|
||
/// Sender + receiver reports at RTT-adaptive intervals. Maximum fidelity.
|
||
#[default]
|
||
Full,
|
||
/// Receiver reports only. Loss inferred from counter gaps.
|
||
Lightweight,
|
||
/// Spin bit + CE echo only. No reports exchanged.
|
||
Minimal,
|
||
}
|
||
|
||
impl fmt::Display for MmpMode {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
match self {
|
||
MmpMode::Full => write!(f, "full"),
|
||
MmpMode::Lightweight => write!(f, "lightweight"),
|
||
MmpMode::Minimal => write!(f, "minimal"),
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Configuration
|
||
// ============================================================================
|
||
|
||
/// MMP configuration (`node.mmp.*`).
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct MmpConfig {
|
||
/// Operating mode (`node.mmp.mode`).
|
||
#[serde(default)]
|
||
pub mode: MmpMode,
|
||
|
||
/// Periodic operator log interval in seconds (`node.mmp.log_interval_secs`).
|
||
#[serde(default = "MmpConfig::default_log_interval_secs")]
|
||
pub log_interval_secs: u64,
|
||
|
||
/// OWD trend ring buffer size (`node.mmp.owd_window_size`).
|
||
#[serde(default = "MmpConfig::default_owd_window_size")]
|
||
pub owd_window_size: usize,
|
||
}
|
||
|
||
impl Default for MmpConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
mode: MmpMode::default(),
|
||
log_interval_secs: DEFAULT_LOG_INTERVAL_SECS,
|
||
owd_window_size: DEFAULT_OWD_WINDOW_SIZE,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl MmpConfig {
|
||
fn default_log_interval_secs() -> u64 {
|
||
DEFAULT_LOG_INTERVAL_SECS
|
||
}
|
||
fn default_owd_window_size() -> usize {
|
||
DEFAULT_OWD_WINDOW_SIZE
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Per-Peer MMP State
|
||
// ============================================================================
|
||
|
||
/// Combined MMP state for a single peer link.
|
||
///
|
||
/// Wraps sender, receiver, and metrics state. One instance
|
||
/// per `ActivePeer`.
|
||
pub struct MmpPeerState {
|
||
pub sender: SenderState,
|
||
pub receiver: ReceiverState,
|
||
pub metrics: MmpMetrics,
|
||
mode: MmpMode,
|
||
log_interval: Duration,
|
||
last_log_time: Option<Instant>,
|
||
}
|
||
|
||
impl MmpPeerState {
|
||
/// Create MMP state for a new peer link.
|
||
pub fn new(config: &MmpConfig, is_initiator: bool) -> Self {
|
||
let _ = is_initiator; // retained for API compatibility
|
||
Self {
|
||
sender: SenderState::new(),
|
||
receiver: ReceiverState::new(config.owd_window_size),
|
||
metrics: MmpMetrics::new(),
|
||
mode: config.mode,
|
||
log_interval: Duration::from_secs(config.log_interval_secs),
|
||
last_log_time: None,
|
||
}
|
||
}
|
||
|
||
/// Reset counter-dependent state for rekey cutover.
|
||
pub fn reset_for_rekey(&mut self, now: Instant) {
|
||
self.receiver.reset_for_rekey(now);
|
||
self.metrics.reset_for_rekey();
|
||
}
|
||
|
||
/// Current operating mode.
|
||
pub fn mode(&self) -> MmpMode {
|
||
self.mode
|
||
}
|
||
|
||
/// Check if it's time to emit a periodic metrics log.
|
||
pub fn should_log(&self, now: Instant) -> bool {
|
||
match self.last_log_time {
|
||
None => true,
|
||
Some(last) => now.duration_since(last) >= self.log_interval,
|
||
}
|
||
}
|
||
|
||
/// Mark that a periodic log was emitted.
|
||
pub fn mark_logged(&mut self, now: Instant) {
|
||
self.last_log_time = Some(now);
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Per-Session MMP State (session-layer instantiation)
|
||
// ============================================================================
|
||
|
||
/// Combined MMP state for a single end-to-end session.
|
||
///
|
||
/// Wraps sender, receiver, metrics, and path MTU state.
|
||
/// One instance per established `SessionEntry`.
|
||
pub struct MmpSessionState {
|
||
pub sender: SenderState,
|
||
pub receiver: ReceiverState,
|
||
pub metrics: MmpMetrics,
|
||
mode: MmpMode,
|
||
log_interval: Duration,
|
||
last_log_time: Option<Instant>,
|
||
pub path_mtu: PathMtuState,
|
||
}
|
||
|
||
impl MmpSessionState {
|
||
/// Create MMP state for a new session.
|
||
pub fn new(config: &crate::config::SessionMmpConfig, is_initiator: bool) -> Self {
|
||
let _ = is_initiator; // retained for API compatibility
|
||
Self {
|
||
sender: SenderState::new_with_cold_start(SESSION_COLD_START_INTERVAL_MS),
|
||
receiver: ReceiverState::new_with_cold_start(
|
||
config.owd_window_size,
|
||
SESSION_COLD_START_INTERVAL_MS,
|
||
),
|
||
metrics: MmpMetrics::new(),
|
||
mode: config.mode,
|
||
log_interval: Duration::from_secs(config.log_interval_secs),
|
||
last_log_time: None,
|
||
path_mtu: PathMtuState::new(),
|
||
}
|
||
}
|
||
|
||
/// Reset counter-dependent state for rekey cutover.
|
||
pub fn reset_for_rekey(&mut self, now: Instant) {
|
||
self.receiver.reset_for_rekey(now);
|
||
self.metrics.reset_for_rekey();
|
||
}
|
||
|
||
/// Current operating mode.
|
||
pub fn mode(&self) -> MmpMode {
|
||
self.mode
|
||
}
|
||
|
||
/// Check if it's time to emit a periodic metrics log.
|
||
pub fn should_log(&self, now: Instant) -> bool {
|
||
match self.last_log_time {
|
||
None => true,
|
||
Some(last) => now.duration_since(last) >= self.log_interval,
|
||
}
|
||
}
|
||
|
||
/// Mark that a periodic log was emitted.
|
||
pub fn mark_logged(&mut self, now: Instant) {
|
||
self.last_log_time = Some(now);
|
||
}
|
||
}
|
||
|
||
impl Debug for MmpSessionState {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
f.debug_struct("MmpSessionState")
|
||
.field("mode", &self.mode)
|
||
.field("path_mtu", &self.path_mtu.current_mtu())
|
||
.finish_non_exhaustive()
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Path MTU State (session-layer only)
|
||
// ============================================================================
|
||
|
||
/// Path MTU tracking for a single session.
|
||
///
|
||
/// Destination side: observes `path_mtu` from incoming SessionDatagram envelopes
|
||
/// and generates PathMtuNotification messages back to the source.
|
||
///
|
||
/// Source side: applies received PathMtuNotification to limit outbound datagram
|
||
/// size. Decrease is immediate; increase requires 3 consecutive notifications.
|
||
pub struct PathMtuState {
|
||
/// Current effective path MTU (what we use for sending).
|
||
current_mtu: u16,
|
||
/// Last observed path MTU from incoming datagrams (destination-side).
|
||
last_observed_mtu: u16,
|
||
/// Whether the observed MTU has changed since the last notification.
|
||
observed_changed: bool,
|
||
/// Last time a PathMtuNotification was sent.
|
||
last_notification_time: Option<Instant>,
|
||
/// Notification interval: max(10s, 5 * SRTT). Default 10s.
|
||
notification_interval: Duration,
|
||
/// For source-side increase tracking: consecutive higher-value notifications.
|
||
consecutive_increase_count: u8,
|
||
/// Time of the first notification in the current increase sequence.
|
||
first_increase_time: Option<Instant>,
|
||
/// The MTU value being proposed for increase.
|
||
pending_increase_mtu: u16,
|
||
}
|
||
|
||
impl PathMtuState {
|
||
/// Create path MTU state with no initial measurement.
|
||
pub fn new() -> Self {
|
||
Self {
|
||
current_mtu: u16::MAX,
|
||
last_observed_mtu: u16::MAX,
|
||
observed_changed: false,
|
||
last_notification_time: None,
|
||
notification_interval: Duration::from_secs(10),
|
||
consecutive_increase_count: 0,
|
||
first_increase_time: None,
|
||
pending_increase_mtu: 0,
|
||
}
|
||
}
|
||
|
||
/// Current effective path MTU (source-side, for sending).
|
||
pub fn current_mtu(&self) -> u16 {
|
||
self.current_mtu
|
||
}
|
||
|
||
/// Last observed incoming path MTU (destination-side).
|
||
pub fn last_observed_mtu(&self) -> u16 {
|
||
self.last_observed_mtu
|
||
}
|
||
|
||
/// Update notification interval from SRTT: max(10s, 5 * SRTT).
|
||
pub fn update_interval_from_srtt(&mut self, srtt_ms: f64) {
|
||
let five_srtt = Duration::from_millis((srtt_ms * 5.0) as u64);
|
||
self.notification_interval = five_srtt.max(Duration::from_secs(10));
|
||
}
|
||
|
||
/// Seed source-side current_mtu from outbound transport MTU.
|
||
///
|
||
/// Called on each send. Only decreases (never increases) the current_mtu
|
||
/// so the destination's PathMtuNotification can still raise it later.
|
||
/// Ensures current_mtu doesn't stay at u16::MAX before any notification
|
||
/// arrives from the destination.
|
||
pub fn seed_source_mtu(&mut self, outbound_mtu: u16) {
|
||
if outbound_mtu < self.current_mtu {
|
||
self.current_mtu = outbound_mtu;
|
||
}
|
||
}
|
||
|
||
// --- Destination side ---
|
||
|
||
/// Observe the path_mtu from an incoming SessionDatagram envelope.
|
||
///
|
||
/// Called on the destination (receiver) side for every session message.
|
||
pub fn observe_incoming_mtu(&mut self, path_mtu: u16) {
|
||
if path_mtu != self.last_observed_mtu {
|
||
self.observed_changed = true;
|
||
self.last_observed_mtu = path_mtu;
|
||
}
|
||
}
|
||
|
||
/// Check if a PathMtuNotification should be sent.
|
||
///
|
||
/// Send on first measurement, on decrease (immediate), or periodic
|
||
/// confirmation at the notification interval.
|
||
pub fn should_send_notification(&self, now: Instant) -> bool {
|
||
if self.last_observed_mtu == u16::MAX {
|
||
return false; // No measurement yet
|
||
}
|
||
match self.last_notification_time {
|
||
None => true, // First measurement
|
||
Some(last) => {
|
||
// Immediate on decrease
|
||
if self.observed_changed && self.last_observed_mtu < self.current_mtu {
|
||
return true;
|
||
}
|
||
// Periodic confirmation
|
||
now.duration_since(last) >= self.notification_interval
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Build a PathMtuNotification from current state.
|
||
///
|
||
/// Returns the path_mtu value to send. Caller handles encoding.
|
||
pub fn build_notification(&mut self, now: Instant) -> Option<u16> {
|
||
if self.last_observed_mtu == u16::MAX {
|
||
return None;
|
||
}
|
||
self.last_notification_time = Some(now);
|
||
self.observed_changed = false;
|
||
Some(self.last_observed_mtu)
|
||
}
|
||
|
||
// --- Source side ---
|
||
|
||
/// Apply a received PathMtuNotification.
|
||
///
|
||
/// - Decrease: immediate (take the lower value).
|
||
/// - Increase: require 3 consecutive notifications with the same higher
|
||
/// value, spanning at least 2 * notification_interval.
|
||
///
|
||
/// Returns `true` if the effective MTU changed.
|
||
pub fn apply_notification(&mut self, reported_mtu: u16, now: Instant) -> bool {
|
||
if reported_mtu < self.current_mtu {
|
||
// Decrease: immediate
|
||
self.current_mtu = reported_mtu;
|
||
self.consecutive_increase_count = 0;
|
||
self.first_increase_time = None;
|
||
return true;
|
||
}
|
||
|
||
if reported_mtu > self.current_mtu {
|
||
// Increase: track consecutive notifications
|
||
if reported_mtu == self.pending_increase_mtu {
|
||
self.consecutive_increase_count += 1;
|
||
} else {
|
||
// Different value: reset sequence
|
||
self.pending_increase_mtu = reported_mtu;
|
||
self.consecutive_increase_count = 1;
|
||
self.first_increase_time = Some(now);
|
||
}
|
||
|
||
// Accept increase after 3 consecutive spanning 2 * interval
|
||
if self.consecutive_increase_count >= 3
|
||
&& let Some(first_time) = self.first_increase_time
|
||
{
|
||
let required = self.notification_interval * 2;
|
||
if now.duration_since(first_time) >= required {
|
||
self.current_mtu = reported_mtu;
|
||
self.consecutive_increase_count = 0;
|
||
self.first_increase_time = None;
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// No change (equal or increase not yet confirmed)
|
||
false
|
||
}
|
||
}
|
||
|
||
impl Default for PathMtuState {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
impl Debug for MmpPeerState {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
f.debug_struct("MmpPeerState")
|
||
.field("mode", &self.mode)
|
||
.finish_non_exhaustive()
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Tests
|
||
// ============================================================================
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_mode_default() {
|
||
assert_eq!(MmpMode::default(), MmpMode::Full);
|
||
}
|
||
|
||
#[test]
|
||
fn test_mode_display() {
|
||
assert_eq!(MmpMode::Full.to_string(), "full");
|
||
assert_eq!(MmpMode::Lightweight.to_string(), "lightweight");
|
||
assert_eq!(MmpMode::Minimal.to_string(), "minimal");
|
||
}
|
||
|
||
#[test]
|
||
fn test_mode_serde_roundtrip() {
|
||
let yaml = "full";
|
||
let mode: MmpMode = serde_yaml::from_str(yaml).unwrap();
|
||
assert_eq!(mode, MmpMode::Full);
|
||
|
||
let yaml = "lightweight";
|
||
let mode: MmpMode = serde_yaml::from_str(yaml).unwrap();
|
||
assert_eq!(mode, MmpMode::Lightweight);
|
||
|
||
let yaml = "minimal";
|
||
let mode: MmpMode = serde_yaml::from_str(yaml).unwrap();
|
||
assert_eq!(mode, MmpMode::Minimal);
|
||
}
|
||
|
||
#[test]
|
||
fn test_config_default() {
|
||
let config = MmpConfig::default();
|
||
assert_eq!(config.mode, MmpMode::Full);
|
||
assert_eq!(config.log_interval_secs, 30);
|
||
assert_eq!(config.owd_window_size, 32);
|
||
}
|
||
|
||
#[test]
|
||
fn test_config_yaml_parse() {
|
||
let yaml = r#"
|
||
mode: lightweight
|
||
log_interval_secs: 60
|
||
owd_window_size: 48
|
||
"#;
|
||
let config: MmpConfig = serde_yaml::from_str(yaml).unwrap();
|
||
assert_eq!(config.mode, MmpMode::Lightweight);
|
||
assert_eq!(config.log_interval_secs, 60);
|
||
assert_eq!(config.owd_window_size, 48);
|
||
}
|
||
|
||
#[test]
|
||
fn test_config_yaml_partial() {
|
||
let yaml = "mode: minimal";
|
||
let config: MmpConfig = serde_yaml::from_str(yaml).unwrap();
|
||
assert_eq!(config.mode, MmpMode::Minimal);
|
||
assert_eq!(config.log_interval_secs, DEFAULT_LOG_INTERVAL_SECS);
|
||
assert_eq!(config.owd_window_size, DEFAULT_OWD_WINDOW_SIZE);
|
||
}
|
||
}
|