Skip to main content

moq_json/stream/
mod.rs

1//! Append-log JSON publishing over [`moq-net`](moq_net) tracks.
2//!
3//! The counterpart to [`snapshot`](crate::snapshot) mode: instead of one JSON value updated over
4//! time, a stream is an ordered log of self-contained records. Every [`Producer::append`] writes one
5//! JSON object as one frame, and a [`Consumer`] yields every record in order.
6//!
7//! The whole log rides a **single group** that is never rolled: with
8//! [`ProducerConfig::compression`] on, that one group is one DEFLATE window, so every record
9//! compresses against all the earlier ones. There is deliberately no group rolling (and so no
10//! catch-up machinery): the only reason to roll would be moq-net's per-group frame cap, which
11//! isn't worth working around here. A caller that wants to bound the record rate throttles at
12//! the source (e.g. the timeline's granularity); a consumer that finds a gap can fetch or
13//! extrapolate.
14//!
15//! That single group is what bounds the log's history. moq-net caps a group's cached bytes, and a
16//! consumer always starts at frame 0, so once the log outgrows that budget and the earliest frames
17//! are evicted a new consumer fails with [`moq_net::Error::Lagged`] rather than reading a partial
18//! log. (With compression the retained suffix would be undecodable anyway, since its DEFLATE window
19//! depends on the evicted prefix.) The live stream is therefore bounded history by design; deep
20//! history is served from a recording.
21//!
22//! # Choosing a layer
23//!
24//! [`Producer`] and [`Consumer`] own a track. [`Encoder`] and [`Decoder`] are the same logic
25//! without it, for when something else is already in charge of the track; they carry the shared
26//! DEFLATE window and nothing else, since a log has no group boundaries to report.
27
28mod consumer;
29mod decoder;
30mod encoder;
31mod producer;
32
33pub use consumer::Consumer;
34pub use decoder::{ConsumerConfig, Decoder};
35pub use encoder::{Encoder, Pending, ProducerConfig};
36pub use producer::Producer;
37
38#[cfg(test)]
39mod test {
40	use std::task::Poll;
41
42	use serde_json::{Value, json};
43
44	use super::*;
45
46	fn producer(config: ProducerConfig) -> (Producer<Value>, moq_net::track::Subscriber) {
47		let track = moq_net::broadcast::Info::new()
48			.produce()
49			.create_track("test", None)
50			.unwrap();
51		let consumer = track.subscribe(None);
52		(Producer::new(track, config), consumer)
53	}
54
55	fn compressed() -> ProducerConfig {
56		ProducerConfig::default().with_compression(true)
57	}
58
59	fn consumer(track: moq_net::track::Subscriber, compression: bool) -> Consumer<Value> {
60		Consumer::new(track, ConsumerConfig::default().with_compression(compression))
61	}
62
63	/// Drain every record currently available without blocking.
64	fn drain(mut consumer: Consumer<Value>) -> Vec<Value> {
65		let waiter = kio::Waiter::noop();
66		let mut out = Vec::new();
67		while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) {
68			out.push(value);
69		}
70		out
71	}
72
73	#[test]
74	fn plaintext_roundtrip_in_order() {
75		let (mut producer, track) = producer(ProducerConfig::default());
76		for n in 0..5 {
77			producer.append(&json!({ "n": n })).unwrap();
78		}
79		producer.finish().unwrap();
80
81		let records = drain(consumer(track, false));
82		assert_eq!(records, (0..5).map(|n| json!({ "n": n })).collect::<Vec<_>>());
83	}
84
85	#[test]
86	fn compressed_roundtrip_in_order() {
87		let (mut producer, track) = producer(compressed());
88		for n in 0..20 {
89			producer.append(&json!({ "group": n, "pts": n * 2_000 })).unwrap();
90		}
91		producer.finish().unwrap();
92
93		let records = drain(consumer(track, true));
94		assert_eq!(records.len(), 20);
95		assert_eq!(records[7], json!({ "group": 7, "pts": 14_000 }));
96	}
97
98	#[test]
99	fn all_records_ride_one_group() {
100		let (mut producer, track) = producer(compressed());
101		for n in 0..50 {
102			producer.append(&json!({ "n": n })).unwrap();
103		}
104		producer.finish().unwrap();
105
106		// Never rolled: a single group holds the whole log.
107		assert_eq!(track.latest(), Some(0));
108		assert_eq!(drain(consumer(track, true)).len(), 50);
109	}
110
111	#[test]
112	fn live_consumer_sees_each_record() {
113		let (mut producer, track) = producer(compressed());
114		let mut consumer = consumer(track, true);
115		let waiter = kio::Waiter::noop();
116
117		for n in 0..3 {
118			producer.append(&json!({ "n": n })).unwrap();
119			match consumer.poll_next(&waiter) {
120				Poll::Ready(Ok(Some(value))) => assert_eq!(value, json!({ "n": n })),
121				other => panic!("expected record, got {other:?}"),
122			}
123		}
124		assert!(matches!(consumer.poll_next(&waiter), Poll::Pending));
125		producer.finish().unwrap();
126	}
127
128	#[test]
129	fn shared_window_shrinks_repetitive_records() {
130		let (mut producer, mut track) = producer(compressed());
131		for n in 0..8 {
132			producer.append(&json!({ "group": n, "pts": n * 2_000 })).unwrap();
133		}
134		producer.finish().unwrap();
135
136		let waiter = kio::Waiter::noop();
137		let Poll::Ready(Ok(Some(mut group))) = track.poll_next_group(&waiter) else {
138			panic!("expected a group");
139		};
140		let mut sizes = Vec::new();
141		while let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) {
142			sizes.push(frame.payload.len());
143		}
144		assert_eq!(sizes.len(), 8);
145		let raw = serde_json::to_vec(&json!({ "group": 7, "pts": 14_000 })).unwrap().len();
146		assert!(
147			*sizes.last().unwrap() < raw / 2,
148			"windowed record {} should be far below its raw size {raw}",
149			sizes.last().unwrap()
150		);
151	}
152
153	/// A record the encoder rejects must not have published a group first: a live consumer would
154	/// advance into it and wait there even though nothing was ever appended.
155	#[test]
156	fn a_rejected_record_does_not_open_a_group() {
157		// A map with non-string keys can't be represented as JSON, so serialization fails.
158		let track = moq_net::broadcast::Info::new()
159			.produce()
160			.create_track("test", None)
161			.unwrap();
162		let subscriber = track.subscribe(None);
163		let mut producer = Producer::<std::collections::BTreeMap<(u8, u8), u8>>::new(track, ProducerConfig::default());
164
165		let mut bad = std::collections::BTreeMap::new();
166		bad.insert((1, 2), 3);
167		assert!(producer.append(&bad).is_err());
168
169		assert_eq!(subscriber.latest(), None, "a rejected record opened a group");
170	}
171
172	/// A track whose timescale is extreme enough that converting a wall-clock timestamp into it
173	/// overflows, so `write_frame` rejects every frame. That stands in for any post-`append_group`
174	/// write failure (the reported one is a frame over moq-net's 32 MB per-group cache) without
175	/// allocating 32 MB to provoke it.
176	fn rejecting_track() -> moq_net::track::Producer {
177		let mut info = moq_net::track::Info::default();
178		info.timescale = moq_net::Timescale::new((1u64 << 62) - 1).unwrap();
179
180		moq_net::broadcast::Info::new()
181			.produce()
182			.create_track("test", Some(info))
183			.unwrap()
184	}
185
186	/// Same as the snapshot case: the log's group is published by `open`, so a record the track
187	/// rejects must not leave it open with nothing in it.
188	#[test]
189	fn a_rejected_record_does_not_strand_an_empty_group() {
190		let track = rejecting_track();
191		let mut subscriber = track.subscribe(None);
192		let mut producer = Producer::<Value>::new(track, ProducerConfig::default());
193
194		assert!(producer.append(&json!({ "n": 1 })).is_err());
195
196		let waiter = kio::Waiter::noop();
197		let Poll::Ready(Ok(Some(mut group))) = subscriber.poll_next_group(&waiter) else {
198			panic!("the group was published, so a subscriber sees it");
199		};
200		assert!(
201			matches!(group.poll_read_frame(&waiter), Poll::Ready(Ok(None))),
202			"the empty group must be closed, not left open for a subscriber to wait in"
203		);
204	}
205
206	/// Closing the rejected group is only half the recovery. The record that never landed desyncs a
207	/// compressed encoder, so without a matching reset every later append fails with
208	/// [`Error::Desync`](crate::Error::Desync) before it can use the fresh group that closing prepared.
209	#[test]
210	fn a_rejected_record_leaves_the_encoder_able_to_retry() {
211		let track = rejecting_track();
212		let mut producer = Producer::<Value>::new(track, ProducerConfig::default().with_compression(true));
213
214		assert!(matches!(producer.append(&json!({ "n": 1 })), Err(crate::Error::Net(_))));
215
216		// The retry fails on the same track, but it has to fail for the same reason: a desync here
217		// would mean the producer had latched itself shut instead of starting a new group.
218		assert!(
219			matches!(producer.append(&json!({ "n": 2 })), Err(crate::Error::Net(_))),
220			"the encoder latched a desync instead of retrying into a fresh group"
221		);
222	}
223
224	#[test]
225	fn embedded_newlines_survive() {
226		// Each record is its own frame (one JSON object), and JSON escapes control characters, so a
227		// string value containing a newline round-trips cleanly.
228		let (mut producer, track) = producer(compressed());
229		let value = json!({ "s": "line1\nline2\ttab", "u": "a\u{000a}b" });
230		for _ in 0..4 {
231			producer.append(&value).unwrap();
232		}
233		producer.finish().unwrap();
234
235		let records = drain(consumer(track, true));
236		assert_eq!(records, vec![value.clone(), value.clone(), value.clone(), value]);
237	}
238}