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
123    /// deliberately does 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            // build() succeeded at construction, so it cannot fail here.
204            .map(|(_, ty, _)| {
205                ColumnWriter::build(ty).expect("column type validated at construction")
206            })
207            .collect()
208    }
209}
210
211/// Encodes a record family's `Serialize` rows into the ClickHouse Native
212/// format. Runs on pipeline threads inside the terminal stage; buffers rows
213/// columnar in [`encode`](RowEncoder::encode) and emits one block per chunk
214/// in [`finish_chunk`](RowEncoder::finish_chunk).
215///
216/// `F` is the **record family**: `Owned<T>` for plain owned row structs
217/// (`NativeEncoder::<Owned<MyRow>>::new(schema)`), or a borrowed family for
218/// zero-copy pipelines — any family whose records implement `Serialize` at
219/// every lifetime encodes.
220///
221/// Cloning mints a fresh, empty encoder over the same schema — the terminal
222/// stage clones one per shard.
223pub struct NativeEncoder<F> {
224    schema: Arc<NativeSchema>,
225    columns: Vec<ColumnWriter>,
226    rows: u32,
227    /// Set if a row failed mid-encode, leaving columns of unequal length. The
228    /// pending block is unemittable; [`finish_chunk`](RowEncoder::finish_chunk)
229    /// refuses it so the buffered rows fail and replay rather than producing a
230    /// corrupt block. A failed row is fatal anyway (a type-driven encoder only
231    /// fails on a schema/struct mismatch, which recurs), so the pipeline stops.
232    poisoned: bool,
233    /// Whether the first-record field-name check has run. Cleared on clone so
234    /// each per-shard encoder re-validates its own first record (cheap, and a
235    /// mismatch is the same fatal error on any shard).
236    checked: bool,
237    /// Cached approximate buffered size, refreshed periodically in `encode`
238    /// so `buffered_bytes` (called by the terminal stage on every record) is
239    /// O(1) instead of an O(columns) walk per row.
240    approx_bytes: usize,
241    _row: PhantomData<fn(F)>,
242}
243
244impl<F> NativeEncoder<F> {
245    /// A Native encoder for the columns described by `schema`.
246    #[must_use]
247    pub fn new(schema: Arc<NativeSchema>) -> Self {
248        let columns = schema.fresh_columns();
249        NativeEncoder {
250            schema,
251            columns,
252            rows: 0,
253            poisoned: false,
254            checked: false,
255            approx_bytes: 0,
256            _row: PhantomData,
257        }
258    }
259
260    /// Sum the current buffered bytes across all columns (O(columns)).
261    fn compute_buffered(&self) -> usize {
262        self.columns
263            .iter()
264            .map(ColumnWriter::byte_len)
265            .sum::<usize>()
266            + self.columns.len() * 16
267    }
268
269    /// Build directly from a validated [`RowSchema`].
270    pub fn from_row_schema(schema: &RowSchema) -> Result<Self, NativeError> {
271        Ok(Self::new(NativeSchema::from_row_schema(schema)?))
272    }
273
274    fn finalize_block(&mut self, buf: &mut BytesMut) {
275        // One up-front reservation for the whole block: a cheap overestimate
276        // (compute_buffered over-counts LowCard keys ×8 and adds 16 B/column
277        // slack) plus each column's name/type-name bytes and a constant for
278        // the two leading VarUInts. Deliberately not exact — the overestimate
279        // is what guarantees a single output allocation.
280        let reserve = self.compute_buffered()
281            + self
282                .schema
283                .columns()
284                .iter()
285                .map(|(name, _, type_name)| name.len() + type_name.len())
286                .sum::<usize>()
287            + 20;
288        buf.reserve(reserve);
289        put_leb128(buf, self.columns.len() as u64);
290        put_leb128(buf, u64::from(self.rows));
291        for (col, (name, _, type_name)) in self.columns.iter().zip(self.schema.columns()) {
292            put_string(buf, name.as_bytes());
293            put_string(buf, type_name.as_bytes());
294            // Per column: state prefix (inner-first), then data streams.
295            col.write_prefix(buf);
296            col.write_data(buf);
297        }
298        for col in &mut self.columns {
299            col.reset();
300        }
301        self.rows = 0;
302        self.approx_bytes = 0;
303    }
304}
305
306impl<F> Clone for NativeEncoder<F> {
307    fn clone(&self) -> Self {
308        // A per-shard clone is a fresh, empty encoder over the same schema —
309        // never a copy of another shard's buffered rows.
310        NativeEncoder::new(Arc::clone(&self.schema))
311    }
312}
313
314impl<F> fmt::Debug for NativeEncoder<F> {
315    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
316        f.debug_struct("NativeEncoder")
317            .field("columns", &self.schema.columns().len())
318            .field("rows", &self.rows)
319            .finish()
320    }
321}
322
323impl<F> RowEncoder<F> for NativeEncoder<F>
324where
325    F: RecFamily,
326    for<'b> F::Rec<'b>: Serialize,
327{
328    fn encode<'buf>(
329        &mut self,
330        rec: &Record<F::Rec<'buf>>,
331        _buf: &mut BytesMut,
332    ) -> Result<(), SinkError> {
333        // First record only: validate the row's probed struct against the
334        // configured columns off the per-row path — positional dispatch would
335        // otherwise silently mis-column a same-wire-class field/column swap,
336        // and (in `full` mode) a wire wrapper whose scale disagrees with the
337        // column's declared precision would silently land wrong values.
338        // Best-effort safety, not a gate: `probe_row` errors for tuple/seq rows
339        // (no field names) and other exotic-but-encodable shapes; skip the
340        // check there rather than reject a row the encoder would accept. Only a
341        // positive mismatch is fatal. Same first-record pattern as the
342        // RowBinary encoder, except a probe failure is fatal there (RowBinary's
343        // check requires a named struct) and skipped here (Native also accepts
344        // tuple rows).
345        if !self.checked {
346            if let Ok(fields) = crate::schema::probe::probe_row(&rec.payload) {
347                crate::schema::check_first_record(&self.schema.expected, &fields)
348                    .map_err(|diff| NativeError::FirstRecord(diff).into_sink_error())?;
349            }
350            self.checked = true;
351        }
352        // No per-row buffer bookkeeping on the happy path: route the row's
353        // fields straight into the column buffers. A failure part-way leaves
354        // columns unequal, so poison the block — the error is fatal (a
355        // type-driven encoder only fails on schema/struct mismatch, which
356        // recurs), the pipeline stops, and the buffered rows fail and replay.
357        match rec.payload.serialize(RowDispatchSer {
358            columns: &mut self.columns,
359        }) {
360            Ok(()) => {
361                self.rows += 1;
362                // Refresh the cached size cheaply: every row for the first 16
363                // (small blocks stay accurate), then every 16th (amortizing
364                // the O(columns) walk). The seal threshold tolerates the
365                // ≤16-row lag; blocks are thousands of rows.
366                if self.rows <= 16 || self.rows.is_multiple_of(16) {
367                    self.approx_bytes = self.compute_buffered();
368                }
369                Ok(())
370            }
371            Err(e) => {
372                self.poisoned = true;
373                Err(e.into_sink_error())
374            }
375        }
376    }
377
378    #[inline]
379    fn buffered_bytes(&self) -> usize {
380        // O(1): the cache is refreshed in `encode` (see above). Used only to
381        // decide when a block reaches the target size, so a small lag is fine.
382        self.approx_bytes
383    }
384
385    fn finish_chunk(&mut self, buf: &mut BytesMut) -> Result<(), SinkError> {
386        if self.poisoned {
387            // A prior row failed mid-encode; the columns are unequal length.
388            // Refuse rather than emit a corrupt block — the stage treats this
389            // as fatal and the buffered rows fail (replay). Defensive: encode
390            // already returned fatal, which should stop the pipeline first.
391            return Err(SinkError::Client {
392                class: ErrorClass::Fatal,
393                reason: "native block abandoned after a failed row".into(),
394            });
395        }
396        if self.rows > 0 {
397            self.finalize_block(buf);
398        }
399        Ok(())
400    }
401}
402
403#[cfg(test)]
404impl<T> NativeEncoder<T> {
405    /// Encode `rows` into a single Native block and return the bytes. Panics
406    /// on encode failure — tests supply matching schema and rows.
407    pub(crate) fn block_of<R: Serialize>(
408        schema: Arc<NativeSchema>,
409        rows: &[R],
410    ) -> Result<Vec<u8>, NativeError> {
411        let mut enc = NativeEncoder::<T>::new(schema);
412        for r in rows {
413            r.serialize(RowDispatchSer {
414                columns: &mut enc.columns,
415            })?;
416            enc.rows += 1;
417        }
418        let mut buf = BytesMut::new();
419        if enc.rows > 0 {
420            enc.finalize_block(&mut buf);
421        }
422        Ok(buf.to_vec())
423    }
424}
425
426#[cfg(test)]
427mod tests;