Skip to main content

faucet_core/
columnar.rs

1//! Opt-in Apache Arrow columnar record path (feature `arrow`, RFC 0002 / #375).
2//!
3//! This is the **escape hatch** described in #324: a columnar representation a
4//! connector may produce or consume at the page boundary, *additive* to and
5//! coexisting with the default `serde_json::Value` row model. It touches neither
6//! [`StreamPage`](crate::StreamPage) nor any existing connector — an Arrow-native
7//! source overrides [`Source::stream_batches`](crate::Source::stream_batches) and
8//! an Arrow-native sink overrides
9//! [`Sink::write_batch_columnar`](crate::Sink::write_batch_columnar); the pipeline
10//! uses the columnar path only when **both** sides advertise support (and no
11//! `Value`-shaped stage needs to observe the records), so a
12//! `parquet → parquet` chain never materializes `Value`.
13//!
14//! The `RecordBatch ↔ Value` conversions here are the single source of truth for
15//! the shim (they match `faucet-transform-sql`'s `shovel` byte-for-byte, incl.
16//! `with_explicit_nulls(true)` so an explicit-null field round-trips as
17//! `"key": null` rather than being silently dropped — audit #321 H6).
18
19use crate::FaucetError;
20use arrow::array::RecordBatch;
21use arrow::datatypes::{Schema, SchemaRef};
22use serde_json::Value;
23use std::sync::Arc;
24
25/// A page of records in **columnar** (Arrow) form, the columnar analogue of
26/// [`StreamPage`](crate::StreamPage).
27///
28/// `bookmark` carries the exact same checkpoint semantics as `StreamPage`:
29/// `Some` triggers flush + bookmark-persist after the batch is durably written;
30/// most sources emit `Some` only on the final batch, CDC-style sources per
31/// committed transaction.
32#[derive(Debug, Clone)]
33pub struct ColumnarPage {
34    /// The record batch to write to the sink for this page.
35    pub batch: RecordBatch,
36    /// Optional bookmark to checkpoint after this batch is durably written.
37    pub bookmark: Option<Value>,
38}
39
40impl ColumnarPage {
41    /// Construct a columnar page from a batch and optional bookmark.
42    pub fn new(batch: RecordBatch, bookmark: Option<Value>) -> Self {
43        Self { batch, bookmark }
44    }
45
46    /// Number of rows in the batch.
47    pub fn num_rows(&self) -> usize {
48        self.batch.num_rows()
49    }
50}
51
52/// Map any display-able error into a [`FaucetError::Transform`] with context.
53fn te<E: std::fmt::Display>(ctx: &str, e: E) -> FaucetError {
54    FaucetError::Transform(format!("columnar shim: {ctx}: {e}"))
55}
56
57/// Infer an Arrow [`Schema`] from a slice of JSON records (each a JSON object).
58///
59/// Returns the inferred schema in an [`Arc`]. An empty slice yields a schema
60/// with no fields.
61pub fn infer_arrow_schema(records: &[Value]) -> Result<SchemaRef, FaucetError> {
62    let iter = records
63        .iter()
64        .map(|v| Ok::<_, arrow::error::ArrowError>(v.clone()));
65    let schema = arrow_json::reader::infer_json_schema_from_iterator(iter)
66        .map_err(|e| te("schema inference", e))?;
67    refine_wide_integers(&schema, records).map(Arc::new)
68}
69
70/// Re-type fields that `arrow-json` widened to `Float64` purely because an
71/// integer did not fit `i64`, and refuse the cases that cannot be re-typed.
72///
73/// `arrow-json` infers `Float64` for any integer outside `i64` range, so a `u64`
74/// id such as `18446744073709551615` silently becomes `1.8446744073709552e19` —
75/// exact value gone, `Ok` returned (#460). Where every observed value for such a
76/// field is a non-negative integer, `UInt64` holds it exactly, so use that.
77/// Where no integer type fits (values spanning negative *and* above `i64::MAX`)
78/// or the value sits somewhere this pass does not re-type (inside a list), fail
79/// with a typed error naming the path rather than approximating it.
80///
81/// Fields arrow-json typed `Float64` because a value genuinely *is* fractional
82/// are left alone, as is a field mixing integers and floats — coercing those to
83/// `Float64` is ordinary JSON-number behaviour, not loss of an exact integer.
84fn refine_wide_integers(schema: &Schema, records: &[Value]) -> Result<Schema, FaucetError> {
85    use arrow::datatypes::{DataType, Field};
86
87    /// Values observed at one field across the page (nulls skipped).
88    fn observed<'a>(records: &'a [Value], name: &str) -> Vec<&'a Value> {
89        records
90            .iter()
91            .filter_map(|r| r.get(name))
92            .filter(|v| !v.is_null())
93            .collect()
94    }
95
96    /// Does this number need more than `i64` *and* is it an exact integer?
97    fn is_wide_integer(v: &Value) -> bool {
98        matches!(v, Value::Number(n) if !n.is_i64() && n.is_u64())
99    }
100
101    fn refine_field(field: &Field, values: Vec<&Value>) -> Result<Field, FaucetError> {
102        match field.data_type() {
103            DataType::Float64 if values.iter().any(|v| is_wide_integer(v)) => {
104                // Every value integral and non-negative → UInt64 is exact.
105                if values
106                    .iter()
107                    .all(|v| matches!(v, Value::Number(n) if n.is_u64()))
108                {
109                    Ok(field.clone().with_data_type(DataType::UInt64))
110                } else {
111                    Err(FaucetError::Transform(format!(
112                        "columnar shim: field {:?} mixes an integer above i64::MAX with values \
113                         no unsigned type can hold, so no exact Arrow type fits. Convert it to a \
114                         string first (a `cast` transform) — it is not silently widened to a \
115                         float because that loses the exact value",
116                        field.name()
117                    )))
118                }
119            }
120            DataType::Struct(children) => {
121                let refined = children
122                    .iter()
123                    .map(|child| {
124                        let child_values = values
125                            .iter()
126                            .filter_map(|v| v.get(child.name()))
127                            .filter(|v| !v.is_null())
128                            .collect();
129                        refine_field(child, child_values).map(Arc::new)
130                    })
131                    .collect::<Result<Vec<_>, _>>()?;
132                Ok(field
133                    .clone()
134                    .with_data_type(DataType::Struct(refined.into())))
135            }
136            // A wide integer inside a list is not re-typed by this pass; refusing
137            // beats writing an approximation nobody asked for.
138            _ if contains_wide_integer_in_container(&values) => {
139                Err(FaucetError::Transform(format!(
140                    "columnar shim: field {:?} contains an integer above i64::MAX inside a list, \
141                     which the columnar path cannot represent exactly. Convert those elements to \
142                     strings first (a `cast` transform)",
143                    field.name()
144                )))
145            }
146            _ => Ok(field.clone()),
147        }
148    }
149
150    /// Any wide integer nested inside an array (at any depth).
151    fn contains_wide_integer_in_container(values: &[&Value]) -> bool {
152        fn walk(v: &Value, in_list: bool) -> bool {
153            match v {
154                Value::Array(items) => items.iter().any(|i| walk(i, true)),
155                Value::Object(map) => map.values().any(|i| walk(i, in_list)),
156                other => in_list && is_wide_integer(other),
157            }
158        }
159        values.iter().any(|v| walk(v, false))
160    }
161
162    let fields = schema
163        .fields()
164        .iter()
165        .map(|f| refine_field(f, observed(records, f.name())).map(Arc::new))
166        .collect::<Result<Vec<_>, _>>()?;
167    Ok(Schema::new(fields).with_metadata(schema.metadata().clone()))
168}
169
170/// Encode a slice of JSON records into a single [`RecordBatch`] against `schema`.
171///
172/// Returns an empty batch if `records` is empty.
173pub fn values_to_record_batch(
174    records: &[Value],
175    schema: SchemaRef,
176) -> Result<RecordBatch, FaucetError> {
177    let mut decoder = arrow_json::ReaderBuilder::new(schema.clone())
178        .build_decoder()
179        .map_err(|e| te("decoder build", e))?;
180    decoder.serialize(records).map_err(|e| te("encode", e))?;
181    let mut batches = Vec::new();
182    while let Some(b) = decoder.flush().map_err(|e| te("flush", e))? {
183        batches.push(b);
184    }
185    if batches.is_empty() {
186        return Ok(RecordBatch::new_empty(schema));
187    }
188    if batches.len() == 1 {
189        return Ok(batches.pop().unwrap());
190    }
191    arrow::compute::concat_batches(&schema, &batches).map_err(|e| te("concat", e))
192}
193
194/// Convenience: infer the schema from `records` and encode them into a batch.
195pub fn values_to_record_batch_inferred(records: &[Value]) -> Result<RecordBatch, FaucetError> {
196    let schema = infer_arrow_schema(records)?;
197    values_to_record_batch(records, schema)
198}
199
200/// Decode a [`RecordBatch`] into JSON objects (one per row).
201///
202/// Uses `arrow-json`'s array writer with **explicit nulls enabled**, so a
203/// null-valued column is emitted as `"key": null` rather than omitted — without
204/// this a `SELECT *`-style identity would silently delete every explicit-null
205/// field (audit #321 H6). An empty batch returns an empty `Vec`.
206pub fn record_batch_to_values(batch: &RecordBatch) -> Result<Vec<Value>, FaucetError> {
207    let mut buf = Vec::new();
208    {
209        let mut writer = arrow_json::writer::WriterBuilder::new()
210            .with_explicit_nulls(true)
211            .build::<_, arrow_json::writer::JsonArray>(&mut buf);
212        writer.write(batch).map_err(|e| te("json write", e))?;
213        writer.finish().map_err(|e| te("json finish", e))?;
214    }
215    serde_json::from_slice(&buf).map_err(|e| te("json parse", e))
216}
217
218/// Compare two schemas for field-level equality (name + data-type + nullability).
219pub fn schema_eq(a: &Schema, b: &Schema) -> bool {
220    a.fields() == b.fields()
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use serde_json::json;
227
228    #[test]
229    fn round_trip_scalars_nulls_nested() {
230        let recs = vec![
231            json!({"id": 1, "name": "a", "score": 1.5, "ok": true, "tags": ["x", "y"]}),
232            json!({"id": 2, "name": null, "score": null, "ok": false, "tags": []}),
233        ];
234        let batch = values_to_record_batch_inferred(&recs).unwrap();
235        assert_eq!(batch.num_rows(), 2);
236        let back = record_batch_to_values(&batch).unwrap();
237        assert_eq!(back[0]["id"], json!(1));
238        assert_eq!(back[0]["tags"], json!(["x", "y"]));
239        // #321 H6: an explicit-null field survives the round-trip.
240        assert!(back[1].as_object().unwrap().contains_key("name"));
241        assert_eq!(back[1]["name"], json!(null));
242    }
243
244    #[test]
245    fn empty_records_yield_empty_batch_and_back() {
246        let schema = infer_arrow_schema(&[json!({"a": 1})]).unwrap();
247        let batch = values_to_record_batch(&[], schema).unwrap();
248        assert_eq!(batch.num_rows(), 0);
249        assert!(record_batch_to_values(&batch).unwrap().is_empty());
250    }
251
252    #[test]
253    fn columnar_page_reports_rows() {
254        let batch = values_to_record_batch_inferred(&[json!({"a": 1}), json!({"a": 2})]).unwrap();
255        let page = ColumnarPage::new(batch, Some(json!({"lsn": 42})));
256        assert_eq!(page.num_rows(), 2);
257        assert_eq!(page.bookmark, Some(json!({"lsn": 42})));
258    }
259}
260
261#[cfg(test)]
262mod wide_integer_tests {
263    use super::*;
264    use serde_json::json;
265
266    /// #460: arrow-json types an integer above `i64::MAX` as `Float64`, so
267    /// `18446744073709551615` used to come back as `1.8446744073709552e19` with
268    /// `Ok`. It must now round-trip exactly.
269    #[test]
270    fn u64_above_i64_max_round_trips_exactly() {
271        let recs = vec![json!({"id": u64::MAX})];
272        let back =
273            record_batch_to_values(&values_to_record_batch_inferred(&recs).unwrap()).unwrap();
274        assert_eq!(back[0]["id"], json!(u64::MAX), "exact value must survive");
275        assert!(back[0]["id"].is_u64(), "and stay an integer, not a float");
276    }
277
278    #[test]
279    fn mixed_small_and_wide_integers_all_survive() {
280        let recs = vec![
281            json!({"id": 1}),
282            json!({"id": u64::MAX}),
283            json!({"id": null}),
284        ];
285        let back =
286            record_batch_to_values(&values_to_record_batch_inferred(&recs).unwrap()).unwrap();
287        assert_eq!(back[0]["id"], json!(1));
288        assert_eq!(back[1]["id"], json!(u64::MAX));
289        assert_eq!(back[2]["id"], json!(null));
290    }
291
292    #[test]
293    fn wide_integer_nested_in_a_struct_survives() {
294        let recs = vec![json!({"outer": {"id": u64::MAX, "n": 3}})];
295        let back =
296            record_batch_to_values(&values_to_record_batch_inferred(&recs).unwrap()).unwrap();
297        assert_eq!(back[0]["outer"]["id"], json!(u64::MAX));
298        assert_eq!(back[0]["outer"]["n"], json!(3));
299    }
300
301    /// Genuine floats, and integer/float mixes, keep their existing behaviour —
302    /// coercing those to Float64 is ordinary JSON-number semantics, not loss of
303    /// an exact integer.
304    #[test]
305    fn genuine_floats_are_untouched() {
306        let recs = vec![json!({"a": 1.5}), json!({"a": 2})];
307        let back =
308            record_batch_to_values(&values_to_record_batch_inferred(&recs).unwrap()).unwrap();
309        assert_eq!(back[0]["a"], json!(1.5));
310        assert_eq!(back[1]["a"], json!(2.0));
311
312        // i64 range is unaffected (2^53+1 already round-tripped before).
313        let recs = vec![json!({"n": 9007199254740993i64, "m": i64::MIN})];
314        let back =
315            record_batch_to_values(&values_to_record_batch_inferred(&recs).unwrap()).unwrap();
316        assert_eq!(back[0]["n"], json!(9007199254740993i64));
317        assert_eq!(back[0]["m"], json!(i64::MIN));
318    }
319
320    /// No exact type fits a field spanning negatives and above-i64::MAX, so it
321    /// must fail loudly rather than approximate.
322    #[test]
323    fn unrepresentable_mix_is_refused() {
324        let recs = vec![json!({"v": -1}), json!({"v": u64::MAX})];
325        let err = match values_to_record_batch_inferred(&recs) {
326            Err(e) => e.to_string(),
327            Ok(b) => panic!("must refuse; got {:?}", record_batch_to_values(&b).unwrap()),
328        };
329        assert!(err.contains("\"v\""), "{err}");
330        assert!(err.contains("cast"), "points at the workaround: {err}");
331    }
332
333    /// A wide integer inside a list is not re-typed by this pass, so it is
334    /// refused rather than silently written as a float.
335    #[test]
336    fn wide_integer_inside_a_list_is_refused() {
337        let recs = vec![json!({"ids": [1, u64::MAX]})];
338        let err = match values_to_record_batch_inferred(&recs) {
339            Err(e) => e.to_string(),
340            Ok(_) => panic!("must refuse a wide integer inside a list"),
341        };
342        assert!(err.contains("\"ids\""), "{err}");
343        assert!(err.contains("list"), "{err}");
344    }
345}