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