Skip to main content

moq_json/
stream.rs

1//! Append-log JSON publishing over [`moq-net`](moq_net) tracks.
2//!
3//! The counterpart to the crate root's snapshot/delta ("object") mode: instead of one JSON
4//! value updated over time, a stream is an ordered log of self-contained records. Every
5//! [`Producer::append`] writes one JSON object as one frame, and a [`Consumer`] yields every
6//! record in order.
7//!
8//! The whole log rides a **single group** that is never rolled: with
9//! [`ProducerConfig::compression`] on, that one 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 granularity); a consumer that finds a gap can fetch or
14//! extrapolate. A late joiner reads whatever frames the relay still retains for the group;
15//! deep history is served from a recording, not this live stream.
16
17use std::marker::PhantomData;
18use std::sync::{Arc, Mutex};
19use std::task::Poll;
20
21use bytes::Bytes;
22use moq_flate::{Decoder, Encoder};
23use serde::Serialize;
24use serde::de::DeserializeOwned;
25
26use crate::Result;
27
28/// Configuration for a stream [`Producer`].
29///
30/// Build from [`Default`] and override fields (the struct is `#[non_exhaustive]`, so new
31/// options stay additive).
32#[derive(Debug, Clone, Default)]
33#[non_exhaustive]
34pub struct ProducerConfig {
35	/// Compress the group as one sync-flushed DEFLATE stream, so each record reuses the earlier
36	/// ones as context and shrinks sharply.
37	///
38	/// `false` (the default) writes plaintext JSON frames. A [`Consumer`] reading the track must
39	/// set [`ConsumerConfig::compression`] to match.
40	pub compression: bool,
41}
42
43impl ProducerConfig {
44	/// Set [`compression`](Self::compression) (a builder, since the struct is `#[non_exhaustive]`).
45	pub fn with_compression(mut self, compression: bool) -> Self {
46		self.compression = compression;
47		self
48	}
49}
50
51/// Configuration for a stream [`Consumer`].
52///
53/// Build from [`Default`] and override fields (the struct is `#[non_exhaustive]`, so new options
54/// stay additive).
55#[derive(Debug, Clone, Default)]
56#[non_exhaustive]
57pub struct ConsumerConfig {
58	/// Whether the track's frames are DEFLATE-compressed. Must match the producer's
59	/// [`ProducerConfig::compression`]. Defaults to `false`.
60	pub compression: bool,
61}
62
63impl ConsumerConfig {
64	/// Set [`compression`](Self::compression) (a builder, since the struct is `#[non_exhaustive]`).
65	pub fn with_compression(mut self, compression: bool) -> Self {
66		self.compression = compression;
67		self
68	}
69}
70
71/// Publishes an ordered log of JSON records over a track, one record per frame in a single group.
72///
73/// Cheaply clonable: clones share one underlying track and publishing state, so multiple owners
74/// (e.g. several producers feeding one log) append into a single ordered stream.
75pub struct Producer<T> {
76	inner: Arc<Mutex<Inner>>,
77	_marker: PhantomData<fn(T)>,
78}
79
80impl<T> Clone for Producer<T> {
81	fn clone(&self) -> Self {
82		Self {
83			inner: self.inner.clone(),
84			_marker: PhantomData,
85		}
86	}
87}
88
89impl<T> Producer<T> {
90	/// Create a subscriber for the underlying track.
91	pub fn consume(&self) -> moq_net::TrackConsumer {
92		self.inner.lock().unwrap().track.consume()
93	}
94}
95
96impl<T: Serialize> Producer<T> {
97	/// Create a producer that publishes to the given track.
98	pub fn new(track: moq_net::TrackProducer, config: ProducerConfig) -> Self {
99		Self {
100			inner: Arc::new(Mutex::new(Inner {
101				track,
102				group: None,
103				encoder: None,
104				config,
105			})),
106			_marker: PhantomData,
107		}
108	}
109
110	/// Append one record to the log.
111	pub fn append(&mut self, value: &T) -> Result<()> {
112		self.inner.lock().unwrap().append(value)
113	}
114
115	/// Finish the track, closing the group.
116	pub fn finish(&mut self) -> Result<()> {
117		self.inner.lock().unwrap().finish()
118	}
119}
120
121/// Shared publishing state behind [`Producer`]'s `Arc<Mutex>`.
122struct Inner {
123	track: moq_net::TrackProducer,
124	// The single group carrying the whole log, opened on the first append.
125	group: Option<moq_net::GroupProducer>,
126	// The group's DEFLATE encoder (one window for the whole log), `Some` while compressing.
127	encoder: Option<Encoder>,
128	config: ProducerConfig,
129}
130
131impl Inner {
132	fn append<T: Serialize>(&mut self, value: &T) -> Result<()> {
133		let payload = Bytes::from(serde_json::to_vec(value)?);
134
135		if self.group.is_none() {
136			self.group = Some(self.track.append_group()?);
137			self.encoder = self.config.compression.then(Encoder::new);
138		}
139
140		let slice = match self.encoder.as_mut() {
141			Some(encoder) => encoder.frame(&payload),
142			None => payload,
143		};
144		self.group.as_mut().expect("a group is open").write_frame(slice)?;
145		Ok(())
146	}
147
148	fn finish(&mut self) -> Result<()> {
149		if let Some(mut group) = self.group.take() {
150			group.finish()?;
151		}
152		self.track.finish()?;
153		Ok(())
154	}
155}
156
157/// Consumes an ordered log of JSON records from a track, yielding every record in order.
158///
159/// The log rides a single group, so this reads that group's frames in order; one record per frame.
160pub struct Consumer<T> {
161	track: moq_net::TrackConsumer,
162	group: Option<moq_net::GroupConsumer>,
163	compressed: bool,
164	// The group's DEFLATE decoder (one window for the whole log), built on the first frame.
165	decoder: Option<Decoder>,
166	_marker: PhantomData<fn() -> T>,
167}
168
169impl<T: DeserializeOwned> Consumer<T> {
170	/// Create a consumer reading from the given track subscriber.
171	///
172	/// Set [`ConsumerConfig::compression`] to read a track written by a producer with
173	/// [`ProducerConfig::compression`] on.
174	pub fn new(track: moq_net::TrackConsumer, config: ConsumerConfig) -> Self {
175		Self {
176			track,
177			group: None,
178			compressed: config.compression,
179			decoder: None,
180			_marker: PhantomData,
181		}
182	}
183
184	/// Get the next record, or `None` once the track ends.
185	pub async fn next(&mut self) -> Result<Option<T>>
186	where
187		T: Unpin,
188	{
189		kio::wait(|waiter| self.poll_next(waiter)).await
190	}
191
192	/// Poll for the next record, without blocking.
193	pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll<Result<Option<T>>> {
194		loop {
195			let Some(group) = &mut self.group else {
196				match self.track.poll_next_group(waiter)? {
197					Poll::Ready(Some(group)) => {
198						self.decoder = self.compressed.then(Decoder::new);
199						self.group = Some(group);
200						continue;
201					}
202					Poll::Ready(None) => return Poll::Ready(Ok(None)),
203					Poll::Pending => return Poll::Pending,
204				}
205			};
206
207			match group.poll_read_frame(waiter)? {
208				Poll::Ready(Some(frame)) => {
209					let plain = match self.decoder.as_mut() {
210						Some(decoder) => decoder.frame(&frame)?,
211						None => frame,
212					};
213					return Poll::Ready(Ok(Some(serde_json::from_slice(&plain)?)));
214				}
215				Poll::Ready(None) => {
216					// The group is finished; the log rides just this one, so the next poll for a
217					// group ends the stream.
218					self.group = None;
219					self.decoder = None;
220				}
221				Poll::Pending => return Poll::Pending,
222			}
223		}
224	}
225}
226
227#[cfg(test)]
228mod test {
229	use super::*;
230	use serde_json::{Value, json};
231
232	fn producer(config: ProducerConfig) -> (Producer<Value>, moq_net::TrackConsumer) {
233		let track = moq_net::Track::new("test").produce();
234		let consumer = track.consume();
235		(Producer::new(track, config), consumer)
236	}
237
238	fn compressed() -> ProducerConfig {
239		ProducerConfig { compression: true }
240	}
241
242	fn consumer(track: moq_net::TrackConsumer, compression: bool) -> Consumer<Value> {
243		Consumer::new(track, ConsumerConfig { compression })
244	}
245
246	/// Drain every record currently available without blocking.
247	fn drain(mut consumer: Consumer<Value>) -> Vec<Value> {
248		let waiter = kio::Waiter::noop();
249		let mut out = Vec::new();
250		while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) {
251			out.push(value);
252		}
253		out
254	}
255
256	#[test]
257	fn plaintext_roundtrip_in_order() {
258		let (mut producer, track) = producer(ProducerConfig::default());
259		for n in 0..5 {
260			producer.append(&json!({ "n": n })).unwrap();
261		}
262		producer.finish().unwrap();
263
264		let records = drain(consumer(track, false));
265		assert_eq!(records, (0..5).map(|n| json!({ "n": n })).collect::<Vec<_>>());
266	}
267
268	#[test]
269	fn compressed_roundtrip_in_order() {
270		let (mut producer, track) = producer(compressed());
271		for n in 0..20 {
272			producer.append(&json!({ "group": n, "pts": n * 2_000 })).unwrap();
273		}
274		producer.finish().unwrap();
275
276		let records = drain(consumer(track, true));
277		assert_eq!(records.len(), 20);
278		assert_eq!(records[7], json!({ "group": 7, "pts": 14_000 }));
279	}
280
281	#[test]
282	fn all_records_ride_one_group() {
283		let (mut producer, track) = producer(compressed());
284		for n in 0..50 {
285			producer.append(&json!({ "n": n })).unwrap();
286		}
287		producer.finish().unwrap();
288
289		// Never rolled: a single group holds the whole log.
290		assert_eq!(track.latest(), Some(0));
291		assert_eq!(drain(consumer(track, true)).len(), 50);
292	}
293
294	#[test]
295	fn live_consumer_sees_each_record() {
296		let (mut producer, track) = producer(compressed());
297		let mut consumer = consumer(track, true);
298		let waiter = kio::Waiter::noop();
299
300		for n in 0..3 {
301			producer.append(&json!({ "n": n })).unwrap();
302			match consumer.poll_next(&waiter) {
303				Poll::Ready(Ok(Some(value))) => assert_eq!(value, json!({ "n": n })),
304				other => panic!("expected record, got {other:?}"),
305			}
306		}
307		assert!(matches!(consumer.poll_next(&waiter), Poll::Pending));
308		producer.finish().unwrap();
309	}
310
311	#[test]
312	fn shared_window_shrinks_repetitive_records() {
313		let (mut producer, mut track) = producer(compressed());
314		for n in 0..8 {
315			producer.append(&json!({ "group": n, "pts": n * 2_000 })).unwrap();
316		}
317		producer.finish().unwrap();
318
319		let waiter = kio::Waiter::noop();
320		let Poll::Ready(Ok(Some(mut group))) = track.poll_next_group(&waiter) else {
321			panic!("expected a group");
322		};
323		let mut sizes = Vec::new();
324		while let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) {
325			sizes.push(frame.len());
326		}
327		assert_eq!(sizes.len(), 8);
328		let raw = serde_json::to_vec(&json!({ "group": 7, "pts": 14_000 })).unwrap().len();
329		assert!(
330			*sizes.last().unwrap() < raw / 2,
331			"windowed record {} should be far below its raw size {raw}",
332			sizes.last().unwrap()
333		);
334	}
335
336	#[test]
337	fn embedded_newlines_survive() {
338		// Each record is its own frame (one JSON object), and JSON escapes control characters, so a
339		// string value containing a newline round-trips cleanly.
340		let (mut producer, track) = producer(compressed());
341		let value = json!({ "s": "line1\nline2\ttab", "u": "a\u{000a}b" });
342		for _ in 0..4 {
343			producer.append(&value).unwrap();
344		}
345		producer.finish().unwrap();
346
347		let records = drain(consumer(track, true));
348		assert_eq!(records, vec![value.clone(), value.clone(), value.clone(), value]);
349	}
350}