Skip to main content

spate_json/
deser.rs

1//! The JSON deserializers.
2//!
3//! Both deserializers share [`DecoderCore`]: framing → per-item decode →
4//! emit. Framing splits one payload into 0..N JSON documents; each document is
5//! decoded into the record type and emitted with the payload's metadata and a
6//! clone of the batch ack. Empty or whitespace-only payloads decode to zero
7//! records (source tombstones).
8//!
9//! Backpressure is handled by the chain *between* payloads, so the decoder
10//! emits every record for a payload and ignores the [`Flow`] return (like the
11//! framework's other decoders); the driver stops pulling the next payload when
12//! downstream is full.
13
14use crate::backend;
15use crate::config::{JsonFraming, OnError};
16use crate::metrics::JsonDeserMetrics;
17use serde::de::DeserializeOwned;
18use serde_json::Value;
19use spate_core::checkpoint::AckRef;
20use spate_core::deser::{Deserializer, EmitRecord, Owned};
21use spate_core::error::DeserError;
22use spate_core::record::{RawPayload, Record};
23use spate_core::telemetry::RateLimit;
24use std::marker::PhantomData;
25use std::time::Duration;
26
27/// Rate-limits the per-record skip warning so a poison storm can't flood the
28/// logs; the exact drop count is always in `spate_json_deser_records_dropped_total`.
29static SKIP_WARN: RateLimit = RateLimit::new(5, Duration::from_secs(10));
30
31/// Why a record was dropped, for the metric label and log field.
32#[derive(Clone, Copy)]
33enum Reason {
34    Malformed,
35    DuplicateKey,
36}
37
38impl Reason {
39    fn label(self) -> &'static str {
40        match self {
41            Reason::Malformed => "malformed",
42            Reason::DuplicateKey => "duplicate_key",
43        }
44    }
45}
46
47/// Classify an error returned by the duplicate-key structural pass.
48/// [`DupGuard`](backend) accepts every JSON shape, so a *data* error is our
49/// injected duplicate-key rejection, while a *syntax*/EOF error is just
50/// malformed input the structural pass happened to reach before the decode
51/// did — which must still be reported as `malformed`, not `duplicate_key`.
52fn dup_check_reason(e: &backend::DecodeError) -> Reason {
53    if e.is_data {
54        Reason::DuplicateKey
55    } else {
56        Reason::Malformed
57    }
58}
59
60/// Shared framing + error-policy state behind every JSON deserializer.
61#[derive(Clone, Debug)]
62pub(crate) struct DecoderCore {
63    pub(crate) framing: JsonFraming,
64    pub(crate) on_error: OnError,
65    pub(crate) reject_duplicate_keys: bool,
66    pub(crate) metrics: Option<JsonDeserMetrics>,
67}
68
69impl DecoderCore {
70    /// Decode `raw` into 0..N records of type `P` and emit them.
71    fn run<'buf, P>(
72        &self,
73        raw: &RawPayload<'buf>,
74        ack: &AckRef,
75        out: &mut dyn EmitRecord<'buf, P>,
76    ) -> Result<(), DeserError>
77    where
78        P: DeserializeOwned,
79    {
80        match self.framing {
81            JsonFraming::Single => self.run_single(raw, ack, out),
82            JsonFraming::Ndjson => self.run_ndjson(raw, ack, out),
83            JsonFraming::Array => self.run_array(raw, ack, out),
84        }
85    }
86
87    fn run_single<'buf, P: DeserializeOwned>(
88        &self,
89        raw: &RawPayload<'buf>,
90        ack: &AckRef,
91        out: &mut dyn EmitRecord<'buf, P>,
92    ) -> Result<(), DeserError> {
93        if is_blank(raw.bytes) {
94            return Ok(()); // tombstone
95        }
96        if let Some(payload) = self.decode_item::<P>(raw.bytes)? {
97            let _ = out.emit(Record {
98                payload,
99                meta: raw.meta(),
100                ack: ack.clone(),
101            });
102        }
103        Ok(())
104    }
105
106    fn run_ndjson<'buf, P: DeserializeOwned>(
107        &self,
108        raw: &RawPayload<'buf>,
109        ack: &AckRef,
110        out: &mut dyn EmitRecord<'buf, P>,
111    ) -> Result<(), DeserError> {
112        // One JSON value per `\n`-separated line (NDJSON forbids embedded
113        // newlines), so a line split isolates each record.
114        match self.on_error {
115            // Skip streams line by line: a malformed line is dropped on its own
116            // and the good lines around it still flow.
117            OnError::Skip => {
118                for line in raw.bytes.split(|&b| b == b'\n') {
119                    if is_blank(line) {
120                        continue;
121                    }
122                    if let Some(payload) = self.decode_item::<P>(line)? {
123                        let _ = out.emit(Record {
124                            payload,
125                            meta: raw.meta(),
126                            ack: ack.clone(),
127                        });
128                    }
129                }
130                Ok(())
131            }
132            // Fail is atomic per payload: decode every line before emitting any
133            // record, so the first bad line returns the error with nothing yet
134            // emitted. Emitting a prefix and *then* failing would let the
135            // chain's Skip deserializer policy commit the source offset past the
136            // un-emitted tail — silently losing the records after the bad line.
137            OnError::Fail => {
138                let mut decoded = Vec::new();
139                for line in raw.bytes.split(|&b| b == b'\n') {
140                    if is_blank(line) {
141                        continue;
142                    }
143                    if let Some(payload) = self.decode_item::<P>(line)? {
144                        decoded.push(payload);
145                    }
146                }
147                for payload in decoded {
148                    let _ = out.emit(Record {
149                        payload,
150                        meta: raw.meta(),
151                        ack: ack.clone(),
152                    });
153                }
154                Ok(())
155            }
156        }
157    }
158
159    fn run_array<'buf, P: DeserializeOwned>(
160        &self,
161        raw: &RawPayload<'buf>,
162        ack: &AckRef,
163        out: &mut dyn EmitRecord<'buf, P>,
164    ) -> Result<(), DeserError> {
165        if is_blank(raw.bytes) {
166            return Ok(());
167        }
168        // The array is decoded in one pass, so its error handling is atomic:
169        // a malformed element fails the whole payload (per `on_error`). Use
170        // `ndjson` when you need per-record isolation.
171        if self.reject_duplicate_keys
172            && let Err(e) = backend::check_no_duplicate_keys(raw.bytes)
173        {
174            return self.on_item_error(&e, dup_check_reason(&e));
175        }
176        match backend::decode_one::<Vec<P>>(raw.bytes) {
177            Ok(items) => {
178                for payload in items {
179                    let _ = out.emit(Record {
180                        payload,
181                        meta: raw.meta(),
182                        ack: ack.clone(),
183                    });
184                }
185                Ok(())
186            }
187            Err(e) => self.on_item_error(&e, Reason::Malformed),
188        }
189    }
190
191    /// Decode one JSON document into `P`. `Ok(Some)` = decoded, `Ok(None)` =
192    /// dropped under `on_error: skip`, `Err` = `on_error: fail`.
193    fn decode_item<P: DeserializeOwned>(&self, bytes: &[u8]) -> Result<Option<P>, DeserError> {
194        if self.reject_duplicate_keys
195            && let Err(e) = backend::check_no_duplicate_keys(bytes)
196        {
197            self.on_item_error(&e, dup_check_reason(&e))?;
198            return Ok(None);
199        }
200        match backend::decode_one::<P>(bytes) {
201            Ok(payload) => Ok(Some(payload)),
202            Err(e) => {
203                self.on_item_error(&e, Reason::Malformed)?;
204                Ok(None)
205            }
206        }
207    }
208
209    /// Apply the error policy to a decode failure: `Skip` counts it and
210    /// returns `Ok`, `Fail` returns [`DeserError::Malformed`].
211    fn on_item_error(&self, e: &backend::DecodeError, reason: Reason) -> Result<(), DeserError> {
212        match self.on_error {
213            OnError::Skip => {
214                if let Some(m) = &self.metrics {
215                    match reason {
216                        Reason::Malformed => m.dropped_malformed(),
217                        Reason::DuplicateKey => m.dropped_duplicate_key(),
218                    }
219                }
220                spate_core::rate_limited_warn!(
221                    SKIP_WARN,
222                    reason = reason.label(),
223                    error = %e,
224                    "json record skipped by on_error policy"
225                );
226                Ok(())
227            }
228            OnError::Fail => Err(DeserError::Malformed {
229                reason: format!("{}: {e}", reason.label()),
230            }),
231        }
232    }
233}
234
235/// True when `bytes` is empty or only JSON whitespace — a tombstone that
236/// yields no records.
237fn is_blank(bytes: &[u8]) -> bool {
238    bytes.iter().all(u8::is_ascii_whitespace)
239}
240
241/// Typed deserializer: decodes each JSON document into your own
242/// `T: serde::de::DeserializeOwned`. No `serde_json` types appear in the
243/// pipeline. This is the flagship path.
244pub struct JsonSerdeDeserializer<T> {
245    core: DecoderCore,
246    _t: PhantomData<fn() -> T>,
247}
248
249// Manual `Clone`/`Debug` so the record type `T` need not be `Clone`/`Debug` —
250// the only state is the `DecoderCore`; `T` is a type tag (`fn() -> T`).
251impl<T> Clone for JsonSerdeDeserializer<T> {
252    fn clone(&self) -> Self {
253        JsonSerdeDeserializer {
254            core: self.core.clone(),
255            _t: PhantomData,
256        }
257    }
258}
259
260impl<T> std::fmt::Debug for JsonSerdeDeserializer<T> {
261    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262        f.debug_struct("JsonSerdeDeserializer")
263            .field("core", &self.core)
264            .finish_non_exhaustive()
265    }
266}
267
268impl<T> JsonSerdeDeserializer<T> {
269    pub(crate) fn new(core: DecoderCore) -> Self {
270        JsonSerdeDeserializer {
271            core,
272            _t: PhantomData,
273        }
274    }
275}
276
277impl<T> Deserializer<Owned<T>> for JsonSerdeDeserializer<T>
278where
279    T: DeserializeOwned + Send + 'static,
280{
281    fn deserialize<'buf>(
282        &mut self,
283        raw: &RawPayload<'buf>,
284        ack: &AckRef,
285        out: &mut dyn EmitRecord<'buf, T>,
286    ) -> Result<(), DeserError> {
287        self.core.run::<T>(raw, ack, out)
288    }
289}
290
291/// Dynamically-typed deserializer: emits [`serde_json::Value`] records, for
292/// pipelines that inspect or route on structure not known at compile time.
293#[derive(Clone, Debug)]
294pub struct JsonValueDeserializer {
295    core: DecoderCore,
296}
297
298impl JsonValueDeserializer {
299    pub(crate) fn new(core: DecoderCore) -> Self {
300        JsonValueDeserializer { core }
301    }
302}
303
304impl Deserializer<Owned<Value>> for JsonValueDeserializer {
305    fn deserialize<'buf>(
306        &mut self,
307        raw: &RawPayload<'buf>,
308        ack: &AckRef,
309        out: &mut dyn EmitRecord<'buf, Value>,
310    ) -> Result<(), DeserError> {
311        self.core.run::<Value>(raw, ack, out)
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::config::{JsonDeserializerBuilder, JsonSettings};
319    use serde::Deserialize;
320    use spate_core::record::{Flow, PartitionId};
321
322    #[derive(Debug, Deserialize, PartialEq)]
323    struct Ev {
324        id: i64,
325        name: String,
326    }
327
328    struct Collected<T>(Vec<Record<T>>);
329    impl<'buf, T> EmitRecord<'buf, T> for Collected<T> {
330        fn emit(&mut self, rec: Record<T>) -> Flow {
331            self.0.push(rec);
332            Flow::Continue
333        }
334    }
335
336    fn raw(bytes: &[u8]) -> RawPayload<'_> {
337        RawPayload {
338            bytes,
339            key: Some(b"k"),
340            partition: PartitionId(3),
341            offset: 42,
342            timestamp_ms: 1_000,
343        }
344    }
345
346    fn test_ack() -> AckRef {
347        AckRef::test_pair().0
348    }
349
350    fn builder(framing: JsonFraming, on_error: OnError, dup: bool) -> JsonDeserializerBuilder {
351        JsonDeserializerBuilder::from_settings(JsonSettings {
352            framing,
353            on_error,
354            reject_duplicate_keys: dup,
355        })
356    }
357
358    #[test]
359    fn single_round_trip_and_meta() {
360        let mut d = builder(JsonFraming::Single, OnError::Skip, false).build_serde::<Ev>();
361        let mut out = Collected(Vec::new());
362        d.deserialize(&raw(br#"{"id":7,"name":"orders"}"#), &test_ack(), &mut out)
363            .unwrap();
364        assert_eq!(out.0.len(), 1);
365        assert_eq!(out.0[0].meta.offset, 42);
366        assert_eq!(
367            out.0[0].payload,
368            Ev {
369                id: 7,
370                name: "orders".into()
371            }
372        );
373    }
374
375    #[test]
376    fn ndjson_emits_a_record_per_line() {
377        let mut d = builder(JsonFraming::Ndjson, OnError::Skip, false).build_serde::<Ev>();
378        let mut out = Collected(Vec::new());
379        let payload =
380            b"{\"id\":1,\"name\":\"a\"}\n{\"id\":2,\"name\":\"b\"}\n{\"id\":3,\"name\":\"c\"}";
381        d.deserialize(&raw(payload), &test_ack(), &mut out).unwrap();
382        assert_eq!(out.0.len(), 3);
383        assert_eq!(out.0[2].payload.id, 3);
384        // every derived record carries the payload's offset (one Kafka message)
385        assert!(out.0.iter().all(|r| r.meta.offset == 42));
386    }
387
388    #[test]
389    fn array_explodes_elements() {
390        let mut d = builder(JsonFraming::Array, OnError::Skip, false).build_serde::<Ev>();
391        let mut out = Collected(Vec::new());
392        d.deserialize(
393            &raw(br#"[{"id":1,"name":"a"},{"id":2,"name":"b"}]"#),
394            &test_ack(),
395            &mut out,
396        )
397        .unwrap();
398        assert_eq!(out.0.len(), 2);
399    }
400
401    #[test]
402    fn empty_and_blank_payloads_are_tombstones() {
403        let mut d = builder(JsonFraming::Single, OnError::Skip, false).build_value();
404        let mut out = Collected(Vec::new());
405        d.deserialize(&raw(b""), &test_ack(), &mut out).unwrap();
406        d.deserialize(&raw(b"  \n  "), &test_ack(), &mut out)
407            .unwrap();
408        assert!(out.0.is_empty());
409    }
410
411    #[test]
412    fn malformed_single_skips_or_fails() {
413        let mut skip = builder(JsonFraming::Single, OnError::Skip, false).build_serde::<Ev>();
414        let mut out = Collected(Vec::new());
415        skip.deserialize(&raw(b"{not json"), &test_ack(), &mut out)
416            .unwrap();
417        assert!(out.0.is_empty(), "skip drops the bad payload");
418
419        let mut fail = builder(JsonFraming::Single, OnError::Fail, false).build_serde::<Ev>();
420        let err = fail
421            .deserialize(&raw(b"{not json"), &test_ack(), &mut out)
422            .unwrap_err();
423        assert!(matches!(err, DeserError::Malformed { .. }), "{err}");
424    }
425
426    #[test]
427    fn ndjson_skip_isolates_the_bad_line() {
428        let mut d = builder(JsonFraming::Ndjson, OnError::Skip, false).build_serde::<Ev>();
429        let mut out = Collected(Vec::new());
430        let payload = b"{\"id\":1,\"name\":\"a\"}\nGARBAGE\n{\"id\":3,\"name\":\"c\"}";
431        d.deserialize(&raw(payload), &test_ack(), &mut out).unwrap();
432        assert_eq!(out.0.len(), 2, "good lines flow, bad line dropped");
433        assert_eq!(out.0[0].payload.id, 1);
434        assert_eq!(out.0[1].payload.id, 3);
435    }
436
437    #[test]
438    fn ndjson_fail_stops_on_the_bad_line() {
439        let mut d = builder(JsonFraming::Ndjson, OnError::Fail, false).build_serde::<Ev>();
440        let mut out = Collected(Vec::new());
441        let payload = b"{\"id\":1,\"name\":\"a\"}\nGARBAGE\n{\"id\":3,\"name\":\"c\"}";
442        let err = d
443            .deserialize(&raw(payload), &test_ack(), &mut out)
444            .unwrap_err();
445        assert!(matches!(err, DeserError::Malformed { .. }), "{err}");
446        // Fail is atomic: the good line before GARBAGE must NOT be emitted, or
447        // the chain's Skip policy would commit the offset past the lost tail.
448        assert!(
449            out.0.is_empty(),
450            "no record may be emitted when the payload fails"
451        );
452    }
453
454    #[test]
455    fn array_error_is_atomic() {
456        let mut d = builder(JsonFraming::Array, OnError::Skip, false).build_serde::<Ev>();
457        let mut out = Collected(Vec::new());
458        // The second element does not match `Ev`; the whole array is dropped.
459        d.deserialize(
460            &raw(br#"[{"id":1,"name":"a"},{"oops":true}]"#),
461            &test_ack(),
462            &mut out,
463        )
464        .unwrap();
465        assert!(out.0.is_empty(), "array error handling is atomic");
466    }
467
468    #[test]
469    fn duplicate_keys_last_wins_by_default_reject_when_configured() {
470        // Default: serde_json is last-value-wins, one record emitted.
471        let mut allow = builder(JsonFraming::Single, OnError::Skip, false).build_value();
472        let mut out = Collected(Vec::new());
473        allow
474            .deserialize(&raw(br#"{"id":1,"id":2}"#), &test_ack(), &mut out)
475            .unwrap();
476        assert_eq!(out.0.len(), 1);
477        assert_eq!(out.0[0].payload["id"], serde_json::json!(2));
478
479        // reject_duplicate_keys + fail: hard error, labeled duplicate_key.
480        let mut reject = builder(JsonFraming::Single, OnError::Fail, true).build_value();
481        let err = reject
482            .deserialize(&raw(br#"{"id":1,"id":2}"#), &test_ack(), &mut out)
483            .unwrap_err();
484        match err {
485            DeserError::Malformed { reason } => assert!(
486                reason.starts_with("duplicate_key:"),
487                "a real duplicate key must be labeled duplicate_key, got `{reason}`"
488            ),
489            other => panic!("expected Malformed, got {other}"),
490        }
491    }
492
493    #[test]
494    fn nested_duplicate_keys_are_rejected() {
495        let mut d = builder(JsonFraming::Single, OnError::Fail, true).build_value();
496        let mut out = Collected(Vec::new());
497        let err = d
498            .deserialize(&raw(br#"{"outer":{"a":1,"a":2}}"#), &test_ack(), &mut out)
499            .unwrap_err();
500        assert!(matches!(err, DeserError::Malformed { .. }), "{err}");
501    }
502
503    #[test]
504    fn malformed_with_dedup_is_labeled_malformed_not_duplicate_key() {
505        // With reject_duplicate_keys on, the structural pass runs first; a plain
506        // syntax error (no duplicate key) must still be reported as `malformed`.
507        let mut d = builder(JsonFraming::Single, OnError::Fail, true).build_serde::<Ev>();
508        let mut out = Collected(Vec::new());
509        let err = d
510            .deserialize(&raw(b"{not valid json"), &test_ack(), &mut out)
511            .unwrap_err();
512        match err {
513            DeserError::Malformed { reason } => assert!(
514                reason.starts_with("malformed:"),
515                "a syntax error must be labeled malformed, got `{reason}`"
516            ),
517            other => panic!("expected Malformed, got {other}"),
518        }
519    }
520
521    /// Run `f` against a local Prometheus recorder and return the rendered
522    /// exposition. Handles must be resolved inside `f`.
523    fn render(f: impl FnOnce()) -> String {
524        let recorder = metrics_exporter_prometheus::PrometheusBuilder::new().build_recorder();
525        let handle = recorder.handle();
526        metrics::with_local_recorder(&recorder, f);
527        handle.run_upkeep();
528        handle.render()
529    }
530
531    #[test]
532    fn skipped_records_are_counted_in_metrics() {
533        const STD: &str = r#"pipeline="orders",component="main",component_type="deserializer""#;
534        let rendered = render(|| {
535            let mut d = builder(JsonFraming::Ndjson, OnError::Skip, false)
536                .with_metrics("orders", "main")
537                .build_serde::<Ev>();
538            let mut out = Collected(Vec::new());
539            let payload =
540                b"{\"id\":1,\"name\":\"a\"}\nGARBAGE\nALSO BAD\n{\"id\":4,\"name\":\"d\"}";
541            d.deserialize(&raw(payload), &test_ack(), &mut out).unwrap();
542            assert_eq!(out.0.len(), 2);
543        });
544        let needle =
545            format!(r#"spate_json_deser_records_dropped_total{{{STD},reason="malformed"}} 2"#);
546        assert!(
547            rendered.contains(&needle),
548            "missing `{needle}`:\n{rendered}"
549        );
550    }
551}