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