Skip to main content

moq_stats/
aggregate.rs

1//! The aggregating half: fold a group's per-node stats broadcasts into one view.
2//!
3//! A single-broadcast [`Consumer`](crate::Consumer) reads one
4//! `<prefix>/<group>/node/<node>` broadcast. This reader watches an origin's
5//! announce stream for *every* node broadcast in a group and folds their
6//! cumulative counters into one merged frame per `(tier, role)`, so a downstream
7//! sees a project's whole live traffic as if it came from a single node.
8
9use std::collections::hash_map::Entry;
10use std::collections::{BTreeMap, HashMap};
11use std::task::Poll;
12
13use moq_net::kio::{self, Pending, Waiter};
14use moq_net::stats::{Presence, Role, Tier, Traffic};
15use moq_net::track::Subscribing;
16use moq_net::{PathOwned, origin};
17
18use crate::{Result, SessionsFrame, TrafficFrame, parse_node_path, sessions_track, traffic_track};
19
20/// Configuration for an [`Consumer`]. Construct with [`Config::new`] and chain
21/// the `with_*` setters.
22///
23/// The `prefix` and `depth` must match the producing side's
24/// [`produce::Config`](crate::produce::Config): they are how announced paths are
25/// recognized as node broadcasts and filtered from sibling categories under the
26/// same prefix.
27#[derive(Debug, Clone)]
28#[non_exhaustive]
29pub struct Config {
30	/// Top-level path stats are published under (default `.stats`). Must match
31	/// the producer's prefix.
32	pub prefix: PathOwned,
33	/// The producer's grouping depth (default `0`). Announced paths whose group
34	/// is deeper than this are not recognized as node broadcasts and are
35	/// skipped. Must match the producer's depth.
36	pub depth: usize,
37	/// Read the compressed `.json.z` tracks instead of the plain `.json` ones.
38	/// Same data for a fraction of the bytes, but requires a producer that
39	/// publishes them. Defaults to `false`.
40	pub compression: bool,
41}
42
43impl Config {
44	/// A config with default settings: the `.stats` prefix, depth `0`, and the
45	/// plain `.json` tracks.
46	pub fn new() -> Self {
47		Self::default()
48	}
49
50	/// Override the top-level prefix (default `.stats`). Must match the producer.
51	pub fn with_prefix(mut self, prefix: impl Into<PathOwned>) -> Self {
52		self.prefix = prefix.into();
53		self
54	}
55
56	/// Override the grouping depth (default `0`). Must match the producer.
57	pub fn with_depth(mut self, depth: usize) -> Self {
58		self.depth = depth;
59		self
60	}
61
62	/// Read the compressed `.json.z` tracks instead of the plain `.json` ones.
63	pub fn with_compression(mut self, compression: bool) -> Self {
64		self.compression = compression;
65		self
66	}
67}
68
69impl Default for Config {
70	fn default() -> Self {
71		Self {
72			prefix: PathOwned::from(".stats"),
73			depth: 0,
74			compression: false,
75		}
76	}
77}
78
79/// Folds a group's per-node stats broadcasts into one merged view.
80///
81/// Scope an [`origin::Consumer`] to a single group (e.g. `.stats/<pid>`) and
82/// hand it here; each [`Self::traffic`] / [`Self::sessions`] call opens its own
83/// announce cursor and subscribes to that track on every node broadcast in the
84/// group, summing the cumulative counters per key. Traffic is sticky: a node
85/// dropping out (its broadcast unannounces or its reader ends) keeps its last
86/// contribution, so a relay that returns with its boot-lifetime counters
87/// intact never looks like new traffic. Only a genuine per-node counter
88/// regression (a restarted relay) regresses the merged counter, the same reset
89/// contract a single node's own restart follows. Presence is not sticky: a
90/// departed node stops counting sessions immediately.
91pub struct Consumer {
92	origin: origin::Consumer,
93	config: Config,
94}
95
96impl Consumer {
97	/// Wrap an origin consumer, ideally already scoped to one group. `config`'s
98	/// `prefix` and `depth` must match the producing side.
99	pub fn new(origin: origin::Consumer, config: Config) -> Self {
100		Self { origin, config }
101	}
102
103	/// A merged reader over the traffic track for `(tier, role)`, folding every
104	/// node broadcast in the group. Nodes are subscribed lazily as they announce,
105	/// so this returns without a handshake.
106	pub fn traffic(&self, tier: &Tier, role: Role) -> TrafficConsumer {
107		let name = traffic_track(tier, role, self.config.compression);
108		TrafficConsumer {
109			inner: Merged::new(self.origin.clone(), &self.config, name),
110		}
111	}
112
113	/// A merged reader over the sessions track for `tier`; see [`Self::traffic`].
114	pub fn sessions(&self, tier: &Tier) -> SessionsConsumer {
115		let name = sessions_track(tier, self.config.compression);
116		SessionsConsumer {
117			inner: Merged::new(self.origin.clone(), &self.config, name),
118		}
119	}
120}
121
122/// A merged reader over one traffic track across every node in the group. Yields
123/// the latest merged [`TrafficFrame`]; a slow reader collapses intermediate
124/// frames, which is safe because the counters are cumulative.
125pub struct TrafficConsumer {
126	inner: Merged<Traffic>,
127}
128
129impl TrafficConsumer {
130	/// The next merged frame, or `None` once the announce stream ends (the
131	/// source origin went away).
132	pub async fn next(&mut self) -> Result<Option<TrafficFrame>> {
133		kio::wait(|waiter| self.inner.poll_next(waiter)).await
134	}
135}
136
137/// A merged reader over one sessions track across every node in the group; see
138/// [`TrafficConsumer`].
139pub struct SessionsConsumer {
140	inner: Merged<Presence>,
141}
142
143impl SessionsConsumer {
144	/// The next merged frame, or `None` once the announce stream ends.
145	pub async fn next(&mut self) -> Result<Option<SessionsFrame>> {
146		kio::wait(|waiter| self.inner.poll_next(waiter)).await
147	}
148}
149
150/// A per-key counter that folds across nodes: the two wire counter types.
151trait Mergeable: serde::de::DeserializeOwned + Default + Copy + 'static {
152	/// Fold `other` into `acc`.
153	fn merge(acc: &mut Self, other: Self);
154
155	/// Whether a node's last contribution survives its departure. Cumulative
156	/// counters do: a relay that leaves and returns with its boot-lifetime
157	/// counters intact must not look like new traffic. Gauges don't: a
158	/// departed node stops counting the moment it's gone.
159	const STICKY: bool;
160
161	/// Retire any outstanding live state in a kept contribution, returning
162	/// whether it changed. Cumulative totals are untouched; this is how a
163	/// departed node stops reporting open counters without losing its history.
164	fn retire(&mut self) -> bool;
165}
166
167impl Mergeable for Traffic {
168	const STICKY: bool = true;
169
170	fn merge(acc: &mut Self, other: Self) {
171		acc.add(other);
172	}
173
174	/// Close every open counter pair: a departed relay can no longer carry its
175	/// broadcasts or subscriptions, so the merged view must not keep counting
176	/// them as live. The cumulative counters, bytes included, stay.
177	fn retire(&mut self) -> bool {
178		let changed = self.announces_ended < self.announces_started
179			|| self.broadcasts_ended < self.broadcasts_started
180			|| self.subscriptions_ended < self.subscriptions_started;
181		self.announces_ended = self.announces_ended.max(self.announces_started);
182		self.broadcasts_ended = self.broadcasts_ended.max(self.broadcasts_started);
183		self.subscriptions_ended = self.subscriptions_ended.max(self.subscriptions_started);
184		changed
185	}
186}
187
188impl Mergeable for Presence {
189	const STICKY: bool = false;
190
191	fn merge(acc: &mut Self, other: Self) {
192		acc.add(other);
193	}
194
195	/// Presence is not sticky, so it never reaches here; its entry is dropped.
196	fn retire(&mut self) -> bool {
197		false
198	}
199}
200
201/// One node's subscription to the merged track.
202enum Reader<V: Mergeable> {
203	/// Resolving the announced path into a broadcast. `queued` records whether
204	/// the request was handed to a serving route (fixed at request time): a
205	/// queued request that fails `Unroutable` was killed by its serving route
206	/// retracting (the table has already changed), while an unqueued
207	/// `Unroutable` means nothing serves the path at all.
208	Resolving {
209		pending: Pending<origin::Requesting>,
210		queued: bool,
211	},
212	/// Awaiting the subscription handshake.
213	Subscribing(Pending<Subscribing>),
214	/// Reading frames. Boxed: the snapshot consumer dwarfs the other variants,
215	/// and one lives per node in a map.
216	Active(Box<moq_json::snapshot::Consumer<BTreeMap<String, V>>>),
217	/// The subscription failed or the track ended; the node no longer reads. It
218	/// lingers until it unannounces or reannounces, still contributing its last
219	/// frame when [`Mergeable::STICKY`].
220	Ended,
221}
222
223/// One node's reader plus the last frame it produced (the value folded into the
224/// merged view).
225struct Node<V: Mergeable> {
226	reader: Reader<V>,
227	/// The announced path, relative to the announce cursor: what a re-resolve
228	/// after the reader ends requests again.
229	path: PathOwned,
230	last: Option<BTreeMap<String, V>>,
231}
232
233impl<V: Mergeable> Node<V> {
234	/// The node's broadcast went away (unannounced, replaced by one without
235	/// this track, or its subscription ended): stop reading, and keep the last
236	/// frame only when sticky. A kept frame retires its live counters, so a
237	/// departed node stops reporting open state while its totals stay. Returns
238	/// whether the merged view changed.
239	fn depart(&mut self) -> bool {
240		self.reader = Reader::Ended;
241		if !V::STICKY {
242			return self.last.take().is_some();
243		}
244		let mut changed = false;
245		if let Some(last) = &mut self.last {
246			for value in last.values_mut() {
247				changed |= value.retire();
248			}
249		}
250		changed
251	}
252}
253
254/// Watches a group's node announces and folds one track across all of them.
255struct Merged<V: Mergeable> {
256	/// Resolves announced node paths into broadcasts.
257	origin: origin::Consumer,
258	announce: moq_net::announce::Consumer,
259	prefix: PathOwned,
260	depth: usize,
261	/// Track name subscribed on each node broadcast.
262	name: String,
263	config: moq_json::snapshot::consumer::Config,
264	/// One entry per live node broadcast, keyed by absolute announced path.
265	nodes: HashMap<PathOwned, Node<V>>,
266}
267
268impl<V: Mergeable> Merged<V> {
269	fn new(origin: origin::Consumer, config: &Config, name: String) -> Self {
270		Self {
271			announce: origin.announced(),
272			origin,
273			prefix: config.prefix.clone(),
274			depth: config.depth,
275			name,
276			config: {
277				let mut json = moq_json::snapshot::consumer::Config::default();
278				if config.compression {
279					json.compression = moq_json::Compression::Deflate;
280				}
281				json
282			},
283			nodes: HashMap::new(),
284		}
285	}
286
287	/// Poll for the next merged frame. Returns `Ready(Some(_))` whenever the
288	/// merged view changed (a node produced a frame, or a non-sticky
289	/// contribution left), `Ready(None)` once the announce stream closes, else
290	/// `Pending`.
291	fn poll_next(&mut self, waiter: &Waiter) -> Poll<Result<Option<BTreeMap<String, V>>>> {
292		let mut changed = false;
293
294		// Drain announce membership updates: add/replace on announce, drop on
295		// unannounce. A closed stream ends the merged view.
296		loop {
297			match self.announce.poll_next(waiter) {
298				Poll::Ready(Some(update)) => changed |= self.apply_announce(update),
299				Poll::Ready(None) => return Poll::Ready(Ok(None)),
300				Poll::Pending => break,
301			}
302		}
303
304		// Advance each node's reader, collapsing any backlog to its latest frame.
305		let config = &self.config;
306		let name = self.name.as_str();
307		let origin = &self.origin;
308		for node in self.nodes.values_mut() {
309			changed |= advance(node, origin, config, name, waiter);
310		}
311
312		if changed {
313			Poll::Ready(Ok(Some(self.merged())))
314		} else {
315			Poll::Pending
316		}
317	}
318
319	/// Apply one announce update to the node set. Returns whether the merged view
320	/// changed (only a non-sticky contribution leaving does; a sticky one is
321	/// kept).
322	fn apply_announce(&mut self, update: moq_net::announce::Update) -> bool {
323		let path = update.prefix;
324		let absolute = self.announce.absolute(&path).to_owned();
325
326		// Only fold node-category routes; skip sibling categories a producer
327		// may publish under the same prefix. A route names a prefix, and node
328		// broadcasts are announced at their exact path by convention.
329		if parse_node_path(&self.prefix, self.depth, &absolute).is_none() {
330			return false;
331		}
332
333		if update.kind.is_active() {
334			// A route update on a node already tracked (a reprice, or a takeover
335			// with different metadata) keeps the live reader: existing
336			// subscriptions survive a takeover, and the reader re-resolves through
337			// the current best route the moment it actually ends. Only an ended
338			// node re-arms here, since a fresh route is new evidence that a
339			// re-resolve could succeed. A sticky contribution carries across the
340			// re-arm, holding the total until a fresh frame replaces it.
341			match self.nodes.entry(absolute) {
342				Entry::Occupied(mut entry) => {
343					let node = entry.get_mut();
344					if matches!(node.reader, Reader::Ended) {
345						node.reader = resolve(&self.origin, &node.path);
346					}
347					false
348				}
349				Entry::Vacant(entry) => {
350					entry.insert(Node {
351						reader: resolve(&self.origin, &path),
352						path,
353						last: None,
354					});
355					false
356				}
357			}
358		} else if V::STICKY {
359			// Unannounce: keep the cumulative totals, retire the live gauges, and
360			// stop reading. The entry stays so a reannounce re-arms it.
361			match self.nodes.get_mut(&absolute) {
362				Some(node) => node.depart(),
363				None => false,
364			}
365		} else {
366			// A gauge drops its contribution, and its entry, so a departed node
367			// stops counting and its path is not retained.
368			self.nodes.remove(&absolute).is_some_and(|old| old.last.is_some())
369		}
370	}
371
372	/// Sum every node's last frame, per key.
373	fn merged(&self) -> BTreeMap<String, V> {
374		let mut acc: BTreeMap<String, V> = BTreeMap::new();
375		for node in self.nodes.values() {
376			if let Some(last) = &node.last {
377				for (key, value) in last {
378					V::merge(acc.entry(key.clone()).or_default(), *value);
379				}
380			}
381		}
382		acc
383	}
384}
385
386/// Drive one node's reader as far as it goes, updating its `last` frame. Returns
387/// whether that node's contribution to the merged view changed.
388fn advance<V: Mergeable>(
389	node: &mut Node<V>,
390	origin: &origin::Consumer,
391	config: &moq_json::snapshot::consumer::Config,
392	name: &str,
393	waiter: &Waiter,
394) -> bool {
395	let mut changed = false;
396	// At most one re-resolve per call: a subscription that terminates
397	// synchronously twice in a row is done, not failing over.
398	let mut rearmed = false;
399	loop {
400		match &mut node.reader {
401			Reader::Resolving { pending, queued } => match pending.poll_ok(waiter) {
402				Poll::Ready(Ok(broadcast)) => match broadcast.track(name) {
403					Ok(track) => node.reader = Reader::Subscribing(track.subscribe(None)),
404					Err(err) => {
405						tracing::debug!(?err, name, "stats: node missing track");
406						return changed | node.depart();
407					}
408				},
409				// A queued request killed by its route retracting: an identical
410				// standby swaps in without any announce update, so re-resolve
411				// through the already-updated table. Each retry consumed a real
412				// retraction, so this cannot spin; an instant Unroutable instead
413				// means nothing serves the path (the retraction that empties the
414				// table also unannounces this node).
415				Poll::Ready(Err(moq_net::Error::Unroutable)) if *queued => {
416					node.reader = resolve(origin, &node.path);
417				}
418				Poll::Ready(Err(err)) => {
419					tracing::debug!(?err, name, "stats: node broadcast unresolvable");
420					return changed | node.depart();
421				}
422				Poll::Pending => return changed,
423			},
424			Reader::Subscribing(pending) => match pending.poll_ok(waiter) {
425				Poll::Ready(Ok(subscriber)) => {
426					node.reader =
427						Reader::Active(Box::new(moq_json::snapshot::Consumer::new(subscriber, config.clone())));
428				}
429				Poll::Ready(Err(err)) => {
430					tracing::debug!(?err, name, "stats: node subscribe failed");
431					return changed | node.depart();
432				}
433				Poll::Pending => return changed,
434			},
435			Reader::Active(reader) => match reader.poll_next(waiter) {
436				Poll::Ready(Ok(Some(frame))) => {
437					node.last = Some(frame);
438					changed = true;
439				}
440				// The subscription ended: the serving session died (a failover to
441				// an identical route delivers no announce update) or the track
442				// finished. A non-sticky gauge drops its last frame so a stale
443				// value stops pinning the sum, while a sticky counter keeps it.
444				// Then re-resolve through the current best route; an
445				// authoritative refusal on the way ends the node instead.
446				Poll::Ready(result @ (Ok(None) | Err(_))) => {
447					if let Err(err) = result {
448						// One bad node must not tear down the whole merged view;
449						// re-resolve just this node and keep folding the rest.
450						tracing::debug!(?err, name, "stats: node read error");
451					}
452					// `depart` drops the dead reader before the re-request: our
453					// own handle is what keeps a dying served broadcast cached,
454					// and releasing it first lets the request materialize a
455					// fresh one.
456					changed |= node.depart();
457					if rearmed {
458						return changed;
459					}
460					rearmed = true;
461					node.reader = resolve(origin, &node.path);
462				}
463				Poll::Pending => return changed,
464			},
465			Reader::Ended => return changed,
466		}
467	}
468}
469
470/// Start resolving `path` (relative to the announce cursor) into a broadcast.
471fn resolve<V: Mergeable>(origin: &origin::Consumer, path: &PathOwned) -> Reader<V> {
472	let pending = origin.request_broadcast(path);
473	let queued = pending.is_queued();
474	Reader::Resolving { pending, queued }
475}
476
477#[cfg(test)]
478mod tests {
479	/// Build an origin producer, spawning its driver on the ambient runtime.
480	fn produce_origin() -> moq_net::origin::Producer {
481		let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default());
482		if tokio::runtime::Handle::try_current().is_ok() {
483			tokio::spawn(moq_net::time::run(driver));
484		} else {
485			// A sync test: nothing polls the driver, and dropping it would tear
486			// the origin down, so leak it and rely on the synchronous half.
487			std::mem::forget(driver);
488		}
489		producer
490	}
491
492	use std::time::Duration;
493
494	use moq_net::{PathOwned, Timestamp, announce, broadcast, origin, track};
495
496	use crate::{Producer, produce};
497
498	use super::*;
499
500	/// A stats producer publishing one node's broadcasts on `origin`, grouped at
501	/// depth 1 (so feeding a broadcast under `<group>/...` announces
502	/// `.stats/<group>/node/<node>`).
503	fn node_producer(origin: &origin::Producer, node: &str) -> Producer {
504		Producer::new(
505			produce::Config::new()
506				.with_origin(origin.clone())
507				.with_node(PathOwned::from(node.to_string()))
508				.with_depth(1),
509		)
510	}
511
512	/// Kept-alive handles from [`feed`]: dropping them closes the subscription and
513	/// announce, bumping the `_closed` counters.
514	#[allow(dead_code)]
515	struct Feed {
516		announced: announce::Consumer,
517		source: broadcast::Producer,
518		consumer: broadcast::Consumer,
519		sub: track::Subscriber,
520		ctx: moq_net::stats::Session,
521	}
522
523	/// Record `bytes` of egress traffic on `path` in `producer`'s registry under
524	/// `tier`/`root`, by driving a throwaway tagged broadcast. The broadcast lives
525	/// on its own origin so it never lands on the stats origin.
526	async fn feed(producer: &Producer, tier: Tier, root: &str, path: &str, bytes: usize) -> Feed {
527		let ctx = producer.registry().tier(tier).session(root);
528		let feed_origin = produce_origin();
529		let egress = feed_origin.consume().with_stats(ctx.clone());
530
531		let mut announced = egress.announced();
532		let source = feed_origin.create_broadcast(path).expect("create_broadcast");
533		source.announce(origin::Route::default()).expect("announce");
534		let track = source.create_track("video", None).expect("create_track");
535
536		let update = announced.next().await.expect("announce");
537		assert!(update.kind.is_active());
538		let consumer = egress.request_broadcast(path).await.expect("resolve");
539		let mut sub = consumer.track("video").unwrap().subscribe(None).await.unwrap();
540
541		let mut group = track.append_group().unwrap();
542		group.write_frame(Timestamp::ZERO, vec![0u8; bytes]).unwrap();
543		group.finish().unwrap();
544		let mut group = sub.recv_group().await.unwrap().unwrap();
545		while group.read_frame().await.unwrap().is_some() {}
546
547		Feed {
548			announced,
549			source,
550			consumer,
551			sub,
552			ctx,
553		}
554	}
555
556	/// Advance past one publish interval so every producer task drains and writes.
557	async fn drive_tick() {
558		tokio::time::advance(Duration::from_millis(1100)).await;
559		for _ in 0..8 {
560			tokio::task::yield_now().await;
561		}
562	}
563
564	/// Read merged traffic frames until `path`'s byte count reaches `want` (each
565	/// node folds in independently, so a partial frame can arrive first).
566	async fn read_until_bytes(consumer: &mut TrafficConsumer, path: &str, want: u64) -> TrafficFrame {
567		loop {
568			let frame = consumer.next().await.expect("read").expect("frame");
569			if frame.get(path).map(|t| t.bytes).unwrap_or(0) >= want {
570				return frame;
571			}
572		}
573	}
574
575	/// Read merged traffic frames until `path`'s byte count reaches `want`,
576	/// asserting it never regresses below `min` along the way: a departed node's
577	/// kept contribution must hold the total through every intermediate frame.
578	async fn read_monotonic_until(consumer: &mut TrafficConsumer, path: &str, min: u64, want: u64) -> TrafficFrame {
579		loop {
580			let frame = consumer.next().await.expect("read").expect("frame");
581			let bytes = frame.get(path).map(|t| t.bytes).unwrap_or(0);
582			assert!(bytes >= min, "traffic regressed below {min}: {bytes}");
583			if bytes >= want {
584				return frame;
585			}
586		}
587	}
588
589	/// A hand-published node broadcast at `.stats/<group>/node/<node>` with a
590	/// plain default-tier traffic track, so a test controls the exact frames and
591	/// can fail one node's reader alone (the registry-driven producer only
592	/// publishes whole broadcasts). Dropping it unannounces the node.
593	#[allow(dead_code)]
594	struct NodeBroadcast {
595		source: broadcast::Producer,
596		traffic: moq_json::snapshot::Producer<TrafficFrame>,
597		track: track::Producer,
598		frame: TrafficFrame,
599	}
600
601	impl NodeBroadcast {
602		fn new(origin: &origin::Producer, group: &str, node: &str) -> Self {
603			let path = format!(".stats/{group}/node/{node}");
604			let source = origin.create_broadcast(path.as_str()).expect("create broadcast");
605			source.announce(origin::Route::default()).expect("announce");
606			let name = traffic_track(&Tier::default(), Role::Publisher, false);
607			let track = source.create_track(name, None).expect("create track");
608			let config = moq_json::snapshot::Config::default().with_delta_ratio(0);
609			Self {
610				traffic: moq_json::snapshot::Producer::new(track.clone(), config),
611				track,
612				source,
613				frame: TrafficFrame::new(),
614			}
615		}
616
617		/// Add `bytes` to `path`'s cumulative counter and publish the node's
618		/// whole snapshot, like a real node's registry drain would.
619		fn publish(&mut self, path: &str, bytes: u64) {
620			let entry = self.frame.entry(path.to_string()).or_default();
621			entry.bytes += bytes;
622			self.traffic.update(&self.frame).expect("publish");
623		}
624
625		/// Fail the node's reader: append a frame the snapshot decoder can't
626		/// parse, so the subscription errors while the broadcast stays announced.
627		fn fail_traffic(&mut self) {
628			let mut group = self.track.append_group().expect("append group");
629			group
630				.write_frame(Timestamp::ZERO, b"not json".to_vec())
631				.expect("write frame");
632			group.finish().expect("finish group");
633		}
634	}
635
636	#[tokio::test(start_paused = true)]
637	async fn merges_traffic_across_nodes() {
638		// Two nodes each serve the same broadcast; the merged view sums their
639		// cumulative counters per path.
640		let origin = produce_origin();
641		let node_a = node_producer(&origin, "a");
642		let node_b = node_producer(&origin, "b");
643
644		let _fa = feed(&node_a, Tier::default(), "acme", "acme/room", 100).await;
645		let _fb = feed(&node_b, Tier::default(), "acme", "acme/room", 40).await;
646		drive_tick().await;
647
648		let agg = Consumer::new(origin.consume(), Config::new().with_depth(1));
649		let mut traffic = agg.traffic(&Tier::default(), Role::Publisher);
650
651		let frame = read_until_bytes(&mut traffic, "acme/room", 140).await;
652		let snap = frame.get("acme/room").expect("entry");
653		assert_eq!(snap.bytes, 140, "bytes sum across both nodes");
654		assert_eq!(snap.subscriptions_started, 2, "one subscription per node");
655		assert_eq!(snap.broadcasts_started, 2, "one viewer per node");
656	}
657
658	#[tokio::test(start_paused = true)]
659	async fn node_drop_keeps_the_traffic_total() {
660		// Dropping a node unannounces its broadcast, but traffic is sticky: its
661		// last contribution stays in the total, so a relay that returns with its
662		// boot-lifetime counters intact never looks like new traffic.
663		let origin = produce_origin();
664		let node_a = node_producer(&origin, "a");
665		let node_b = node_producer(&origin, "b");
666
667		let _fa = feed(&node_a, Tier::default(), "acme", "acme/room", 100).await;
668		let fb = feed(&node_b, Tier::default(), "acme", "acme/room", 40).await;
669		drive_tick().await;
670
671		let agg = Consumer::new(origin.consume(), Config::new().with_depth(1));
672		let mut traffic = agg.traffic(&Tier::default(), Role::Publisher);
673		read_until_bytes(&mut traffic, "acme/room", 140).await;
674
675		// Drop node B entirely: its publish task ends and finishes the broadcast.
676		drop(fb);
677		drop(node_b);
678		drive_tick().await;
679
680		// Node A serves more traffic on another path; the merged frame still
681		// carries node B's kept contribution for acme/room.
682		let _fa2 = feed(&node_a, Tier::default(), "acme", "acme/other", 10).await;
683		drive_tick().await;
684
685		let frame = read_until_bytes(&mut traffic, "acme/other", 10).await;
686		assert_eq!(
687			frame.get("acme/room").map(|t| t.bytes),
688			Some(140),
689			"the departed node's contribution stays in the total",
690		);
691	}
692
693	#[tokio::test(start_paused = true)]
694	async fn reannounce_with_higher_counter_stays_monotonic() {
695		// A node's stats session reconnects with its cumulative counters intact
696		// and still growing: the total holds through the swap, then advances,
697		// never dipping.
698		let origin = produce_origin();
699		let node_a = node_producer(&origin, "a");
700		let node_b = node_producer(&origin, "b");
701
702		let fa = feed(&node_a, Tier::default(), "acme", "acme/room", 100).await;
703		let _fb = feed(&node_b, Tier::default(), "acme", "acme/room", 40).await;
704		drive_tick().await;
705
706		let agg = Consumer::new(origin.consume(), Config::new().with_depth(1));
707		let mut traffic = agg.traffic(&Tier::default(), Role::Publisher);
708		read_until_bytes(&mut traffic, "acme/room", 140).await;
709
710		// Node A's broadcast goes away and comes back with a higher counter.
711		drop(fa);
712		drop(node_a);
713		drive_tick().await;
714
715		let node_a = node_producer(&origin, "a");
716		let _fa = feed(&node_a, Tier::default(), "acme", "acme/room", 120).await;
717		drive_tick().await;
718
719		// The kept contribution holds the total at 140 until the new frame
720		// replaces it, landing on 120 + 40.
721		let frame = read_monotonic_until(&mut traffic, "acme/room", 140, 160).await;
722		assert_eq!(frame.get("acme/room").expect("entry").bytes, 160);
723	}
724
725	#[tokio::test(start_paused = true)]
726	async fn restarted_node_regresses_the_total() {
727		// A node that returns with a fresh counter (it restarted) replaces its
728		// contribution wholesale: the total regresses once, the existing
729		// fresh-segment contract.
730		let origin = produce_origin();
731		let node_a = node_producer(&origin, "a");
732		let node_b = node_producer(&origin, "b");
733
734		let fa = feed(&node_a, Tier::default(), "acme", "acme/room", 100).await;
735		let _fb = feed(&node_b, Tier::default(), "acme", "acme/room", 40).await;
736		drive_tick().await;
737
738		let agg = Consumer::new(origin.consume(), Config::new().with_depth(1));
739		let mut traffic = agg.traffic(&Tier::default(), Role::Publisher);
740		read_until_bytes(&mut traffic, "acme/room", 140).await;
741
742		// Node A restarts: its broadcast returns with a lower counter.
743		drop(fa);
744		drop(node_a);
745		drive_tick().await;
746
747		let node_a = node_producer(&origin, "a");
748		let _fa = feed(&node_a, Tier::default(), "acme", "acme/room", 30).await;
749		drive_tick().await;
750
751		// The restarted frame replaces A's contribution, so the total drops to
752		// 30 + 40, a genuine per-node regression downstream treats as a fresh
753		// segment. An earlier frame may retire A's live gauges first.
754		loop {
755			let frame = traffic.next().await.expect("read").expect("frame");
756			if frame.get("acme/room").map(|t| t.bytes) == Some(70) {
757				break;
758			}
759		}
760	}
761
762	#[tokio::test(start_paused = true)]
763	async fn reader_failure_keeps_the_traffic_total() {
764		// A node's reader failing while its broadcast is still announced keeps
765		// its last contribution in the total.
766		let origin = produce_origin();
767		let mut node_a = NodeBroadcast::new(&origin, "acme", "a");
768		let mut node_b = NodeBroadcast::new(&origin, "acme", "b");
769		node_a.publish("acme/room", 100);
770		node_b.publish("acme/room", 40);
771
772		let agg = Consumer::new(origin.consume(), Config::new().with_depth(1));
773		let mut traffic = agg.traffic(&Tier::default(), Role::Publisher);
774		read_until_bytes(&mut traffic, "acme/room", 140).await;
775
776		// Node A's subscription errors under its still-announced broadcast.
777		node_a.fail_traffic();
778
779		// Node B reports more traffic; node A's kept 100 stays in the total.
780		node_b.publish("acme/other", 10);
781
782		let frame = read_until_bytes(&mut traffic, "acme/other", 10).await;
783		assert_eq!(
784			frame.get("acme/room").map(|t| t.bytes),
785			Some(140),
786			"the failed node's contribution stays in the total",
787		);
788	}
789
790	#[tokio::test(start_paused = true)]
791	async fn unannounce_retires_live_gauges() {
792		// A departed node's totals stay in the merged view, but its live gauges
793		// retire: the aggregate must not show phantom viewers or broadcasts for
794		// a node that is gone.
795		let origin = produce_origin();
796		let mut node_a = NodeBroadcast::new(&origin, "acme", "a");
797
798		let mut published = Traffic::default();
799		published.announces_started = 2;
800		published.announces_ended = 1;
801		published.broadcasts_started = 3;
802		published.broadcasts_ended = 1;
803		published.subscriptions_started = 4;
804		published.subscriptions_ended = 1;
805		published.bytes = 100;
806		node_a.frame.insert("acme/room".to_string(), published);
807		node_a.traffic.update(&node_a.frame).expect("publish");
808
809		let agg = Consumer::new(origin.consume(), Config::new().with_depth(1));
810		let mut traffic = agg.traffic(&Tier::default(), Role::Publisher);
811		let frame = read_until_bytes(&mut traffic, "acme/room", 100).await;
812		let snap = frame.get("acme/room").expect("entry");
813		assert!(snap.is_announced());
814		assert_eq!(snap.active_broadcasts(), 2);
815		assert_eq!(snap.active_subscriptions(), 3);
816
817		// The node departs with those sessions still open.
818		drop(node_a);
819
820		// The totals stay; the live gauges retire.
821		let frame = traffic.next().await.expect("read").expect("frame");
822		let snap = frame.get("acme/room").expect("entry");
823		assert_eq!(snap.bytes, 100, "cumulative totals stay");
824		assert!(!snap.is_announced(), "no phantom announcement");
825		assert_eq!(snap.active_broadcasts(), 0, "no phantom broadcasts");
826		assert_eq!(snap.active_subscriptions(), 0, "no phantom subscriptions");
827	}
828
829	#[tokio::test(start_paused = true)]
830	async fn merges_sessions_across_nodes() {
831		// Session presence sums per auth root across nodes.
832		let origin = produce_origin();
833		let node_a = node_producer(&origin, "a");
834		let node_b = node_producer(&origin, "b");
835
836		// Each node needs a live broadcast to announce; the sessions ride the same
837		// group.
838		let _fa = feed(&node_a, Tier::default(), "acme", "acme/room", 8).await;
839		let _fb = feed(&node_b, Tier::default(), "acme", "acme/room", 8).await;
840		let _sa = node_a.registry().tier(Tier::default()).session("acme");
841		let _sb = node_b.registry().tier(Tier::default()).session("acme");
842		drive_tick().await;
843
844		let agg = Consumer::new(origin.consume(), Config::new().with_depth(1));
845		let mut sessions = agg.sessions(&Tier::default());
846
847		loop {
848			let frame = sessions.next().await.expect("read").expect("frame");
849			// Each feed opens one session ("acme" root) plus the explicit ones,
850			// summed across both nodes.
851			if frame.get("acme").map(|p| p.active()) >= Some(4) {
852				break;
853			}
854		}
855	}
856
857	#[tokio::test(start_paused = true)]
858	async fn unannounce_drops_presence_immediately() {
859		// Unlike traffic, presence is a gauge: a node's unannounce must stop
860		// counting its sessions immediately, not pin a stale gauge.
861		let origin = produce_origin();
862		let node_a = node_producer(&origin, "a");
863		let node_b = node_producer(&origin, "b");
864
865		let _fa = feed(&node_a, Tier::default(), "acme", "acme/room", 8).await;
866		let fb = feed(&node_b, Tier::default(), "acme", "acme/room", 8).await;
867		let _sa = node_a.registry().tier(Tier::default()).session("acme");
868		let sb = node_b.registry().tier(Tier::default()).session("acme");
869		drive_tick().await;
870
871		let agg = Consumer::new(origin.consume(), Config::new().with_depth(1));
872		let mut sessions = agg.sessions(&Tier::default());
873
874		loop {
875			let frame = sessions.next().await.expect("read").expect("frame");
876			// Each feed opens one session ("acme" root) plus the explicit ones,
877			// summed across both nodes.
878			if frame.get("acme").map(|p| p.active()) >= Some(4) {
879				break;
880			}
881		}
882
883		// Node B departs: its sessions leave the gauge at once.
884		drop(fb);
885		drop(sb);
886		drop(node_b);
887		drive_tick().await;
888
889		let frame = sessions.next().await.expect("read").expect("frame");
890		assert_eq!(
891			frame.get("acme").map(|p| p.active()),
892			Some(2),
893			"presence drops immediately"
894		);
895	}
896}