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;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SummaryObservation {
MultiEntry,
SingleEntryFullBytes,
SingleEntryDigest,
}
impl SummaryObservation {
pub(crate) fn full_bytes(single_entry: bool) -> Self {
if single_entry {
Self::SingleEntryFullBytes
} else {
Self::MultiEntry
}
}
pub(crate) fn digest(single_entry: bool) -> Self {
if single_entry {
Self::SingleEntryDigest
} else {
Self::MultiEntry
}
}
fn is_single_full_bytes(self) -> bool {
matches!(self, Self::SingleEntryFullBytes)
}
fn is_single_digest(self) -> bool {
matches!(self, Self::SingleEntryDigest)
}
}
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
struct DifferingCount {
total: u64,
single: u64,
}
impl DifferingCount {
fn add(&mut self, obs: SummaryObservation) {
self.total = self.total.saturating_add(1);
if obs.is_single_full_bytes() {
self.single = self.single.saturating_add(1);
}
}
}
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) enum SummariesWireForm {
#[default]
FullBytes,
Digests,
}
impl SummariesWireForm {
fn index(self) -> usize {
match self {
Self::FullBytes => 0,
Self::Digests => 1,
}
}
fn stem(self) -> &'static str {
match self {
Self::FullBytes => "full_bytes",
Self::Digests => "digests",
}
}
}
const SUMMARIES_WIRE_FORMS: [SummariesWireForm; 2] =
[SummariesWireForm::FullBytes, SummariesWireForm::Digests];
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct FleetSingleEntryTotals {
notification_msgs: u64,
other_msgs: u64,
}
impl FleetSingleEntryTotals {
#[allow(dead_code)] fn from_census(per_arm: [u64; SUMMARIES_ARMS.len()]) -> Self {
let mut notification_msgs = 0u64;
let mut other_msgs = 0u64;
for emitter in SUMMARIES_ARMS {
let n = per_arm[summaries_index(emitter)];
match emitter {
SummariesEmitter::Notification => {
notification_msgs = notification_msgs.saturating_add(n);
}
SummariesEmitter::SummaryRequest => {}
SummariesEmitter::InterestsReply
| SummariesEmitter::ChangeInterestsReply
| SummariesEmitter::Rejection
| SummariesEmitter::SummaryRequestReply
| SummariesEmitter::Other => {
other_msgs = other_msgs.saturating_add(n);
}
}
}
Self {
notification_msgs,
other_msgs,
}
}
}
#[allow(dead_code)] fn notification_share_bounds(t: FleetSingleEntryTotals) -> (f64, f64) {
let total = t.notification_msgs.saturating_add(t.other_msgs);
if total == 0 {
return (0.0, 1.0);
}
let upper = t.notification_msgs as f64 / total as f64;
let lower = t.notification_msgs.saturating_sub(t.other_msgs) as f64 / total as f64;
(lower, upper)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct SummariesDetail {
emitter: SummariesEmitter,
wire_form: SummariesWireForm,
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,
wire_form: SummariesWireForm::FullBytes,
entries: entries.len() as u64,
}),
},
InterestMessage::SummaryDigests { entries, emitter } => Self {
kind: OutboundKind::InterestSyncSummaries,
summaries: Some(SummariesDetail {
emitter: *emitter,
wire_form: SummariesWireForm::Digests,
entries: entries.len() as u64,
}),
},
InterestMessage::SummaryRequest { hashes } => Self {
kind: OutboundKind::InterestSyncSummaries,
summaries: Some(SummariesDetail {
emitter: SummariesEmitter::SummaryRequest,
wire_form: SummariesWireForm::Digests,
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()],
summaries_single_entry_msgs: [[u64; SUMMARIES_WIRE_FORMS.len()]; SUMMARIES_ARMS.len()],
summary_entries_identical: u64,
summary_entries_differing: u64,
summary_entries_identical_single: u64,
summary_entries_differing_single: u64,
summary_entries_identical_single_digest: u64,
summary_entries_differing_single_digest: u64,
summary_entries_one_sided: u64,
summary_entries_one_sided_single: u64,
differing_by_contract: HashMap<ContractInstanceId, DifferingCount>,
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);
if detail.entries == 1 {
let f = detail.wire_form.index();
w.summaries_single_entry_msgs[s][f] =
w.summaries_single_entry_msgs[s][f].saturating_add(1);
}
}
}
pub(crate) fn record_summary_comparison(
&self,
contract: &ContractInstanceId,
ours: &[u8],
theirs: &[u8],
obs: SummaryObservation,
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);
if obs.is_single_full_bytes() {
w.summary_entries_identical_single =
w.summary_entries_identical_single.saturating_add(1);
}
if obs.is_single_digest() {
w.summary_entries_identical_single_digest =
w.summary_entries_identical_single_digest.saturating_add(1);
}
return;
}
w.summary_entries_differing = w.summary_entries_differing.saturating_add(1);
if obs.is_single_full_bytes() {
w.summary_entries_differing_single =
w.summary_entries_differing_single.saturating_add(1);
}
if obs.is_single_digest() {
w.summary_entries_differing_single_digest =
w.summary_entries_differing_single_digest.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().add(obs);
}
std::collections::hash_map::Entry::Vacant(e) => {
if len < MAX_TRACKED_CONTRACTS {
e.insert(DifferingCount::default()).add(obs);
} 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,
obs: SummaryObservation,
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);
if obs.is_single_full_bytes() {
w.summary_entries_one_sided_single =
w.summary_entries_one_sided_single.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())
}
#[cfg(test)]
pub(crate) fn rollup_body_for_test(&self) -> serde_json::Value {
outbound_mix_json(&self.window.lock(), 60)
}
}
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(),
);
for form in SUMMARIES_WIRE_FORMS {
let f = form.index();
body.insert(
format!("{stem}_{}_single_entry_msgs", form.stem()),
w.summaries_single_entry_msgs[s][f].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(
"summary_entries_identical_single".into(),
w.summary_entries_identical_single.into(),
);
body.insert(
"summary_entries_differing_single".into(),
w.summary_entries_differing_single.into(),
);
body.insert(
"summary_entries_one_sided_single".into(),
w.summary_entries_one_sided_single.into(),
);
body.insert(
"summary_entries_identical_single_digest".into(),
w.summary_entries_identical_single_digest.into(),
);
body.insert(
"summary_entries_differing_single_digest".into(),
w.summary_entries_differing_single_digest.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, DifferingCount)> = w
.differing_by_contract
.iter()
.map(|(k, v)| (k.to_string(), *v))
.collect();
differing.sort_unstable_by(|a, b| b.1.total.cmp(&a.1.total).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.total.into());
o.insert("single_count".into(), count.single.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,
);
}
fn record_digests(mix: &OutboundMix, emitter: SummariesEmitter, n: usize) {
let msg = NetMessage::V1(NetMessageV1::InterestSync {
message: InterestMessage::SummaryDigests {
entries: (0..n)
.map(|i| crate::message::SummaryDigestEntry {
hash: i as u32,
summary_digest: None,
})
.collect(),
emitter,
},
});
mix.record_sent(OutboundClass::classify(&msg), 21 * n);
}
fn record_request_leg(mix: &OutboundMix, n: usize) {
let msg = NetMessage::V1(NetMessageV1::InterestSync {
message: InterestMessage::SummaryRequest {
hashes: (0..n).map(|i| i as u32).collect(),
},
});
mix.record_sent(OutboundClass::classify(&msg), 4 * n);
}
#[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",
SummaryObservation::MultiEntry,
&mut HashSet::new(),
);
mix.record_summary_comparison(
&a,
b"ours",
b"theirs",
SummaryObservation::MultiEntry,
&mut HashSet::new(),
);
mix.record_summary_comparison(
&b,
b"ours",
b"theirs",
SummaryObservation::MultiEntry,
&mut HashSet::new(),
);
mix.record_summary_comparison(
&a,
b"same",
b"same",
SummaryObservation::MultiEntry,
&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).map(|c| c.total), Some(1));
assert_eq!(w.differing_by_contract.get(&b).map(|c| c.total), 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",
SummaryObservation::MultiEntry,
&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",
SummaryObservation::MultiEntry,
&mut HashSet::new(),
);
}
mix.record_summary_comparison(
&tracked,
b"ours",
b"theirs",
SummaryObservation::MultiEntry,
&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).map(|c| c.total),
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",
SummaryObservation::MultiEntry,
&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",
SummaryObservation::MultiEntry,
&mut HashSet::new(),
);
}
for _ in 0..5 {
mix.record_summary_comparison(
&c,
b"ours",
b"theirs",
SummaryObservation::MultiEntry,
&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 single_entry_buckets_count_only_single_entry_observations() {
let mix = OutboundMix::new();
let c = test_instance_id(1);
for _ in 0..2 {
mix.record_summary_comparison(
&c,
b"same",
b"same",
SummaryObservation::SingleEntryFullBytes,
&mut HashSet::new(),
);
}
for _ in 0..3 {
mix.record_summary_comparison(
&c,
b"same",
b"same",
SummaryObservation::MultiEntry,
&mut HashSet::new(),
);
}
for _ in 0..4 {
mix.record_summary_comparison(
&c,
b"ours",
b"theirs",
SummaryObservation::SingleEntryFullBytes,
&mut HashSet::new(),
);
}
mix.record_summary_comparison(
&c,
b"ours",
b"theirs",
SummaryObservation::MultiEntry,
&mut HashSet::new(),
);
for _ in 0..5 {
mix.record_summary_one_sided(
&c,
SummaryObservation::SingleEntryFullBytes,
&mut HashSet::new(),
);
}
for _ in 0..2 {
mix.record_summary_one_sided(&c, SummaryObservation::MultiEntry, &mut HashSet::new());
}
let w = mix.take_window();
assert_eq!(w.summary_entries_identical, 5);
assert_eq!(w.summary_entries_identical_single, 2);
assert_eq!(w.summary_entries_differing, 5);
assert_eq!(w.summary_entries_differing_single, 4);
assert_eq!(w.summary_entries_one_sided, 7);
assert_eq!(w.summary_entries_one_sided_single, 5);
for (single, total, label) in [
(
w.summary_entries_identical_single,
w.summary_entries_identical,
"identical",
),
(
w.summary_entries_differing_single,
w.summary_entries_differing,
"differing",
),
(
w.summary_entries_one_sided_single,
w.summary_entries_one_sided,
"one_sided",
),
] {
assert!(
single <= total,
"{label}: single-entry bucket ({single}) must be a SUBSET of \
its total ({total}) — otherwise the two count different \
populations and their ratio is not a rate"
);
}
}
#[test]
fn single_entry_buckets_are_exactly_zero_when_the_discriminator_is_false() {
let mix = OutboundMix::new();
let c = test_instance_id(1);
mix.record_summary_comparison(
&c,
b"same",
b"same",
SummaryObservation::MultiEntry,
&mut HashSet::new(),
);
mix.record_summary_comparison(
&c,
b"ours",
b"theirs",
SummaryObservation::MultiEntry,
&mut HashSet::new(),
);
mix.record_summary_one_sided(&c, SummaryObservation::MultiEntry, &mut HashSet::new());
let w = mix.take_window();
assert_eq!(
w.summary_entries_identical, 1,
"the total must be unchanged"
);
assert_eq!(
w.summary_entries_differing, 1,
"the total must be unchanged"
);
assert_eq!(
w.summary_entries_one_sided, 1,
"the total must be unchanged"
);
assert_eq!(w.summary_entries_identical_single, 0);
assert_eq!(w.summary_entries_differing_single, 0);
assert_eq!(w.summary_entries_one_sided_single, 0);
assert_eq!(
w.differing_by_contract.get(&c).map(|d| d.single),
Some(0),
"the per-contract single count must go to zero too, or a deleted \
discriminator would still look attributed"
);
}
#[test]
fn single_entry_counters_reach_the_rollup_body_under_the_right_keys() {
let mix = OutboundMix::new();
let c = test_instance_id(1);
for _ in 0..2 {
mix.record_summary_comparison(
&c,
b"same",
b"same",
SummaryObservation::SingleEntryFullBytes,
&mut HashSet::new(),
);
}
for _ in 0..4 {
mix.record_summary_comparison(
&c,
b"ours",
b"theirs",
SummaryObservation::SingleEntryFullBytes,
&mut HashSet::new(),
);
}
for _ in 0..6 {
mix.record_summary_one_sided(
&c,
SummaryObservation::SingleEntryFullBytes,
&mut HashSet::new(),
);
}
mix.record_summary_one_sided(&c, SummaryObservation::MultiEntry, &mut HashSet::new());
let body = outbound_mix_json(&mix.take_window(), 60);
assert_eq!(
body.get("summary_entries_one_sided")
.and_then(|v| v.as_u64()),
Some(7),
"the one-sided TOTAL must be distinguishable from its single-entry \
subset, or the two keys can be transposed undetected"
);
assert_eq!(
body.get("summary_entries_identical_single")
.and_then(|v| v.as_u64()),
Some(2)
);
assert_eq!(
body.get("summary_entries_differing_single")
.and_then(|v| v.as_u64()),
Some(4)
);
assert_eq!(
body.get("summary_entries_one_sided_single")
.and_then(|v| v.as_u64()),
Some(6)
);
let idle = outbound_mix_json(&Window::default(), 60);
for key in [
"summary_entries_identical_single",
"summary_entries_differing_single",
"summary_entries_one_sided_single",
] {
assert_eq!(
idle.get(key).and_then(|v| v.as_u64()),
Some(0),
"{key} must be emitted as an explicit zero on an idle window"
);
}
}
#[test]
fn per_contract_attribution_carries_the_single_entry_subset() {
let mix = OutboundMix::new();
let busy = test_instance_id(1);
let noisy = test_instance_id(2);
for _ in 0..5 {
mix.record_summary_comparison(
&busy,
b"ours",
b"theirs",
SummaryObservation::MultiEntry,
&mut HashSet::new(),
);
}
mix.record_summary_comparison(
&busy,
b"ours",
b"theirs",
SummaryObservation::SingleEntryFullBytes,
&mut HashSet::new(),
);
for _ in 0..3 {
mix.record_summary_comparison(
&noisy,
b"ours",
b"theirs",
SummaryObservation::SingleEntryFullBytes,
&mut HashSet::new(),
);
}
let body = outbound_mix_json(&mix.take_window(), 60);
let listed = body
.get("summary_differing_contracts")
.and_then(|v| v.as_array())
.expect("summary_differing_contracts must be an array")
.clone();
let read = |i: usize, field: &str| {
listed[i]
.get(field)
.and_then(|v| v.as_u64())
.unwrap_or_else(|| panic!("entry {i} must carry {field}"))
};
assert_eq!(listed.len(), 2);
assert_eq!(
listed[0].get("contract").and_then(|v| v.as_str()),
Some(busy.to_string().as_str()),
"ranking must stay on the TOTAL, not the single-entry subset"
);
assert_eq!(read(0, "count"), 6);
assert_eq!(read(0, "single_count"), 1);
assert_eq!(read(1, "count"), 3);
assert_eq!(
read(1, "single_count"),
3,
"a contract that diverges only on the notification leg must be \
visible as such"
);
for i in 0..listed.len() {
assert!(
read(i, "single_count") <= read(i, "count"),
"entry {i}: the per-contract single count must be a subset of \
its total"
);
}
}
#[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",
SummaryObservation::MultiEntry,
&mut first_message,
);
}
mix.record_summary_comparison(
&other,
b"same",
b"same",
SummaryObservation::MultiEntry,
&mut first_message,
);
let mut second_message = HashSet::new();
mix.record_summary_comparison(
&c,
b"ours",
b"theirs",
SummaryObservation::MultiEntry,
&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).map(|c| c.total),
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)
);
for key in [
"summary_entries_one_sided",
"summary_entries_identical_single",
"summary_entries_differing_single",
"summary_entries_one_sided_single",
"summary_entries_identical_single_digest",
"summary_entries_differing_single_digest",
"notification_targets_sent",
"notification_cohosts_skipped",
] {
assert_eq!(
body.get(key).and_then(|v| v.as_u64()),
Some(0),
"an idle window must emit {key} as an explicit zero"
);
}
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",
SummaryObservation::MultiEntry,
&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 single_entry_census_is_per_emitter_leg_split_and_excludes_wide_messages() {
let mix = OutboundMix::new();
for _ in 0..3 {
record_summaries(&mix, SummariesEmitter::Notification, 1, 100);
}
record_summaries(&mix, SummariesEmitter::ChangeInterestsReply, 1, 400);
record_digests(&mix, SummariesEmitter::ChangeInterestsReply, 1);
record_digests(&mix, SummariesEmitter::ChangeInterestsReply, 1);
record_summaries(&mix, SummariesEmitter::InterestsReply, 224, 200);
record_summaries(&mix, SummariesEmitter::Rejection, 1, 800);
record_summaries(&mix, SummariesEmitter::SummaryRequestReply, 1, 1600);
record_summaries(&mix, SummariesEmitter::SummaryRequestReply, 7, 1600);
record_request_leg(&mix, 1);
let w = mix.take_window();
let full = |e| {
w.summaries_single_entry_msgs[summaries_index(e)][SummariesWireForm::FullBytes.index()]
};
let dig = |e| {
w.summaries_single_entry_msgs[summaries_index(e)][SummariesWireForm::Digests.index()]
};
assert_eq!(full(SummariesEmitter::Notification), 3);
assert_eq!(
full(SummariesEmitter::ChangeInterestsReply),
1,
"the largest contaminant must be counted, not assumed away"
);
assert_eq!(
dig(SummariesEmitter::ChangeInterestsReply),
2,
"its DIGEST sends must land on the digest leg, not folded into \
full-bytes — that fold over-subtracts the leg `p` is computed on. \
Two here against one full-bytes so a key transposition is visible"
);
assert_eq!(
full(SummariesEmitter::InterestsReply),
0,
"a 224-entry message is not single-entry; counting it would make the \
census meaningless"
);
assert_eq!(full(SummariesEmitter::Rejection), 1);
assert_eq!(full(SummariesEmitter::SummaryRequestReply), 1);
assert_eq!(dig(SummariesEmitter::SummaryRequest), 1);
let body = outbound_mix_json(&w, 60);
for (key, want) in [
(
"interest_sync_summaries_notification_full_bytes_single_entry_msgs",
3,
),
(
"interest_sync_summaries_change_interests_reply_full_bytes_single_entry_msgs",
1,
),
(
"interest_sync_summaries_change_interests_reply_digests_single_entry_msgs",
2,
),
(
"interest_sync_summaries_interests_reply_full_bytes_single_entry_msgs",
0,
),
(
"interest_sync_summaries_rejection_full_bytes_single_entry_msgs",
1,
),
] {
assert_eq!(
body.get(key).and_then(|v| v.as_u64()),
Some(want),
"{key} must reach the rollup with the leg split intact"
);
}
}
#[test]
fn census_denominator_excludes_the_request_leg() {
let mut per_arm = [0u64; SUMMARIES_ARMS.len()];
per_arm[summaries_index(SummariesEmitter::Notification)] = 90;
per_arm[summaries_index(SummariesEmitter::ChangeInterestsReply)] = 10;
per_arm[summaries_index(SummariesEmitter::SummaryRequest)] = 900;
let totals = FleetSingleEntryTotals::from_census(per_arm);
assert_eq!(totals.notification_msgs, 90);
assert_eq!(
totals.other_msgs, 10,
"the request leg is not contamination either — it is not a \
comparison at all"
);
let (lower, upper) = notification_share_bounds(totals);
assert!(
(upper - 0.9).abs() < 1e-9,
"share must be 90/100, not 90/1000: got {upper}"
);
assert!(
(lower - 0.8).abs() < 1e-9,
"lower bound charges the contamination to notifications: got {lower}"
);
}
#[test]
fn empty_census_reads_as_maximally_uncertain() {
let totals = FleetSingleEntryTotals::from_census([0u64; SUMMARIES_ARMS.len()]);
assert_eq!(notification_share_bounds(totals), (0.0, 1.0));
}
#[test]
fn notification_share_worked_example_on_measured_window() {
let mut per_arm = [0u64; SUMMARIES_ARMS.len()];
per_arm[summaries_index(SummariesEmitter::Notification)] = 3_194_108;
per_arm[summaries_index(SummariesEmitter::ChangeInterestsReply)] = 418_476;
per_arm[summaries_index(SummariesEmitter::SummaryRequestReply)] = 131_153;
per_arm[summaries_index(SummariesEmitter::Rejection)] = 669;
per_arm[summaries_index(SummariesEmitter::SummaryRequest)] = 130_834;
let totals = FleetSingleEntryTotals::from_census(per_arm);
let (lower, upper) = notification_share_bounds(totals);
assert!(
(0.852..0.854).contains(&upper),
"expected ~0.853 notification share, got {upper}"
);
assert!(
(0.705..0.707).contains(&lower),
"expected ~0.706 worst case, got {lower}"
);
}
#[test]
fn digest_leg_single_entry_observations_stay_out_of_the_full_bytes_bucket() {
let mix = OutboundMix::new();
let c = test_instance_id(1);
mix.record_summary_comparison(
&c,
b"same",
b"same",
SummaryObservation::SingleEntryDigest,
&mut HashSet::new(),
);
for _ in 0..2 {
mix.record_summary_comparison(
&c,
b"ours",
b"theirs",
SummaryObservation::SingleEntryDigest,
&mut HashSet::new(),
);
}
let w = mix.take_window();
assert_eq!(w.summary_entries_identical, 1, "totals still count it");
assert_eq!(w.summary_entries_differing, 2, "totals still count it");
assert_eq!(
w.summary_entries_identical_single, 0,
"a digest-leg observation is NOT the R4b full-bytes population"
);
assert_eq!(w.summary_entries_differing_single, 0);
assert_eq!(w.summary_entries_identical_single_digest, 1);
assert_eq!(w.summary_entries_differing_single_digest, 2);
assert_eq!(
w.differing_by_contract.get(&c).map(|d| d.single),
Some(0),
"per-contract attribution must follow the same split, or the \
notification-leg share of a contract's divergence is overstated"
);
let body = outbound_mix_json(&w, 60);
for (key, want) in [
("summary_entries_identical_single", 0),
("summary_entries_differing_single", 0),
("summary_entries_identical_single_digest", 1),
("summary_entries_differing_single_digest", 2),
] {
assert_eq!(
body.get(key).and_then(|v| v.as_u64()),
Some(want),
"{key} must reach the rollup with the leg split intact"
);
}
}
#[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",
"full_bytes_single_entry_msgs",
"digests_single_entry_msgs",
] {
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:?}"
);
}
}
}