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