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	/// Demand follows the track's subscribers, so a producer can idle while nobody is watching.
62	#[test]
63	fn demand_follows_subscribers() {
64		let (producer, consumer) = producer(Config::default());
65		let demand = producer.demand();
66		let waiter = kio::Waiter::noop();
67		assert!(matches!(demand.poll_used(&waiter), Poll::Ready(Ok(()))));
68
69		drop(consumer);
70		assert!(matches!(demand.poll_unused(&waiter), Poll::Ready(Ok(()))));
71		assert!(demand.poll_used(&waiter).is_pending());
72
73		let _consumer = producer.consume();
74		assert!(matches!(demand.poll_used(&waiter), Poll::Ready(Ok(()))));
75		assert!(demand.poll_unused(&waiter).is_pending());
76	}
77
78	fn compressed() -> Config {
79		Config {
80			compression: Compression::Deflate,
81		}
82	}
83
84	fn consume(track: moq_net::track::Subscriber, compression: bool) -> Consumer<Value> {
85		Consumer::new(
86			track,
87			Config {
88				compression: if compression {
89					Compression::Deflate
90				} else {
91					Compression::None
92				},
93			},
94		)
95	}
96
97	/// Drain every record currently available without blocking.
98	fn drain(mut consumer: Consumer<Value>) -> Vec<Value> {
99		let waiter = kio::Waiter::noop();
100		let mut out = Vec::new();
101		while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) {
102			out.push(value);
103		}
104		out
105	}
106
107	#[test]
108	fn plaintext_roundtrip_in_order() {
109		let (mut producer, track) = producer(Config::default());
110		for n in 0..5 {
111			producer.append(&json!({ "n": n })).unwrap();
112		}
113		producer.finish().unwrap();
114
115		let records = drain(consume(track, false));
116		assert_eq!(records, (0..5).map(|n| json!({ "n": n })).collect::<Vec<_>>());
117	}
118
119	#[test]
120	fn compressed_roundtrip_in_order() {
121		let (mut producer, track) = producer(compressed());
122		for n in 0..20 {
123			producer.append(&json!({ "group": n, "pts": n * 2_000 })).unwrap();
124		}
125		producer.finish().unwrap();
126
127		let records = drain(consume(track, true));
128		assert_eq!(records.len(), 20);
129		assert_eq!(records[7], json!({ "group": 7, "pts": 14_000 }));
130	}
131
132	#[test]
133	fn all_records_ride_one_group() {
134		let (mut producer, track) = producer(compressed());
135		for n in 0..50 {
136			producer.append(&json!({ "n": n })).unwrap();
137		}
138		producer.finish().unwrap();
139
140		// Never rolled: a single group holds the whole log.
141		assert_eq!(track.latest(), Some(0));
142		assert_eq!(drain(consume(track, true)).len(), 50);
143	}
144
145	#[test]
146	fn live_consumer_sees_each_record() {
147		let (mut producer, track) = producer(compressed());
148		let mut consumer = consume(track, true);
149		let waiter = kio::Waiter::noop();
150
151		for n in 0..3 {
152			producer.append(&json!({ "n": n })).unwrap();
153			match consumer.poll_next(&waiter) {
154				Poll::Ready(Ok(Some(value))) => assert_eq!(value, json!({ "n": n })),
155				other => panic!("expected record, got {other:?}"),
156			}
157		}
158		assert!(matches!(consumer.poll_next(&waiter), Poll::Pending));
159		producer.finish().unwrap();
160	}
161
162	#[test]
163	fn shared_window_shrinks_repetitive_records() {
164		let (mut producer, track) = producer(compressed());
165		for n in 0..8 {
166			producer.append(&json!({ "group": n, "pts": n * 2_000 })).unwrap();
167		}
168		producer.finish().unwrap();
169
170		let waiter = kio::Waiter::noop();
171		let mut track = track.ordered();
172		let Poll::Ready(Ok(Some(mut group))) = track.poll_next_group(&waiter) else {
173			panic!("expected a group");
174		};
175		let mut sizes = Vec::new();
176		while let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) {
177			sizes.push(frame.payload.len());
178		}
179		assert_eq!(sizes.len(), 8);
180		let raw = serde_json::to_vec(&json!({ "group": 7, "pts": 14_000 })).unwrap().len();
181		assert!(
182			*sizes.last().unwrap() < raw / 2,
183			"windowed record {} should be far below its raw size {raw}",
184			sizes.last().unwrap()
185		);
186	}
187
188	/// A record the encoder rejects must not have published a group first: a live consumer would
189	/// advance into it and wait there even though nothing was ever appended. It still ends the
190	/// track, since the log is missing the record either way.
191	#[test]
192	fn a_rejected_record_does_not_open_a_group() {
193		// A map with non-string keys can't be represented as JSON, so serialization fails.
194		let track = moq_net::broadcast::Info::new()
195			.produce()
196			.create_track("test", None)
197			.unwrap();
198		let mut subscriber = track.subscribe(None);
199		let mut producer = Producer::<std::collections::BTreeMap<(u8, u8), u8>>::new(track, Config::default());
200
201		let mut bad = std::collections::BTreeMap::new();
202		bad.insert((1, 2), 3);
203		assert!(producer.append(&bad).is_err());
204
205		assert_eq!(subscriber.latest(), None, "a rejected record opened a group");
206
207		let waiter = kio::Waiter::noop();
208		assert!(
209			matches!(subscriber.poll_recv_group(&waiter), Poll::Ready(Err(_))),
210			"the log is missing a record, so the track must end rather than stay writable"
211		);
212	}
213
214	/// A track whose timescale is extreme enough that converting a wall-clock timestamp into it
215	/// overflows, so `write_frame` rejects every frame. That stands in for any post-`append_group`
216	/// write failure (the reported one is a frame over moq-net's 32 MB per-group cache) without
217	/// allocating 32 MB to provoke it.
218	fn rejecting_track() -> moq_net::track::Producer {
219		let mut info = moq_net::track::Info::default();
220		info.timescale = moq_net::Timescale::new((1u64 << 62) - 1).unwrap();
221
222		moq_net::broadcast::Info::new()
223			.produce()
224			.create_track("test", Some(info))
225			.unwrap()
226	}
227
228	/// A failed write must reach the consumer, not just the caller. A clean close drains a reader to
229	/// `None`, which is exactly what a completed log looks like, so a truncated log would be
230	/// indistinguishable from a whole one.
231	#[test]
232	fn a_failed_write_aborts_the_track() {
233		let track = rejecting_track();
234		let mut subscriber = track.subscribe(None);
235		let mut producer = Producer::<Value>::new(track, Config::default());
236
237		assert!(matches!(producer.append(&json!({ "n": 1 })), Err(crate::Error::Net(_))));
238
239		let waiter = kio::Waiter::noop();
240		assert!(
241			matches!(subscriber.poll_recv_group(&waiter), Poll::Ready(Err(_))),
242			"a truncated log must surface an error rather than read as a completed one"
243		);
244	}
245
246	/// The track ends with the group, so nothing opens a second one and splits the log. The retry
247	/// reports the ended track rather than the [`Error::Desync`](crate::Error::Desync) the dropped
248	/// record left on the encoder, which says nothing about why the log stopped.
249	#[test]
250	fn a_failed_write_ends_the_track() {
251		let track = rejecting_track();
252		let mut producer = Producer::<Value>::new(track, compressed());
253
254		assert!(matches!(producer.append(&json!({ "n": 1 })), Err(crate::Error::Net(_))));
255
256		// The retry reports the abort rather than the `Error::Desync` the dropped record left on the
257		// encoder, which says nothing about why the log stopped.
258		assert!(
259			matches!(producer.append(&json!({ "n": 2 })), Err(crate::Error::Net(_))),
260			"a second append must fail on the ended track rather than open another group"
261		);
262
263		// A subscriber taken after the abort still exists; it surfaces the failure on its first read,
264		// which is how a late reader learns the log is truncated.
265		let waiter = kio::Waiter::noop();
266		assert!(matches!(
267			producer.consume().poll_recv_group(&waiter),
268			Poll::Ready(Err(_))
269		));
270	}
271
272	/// A completed log is still readable, so finishing must not end the track the way an abort does.
273	/// The append that follows fails on the closed track without turning it into a failure.
274	#[test]
275	fn appending_after_finish_fails_without_aborting() {
276		let (mut producer, _track) = producer(compressed());
277		producer.append(&json!({ "n": 0 })).unwrap();
278		producer.finish().unwrap();
279
280		assert!(producer.append(&json!({ "n": 1 })).is_err());
281		assert_eq!(drain(consume(producer.consume(), true)), vec![json!({ "n": 0 })]);
282	}
283
284	/// A stream is one group. A publisher that opens a second lost whatever would have completed the
285	/// first, so the read reports that rather than handing back the remainder as a continuous log.
286	/// A boundary-only check would never look at the track again while the first group is open, so
287	/// this parks forever without the eager check. Written by hand because this producer never rolls.
288	#[test]
289	fn a_second_group_is_reported_while_the_first_is_open() {
290		let track = moq_net::broadcast::Info::new()
291			.produce()
292			.create_track("test", None)
293			.unwrap();
294
295		// Ask for a replay window, so the first group is delivered rather than skipped by the
296		// subscriber's default max-age budget once a newer group exists.
297		let subscription = moq_net::track::Subscription::default().with_max_age(std::time::Duration::from_secs(30));
298		let subscriber = track.subscribe(subscription);
299
300		// Both groups stay open, the way a publisher writing to two at once leaves them.
301		let mut first = track.append_group().unwrap();
302		first
303			.write_frame(moq_net::Timestamp::now(), br#"{"n":0}"#.as_slice())
304			.unwrap();
305		let mut second = track.append_group().unwrap();
306		second
307			.write_frame(moq_net::Timestamp::now(), br#"{"n":1}"#.as_slice())
308			.unwrap();
309
310		let mut consumer = consume(subscriber, false);
311		let waiter = kio::Waiter::noop();
312
313		assert!(matches!(
314			consumer.poll_next(&waiter),
315			Poll::Ready(Ok(Some(value))) if value == json!({ "n": 0 })
316		));
317		assert!(matches!(
318			consumer.poll_next(&waiter),
319			Poll::Ready(Err(crate::Error::Rolled))
320		));
321
322		// Sticky: a later read must not report the rest of the first group as a whole log.
323		first
324			.write_frame(moq_net::Timestamp::now(), br#"{"n":2}"#.as_slice())
325			.unwrap();
326		assert!(matches!(
327			consumer.poll_next(&waiter),
328			Poll::Ready(Err(crate::Error::Rolled))
329		));
330	}
331
332	#[test]
333	fn embedded_newlines_survive() {
334		// Each record is its own frame (one JSON object), and JSON escapes control characters, so a
335		// string value containing a newline round-trips cleanly.
336		let (mut producer, track) = producer(compressed());
337		let value = json!({ "s": "line1\nline2\ttab", "u": "a\u{000a}b" });
338		for _ in 0..4 {
339			producer.append(&value).unwrap();
340		}
341		producer.finish().unwrap();
342
343		let records = drain(consume(track, true));
344		assert_eq!(records, vec![value.clone(), value.clone(), value.clone(), value]);
345	}
346}