Skip to main content

moq_json/
lib.rs

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