Skip to main content

moq_json/snapshot/
decoder.rs

1//! The track-free half of snapshot consuming: frame payloads in, values out.
2
3use std::cell::RefCell;
4use std::marker::PhantomData;
5
6use serde::de::DeserializeOwned;
7use serde_json::Value;
8
9use super::consumer::Config;
10use crate::{Error, Result};
11
12/// Reconstructs a JSON value from the snapshot and delta frames of a group.
13///
14/// The track-free core of [`Consumer`](super::Consumer), and the mirror of
15/// [`Encoder`](super::Encoder). The caller reads frames from wherever it likes and routes each one
16/// by its position in the group: the first frame of every group is a
17/// [`snapshot`](Self::snapshot), the rest are [`delta`](Self::delta)s.
18///
19/// ```ignore
20/// match frame.keyframe {
21///     true => decoder.snapshot(&frame.payload)?,
22///     false => decoder.delta(&frame.payload)?,
23/// }
24/// let value = decoder.decode()?;
25/// ```
26///
27/// Applying and materializing are separate on purpose. Frames must be applied in order (the merge
28/// patches and the DEFLATE window are both sequential), but a consumer catching up on a backlog only
29/// wants the value at the head, so it applies every frame and calls [`decode`](Self::decode) once.
30/// A caller that wants a value per frame just calls it every time.
31pub struct Decoder<T> {
32	/// Whether frames are DEFLATE-compressed, matching the encoder's config.
33	compression: bool,
34
35	/// Maximum bytes in an inflated frame or the reconstructed value's JSON.
36	limit: usize,
37
38	/// The reconstructed value's size in JSON bytes: the snapshot's own bytes, then moved by each
39	/// patch's exact effect on the compact JSON. Measuring only what a patch touches keeps the
40	/// cost per frame proportional to that frame, never to the whole value. The snapshot is not
41	/// re-serialized, so a number printed longer than its source (`9e9`) goes uncounted, but the
42	/// value's memory follows its nodes, which its bytes bound all the same.
43	size: usize,
44
45	/// The current group's DEFLATE decoder (one window per group), rebuilt at each snapshot.
46	flate: Option<moq_flate::Decoder>,
47
48	/// Reused output for inflated delta frames.
49	plain: Vec<u8>,
50
51	/// Reused key buffers for validating remote patches before changing the baseline.
52	check: RefCell<crate::merge::CheckScratch>,
53
54	/// The reconstructed value, `None` until the first snapshot.
55	current: Option<Value>,
56
57	_marker: PhantomData<fn() -> T>,
58}
59
60impl<T> Decoder<T> {
61	/// Create a decoder with no value, awaiting its first [`snapshot`](Self::snapshot).
62	pub fn new(config: Config) -> Self {
63		Self {
64			compression: config.compression.is_deflate(),
65			// Unset falls back to the largest frame a producer emits, which also caps its snapshots,
66			// so any value it can publish fits while a peer cannot grow the state without bound.
67			limit: config.max_size.unwrap_or(moq_flate::DEFAULT_MAX_FRAME_SIZE as usize),
68			size: 0,
69			flate: None,
70			plain: Vec::new(),
71			check: RefCell::new(crate::merge::CheckScratch::default()),
72			current: None,
73			_marker: PhantomData,
74		}
75	}
76
77	/// Apply a group's first frame: a full snapshot that replaces the current value.
78	///
79	/// Also starts the group's DEFLATE window, so this must be called at every group boundary, not
80	/// only the first.
81	pub fn snapshot(&mut self, payload: &[u8]) -> Result<()> {
82		self.current = None;
83		// Every group starts a new DEFLATE window; enforce the budget while inflating.
84		self.flate = self.compression.then(|| {
85			moq_flate::Decoder::with_max_frame_size((self.limit as u64).min(moq_flate::DEFAULT_MAX_FRAME_SIZE))
86		});
87		let inflated = self.flate.as_mut().map(|flate| flate.frame(payload)).transpose()?;
88		let plain = inflated.as_deref().unwrap_or(payload);
89		if plain.len() > self.limit {
90			return Err(Error::TooLarge(self.limit));
91		}
92		self.current = Some(serde_json::from_slice(plain)?);
93		self.size = plain.len();
94		Ok(())
95	}
96
97	/// Apply a merge patch, refusing frames or reconstructed state beyond the configured budget.
98	/// A failed patch invalidates the baseline; start a new snapshot before applying more deltas.
99	pub fn delta(&mut self, payload: &[u8]) -> Result<()> {
100		if self.current.is_none() {
101			return Err(Error::MissingSnapshot);
102		}
103		let result = (|| {
104			let plain = match self.flate.as_mut() {
105				Some(flate) => {
106					flate.frame_into(payload, &mut self.plain)?;
107					self.plain.as_slice()
108				}
109				None => payload,
110			};
111			if plain.len() > self.limit {
112				return Err(Error::TooLarge(self.limit));
113			}
114			let current = self.current.as_mut().expect("a snapshot precedes any delta");
115			let growth = crate::merge::apply_bytes(current, plain, &self.check)?;
116			self.size = (self.size as i64).saturating_add(growth).max(0) as usize;
117			// An oversized value is dropped before anything can materialize it.
118			if self.size > self.limit {
119				return Err(Error::TooLarge(self.limit));
120			}
121			Ok(())
122		})();
123		if result.is_err() {
124			self.current = None;
125		}
126		result
127	}
128
129	/// The reconstructed value as raw JSON, or `None` before the first snapshot.
130	pub fn value(&self) -> Option<&Value> {
131		self.current.as_ref()
132	}
133}
134
135impl<T: DeserializeOwned> Decoder<T> {
136	/// Materialize the reconstructed value as `T`, or `None` before the first snapshot.
137	///
138	/// Deserializing from the reconstructed [`Value`] rather than the frame bytes costs the line and
139	/// column a parse error would carry, so the error is prefixed with the JSON path of the offending
140	/// field instead. Without it a rejected field deep in a document reports only its own complaint,
141	/// with nothing to say where it came from.
142	pub fn decode(&self) -> Result<Option<T>> {
143		let Some(current) = self.current.as_ref() else {
144			return Ok(None);
145		};
146
147		// Tracking the path allocates for every key walked, which dwarfed the decode itself on large
148		// documents, so it only runs again to explain a failure.
149		if let Ok(value) = T::deserialize(current) {
150			return Ok(Some(value));
151		}
152
153		let value = serde_path_to_error::deserialize(current).map_err(|err| {
154			let path = err.path().to_string();
155			match path.as_str() {
156				// The whole document, not a field within it: nothing useful to prefix.
157				"." => Error::Json(err.into_inner().to_string()),
158				_ => Error::Json(format!("{}: {}", path, err.into_inner())),
159			}
160		})?;
161
162		Ok(Some(value))
163	}
164}
165
166#[cfg(test)]
167mod test {
168	use super::super::consumer::Config as ConsumerConfig;
169	use super::super::{Config, Encoder};
170	use super::*;
171	use crate::Compression;
172	use serde_json::{Value, json};
173
174	fn consume(compression: Compression) -> ConsumerConfig {
175		ConsumerConfig {
176			compression,
177			..Default::default()
178		}
179	}
180
181	fn deflate() -> Config {
182		Config {
183			compression: Compression::Deflate,
184			..Default::default()
185		}
186	}
187
188	/// Round-trip a sequence of values through an encoder and decoder, yielding the value the
189	/// decoder reconstructs after each frame.
190	fn roundtrip(config: Config, values: &[Value]) -> Vec<Value> {
191		let compression = config.compression;
192		let mut encoder = Encoder::<Value>::new(config);
193		let mut decoder = Decoder::<Value>::new(consume(compression));
194
195		let mut out = Vec::new();
196		for value in values {
197			let Some(frame) = encoder.update(value).unwrap() else {
198				continue;
199			};
200			match frame.keyframe {
201				true => decoder.snapshot(&frame.payload).unwrap(),
202				false => decoder.delta(&frame.payload).unwrap(),
203			}
204			frame.commit();
205			out.push(decoder.decode().unwrap().unwrap());
206		}
207		out
208	}
209
210	#[test]
211	fn inflated_snapshots_and_patches_obey_the_budget() {
212		for snapshot in [true, false] {
213			let mut config = consume(Compression::Deflate);
214			config.max_size = Some(1024);
215			let mut decoder = Decoder::<Value>::new(config);
216			let mut encoder = moq_flate::Encoder::new();
217			if !snapshot {
218				decoder.snapshot(&encoder.frame(b"{}")).unwrap();
219			}
220			let payload = encoder.frame(
221				serde_json::to_string(&json!({"large": "x".repeat(4096)}))
222					.unwrap()
223					.as_bytes(),
224			);
225			assert!(payload.len() < 1024);
226			let result = if snapshot {
227				decoder.snapshot(&payload)
228			} else {
229				decoder.delta(&payload)
230			};
231			assert!(matches!(result, Err(Error::Flate(moq_flate::Error::TooLarge(1024)))));
232			assert!(decoder.value().is_none());
233		}
234	}
235
236	#[test]
237	fn patches_cannot_accumulate_past_the_budget() {
238		for compression in [Compression::None, Compression::Deflate] {
239			let mut config = consume(compression);
240			config.max_size = Some(13);
241			let mut decoder = Decoder::<Value>::new(config);
242			let mut encoder = moq_flate::Encoder::new();
243			let mut payload = |bytes: &[u8]| {
244				if compression == Compression::Deflate {
245					encoder.frame(bytes).to_vec()
246				} else {
247					bytes.to_vec()
248				}
249			};
250			decoder.snapshot(&payload(br#"{"a":1}"#)).unwrap();
251			decoder.delta(&payload(br#"{"b":2}"#)).unwrap(); // exactly 13 bytes
252			assert_eq!(decoder.value(), Some(&json!({"a":1,"b":2})));
253			decoder.delta(&payload(br#"{"a":null}"#)).unwrap(); // deletion frees budget
254			decoder.delta(&payload(br#"{"c":3}"#)).unwrap();
255			assert!(matches!(
256				decoder.delta(&payload(br#"{"d":4}"#)),
257				Err(Error::TooLarge(13))
258			));
259			assert!(decoder.value().is_none());
260			assert!(matches!(decoder.delta(b"{}"), Err(Error::MissingSnapshot)));
261			// A fresh group can recover after refusal.
262			let bytes = if compression == Compression::Deflate {
263				moq_flate::Encoder::new().frame(b"{}").to_vec()
264			} else {
265				b"{}".to_vec()
266			};
267			decoder.snapshot(&bytes).unwrap();
268		}
269	}
270
271	#[test]
272	fn unset_budget_still_bounds_compressed_state() {
273		// Each frame inflates under the per-frame cap, but together they would grow the state past
274		// it, which no producer could publish as a snapshot.
275		let half = moq_flate::DEFAULT_MAX_FRAME_SIZE as usize / 2;
276		let mut decoder = Decoder::<Value>::new(consume(Compression::Deflate));
277		let mut encoder = moq_flate::Encoder::new();
278		let mut frame = |key: &str| encoder.frame(format!(r#"{{"{key}":"{}"}}"#, "x".repeat(half)).as_bytes());
279		decoder.snapshot(&frame("a")).unwrap();
280		assert!(matches!(
281			decoder.delta(&frame("b")),
282			Err(Error::TooLarge(limit)) if limit as u64 == moq_flate::DEFAULT_MAX_FRAME_SIZE
283		));
284		assert!(decoder.value().is_none());
285	}
286
287	#[test]
288	fn plain_frames_obey_the_budget_before_parsing() {
289		let config = ConsumerConfig {
290			max_size: Some(2),
291			..Default::default()
292		};
293		let mut decoder = Decoder::<Value>::new(config);
294		assert!(matches!(decoder.snapshot(b"not json"), Err(Error::TooLarge(2))));
295		decoder.snapshot(b"{}").unwrap();
296		assert!(matches!(decoder.delta(b"not json"), Err(Error::TooLarge(2))));
297		assert!(decoder.value().is_none());
298	}
299
300	#[test]
301	fn plaintext_roundtrip() {
302		let values = vec![
303			json!({ "a": 1, "b": 1 }),
304			json!({ "a": 1, "b": 2 }),
305			json!({ "a": 5, "b": 2 }),
306		];
307		assert_eq!(roundtrip(Config::default(), &values), values);
308	}
309
310	#[test]
311	fn compressed_roundtrip() {
312		let values = vec![
313			json!({ "a": 1, "b": 1 }),
314			json!({ "a": 1, "b": 2 }),
315			json!({ "a": 5, "b": 2 }),
316		];
317		assert_eq!(roundtrip(deflate(), &values), values);
318	}
319
320	/// The window is per group, so a keyframe mid-stream has to restart it on both sides. A decoder
321	/// that kept the old window here would fail to inflate the new group's snapshot.
322	#[test]
323	fn compressed_roundtrip_across_a_group_boundary() {
324		// A tight ratio guarantees at least one roll partway through.
325		let values: Vec<Value> = (0..=40).map(|n| json!({ "n": n })).collect();
326		let config = deflate().with_delta_ratio(2);
327		assert_eq!(roundtrip(config, &values).last().unwrap(), &json!({ "n": 40 }));
328	}
329
330	#[test]
331	fn no_value_before_the_first_snapshot() {
332		let decoder = Decoder::<Value>::new(ConsumerConfig::default());
333		assert_eq!(decoder.value(), None);
334		assert_eq!(decoder.decode().unwrap(), None);
335	}
336
337	#[test]
338	fn a_delta_before_a_snapshot_is_an_error() {
339		let mut decoder = Decoder::<Value>::new(ConsumerConfig::default());
340		assert!(matches!(decoder.delta(br#"{"a":1}"#), Err(Error::MissingSnapshot)));
341	}
342
343	/// A backlog is applied in full but materialized once: the intermediate reconstructions are
344	/// stale, and deserializing each one is exactly the cost the split exists to avoid.
345	#[test]
346	fn frames_apply_without_materializing() {
347		let mut encoder = Encoder::<Value>::new(Config::default().with_delta_ratio(100));
348		let mut decoder = Decoder::<Value>::new(ConsumerConfig::default());
349
350		for n in 0..=20 {
351			let frame = encoder.update(&json!({ "n": n })).unwrap().unwrap();
352			match frame.keyframe {
353				true => decoder.snapshot(&frame.payload).unwrap(),
354				false => decoder.delta(&frame.payload).unwrap(),
355			}
356			frame.commit();
357		}
358
359		assert_eq!(decoder.decode().unwrap(), Some(json!({ "n": 20 })));
360	}
361
362	#[test]
363	fn a_rejected_field_names_its_path() {
364		#[derive(serde::Deserialize, Debug)]
365		#[allow(dead_code)]
366		struct Inner {
367			count: u8,
368		}
369		#[derive(serde::Deserialize, Debug)]
370		#[allow(dead_code)]
371		struct Outer {
372			inner: Inner,
373		}
374
375		let mut decoder = Decoder::<Outer>::new(ConsumerConfig::default());
376		decoder.snapshot(br#"{"inner":{"count":300}}"#).unwrap();
377
378		let err = decoder.decode().unwrap_err();
379		assert!(err.to_string().starts_with("json: inner.count: "), "{err}");
380	}
381}