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;