Skip to main content

moq_json/window/
encoder.rs

1//! The track-free half of window publishing: window edits in, frame payloads out.
2
3use std::collections::VecDeque;
4use std::marker::PhantomData;
5
6use bytes::Bytes;
7use serde::Serialize;
8use serde_json::Value;
9
10use super::op::{Header, Op};
11use crate::{Error, Result};
12
13/// Frames (header included) in one group before a new group is forced, matching
14/// [`snapshot`](crate::snapshot)'s cap. Kept well below moq-net's per-group frame cap so a late
15/// joiner can always read the header at frame 0, and so a roll always precedes
16/// [`moq_net::Error::GroupTooLarge`].
17pub(super) const MAX_GROUP_FRAMES: usize = 1024;
18
19/// Largest index represented exactly by both Rust and JavaScript implementations.
20pub(super) const MAX_INDEX: u64 = (1 << 53) - 1;
21
22/// Configuration for an [`Encoder`] and the [`Producer`](super::Producer) wrapping one.
23///
24/// Build from [`Default`] and override fields (the struct is `#[non_exhaustive]`, so new options
25/// stay additive), or chain the `with_*` setters.
26#[derive(Debug, Clone)]
27#[non_exhaustive]
28pub struct ProducerConfig {
29	/// How much the ops in a group may cost before a fresh group is emitted.
30	///
31	/// A new group opens once the pushes and pops *already written* exceed `op_ratio` times the
32	/// size of the group's header frame. The pending op is excluded from that check, so the one that
33	/// tips the group over budget still lands: a group overshoots by at most one op before rolling.
34	/// `0` disables ops entirely, so every edit is its own single-frame group.
35	///
36	/// This is the window's counterpart to
37	/// [`snapshot::Config::delta_ratio`](crate::snapshot::Config::delta_ratio), and
38	/// the same trade: a bigger ratio spends less on headers and makes a late joiner read more ops.
39	///
40	/// Defaults to `8`.
41	pub op_ratio: u32,
42
43	/// Compress each group as one sync-flushed DEFLATE stream, so every op reuses the header and the
44	/// ops before it as context.
45	///
46	/// `false` (the default) emits plaintext JSON frames. A [`Decoder`](super::Decoder) reading them
47	/// must set [`ConsumerConfig::compression`](super::ConsumerConfig::compression) to match.
48	pub compression: bool,
49
50	/// Maximum records retained and repeated in a group checkpoint.
51	///
52	/// `None` (the default) repeats the complete window. A bound keeps checkpoints finite for an
53	/// unbounded window: readers following every group retain earlier records, while one joining a
54	/// later group receives [`Event::Skip`](super::Event::Skip) for the omitted prefix.
55	pub checkpoint_records: Option<usize>,
56}
57
58impl Default for ProducerConfig {
59	fn default() -> Self {
60		Self {
61			op_ratio: 8,
62			compression: false,
63			checkpoint_records: None,
64		}
65	}
66}
67
68impl ProducerConfig {
69	/// Set [`op_ratio`](Self::op_ratio) (a builder, since the struct is `#[non_exhaustive]`).
70	pub fn with_op_ratio(mut self, op_ratio: u32) -> Self {
71		self.op_ratio = op_ratio;
72		self
73	}
74
75	/// Set [`compression`](Self::compression) (a builder, since the struct is `#[non_exhaustive]`).
76	pub fn with_compression(mut self, compression: bool) -> Self {
77		self.compression = compression;
78		self
79	}
80
81	/// Set [`checkpoint_records`](Self::checkpoint_records). Must be at least one.
82	pub fn with_checkpoint_records(mut self, checkpoint_records: usize) -> Self {
83		assert!(checkpoint_records > 0, "checkpoint_records must be positive");
84		self.checkpoint_records = Some(checkpoint_records);
85		self
86	}
87}
88
89/// One encoded frame, and the group boundary it implies.
90#[derive(Clone, Debug)]
91#[non_exhaustive]
92pub struct Encoded {
93	/// The frame payload, DEFLATE-compressed when [`ProducerConfig::compression`] is set.
94	pub payload: Bytes,
95
96	/// Whether this frame is a group header, which must open a new group.
97	///
98	/// The encoder decides this, never the caller: the op budget and the frame cap force a new group
99	/// independently of which edit was requested.
100	pub keyframe: bool,
101}
102
103/// An encoded frame the caller has not yet acknowledged writing.
104///
105/// Write the frame, then [`commit`](Self::commit). The edit is staged until commit, so dropping the
106/// frame leaves the window unchanged and makes the next frame open a new group.
107#[must_use = "write the frame, then commit it"]
108pub struct Pending<'a, T> {
109	encoder: &'a mut Encoder<T>,
110	encoded: Encoded,
111	edit: Option<Edit>,
112}
113
114/// The window mutation staged behind a [`Pending`] frame.
115enum Edit {
116	Push(Value),
117	Pop(u64),
118}
119
120impl<T> std::ops::Deref for Pending<'_, T> {
121	type Target = Encoded;
122
123	fn deref(&self) -> &Encoded {
124		&self.encoded
125	}
126}
127
128impl<T> Pending<'_, T> {
129	/// Acknowledge that the frame reached the wire, applying its edit to the retained window.
130	///
131	/// Only call this once the write has actually succeeded.
132	pub fn commit(mut self) {
133		let edit = self.edit.take().expect("pending edit");
134		self.encoder.commit(edit);
135	}
136}
137
138impl<T> Drop for Pending<'_, T> {
139	fn drop(&mut self) {
140		if self.edit.is_some() {
141			self.encoder.resync();
142		}
143	}
144}
145
146/// Encodes window edits into frame payloads, deciding where the group boundaries fall.
147///
148/// The track-free core of [`Producer`](super::Producer). It owns the retained window, so it can
149/// restate it whenever a group rolls; that restatement is the whole point of the mode, and is what
150/// an append-only log cannot do.
151///
152/// Frames must reach the wire in the order they were encoded, and a frame with
153/// [`keyframe`](Encoded::keyframe) set must open a new group: both the positional indices and the
154/// group-scoped DEFLATE window depend on it.
155pub struct Encoder<T> {
156	config: ProducerConfig,
157
158	/// The decodable checkpoint suffix. With no checkpoint bound this is the complete window.
159	window: VecDeque<Value>,
160
161	/// Absolute index of the oldest logically retained record.
162	offset: u64,
163
164	/// Absolute index of `window.front()`, which may follow `offset` in checkpoint mode.
165	start: u64,
166
167	/// The current group's DEFLATE encoder (one window per group), `Some` while compressing.
168	flate: Option<moq_flate::Encoder>,
169
170	/// Bytes of pushes and pops emitted into the current group, excluding its header frame.
171	op_bytes: u64,
172
173	/// Reference size the op budget is measured against: the current group's header frame.
174	header_len: u64,
175
176	/// Frames emitted into the current group, header included.
177	group_frames: usize,
178
179	/// Whether the next frame must be a header because a frame was lost. Kept separate from the
180	/// window, which a resync must never discard.
181	resync: bool,
182
183	_marker: PhantomData<fn(T)>,
184}
185
186impl<T> Encoder<T> {
187	/// Create an encoder with an empty window, so the first edit opens a group.
188	pub fn new(config: ProducerConfig) -> Self {
189		assert!(
190			config.checkpoint_records != Some(0),
191			"checkpoint_records must be positive"
192		);
193		Self {
194			config,
195			window: VecDeque::new(),
196			offset: 0,
197			start: 0,
198			flate: None,
199			op_bytes: 0,
200			header_len: 0,
201			group_frames: 0,
202			resync: true,
203			_marker: PhantomData,
204		}
205	}
206
207	/// The retained checkpoint suffix, oldest first.
208	///
209	/// This is the complete window unless [`ProducerConfig::checkpoint_records`] is set.
210	pub fn window(&self) -> Vec<Value> {
211		self.window.iter().cloned().collect()
212	}
213
214	/// Absolute index of the oldest retained record, and of the next one to be pushed.
215	pub fn range(&self) -> std::ops::Range<u64> {
216		self.offset..self.start + self.window.len() as u64
217	}
218
219	/// Discard group-local state after an encoded frame did not reach the wire.
220	fn resync(&mut self) {
221		self.flate = None;
222		self.op_bytes = 0;
223		self.header_len = 0;
224		self.group_frames = 0;
225		self.resync = true;
226	}
227
228	/// Apply an edit after its encoded frame reached the wire.
229	fn commit(&mut self, edit: Edit) {
230		match edit {
231			Edit::Push(record) => {
232				self.window.push_back(record);
233				if let Some(limit) = self.config.checkpoint_records {
234					while self.window.len() > limit {
235						self.window.pop_front();
236						self.start += 1;
237					}
238				}
239			}
240			Edit::Pop(count) => {
241				let offset = self.offset + count;
242				let stored = offset.saturating_sub(self.start).min(self.window.len() as u64);
243				self.window.drain(..stored as usize);
244				self.start += stored;
245				self.offset = offset;
246			}
247		}
248	}
249
250	/// Whether the pending edit may ride as an op in the open group.
251	fn op_allowed(&self) -> bool {
252		let ratio = u64::from(self.config.op_ratio);
253		ratio != 0
254			&& self.group_frames > 0
255			&& self.group_frames < MAX_GROUP_FRAMES
256			&& self.op_bytes <= ratio * self.header_len
257	}
258
259	/// Reject plaintext that the paired DEFLATE decoder could not produce.
260	fn validate_plaintext(len: usize, kind: &str) -> Result<()> {
261		if u64::try_from(len).unwrap_or(u64::MAX) > moq_flate::DEFAULT_MAX_FRAME_SIZE {
262			return Err(Error::Json(format!(
263				"window {kind} exceeds the decoder's decompressed size limit"
264			)));
265		}
266		Ok(())
267	}
268
269	/// Compress an already-serialized op into the open group, charging it to the budget.
270	fn frame(&mut self, bytes: Vec<u8>) -> Result<Encoded> {
271		Self::validate_plaintext(bytes.len(), "frame")?;
272		let payload = match self.flate.as_mut() {
273			Some(flate) => flate.frame(&bytes),
274			None => Bytes::from(bytes),
275		};
276
277		self.op_bytes += payload.len() as u64;
278		self.group_frames += 1;
279
280		Ok(Encoded {
281			payload,
282			keyframe: false,
283		})
284	}
285
286	/// Emit an op when the header will remain cached, otherwise restate the window in a new group.
287	fn emit_op(&mut self, bytes: Vec<u8>) -> Result<Option<Encoded>> {
288		let encoded = self.frame(bytes)?;
289		let group_bytes = self.header_len.saturating_add(self.op_bytes);
290		if group_bytes > moq_net::group::MAX_CACHE_BYTES {
291			self.resync();
292			Ok(None)
293		} else {
294			Ok(Some(encoded))
295		}
296	}
297
298	/// Serialize a bounded suffix before mutably borrowing the compression state.
299	fn header<'a>(
300		config: &ProducerConfig,
301		offset: u64,
302		start: u64,
303		len: usize,
304		records: impl Iterator<Item = &'a Value>,
305	) -> Result<Vec<u8>> {
306		let skip = config
307			.checkpoint_records
308			.map(|limit| len.saturating_sub(limit))
309			.unwrap_or_default();
310		let start = start
311			.checked_add(skip as u64)
312			.ok_or_else(|| Error::Json("window checkpoint exceeds u64".into()))?;
313		let header = Header {
314			offset,
315			start: (start != offset).then_some(start),
316			records: records.skip(skip).collect(),
317		};
318		Ok(serde_json::to_vec(&header)?)
319	}
320
321	/// Encode the header restating the whole window and opening a new group.
322	fn emit_header(&mut self, bytes: Vec<u8>) -> Result<Encoded> {
323		Self::validate_plaintext(bytes.len(), "header")?;
324
325		// Open a fresh per-group encoder (cold window) and compress the header as frame 0, recording
326		// its wire size as the op budget's anchor.
327		let (payload, flate) = match self.config.compression {
328			true => {
329				let mut flate = moq_flate::Encoder::new();
330				let payload = flate.frame(&bytes);
331				(payload, Some(flate))
332			}
333			false => (Bytes::from(bytes), None),
334		};
335		if payload.len() as u64 > moq_net::group::MAX_CACHE_BYTES {
336			return Err(Error::Json("window header exceeds the group cache limit".into()));
337		}
338
339		self.header_len = payload.len() as u64;
340		self.op_bytes = 0;
341		self.group_frames = 1;
342		self.flate = flate;
343		self.resync = false;
344
345		Ok(Encoded {
346			payload,
347			keyframe: true,
348		})
349	}
350
351	/// Drop `count` records from the front of the window.
352	///
353	/// Returns `None` when there is nothing to drop, so a caller can trim unconditionally. Emits a
354	/// pop into the open group, or a header restating what is left in a new group.
355	pub fn pop(&mut self, count: u64) -> Result<Option<Pending<'_, T>>> {
356		let count = count.min(self.range().end - self.offset);
357		if count == 0 {
358			return Ok(None);
359		}
360
361		let offset = self.offset + count;
362		let stored = offset.saturating_sub(self.start).min(self.window.len() as u64) as usize;
363		let start = self.start + stored as u64;
364		let encoded = match self.resync || !self.op_allowed() {
365			true => {
366				let bytes = Self::header(
367					&self.config,
368					offset,
369					start,
370					self.window.len() - stored,
371					self.window.iter().skip(stored),
372				)?;
373				self.emit_header(bytes)?
374			}
375			false => {
376				let bytes = serde_json::to_vec(&Op::<&Value>::Pop(count))?;
377				match self.emit_op(bytes)? {
378					Some(encoded) => encoded,
379					None => {
380						let bytes = Self::header(
381							&self.config,
382							offset,
383							start,
384							self.window.len() - stored,
385							self.window.iter().skip(stored),
386						)?;
387						self.emit_header(bytes)?
388					}
389				}
390			}
391		};
392
393		Ok(Some(self.pending(encoded, Edit::Pop(count))))
394	}
395
396	/// Wrap an encoded frame so the caller has to say whether it reached the wire.
397	fn pending(&mut self, encoded: Encoded, edit: Edit) -> Pending<'_, T> {
398		Pending {
399			encoder: self,
400			encoded,
401			edit: Some(edit),
402		}
403	}
404}
405
406impl<T: Serialize> Encoder<T> {
407	/// Append one record to the back of the window.
408	///
409	/// Emits a push into the open group, or a header restating the window (the new record included)
410	/// when the op budget is spent or a frame was lost.
411	pub fn push(&mut self, value: &T) -> Result<Pending<'_, T>> {
412		// Serialize before touching the window, so a value that can't be encoded leaves the encoder
413		// exactly as it was. Reading the record back out of its own bytes keeps the stored copy
414		// identical to what a push would have put on the wire.
415		let bytes = serde_json::to_vec(value)?;
416		let record: Value = serde_json::from_slice(&bytes)?;
417		if self.range().end >= MAX_INDEX {
418			return Err(crate::Error::Json("window index exceeds the safe integer range".into()));
419		}
420
421		let encoded = match self.resync || !self.op_allowed() {
422			true => {
423				let bytes = Self::header(
424					&self.config,
425					self.offset,
426					self.start,
427					self.window.len() + 1,
428					self.window.iter().chain(std::iter::once(&record)),
429				)?;
430				self.emit_header(bytes)?
431			}
432			false => {
433				let bytes = serde_json::to_vec(&Op::Push(&record))?;
434				match self.emit_op(bytes)? {
435					Some(encoded) => encoded,
436					None => {
437						let bytes = Self::header(
438							&self.config,
439							self.offset,
440							self.start,
441							self.window.len() + 1,
442							self.window.iter().chain(std::iter::once(&record)),
443						)?;
444						self.emit_header(bytes)?
445					}
446				}
447			}
448		};
449
450		Ok(self.pending(encoded, Edit::Push(record)))
451	}
452}
453
454#[cfg(test)]
455mod test {
456	use super::*;
457
458	#[test]
459	fn an_op_that_would_evict_the_header_rolls_first() {
460		let mut encoder = Encoder::<String>::new(ProducerConfig::default().with_op_ratio(u32::MAX));
461		let first = "a".repeat(16 * 1024 * 1024);
462		let next = "b".repeat(15 * 1024 * 1024);
463
464		let frame = encoder.push(&first).unwrap();
465		assert!(frame.keyframe);
466		frame.commit();
467
468		let frame = encoder.push(&next).unwrap();
469		assert!(!frame.keyframe);
470		frame.commit();
471
472		let frame = encoder.pop(1).unwrap().unwrap();
473		assert!(!frame.keyframe);
474		frame.commit();
475
476		let frame = encoder.push(&next).unwrap();
477		assert!(frame.keyframe);
478		assert!(frame.payload.len() < moq_net::group::MAX_CACHE_BYTES as usize);
479		frame.commit();
480	}
481
482	#[test]
483	fn an_uncommitted_edit_leaves_the_window_unchanged() {
484		let mut encoder = Encoder::<u64>::new(ProducerConfig::default());
485
486		drop(encoder.push(&1).unwrap());
487		assert!(encoder.window().is_empty());
488
489		let frame = encoder.push(&2).unwrap();
490		assert!(frame.keyframe);
491		frame.commit();
492		assert_eq!(encoder.window(), vec![Value::from(2)]);
493
494		drop(encoder.pop(1).unwrap().unwrap());
495		assert_eq!(encoder.window(), vec![Value::from(2)]);
496	}
497
498	#[test]
499	fn a_header_larger_than_the_group_cache_is_rejected() {
500		let mut encoder = Encoder::<String>::new(ProducerConfig::default());
501		let record = "x".repeat(moq_net::group::MAX_CACHE_BYTES as usize);
502
503		let err = encoder.push(&record).err().expect("oversized header should fail");
504		assert!(err.to_string().contains("group cache limit"));
505		assert!(encoder.window().is_empty());
506
507		let frame = encoder.push(&"ok".to_string()).unwrap();
508		assert!(frame.keyframe);
509	}
510
511	#[test]
512	fn plaintext_is_bounded_by_the_decoder_limit() {
513		let len = usize::try_from(moq_flate::DEFAULT_MAX_FRAME_SIZE + 1).unwrap();
514		assert!(Encoder::<()>::validate_plaintext(len, "frame").is_err());
515	}
516}