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::{BTreeMap, HashMap};
10use std::task::Poll;
11
12use moq_net::kio::{self, Pending, Waiter};
13use moq_net::stats::{Presence, Role, Tier, Traffic};
14use moq_net::track::Subscribing;
15use moq_net::{PathOwned, origin};
16
17use crate::{Result, SessionsFrame, TrafficFrame, parse_node_path, sessions_track, traffic_track};
18
19/// Configuration for an [`Consumer`]. Construct with [`Config::new`] and chain
20/// the `with_*` setters.
21///
22/// The `prefix` and `depth` must match the producing side's
23/// [`ProducerConfig`](crate::ProducerConfig): they are how announced paths are
24/// recognized as node broadcasts and filtered from sibling categories under the
25/// same prefix.
26#[derive(Debug, Clone)]
27#[non_exhaustive]
28pub struct Config {
29	/// Top-level path stats are published under (default `.stats`). Must match
30	/// the producer's prefix.
31	pub prefix: PathOwned,
32	/// The producer's grouping depth (default `0`). Announced paths whose group
33	/// is deeper than this are not recognized as node broadcasts and are
34	/// skipped. Must match the producer's depth.
35	pub depth: usize,
36	/// Read the compressed `.json.z` tracks instead of the plain `.json` ones.
37	/// Same data for a fraction of the bytes, but requires a producer that
38	/// publishes them. Defaults to `false`.
39	pub compression: bool,
40}
41
42impl Config {
43	/// A config with default settings: the `.stats` prefix, depth `0`, and the
44	/// plain `.json` tracks.
45	pub fn new() -> Self {
46		Self::default()
47	}
48
49	/// Override the top-level prefix (default `.stats`). Must match the producer.
50	pub fn with_prefix(mut self, prefix: impl Into<PathOwned>) -> Self {
51		self.prefix = prefix.into();
52		self
53	}
54
55	/// Override the grouping depth (default `0`). Must match the producer.
56	pub fn with_depth(mut self, depth: usize) -> Self {
57		self.depth = depth;
58		self
59	}
60
61	/// Read the compressed `.json.z` tracks instead of the plain `.json` ones.
62	pub fn with_compression(mut self, compression: bool) -> Self {
63		self.compression = compression;
64		self
65	}
66}
67
68impl Default for Config {
69	fn default() -> Self {
70		Self {
71			prefix: PathOwned::from(".stats"),
72			depth: 0,
73			compression: false,
74		}
75	}
76}
77
78/// Folds a group's per-node stats broadcasts into one merged view.
79///
80/// Scope an [`origin::Consumer`] to a single group (e.g. `.stats/<pid>`) and
81/// hand it here; each [`Self::traffic`] / [`Self::sessions`] call opens its own
82/// announce cursor and subscribes to that track on every node broadcast in the
83/// group, summing the cumulative counters per key. A node dropping out (its
84/// broadcast unannounces) removes its contribution, so the merged counter
85/// regresses: downstream treats that decrease as a fresh segment, the same
86/// reset contract a single node's own restart follows.
87pub struct Consumer {
88	origin: origin::Consumer,
89	config: Config,
90}
91
92impl Consumer {
93	/// Wrap an origin consumer, ideally already scoped to one group. `config`'s
94	/// `prefix` and `depth` must match the producing side.
95	pub fn new(origin: origin::Consumer, config: Config) -> Self {
96		Self { origin, config }
97	}
98
99	/// A merged reader over the traffic track for `(tier, role)`, folding every
100	/// node broadcast in the group. Nodes are subscribed lazily as they announce,
101	/// so this returns without a handshake.
102	pub fn traffic(&self, tier: &Tier, role: Role) -> TrafficConsumer {
103		let name = traffic_track(tier, role, self.config.compression);
104		TrafficConsumer {
105			inner: Merged::new(self.origin.announced(), &self.config, name),
106		}
107	}
108
109	/// A merged reader over the sessions track for `tier`; see [`Self::traffic`].
110	pub fn sessions(&self, tier: &Tier) -> SessionsConsumer {
111		let name = sessions_track(tier, self.config.compression);
112		SessionsConsumer {
113			inner: Merged::new(self.origin.announced(), &self.config, name),
114		}
115	}
116}
117
118/// A merged reader over one traffic track across every node in the group. Yields
119/// the latest merged [`TrafficFrame`]; a slow reader collapses intermediate
120/// frames, which is safe because the counters are cumulative.
121pub struct TrafficConsumer {
122	inner: Merged<Traffic>,
123}
124
125impl TrafficConsumer {
126	/// The next merged frame, or `None` once the announce stream ends (the
127	/// source origin went away).
128	pub async fn next(&mut self) -> Result<Option<TrafficFrame>> {
129		kio::wait(|waiter| self.inner.poll_next(waiter)).await
130	}
131}
132
133/// A merged reader over one sessions track across every node in the group; see
134/// [`TrafficConsumer`].
135pub struct SessionsConsumer {
136	inner: Merged<Presence>,
137}
138
139impl SessionsConsumer {
140	/// The next merged frame, or `None` once the announce stream ends.
141	pub async fn next(&mut self) -> Result<Option<SessionsFrame>> {
142		kio::wait(|waiter| self.inner.poll_next(waiter)).await
143	}
144}
145
146/// A per-key counter that folds across nodes: the two wire counter types.
147trait Mergeable: serde::de::DeserializeOwned + Default + Copy + 'static {
148	/// Fold `other` into `acc`.
149	fn merge(acc: &mut Self, other: Self);
150}
151
152impl Mergeable for Traffic {
153	fn merge(acc: &mut Self, other: Self) {
154		acc.add(other);
155	}
156}
157
158impl Mergeable for Presence {
159	fn merge(acc: &mut Self, other: Self) {
160		acc.add(other);
161	}
162}
163
164/// One node's subscription to the merged track.
165enum Reader<V: Mergeable> {
166	/// Awaiting the subscription handshake.
167	Subscribing(Pending<Subscribing>),
168	/// Reading frames. Boxed: the snapshot consumer dwarfs the other variants,
169	/// and one lives per node in a map.
170	Active(Box<moq_json::snapshot::Consumer<BTreeMap<String, V>>>),
171	/// The subscription failed or the track ended; the node no longer
172	/// contributes and lingers only until it unannounces.
173	Ended,
174}
175
176/// One node's reader plus the last frame it produced (the value folded into the
177/// merged view).
178struct Node<V: Mergeable> {
179	reader: Reader<V>,
180	last: Option<BTreeMap<String, V>>,
181}
182
183/// Watches a group's node announces and folds one track across all of them.
184struct Merged<V: Mergeable> {
185	announce: moq_net::announce::Consumer,
186	prefix: PathOwned,
187	depth: usize,
188	/// Track name subscribed on each node broadcast.
189	name: String,
190	config: moq_json::snapshot::ConsumerConfig,
191	/// One entry per live node broadcast, keyed by absolute announced path.
192	nodes: HashMap<PathOwned, Node<V>>,
193}
194
195impl<V: Mergeable> Merged<V> {
196	fn new(announce: moq_net::announce::Consumer, config: &Config, name: String) -> Self {
197		Self {
198			announce,
199			prefix: config.prefix.clone(),
200			depth: config.depth,
201			name,
202			config: moq_json::snapshot::ConsumerConfig::default().with_compression(config.compression),
203			nodes: HashMap::new(),
204		}
205	}
206
207	/// Poll for the next merged frame. Returns `Ready(Some(_))` whenever the
208	/// merged view changed (a node produced a frame, appeared, or dropped),
209	/// `Ready(None)` once the announce stream closes, else `Pending`.
210	fn poll_next(&mut self, waiter: &Waiter) -> Poll<Result<Option<BTreeMap<String, V>>>> {
211		let mut changed = false;
212
213		// Drain announce membership updates: add/replace on announce, drop on
214		// unannounce. A closed stream ends the merged view.
215		loop {
216			match self.announce.poll_next(waiter) {
217				Poll::Ready(Some(update)) => changed |= self.apply_announce(update),
218				Poll::Ready(None) => return Poll::Ready(Ok(None)),
219				Poll::Pending => break,
220			}
221		}
222
223		// Advance each node's reader, collapsing any backlog to its latest frame.
224		let config = &self.config;
225		let name = self.name.as_str();
226		for node in self.nodes.values_mut() {
227			changed |= advance(node, config, name, waiter);
228		}
229
230		if changed {
231			Poll::Ready(Ok(Some(self.merged())))
232		} else {
233			Poll::Pending
234		}
235	}
236
237	/// Apply one announce update to the node set. Returns whether the merged view
238	/// changed (only a drop or a replacement of a node that had a value does).
239	fn apply_announce(&mut self, update: moq_net::announce::Update) -> bool {
240		let moq_net::announce::Update { path, broadcast } = update;
241		let absolute = self.announce.absolute(&path).to_owned();
242
243		// Only fold node-category broadcasts; skip sibling categories a producer
244		// may publish under the same prefix.
245		if parse_node_path(&self.prefix, self.depth, &absolute).is_none() {
246			return false;
247		}
248
249		match broadcast {
250			Some(broadcast) => match broadcast.track(&self.name) {
251				Ok(track) => {
252					let node = Node {
253						reader: Reader::Subscribing(track.subscribe(None)),
254						last: None,
255					};
256					// A replacement (failover) drops the old value until the new
257					// subscription catches up.
258					self.nodes.insert(absolute, node).is_some_and(|old| old.last.is_some())
259				}
260				Err(err) => {
261					tracing::debug!(?err, node = %absolute, name = %self.name, "stats: node missing track");
262					self.nodes.remove(&absolute).is_some_and(|old| old.last.is_some())
263				}
264			},
265			None => self.nodes.remove(&absolute).is_some_and(|old| old.last.is_some()),
266		}
267	}
268
269	/// Sum every node's last frame, per key.
270	fn merged(&self) -> BTreeMap<String, V> {
271		let mut acc: BTreeMap<String, V> = BTreeMap::new();
272		for node in self.nodes.values() {
273			if let Some(last) = &node.last {
274				for (key, value) in last {
275					V::merge(acc.entry(key.clone()).or_default(), *value);
276				}
277			}
278		}
279		acc
280	}
281}
282
283/// Drive one node's reader as far as it goes, updating its `last` frame. Returns
284/// whether that node's contribution to the merged view changed.
285fn advance<V: Mergeable>(
286	node: &mut Node<V>,
287	config: &moq_json::snapshot::ConsumerConfig,
288	name: &str,
289	waiter: &Waiter,
290) -> bool {
291	let mut changed = false;
292	loop {
293		match &mut node.reader {
294			Reader::Subscribing(pending) => match pending.poll_ok(waiter) {
295				Poll::Ready(Ok(subscriber)) => {
296					node.reader =
297						Reader::Active(Box::new(moq_json::snapshot::Consumer::new(subscriber, config.clone())));
298				}
299				Poll::Ready(Err(err)) => {
300					tracing::debug!(?err, name, "stats: node subscribe failed");
301					node.reader = Reader::Ended;
302					return changed;
303				}
304				Poll::Pending => return changed,
305			},
306			Reader::Active(reader) => match reader.poll_next(waiter) {
307				Poll::Ready(Ok(Some(frame))) => {
308					node.last = Some(frame);
309					changed = true;
310				}
311				Poll::Ready(Ok(None)) => return terminate(node, changed),
312				Poll::Ready(Err(err)) => {
313					// One bad node must not tear down the whole merged view; drop
314					// just this node and keep folding the rest.
315					tracing::debug!(?err, name, "stats: node read error");
316					return terminate(node, changed);
317				}
318				Poll::Pending => return changed,
319			},
320			Reader::Ended => return changed,
321		}
322	}
323}
324
325/// Retire a node whose reader terminated: drop its last frame so it stops
326/// contributing (a still-announced but unreadable node must not pin a stale
327/// gauge into the sum) and mark it ended so it lingers only until it
328/// unannounces. Returns whether the merged view changed.
329fn terminate<V: Mergeable>(node: &mut Node<V>, changed: bool) -> bool {
330	let had_value = node.last.take().is_some();
331	node.reader = Reader::Ended;
332	changed || had_value
333}
334
335#[cfg(test)]
336mod tests {
337	use std::time::Duration;
338
339	use moq_net::{Origin, PathOwned, Timestamp, announce, broadcast, origin, track};
340
341	use crate::{Producer, ProducerConfig};
342
343	use super::*;
344
345	/// A stats producer publishing one node's broadcasts on `origin`, grouped at
346	/// depth 1 (so feeding a broadcast under `<group>/...` announces
347	/// `.stats/<group>/node/<node>`).
348	fn node_producer(origin: &origin::Producer, node: &str) -> Producer {
349		Producer::new(
350			ProducerConfig::new()
351				.with_origin(origin.clone())
352				.with_node(PathOwned::from(node.to_string()))
353				.with_depth(1),
354		)
355	}
356
357	/// Kept-alive handles from [`feed`]: dropping them closes the subscription and
358	/// announce, bumping the `_closed` counters.
359	#[allow(dead_code)]
360	struct Feed {
361		announced: announce::Consumer,
362		source: broadcast::Producer,
363		consumer: broadcast::Consumer,
364		sub: track::Subscriber,
365		ctx: moq_net::stats::Session,
366	}
367
368	/// Record `bytes` of egress traffic on `path` in `producer`'s registry under
369	/// `tier`/`root`, by driving a throwaway tagged broadcast. The broadcast lives
370	/// on its own origin so it never lands on the stats origin.
371	async fn feed(producer: &Producer, tier: Tier, root: &str, path: &str, bytes: usize) -> Feed {
372		let ctx = producer.registry().tier(tier).session(root);
373		let feed_origin = Origin::random().produce();
374		let egress = feed_origin.consume().with_stats(ctx.clone());
375
376		let mut announced = egress.announced();
377		let mut source = feed_origin
378			.create_broadcast(path, broadcast::Route::announced())
379			.expect("create_broadcast");
380		let mut track = source.create_track("video", None).expect("create_track");
381
382		// Let the origin's source watcher attach and announce.
383		tokio::time::sleep(Duration::from_millis(1)).await;
384		tokio::time::sleep(Duration::from_millis(1)).await;
385
386		let announce::Update { broadcast, .. } = announced.next().await.expect("announce");
387		let consumer = broadcast.expect("active");
388		let mut sub = consumer.track("video").unwrap().subscribe(None).await.unwrap();
389
390		let mut group = track.append_group().unwrap();
391		group.write_frame(Timestamp::ZERO, vec![0u8; bytes]).unwrap();
392		group.finish().unwrap();
393		let mut group = sub.recv_group().await.unwrap().unwrap();
394		while group.read_frame().await.unwrap().is_some() {}
395
396		Feed {
397			announced,
398			source,
399			consumer,
400			sub,
401			ctx,
402		}
403	}
404
405	/// Advance past one publish interval so every producer task drains and writes.
406	async fn drive_tick() {
407		tokio::time::advance(Duration::from_millis(1100)).await;
408		for _ in 0..8 {
409			tokio::task::yield_now().await;
410		}
411	}
412
413	/// Read merged traffic frames until `path`'s byte count reaches `want` (each
414	/// node folds in independently, so a partial frame can arrive first).
415	async fn read_until_bytes(consumer: &mut TrafficConsumer, path: &str, want: u64) -> TrafficFrame {
416		loop {
417			let frame = consumer.next().await.expect("read").expect("frame");
418			if frame.get(path).map(|t| t.bytes).unwrap_or(0) >= want {
419				return frame;
420			}
421		}
422	}
423
424	#[tokio::test(start_paused = true)]
425	async fn merges_traffic_across_nodes() {
426		// Two nodes each serve the same broadcast; the merged view sums their
427		// cumulative counters per path.
428		let origin = Origin::random().produce();
429		let node_a = node_producer(&origin, "a");
430		let node_b = node_producer(&origin, "b");
431
432		let _fa = feed(&node_a, Tier::default(), "acme", "acme/room", 100).await;
433		let _fb = feed(&node_b, Tier::default(), "acme", "acme/room", 40).await;
434		drive_tick().await;
435
436		let agg = Consumer::new(origin.consume(), Config::new().with_depth(1));
437		let mut traffic = agg.traffic(&Tier::default(), Role::Publisher);
438
439		let frame = read_until_bytes(&mut traffic, "acme/room", 140).await;
440		let snap = frame.get("acme/room").expect("entry");
441		assert_eq!(snap.bytes, 140, "bytes sum across both nodes");
442		assert_eq!(snap.subscriptions, 2, "one subscription per node");
443		assert_eq!(snap.broadcasts, 2, "one viewer per node");
444	}
445
446	#[tokio::test(start_paused = true)]
447	async fn node_drop_regresses_the_merged_view() {
448		// Dropping a node unannounces its broadcast; its contribution leaves the
449		// sum, so the merged counter regresses (a fresh segment downstream).
450		let origin = Origin::random().produce();
451		let node_a = node_producer(&origin, "a");
452		let node_b = node_producer(&origin, "b");
453
454		let _fa = feed(&node_a, Tier::default(), "acme", "acme/room", 100).await;
455		let fb = feed(&node_b, Tier::default(), "acme", "acme/room", 40).await;
456		drive_tick().await;
457
458		let agg = Consumer::new(origin.consume(), Config::new().with_depth(1));
459		let mut traffic = agg.traffic(&Tier::default(), Role::Publisher);
460		read_until_bytes(&mut traffic, "acme/room", 140).await;
461
462		// Drop node B entirely: its publish task ends and finishes the broadcast.
463		drop(fb);
464		drop(node_b);
465		drive_tick().await;
466
467		// The merged view drops back to node A's contribution alone.
468		loop {
469			let frame = traffic.next().await.expect("read").expect("frame");
470			if frame.get("acme/room").map(|t| t.bytes) == Some(100) {
471				break;
472			}
473		}
474	}
475
476	#[tokio::test(start_paused = true)]
477	async fn merges_sessions_across_nodes() {
478		// Session presence sums per auth root across nodes.
479		let origin = Origin::random().produce();
480		let node_a = node_producer(&origin, "a");
481		let node_b = node_producer(&origin, "b");
482
483		// Each node needs a live broadcast to announce; the sessions ride the same
484		// group.
485		let _fa = feed(&node_a, Tier::default(), "acme", "acme/room", 8).await;
486		let _fb = feed(&node_b, Tier::default(), "acme", "acme/room", 8).await;
487		let _sa = node_a.registry().tier(Tier::default()).session("acme");
488		let _sb = node_b.registry().tier(Tier::default()).session("acme");
489		drive_tick().await;
490
491		let agg = Consumer::new(origin.consume(), Config::new().with_depth(1));
492		let mut sessions = agg.sessions(&Tier::default());
493
494		loop {
495			let frame = sessions.next().await.expect("read").expect("frame");
496			// Each feed opens one session ("acme" root) plus the explicit ones,
497			// summed across both nodes.
498			if frame.get("acme").map(|p| p.active()) >= Some(4) {
499				break;
500			}
501		}
502	}
503}