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;