Skip to main content

spate_clickhouse/
encoder.rs

1//! The CPU half of the sink: encoding records to RowBinary on pipeline
2//! threads.
3
4use crate::rowbinary;
5use crate::schema::{self, RowSchema};
6use bytes::BytesMut;
7use serde::Serialize;
8use spate_core::deser::{Owned, RecFamily};
9use spate_core::error::{ErrorClass, SinkError};
10use spate_core::record::Record;
11use spate_core::sink::RowEncoder;
12use std::marker::PhantomData;
13use std::sync::Arc;
14
15/// Encodes a record family's `Serialize` rows into RowBinary via this
16/// crate's [serializer](crate::rowbinary). Runs inside the chain's terminal
17/// stage on pinned pipeline threads; sink workers ship the resulting frames
18/// as-is.
19///
20/// `F` is the **record family**: use `Owned<T>` for plain owned row structs
21/// (`ClickHouseEncoder::<Owned<MyRow>>::new()`), or a borrowed family whose
22/// `Rec<'buf>` points into the payload buffer for zero-copy pipelines — any
23/// family whose records implement `Serialize` at every lifetime encodes.
24///
25/// The row struct's **field declaration order is the wire contract** — it
26/// must match the column list configured for the sink (see the crate docs).
27/// [`ClickHouseEncoder::with_schema`] checks that contract against the
28/// live table's schema on each pipeline thread's first record.
29#[derive(Debug)]
30pub struct ClickHouseEncoder<F> {
31    check: Option<CheckState>,
32    _row: PhantomData<fn(F)>,
33}
34
35#[derive(Debug)]
36struct CheckState {
37    expected: Arc<RowSchema>,
38    done: bool,
39}
40
41impl<F> ClickHouseEncoder<F> {
42    /// An encoder for the family's rows.
43    #[must_use]
44    pub fn new() -> Self {
45        ClickHouseEncoder {
46            check: None,
47            _row: PhantomData,
48        }
49    }
50
51    /// An encoder that validates the row struct against `expected` (the
52    /// schema returned by [`crate::ClickHouseSink::validate_schema`]) on
53    /// the first record it encodes: field names and order always, type
54    /// classes in `full` mode. A mismatch is an
55    /// [`ErrorClass::Fatal`] error — it stops the pipeline before any
56    /// misaligned batch is sent. Steady-state cost after the first record
57    /// is one predictable branch.
58    #[must_use]
59    pub fn with_schema(expected: Arc<RowSchema>) -> Self {
60        ClickHouseEncoder {
61            check: Some(CheckState {
62                expected,
63                done: false,
64            }),
65            _row: PhantomData,
66        }
67    }
68}
69
70impl<F> Default for ClickHouseEncoder<F> {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76impl<F> Clone for ClickHouseEncoder<F> {
77    fn clone(&self) -> Self {
78        // Each pipeline thread's clone re-validates its own first record:
79        // the check is cheap, and threads must not race a shared flag.
80        ClickHouseEncoder {
81            check: self.check.as_ref().map(|c| CheckState {
82                expected: Arc::clone(&c.expected),
83                done: false,
84            }),
85            _row: PhantomData,
86        }
87    }
88}
89
90impl<F> RowEncoder<F> for ClickHouseEncoder<F>
91where
92    F: RecFamily,
93    for<'b> F::Rec<'b>: Serialize,
94{
95    fn encode<'buf>(
96        &mut self,
97        rec: &Record<F::Rec<'buf>>,
98        buf: &mut BytesMut,
99    ) -> Result<(), SinkError> {
100        if let Some(check) = &mut self.check
101            && !check.done
102        {
103            let fields = schema::probe::probe_row(&rec.payload).map_err(|e| SinkError::Client {
104                class: ErrorClass::Fatal,
105                reason: format!("schema validation could not probe the row struct: {e}"),
106            })?;
107            schema::check_first_record(&check.expected, &fields).map_err(|diff| {
108                SinkError::Client {
109                    class: ErrorClass::Fatal,
110                    reason: diff,
111                }
112            })?;
113            check.done = true;
114        }
115        rowbinary::serialize_row(&rec.payload, buf).map_err(|e| SinkError::Client {
116            class: ErrorClass::RecordLevel,
117            reason: format!("rowbinary encoding failed: {e}"),
118        })
119    }
120}
121
122/// Passthrough for records that are **already RowBinary-encoded** rows
123/// (`Vec<u8>` payloads): appends the bytes verbatim. For pipelines that
124/// encode upstream or replicate pre-encoded data. One record must be
125/// exactly one encoded row — the framework counts rows by records.
126#[derive(Clone, Copy, Debug, Default)]
127pub struct PreEncodedRows;
128
129impl RowEncoder<Owned<Vec<u8>>> for PreEncodedRows {
130    fn encode<'buf>(&mut self, rec: &Record<Vec<u8>>, buf: &mut BytesMut) -> Result<(), SinkError> {
131        buf.extend_from_slice(&rec.payload);
132        Ok(())
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use serde::Serialize;
140    use spate_core::checkpoint::AckRef;
141    use spate_core::record::{PartitionId, RecordMeta};
142
143    #[derive(Serialize)]
144    struct Row {
145        id: u64,
146        name: String,
147    }
148
149    fn record<T>(
150        payload: T,
151    ) -> (
152        Record<T>,
153        crossbeam_channel::Receiver<spate_core::checkpoint::AckMsg>,
154    ) {
155        let (ack, rx) = AckRef::test_pair();
156        (
157            Record {
158                payload,
159                meta: RecordMeta {
160                    partition: PartitionId(0),
161                    offset: 0,
162                    event_time_ms: 0,
163                    key_hash: None,
164                },
165                ack,
166            },
167            rx,
168        )
169    }
170
171    #[test]
172    fn encodes_serializable_rows() {
173        let (rec, _rx) = record(Row {
174            id: 7,
175            name: "x".into(),
176        });
177        let mut buf = BytesMut::new();
178        ClickHouseEncoder::<Owned<Row>>::new()
179            .encode(&rec, &mut buf)
180            .unwrap();
181        assert_eq!(buf.as_ref(), &[7, 0, 0, 0, 0, 0, 0, 0, 1, b'x']);
182    }
183
184    #[test]
185    fn encodes_borrowed_rows_identically_to_owned() {
186        // A borrowed family encodes byte-for-byte like its owned equivalent:
187        // RowBinary only needs `Serialize`, never an owned or 'static row.
188        #[derive(Serialize)]
189        struct RowRef<'a> {
190            id: u64,
191            name: &'a str,
192        }
193        struct RowRefFam;
194        impl RecFamily for RowRefFam {
195            type Rec<'buf> = RowRef<'buf>;
196        }
197
198        let name = String::from("x");
199        let (rec, _rx) = record(RowRef { id: 7, name: &name });
200        let mut buf = BytesMut::new();
201        ClickHouseEncoder::<RowRefFam>::new()
202            .encode(&rec, &mut buf)
203            .unwrap();
204        assert_eq!(buf.as_ref(), &[7, 0, 0, 0, 0, 0, 0, 0, 1, b'x']);
205    }
206
207    #[test]
208    fn encoding_failures_are_record_level() {
209        #[derive(Serialize)]
210        struct Bad {
211            c: char,
212        }
213        let (rec, _rx) = record(Bad { c: 'x' });
214        let err = ClickHouseEncoder::<Owned<Bad>>::new()
215            .encode(&rec, &mut BytesMut::new())
216            .unwrap_err();
217        match err {
218            SinkError::Client { class, .. } => assert_eq!(class, ErrorClass::RecordLevel),
219            other => panic!("unexpected error shape: {other:?}"),
220        }
221    }
222
223    #[test]
224    fn pre_encoded_rows_pass_through() {
225        let (rec, _rx) = record(vec![1u8, 2, 3]);
226        let mut buf = BytesMut::new();
227        PreEncodedRows.encode(&rec, &mut buf).unwrap();
228        assert_eq!(buf.as_ref(), &[1, 2, 3]);
229    }
230}