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//! [`Config::compression`] set to [`crate::Compression::Deflate`], that one
9//! group is one DEFLATE window, so every record
10//! compresses against all the earlier ones. There is deliberately no group rolling (and so no
11//! catch-up machinery): the only reason to roll would be moq-net's per-group frame cap, which
12//! isn't worth working around here. A caller that wants to bound the record rate throttles at
13//! the source (e.g. the timeline's segment cadence); a consumer that finds a gap can fetch or
14//! extrapolate.
15//!
16//! A record that cannot be encoded or written therefore ends the track rather than continuing in a
17//! second group: a log missing a record is not lossless, and a gap dressed up as a complete log is
18//! worse than a visible failure. A publisher with more to say opens a new track.
19//!
20//! That single group is what bounds the log's history. moq-net caps a group's cached bytes and
21//! frame count, and a consumer always starts at frame 0, so a write that would outgrow the
22//! budget aborts the group with [`moq_net::Error::GroupTooLarge`] rather than dropping a prefix
23//! some readers missed. (With compression the retained suffix would be undecodable anyway,
24//! since its DEFLATE window depends on the dropped prefix.) The live stream is therefore
25//! bounded history by design; deep history is served from a recording.
26//!
27//! # Choosing a layer
28//!
29//! [`Producer`] and [`Consumer`] own a track. [`Encoder`] and [`Decoder`] are the same logic
30//! without it, for when something else is already in charge of the track; they carry the shared
31//! DEFLATE window and nothing else, since a log has no group boundaries to report.
32
33pub mod consumer;
34mod decoder;
35mod encoder;
36pub mod producer;
37
38pub use consumer::Consumer;
39pub use decoder::Decoder;
40pub use encoder::{Config, Encoder, Pending};
41pub use producer::Producer;
42
43#[cfg(test)]
44mod test {
45	use std::task::Poll;
46
47	use serde_json::{Value, json};
48
49	use super::*;
50	use crate::Compression;
51
52	fn producer(config: Config) -> (Producer<Value>, moq_net::track::Subscriber) {
53		let track = moq_net::broadcast::Info::new()
54			.produce()
55			.create_track("test", None)
56			.unwrap();
57		let consumer = track.subscribe(None);
58		(Producer::new(track, config), consumer)
59	}
60
61	fn compressed() -> Config {
62		Config {
63			compression: Compression::Deflate,
64		}
65	}
66
67	fn consume(track: moq_net::track::Subscriber, compression: bool) -> Consumer<Value> {
68		Consumer::new(
69			track,
70			Config {
71				compression: if compression {
72					Compression::Deflate
73				} else {
74					Compression::None
75				},
76			},
77		)
78	}
79
80	/// Drain every record currently available without blocking.
81	fn drain(mut consumer: Consumer<Value>) -> Vec<Value> {
82		let waiter = kio::Waiter::noop();
83		let mut out = Vec::new();
84		while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) {
85			out.push(value);
86		}
87		out
88	}
89
90	#[test]
91	fn plaintext_roundtrip_in_order() {
92		let (mut producer, track) = producer(Config::default());
93		for n in 0..5 {
94			producer.append(&json!({ "n": n })).unwrap();
95		}
96		producer.finish().unwrap();
97
98		let records = drain(consume(track, false));
99		assert_eq!(records, (0..5).map(|n| json!({ "n": n })).collect::<Vec<_>>());
100	}
101
102	#[test]
103	fn compressed_roundtrip_in_order() {
104		let (mut producer, track) = producer(compressed());
105		for n in 0..20 {
106			producer.append(&json!({ "group": n, "pts": n * 2_000 })).unwrap();
107		}
108		producer.finish().unwrap();
109
110		let records = drain(consume(track, true));
111		assert_eq!(records.len(), 20);
112		assert_eq!(records[7], json!({ "group": 7, "pts": 14_000 }));
113	}
114
115	#[test]
116	fn all_records_ride_one_group() {
117		let (mut producer, track) = producer(compressed());
118		for n in 0..50 {
119			producer.append(&json!({ "n": n })).unwrap();
120		}
121		producer.finish().unwrap();
122
123		// Never rolled: a single group holds the whole log.
124		assert_eq!(track.latest(), Some(0));
125		assert_eq!(drain(consume(track, true)).len(), 50);
126	}
127
128	#[test]
129	fn live_consumer_sees_each_record() {
130		let (mut producer, track) = producer(compressed());
131		let mut consumer = consume(track, true);
132		let waiter = kio::Waiter::noop();
133
134		for n in 0..3 {
135			producer.append(&json!({ "n": n })).unwrap();
136			match consumer.poll_next(&waiter) {
137				Poll::Ready(Ok(Some(value))) => assert_eq!(value, json!({ "n": n })),
138				other => panic!("expected record, got {other:?}"),
139			}
140		}
141		assert!(matches!(consumer.poll_next(&waiter), Poll::Pending));
142		producer.finish().unwrap();
143	}
144
145	#[test]
146	fn shared_window_shrinks_repetitive_records() {
147		let (mut producer, track) = producer(compressed());
148		for n in 0..8 {
149			producer.append(&json!({ "group": n, "pts": n * 2_000 })).unwrap();
150		}
151		producer.finish().unwrap();
152
153		let waiter = kio::Waiter::noop();
154		let mut track = track.ordered();
155		let Poll::Ready(Ok(Some(mut group))) = track.poll_next_group(&waiter) else {
156			panic!("expected a group");
157		};
158		let mut sizes = Vec::new();
159		while let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) {
160			sizes.push(frame.payload.len());
161		}
162		assert_eq!(sizes.len(), 8);
163		let raw = serde_json::to_vec(&json!({ "group": 7, "pts": 14_000 })).unwrap().len();
164		assert!(
165			*sizes.last().unwrap() < raw / 2,
166			"windowed record {} should be far below its raw size {raw}",
167			sizes.last().unwrap()
168		);
169	}
170
171	/// A record the encoder rejects must not have published a group first: a live consumer would
172	/// advance into it and wait there even though nothing was ever appended. It still ends the
173	/// track, since the log is missing the record either way.
174	#[test]
175	fn a_rejected_record_does_not_open_a_group() {
176		// A map with non-string keys can't be represented as JSON, so serialization fails.
177		let track = moq_net::broadcast::Info::new()
178			.produce()
179			.create_track("test", None)
180			.unwrap();
181		let mut subscriber = track.subscribe(None);
182		let mut producer = Producer::<std::collections::BTreeMap<(u8, u8), u8>>::new(track, Config::default());
183
184		let mut bad = std::collections::BTreeMap::new();
185		bad.insert((1, 2), 3);
186		assert!(producer.append(&bad).is_err());
187
188		assert_eq!(subscriber.latest(), None, "a rejected record opened a group");
189
190		let waiter = kio::Waiter::noop();
191		assert!(
192			matches!(subscriber.poll_recv_group(&waiter), Poll::Ready(Err(_))),
193			"the log is missing a record, so the track must end rather than stay writable"
194		);
195	}
196
197	/// A track whose timescale is extreme enough that converting a wall-clock timestamp into it
198	/// overflows, so `write_frame` rejects every frame. That stands in for any post-`append_group`
199	/// write failure (the reported one is a frame over moq-net's 32 MB per-group cache) without
200	/// allocating 32 MB to provoke it.
201	fn rejecting_track() -> moq_net::track::Producer {
202		let mut info = moq_net::track::Info::default();
203		info.timescale = moq_net::Timescale::new((1u64 << 62) - 1).unwrap();
204
205		moq_net::broadcast::Info::new()
206			.produce()
207			.create_track("test", Some(info))
208			.unwrap()
209	}
210
211	/// A failed write must reach the consumer, not just the caller. A clean close drains a reader to
212	/// `None`, which is exactly what a completed log looks like, so a truncated log would be
213	/// indistinguishable from a whole one.
214	#[test]
215	fn a_failed_write_aborts_the_track() {
216		let track = rejecting_track();
217		let mut subscriber = track.subscribe(None);
218		let mut producer = Producer::<Value>::new(track, Config::default());
219
220		assert!(matches!(producer.append(&json!({ "n": 1 })), Err(crate::Error::Net(_))));
221
222		let waiter = kio::Waiter::noop();
223		assert!(
224			matches!(subscriber.poll_recv_group(&waiter), Poll::Ready(Err(_))),
225			"a truncated log must surface an error rather than read as a completed one"
226		);
227	}
228
229	/// The track ends with the group, so nothing opens a second one and splits the log. The retry
230	/// reports the ended track rather than the [`Error::Desync`](crate::Error::Desync) the dropped
231	/// record left on the encoder, which says nothing about why the log stopped.
232	#[test]
233	fn a_failed_write_ends_the_track() {
234		let track = rejecting_track();
235		let mut producer = Producer::<Value>::new(track, compressed());
236
237		assert!(matches!(producer.append(&json!({ "n": 1 })), Err(crate::Error::Net(_))));
238
239		// The retry reports the abort rather than the `Error::Desync` the dropped record left on the
240		// encoder, which says nothing about why the log stopped.
241		assert!(
242			matches!(producer.append(&json!({ "n": 2 })), Err(crate::Error::Net(_))),
243			"a second append must fail on the ended track rather than open another group"
244		);
245
246		// A subscriber taken after the abort still exists; it surfaces the failure on its first read,
247		// which is how a late reader learns the log is truncated.
248		let waiter = kio::Waiter::noop();
249		assert!(matches!(
250			producer.consume().poll_recv_group(&waiter),
251			Poll::Ready(Err(_))
252		));
253	}
254
255	/// A completed log is still readable, so finishing must not end the track the way an abort does.
256	/// The append that follows fails on the closed track without turning it into a failure.
257	#[test]
258	fn appending_after_finish_fails_without_aborting() {
259		let (mut producer, _track) = producer(compressed());
260		producer.append(&json!({ "n": 0 })).unwrap();
261		producer.finish().unwrap();
262
263		assert!(producer.append(&json!({ "n": 1 })).is_err());
264		assert_eq!(drain(consume(producer.consume(), true)), vec![json!({ "n": 0 })]);
265	}
266
267	/// A stream is one group. A publisher that opens a second lost whatever would have completed the
268	/// first, so the read reports that rather than handing back the remainder as a continuous log.
269	/// A boundary-only check would never look at the track again while the first group is open, so
270	/// this parks forever without the eager check. Written by hand because this producer never rolls.
271	#[test]
272	fn a_second_group_is_reported_while_the_first_is_open() {
273		let track = moq_net::broadcast::Info::new()
274			.produce()
275			.create_track("test", None)
276			.unwrap();
277
278		// Ask for a replay window, so the first group is delivered rather than skipped by the
279		// subscriber's default max-age budget once a newer group exists.
280		let subscription = moq_net::track::Subscription::default().with_max_age(std::time::Duration::from_secs(30));
281		let subscriber = track.subscribe(subscription);
282
283		// Both groups stay open, the way a publisher writing to two at once leaves them.
284		let mut first = track.append_group().unwrap();
285		first
286			.write_frame(moq_net::Timestamp::now(), br#"{"n":0}"#.as_slice())
287			.unwrap();
288		let mut second = track.append_group().unwrap();
289		second
290			.write_frame(moq_net::Timestamp::now(), br#"{"n":1}"#.as_slice())
291			.unwrap();
292
293		let mut consumer = consume(subscriber, false);
294		let waiter = kio::Waiter::noop();
295
296		assert!(matches!(
297			consumer.poll_next(&waiter),
298			Poll::Ready(Ok(Some(value))) if value == json!({ "n": 0 })
299		));
300		assert!(matches!(
301			consumer.poll_next(&waiter),
302			Poll::Ready(Err(crate::Error::Rolled))
303		));
304
305		// Sticky: a later read must not report the rest of the first group as a whole log.
306		first
307			.write_frame(moq_net::Timestamp::now(), br#"{"n":2}"#.as_slice())
308			.unwrap();
309		assert!(matches!(
310			consumer.poll_next(&waiter),
311			Poll::Ready(Err(crate::Error::Rolled))
312		));
313	}
314
315	#[test]
316	fn embedded_newlines_survive() {
317		// Each record is its own frame (one JSON object), and JSON escapes control characters, so a
318		// string value containing a newline round-trips cleanly.
319		let (mut producer, track) = producer(compressed());
320		let value = json!({ "s": "line1\nline2\ttab", "u": "a\u{000a}b" });
321		for _ in 0..4 {
322			producer.append(&value).unwrap();
323		}
324		producer.finish().unwrap();
325
326		let records = drain(consume(track, true));
327		assert_eq!(records, vec![value.clone(), value.clone(), value.clone(), value]);
328	}
329}