use std::collections::{HashMap, HashSet};
use std::time::Duration;
use freenet_stdlib::prelude::ContractInstanceId;
use parking_lot::Mutex;
use crate::message::{InterestMessage, NetMessage, NetMessageV1, SummariesEmitter};
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",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct SummariesDetail {
emitter: SummariesEmitter,
entries: u64,
}
const SUMMARIES_ARMS: [SummariesEmitter; 7] = [
SummariesEmitter::Notification,
SummariesEmitter::InterestsReply,
SummariesEmitter::ChangeInterestsReply,
SummariesEmitter::Rejection,
SummariesEmitter::SummaryRequestReply,
SummariesEmitter::SummaryRequest,
SummariesEmitter::Other,
];
const fn summaries_index(emitter: SummariesEmitter) -> usize {
match emitter {
SummariesEmitter::Notification => 0,
SummariesEmitter::InterestsReply => 1,
SummariesEmitter::ChangeInterestsReply => 2,
SummariesEmitter::Rejection => 3,
SummariesEmitter::SummaryRequestReply => 4,
SummariesEmitter::SummaryRequest => 5,
SummariesEmitter::Other => 6,
}
}
const fn summaries_stem(emitter: SummariesEmitter) -> &'static str {
match emitter {
SummariesEmitter::Notification => "interest_sync_summaries_notification",
SummariesEmitter::InterestsReply => "interest_sync_summaries_interests_reply",
SummariesEmitter::ChangeInterestsReply => "interest_sync_summaries_change_interests_reply",
SummariesEmitter::Rejection => "interest_sync_summaries_rejection",
SummariesEmitter::SummaryRequestReply => "interest_sync_summaries_request_reply",
SummariesEmitter::SummaryRequest => "interest_sync_summaries_request_leg",
SummariesEmitter::Other => "interest_sync_summaries_other",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct OutboundClass {
pub(crate) kind: OutboundKind,
summaries: Option<SummariesDetail>,
}
impl OutboundClass {
const fn plain(kind: OutboundKind) -> Self {
Self {
kind,
summaries: None,
}
}
pub(crate) fn classify(msg: &NetMessage) -> Self {
match msg {
NetMessage::V1(v1) => match v1 {
NetMessageV1::Connect(_) => Self::plain(OutboundKind::Connect),
NetMessageV1::Put(_) => Self::plain(OutboundKind::Put),
NetMessageV1::Get(_) => Self::plain(OutboundKind::Get),
NetMessageV1::Subscribe(_) => Self::plain(OutboundKind::Subscribe),
NetMessageV1::Update(_) => Self::plain(OutboundKind::Update),
NetMessageV1::InterestSync { message } => match message {
InterestMessage::Interests { .. } | InterestMessage::ChangeInterests { .. } => {
Self::plain(OutboundKind::InterestSyncInterests)
}
InterestMessage::Summaries { entries, emitter } => Self {
kind: OutboundKind::InterestSyncSummaries,
summaries: Some(SummariesDetail {
emitter: *emitter,
entries: entries.len() as u64,
}),
},
InterestMessage::SummaryDigests { entries, emitter } => Self {
kind: OutboundKind::InterestSyncSummaries,
summaries: Some(SummariesDetail {
emitter: *emitter,
entries: entries.len() as u64,
}),
},
InterestMessage::SummaryRequest { hashes } => Self {
kind: OutboundKind::InterestSyncSummaries,
summaries: Some(SummariesDetail {
emitter: SummariesEmitter::SummaryRequest,
entries: hashes.len() as u64,
}),
},
InterestMessage::ResyncRequest { .. }
| InterestMessage::ResyncResponse { .. } => {
Self::plain(OutboundKind::InterestSyncResync)
}
},
NetMessageV1::NeighborHosting { .. } => Self::plain(OutboundKind::NeighborHosting),
NetMessageV1::Aborted(_)
| NetMessageV1::ReadyState { .. }
| NetMessageV1::SubscribeHint { .. } => Self::plain(OutboundKind::Other),
},
}
}
}
#[derive(Default)]
struct Window {
msgs: [u64; 10],
bytes: [u64; 10],
max_bytes: [u64; 10],
summaries_msgs: [u64; SUMMARIES_ARMS.len()],
summaries_bytes: [u64; SUMMARIES_ARMS.len()],
summaries_max_bytes: [u64; SUMMARIES_ARMS.len()],
summaries_entries: [u64; SUMMARIES_ARMS.len()],
summaries_max_entries: [u64; SUMMARIES_ARMS.len()],
summary_entries_identical: u64,
summary_entries_differing: u64,
summary_entries_one_sided: u64,
differing_by_contract: HashMap<ContractInstanceId, u64>,
notification_targets_sent: u64,
notification_cohosts_skipped: 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, class: OutboundClass, bytes: usize) {
let b = bytes as u64;
let idx = class.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);
if class.kind == OutboundKind::InterestSyncSummaries {
let detail = class.summaries.unwrap_or_default();
let s = summaries_index(detail.emitter);
w.summaries_msgs[s] = w.summaries_msgs[s].saturating_add(1);
w.summaries_bytes[s] = w.summaries_bytes[s].saturating_add(b);
w.summaries_max_bytes[s] = w.summaries_max_bytes[s].max(b);
w.summaries_entries[s] = w.summaries_entries[s].saturating_add(detail.entries);
w.summaries_max_entries[s] = w.summaries_max_entries[s].max(detail.entries);
}
}
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);
}
}
pub(crate) fn record_summary_one_sided(
&self,
contract: &ContractInstanceId,
counted_this_message: &mut HashSet<ContractInstanceId>,
) {
if !counted_this_message.insert(*contract) {
return;
}
let mut w = self.window.lock();
w.summary_entries_one_sided = w.summary_entries_one_sided.saturating_add(1);
}
pub(crate) fn record_notification_recipients(&self, sent: u64, skipped: u64) {
let mut w = self.window.lock();
w.notification_targets_sent = w.notification_targets_sent.saturating_add(sent);
w.notification_cohosts_skipped = w.notification_cohosts_skipped.saturating_add(skipped);
}
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());
}
for emitter in SUMMARIES_ARMS {
let s = summaries_index(emitter);
let stem = summaries_stem(emitter);
body.insert(format!("{stem}_msgs"), w.summaries_msgs[s].into());
body.insert(format!("{stem}_bytes"), w.summaries_bytes[s].into());
body.insert(format!("{stem}_max_bytes"), w.summaries_max_bytes[s].into());
body.insert(format!("{stem}_entries"), w.summaries_entries[s].into());
body.insert(
format!("{stem}_max_entries"),
w.summaries_max_entries[s].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(
"summary_entries_one_sided".into(),
w.summary_entries_one_sided.into(),
);
body.insert(
"differing_attribution_dropped".into(),
w.differing_attribution_dropped.into(),
);
body.insert(
"notification_targets_sent".into(),
w.notification_targets_sent.into(),
);
body.insert(
"notification_cohosts_skipped".into(),
w.notification_cohosts_skipped.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]),
)
}
fn record(mix: &OutboundMix, kind: OutboundKind, bytes: usize) {
mix.record_sent(OutboundClass::plain(kind), bytes);
}
fn summaries_msg(emitter: SummariesEmitter, n: usize, per_entry: usize) -> NetMessage {
NetMessage::V1(NetMessageV1::InterestSync {
message: InterestMessage::Summaries {
entries: (0..n)
.map(|i| crate::message::SummaryEntry {
hash: i as u32,
summary_bytes: Some(vec![0u8; per_entry]),
})
.collect(),
emitter,
},
})
}
fn record_summaries(mix: &OutboundMix, emitter: SummariesEmitter, n: usize, bytes: usize) {
mix.record_sent(
OutboundClass::classify(&summaries_msg(emitter, n, 1)),
bytes,
);
}
#[test]
fn take_window_resets_the_window() {
let mix = OutboundMix::new();
record(&mix, 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();
record(&mix, OutboundKind::Get, 10);
record(&mix, OutboundKind::Put, 20);
record(&mix, OutboundKind::InterestSyncSummaries, 30);
record(&mix, 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();
record(&mix, OutboundKind::Update, 100);
record(&mix, OutboundKind::Update, 900);
record(&mix, 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![],
emitter: SummariesEmitter::InterestsReply,
},
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!(
OutboundClass::classify(&wrap(msg)).kind,
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 notification_recipient_split_reaches_the_rollup_body_under_the_right_keys() {
let mix = OutboundMix::new();
mix.record_notification_recipients(1, 2);
mix.record_notification_recipients(3, 7);
let body = outbound_mix_json(&mix.take_window(), 60);
assert_eq!(
body.get("notification_targets_sent")
.and_then(|v| v.as_u64()),
Some(4),
"sent count must accumulate and reach the body under its own key"
);
assert_eq!(
body.get("notification_cohosts_skipped")
.and_then(|v| v.as_u64()),
Some(9),
"skipped count must accumulate and reach the body under its own key"
);
}
#[test]
fn notification_recipient_split_is_emitted_even_when_idle() {
let body = outbound_mix_json(&OutboundMix::new().take_window(), 60);
assert_eq!(
body.get("notification_targets_sent")
.and_then(|v| v.as_u64()),
Some(0)
);
assert_eq!(
body.get("notification_cohosts_skipped")
.and_then(|v| v.as_u64()),
Some(0)
);
}
#[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);
}
#[test]
fn summaries_sub_arms_have_unique_indices_and_stems() {
let mut idxs: Vec<usize> = SUMMARIES_ARMS
.iter()
.copied()
.map(summaries_index)
.collect();
idxs.sort_unstable();
idxs.dedup();
assert_eq!(idxs.len(), SUMMARIES_ARMS.len(), "duplicate sub-arm index");
assert_eq!(
*idxs.last().expect("non-empty"),
SUMMARIES_ARMS.len() - 1,
"indices must be dense so the fixed-size arrays cover them"
);
let mut stems: Vec<&str> = SUMMARIES_ARMS.iter().copied().map(summaries_stem).collect();
stems.sort_unstable();
stems.dedup();
assert_eq!(
stems.len(),
SUMMARIES_ARMS.len(),
"duplicate sub-arm field stem"
);
let parent = OutboundKind::InterestSyncSummaries.stem();
for emitter in SUMMARIES_ARMS {
let stem = summaries_stem(emitter);
assert!(
stem.starts_with(parent),
"{stem} must nest under {parent} so the split is discoverable \
from the arm it refines"
);
}
}
#[test]
fn each_emitter_lands_in_its_own_sub_arm() {
let mix = OutboundMix::new();
record_summaries(&mix, SummariesEmitter::Notification, 1, 100);
record_summaries(&mix, SummariesEmitter::InterestsReply, 4, 200);
record_summaries(&mix, SummariesEmitter::ChangeInterestsReply, 3, 400);
record_summaries(&mix, SummariesEmitter::Rejection, 1, 800);
record_summaries(&mix, SummariesEmitter::SummaryRequestReply, 2, 1600);
record_summaries(&mix, SummariesEmitter::SummaryRequest, 5, 3200);
record_summaries(&mix, SummariesEmitter::Other, 1, 6400);
let w = mix.take_window();
let bytes_of = |e| w.summaries_bytes[summaries_index(e)];
assert_eq!(bytes_of(SummariesEmitter::Notification), 100);
assert_eq!(bytes_of(SummariesEmitter::InterestsReply), 200);
assert_eq!(bytes_of(SummariesEmitter::ChangeInterestsReply), 400);
assert_eq!(bytes_of(SummariesEmitter::Rejection), 800);
assert_eq!(bytes_of(SummariesEmitter::SummaryRequestReply), 1600);
assert_eq!(bytes_of(SummariesEmitter::SummaryRequest), 3200);
assert_eq!(bytes_of(SummariesEmitter::Other), 6400);
for emitter in SUMMARIES_ARMS {
assert_eq!(
w.summaries_msgs[summaries_index(emitter)],
1,
"each emitter sent exactly one message: {emitter:?}"
);
}
}
#[test]
fn summaries_sub_arms_reconcile_with_the_parent_arm() {
let mix = OutboundMix::new();
record(&mix, OutboundKind::Update, 5_000);
record(&mix, OutboundKind::InterestSyncInterests, 40);
record_summaries(&mix, SummariesEmitter::Notification, 1, 100);
record_summaries(&mix, SummariesEmitter::Notification, 1, 150);
record_summaries(&mix, SummariesEmitter::InterestsReply, 9, 9_000);
record_summaries(&mix, SummariesEmitter::ChangeInterestsReply, 2, 300);
record_summaries(&mix, SummariesEmitter::Rejection, 1, 120);
record(&mix, OutboundKind::InterestSyncResync, 70_000);
let w = mix.take_window();
let parent = OutboundKind::InterestSyncSummaries.index();
assert_eq!(
w.summaries_bytes.iter().sum::<u64>(),
w.bytes[parent],
"sub-arm bytes must sum to the parent arm"
);
assert_eq!(
w.summaries_msgs.iter().sum::<u64>(),
w.msgs[parent],
"sub-arm messages must sum to the parent arm"
);
assert_eq!(w.bytes[parent], 100 + 150 + 9_000 + 300 + 120);
assert!(w.bytes.iter().sum::<u64>() > w.bytes[parent]);
}
#[test]
fn an_unattributed_summaries_message_lands_in_the_residual_not_the_void() {
let mix = OutboundMix::new();
mix.record_sent(
OutboundClass {
kind: OutboundKind::InterestSyncSummaries,
summaries: None,
},
777,
);
let w = mix.take_window();
assert_eq!(
w.summaries_bytes[summaries_index(SummariesEmitter::Other)],
777,
"an unattributed Summaries must land in the residual arm"
);
assert_eq!(
w.summaries_bytes.iter().sum::<u64>(),
w.bytes[OutboundKind::InterestSyncSummaries.index()],
"the reconciliation must hold even for an unattributed message"
);
}
#[test]
fn entry_counts_are_recorded_per_sub_arm() {
let mix = OutboundMix::new();
record_summaries(&mix, SummariesEmitter::Notification, 1, 100);
record_summaries(&mix, SummariesEmitter::Notification, 1, 110);
record_summaries(&mix, SummariesEmitter::InterestsReply, 12, 12_000);
record_summaries(&mix, SummariesEmitter::InterestsReply, 4, 4_000);
let w = mix.take_window();
let notif = summaries_index(SummariesEmitter::Notification);
let reply = summaries_index(SummariesEmitter::InterestsReply);
assert_eq!(w.summaries_entries[notif], 2);
assert_eq!(w.summaries_msgs[notif], 2);
assert_eq!(
w.summaries_max_entries[notif], 1,
"a single-entry emitter must never report a wider max"
);
assert_eq!(w.summaries_entries[reply], 16);
assert_eq!(w.summaries_msgs[reply], 2);
assert_eq!(
w.summaries_max_entries[reply], 12,
"max_entries must track the widest single reply, not the mean"
);
assert_eq!(w.summaries_entries[notif] / w.summaries_msgs[notif], 1);
assert_eq!(w.summaries_entries[reply] / w.summaries_msgs[reply], 8);
}
#[test]
fn classify_reads_the_emitter_tag_not_the_message_shape() {
let detail = |m: &NetMessage| {
let class = OutboundClass::classify(m);
assert_eq!(class.kind, OutboundKind::InterestSyncSummaries);
class.summaries.expect("Summaries must carry a sub-arm")
};
let notification = detail(&summaries_msg(SummariesEmitter::Notification, 1, 64));
let reply = detail(&summaries_msg(SummariesEmitter::InterestsReply, 1, 64));
assert_eq!(notification.emitter, SummariesEmitter::Notification);
assert_eq!(reply.emitter, SummariesEmitter::InterestsReply);
assert_eq!(
notification.entries, reply.entries,
"the two cases must be shape-identical, or this test proves nothing"
);
assert_eq!(
detail(&summaries_msg(SummariesEmitter::InterestsReply, 7, 8)).entries,
7
);
let others = [
NetMessage::V1(NetMessageV1::InterestSync {
message: InterestMessage::Interests { hashes: vec![1] },
}),
NetMessage::V1(NetMessageV1::InterestSync {
message: InterestMessage::ResyncRequest {
key: test_contract_key(1),
},
}),
NetMessage::V1(NetMessageV1::ReadyState { ready: true }),
];
for msg in others {
let class = OutboundClass::classify(&msg);
assert_ne!(class.kind, OutboundKind::InterestSyncSummaries);
assert!(
class.summaries.is_none(),
"only Summaries may carry a sub-arm, got one for {class:?}"
);
}
}
#[test]
fn sub_arm_counters_reach_the_rollup_body_under_the_right_keys() {
let mix = OutboundMix::new();
let sends = [
(SummariesEmitter::Notification, 1usize, 11u64),
(SummariesEmitter::InterestsReply, 22, 222),
(SummariesEmitter::ChangeInterestsReply, 3, 333),
(SummariesEmitter::Rejection, 4, 444),
(SummariesEmitter::Other, 5, 555),
];
for (emitter, entries, bytes) in sends {
record_summaries(&mix, emitter, entries, bytes as usize);
}
let body = outbound_mix_json(&mix.take_window(), 60);
let field = |k: &str| {
body.get(k)
.and_then(|v| v.as_u64())
.unwrap_or_else(|| panic!("missing rollup field {k}"))
};
for (emitter, entries, bytes) in sends {
let stem = summaries_stem(emitter);
assert_eq!(field(&format!("{stem}_msgs")), 1, "{stem}_msgs");
assert_eq!(field(&format!("{stem}_bytes")), bytes, "{stem}_bytes");
assert_eq!(
field(&format!("{stem}_max_bytes")),
bytes,
"{stem}_max_bytes"
);
assert_eq!(
field(&format!("{stem}_entries")),
entries as u64,
"{stem}_entries"
);
assert_eq!(
field(&format!("{stem}_max_entries")),
entries as u64,
"{stem}_max_entries"
);
}
let summed: u64 = SUMMARIES_ARMS
.iter()
.map(|e| field(&format!("{}_bytes", summaries_stem(*e))))
.sum();
assert_eq!(
summed,
field("interest_sync_summaries_bytes"),
"the emitted sub-arms must reconcile with the emitted parent arm"
);
let idle = outbound_mix_json(&Window::default(), 60);
for emitter in SUMMARIES_ARMS {
let stem = summaries_stem(emitter);
for suffix in ["msgs", "bytes", "max_bytes", "entries", "max_entries"] {
let key = format!("{stem}_{suffix}");
assert_eq!(
idle.get(&key).and_then(|v| v.as_u64()),
Some(0),
"an idle window must emit {key} as an explicit zero"
);
}
}
}
#[test]
fn summaries_emitter_sites_are_pinned() {
use crate::node::network_bridge::p2p_protoc::tests::{
collect_rs_files, strip_cfg_test_regions,
};
use std::collections::{BTreeMap, BTreeSet};
let expected_files: BTreeSet<&str> = [
"message.rs", "node.rs", "operations/update.rs", "node/network_bridge/outbound_message_mix.rs",
]
.into_iter()
.collect();
let expected_arms: BTreeMap<&str, Vec<&str>> = [
(
"node.rs",
vec![
"ChangeInterestsReply",
"InterestsReply",
"SummaryRequestReply",
],
),
("operations/update.rs", vec!["Notification", "Rejection"]),
(
"node/network_bridge/outbound_message_mix.rs",
vec!["SummaryRequest"],
),
]
.into_iter()
.collect();
let src_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
collect_rs_files(&src_root, &mut files);
assert!(
!files.is_empty(),
"#5052: the source walk found no .rs files under {} — the pin \
cannot guarantee completeness if it can't read the crate source",
src_root.display()
);
let mentions = concat!("InterestMessage::", "Summaries {");
let tag = concat!("SummariesEmitter", "::");
let mut found_files: BTreeSet<String> = Default::default();
let mut found_arms: BTreeMap<String, Vec<String>> = Default::default();
for path in &files {
let rel = path
.strip_prefix(&src_root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
if rel.ends_with("/tests.rs") || rel == "tests.rs" || rel.contains("/tests/") {
continue;
}
let Ok(src) = std::fs::read_to_string(path) else {
continue;
};
let prod_all = strip_cfg_test_regions(&src);
let prod: String = prod_all
.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
if rel == "node/network_bridge/outbound_message_mix.rs" {
let assign = concat!("emitter: ", "SummariesEmitter", "::");
let mut arms: Vec<String> = prod
.match_indices(assign)
.map(|(idx, _)| {
prod[idx + assign.len()..]
.split(|c: char| !c.is_alphanumeric() && c != '_')
.next()
.unwrap_or("")
.to_string()
})
.collect();
arms.sort();
arms.dedup();
if !arms.is_empty() {
found_arms.insert(rel.clone(), arms);
found_files.insert(rel);
}
continue;
}
if !prod.contains(mentions) && !prod.contains(tag) {
continue;
}
found_files.insert(rel.clone());
let mut arms: Vec<String> = prod
.match_indices(tag)
.map(|(idx, _)| {
prod[idx + tag.len()..]
.split(|c: char| !c.is_alphanumeric() && c != '_')
.next()
.unwrap_or("")
.to_string()
})
.collect();
arms.sort();
arms.dedup();
if !arms.is_empty() {
found_arms.insert(rel, arms);
}
}
let found_files_view: BTreeSet<&str> = found_files.iter().map(|s| s.as_str()).collect();
assert_eq!(
found_files_view, expected_files,
"#5052: the set of production files mentioning InterestMessage::Summaries \
changed. If this is a NEW emitter, give it its OWN SummariesEmitter arm \
rather than reusing one — reusing a tag re-creates exactly the conflation \
this split undoes, and the bytes still reconcile so nothing else flags it. \
Then register the file here."
);
let found_arms_view: BTreeMap<&str, Vec<&str>> = found_arms
.iter()
.map(|(f, arms)| (f.as_str(), arms.iter().map(|a| a.as_str()).collect()))
.collect();
assert_eq!(
found_arms_view, expected_arms,
"#5052: the emitter→arm mapping changed. Every emitter must claim its own \
arm; update this pin only after confirming the new site genuinely belongs \
in the arm it names."
);
let claimed: BTreeSet<&str> = found_arms_view.values().flatten().copied().collect();
for emitter in SUMMARIES_ARMS {
if emitter == SummariesEmitter::Other {
continue;
}
let name = format!("{emitter:?}");
assert!(
claimed.contains(name.as_str()),
"#5052: SummariesEmitter::{name} is declared but no production site \
emits it — it would report a permanent zero, which reads as \
'that emitter costs nothing' rather than 'nothing emits it'. \
Either wire it up or delete the arm. Claimed: {claimed:?}"
);
}
}
}