Skip to main content

moq_json/
lib.rs

1//! Snapshot/delta JSON publishing over [`moq-net`](moq_net) tracks.
2//!
3//! A JSON value is published over a track as a series of groups, where each group is
4//! self-contained: its first frame is a full snapshot and any following frames are
5//! [RFC 7396](https://www.rfc-editor.org/rfc/rfc7396.html) JSON Merge Patch deltas applied in
6//! order. A consumer jumps to the newest group, reads the snapshot, and applies the deltas, so
7//! a late joiner never needs older groups.
8//!
9//! Deltas are controlled by [`Config::delta_ratio`]. A ratio of `0` disables them, so every
10//! change is a fresh snapshot group, matching a plain "one JSON blob per group" track.
11
12mod diff;
13
14use std::marker::PhantomData;
15use std::ops::{Deref, DerefMut};
16use std::sync::{Arc, Mutex, MutexGuard};
17use std::task::Poll;
18
19use serde::Serialize;
20use serde::de::DeserializeOwned;
21use serde_json::Value;
22
23use crate::diff::diff;
24
25/// Maximum frames (snapshot + deltas) in a single group before a new snapshot is forced.
26///
27/// Kept well below moq-net's per-group frame cap so a late joiner can always read the snapshot
28/// at frame 0 before the group is evicted.
29const MAX_DELTA_FRAMES: usize = 256;
30
31/// Errors produced while publishing or consuming JSON.
32#[derive(thiserror::Error, Debug, Clone)]
33#[non_exhaustive]
34pub enum Error {
35	/// An error from the underlying track.
36	#[error(transparent)]
37	Net(#[from] moq_net::Error),
38
39	/// A value failed to serialize, deserialize, or apply as a merge patch.
40	///
41	/// Stored as a string since [`serde_json::Error`] is not [`Clone`].
42	#[error("json: {0}")]
43	Json(String),
44}
45
46impl From<serde_json::Error> for Error {
47	fn from(err: serde_json::Error) -> Self {
48		Error::Json(err.to_string())
49	}
50}
51
52/// A [`Result`](std::result::Result) using this crate's [`Error`].
53pub type Result<T> = std::result::Result<T, Error>;
54
55/// Configuration for a [`Producer`].
56#[derive(Debug, Clone)]
57pub struct Config {
58	/// Controls how aggressively the producer emits deltas (merge patches) instead of full snapshots.
59	///
60	/// A ratio of `0` disables deltas: every change is published as a new snapshot group.
61	///
62	/// A positive ratio enables deltas. A delta is appended to the current group as long as the
63	/// accumulated deltas (excluding the snapshot frame) stay within `ratio` times the size of a
64	/// fresh snapshot; otherwise a new snapshot group is started. So `1` allows deltas totalling up
65	/// to one snapshot before rolling, and a larger ratio tolerates more deltas per snapshot.
66	///
67	/// Defaults to `8`.
68	pub delta_ratio: u32,
69}
70
71impl Default for Config {
72	fn default() -> Self {
73		Self { delta_ratio: 8 }
74	}
75}
76
77/// Publishes a JSON value over a track, choosing snapshots and deltas automatically.
78///
79/// Cheaply clonable: clones share one underlying track and publishing state, like other MoQ
80/// producers.
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::TrackConsumer {
98		self.inner.lock().unwrap().track.consume()
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::TrackProducer, config: Config) -> Self {
105		Self {
106			inner: Arc::new(Mutex::new(Inner {
107				track,
108				group: None,
109				last: None,
110				delta_bytes: 0,
111				group_frames: 0,
112				config,
113			})),
114			_marker: PhantomData,
115		}
116	}
117
118	/// Publish a new value, emitting a snapshot or a delta automatically.
119	///
120	/// Does nothing if the value is unchanged from the previous publish.
121	pub fn update(&mut self, value: &T) -> Result<()> {
122		let json = serde_json::to_value(value)?;
123		// Serialize the value directly (not via `json`) so a snapshot preserves the type's own
124		// field order, keeping the wire bytes identical to serializing `T` straight to a frame.
125		let snapshot = serde_json::to_vec(value)?;
126		self.inner.lock().unwrap().update(json, snapshot)
127	}
128
129	/// Lock the current value for in-place editing, publishing on drop.
130	///
131	/// The returned [`Guard`] derefs to the last-published value (or `T::default()` if nothing has
132	/// been published yet). Editing it through [`DerefMut`] marks the guard dirty; when a dirty
133	/// guard drops it publishes the result, a no-op if unchanged.
134	///
135	/// This is the counterpart to a callback: hold the guard, mutate, drop. The guard holds the
136	/// producer's lock for its lifetime, so independent owners are serialized: each one starts from
137	/// the latest value and their changes compose instead of clobbering. Don't hold a guard across
138	/// an `.await`, since that keeps the lock held while suspended.
139	pub fn lock(&mut self) -> Guard<'_, T>
140	where
141		T: Default + DeserializeOwned,
142	{
143		let inner = self.inner.lock().unwrap();
144		let value = inner
145			.last
146			.as_ref()
147			.and_then(|last| serde_json::from_value(last.clone()).ok())
148			.unwrap_or_default();
149
150		Guard {
151			inner,
152			value,
153			dirty: false,
154		}
155	}
156
157	/// Finish the track, closing any open group.
158	pub fn finish(&mut self) -> Result<()> {
159		self.inner.lock().unwrap().finish()
160	}
161}
162
163/// An RAII editing guard returned by [`Producer::lock`].
164///
165/// Holds the producer's lock for its lifetime and derefs to the current value. Mutating it through
166/// [`DerefMut`] marks it dirty, and dropping a dirty guard publishes the edited value.
167pub struct Guard<'a, T: Serialize> {
168	inner: MutexGuard<'a, Inner>,
169	value: T,
170	dirty: bool,
171}
172
173impl<T: Serialize> Deref for Guard<'_, T> {
174	type Target = T;
175
176	fn deref(&self) -> &T {
177		&self.value
178	}
179}
180
181impl<T: Serialize> DerefMut for Guard<'_, T> {
182	fn deref_mut(&mut self) -> &mut T {
183		self.dirty = true;
184		&mut self.value
185	}
186}
187
188impl<T: Serialize> Drop for Guard<'_, T> {
189	fn drop(&mut self) {
190		if !self.dirty {
191			return;
192		}
193
194		let Ok(json) = serde_json::to_value(&self.value) else {
195			return;
196		};
197		let Ok(snapshot) = serde_json::to_vec(&self.value) else {
198			return;
199		};
200
201		// We already hold the lock, so publish through the held guard rather than re-locking.
202		let _ = self.inner.update(json, snapshot);
203	}
204}
205
206/// Shared publishing state behind [`Producer`]'s `Arc<Mutex>`.
207struct Inner {
208	track: moq_net::TrackProducer,
209	group: Option<moq_net::GroupProducer>,
210	last: Option<Value>,
211	// Bytes of deltas accumulated in the current group, excluding the snapshot frame.
212	delta_bytes: u64,
213	group_frames: usize,
214	config: Config,
215}
216
217impl Inner {
218	fn update(&mut self, json: Value, snapshot: Vec<u8>) -> Result<()> {
219		if self.last.as_ref() == Some(&json) {
220			return Ok(());
221		}
222
223		match self.delta(&json, snapshot.len())? {
224			Some(delta) => {
225				let group = self.group.as_mut().expect("delta requires an open group");
226				let len = delta.len() as u64;
227				group.write_frame(delta)?;
228				self.delta_bytes += len;
229				self.group_frames += 1;
230			}
231			None => self.snapshot(snapshot)?,
232		}
233
234		self.last = Some(json);
235		Ok(())
236	}
237
238	/// Serialize a delta if deltas are enabled and appending one keeps the group within budget;
239	/// otherwise `None`, signalling that a fresh snapshot should be published instead.
240	fn delta(&self, value: &Value, snapshot_len: usize) -> Result<Option<Vec<u8>>> {
241		let ratio = self.config.delta_ratio;
242		if ratio == 0 {
243			return Ok(None);
244		}
245		let Some(last) = &self.last else {
246			return Ok(None);
247		};
248		if self.group.is_none() || self.group_frames >= MAX_DELTA_FRAMES {
249			return Ok(None);
250		}
251
252		let diff = diff(last, value);
253		if diff.forced_snapshot {
254			return Ok(None);
255		}
256
257		let delta = serde_json::to_vec(&diff.patch)?;
258
259		// Roll a snapshot once the deltas would outgrow the budget (snapshot frame excluded).
260		let projected = self.delta_bytes + delta.len() as u64;
261		if projected > ratio as u64 * snapshot_len as u64 {
262			return Ok(None);
263		}
264
265		Ok(Some(delta))
266	}
267
268	/// Start a new group with a full snapshot as its first frame.
269	fn snapshot(&mut self, snapshot: Vec<u8>) -> Result<()> {
270		// The previous group is complete; no more frames will be appended to it.
271		if let Some(mut group) = self.group.take() {
272			group.finish()?;
273		}
274
275		let mut group = self.track.append_group()?;
276		group.write_frame(snapshot)?;
277		self.delta_bytes = 0;
278		self.group_frames = 1;
279
280		if self.config.delta_ratio != 0 {
281			// Keep the group open so future deltas can be appended.
282			self.group = Some(group);
283		} else {
284			// Deltas disabled: one frame per group, identical to a plain JSON track.
285			group.finish()?;
286		}
287
288		Ok(())
289	}
290
291	fn finish(&mut self) -> Result<()> {
292		if let Some(mut group) = self.group.take() {
293			group.finish()?;
294		}
295		self.track.finish()?;
296		Ok(())
297	}
298}
299
300/// Consumes a JSON value from a track, reconstructing it from snapshots and deltas.
301pub struct Consumer<T> {
302	track: moq_net::TrackConsumer,
303	group: Option<moq_net::GroupConsumer>,
304	current: Option<Value>,
305	frames_read: usize,
306	_marker: PhantomData<fn() -> T>,
307}
308
309// Manual impl so cloning doesn't require `T: Clone`; `T` only lives in PhantomData.
310// Cloned readers inherit the current reconstruction state, then advance in parallel.
311impl<T> Clone for Consumer<T> {
312	fn clone(&self) -> Self {
313		Self {
314			track: self.track.clone(),
315			group: self.group.clone(),
316			current: self.current.clone(),
317			frames_read: self.frames_read,
318			_marker: PhantomData,
319		}
320	}
321}
322
323impl<T: DeserializeOwned> Consumer<T> {
324	/// Create a consumer reading from the given track subscriber.
325	pub fn new(track: moq_net::TrackConsumer) -> Self {
326		Self {
327			track,
328			group: None,
329			current: None,
330			frames_read: 0,
331			_marker: PhantomData,
332		}
333	}
334
335	/// Get the next reconstructed value, or `None` once the track ends.
336	pub async fn next(&mut self) -> Result<Option<T>>
337	where
338		T: Unpin,
339	{
340		kio::wait(|waiter| self.poll_next(waiter)).await
341	}
342
343	/// Poll for the next reconstructed value, without blocking.
344	///
345	/// Jumps to the newest group, reads its snapshot, and applies deltas in order, yielding the
346	/// reconstructed value after each frame. Switching to a newer group discards the older one.
347	pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll<Result<Option<T>>> {
348		// Drain to the newest group, resetting reconstruction state whenever we switch.
349		let track_finished = loop {
350			match self.track.poll_next_group(waiter)? {
351				Poll::Ready(Some(group)) => {
352					self.group = Some(group);
353					self.current = None;
354					self.frames_read = 0;
355				}
356				Poll::Ready(None) => break true,
357				Poll::Pending => break false,
358			}
359		};
360
361		if let Some(group) = &mut self.group {
362			match group.poll_read_frame(waiter)? {
363				Poll::Ready(Some(frame)) => return Poll::Ready(Ok(Some(self.apply(frame)?))),
364				// The current group is exhausted; wait for a newer one.
365				Poll::Ready(None) => self.group = None,
366				Poll::Pending => return Poll::Pending,
367			}
368		}
369
370		if track_finished {
371			Poll::Ready(Ok(None))
372		} else {
373			Poll::Pending
374		}
375	}
376
377	/// Apply one frame: frame 0 of a group is a snapshot, the rest are merge patches.
378	fn apply(&mut self, frame: bytes::Bytes) -> Result<T> {
379		if self.frames_read == 0 {
380			self.current = Some(serde_json::from_slice(&frame)?);
381		} else {
382			let patch: Value = serde_json::from_slice(&frame)?;
383			let current = self.current.as_mut().expect("a snapshot precedes any delta");
384			json_patch::merge(current, &patch);
385		}
386		self.frames_read += 1;
387
388		let current = self
389			.current
390			.as_ref()
391			.expect("a value is present after applying a frame");
392		Ok(serde_json::from_value(current.clone())?)
393	}
394}
395
396#[cfg(test)]
397mod test {
398	use super::*;
399	use serde_json::json;
400
401	fn producer(config: Config) -> (Producer<Value>, moq_net::TrackConsumer) {
402		let track = moq_net::Track::new("test").produce();
403		let consumer = track.consume();
404		(Producer::new(track, config), consumer)
405	}
406
407	/// Drain every value currently available from a consumer without blocking.
408	fn drain(track: moq_net::TrackConsumer) -> Vec<Value> {
409		let mut consumer = Consumer::<Value>::new(track);
410		let waiter = kio::Waiter::noop();
411		let mut out = Vec::new();
412		while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) {
413			out.push(value);
414		}
415		out
416	}
417
418	#[test]
419	fn deltas_off_snapshot_per_group() {
420		let (mut producer, track) = producer(Config { delta_ratio: 0 });
421		producer.update(&json!({ "a": 1 })).unwrap();
422		producer.update(&json!({ "a": 2 })).unwrap();
423		producer.finish().unwrap();
424
425		// Two updates => two groups, each a full snapshot. A consumer that joins after both
426		// exist only sees the latest, like the existing catalog consumer.
427		assert_eq!(track.latest(), Some(1));
428		assert_eq!(drain(track), vec![json!({ "a": 2 })]);
429	}
430
431	#[test]
432	fn live_consumer_sees_each_update() {
433		let (mut producer, track) = producer(Config::default());
434		let mut consumer = Consumer::<Value>::new(track);
435		let waiter = kio::Waiter::noop();
436
437		for n in 1..=3 {
438			producer.update(&json!({ "a": n })).unwrap();
439			match consumer.poll_next(&waiter) {
440				Poll::Ready(Ok(Some(value))) => assert_eq!(value, json!({ "a": n })),
441				other => panic!("expected value, got {other:?}"),
442			}
443		}
444	}
445
446	#[test]
447	fn unchanged_value_writes_nothing() {
448		let (mut producer, track) = producer(Config::default());
449		producer.update(&json!({ "a": 1 })).unwrap();
450		producer.update(&json!({ "a": 1 })).unwrap();
451		producer.finish().unwrap();
452
453		assert_eq!(track.latest(), Some(0));
454		assert_eq!(drain(track), vec![json!({ "a": 1 })]);
455	}
456
457	#[test]
458	fn deltas_share_one_group() {
459		let config = Config { delta_ratio: 100 };
460		let (mut producer, track) = producer(config);
461		producer.update(&json!({ "a": 1, "b": 1 })).unwrap();
462		producer.update(&json!({ "a": 1, "b": 2 })).unwrap();
463		producer.update(&json!({ "a": 1, "b": 3 })).unwrap();
464		producer.finish().unwrap();
465
466		// All updates fit in a single group as snapshot + deltas.
467		assert_eq!(track.latest(), Some(0));
468		let values = drain(track);
469		assert_eq!(values.last().unwrap(), &json!({ "a": 1, "b": 3 }));
470	}
471
472	#[test]
473	fn tight_ratio_rolls_snapshots() {
474		// A ratio of 1 admits deltas only up to the snapshot size: with equal 7-byte frames that is a
475		// single delta per group, so it rolls every other update. (Still distinct from 0, which would
476		// disable deltas entirely and never produce the group-0 delta below.)
477		let config = Config { delta_ratio: 1 };
478		let (mut producer, track) = producer(config);
479		producer.update(&json!({ "a": 1 })).unwrap(); // snapshot, group 0
480		producer.update(&json!({ "a": 2 })).unwrap(); // delta, group 0
481		producer.update(&json!({ "a": 3 })).unwrap(); // exceeds budget, rolls group 1
482		producer.update(&json!({ "a": 4 })).unwrap(); // delta, group 1
483		producer.finish().unwrap();
484
485		assert_eq!(track.latest(), Some(1));
486	}
487
488	#[test]
489	fn deltas_stay_within_ratio_times_snapshot() {
490		// The budget covers only the deltas, not the snapshot frame, measured against the current
491		// snapshot size. Single-digit values keep every frame at a constant 7 bytes (`{"n":N}`), so a
492		// `ratio = 8` admits 8 deltas (8x the snapshot, the inclusive limit) on top of the snapshot
493		// before the 9th delta rolls.
494		let config = Config { delta_ratio: 8 };
495		let (mut producer, track) = producer(config);
496		for n in 0..=9 {
497			producer.update(&json!({ "n": n })).unwrap();
498		}
499		producer.finish().unwrap();
500
501		// Group 0 carries the snapshot plus 8 deltas (9 frames); the 9th delta opens group 1.
502		assert_eq!(track.latest(), Some(1));
503		assert_eq!(drain(track).last().unwrap(), &json!({ "n": 9 }));
504	}
505
506	#[test]
507	fn array_change_is_delta() {
508		let config = Config { delta_ratio: 100 };
509		let (mut producer, track) = producer(config);
510		producer.update(&json!({ "list": [1, 2] })).unwrap();
511		producer.update(&json!({ "list": [1, 2, 3] })).unwrap();
512		producer.finish().unwrap();
513
514		// The array is replaced wholesale in a delta, so it stays in the same group.
515		assert_eq!(track.latest(), Some(0));
516		assert_eq!(drain(track).last().unwrap(), &json!({ "list": [1, 2, 3] }));
517	}
518
519	#[test]
520	fn frame_cap_rolls_snapshot() {
521		let config = Config { delta_ratio: 1_000_000 };
522		let (mut producer, track) = producer(config);
523		// First update is the snapshot (frame 0); then MAX_DELTA_FRAMES - 1 deltas fill the group.
524		for i in 0..=MAX_DELTA_FRAMES {
525			producer.update(&json!({ "n": i })).unwrap();
526		}
527		producer.finish().unwrap();
528
529		// The frame cap forced exactly one extra snapshot group despite the huge ratio.
530		assert_eq!(track.latest(), Some(1));
531		assert_eq!(drain(track).last().unwrap(), &json!({ "n": MAX_DELTA_FRAMES }));
532	}
533
534	#[test]
535	fn late_joiner_reconstructs_from_deltas() {
536		let config = Config { delta_ratio: 100 };
537		let (mut producer, track) = producer(config);
538		producer.update(&json!({ "a": 1, "b": 1 })).unwrap();
539		producer.update(&json!({ "a": 1, "b": 2 })).unwrap();
540		producer.update(&json!({ "a": 5, "b": 2 })).unwrap();
541		producer.finish().unwrap();
542
543		// A consumer created only now still rebuilds the final value from snapshot + deltas.
544		assert_eq!(drain(track).last().unwrap(), &json!({ "a": 5, "b": 2 }));
545	}
546
547	#[test]
548	fn lock_composes_independent_owners() {
549		// Mirrors the catalog use case: separate owners each edit their own field through the guard.
550		#[derive(serde::Serialize, serde::Deserialize, Default, PartialEq, Debug)]
551		struct Doc {
552			#[serde(skip_serializing_if = "Option::is_none")]
553			video: Option<String>,
554			#[serde(skip_serializing_if = "Option::is_none")]
555			scte35: Option<u32>,
556		}
557
558		let track = moq_net::Track::new("test").produce();
559		let consumer = track.consume();
560		let mut producer = Producer::<Doc>::new(track, Config::default());
561
562		// First owner sets its field.
563		producer.lock().video = Some("v1".to_string());
564
565		// Second owner starts from the latest value and adds its own field without clobbering.
566		producer.lock().scte35 = Some(42);
567
568		// Locking without mutating publishes nothing (the guard stays clean).
569		let _ = producer.lock();
570
571		producer.finish().unwrap();
572
573		let mut consumer = Consumer::<Doc>::new(consumer);
574		let waiter = kio::Waiter::noop();
575		let mut last = None;
576		while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) {
577			last = Some(value);
578		}
579		assert_eq!(
580			last.unwrap(),
581			Doc {
582				video: Some("v1".to_string()),
583				scte35: Some(42),
584			}
585		);
586	}
587
588	#[test]
589	fn newer_group_supersedes_in_progress_reconstruction() {
590		// A tight ratio lets one delta fit, then forces the next update into a new snapshot group.
591		let config = Config { delta_ratio: 1 };
592		let (mut producer, track) = producer(config);
593		let observer = producer.consume();
594		let mut consumer = Consumer::<Value>::new(track);
595		let waiter = kio::Waiter::noop();
596
597		producer.update(&json!({ "a": 1 })).unwrap(); // snapshot, group 0
598		match consumer.poll_next(&waiter) {
599			Poll::Ready(Ok(Some(value))) => assert_eq!(value, json!({ "a": 1 })),
600			other => panic!("expected first value, got {other:?}"),
601		}
602
603		producer.update(&json!({ "a": 2 })).unwrap(); // delta in group 0
604		producer.update(&json!({ "a": 3 })).unwrap(); // exceeds budget, rolls group 1
605		producer.finish().unwrap();
606		assert_eq!(observer.latest(), Some(1));
607
608		// The consumer jumps to the newest group and never yields a stale value.
609		let mut last = None;
610		while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) {
611			last = Some(value);
612		}
613		assert_eq!(last.unwrap(), json!({ "a": 3 }));
614	}
615
616	#[test]
617	fn cloned_consumer_reconstructs_independently() {
618		// Deltas share one group, so a clone taken mid-group carries in-progress reconstruction state.
619		let config = Config { delta_ratio: 100 };
620		let (mut producer, track) = producer(config);
621		let mut consumer = Consumer::<Value>::new(track);
622		let waiter = kio::Waiter::noop();
623
624		producer.update(&json!({ "a": 1, "b": 1 })).unwrap(); // snapshot, group 0
625		match consumer.poll_next(&waiter) {
626			Poll::Ready(Ok(Some(value))) => assert_eq!(value, json!({ "a": 1, "b": 1 })),
627			other => panic!("expected snapshot, got {other:?}"),
628		}
629
630		// Clone after the snapshot: the copy inherits `current`/`frames_read` and an independent cursor.
631		let mut clone = consumer.clone();
632
633		producer.update(&json!({ "a": 1, "b": 2 })).unwrap(); // delta in group 0
634		producer.finish().unwrap();
635
636		// Each consumer applies the delta on top of its own reconstruction state.
637		let expected = json!({ "a": 1, "b": 2 });
638		for consumer in [&mut consumer, &mut clone] {
639			match consumer.poll_next(&waiter) {
640				Poll::Ready(Ok(Some(value))) => assert_eq!(value, expected),
641				other => panic!("expected delta, got {other:?}"),
642			}
643		}
644	}
645}