Skip to main content

telemetry/
telemetry.rs

1//! Measure wire savings of group-scoped DEFLATE + snapshot/delta on a telemetry stream.
2//!
3//! Simulates a realistic device telemetry blob that ticks once per second: most of the document
4//! is static (identity, config, geo) while a handful of gauges and counters change each tick. This
5//! is exactly the shape `moq-json` targets, so it shows the snapshot/delta and compression knobs
6//! pulling in the same direction.
7//!
8//! Run with: `cargo run -p moq-json --example telemetry`
9
10use std::task::Poll;
11
12use moq_json::{ConsumerConfig, Producer, ProducerConfig};
13use serde_json::{Value, json};
14
15/// One second of telemetry for a fleet device: a big static core plus a few moving numbers.
16fn telemetry(tick: u64) -> Value {
17	// A slow drift so consecutive ticks differ by a little, like real sensors.
18	let t = tick as f64;
19	let lat = 37.7749 + (t * 0.0001).sin() * 0.01;
20	let lon = -122.4194 + (t * 0.0001).cos() * 0.01;
21
22	json!({
23		"device": {
24			"id": "veh-4417-a2",
25			"model": "Sentinel X2",
26			"firmware": "4.18.2-rc1",
27			"serial": "SNX2-0000-4417-A2C9",
28			"region": "us-west-2",
29			"fleet": "logistics-prod",
30			"tags": ["cold-chain", "long-haul", "priority"],
31		},
32		"config": {
33			"sample_hz": 1,
34			"upload_hz": 1,
35			"geofence": "bay-area",
36			"thresholds": { "temp_c": 8.0, "humidity": 85, "shock_g": 3.5, "battery_pct": 15 },
37			"contacts": ["ops@example.com", "fleet@example.com"],
38		},
39		"ts": 1_700_000_000 + tick,
40		"uptime_s": tick,
41		"location": {
42			"lat": (lat * 1e6).round() / 1e6,
43			"lon": (lon * 1e6).round() / 1e6,
44			"alt_m": 12 + (tick % 5),
45			"heading": (tick * 7) % 360,
46			"speed_kph": 40 + (tick % 25),
47			"fix": "3d",
48			"sats": 9 + (tick % 3),
49		},
50		"sensors": {
51			"temp_c": (4.0 + (t * 0.05).sin() * 1.5 * 100.0).round() / 100.0,
52			"humidity": 60 + (tick % 10),
53			"shock_g": (((t * 0.3).sin().abs()) * 100.0).round() / 100.0,
54			"door_open": tick % 30 == 0,
55		},
56		"power": {
57			"battery_pct": 100 - (tick / 6) % 100,
58			"charging": false,
59			"voltage_mv": 12_400 - (tick % 50) as i64,
60			"current_ma": 850 + (tick % 120) as i64,
61		},
62		"network": {
63			"rssi_dbm": -70 - (tick % 15) as i64,
64			"type": "lte",
65			"bytes_up": 1_024 * tick,
66			"bytes_down": 256 * tick,
67			"latency_ms": 35 + (tick % 40),
68		},
69		"counters": {
70			"events": tick,
71			"errors": tick / 50,
72			"reconnects": tick / 120,
73		},
74	})
75}
76
77/// Total wire bytes of every frame across every group for a full run under `config`.
78fn wire_bytes(config: ProducerConfig, ticks: u64) -> usize {
79	let track = moq_net::Track::new("telemetry").produce();
80	let consumer = track.consume();
81	let mut producer = Producer::<Value>::new(track, config);
82
83	for tick in 0..ticks {
84		producer.update(&telemetry(tick)).unwrap();
85	}
86	producer.finish().unwrap();
87
88	// Drain the raw stored frames (compressed if the producer compressed them) and sum their sizes.
89	let waiter = kio::Waiter::noop();
90	let mut total = 0;
91	let mut track = consumer;
92	while let Poll::Ready(Ok(Some(mut group))) = track.poll_next_group(&waiter) {
93		while let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) {
94			total += frame.len();
95		}
96	}
97	total
98}
99
100/// Drive a producer and a live consumer in lockstep, asserting that EVERY tick reconstructs to the
101/// exact input value after decompression and delta application (not just the final one).
102fn verify(producer_config: ProducerConfig, ticks: u64) {
103	let track = moq_net::Track::new("telemetry").produce();
104	let consumer = track.consume();
105	let mut producer = Producer::<Value>::new(track, producer_config.clone());
106
107	let mut consumer_config = ConsumerConfig::default();
108	consumer_config.compression = producer_config.compression;
109	let mut consumer = moq_json::Consumer::<Value>::new(consumer, consumer_config);
110	let waiter = kio::Waiter::noop();
111
112	for tick in 0..ticks {
113		let expected = telemetry(tick);
114		producer.update(&expected).unwrap();
115		// The producer emits exactly one frame per update, so the live consumer yields exactly one
116		// reconstructed value: it must match the input byte-for-byte after decompression + patching.
117		match consumer.poll_next(&waiter) {
118			Poll::Ready(Ok(Some(value))) => assert_eq!(value, expected, "tick {tick} reconstruction mismatch"),
119			other => panic!("tick {tick}: expected a value, got {other:?}"),
120		}
121	}
122	producer.finish().unwrap();
123
124	// Drain: nothing left and the stream ends cleanly.
125	assert!(
126		matches!(consumer.poll_next(&waiter), Poll::Ready(Ok(None))),
127		"stream did not end cleanly"
128	);
129}
130
131/// A consumer that joins only after the whole stream exists must still rebuild the latest value from
132/// the newest group's snapshot + deltas. For the compressed path this exercises the lazy decoder
133/// replaying the group's already-stored slices to warm its window before decoding the final frame.
134///
135/// Returns how many values the late joiner surfaced to the application: with backlog collapsing this
136/// is far below `ticks`, since stale intermediate reconstructions are applied internally but skipped.
137fn verify_late_joiner(producer_config: ProducerConfig, ticks: u64) -> usize {
138	let track = moq_net::Track::new("telemetry").produce();
139	let consumer = track.consume();
140	let mut producer = Producer::<Value>::new(track, producer_config.clone());
141	for tick in 0..ticks {
142		producer.update(&telemetry(tick)).unwrap();
143	}
144	producer.finish().unwrap();
145
146	let mut consumer_config = ConsumerConfig::default();
147	consumer_config.compression = producer_config.compression;
148	let mut consumer = moq_json::Consumer::<Value>::new(consumer, consumer_config);
149	let waiter = kio::Waiter::noop();
150	let mut last = None;
151	let mut yielded = 0;
152	while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) {
153		last = Some(value);
154		yielded += 1;
155	}
156	assert_eq!(
157		last.as_ref(),
158		Some(&telemetry(ticks - 1)),
159		"late joiner reconstruction mismatch"
160	);
161	yielded
162}
163
164fn cfg(delta_ratio: u32, compression: bool) -> ProducerConfig {
165	let mut config = ProducerConfig::default();
166	config.delta_ratio = delta_ratio;
167	config.compression = compression;
168	config
169}
170
171fn main() {
172	const TICKS: u64 = 60;
173
174	// Raw baseline: every tick as a full JSON blob, no moq-json framing tricks.
175	let raw: usize = (0..TICKS)
176		.map(|t| serde_json::to_vec(&telemetry(t)).unwrap().len())
177		.sum();
178	let snapshot_len = serde_json::to_vec(&telemetry(0)).unwrap().len();
179
180	let combos = [
181		("snapshot-per-group, plaintext", cfg(0, false)),
182		("snapshot-per-group, deflate   ", cfg(0, true)),
183		("snapshot+delta,     plaintext", cfg(8, false)),
184		("snapshot+delta,     deflate   ", cfg(8, true)),
185	];
186
187	println!("Telemetry stream: {TICKS} ticks, ~{snapshot_len} bytes per snapshot\n");
188	println!("Raw JSON (one blob per tick):        {raw:>8} bytes  (baseline)\n");
189
190	println!("{:<32} {:>10} {:>10} {:>9}", "config", "wire", "vs raw", "saved");
191	println!("{}", "-".repeat(64));
192	for (name, config) in combos.clone() {
193		verify(config.clone(), TICKS);
194		verify_late_joiner(config.clone(), TICKS);
195		let bytes = wire_bytes(config, TICKS);
196		let pct = 100.0 * bytes as f64 / raw as f64;
197		let saved = 100.0 - pct;
198		println!("{name:<32} {bytes:>8} B {pct:>8.1}% {saved:>7.1}%");
199	}
200
201	println!("\nVerified: every tick reconstructs exactly (live + late joiner) for all 4 configs.");
202
203	// Late-joiner collapse: a consumer joining after all {TICKS} ticks exist gets the head in one
204	// step, not a replay of every superseded state.
205	println!("\nLate joiner: values surfaced to the app (was {TICKS} per-frame, now collapsed):");
206	for (name, config) in combos {
207		let yielded = verify_late_joiner(config, TICKS);
208		println!("  {name:<32} {yielded:>3} value(s)");
209	}
210}