Skip to main content

spate_clickhouse/native/
mod.rs

1//! ClickHouse **Native** (columnar, block-framed) encoder.
2//!
3//! Where [`crate::rowbinary`] streams rows, Native transposes a chunk of rows
4//! into per-column buffers and emits one self-describing **block**:
5//! ```text
6//! [VarUInt num_columns][VarUInt num_rows]
7//! ( [String name][String type][column prefix][column data] ) × num_columns
8//! ```
9//! A `FORMAT Native` insert body over HTTP is a *stream* of such blocks (they
10//! concatenate), so the sink's per-chunk framing is unchanged: each
11//! [`crate::native::NativeEncoder`]-produced chunk is one complete block.
12//!
13//! The encoder is **type-driven**: it needs each column's ClickHouse type to
14//! lay bytes out columnar, so it is built from a fetched [`RowSchema`]. The
15//! per-column type-name string emitted on the wire is the *raw*
16//! `system.columns.type` text (so `Enum8('a'=1,…)`, `Decimal(P,S)`,
17//! `DateTime64(3,'UTC')` etc. round-trip verbatim). Layout decisions come
18//! from the parsed `ChType`.
19//!
20//! Targets `INSERT … FORMAT Native` over HTTP, which is hardcoded to
21//! revision 0 / default serialization: no `BlockInfo`, no
22//! `has_custom_serialization` byte, no sparse/detached columns.
23
24mod column;
25mod dispatch;
26mod leaf;
27mod lowcard;
28
29use crate::schema::RowSchema;
30use crate::schema::typeparse::ChType;
31use bytes::BytesMut;
32use column::ColumnWriter;
33use dispatch::RowDispatchSer;
34use leaf::{put_leb128, put_string};
35use serde::Serialize;
36use spate_core::deser::RecFamily;
37use spate_core::error::{ErrorClass, SinkError};
38use spate_core::record::Record;
39use spate_core::sink::RowEncoder;
40use std::fmt;
41use std::marker::PhantomData;
42use std::sync::Arc;
43
44/// A Native encoding failure.
45#[derive(Debug, thiserror::Error)]
46#[non_exhaustive]
47pub enum NativeError {
48    /// A column's ClickHouse type is not supported by the Native encoder
49    /// (fatal, surfaced at construction before any row is sent).
50    #[error("column `{column}`: type `{ch_type}` is not supported by the Native encoder")]
51    UnsupportedColumn {
52        /// The column name.
53        column: String,
54        /// The offending (possibly nested) type description.
55        ch_type: String,
56    },
57    /// The row struct's value did not match the column's type class.
58    #[error("value does not match column type (expected {expected})")]
59    TypeMismatch {
60        /// A short label for the column type the value should have matched.
61        expected: &'static str,
62    },
63    /// A fixed-width blob column (`UUID`/`IPv6`/`Int256`/`UInt256`) got the
64    /// wrong number of bytes.
65    #[error("fixed-width column expected {expected} bytes, got {got}")]
66    RawWidth {
67        /// The column's fixed width.
68        expected: usize,
69        /// The bytes the value produced.
70        got: usize,
71    },
72    /// A `FixedString(N)` value was longer than `N`.
73    #[error("FixedString({width}) value is {got} bytes (too long)")]
74    FixedTooLong {
75        /// The column width.
76        width: usize,
77        /// The value length.
78        got: usize,
79    },
80    /// The row serialized a different number of fields than the schema has
81    /// columns (positional mismatch).
82    #[error("row has {got} field(s) but the table has {expected} column(s)")]
83    RowArity {
84        /// Columns in the schema.
85        expected: usize,
86        /// Fields the row serialized.
87        got: usize,
88    },
89    /// The first record's probed struct does not match the configured
90    /// columns (pre-formatted multi-line diff). Field names and order are
91    /// checked whenever the schema was fetched; under `validate_schema:
92    /// full` this also rejects class-incompatible types per position —
93    /// including a wire-wrapper scale that disagrees with the column
94    /// (`DateTime64Millis` into `DateTime64(6)`).
95    #[error("{0}")]
96    FirstRecord(String),
97    /// A tuple/geo value serialized more elements than its column has.
98    #[error("tuple value has more elements than the column type")]
99    TupleArity,
100    /// The row did not serialize as a struct/tuple of columns.
101    #[error("row must serialize as a struct or tuple of columns")]
102    NotAStruct,
103    /// An internal invariant was violated (a bug).
104    #[error("internal Native encoder error: {0}")]
105    Internal(&'static str),
106    /// An error raised by a `Serialize` implementation.
107    #[error("serialize error: {0}")]
108    Custom(String),
109}
110
111impl serde::ser::Error for NativeError {
112    fn custom<T: fmt::Display>(msg: T) -> Self {
113        NativeError::Custom(msg.to_string())
114    }
115}
116
117impl NativeError {
118    /// Map onto the framework's error taxonomy. A columnar encode failure is
119    /// **fatal**: it poisons the pending block (columns of unequal length),
120    /// and a type-driven encoder only fails when the row type disagrees with
121    /// the schema, which recurs for every record. Record-level skipping is
122    /// not possible without a mid-block rollback, which the happy path does
123    /// not pay for.
124    fn into_sink_error(self) -> SinkError {
125        SinkError::Client {
126            class: ErrorClass::Fatal,
127            reason: format!("native encoding failed: {self}"),
128        }
129    }
130}
131
132/// The immutable, `clickhouse`-free column template a [`NativeEncoder`] is
133/// built from: the validated, config-ordered `(name, parsed type, raw type
134/// string)` columns plus the validation mode they were fetched under, which
135/// the encoder's first-record struct check applies.
136#[derive(Debug)]
137pub struct NativeSchema {
138    expected: RowSchema,
139}
140
141impl NativeSchema {
142    /// Build from a validated [`RowSchema`] (fetched from `system.columns`),
143    /// failing fatally for any column whose type the encoder cannot lay out.
144    ///
145    /// The schema's validation mode carries over to the encoder's
146    /// first-record check: under `validate_schema: full` each field's type
147    /// class is checked against the live column type, so a wire-wrapper
148    /// scale that disagrees with the table's declared precision
149    /// (`DateTime64Millis` into `DateTime64(6)`) fails on the first record
150    /// instead of silently landing wrong timestamps.
151    pub fn from_row_schema(schema: &RowSchema) -> Result<Arc<NativeSchema>, NativeError> {
152        Self::from_expected(RowSchema {
153            mode: schema.mode,
154            table: schema.table.clone(),
155            columns: schema.columns.clone(),
156        })
157    }
158
159    fn from_expected(expected: RowSchema) -> Result<Arc<NativeSchema>, NativeError> {
160        for (name, ty, _) in &expected.columns {
161            // Validate the writer can be built (fail fast, before any row).
162            ColumnWriter::build(ty).map_err(|ch_type| NativeError::UnsupportedColumn {
163                column: name.clone(),
164                ch_type,
165            })?;
166        }
167        Ok(Arc::new(NativeSchema { expected }))
168    }
169
170    /// Build a schema from `(column name, ClickHouse type string)` pairs,
171    /// e.g. `[("id", "UInt64"), ("tags", "Array(LowCardinality(String))")]`.
172    ///
173    /// The normal path is [`ClickHouseSink::native_schema`](crate::ClickHouseSink::native_schema),
174    /// which fetches the real types from `system.columns`. Use this only when
175    /// the schema is known statically; the type strings must match the
176    /// server's column types exactly, or the server will reject the block.
177    /// The first-record check runs at name level only (there is no fetched
178    /// truth to compare type classes against).
179    pub fn from_columns(specs: &[(&str, &str)]) -> Result<Arc<NativeSchema>, NativeError> {
180        Self::from_expected(RowSchema {
181            mode: crate::config::SchemaValidation::Names,
182            table: "<static schema>".into(),
183            columns: specs
184                .iter()
185                .map(|(n, t)| {
186                    (
187                        (*n).to_string(),
188                        crate::schema::typeparse::parse(t),
189                        (*t).to_string(),
190                    )
191                })
192                .collect(),
193        })
194    }
195
196    fn columns(&self) -> &[(String, ChType, String)] {
197        &self.expected.columns
198    }
199
200    fn fresh_columns(&self) -> Vec<ColumnWriter> {
201        self.columns()
202            .iter()
203            .map(|(_, ty, _)| {
204                ColumnWriter::build(ty).expect("column type validated at construction")
205            })
206            .collect()
207    }
208}
209
210/// Encodes a record family's `Serialize` rows into the ClickHouse Native
211/// format. Runs on pipeline threads inside the terminal stage; buffers rows
212/// columnar in [`encode`](RowEncoder::encode) and emits one block per chunk
213/// in [`finish_chunk`](RowEncoder::finish_chunk).
214///
215/// `F` is the **record family**: `Owned<T>` for plain owned row structs
216/// (`NativeEncoder::<Owned<MyRow>>::new(schema)`), or a borrowed family for
217/// zero-copy pipelines. Any family whose records implement `Serialize` at
218/// every lifetime encodes.
219///
220/// Cloning mints a fresh, empty encoder over the same schema; the terminal
221/// stage clones one per shard.
222pub struct NativeEncoder<F> {
223    schema: Arc<NativeSchema>,
224    columns: Vec<ColumnWriter>,
225    rows: u32,
226    /// Set if a row failed mid-encode, leaving columns of unequal length. The
227    /// pending block is unemittable; [`finish_chunk`](RowEncoder::finish_chunk)
228    /// refuses it so the buffered rows fail and replay rather than producing a
229    /// corrupt block. A failed row is fatal anyway (a type-driven encoder only
230    /// fails on a schema/struct mismatch, which recurs), so the pipeline stops.
231    poisoned: bool,
232    /// Whether the first-record field-name check has run. Cleared on clone so
233    /// each per-shard encoder re-validates its own first record (cheap, and a
234    /// mismatch is the same fatal error on any shard).
235    checked: bool,
236    /// Cached approximate buffered size, refreshed periodically in `encode`
237    /// so `buffered_bytes` (called by the terminal stage on every record) is
238    /// O(1) instead of an O(columns) walk per row.
239    approx_bytes: usize,
240    _row: PhantomData<fn(F)>,
241}
242
243impl<F> NativeEncoder<F> {
244    /// A Native encoder for the columns described by `schema`.
245    #[must_use]
246    pub fn new(schema: Arc<NativeSchema>) -> Self {
247        let columns = schema.fresh_columns();
248        NativeEncoder {
249            schema,
250            columns,
251            rows: 0,
252            poisoned: false,
253            checked: false,
254            approx_bytes: 0,
255            _row: PhantomData,
256        }
257    }
258
259    /// Sum the current buffered bytes across all columns (O(columns)).
260    fn compute_buffered(&self) -> usize {
261        self.columns
262            .iter()
263            .map(ColumnWriter::byte_len)
264            .sum::<usize>()
265            + self.columns.len() * 16
266    }
267
268    /// Build directly from a validated [`RowSchema`].
269    pub fn from_row_schema(schema: &RowSchema) -> Result<Self, NativeError> {
270        Ok(Self::new(NativeSchema::from_row_schema(schema)?))
271    }
272
273    fn finalize_block(&mut self, buf: &mut BytesMut) {
274        // One up-front reservation for the whole block: a cheap overestimate
275        // (compute_buffered over-counts LowCard keys ×8 and adds 16 B/column
276        // slack) plus each column's name/type-name bytes and a constant for
277        // the two leading VarUInts. Not exact: the overestimate is what
278        // guarantees a single output allocation.
279        let reserve = self.compute_buffered()
280            + self
281                .schema
282                .columns()
283                .iter()
284                .map(|(name, _, type_name)| name.len() + type_name.len())
285                .sum::<usize>()
286            + 20;
287        buf.reserve(reserve);
288        put_leb128(buf, self.columns.len() as u64);
289        put_leb128(buf, u64::from(self.rows));
290        for (col, (name, _, type_name)) in self.columns.iter().zip(self.schema.columns()) {
291            put_string(buf, name.as_bytes());
292            put_string(buf, type_name.as_bytes());
293            // Per column: state prefix (inner-first), then data streams.
294            col.write_prefix(buf);
295            col.write_data(buf);
296        }
297        for col in &mut self.columns {
298            col.reset();
299        }
300        self.rows = 0;
301        self.approx_bytes = 0;
302    }
303}
304
305impl<F> Clone for NativeEncoder<F> {
306    fn clone(&self) -> Self {
307        // A per-shard clone is a fresh, empty encoder over the same schema —
308        // never a copy of another shard's buffered rows.
309        NativeEncoder::new(Arc::clone(&self.schema))
310    }
311}
312
313impl<F> fmt::Debug for NativeEncoder<F> {
314    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315        f.debug_struct("NativeEncoder")
316            .field("columns", &self.schema.columns().len())
317            .field("rows", &self.rows)
318            .finish()
319    }
320}
321
322impl<F> RowEncoder<F> for NativeEncoder<F>
323where
324    F: RecFamily,
325    for<'b> F::Rec<'b>: Serialize,
326{
327    fn encode<'buf>(
328        &mut self,
329        rec: &Record<F::Rec<'buf>>,
330        _buf: &mut BytesMut,
331    ) -> Result<(), SinkError> {
332        // First record only: validate the row's probed struct against the
333        // configured columns off the per-row path. Positional dispatch would
334        // otherwise silently mis-column a same-wire-class field/column swap,
335        // and (in `full` mode) a wire wrapper whose scale disagrees with the
336        // column's declared precision would silently land wrong values.
337        // Best-effort safety, not a gate: `probe_row` errors for tuple/seq rows
338        // (no field names) and other exotic-but-encodable shapes; skip the
339        // check there rather than reject a row the encoder would accept. Only a
340        // positive mismatch is fatal. Same first-record pattern as the
341        // RowBinary encoder, except a probe failure is fatal there (RowBinary's
342        // check requires a named struct) and skipped here (Native also accepts
343        // tuple rows).
344        if !self.checked {
345            if let Ok(fields) = crate::schema::probe::probe_row(&rec.payload) {
346                crate::schema::check_first_record(&self.schema.expected, &fields)
347                    .map_err(|diff| NativeError::FirstRecord(diff).into_sink_error())?;
348            }
349            self.checked = true;
350        }
351        // No per-row buffer bookkeeping on the happy path: route the row's
352        // fields straight into the column buffers. A failure part-way leaves
353        // columns unequal, so poison the block. The error is fatal (a
354        // type-driven encoder only fails on schema/struct mismatch, which
355        // recurs), the pipeline stops, and the buffered rows fail and replay.
356        match rec.payload.serialize(RowDispatchSer {
357            columns: &mut self.columns,
358        }) {
359            Ok(()) => {
360                self.rows += 1;
361                // Refresh the cached size cheaply: every row for the first 16
362                // (small blocks stay accurate), then every 16th (amortizing
363                // the O(columns) walk). The seal threshold tolerates the
364                // ≤16-row lag; blocks are thousands of rows.
365                if self.rows <= 16 || self.rows.is_multiple_of(16) {
366                    self.approx_bytes = self.compute_buffered();
367                }
368                Ok(())
369            }
370            Err(e) => {
371                self.poisoned = true;
372                Err(e.into_sink_error())
373            }
374        }
375    }
376
377    #[inline]
378    fn buffered_bytes(&self) -> usize {
379        // O(1): the cache is refreshed in `encode` (see above). Used only to
380        // decide when a block reaches the target size, so a small lag is fine.
381        self.approx_bytes
382    }
383
384    fn finish_chunk(&mut self, buf: &mut BytesMut) -> Result<(), SinkError> {
385        if self.poisoned {
386            // A prior row failed mid-encode; the columns are unequal length.
387            // Refuse rather than emit a corrupt block; the stage treats this
388            // as fatal and the buffered rows fail (replay).
389            return Err(SinkError::Client {
390                class: ErrorClass::Fatal,
391                reason: "native block abandoned after a failed row".into(),
392            });
393        }
394        if self.rows > 0 {
395            self.finalize_block(buf);
396        }
397        Ok(())
398    }
399}
400
401#[cfg(test)]
402impl<T> NativeEncoder<T> {
403    /// Encode `rows` into a single Native block and return the bytes. Panics
404    /// on encode failure; tests supply matching schema and rows.
405    pub(crate) fn block_of<R: Serialize>(
406        schema: Arc<NativeSchema>,
407        rows: &[R],
408    ) -> Result<Vec<u8>, NativeError> {
409        let mut enc = NativeEncoder::<T>::new(schema);
410        for r in rows {
411            r.serialize(RowDispatchSer {
412                columns: &mut enc.columns,
413            })?;
414            enc.rows += 1;
415        }
416        let mut buf = BytesMut::new();
417        if enc.rows > 0 {
418            enc.finalize_block(&mut buf);
419        }
420        Ok(buf.to_vec())
421    }
422}
423
424#[cfg(test)]
425mod tests;