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