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 [`ProducerConfig::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 bytes::Bytes;
20use moq_flate::{Decoder, Encoder};
21use serde::Serialize;
22use serde::de::DeserializeOwned;
23use serde_json::Value;
24
25use crate::diff::diff;
26
27/// Maximum frames (snapshot + deltas) in a single group before a new snapshot is forced.
28///
29/// Kept well below moq-net's per-group frame cap so a late joiner can always read the snapshot
30/// at frame 0 before the group is evicted.
31const MAX_DELTA_FRAMES: usize = 256;
32
33/// Errors produced while publishing or consuming JSON.
34#[derive(thiserror::Error, Debug, Clone)]
35#[non_exhaustive]
36pub enum Error {
37	/// An error from the underlying track.
38	#[error(transparent)]
39	Net(#[from] moq_net::Error),
40
41	/// A value failed to serialize, deserialize, or apply as a merge patch.
42	///
43	/// Stored as a string since [`serde_json::Error`] is not [`Clone`].
44	#[error("json: {0}")]
45	Json(String),
46
47	/// A compressed frame could not be decoded (malformed, truncated, or oversized).
48	#[error(transparent)]
49	Flate(#[from] moq_flate::Error),
50}
51
52impl From<serde_json::Error> for Error {
53	fn from(err: serde_json::Error) -> Self {
54		Error::Json(err.to_string())
55	}
56}
57
58/// A [`Result`](std::result::Result) using this crate's [`Error`].
59pub type Result<T> = std::result::Result<T, Error>;
60
61/// Configuration for a [`Producer`].
62///
63/// Build from [`Default`] and override fields (the struct is `#[non_exhaustive]`, so new
64/// options stay additive): `let mut config = ProducerConfig::default(); config.delta_ratio = 0;`.
65#[derive(Debug, Clone)]
66#[non_exhaustive]
67pub struct ProducerConfig {
68	/// Controls how aggressively the producer emits deltas (merge patches) instead of full snapshots.
69	///
70	/// A ratio of `0` disables deltas: every change is published as a new snapshot group.
71	///
72	/// A positive ratio enables deltas. A delta is appended to the current group as long as the
73	/// accumulated deltas (excluding the snapshot frame) stay within `ratio` times the size of a
74	/// snapshot; otherwise a new snapshot group is started. So `1` allows deltas totalling up
75	/// to one snapshot before rolling, and a larger ratio tolerates more deltas per snapshot.
76	///
77	/// When [`compression`](Self::compression) is on, both sides of the comparison are measured on
78	/// the *compressed* frame sizes (the real wire cost).
79	///
80	/// Defaults to `8`.
81	pub delta_ratio: u32,
82
83	/// Compress each group as one sync-flushed DEFLATE stream, so deltas reuse the snapshot as
84	/// context and shrink sharply.
85	///
86	/// `false` (the default) writes plaintext JSON frames, identical on the wire to an uncompressed
87	/// track. A [`Consumer`] reading the track must set [`ConsumerConfig::compression`] to match.
88	pub compression: bool,
89}
90
91impl Default for ProducerConfig {
92	fn default() -> Self {
93		Self {
94			delta_ratio: 8,
95			compression: false,
96		}
97	}
98}
99
100/// Configuration for a [`Consumer`].
101///
102/// Build from [`Default`] and override fields (the struct is `#[non_exhaustive]`, so new options
103/// stay additive).
104#[derive(Debug, Clone, Default)]
105#[non_exhaustive]
106pub struct ConsumerConfig {
107	/// Whether the track's frames are DEFLATE-compressed. Must match the producer's
108	/// [`ProducerConfig::compression`]. Defaults to `false`.
109	pub compression: bool,
110}
111
112/// Publishes a JSON value over a track, choosing snapshots and deltas automatically.
113///
114/// Cheaply clonable: clones share one underlying track and publishing state, like other MoQ
115/// producers.
116pub struct Producer<T> {
117	inner: Arc<Mutex<Inner>>,
118	_marker: PhantomData<fn(T)>,
119}
120
121impl<T> Clone for Producer<T> {
122	fn clone(&self) -> Self {
123		Self {
124			inner: self.inner.clone(),
125			_marker: PhantomData,
126		}
127	}
128}
129
130impl<T> Producer<T> {
131	/// Create a subscriber for the underlying track.
132	pub fn consume(&self) -> moq_net::TrackConsumer {
133		self.inner.lock().unwrap().track.consume()
134	}
135}
136
137impl<T: Serialize> Producer<T> {
138	/// Create a producer that publishes to the given track.
139	pub fn new(track: moq_net::TrackProducer, config: ProducerConfig) -> Self {
140		Self {
141			inner: Arc::new(Mutex::new(Inner {
142				track,
143				group: None,
144				encoder: None,
145				last: None,
146				delta_bytes: 0,
147				snapshot_len: 0,
148				group_frames: 0,
149				config,
150			})),
151			_marker: PhantomData,
152		}
153	}
154
155	/// Publish a new value, emitting a snapshot or a delta automatically.
156	///
157	/// Does nothing if the value is unchanged from the previous publish.
158	pub fn update(&mut self, value: &T) -> Result<()> {
159		let json = serde_json::to_value(value)?;
160		// Serialize the value directly (not via `json`) so a snapshot preserves the type's own
161		// field order, keeping the wire bytes identical to serializing `T` straight to a frame.
162		let snapshot = serde_json::to_vec(value)?;
163		self.inner.lock().unwrap().update(json, snapshot)
164	}
165
166	/// Lock the current value for in-place editing, publishing on drop.
167	///
168	/// The returned [`Guard`] derefs to the last-published value (or `T::default()` if nothing has
169	/// been published yet). Editing it through [`DerefMut`] marks the guard dirty; when a dirty
170	/// guard drops it publishes the result, a no-op if unchanged.
171	///
172	/// This is the counterpart to a callback: hold the guard, mutate, drop. The guard holds the
173	/// producer's lock for its lifetime, so independent owners are serialized: each one starts from
174	/// the latest value and their changes compose instead of clobbering. Don't hold a guard across
175	/// an `.await`, since that keeps the lock held while suspended.
176	pub fn lock(&mut self) -> Guard<'_, T>
177	where
178		T: Default + DeserializeOwned,
179	{
180		let inner = self.inner.lock().unwrap();
181		let value = inner
182			.last
183			.as_ref()
184			.and_then(|last| serde_json::from_value(last.clone()).ok())
185			.unwrap_or_default();
186
187		Guard {
188			inner,
189			value,
190			dirty: false,
191		}
192	}
193
194	/// Finish the track, closing any open group.
195	pub fn finish(&mut self) -> Result<()> {
196		self.inner.lock().unwrap().finish()
197	}
198}
199
200/// An RAII editing guard returned by [`Producer::lock`].
201///
202/// Holds the producer's lock for its lifetime and derefs to the current value. Mutating it through
203/// [`DerefMut`] marks it dirty, and dropping a dirty guard publishes the edited value.
204pub struct Guard<'a, T: Serialize> {
205	inner: MutexGuard<'a, Inner>,
206	value: T,
207	dirty: bool,
208}
209
210impl<T: Serialize> Deref for Guard<'_, T> {
211	type Target = T;
212
213	fn deref(&self) -> &T {
214		&self.value
215	}
216}
217
218impl<T: Serialize> DerefMut for Guard<'_, T> {
219	fn deref_mut(&mut self) -> &mut T {
220		self.dirty = true;
221		&mut self.value
222	}
223}
224
225impl<T: Serialize> Drop for Guard<'_, T> {
226	fn drop(&mut self) {
227		if !self.dirty {
228			return;
229		}
230
231		let Ok(json) = serde_json::to_value(&self.value) else {
232			return;
233		};
234		let Ok(snapshot) = serde_json::to_vec(&self.value) else {
235			return;
236		};
237
238		// We already hold the lock, so publish through the held guard rather than re-locking.
239		let _ = self.inner.update(json, snapshot);
240	}
241}
242
243/// Shared publishing state behind [`Producer`]'s `Arc<Mutex>`.
244struct Inner {
245	track: moq_net::TrackProducer,
246	group: Option<moq_net::GroupProducer>,
247	// Per-group DEFLATE encoder, `Some` while a compressed group is open (recreated per group).
248	encoder: Option<Encoder>,
249	last: Option<Value>,
250	// Bytes of deltas accumulated in the current group, excluding the snapshot frame. Compressed
251	// slice sizes when compressing, raw patch sizes otherwise.
252	delta_bytes: u64,
253	// Reference size the delta budget is measured against: the current group's snapshot frame.
254	// Its compressed slice size when compressing, raw otherwise.
255	snapshot_len: u64,
256	group_frames: usize,
257	config: ProducerConfig,
258}
259
260impl Inner {
261	fn update(&mut self, json: Value, snapshot: Vec<u8>) -> Result<()> {
262		if self.last.as_ref() == Some(&json) {
263			return Ok(());
264		}
265
266		match self.delta(&json, snapshot.len())? {
267			Some(slice) => {
268				let group = self.group.as_mut().expect("delta requires an open group");
269				let len = slice.len() as u64;
270				group.write_frame(slice)?;
271				self.delta_bytes += len;
272				self.group_frames += 1;
273			}
274			None => self.snapshot(snapshot)?,
275		}
276
277		self.last = Some(json);
278		Ok(())
279	}
280
281	/// Serialize (and, when compressing, compress) a delta if deltas are enabled and appending one
282	/// keeps the group within budget; otherwise `None`, signalling that a fresh snapshot should be
283	/// published instead. Returns the frame slice ready to write.
284	fn delta(&mut self, value: &Value, snapshot_len: usize) -> Result<Option<Bytes>> {
285		let ratio = self.config.delta_ratio;
286		if ratio == 0 {
287			return Ok(None);
288		}
289		if self.group.is_none() || self.group_frames >= MAX_DELTA_FRAMES {
290			return Ok(None);
291		}
292
293		let patch = {
294			let Some(last) = &self.last else {
295				return Ok(None);
296			};
297			let diff = diff(last, value);
298			if diff.forced_snapshot {
299				return Ok(None);
300			}
301			serde_json::to_vec(&diff.patch)?
302		};
303
304		match self.encoder.as_mut() {
305			// Compressed: measure the delta's *compressed* slice size (the real wire cost) against the
306			// group's anchoring snapshot, also compressed. Encoding advances the per-group window; if
307			// the delta doesn't fit we roll a new group with a fresh encoder, discarding this slice
308			// (the abandoned window has no effect on the new group).
309			Some(encoder) => {
310				let slice = encoder.frame(&patch);
311				let projected = self.delta_bytes + slice.len() as u64;
312				if projected > ratio as u64 * self.snapshot_len {
313					return Ok(None);
314				}
315				Ok(Some(slice))
316			}
317			// Uncompressed: raw delta bytes against a fresh snapshot of the current value.
318			None => {
319				let projected = self.delta_bytes + patch.len() as u64;
320				if projected > ratio as u64 * snapshot_len as u64 {
321					return Ok(None);
322				}
323				Ok(Some(Bytes::from(patch)))
324			}
325		}
326	}
327
328	/// Start a new group with a full snapshot as its first frame.
329	fn snapshot(&mut self, snapshot: Vec<u8>) -> Result<()> {
330		// The previous group is complete; no more frames will be appended to it.
331		if let Some(mut group) = self.group.take() {
332			group.finish()?;
333		}
334
335		let mut group = self.track.append_group()?;
336
337		// Open a fresh per-group encoder (cold window) and compress the snapshot as frame 0, recording
338		// its wire size as the delta anchor.
339		let (slice, encoder) = if self.config.compression {
340			let mut encoder = Encoder::new();
341			let slice = encoder.frame(&snapshot);
342			(slice, Some(encoder))
343		} else {
344			(Bytes::from(snapshot), None)
345		};
346		self.snapshot_len = slice.len() as u64;
347		group.write_frame(slice)?;
348		self.delta_bytes = 0;
349		self.group_frames = 1;
350		self.encoder = encoder;
351
352		if self.config.delta_ratio != 0 {
353			// Keep the group (and its encoder) open so future deltas can be appended.
354			self.group = Some(group);
355		} else {
356			// Deltas disabled: one frame per group, identical to a plain JSON track.
357			self.encoder = None;
358			group.finish()?;
359		}
360
361		Ok(())
362	}
363
364	fn finish(&mut self) -> Result<()> {
365		if let Some(mut group) = self.group.take() {
366			group.finish()?;
367		}
368		self.track.finish()?;
369		Ok(())
370	}
371}
372
373/// Consumes a JSON value from a track, reconstructing it from snapshots and deltas.
374pub struct Consumer<T> {
375	track: moq_net::TrackConsumer,
376	group: Option<moq_net::GroupConsumer>,
377	// Whether frames are DEFLATE-compressed, matching the producer's [`Config::compression`].
378	compressed: bool,
379	// Per-group DEFLATE decoder, built lazily on the first compressed frame of a group.
380	decoder: Option<Decoder>,
381	// Compressed slices read so far in the current group, in order. Lets a cloned consumer rebuild
382	// the (non-cloneable) decoder window by replaying them. Empty when uncompressed.
383	group_slices: Vec<Bytes>,
384	current: Option<Value>,
385	frames_read: usize,
386	_marker: PhantomData<fn() -> T>,
387}
388
389// Manual impl so cloning doesn't require `T: Clone`; `T` only lives in PhantomData.
390// Cloned readers inherit the current reconstruction state, then advance in parallel.
391impl<T> Clone for Consumer<T> {
392	fn clone(&self) -> Self {
393		Self {
394			track: self.track.clone(),
395			group: self.group.clone(),
396			compressed: self.compressed,
397			// A DEFLATE decoder can't be cloned (per-group window state), so the clone starts without
398			// one and rebuilds it from `group_slices` on its next compressed read.
399			decoder: None,
400			group_slices: self.group_slices.clone(),
401			current: self.current.clone(),
402			frames_read: self.frames_read,
403			_marker: PhantomData,
404		}
405	}
406}
407
408impl<T: DeserializeOwned> Consumer<T> {
409	/// Create a consumer reading from the given track subscriber.
410	///
411	/// Set [`ConsumerConfig::compression`] to read a track written by a producer with
412	/// [`ProducerConfig::compression`] on.
413	pub fn new(track: moq_net::TrackConsumer, config: ConsumerConfig) -> Self {
414		Self {
415			track,
416			group: None,
417			compressed: config.compression,
418			decoder: None,
419			group_slices: Vec::new(),
420			current: None,
421			frames_read: 0,
422			_marker: PhantomData,
423		}
424	}
425
426	/// Get the next reconstructed value, or `None` once the track ends.
427	pub async fn next(&mut self) -> Result<Option<T>>
428	where
429		T: Unpin,
430	{
431		kio::wait(|waiter| self.poll_next(waiter)).await
432	}
433
434	/// Poll for the next reconstructed value, without blocking.
435	///
436	/// Jumps to the newest group, reads its snapshot, and applies deltas in order. All frames already
437	/// buffered in the group are applied in one poll but only the resulting *latest* value is yielded:
438	/// the intermediate reconstructions are stale, so a late joiner (or any consumer that has fallen
439	/// behind) catches up to the head in a single step instead of replaying every superseded state.
440	/// Frames must still be decoded in order (the DEFLATE window and merge patches are sequential);
441	/// only the per-frame deserialize and yield are skipped. Switching to a newer group discards the
442	/// older one.
443	pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll<Result<Option<T>>> {
444		// Drain to the newest group, resetting reconstruction state whenever we switch.
445		let track_finished = loop {
446			match self.track.poll_next_group(waiter)? {
447				Poll::Ready(Some(group)) => {
448					self.group = Some(group);
449					self.current = None;
450					self.frames_read = 0;
451					// Each group is its own compressed stream, so reset the decoder state.
452					self.decoder = None;
453					self.group_slices.clear();
454				}
455				Poll::Ready(None) => break true,
456				Poll::Pending => break false,
457			}
458		};
459
460		// Apply every frame currently buffered in the group, tracking whether any moved us forward and
461		// whether the group is still open with nothing buffered yet (vs. exhausted).
462		// `poll_read_frame` returns an owned `Poll`, so the borrow of `self.group` ends before the
463		// match arms, leaving `apply` (and clearing the group) free to take `&mut self`.
464		let mut advanced = false;
465		let mut group_pending = false;
466		while let Some(group) = &mut self.group {
467			match group.poll_read_frame(waiter)? {
468				Poll::Ready(Some(frame)) => {
469					self.apply(frame)?;
470					advanced = true;
471				}
472				// The current group is exhausted; wait for a newer one.
473				Poll::Ready(None) => {
474					self.group = None;
475					break;
476				}
477				// The group is still open but has nothing buffered yet.
478				Poll::Pending => {
479					group_pending = true;
480					break;
481				}
482			}
483		}
484
485		if advanced {
486			// Deserialize once, from the head of the backlog we just drained.
487			return Poll::Ready(Ok(Some(self.reconstruct()?)));
488		}
489
490		// An open group may still deliver frames even after the track finishes (it was appended before
491		// the finish), so wait on it rather than ending the stream.
492		if group_pending {
493			return Poll::Pending;
494		}
495
496		if track_finished {
497			Poll::Ready(Ok(None))
498		} else {
499			Poll::Pending
500		}
501	}
502
503	/// Decompress a frame slice, or pass it through when the track is uncompressed.
504	///
505	/// The per-group decoder is built lazily on the first compressed frame. A cloned consumer starts
506	/// without a decoder, so the first call replays the group's already-read slices to rebuild the
507	/// (non-cloneable) DEFLATE window before decoding the new frame.
508	fn decode(&mut self, slice: Bytes) -> Result<Bytes> {
509		if !self.compressed {
510			return Ok(slice);
511		}
512
513		if self.decoder.is_none() {
514			let mut decoder = Decoder::new();
515			for prev in &self.group_slices {
516				decoder.frame(prev)?;
517			}
518			self.decoder = Some(decoder);
519		}
520
521		let plain = self.decoder.as_mut().unwrap().frame(&slice)?;
522		self.group_slices.push(slice);
523		Ok(plain)
524	}
525
526	/// Apply one frame to the in-progress value: frame 0 of a group is a snapshot, the rest are merge
527	/// patches. Updates internal state only; call [`reconstruct`](Self::reconstruct) to materialize `T`.
528	fn apply(&mut self, frame: Bytes) -> Result<()> {
529		let frame = self.decode(frame)?;
530		if self.frames_read == 0 {
531			self.current = Some(serde_json::from_slice(&frame)?);
532		} else {
533			let patch: Value = serde_json::from_slice(&frame)?;
534			let current = self.current.as_mut().expect("a snapshot precedes any delta");
535			json_patch::merge(current, &patch);
536		}
537		self.frames_read += 1;
538		Ok(())
539	}
540
541	/// Materialize the current reconstructed value into `T`. Call only after at least one frame has
542	/// been applied in the current group.
543	fn reconstruct(&self) -> Result<T> {
544		let current = self
545			.current
546			.as_ref()
547			.expect("a value is present after applying a frame");
548		Ok(serde_json::from_value(current.clone())?)
549	}
550}
551
552#[cfg(test)]
553mod test {
554	use super::*;
555	use serde_json::json;
556
557	/// An uncompressed config with the given delta ratio.
558	fn cfg(delta_ratio: u32) -> ProducerConfig {
559		ProducerConfig {
560			delta_ratio,
561			..Default::default()
562		}
563	}
564
565	/// A DEFLATE-compressed config with the given delta ratio.
566	fn cfg_deflate(delta_ratio: u32) -> ProducerConfig {
567		ProducerConfig {
568			delta_ratio,
569			compression: true,
570		}
571	}
572
573	/// A consumer reading compressed frames.
574	fn deflate_consumer(track: moq_net::TrackConsumer) -> Consumer<Value> {
575		Consumer::new(track, ConsumerConfig { compression: true })
576	}
577
578	fn producer(config: ProducerConfig) -> (Producer<Value>, moq_net::TrackConsumer) {
579		let track = moq_net::Track::new("test").produce();
580		let consumer = track.consume();
581		(Producer::new(track, config), consumer)
582	}
583
584	/// Drain every value currently available from a plaintext consumer without blocking.
585	fn drain(track: moq_net::TrackConsumer) -> Vec<Value> {
586		drain_with(Consumer::<Value>::new(track, ConsumerConfig::default()))
587	}
588
589	/// Drain every value currently available from an already-built consumer without blocking.
590	fn drain_with(mut consumer: Consumer<Value>) -> Vec<Value> {
591		let waiter = kio::Waiter::noop();
592		let mut out = Vec::new();
593		while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) {
594			out.push(value);
595		}
596		out
597	}
598
599	#[test]
600	fn deltas_off_snapshot_per_group() {
601		let (mut producer, track) = producer(cfg(0));
602		producer.update(&json!({ "a": 1 })).unwrap();
603		producer.update(&json!({ "a": 2 })).unwrap();
604		producer.finish().unwrap();
605
606		// Two updates => two groups, each a full snapshot. A consumer that joins after both
607		// exist only sees the latest, like the existing catalog consumer.
608		assert_eq!(track.latest(), Some(1));
609		assert_eq!(drain(track), vec![json!({ "a": 2 })]);
610	}
611
612	#[test]
613	fn live_consumer_sees_each_update() {
614		let (mut producer, track) = producer(ProducerConfig::default());
615		let mut consumer = Consumer::<Value>::new(track, ConsumerConfig::default());
616		let waiter = kio::Waiter::noop();
617
618		for n in 1..=3 {
619			producer.update(&json!({ "a": n })).unwrap();
620			match consumer.poll_next(&waiter) {
621				Poll::Ready(Ok(Some(value))) => assert_eq!(value, json!({ "a": n })),
622				other => panic!("expected value, got {other:?}"),
623			}
624		}
625	}
626
627	#[test]
628	fn unchanged_value_writes_nothing() {
629		let (mut producer, track) = producer(ProducerConfig::default());
630		producer.update(&json!({ "a": 1 })).unwrap();
631		producer.update(&json!({ "a": 1 })).unwrap();
632		producer.finish().unwrap();
633
634		assert_eq!(track.latest(), Some(0));
635		assert_eq!(drain(track), vec![json!({ "a": 1 })]);
636	}
637
638	#[test]
639	fn deltas_share_one_group() {
640		let config = cfg(100);
641		let (mut producer, track) = producer(config);
642		producer.update(&json!({ "a": 1, "b": 1 })).unwrap();
643		producer.update(&json!({ "a": 1, "b": 2 })).unwrap();
644		producer.update(&json!({ "a": 1, "b": 3 })).unwrap();
645		producer.finish().unwrap();
646
647		// All updates fit in a single group as snapshot + deltas.
648		assert_eq!(track.latest(), Some(0));
649		let values = drain(track);
650		assert_eq!(values.last().unwrap(), &json!({ "a": 1, "b": 3 }));
651	}
652
653	#[test]
654	fn tight_ratio_rolls_snapshots() {
655		// A ratio of 1 admits deltas only up to the snapshot size: with equal 7-byte frames that is a
656		// single delta per group, so it rolls every other update. (Still distinct from 0, which would
657		// disable deltas entirely and never produce the group-0 delta below.)
658		let config = cfg(1);
659		let (mut producer, track) = producer(config);
660		producer.update(&json!({ "a": 1 })).unwrap(); // snapshot, group 0
661		producer.update(&json!({ "a": 2 })).unwrap(); // delta, group 0
662		producer.update(&json!({ "a": 3 })).unwrap(); // exceeds budget, rolls group 1
663		producer.update(&json!({ "a": 4 })).unwrap(); // delta, group 1
664		producer.finish().unwrap();
665
666		assert_eq!(track.latest(), Some(1));
667	}
668
669	#[test]
670	fn deltas_stay_within_ratio_times_snapshot() {
671		// The budget covers only the deltas, not the snapshot frame, measured against the current
672		// snapshot size. Single-digit values keep every frame at a constant 7 bytes (`{"n":N}`), so a
673		// `ratio = 8` admits 8 deltas (8x the snapshot, the inclusive limit) on top of the snapshot
674		// before the 9th delta rolls.
675		let config = cfg(8);
676		let (mut producer, track) = producer(config);
677		for n in 0..=9 {
678			producer.update(&json!({ "n": n })).unwrap();
679		}
680		producer.finish().unwrap();
681
682		// Group 0 carries the snapshot plus 8 deltas (9 frames); the 9th delta opens group 1.
683		assert_eq!(track.latest(), Some(1));
684		assert_eq!(drain(track).last().unwrap(), &json!({ "n": 9 }));
685	}
686
687	#[test]
688	fn array_change_is_delta() {
689		let config = cfg(100);
690		let (mut producer, track) = producer(config);
691		producer.update(&json!({ "list": [1, 2] })).unwrap();
692		producer.update(&json!({ "list": [1, 2, 3] })).unwrap();
693		producer.finish().unwrap();
694
695		// The array is replaced wholesale in a delta, so it stays in the same group.
696		assert_eq!(track.latest(), Some(0));
697		assert_eq!(drain(track).last().unwrap(), &json!({ "list": [1, 2, 3] }));
698	}
699
700	#[test]
701	fn frame_cap_rolls_snapshot() {
702		let config = cfg(1_000_000);
703		let (mut producer, track) = producer(config);
704		// First update is the snapshot (frame 0); then MAX_DELTA_FRAMES - 1 deltas fill the group.
705		for i in 0..=MAX_DELTA_FRAMES {
706			producer.update(&json!({ "n": i })).unwrap();
707		}
708		producer.finish().unwrap();
709
710		// The frame cap forced exactly one extra snapshot group despite the huge ratio.
711		assert_eq!(track.latest(), Some(1));
712		assert_eq!(drain(track).last().unwrap(), &json!({ "n": MAX_DELTA_FRAMES }));
713	}
714
715	#[test]
716	fn late_joiner_reconstructs_from_deltas() {
717		let config = cfg(100);
718		let (mut producer, track) = producer(config);
719		producer.update(&json!({ "a": 1, "b": 1 })).unwrap();
720		producer.update(&json!({ "a": 1, "b": 2 })).unwrap();
721		producer.update(&json!({ "a": 5, "b": 2 })).unwrap();
722		producer.finish().unwrap();
723
724		// A consumer created only now still rebuilds the final value from snapshot + deltas.
725		assert_eq!(drain(track).last().unwrap(), &json!({ "a": 5, "b": 2 }));
726	}
727
728	#[test]
729	fn lock_composes_independent_owners() {
730		// Mirrors the catalog use case: separate owners each edit their own field through the guard.
731		#[derive(serde::Serialize, serde::Deserialize, Default, PartialEq, Debug)]
732		struct Doc {
733			#[serde(skip_serializing_if = "Option::is_none")]
734			video: Option<String>,
735			#[serde(skip_serializing_if = "Option::is_none")]
736			scte35: Option<u32>,
737		}
738
739		let track = moq_net::Track::new("test").produce();
740		let consumer = track.consume();
741		let mut producer = Producer::<Doc>::new(track, ProducerConfig::default());
742
743		// First owner sets its field.
744		producer.lock().video = Some("v1".to_string());
745
746		// Second owner starts from the latest value and adds its own field without clobbering.
747		producer.lock().scte35 = Some(42);
748
749		// Locking without mutating publishes nothing (the guard stays clean).
750		let _ = producer.lock();
751
752		producer.finish().unwrap();
753
754		let mut consumer = Consumer::<Doc>::new(consumer, ConsumerConfig::default());
755		let waiter = kio::Waiter::noop();
756		let mut last = None;
757		while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) {
758			last = Some(value);
759		}
760		assert_eq!(
761			last.unwrap(),
762			Doc {
763				video: Some("v1".to_string()),
764				scte35: Some(42),
765			}
766		);
767	}
768
769	#[test]
770	fn newer_group_supersedes_in_progress_reconstruction() {
771		// A tight ratio lets one delta fit, then forces the next update into a new snapshot group.
772		let config = cfg(1);
773		let (mut producer, track) = producer(config);
774		let observer = producer.consume();
775		let mut consumer = Consumer::<Value>::new(track, ConsumerConfig::default());
776		let waiter = kio::Waiter::noop();
777
778		producer.update(&json!({ "a": 1 })).unwrap(); // snapshot, group 0
779		match consumer.poll_next(&waiter) {
780			Poll::Ready(Ok(Some(value))) => assert_eq!(value, json!({ "a": 1 })),
781			other => panic!("expected first value, got {other:?}"),
782		}
783
784		producer.update(&json!({ "a": 2 })).unwrap(); // delta in group 0
785		producer.update(&json!({ "a": 3 })).unwrap(); // exceeds budget, rolls group 1
786		producer.finish().unwrap();
787		assert_eq!(observer.latest(), Some(1));
788
789		// The consumer jumps to the newest group and never yields a stale value.
790		let mut last = None;
791		while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) {
792			last = Some(value);
793		}
794		assert_eq!(last.unwrap(), json!({ "a": 3 }));
795	}
796
797	#[test]
798	fn cloned_consumer_reconstructs_independently() {
799		// Deltas share one group, so a clone taken mid-group carries in-progress reconstruction state.
800		let config = cfg(100);
801		let (mut producer, track) = producer(config);
802		let mut consumer = Consumer::<Value>::new(track, ConsumerConfig::default());
803		let waiter = kio::Waiter::noop();
804
805		producer.update(&json!({ "a": 1, "b": 1 })).unwrap(); // snapshot, group 0
806		match consumer.poll_next(&waiter) {
807			Poll::Ready(Ok(Some(value))) => assert_eq!(value, json!({ "a": 1, "b": 1 })),
808			other => panic!("expected snapshot, got {other:?}"),
809		}
810
811		// Clone after the snapshot: the copy inherits `current`/`frames_read` and an independent cursor.
812		let mut clone = consumer.clone();
813
814		producer.update(&json!({ "a": 1, "b": 2 })).unwrap(); // delta in group 0
815		producer.finish().unwrap();
816
817		// Each consumer applies the delta on top of its own reconstruction state.
818		let expected = json!({ "a": 1, "b": 2 });
819		for consumer in [&mut consumer, &mut clone] {
820			match consumer.poll_next(&waiter) {
821				Poll::Ready(Ok(Some(value))) => assert_eq!(value, expected),
822				other => panic!("expected delta, got {other:?}"),
823			}
824		}
825	}
826
827	#[test]
828	fn open_group_pends_after_track_finish() {
829		// A group appended before the track finishes may still deliver frames, so the consumer must
830		// keep waiting on it rather than ending the stream. Regression for the backlog-collapse poll.
831		let mut track = moq_net::Track::new("test").produce();
832		let mut group = track.append_group().unwrap();
833		track.finish().unwrap();
834
835		let mut consumer = Consumer::<Value>::new(track.consume(), ConsumerConfig::default());
836		let waiter = kio::Waiter::noop();
837
838		// Track is finished but the open group is empty: pending, not end-of-stream.
839		assert!(matches!(consumer.poll_next(&waiter), Poll::Pending));
840
841		group
842			.write_frame(Bytes::from(serde_json::to_vec(&json!({ "a": 1 })).unwrap()))
843			.unwrap();
844		group.finish().unwrap();
845
846		match consumer.poll_next(&waiter) {
847			Poll::Ready(Ok(Some(value))) => assert_eq!(value, json!({ "a": 1 })),
848			other => panic!("expected the catalog value, got {other:?}"),
849		}
850	}
851
852	#[test]
853	fn late_joiner_collapses_backlog_to_latest() {
854		// A whole group's worth of snapshot + deltas is buffered before the consumer reads. It should
855		// apply them all but yield only the latest value once, not replay every superseded state.
856		let (mut producer, track) = producer(cfg(100));
857		for n in 0..=20 {
858			producer.update(&json!({ "n": n })).unwrap();
859		}
860		producer.finish().unwrap();
861
862		// One group (ratio is generous), so a single poll drains the backlog into one yield.
863		assert_eq!(track.latest(), Some(0));
864		let values = drain(track);
865		assert_eq!(
866			values,
867			vec![json!({ "n": 20 })],
868			"backlog should collapse to the latest value"
869		);
870	}
871
872	#[test]
873	fn compressed_late_joiner_collapses_backlog_to_latest() {
874		// Same collapse, exercising the lazy decoder replaying the group's slices to warm its window.
875		let (mut producer, track) = producer(cfg_deflate(100));
876		for n in 0..=20 {
877			producer.update(&json!({ "n": n })).unwrap();
878		}
879		producer.finish().unwrap();
880
881		assert_eq!(track.latest(), Some(0));
882		let values = drain_with(deflate_consumer(track));
883		assert_eq!(
884			values,
885			vec![json!({ "n": 20 })],
886			"compressed backlog should collapse to the latest"
887		);
888	}
889
890	#[test]
891	fn compressed_snapshot_per_group_roundtrips() {
892		let (mut producer, track) = producer(cfg_deflate(0));
893		producer.update(&json!({ "a": 1 })).unwrap();
894		producer.update(&json!({ "a": 2 })).unwrap();
895		producer.finish().unwrap();
896
897		// Deltas disabled: one compressed snapshot per group, latest reconstructs identically.
898		assert_eq!(track.latest(), Some(1));
899		let values = drain_with(deflate_consumer(track));
900		assert_eq!(values, vec![json!({ "a": 2 })]);
901	}
902
903	#[test]
904	fn compressed_deltas_share_one_group() {
905		let (mut producer, track) = producer(cfg_deflate(100));
906		producer.update(&json!({ "a": 1, "b": 1 })).unwrap();
907		producer.update(&json!({ "a": 1, "b": 2 })).unwrap();
908		producer.update(&json!({ "a": 1, "b": 3 })).unwrap();
909		producer.finish().unwrap();
910
911		// Snapshot + deltas in one group, each frame decompressed independently.
912		assert_eq!(track.latest(), Some(0));
913		let values = drain_with(deflate_consumer(track));
914		assert_eq!(values.last().unwrap(), &json!({ "a": 1, "b": 3 }));
915	}
916
917	#[test]
918	fn compressed_late_joiner_reconstructs_from_deltas() {
919		let (mut producer, track) = producer(cfg_deflate(100));
920		producer.update(&json!({ "a": 1, "b": 1 })).unwrap();
921		producer.update(&json!({ "a": 1, "b": 2 })).unwrap();
922		producer.update(&json!({ "a": 5, "b": 2 })).unwrap();
923		producer.finish().unwrap();
924
925		// A consumer created only now rebuilds the final value from the compressed snapshot + deltas.
926		let values = drain_with(deflate_consumer(track));
927		assert_eq!(values.last().unwrap(), &json!({ "a": 5, "b": 2 }));
928	}
929
930	#[test]
931	fn compressed_cloned_consumer_reconstructs_mid_group() {
932		// A clone taken mid-group has no decoder window; it must rebuild from the retained slices.
933		let (mut producer, track) = producer(cfg_deflate(100));
934		let mut consumer = deflate_consumer(track);
935		let waiter = kio::Waiter::noop();
936
937		producer.update(&json!({ "a": 1, "b": 1 })).unwrap(); // compressed snapshot, group 0
938		match consumer.poll_next(&waiter) {
939			Poll::Ready(Ok(Some(value))) => assert_eq!(value, json!({ "a": 1, "b": 1 })),
940			other => panic!("expected snapshot, got {other:?}"),
941		}
942
943		let mut clone = consumer.clone();
944
945		producer.update(&json!({ "a": 1, "b": 2 })).unwrap(); // compressed delta, group 0
946		producer.finish().unwrap();
947
948		let expected = json!({ "a": 1, "b": 2 });
949		for consumer in [&mut consumer, &mut clone] {
950			match consumer.poll_next(&waiter) {
951				Poll::Ready(Ok(Some(value))) => assert_eq!(value, expected),
952				other => panic!("expected delta, got {other:?}"),
953			}
954		}
955	}
956
957	#[test]
958	fn compression_shrinks_wire_frames() {
959		// A repetitive payload should serialize to fewer wire bytes compressed than plaintext.
960		let value = json!({ "renditions": ["video".repeat(50), "video".repeat(50), "video".repeat(50)] });
961
962		let plaintext_bytes = wire_frame_len(cfg(0), &value);
963		let compressed_bytes = wire_frame_len(cfg_deflate(0), &value);
964		assert!(
965			compressed_bytes < plaintext_bytes,
966			"compressed frame {compressed_bytes} should be smaller than plaintext {plaintext_bytes}"
967		);
968	}
969
970	#[test]
971	fn compressed_deltas_reuse_window() {
972		// The shared per-group window is the whole point: a delta that restates content already in
973		// the snapshot compresses to far fewer bytes than the raw patch.
974		let (mut producer, mut track) = producer(cfg_deflate(100));
975		let phrase = "Media over QUIC delivers real-time latency at massive scale";
976		producer.update(&json!({ "note": phrase })).unwrap();
977		producer.update(&json!({ "note": phrase, "echo": phrase })).unwrap();
978		producer.finish().unwrap();
979
980		// Both frames land in group 0; read the delta (frame 1) verbatim.
981		let waiter = kio::Waiter::noop();
982		let Poll::Ready(Ok(Some(mut group))) = track.poll_next_group(&waiter) else {
983			panic!("expected a group");
984		};
985		let mut frames = Vec::new();
986		while let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) {
987			frames.push(frame);
988		}
989		assert_eq!(frames.len(), 2, "snapshot + one delta in a single group");
990
991		// The raw patch repeats the whole phrase; compressed against the window it's a fraction.
992		let raw_delta = serde_json::to_vec(&json!({ "echo": phrase })).unwrap();
993		assert!(
994			frames[1].len() < raw_delta.len() / 2,
995			"windowed delta {} should be far below the raw patch {}",
996			frames[1].len(),
997			raw_delta.len()
998		);
999	}
1000
1001	/// Publish a single value and return the byte length of the resulting (frame 0) wire frame.
1002	fn wire_frame_len(config: ProducerConfig, value: &Value) -> usize {
1003		let (mut producer, mut track) = producer(config);
1004		producer.update(value).unwrap();
1005		producer.finish().unwrap();
1006
1007		let waiter = kio::Waiter::noop();
1008		let Poll::Ready(Ok(Some(mut group))) = track.poll_next_group(&waiter) else {
1009			panic!("expected a group");
1010		};
1011		// Read the stored (possibly compressed) frame bytes verbatim, without reconstructing JSON.
1012		let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) else {
1013			panic!("expected a frame");
1014		};
1015		frame.len()
1016	}
1017}