Skip to main content

moq_json/window/
mod.rs

1//! Sliding-window JSON publishing over [`moq-net`](moq_net) tracks.
2//!
3//! A window is an ordered run of records the publisher appends to the back of and drops from the
4//! front of. Unlike [`stream`](crate::stream), which preserves a log forever in one group, and
5//! [`snapshot`](crate::snapshot), which keeps only the latest value, a window keeps a bounded
6//! stretch of records and lets a reader join it at any point.
7//!
8//! # Why a log can't do this
9//!
10//! The obvious alternative is an append-only log that rolls its group and re-seeds the new one with
11//! the records it still holds. That breaks the reader: re-seeded records are indistinguishable from
12//! new ones, so a reader that was keeping up receives them twice. This mode exists to make the
13//! restatement explicit, so a reader can tell "you already have these" from "here is another one".
14//!
15//! # On the wire
16//!
17//! The first frame of every group names the retained `records` and the absolute `offset` of the
18//! first. Later frames are tagged `push` and `pop` ops. A push takes the next index and a pop drops
19//! from the front, both positional against the group header.
20//! Indices stop at 2^53 - 1, the largest integer represented exactly by both implementations.
21//!
22//! Trimming is therefore an op, not a group boundary. Dropping a record costs one small frame
23//! inside the shared compression window instead of a roll that would throw that window away.
24//!
25//! # Group boundaries are invisible
26//!
27//! The publisher rolls a group when the ops in it outgrow
28//! [`ProducerConfig::op_ratio`](ProducerConfig::op_ratio) times the header that opened it, exactly as
29//! [`snapshot`](crate::snapshot) rolls on its delta budget. That is purely a compression decision:
30//! there is no caller-driven cut and no age bound, and a [`Consumer`] never surfaces it. A header
31//! restating records a reader already has yields nothing, so however often the publisher rolls, the
32//! reader sees one continuous stream of [`Event`]s.
33//!
34//! # What a reader is told
35//!
36//! A reader gets [`Event::Push`] when a record arrives, [`Event::Pop`] when a contiguous range
37//! leaves, and [`Event::Skip`] when a range was dropped before this reader saw it. A reader that
38//! keeps up sees pushes and pops; one that falls a group behind learns from the header's offset which
39//! records it will never get, rather than silently missing them.
40//!
41//! # Choosing a layer
42//!
43//! [`Producer`] and [`Consumer`] own a track. [`Encoder`] and [`Decoder`] are the same logic
44//! without it, for when something else is already in charge of the track; the encoder owns the
45//! retained window and says where the group boundaries fall, and the decoder turns frames into
46//! events.
47
48mod consumer;
49mod decoder;
50mod encoder;
51mod op;
52mod producer;
53
54pub use consumer::Consumer;
55pub use decoder::{ConsumerConfig, Decoder, Event, Group};
56pub use encoder::{Encoded, Encoder, Pending, ProducerConfig};
57pub use producer::Producer;
58
59#[cfg(test)]
60mod test {
61	use std::task::Poll;
62
63	use serde_json::{Value, json};
64
65	use super::*;
66
67	fn producer(config: ProducerConfig) -> (Producer<Value>, moq_net::track::Subscriber) {
68		let track = moq_net::broadcast::Info::new()
69			.produce()
70			.create_track("test", None)
71			.unwrap();
72		let consumer = track.subscribe(None);
73		(Producer::new(track, config), consumer)
74	}
75
76	fn consumer(track: moq_net::track::Subscriber, compression: bool) -> Consumer<Value> {
77		Consumer::new(track, ConsumerConfig::default().with_compression(compression))
78	}
79
80	/// A track whose timestamp conversion rejects every frame after its group is published.
81	fn rejecting_track() -> moq_net::track::Producer {
82		let mut info = moq_net::track::Info::default();
83		info.timescale = moq_net::Timescale::new((1u64 << 62) - 1).unwrap();
84
85		moq_net::broadcast::Info::new()
86			.produce()
87			.create_track("test", Some(info))
88			.unwrap()
89	}
90
91	/// Drain every event currently available without blocking.
92	fn drain(consumer: &mut Consumer<Value>) -> Vec<Event<Value>> {
93		let waiter = kio::Waiter::noop();
94		let mut out = Vec::new();
95		while let Poll::Ready(Ok(Some(event))) = consumer.poll_next(&waiter) {
96			out.push(event);
97		}
98		out
99	}
100
101	fn rec(n: u64) -> Value {
102		json!({ "n": n })
103	}
104
105	/// A producer and a consumer that reads after every edit.
106	///
107	/// Polling as the publisher goes is what "keeping up" means: a consumer left until the end is a
108	/// whole group behind, and the default subscription abandons a group as soon as a newer one
109	/// exists, so it would resume at the newest header instead of reading the rolls in between.
110	struct Live {
111		producer: Producer<Value>,
112		consumer: Consumer<Value>,
113		events: Vec<Event<Value>>,
114	}
115
116	impl Live {
117		fn new(config: ProducerConfig) -> Self {
118			let compression = config.compression;
119			let (producer, track) = producer(config);
120			Self {
121				producer,
122				consumer: consumer(track, compression),
123				events: Vec::new(),
124			}
125		}
126
127		fn push(&mut self, n: u64) {
128			self.producer.push(&rec(n)).unwrap();
129			self.read();
130		}
131
132		fn pop(&mut self, count: u64) {
133			self.producer.pop(count).unwrap();
134			self.read();
135		}
136
137		fn read(&mut self) {
138			self.events.extend(drain(&mut self.consumer));
139		}
140
141		fn finish(self) -> Vec<Event<Value>> {
142			let Live {
143				producer,
144				mut consumer,
145				mut events,
146			} = self;
147			producer.finish().unwrap();
148			events.extend(drain(&mut consumer));
149			events
150		}
151
152		/// Just the indices pushed, in order.
153		fn pushed(events: &[Event<Value>]) -> Vec<u64> {
154			events
155				.iter()
156				.filter_map(|e| match e {
157					Event::Push { index, .. } => Some(*index),
158					_ => None,
159				})
160				.collect()
161		}
162	}
163
164	#[test]
165	fn push_and_pop_round_trip() {
166		let mut live = Live::new(ProducerConfig::default());
167		live.push(0);
168		live.push(1);
169		live.pop(1);
170		live.push(2);
171
172		assert_eq!(
173			live.finish(),
174			vec![
175				Event::Push {
176					index: 0,
177					value: rec(0)
178				},
179				Event::Push {
180					index: 1,
181					value: rec(1)
182				},
183				Event::Pop(0..1),
184				Event::Push {
185					index: 2,
186					value: rec(2)
187				},
188			]
189		);
190	}
191
192	#[test]
193	fn the_window_slides() {
194		let (mut producer, _track) = producer(ProducerConfig::default());
195		for n in 0..5 {
196			producer.push(&rec(n)).unwrap();
197			if n >= 2 {
198				producer.pop(1).unwrap();
199			}
200		}
201
202		// Three pops leave the two newest records, at indices 3 and 4.
203		assert_eq!(producer.range(), 3..5);
204		assert_eq!(producer.window(), vec![rec(3), rec(4)]);
205	}
206
207	#[test]
208	fn a_popped_record_is_never_restated() {
209		// Ops disabled, so every single edit is its own group restating the whole window.
210		let mut live = Live::new(ProducerConfig::default().with_op_ratio(0));
211		live.push(0);
212		live.push(1);
213		live.pop(1);
214		live.push(2);
215
216		// Every edit restates the window, yet a record already delivered is never pushed twice. That
217		// is the property an append-only log cannot provide.
218		assert_eq!(
219			live.finish(),
220			vec![
221				Event::Push {
222					index: 0,
223					value: rec(0)
224				},
225				Event::Push {
226					index: 1,
227					value: rec(1)
228				},
229				Event::Pop(0..1),
230				Event::Push {
231					index: 2,
232					value: rec(2)
233				},
234			]
235		);
236	}
237
238	#[test]
239	fn a_fresh_consumer_adopts_the_offset_without_skipping_history() {
240		let track = moq_net::broadcast::Info::new()
241			.produce()
242			.create_track("test", None)
243			.unwrap();
244		let mut producer = Producer::<Value>::new(track, ProducerConfig::default().with_op_ratio(0));
245
246		for n in 0..5 {
247			producer.push(&rec(n)).unwrap();
248		}
249		producer.pop(3).unwrap();
250		let mut subscriber = producer.consume();
251		subscriber.start_at(subscriber.latest().unwrap());
252		let mut fresh = consumer(subscriber, false);
253		producer.finish().unwrap();
254
255		// Joining at offset 3 must not report 3 skips for records that were never this reader's to
256		// miss: it simply starts where the window starts.
257		let events = drain(&mut fresh);
258		assert_eq!(
259			events,
260			vec![
261				Event::Push {
262					index: 3,
263					value: rec(3)
264				},
265				Event::Push {
266					index: 4,
267					value: rec(4)
268				}
269			]
270		);
271		assert!(!events.iter().any(|e| matches!(e, Event::Skip(_))));
272	}
273
274	#[test]
275	fn a_lagging_consumer_is_told_what_it_missed() {
276		// Ops are disabled, so every edit opens a new group. Feed the first two groups to the
277		// decoder, skip the middle groups as a lagging track subscriber would, then resume at the
278		// latest header.
279		let mut encoder = Encoder::<Value>::new(ProducerConfig::default().with_op_ratio(0));
280		let mut decoder = Decoder::<Value>::new(ConsumerConfig::default());
281		for n in 0..2 {
282			let frame = encoder.push(&rec(n)).unwrap();
283			let mut group = decoder.group();
284			group.decode(&frame.payload).unwrap();
285			frame.commit();
286		}
287		assert_eq!(
288			std::iter::from_fn(|| decoder.next_event()).collect::<Vec<_>>(),
289			vec![
290				Event::Push {
291					index: 0,
292					value: rec(0)
293				},
294				Event::Push {
295					index: 1,
296					value: rec(1)
297				}
298			]
299		);
300
301		let mut latest = None;
302		for n in 2..8 {
303			let frame = encoder.push(&rec(n)).unwrap();
304			frame.commit();
305
306			let frame = encoder.pop(1).unwrap().unwrap();
307			latest = Some(frame.payload.clone());
308			frame.commit();
309		}
310		let mut group = decoder.group();
311		group.decode(&latest.unwrap()).unwrap();
312
313		let events = std::iter::from_fn(|| decoder.next_event()).collect::<Vec<_>>();
314		let skipped: Vec<std::ops::Range<u64>> = events
315			.iter()
316			.filter_map(|e| match e {
317				Event::Skip(range) => Some(range.clone()),
318				_ => None,
319			})
320			.collect();
321
322		// Records 2..=5 existed but this reader will never receive them, and it is told so rather than
323		// silently jumping from 1 to 6.
324		assert!(!skipped.is_empty(), "expected skips, got {events:?}");
325		assert_eq!(skipped.first().map(|range| range.start), Some(2));
326
327		// Every index is still accounted for exactly once, in order.
328		let reported: Vec<u64> = events
329			.iter()
330			.flat_map(|e| match e {
331				Event::Push { index, .. } => vec![*index],
332				Event::Skip(range) => range.clone().collect(),
333				Event::Pop(_) => Vec::new(),
334			})
335			.collect();
336		assert!(reported.windows(2).all(|w| w[1] == w[0] + 1), "gaps in {reported:?}");
337	}
338
339	#[test]
340	fn compressed_round_trip_across_rolls() {
341		let mut live = Live::new(ProducerConfig::default().with_compression(true).with_op_ratio(1));
342		for n in 0..40 {
343			live.push(n);
344			if n >= 10 {
345				live.pop(1);
346			}
347		}
348
349		// A tight ratio rolls many times; every record still arrives exactly once, in order.
350		assert_eq!(Live::pushed(&live.finish()), (0..40).collect::<Vec<_>>());
351	}
352
353	#[test]
354	fn an_empty_pop_writes_nothing() {
355		let (mut producer, track) = producer(ProducerConfig::default());
356		producer.pop(5).unwrap();
357		producer.finish().unwrap();
358
359		// Nothing was ever pushed, so there is nothing to drop and no group to publish.
360		assert_eq!(track.latest(), None);
361	}
362
363	#[test]
364	fn a_rejected_edit_leaves_the_window_unchanged() {
365		let track = rejecting_track();
366		let mut subscriber = track.subscribe(None);
367		let mut producer = Producer::<Value>::new(track, ProducerConfig::default());
368
369		assert!(producer.push(&rec(1)).is_err());
370		assert_eq!(producer.range(), 0..0);
371		assert!(producer.window().is_empty());
372
373		let waiter = kio::Waiter::noop();
374		let Poll::Ready(Ok(Some(mut group))) = subscriber.poll_next_group(&waiter) else {
375			panic!("the rejected group's header was published");
376		};
377		assert!(matches!(group.poll_read_frame(&waiter), Poll::Ready(Ok(None))));
378	}
379
380	#[test]
381	fn writes_after_another_clone_finishes_are_rejected() {
382		let (mut producer, _track) = producer(ProducerConfig::default());
383		producer.push(&rec(1)).unwrap();
384		producer.clone().finish().unwrap();
385
386		assert!(matches!(
387			producer.push(&rec(2)),
388			Err(crate::Error::Net(moq_net::Error::Closed))
389		));
390		assert!(matches!(
391			producer.pop(1),
392			Err(crate::Error::Net(moq_net::Error::Closed))
393		));
394		assert_eq!(producer.window(), vec![rec(1)]);
395	}
396
397	#[test]
398	fn a_pop_is_clamped_to_the_window() {
399		let mut live = Live::new(ProducerConfig::default());
400		live.push(0);
401		live.pop(9);
402		live.push(1);
403
404		assert_eq!(
405			live.finish(),
406			vec![
407				Event::Push {
408					index: 0,
409					value: rec(0)
410				},
411				Event::Pop(0..1),
412				Event::Push {
413					index: 1,
414					value: rec(1)
415				},
416			]
417		);
418	}
419
420	#[test]
421	fn a_large_gap_is_one_skip_event() {
422		let mut decoder = Decoder::<Value>::new(ConsumerConfig::default());
423		let mut group = decoder.group();
424		group.decode(br#"{"offset":0,"records":[]}"#).unwrap();
425		let mut group = decoder.group();
426		group.decode(br#"{"offset":9007199254740991,"records":[]}"#).unwrap();
427
428		assert_eq!(decoder.next_event(), Some(Event::Skip(0..super::encoder::MAX_INDEX)));
429		assert_eq!(decoder.next_event(), None);
430	}
431
432	#[test]
433	fn indices_must_fit_the_shared_safe_integer_range() {
434		let mut decoder = Decoder::<Value>::new(ConsumerConfig::default());
435		let mut group = decoder.group();
436		assert!(group.decode(br#"{"offset":9007199254740992,"records":[]}"#).is_err());
437
438		let mut decoder = Decoder::<Value>::new(ConsumerConfig::default());
439		let mut group = decoder.group();
440		group.decode(br#"{"offset":9007199254740991,"records":[]}"#).unwrap();
441		assert!(group.decode(br#"{"push":null}"#).is_err());
442	}
443
444	#[test]
445	fn every_group_requires_a_header() {
446		let mut decoder = Decoder::<Value>::new(ConsumerConfig::default());
447		let mut group = decoder.group();
448		group.decode(br#"{"offset":0,"records":[]}"#).unwrap();
449		let mut group = decoder.group();
450
451		assert!(group.decode(br#"{"push":null}"#).is_err());
452	}
453
454	#[test]
455	fn a_header_is_only_valid_as_frame_zero() {
456		let mut decoder = Decoder::<Value>::new(ConsumerConfig::default());
457		let mut group = decoder.group();
458		group.decode(br#"{"offset":0,"records":[]}"#).unwrap();
459
460		assert!(group.decode(br#"{"offset":0,"records":[]}"#).is_err());
461	}
462
463	#[test]
464	fn rolling_is_invisible_to_the_consumer() {
465		// The same edits, framed two ways: one group for everything, versus a roll per edit.
466		let edits = |ratio: u32| {
467			let mut live = Live::new(ProducerConfig::default().with_op_ratio(ratio));
468			for n in 0..6 {
469				live.push(n);
470				if n >= 3 {
471					live.pop(1);
472				}
473			}
474			live.finish()
475		};
476
477		assert_eq!(edits(1_000), edits(0));
478	}
479}