use std::collections::{HashMap, HashSet};
use std::time::Duration;
use freenet_stdlib::prelude::ContractInstanceId;
use parking_lot::Mutex;
use crate::message::{InterestMessage, NetMessage, NetMessageV1};
use crate::node::background_task_monitor::BackgroundTaskMonitor;
const MAX_TRACKED_CONTRACTS: usize = 256;
const TOP_DIFFERING_CONTRACTS_REPORTED: usize = 10;
const ROLLUP_WINDOW: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum OutboundKind {
Connect,
Put,
Get,
Subscribe,
Update,
InterestSyncInterests,
InterestSyncSummaries,
InterestSyncResync,
NeighborHosting,
Other,
}
impl OutboundKind {
pub(crate) const ALL: [OutboundKind; 10] = [
OutboundKind::Connect,
OutboundKind::Put,
OutboundKind::Get,
OutboundKind::Subscribe,
OutboundKind::Update,
OutboundKind::InterestSyncInterests,
OutboundKind::InterestSyncSummaries,
OutboundKind::InterestSyncResync,
OutboundKind::NeighborHosting,
OutboundKind::Other,
];
const fn index(self) -> usize {
match self {
OutboundKind::Connect => 0,
OutboundKind::Put => 1,
OutboundKind::Get => 2,
OutboundKind::Subscribe => 3,
OutboundKind::Update => 4,
OutboundKind::InterestSyncInterests => 5,
OutboundKind::InterestSyncSummaries => 6,
OutboundKind::InterestSyncResync => 7,
OutboundKind::NeighborHosting => 8,
OutboundKind::Other => 9,
}
}
const fn stem(self) -> &'static str {
match self {
OutboundKind::Connect => "connect",
OutboundKind::Put => "put",
OutboundKind::Get => "get",
OutboundKind::Subscribe => "subscribe",
OutboundKind::Update => "update",
OutboundKind::InterestSyncInterests => "interest_sync_interests",
OutboundKind::InterestSyncSummaries => "interest_sync_summaries",
OutboundKind::InterestSyncResync => "interest_sync_resync",
OutboundKind::NeighborHosting => "neighbor_hosting",
OutboundKind::Other => "other",
}
}
pub(crate) fn classify(msg: &NetMessage) -> Self {
match msg {
NetMessage::V1(v1) => match v1 {
NetMessageV1::Connect(_) => OutboundKind::Connect,
NetMessageV1::Put(_) => OutboundKind::Put,
NetMessageV1::Get(_) => OutboundKind::Get,
NetMessageV1::Subscribe(_) => OutboundKind::Subscribe,
NetMessageV1::Update(_) => OutboundKind::Update,
NetMessageV1::InterestSync { message } => match message {
InterestMessage::Interests { .. } | InterestMessage::ChangeInterests { .. } => {
OutboundKind::InterestSyncInterests
}
InterestMessage::Summaries { .. } => OutboundKind::InterestSyncSummaries,
InterestMessage::ResyncRequest { .. }
| InterestMessage::ResyncResponse { .. } => OutboundKind::InterestSyncResync,
},
NetMessageV1::NeighborHosting { .. } => OutboundKind::NeighborHosting,
NetMessageV1::Aborted(_)
| NetMessageV1::ReadyState { .. }
| NetMessageV1::SubscribeHint { .. } => OutboundKind::Other,
},
}
}
}
#[derive(Default)]
struct Window {
msgs: [u64; 10],
bytes: [u64; 10],
max_bytes: [u64; 10],
summary_entries_identical: u64,
summary_entries_differing: u64,
differing_by_contract: HashMap<ContractInstanceId, u64>,
differing_attribution_dropped: u64,
}
pub(crate) struct OutboundMix {
window: Mutex<Window>,
}
impl OutboundMix {
pub(crate) fn new() -> Self {
Self {
window: Mutex::new(Window::default()),
}
}
pub(crate) fn record_sent(&self, kind: OutboundKind, bytes: usize) {
let b = bytes as u64;
let idx = kind.index();
let mut w = self.window.lock();
w.msgs[idx] = w.msgs[idx].saturating_add(1);
w.bytes[idx] = w.bytes[idx].saturating_add(b);
w.max_bytes[idx] = w.max_bytes[idx].max(b);
}
pub(crate) fn record_summary_comparison(
&self,
contract: &ContractInstanceId,
ours: &[u8],
theirs: &[u8],
counted_this_message: &mut HashSet<ContractInstanceId>,
) {
if !counted_this_message.insert(*contract) {
return;
}
let identical = ours == theirs;
let mut w = self.window.lock();
if identical {
w.summary_entries_identical = w.summary_entries_identical.saturating_add(1);
return;
}
w.summary_entries_differing = w.summary_entries_differing.saturating_add(1);
let mut dropped = false;
let len = w.differing_by_contract.len();
match w.differing_by_contract.entry(*contract) {
std::collections::hash_map::Entry::Occupied(mut e) => {
*e.get_mut() = e.get().saturating_add(1);
}
std::collections::hash_map::Entry::Vacant(e) => {
if len < MAX_TRACKED_CONTRACTS {
e.insert(1);
} else {
dropped = true;
}
}
}
if dropped {
w.differing_attribution_dropped = w.differing_attribution_dropped.saturating_add(1);
}
}
fn take_window(&self) -> Window {
std::mem::take(&mut *self.window.lock())
}
}
fn rollup_window_secs(elapsed: Duration) -> u64 {
elapsed.as_secs().max(1)
}
fn outbound_mix_json(w: &Window, window_secs: u64) -> serde_json::Value {
let total_msgs: u64 = w.msgs.iter().sum();
let total_bytes: u64 = w.bytes.iter().sum();
let mut body = serde_json::Map::new();
body.insert("window_secs".into(), window_secs.into());
body.insert("total_msgs".into(), total_msgs.into());
body.insert("total_bytes".into(), total_bytes.into());
for kind in OutboundKind::ALL {
let idx = kind.index();
let stem = kind.stem();
body.insert(format!("{stem}_msgs"), w.msgs[idx].into());
body.insert(format!("{stem}_bytes"), w.bytes[idx].into());
body.insert(format!("{stem}_max_bytes"), w.max_bytes[idx].into());
}
body.insert(
"summary_entries_identical".into(),
w.summary_entries_identical.into(),
);
body.insert(
"summary_entries_differing".into(),
w.summary_entries_differing.into(),
);
body.insert(
"differing_attribution_dropped".into(),
w.differing_attribution_dropped.into(),
);
let mut differing: Vec<(String, u64)> = w
.differing_by_contract
.iter()
.map(|(k, v)| (k.to_string(), *v))
.collect();
differing.sort_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
differing.truncate(TOP_DIFFERING_CONTRACTS_REPORTED);
body.insert(
"summary_differing_contracts".into(),
serde_json::Value::Array(
differing
.into_iter()
.map(|(key, count)| {
let mut o = serde_json::Map::new();
o.insert("contract".into(), key.into());
o.insert("count".into(), count.into());
serde_json::Value::Object(o)
})
.collect(),
),
);
serde_json::Value::Object(body)
}
fn emit_outbound_mix_rollup(mix: &OutboundMix, local_peer_id: &str, window_secs: u64) {
let w = mix.take_window();
crate::tracing::telemetry::send_standalone_shadow_event_with_peer_id(
"outbound_message_mix",
local_peer_id,
outbound_mix_json(&w, window_secs),
);
}
pub(crate) fn spawn_outbound_mix_aggregator(
mix: std::sync::Arc<OutboundMix>,
local_peer_id: String,
monitor: &BackgroundTaskMonitor,
) {
let handle = tokio::spawn(async move {
let mut ticker = tokio::time::interval(ROLLUP_WINDOW);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
ticker.tick().await; let mut last_rollup = tokio::time::Instant::now();
loop {
ticker.tick().await;
let now = tokio::time::Instant::now();
let elapsed = now.saturating_duration_since(last_rollup);
last_rollup = now;
emit_outbound_mix_rollup(&mix, &local_peer_id, rollup_window_secs(elapsed));
}
});
monitor.register("outbound_message_mix_aggregator", handle);
}
#[cfg(test)]
mod tests {
use super::*;
fn test_instance_id(seed: u32) -> ContractInstanceId {
let mut bytes = [0u8; 32];
bytes[..4].copy_from_slice(&seed.to_le_bytes());
ContractInstanceId::new(bytes)
}
fn test_contract_key(seed: u32) -> freenet_stdlib::prelude::ContractKey {
freenet_stdlib::prelude::ContractKey::from_id_and_code(
test_instance_id(seed),
freenet_stdlib::prelude::CodeHash::new([0u8; 32]),
)
}
#[test]
fn take_window_resets_the_window() {
let mix = OutboundMix::new();
mix.record_sent(OutboundKind::InterestSyncSummaries, 500);
let first = mix.take_window();
assert_eq!(first.msgs[OutboundKind::InterestSyncSummaries.index()], 1);
assert_eq!(
first.bytes[OutboundKind::InterestSyncSummaries.index()],
500
);
let second = mix.take_window();
assert!(
second.msgs.iter().all(|m| *m == 0) && second.bytes.iter().all(|b| *b == 0),
"second take must be empty"
);
}
#[test]
fn arms_partition_recorded_bytes() {
let mix = OutboundMix::new();
mix.record_sent(OutboundKind::Get, 10);
mix.record_sent(OutboundKind::Put, 20);
mix.record_sent(OutboundKind::InterestSyncSummaries, 30);
mix.record_sent(OutboundKind::Get, 40);
let w = mix.take_window();
assert_eq!(w.bytes.iter().sum::<u64>(), 100);
assert_eq!(w.msgs.iter().sum::<u64>(), 4);
assert_eq!(w.bytes[OutboundKind::Get.index()], 50);
}
#[test]
fn max_bytes_tracks_the_largest_single_message() {
let mix = OutboundMix::new();
mix.record_sent(OutboundKind::Update, 100);
mix.record_sent(OutboundKind::Update, 900);
mix.record_sent(OutboundKind::Update, 50);
let w = mix.take_window();
assert_eq!(w.max_bytes[OutboundKind::Update.index()], 900);
assert_eq!(w.bytes[OutboundKind::Update.index()], 1050);
}
#[test]
fn interest_sync_legs_classify_into_separate_arms() {
let wrap = |m: InterestMessage| NetMessage::V1(NetMessageV1::InterestSync { message: m });
let cases: [(InterestMessage, OutboundKind); 5] = [
(
InterestMessage::Interests { hashes: vec![1, 2] },
OutboundKind::InterestSyncInterests,
),
(
InterestMessage::ChangeInterests {
added: vec![1],
removed: vec![],
},
OutboundKind::InterestSyncInterests,
),
(
InterestMessage::Summaries { entries: vec![] },
OutboundKind::InterestSyncSummaries,
),
(
InterestMessage::ResyncRequest {
key: test_contract_key(7),
},
OutboundKind::InterestSyncResync,
),
(
InterestMessage::ResyncResponse {
key: test_contract_key(7),
state_bytes: vec![],
summary_bytes: vec![],
},
OutboundKind::InterestSyncResync,
),
];
for (msg, expected) in cases {
let label = format!("{msg:?}");
assert_eq!(
OutboundKind::classify(&wrap(msg)),
expected,
"wrong arm for {label}"
);
}
}
#[test]
fn summary_comparisons_split_identical_from_differing() {
let mix = OutboundMix::new();
let a = test_instance_id(1);
let b = test_instance_id(2);
mix.record_summary_comparison(&a, b"same", b"same", &mut HashSet::new());
mix.record_summary_comparison(&a, b"ours", b"theirs", &mut HashSet::new());
mix.record_summary_comparison(&b, b"ours", b"theirs", &mut HashSet::new());
mix.record_summary_comparison(&a, b"same", b"same", &mut HashSet::new());
let w = mix.take_window();
assert_eq!(w.summary_entries_identical, 2);
assert_eq!(w.summary_entries_differing, 2);
assert_eq!(w.differing_by_contract.get(&a).copied(), Some(1));
assert_eq!(w.differing_by_contract.get(&b).copied(), Some(1));
}
#[test]
fn differing_attribution_is_bounded_but_keeps_accruing_known_contracts() {
let mix = OutboundMix::new();
let tracked = test_instance_id(0);
mix.record_summary_comparison(&tracked, b"ours", b"theirs", &mut HashSet::new());
for i in 1..(MAX_TRACKED_CONTRACTS as u32 + 300) {
mix.record_summary_comparison(
&test_instance_id(i),
b"ours",
b"theirs",
&mut HashSet::new(),
);
}
mix.record_summary_comparison(&tracked, b"ours", b"theirs", &mut HashSet::new());
let w = mix.take_window();
assert!(
w.differing_by_contract.len() <= MAX_TRACKED_CONTRACTS,
"map must stay bounded, got {}",
w.differing_by_contract.len()
);
assert_eq!(
w.differing_by_contract.get(&tracked).copied(),
Some(2),
"an already-tracked contract must keep accruing past the cap"
);
assert_eq!(
w.summary_entries_differing,
MAX_TRACKED_CONTRACTS as u64 + 301
);
}
#[test]
fn rollup_body_ranks_differing_contracts_descending_and_truncates() {
let mix = OutboundMix::new();
let n = TOP_DIFFERING_CONTRACTS_REPORTED as u32 + 5;
for i in 1..=n {
for _ in 0..i {
mix.record_summary_comparison(
&test_instance_id(i),
b"ours",
b"theirs",
&mut HashSet::new(),
);
}
}
let w = mix.take_window();
let body = outbound_mix_json(&w, 60);
let listed = body
.get("summary_differing_contracts")
.and_then(|v| v.as_array())
.expect("summary_differing_contracts must be an array");
assert_eq!(
listed.len(),
TOP_DIFFERING_CONTRACTS_REPORTED,
"the list must be truncated to the top N"
);
let counts: Vec<u64> = listed
.iter()
.map(|e| e.get("count").and_then(|c| c.as_u64()).expect("count"))
.collect();
let mut descending = counts.clone();
descending.sort_unstable_by(|a, b| b.cmp(a));
assert_eq!(counts, descending, "counts must be ranked descending");
assert_eq!(
counts[0], n as u64,
"the highest-diverging contract must rank first"
);
assert_eq!(
*counts.last().expect("non-empty"),
(n - TOP_DIFFERING_CONTRACTS_REPORTED as u32 + 1) as u64,
"the Nth-ranked count must be the Nth largest, not the smallest"
);
}
#[test]
fn headline_counters_reach_the_rollup_body_under_the_right_keys() {
let mix = OutboundMix::new();
let c = test_instance_id(1);
for _ in 0..3 {
mix.record_summary_comparison(&c, b"same", b"same", &mut HashSet::new());
}
for _ in 0..5 {
mix.record_summary_comparison(&c, b"ours", b"theirs", &mut HashSet::new());
}
let body = outbound_mix_json(&mix.take_window(), 60);
assert_eq!(
body.get("summary_entries_identical")
.and_then(|v| v.as_u64()),
Some(3),
"identical count must reach the body under its own key"
);
assert_eq!(
body.get("summary_entries_differing")
.and_then(|v| v.as_u64()),
Some(5),
"differing count must reach the body under its own key"
);
}
#[test]
fn a_contract_is_counted_once_per_message_however_often_repeated() {
let mix = OutboundMix::new();
let c = test_instance_id(1);
let other = test_instance_id(2);
let mut first_message = HashSet::new();
for _ in 0..5 {
mix.record_summary_comparison(&c, b"ours", b"theirs", &mut first_message);
}
mix.record_summary_comparison(&other, b"same", b"same", &mut first_message);
let mut second_message = HashSet::new();
mix.record_summary_comparison(&c, b"ours", b"theirs", &mut second_message);
let w = mix.take_window();
assert_eq!(
w.summary_entries_differing, 2,
"five repeats in one message count once; the second message counts again"
);
assert_eq!(w.summary_entries_identical, 1);
assert_eq!(
w.differing_by_contract.get(&c).copied(),
Some(2),
"per-contract attribution must dedup the same way as the aggregate"
);
}
#[test]
fn idle_window_emits_zeroed_measurement_fields() {
let body = outbound_mix_json(&Window::default(), 60);
assert_eq!(
body.get("summary_entries_identical")
.and_then(|v| v.as_u64()),
Some(0)
);
assert_eq!(
body.get("summary_entries_differing")
.and_then(|v| v.as_u64()),
Some(0)
);
assert_eq!(
body.get("differing_attribution_dropped")
.and_then(|v| v.as_u64()),
Some(0)
);
assert_eq!(
body.get("summary_differing_contracts")
.and_then(|v| v.as_array())
.map(|a| a.len()),
Some(0),
"an idle window must emit an empty list, not omit the field"
);
}
#[test]
fn capped_attribution_reports_its_drops() {
let mix = OutboundMix::new();
let overflow = 7u32;
for i in 0..(MAX_TRACKED_CONTRACTS as u32 + overflow) {
mix.record_summary_comparison(
&test_instance_id(i),
b"ours",
b"theirs",
&mut HashSet::new(),
);
}
let w = mix.take_window();
assert_eq!(w.differing_by_contract.len(), MAX_TRACKED_CONTRACTS);
assert_eq!(w.differing_attribution_dropped, overflow as u64);
let body = outbound_mix_json(&w, 60);
assert_eq!(
body.get("differing_attribution_dropped")
.and_then(|v| v.as_u64()),
Some(overflow as u64),
"the drop count must reach the rollup, not just the window"
);
}
#[test]
fn arms_have_unique_indices_and_stems() {
let mut idxs: Vec<usize> = OutboundKind::ALL.iter().map(|k| k.index()).collect();
idxs.sort_unstable();
idxs.dedup();
assert_eq!(idxs.len(), OutboundKind::ALL.len(), "duplicate arm index");
assert_eq!(
*idxs.last().expect("non-empty"),
OutboundKind::ALL.len() - 1,
"indices must be dense so the fixed-size arrays cover them"
);
let mut stems: Vec<&str> = OutboundKind::ALL.iter().map(|k| k.stem()).collect();
stems.sort_unstable();
stems.dedup();
assert_eq!(stems.len(), OutboundKind::ALL.len(), "duplicate field stem");
}
#[test]
fn rollup_window_secs_is_clamped_to_the_real_elapsed_span() {
assert_eq!(rollup_window_secs(Duration::from_millis(10)), 1);
assert_eq!(rollup_window_secs(Duration::from_secs(60)), 60);
assert_eq!(rollup_window_secs(Duration::from_secs(300)), 300);
}
}