Skip to main content

moq_json/
snapshot.rs

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