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, 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	fn reconstruct(&self) -> Result<T> {
516		let current = self
517			.current
518			.as_ref()
519			.expect("a value is present after applying a frame");
520		Ok(serde_json::from_value(current.clone())?)
521	}
522}
523
524#[cfg(test)]
525mod test {
526	use super::*;
527	use serde_json::json;
528
529	/// An uncompressed config with the given delta ratio.
530	fn cfg(delta_ratio: u32) -> ProducerConfig {
531		ProducerConfig {
532			delta_ratio,
533			..Default::default()
534		}
535	}
536
537	/// A DEFLATE-compressed config with the given delta ratio.
538	fn cfg_deflate(delta_ratio: u32) -> ProducerConfig {
539		ProducerConfig {
540			delta_ratio,
541			compression: true,
542		}
543	}
544
545	/// A consumer reading compressed frames.
546	fn deflate_consumer(track: moq_net::track::Subscriber) -> Consumer<Value> {
547		Consumer::new(track, ConsumerConfig { compression: true })
548	}
549
550	fn producer(config: ProducerConfig) -> (Producer<Value>, moq_net::track::Subscriber) {
551		let track = moq_net::broadcast::Info::new()
552			.produce()
553			.create_track("test", None)
554			.unwrap();
555		let consumer = track.subscribe(None);
556		(Producer::new(track, config), consumer)
557	}
558
559	/// Drain every value currently available from a plaintext consumer without blocking.
560	fn drain(track: moq_net::track::Subscriber) -> Vec<Value> {
561		drain_with(Consumer::<Value>::new(track, ConsumerConfig::default()))
562	}
563
564	/// Drain every value currently available from an already-built consumer without blocking.
565	fn drain_with(mut consumer: Consumer<Value>) -> Vec<Value> {
566		let waiter = kio::Waiter::noop();
567		let mut out = Vec::new();
568		while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) {
569			out.push(value);
570		}
571		out
572	}
573
574	#[test]
575	fn deltas_off_snapshot_per_group() {
576		let (mut producer, track) = producer(cfg(0));
577		producer.update(&json!({ "a": 1 })).unwrap();
578		producer.update(&json!({ "a": 2 })).unwrap();
579		producer.finish().unwrap();
580
581		// Two updates => two groups, each a full snapshot. A consumer that joins after both
582		// exist only sees the latest, like the existing catalog consumer.
583		assert_eq!(track.latest(), Some(1));
584		assert_eq!(drain(track), vec![json!({ "a": 2 })]);
585	}
586
587	#[test]
588	fn live_consumer_sees_each_update() {
589		let (mut producer, track) = producer(ProducerConfig::default());
590		let mut consumer = Consumer::<Value>::new(track, ConsumerConfig::default());
591		let waiter = kio::Waiter::noop();
592
593		for n in 1..=3 {
594			producer.update(&json!({ "a": n })).unwrap();
595			match consumer.poll_next(&waiter) {
596				Poll::Ready(Ok(Some(value))) => assert_eq!(value, json!({ "a": n })),
597				other => panic!("expected value, got {other:?}"),
598			}
599		}
600	}
601
602	#[test]
603	fn unchanged_value_writes_nothing() {
604		let (mut producer, track) = producer(ProducerConfig::default());
605		producer.update(&json!({ "a": 1 })).unwrap();
606		producer.update(&json!({ "a": 1 })).unwrap();
607		producer.finish().unwrap();
608
609		assert_eq!(track.latest(), Some(0));
610		assert_eq!(drain(track), vec![json!({ "a": 1 })]);
611	}
612
613	#[test]
614	fn deltas_share_one_group() {
615		let config = cfg(100);
616		let (mut producer, track) = producer(config);
617		producer.update(&json!({ "a": 1, "b": 1 })).unwrap();
618		producer.update(&json!({ "a": 1, "b": 2 })).unwrap();
619		producer.update(&json!({ "a": 1, "b": 3 })).unwrap();
620		producer.finish().unwrap();
621
622		// All updates fit in a single group as snapshot + deltas.
623		assert_eq!(track.latest(), Some(0));
624		let values = drain(track);
625		assert_eq!(values.last().unwrap(), &json!({ "a": 1, "b": 3 }));
626	}
627
628	#[test]
629	fn tight_ratio_rolls_snapshots() {
630		// A ratio of 1 budgets deltas up to one snapshot (equal 7-byte frames => 7 bytes). The gate
631		// checks the deltas already written, so the delta that tips the group over budget still lands
632		// (a one-frame overshoot): group 0 takes two deltas (14 bytes) before the fourth update rolls
633		// group 1. (Still distinct from 0, which disables deltas entirely.)
634		let config = cfg(1);
635		let (mut producer, track) = producer(config);
636		producer.update(&json!({ "a": 1 })).unwrap(); // snapshot, group 0
637		producer.update(&json!({ "a": 2 })).unwrap(); // delta, group 0 (deltas = 7)
638		producer.update(&json!({ "a": 3 })).unwrap(); // delta, group 0 (deltas = 14, now over budget)
639		producer.update(&json!({ "a": 4 })).unwrap(); // budget already exceeded, rolls group 1
640		producer.finish().unwrap();
641
642		assert_eq!(track.latest(), Some(1));
643	}
644
645	#[test]
646	fn deltas_stay_within_ratio_times_snapshot() {
647		// The budget covers only the deltas, not the snapshot frame, measured against the group's
648		// snapshot size. Single-digit values keep every frame at a constant 7 bytes (`{"n":N}`), so
649		// `ratio = 8` budgets 56 bytes of deltas. The gate checks the deltas already written, so the
650		// group keeps filling until the accumulated deltas first exceed 56 (nine deltas = 63 bytes) and
651		// the next update rolls (a one-frame overshoot past the 56-byte budget).
652		let config = cfg(8);
653		let (mut producer, track) = producer(config);
654		for n in 0..=10 {
655			producer.update(&json!({ "n": n })).unwrap();
656		}
657		producer.finish().unwrap();
658
659		// Group 0 carries the snapshot plus 9 deltas (10 frames); the 10th delta opens group 1.
660		assert_eq!(track.latest(), Some(1));
661		assert_eq!(drain(track).last().unwrap(), &json!({ "n": 10 }));
662	}
663
664	#[test]
665	fn array_change_is_delta() {
666		let config = cfg(100);
667		let (mut producer, track) = producer(config);
668		producer.update(&json!({ "list": [1, 2] })).unwrap();
669		producer.update(&json!({ "list": [1, 2, 3] })).unwrap();
670		producer.finish().unwrap();
671
672		// The array is replaced wholesale in a delta, so it stays in the same group.
673		assert_eq!(track.latest(), Some(0));
674		assert_eq!(drain(track).last().unwrap(), &json!({ "list": [1, 2, 3] }));
675	}
676
677	#[test]
678	fn frame_cap_rolls_snapshot() {
679		let config = cfg(1_000_000);
680		let (mut producer, track) = producer(config);
681		// First update is the snapshot (frame 0); then MAX_DELTA_FRAMES - 1 deltas fill the group.
682		for i in 0..=MAX_DELTA_FRAMES {
683			producer.update(&json!({ "n": i })).unwrap();
684		}
685		producer.finish().unwrap();
686
687		// The frame cap forced exactly one extra snapshot group despite the huge ratio.
688		assert_eq!(track.latest(), Some(1));
689		assert_eq!(drain(track).last().unwrap(), &json!({ "n": MAX_DELTA_FRAMES }));
690	}
691
692	#[test]
693	fn late_joiner_reconstructs_from_deltas() {
694		let config = cfg(100);
695		let (mut producer, track) = producer(config);
696		producer.update(&json!({ "a": 1, "b": 1 })).unwrap();
697		producer.update(&json!({ "a": 1, "b": 2 })).unwrap();
698		producer.update(&json!({ "a": 5, "b": 2 })).unwrap();
699		producer.finish().unwrap();
700
701		// A consumer created only now still rebuilds the final value from snapshot + deltas.
702		assert_eq!(drain(track).last().unwrap(), &json!({ "a": 5, "b": 2 }));
703	}
704
705	#[test]
706	fn lock_composes_independent_owners() {
707		// Mirrors the catalog use case: separate owners each edit their own field through the guard.
708		#[derive(serde::Serialize, serde::Deserialize, Default, PartialEq, Debug)]
709		struct Doc {
710			#[serde(skip_serializing_if = "Option::is_none")]
711			video: Option<String>,
712			#[serde(skip_serializing_if = "Option::is_none")]
713			scte35: Option<u32>,
714		}
715
716		let track = moq_net::broadcast::Info::new()
717			.produce()
718			.create_track("test", None)
719			.unwrap();
720		let consumer = track.subscribe(None);
721		let mut producer = Producer::<Doc>::new(track, ProducerConfig::default());
722
723		// First owner sets its field.
724		producer.lock().video = Some("v1".to_string());
725
726		// Second owner starts from the latest value and adds its own field without clobbering.
727		producer.lock().scte35 = Some(42);
728
729		// Locking without mutating publishes nothing (the guard stays clean).
730		let _ = producer.lock();
731
732		producer.finish().unwrap();
733
734		let mut consumer = Consumer::<Doc>::new(consumer, ConsumerConfig::default());
735		let waiter = kio::Waiter::noop();
736		let mut last = None;
737		while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) {
738			last = Some(value);
739		}
740		assert_eq!(
741			last.unwrap(),
742			Doc {
743				video: Some("v1".to_string()),
744				scte35: Some(42),
745			}
746		);
747	}
748
749	#[test]
750	fn commit_reports_a_publish_failure() {
751		#[derive(serde::Serialize, serde::Deserialize, Default, PartialEq, Debug)]
752		struct Doc {
753			a: u32,
754		}
755
756		let track = moq_net::broadcast::Info::new()
757			.produce()
758			.create_track("test", None)
759			.unwrap();
760		let mut producer = Producer::<Doc>::new(track, ProducerConfig::default());
761
762		// A finished track can't take another group, so the publish behind the guard fails.
763		producer.finish().unwrap();
764
765		let mut guard = producer.lock();
766		guard.a = 1;
767		assert!(matches!(guard.commit(), Err(crate::Error::Net(_))));
768	}
769
770	#[test]
771	fn commit_publishes_once() {
772		#[derive(serde::Serialize, serde::Deserialize, Default, PartialEq, Debug)]
773		struct Doc {
774			a: u32,
775		}
776
777		let track = moq_net::broadcast::Info::new()
778			.produce()
779			.create_track("test", None)
780			.unwrap();
781		let consumer = track.subscribe(None);
782		let mut producer = Producer::<Doc>::new(track, cfg(0));
783
784		let mut guard = producer.lock();
785		guard.a = 1;
786		guard.commit().unwrap();
787
788		// The drop that follows `commit` must not publish a second group.
789		producer.finish().unwrap();
790		assert_eq!(consumer.latest(), Some(0));
791	}
792
793	#[test]
794	fn newer_group_supersedes_in_progress_reconstruction() {
795		// A tight ratio fills group 0 with a couple of deltas, then forces a later update into a new
796		// snapshot group (the gate overshoots the budget by one delta before rolling).
797		let config = cfg(1);
798		let (mut producer, track) = producer(config);
799		let observer = producer.consume();
800		let mut consumer = Consumer::<Value>::new(track, ConsumerConfig::default());
801		let waiter = kio::Waiter::noop();
802
803		producer.update(&json!({ "a": 1 })).unwrap(); // snapshot, group 0
804		match consumer.poll_next(&waiter) {
805			Poll::Ready(Ok(Some(value))) => assert_eq!(value, json!({ "a": 1 })),
806			other => panic!("expected first value, got {other:?}"),
807		}
808
809		producer.update(&json!({ "a": 2 })).unwrap(); // delta in group 0 (deltas = 7)
810		producer.update(&json!({ "a": 3 })).unwrap(); // delta in group 0 (deltas = 14, now over budget)
811		producer.update(&json!({ "a": 4 })).unwrap(); // budget already exceeded, rolls group 1
812		producer.finish().unwrap();
813		assert_eq!(observer.latest(), Some(1));
814
815		// The consumer jumps to the newest group and never yields a stale value.
816		let mut last = None;
817		while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) {
818			last = Some(value);
819		}
820		assert_eq!(last.unwrap(), json!({ "a": 4 }));
821	}
822
823	#[test]
824	fn open_group_pends_after_track_finish() {
825		// A group appended before the track finishes may still deliver frames, so the consumer must
826		// keep waiting on it rather than ending the stream. Regression for the backlog-collapse poll.
827		let mut track = moq_net::broadcast::Info::new()
828			.produce()
829			.create_track("test", None)
830			.unwrap();
831		let mut group = track.append_group().unwrap();
832		let consumer_track = track.subscribe(None);
833		track.finish().unwrap();
834
835		let mut consumer = Consumer::<Value>::new(consumer_track, 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(
843				moq_net::Timestamp::ZERO,
844				Bytes::from(serde_json::to_vec(&json!({ "a": 1 })).unwrap()),
845			)
846			.unwrap();
847		group.finish().unwrap();
848
849		match consumer.poll_next(&waiter) {
850			Poll::Ready(Ok(Some(value))) => assert_eq!(value, json!({ "a": 1 })),
851			other => panic!("expected the catalog value, got {other:?}"),
852		}
853	}
854
855	#[test]
856	fn late_joiner_collapses_backlog_to_latest() {
857		// A whole group's worth of snapshot + deltas is buffered before the consumer reads. It should
858		// apply them all but yield only the latest value once, not replay every superseded state.
859		let (mut producer, track) = producer(cfg(100));
860		for n in 0..=20 {
861			producer.update(&json!({ "n": n })).unwrap();
862		}
863		producer.finish().unwrap();
864
865		// One group (ratio is generous), so a single poll drains the backlog into one yield.
866		assert_eq!(track.latest(), Some(0));
867		let values = drain(track);
868		assert_eq!(
869			values,
870			vec![json!({ "n": 20 })],
871			"backlog should collapse to the latest value"
872		);
873	}
874
875	#[test]
876	fn compressed_late_joiner_collapses_backlog_to_latest() {
877		// Same collapse, exercising the lazy decoder replaying the group's slices to warm its window.
878		let (mut producer, track) = producer(cfg_deflate(100));
879		for n in 0..=20 {
880			producer.update(&json!({ "n": n })).unwrap();
881		}
882		producer.finish().unwrap();
883
884		assert_eq!(track.latest(), Some(0));
885		let values = drain_with(deflate_consumer(track));
886		assert_eq!(
887			values,
888			vec![json!({ "n": 20 })],
889			"compressed backlog should collapse to the latest"
890		);
891	}
892
893	#[test]
894	fn compressed_snapshot_per_group_roundtrips() {
895		let (mut producer, track) = producer(cfg_deflate(0));
896		producer.update(&json!({ "a": 1 })).unwrap();
897		producer.update(&json!({ "a": 2 })).unwrap();
898		producer.finish().unwrap();
899
900		// Deltas disabled: one compressed snapshot per group, latest reconstructs identically.
901		assert_eq!(track.latest(), Some(1));
902		let values = drain_with(deflate_consumer(track));
903		assert_eq!(values, vec![json!({ "a": 2 })]);
904	}
905
906	#[test]
907	fn compressed_deltas_share_one_group() {
908		let (mut producer, track) = producer(cfg_deflate(100));
909		producer.update(&json!({ "a": 1, "b": 1 })).unwrap();
910		producer.update(&json!({ "a": 1, "b": 2 })).unwrap();
911		producer.update(&json!({ "a": 1, "b": 3 })).unwrap();
912		producer.finish().unwrap();
913
914		// Snapshot + deltas in one group, each frame decompressed against the shared window.
915		assert_eq!(track.latest(), Some(0));
916		let values = drain_with(deflate_consumer(track));
917		assert_eq!(values.last().unwrap(), &json!({ "a": 1, "b": 3 }));
918	}
919
920	#[test]
921	fn compressed_late_joiner_reconstructs_from_deltas() {
922		let (mut producer, track) = producer(cfg_deflate(100));
923		producer.update(&json!({ "a": 1, "b": 1 })).unwrap();
924		producer.update(&json!({ "a": 1, "b": 2 })).unwrap();
925		producer.update(&json!({ "a": 5, "b": 2 })).unwrap();
926		producer.finish().unwrap();
927
928		// A consumer created only now rebuilds the final value from the compressed snapshot + deltas.
929		let values = drain_with(deflate_consumer(track));
930		assert_eq!(values.last().unwrap(), &json!({ "a": 5, "b": 2 }));
931	}
932
933	#[test]
934	fn compressed_deltas_roll_on_compressed_budget() {
935		// With compression the budget is measured on compressed frame sizes: `snapshot_len` and
936		// `delta_bytes` are the compressed slice lengths, not the raw JSON. A tight ratio over many
937		// distinct updates must therefore roll at least one group, and a late joiner must still rebuild
938		// the final value across the compressed group boundary (per-group decoder reset). Guards against
939		// the budget regressing to raw lengths.
940		let (mut producer, track) = producer(cfg_deflate(2));
941		for n in 0..=40 {
942			producer.update(&json!({ "n": n })).unwrap();
943		}
944		producer.finish().unwrap();
945
946		assert!(
947			track.latest().unwrap() > 0,
948			"a tight ratio should roll at least one compressed group"
949		);
950		assert_eq!(drain_with(deflate_consumer(track)).last().unwrap(), &json!({ "n": 40 }));
951	}
952
953	#[test]
954	fn compression_shrinks_wire_frames() {
955		// A repetitive payload should serialize to fewer wire bytes compressed than plaintext.
956		let value = json!({ "renditions": ["video".repeat(50), "video".repeat(50), "video".repeat(50)] });
957
958		let plaintext_bytes = wire_frame_len(cfg(0), &value);
959		let compressed_bytes = wire_frame_len(cfg_deflate(0), &value);
960		assert!(
961			compressed_bytes < plaintext_bytes,
962			"compressed frame {compressed_bytes} should be smaller than plaintext {plaintext_bytes}"
963		);
964	}
965
966	#[test]
967	fn compressed_deltas_reuse_window() {
968		// The shared per-group window is the whole point: a delta that restates content already in
969		// the snapshot compresses to far fewer bytes than the raw patch.
970		let (mut producer, mut track) = producer(cfg_deflate(100));
971		let phrase = "Media over QUIC delivers real-time latency at massive scale";
972		producer.update(&json!({ "note": phrase })).unwrap();
973		producer.update(&json!({ "note": phrase, "echo": phrase })).unwrap();
974		producer.finish().unwrap();
975
976		// Both frames land in group 0; read the delta (frame 1) verbatim.
977		let waiter = kio::Waiter::noop();
978		let Poll::Ready(Ok(Some(mut group))) = track.poll_next_group(&waiter) else {
979			panic!("expected a group");
980		};
981		let mut frames = Vec::new();
982		while let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) {
983			frames.push(frame.payload);
984		}
985		assert_eq!(frames.len(), 2, "snapshot + one delta in a single group");
986
987		// The raw patch repeats the whole phrase; compressed against the window it's a fraction.
988		let raw_delta = serde_json::to_vec(&json!({ "echo": phrase })).unwrap();
989		assert!(
990			frames[1].len() < raw_delta.len() / 2,
991			"windowed delta {} should be far below the raw patch {}",
992			frames[1].len(),
993			raw_delta.len()
994		);
995	}
996
997	/// Publish a single value and return the byte length of the resulting (frame 0) wire frame.
998	fn wire_frame_len(config: ProducerConfig, value: &Value) -> usize {
999		let (mut producer, mut track) = producer(config);
1000		producer.update(value).unwrap();
1001		producer.finish().unwrap();
1002
1003		let waiter = kio::Waiter::noop();
1004		let Poll::Ready(Ok(Some(mut group))) = track.poll_next_group(&waiter) else {
1005			panic!("expected a group");
1006		};
1007		// Read the stored (possibly compressed) frame bytes verbatim, without reconstructing JSON.
1008		let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) else {
1009			panic!("expected a frame");
1010		};
1011		frame.payload.len()
1012	}
1013}