use std::collections::HashMap;
use crate::telemetry::pipe::classify::SessionInput;
use crate::telemetry::pipe::wire::SessionSummary;
use crate::telemetry::routing::{RoutingPhase, RoutingReasonCode};
pub(crate) const MAX_PENDING: usize = 256;
pub(crate) const PAIR_TIMEOUT_MS: i64 = 10 * 60_000;
pub(crate) const MAX_LATENCIES: usize = 1024;
fn escalation_bucket(code: RoutingReasonCode) -> Option<usize> {
match code {
RoutingReasonCode::PoorSignal => Some(0),
RoutingReasonCode::LowSuccessRate => Some(1),
RoutingReasonCode::RetryThreshold => Some(2),
RoutingReasonCode::Congestion => Some(3),
RoutingReasonCode::LowTtl => Some(4),
RoutingReasonCode::CurrentUnavailable => Some(5),
RoutingReasonCode::InitialSelection
| RoutingReasonCode::PrimarySelected
| RoutingReasonCode::PrimarySuccess
| RoutingReasonCode::FallbackSuccess
| RoutingReasonCode::EscalationApplied => None,
}
}
#[derive(Debug)]
pub(crate) struct SessionPairer {
pending: HashMap<String, i64>,
latencies: Vec<i64>,
since_ms: i64,
switches: u32,
escalations: [u32; 6],
encryption_used: u32,
}
impl SessionPairer {
pub(crate) fn new(now_ms: i64) -> Self {
Self {
pending: HashMap::new(),
latencies: Vec::new(),
since_ms: now_ms,
switches: 0,
escalations: [0; 6],
encryption_used: 0,
}
}
pub(crate) fn observe(&mut self, input: SessionInput, now_ms: i64) {
match input {
SessionInput::MlsSessionWanted { peer, ts_ms } => {
self.evict_stale(now_ms);
if !self.pending.contains_key(&peer) && self.pending.len() >= MAX_PENDING {
self.drop_oldest_pending();
}
self.pending.entry(peer).or_insert(ts_ms);
}
SessionInput::MlsSessionReady { peer, ts_ms } => {
self.evict_stale(now_ms);
let Some(init_ts) = self.pending.remove(&peer) else {
return;
};
if self.latencies.len() >= MAX_LATENCIES {
self.latencies.remove(0);
}
self.latencies.push((ts_ms - init_ts).max(0));
}
SessionInput::EncryptionUsed => self.encryption_used += 1,
SessionInput::NeighborChurn => {}
}
}
pub(crate) fn observe_routing(
&mut self,
phase: RoutingPhase,
reason: Option<RoutingReasonCode>,
) {
match phase {
RoutingPhase::Switched => self.switches += 1,
RoutingPhase::Escalated => {
if let Some(bucket) = reason.and_then(escalation_bucket) {
self.escalations[bucket] += 1;
}
}
_ => {}
}
}
pub(crate) fn take_summary(&mut self, now_ms: i64) -> SessionSummary {
let p50 = if self.latencies.is_empty() {
None
} else {
let mut sorted = self.latencies.clone();
sorted.sort_unstable();
Some(rounded_median(&sorted))
};
let summary = SessionSummary {
session_duration_s: ((now_ms - self.since_ms) as f64 / 1000.0).round().max(0.0) as i64,
routing_switches: self.switches,
routing_escalations: self.escalations.iter().sum(),
escalation_reasons: self.escalations,
mls_session_ready_latency_p50_ms: p50,
mls_encryption_used_count: self.encryption_used,
};
self.switches = 0;
self.escalations = [0; 6];
self.encryption_used = 0;
self.latencies.clear();
self.since_ms = now_ms;
summary
}
pub(crate) fn has_unreported(&self) -> bool {
self.switches > 0
|| self.escalations.iter().any(|n| *n > 0)
|| self.encryption_used > 0
|| !self.latencies.is_empty()
}
pub(crate) fn reset(&mut self, now_ms: i64) {
self.latencies.clear();
self.switches = 0;
self.escalations = [0; 6];
self.encryption_used = 0;
self.since_ms = now_ms;
}
pub(crate) fn resume(&mut self, now_ms: i64) {
self.since_ms = now_ms;
}
fn evict_stale(&mut self, now_ms: i64) {
let cutoff = now_ms - PAIR_TIMEOUT_MS;
self.pending.retain(|_, init_ts| *init_ts >= cutoff);
}
fn drop_oldest_pending(&mut self) {
let oldest = self
.pending
.iter()
.min_by_key(|(_, ts)| **ts)
.map(|(sid, _)| sid.clone());
if let Some(sid) = oldest {
self.pending.remove(&sid);
}
}
}
pub(crate) fn rounded_median(sorted: &[i64]) -> i64 {
let n = sorted.len();
if n == 0 {
return 0;
}
let mid = n / 2;
if n % 2 == 1 {
sorted[mid]
} else {
((sorted[mid - 1] + sorted[mid]) as f64 / 2.0).round() as i64
}
}
#[cfg(test)]
mod tests {
use super::*;
fn wanted(peer: &str, ts: i64) -> SessionInput {
SessionInput::MlsSessionWanted {
peer: peer.into(),
ts_ms: ts,
}
}
fn ready(peer: &str, ts: i64) -> SessionInput {
SessionInput::MlsSessionReady {
peer: peer.into(),
ts_ms: ts,
}
}
#[test]
fn pairs_want_with_ready_and_reports_the_rounded_median() {
let mut p = SessionPairer::new(0);
p.observe(wanted("a", 1000), 1000);
p.observe(ready("a", 1100), 1100);
p.observe(wanted("b", 2000), 2000);
p.observe(ready("b", 2201), 2201);
let s = p.take_summary(3000);
assert_eq!(s.mls_session_ready_latency_p50_ms, Some(151));
assert_eq!(s.session_duration_s, 3);
}
#[test]
fn repeated_wants_for_one_peer_measure_from_the_first() {
let mut p = SessionPairer::new(0);
p.observe(wanted("a", 1_000), 1_000);
p.observe(wanted("a", 30_000), 30_000);
p.observe(wanted("a", 60_000), 60_000);
p.observe(ready("a", 61_000), 61_000);
assert_eq!(
p.take_summary(61_000).mls_session_ready_latency_p50_ms,
Some(60_000)
);
}
#[test]
fn a_stale_want_never_pairs() {
let mut p = SessionPairer::new(0);
p.observe(wanted("a", 0), 0);
p.observe(ready("a", PAIR_TIMEOUT_MS + 1), PAIR_TIMEOUT_MS + 1);
assert!(p.take_summary(0).mls_session_ready_latency_p50_ms.is_none());
}
#[test]
fn a_handshake_lasting_minutes_still_pairs() {
let mut p = SessionPairer::new(0);
let five_minutes = 5 * 60_000;
p.observe(wanted("a", 0), 0);
p.observe(ready("a", five_minutes), five_minutes);
assert_eq!(
p.take_summary(five_minutes)
.mls_session_ready_latency_p50_ms,
Some(five_minutes)
);
}
#[test]
fn a_ready_without_a_want_is_ignored() {
let mut p = SessionPairer::new(0);
p.observe(ready("ghost", 10), 10);
assert!(!p.has_unreported());
}
#[test]
fn pending_is_bounded_by_dropping_the_oldest() {
let mut p = SessionPairer::new(0);
for i in 0..MAX_PENDING {
p.observe(wanted(&format!("s{i}"), i as i64), 0);
}
p.observe(wanted("late", 1000), 0);
assert_eq!(p.pending.len(), MAX_PENDING);
assert!(!p.pending.contains_key("s0"));
}
#[test]
fn latencies_are_a_sliding_window() {
let mut p = SessionPairer::new(0);
for i in 0..(MAX_LATENCIES + 10) {
let peer = format!("s{i}");
p.observe(wanted(&peer, 0), 0);
p.observe(ready(&peer, i as i64), 0);
}
assert_eq!(p.latencies.len(), MAX_LATENCIES);
assert_eq!(p.latencies[0], 10);
}
#[test]
fn the_duration_is_a_delta_so_summing_the_rows_yields_the_session() {
let mut p = SessionPairer::new(0);
assert_eq!(p.take_summary(205_000).session_duration_s, 205);
assert_eq!(p.take_summary(260_000).session_duration_s, 55);
assert_eq!(p.take_summary(300_000).session_duration_s, 40);
}
#[test]
fn a_want_survives_a_session_rotation() {
let mut p = SessionPairer::new(0);
p.observe(wanted("a", 1_000), 1_000);
p.reset(2_000);
p.observe(ready("a", 61_000), 61_000);
assert_eq!(
p.take_summary(61_000).mls_session_ready_latency_p50_ms,
Some(60_000)
);
}
#[test]
fn resuming_collection_restamps_the_anchor() {
let mut p = SessionPairer::new(0);
p.resume(3_600_000);
assert_eq!(p.take_summary(3_610_000).session_duration_s, 10);
}
#[test]
fn escalations_count_only_trigger_reasons_and_switches_count_everything() {
let mut p = SessionPairer::new(0);
p.observe_routing(RoutingPhase::Escalated, Some(RoutingReasonCode::PoorSignal));
p.observe_routing(
RoutingPhase::Escalated,
Some(RoutingReasonCode::FallbackSuccess),
);
p.observe_routing(RoutingPhase::Escalated, None);
p.observe_routing(
RoutingPhase::Switched,
Some(RoutingReasonCode::PrimarySuccess),
);
p.observe_routing(
RoutingPhase::Switched,
Some(RoutingReasonCode::EscalationApplied),
);
p.observe_routing(
RoutingPhase::Selected,
Some(RoutingReasonCode::InitialSelection),
);
let s = p.take_summary(0);
assert_eq!(s.routing_escalations, 1);
assert_eq!(s.escalation_reasons, [1, 0, 0, 0, 0, 0]);
assert_eq!(s.routing_switches, 2);
}
#[test]
fn take_summary_drains_so_a_second_summary_reports_only_the_delta() {
let mut p = SessionPairer::new(0);
p.observe(SessionInput::EncryptionUsed, 0);
p.observe(SessionInput::EncryptionUsed, 0);
assert_eq!(p.take_summary(0).mls_encryption_used_count, 2);
assert!(!p.has_unreported());
p.observe(SessionInput::EncryptionUsed, 0);
assert_eq!(p.take_summary(0).mls_encryption_used_count, 1);
}
#[test]
fn the_median_rounds_half_up_like_the_earlier_client() {
assert_eq!(rounded_median(&[]), 0);
assert_eq!(rounded_median(&[5]), 5);
assert_eq!(rounded_median(&[1, 2]), 2);
assert_eq!(rounded_median(&[1, 2, 3, 4]), 3);
}
}