Skip to main content

chronix_query/
convert.rs

1//! Conversion from [`Point`] collections to Arrow [`RecordBatch`].
2//!
3//! This module bridges the chronix-core `Point` type and Apache Arrow
4//! columnar format, enabling zero-copy interop between the memtable
5//! (row-oriented) and the query engine (columnar).
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::sync::Arc;
9
10use arrow::array::{
11    ArrayRef, BooleanArray, Decimal128Array, Float64Array, Int64Array, StringArray, UInt64Array,
12};
13use arrow::datatypes::{DataType, Field, Schema};
14use arrow::record_batch::RecordBatch;
15
16use chronix_core::types::{FieldValue, Point};
17
18use crate::error::{QueryError, Result};
19
20/// Column role in the output schema.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22enum ColumnRole {
23    Timestamp,
24    Tag,
25    Field(FieldType),
26}
27
28/// Field value type for schema inference.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum FieldType {
31    I64,
32    U64,
33    F64,
34    Bool,
35    String,
36    /// An exact decimal, carrying the column's scale.
37    Decimal(u8),
38    /// A native histogram — a composite sample, stored as an encoded blob.
39    Histogram,
40}
41
42/// A discovered column from point data.
43#[derive(Debug)]
44struct Column {
45    name: String,
46    role: ColumnRole,
47}
48
49/// Arrow field metadata naming a column's role.
50///
51/// Private because `ColumnRole` is: the *value* travels on the batch, which
52/// is what a consumer reads.
53///
54/// A tag and a string field are both `Utf8`, so a schema built from types
55/// alone cannot be classified afterwards — the role has to travel with the
56/// batch. `chronix_engine::segment::metadata::roles::ARROW_ROLE_KEY` is the
57/// same key from the storage side.
58#[must_use]
59fn role_metadata(role: ColumnRole) -> std::collections::HashMap<String, String> {
60    // The *role*, not the column name.
61    let name = match role {
62        ColumnRole::Timestamp => "timestamp",
63        ColumnRole::Tag => "tag",
64        ColumnRole::Field(_) => "field",
65    };
66    [("role".to_string(), name.to_string())].into()
67}
68
69/// Convert a slice of [`Point`]s to an Arrow [`RecordBatch`].
70///
71/// The resulting batch has the same columnar structure as what
72/// `SegmentReader::read_all()` produces:
73/// - `timestamp` column (Int64)
74/// - One column per tag key (Utf8)
75/// - One column per field name (typed: I64, U64, F64, Bool, or Utf8)
76///
77/// Points are assumed to all belong to the same measurement.
78///
79/// # Errors
80///
81/// Returns [`QueryError::Arrow`] if the `RecordBatch` cannot be constructed.
82pub fn points_to_record_batch(points: &[Point]) -> Result<RecordBatch> {
83    if points.is_empty() {
84        return Ok(RecordBatch::new_empty(Arc::new(Schema::empty())));
85    }
86
87    // Discover schema by scanning all points
88    let columns = discover_columns(points);
89
90    // Build Arrow arrays
91    let mut arrow_arrays: Vec<ArrayRef> = Vec::with_capacity(columns.len());
92    let mut fields: Vec<Field> = Vec::with_capacity(columns.len());
93
94    for col in &columns {
95        // The role is known right here and used to be dropped: a tag and a
96        // string field are both `Utf8`, so a schema built from types alone
97        // cannot be classified afterwards without guessing — and four
98        // consumers guessed, three of them differently.
99        let meta = role_metadata(col.role);
100        match col.role {
101            ColumnRole::Timestamp => {
102                let values: Vec<i64> = points.iter().map(Point::timestamp).collect();
103                arrow_arrays.push(Arc::new(Int64Array::from(values)));
104                fields
105                    .push(Field::new(&col.name, DataType::Int64, true).with_metadata(meta.clone()));
106            }
107            ColumnRole::Tag => {
108                let values: Vec<Option<&str>> = points
109                    .iter()
110                    .map(|p| p.series_key().tag(&col.name))
111                    .collect();
112                arrow_arrays.push(Arc::new(StringArray::from(values)));
113                fields
114                    .push(Field::new(&col.name, DataType::Utf8, true).with_metadata(meta.clone()));
115            }
116            ColumnRole::Field(ft) => match ft {
117                FieldType::I64 => {
118                    let values: Vec<Option<i64>> = points
119                        .iter()
120                        .map(|p| match p.field(&col.name) {
121                            Some(FieldValue::I64(v)) => Some(*v),
122                            _ => None,
123                        })
124                        .collect();
125                    arrow_arrays.push(Arc::new(Int64Array::from(values)));
126                    fields.push(
127                        Field::new(&col.name, DataType::Int64, true).with_metadata(meta.clone()),
128                    );
129                }
130                FieldType::U64 => {
131                    let values: Vec<Option<u64>> = points
132                        .iter()
133                        .map(|p| match p.field(&col.name) {
134                            Some(FieldValue::U64(v)) => Some(*v),
135                            _ => None,
136                        })
137                        .collect();
138                    arrow_arrays.push(Arc::new(UInt64Array::from(values)));
139                    fields.push(
140                        Field::new(&col.name, DataType::UInt64, true).with_metadata(meta.clone()),
141                    );
142                }
143                FieldType::F64 => {
144                    let values: Vec<Option<f64>> = points
145                        .iter()
146                        .map(|p| match p.field(&col.name) {
147                            Some(FieldValue::F64(v)) => Some(*v),
148                            _ => None,
149                        })
150                        .collect();
151                    arrow_arrays.push(Arc::new(Float64Array::from(values)));
152                    fields.push(
153                        Field::new(&col.name, DataType::Float64, true).with_metadata(meta.clone()),
154                    );
155                }
156                FieldType::Bool => {
157                    let values: Vec<Option<bool>> = points
158                        .iter()
159                        .map(|p| match p.field(&col.name) {
160                            Some(FieldValue::Bool(v)) => Some(*v),
161                            _ => None,
162                        })
163                        .collect();
164                    arrow_arrays.push(Arc::new(BooleanArray::from(values)));
165                    fields.push(
166                        Field::new(&col.name, DataType::Boolean, true).with_metadata(meta.clone()),
167                    );
168                }
169                FieldType::Histogram => {
170                    // A `postcard` blob per value. Encoding cannot fail for
171                    // an owned struct with no borrowed data, so a `None`
172                    // here means the point had no value for this column.
173                    let values: Vec<Option<Vec<u8>>> = points
174                        .iter()
175                        .map(|p| match p.field(&col.name) {
176                            Some(FieldValue::Histogram(h)) => {
177                                postcard::to_allocvec(h.as_ref()).ok()
178                            }
179                            _ => None,
180                        })
181                        .collect();
182                    let refs: Vec<Option<&[u8]>> = values.iter().map(|v| v.as_deref()).collect();
183                    arrow_arrays.push(Arc::new(arrow::array::BinaryArray::from(refs)));
184                    fields.push(
185                        Field::new(&col.name, DataType::Binary, true).with_metadata(meta.clone()),
186                    );
187                }
188                FieldType::Decimal(scale) => {
189                    // Mantissas at the column's scale. A value that arrived
190                    // at a narrower scale is widened losslessly; one that
191                    // cannot be expressed at this scale would have to be
192                    // rounded, so it reads as NULL rather than as a
193                    // quietly wrong number.
194                    let values: Vec<Option<i128>> = points
195                        .iter()
196                        .map(|p| match p.field(&col.name) {
197                            Some(FieldValue::Decimal(d)) => {
198                                d.rescale(scale).ok().map(|v| v.mantissa())
199                            }
200                            _ => None,
201                        })
202                        .collect();
203                    let dt = DataType::Decimal128(
204                        chronix_core::DECIMAL_PRECISION,
205                        i8::try_from(scale).unwrap_or(0),
206                    );
207                    let array = Decimal128Array::from(values)
208                        .with_precision_and_scale(
209                            chronix_core::DECIMAL_PRECISION,
210                            i8::try_from(scale).unwrap_or(0),
211                        )
212                        .map_err(QueryError::from)?;
213                    arrow_arrays.push(Arc::new(array));
214                    fields.push(Field::new(&col.name, dt, true).with_metadata(meta.clone()));
215                }
216                FieldType::String => {
217                    let values: Vec<Option<String>> = points
218                        .iter()
219                        .map(|p| match p.field(&col.name) {
220                            Some(FieldValue::String(v)) => Some(v.clone()),
221                            _ => None,
222                        })
223                        .collect();
224                    let refs: Vec<Option<&str>> = values.iter().map(|v| v.as_deref()).collect();
225                    arrow_arrays.push(Arc::new(StringArray::from(refs)));
226                    fields.push(
227                        Field::new(&col.name, DataType::Utf8, true).with_metadata(meta.clone()),
228                    );
229                }
230            },
231        }
232    }
233
234    let schema = Arc::new(Schema::new(fields));
235    RecordBatch::try_new(schema, arrow_arrays).map_err(QueryError::from)
236}
237
238/// Discover column definitions by scanning all points.
239///
240/// Returns columns in deterministic order: timestamp first, then tags
241/// (sorted), then fields (sorted).
242fn discover_columns(points: &[Point]) -> Vec<Column> {
243    let mut tag_keys: BTreeSet<String> = BTreeSet::new();
244    let mut field_defs: BTreeMap<String, FieldType> = BTreeMap::new();
245
246    for p in points {
247        for key in p.series_key().tag_keys() {
248            tag_keys.insert(key.to_string());
249        }
250        for (name, value) in p.fields() {
251            let ft = match value {
252                FieldValue::I64(_) => FieldType::I64,
253                FieldValue::U64(_) => FieldType::U64,
254                FieldValue::F64(_) => FieldType::F64,
255                FieldValue::Bool(_) => FieldType::Bool,
256                FieldValue::String(_) => FieldType::String,
257                FieldValue::Decimal(d) => FieldType::Decimal(d.scale()),
258                FieldValue::Histogram(_) => FieldType::Histogram,
259            };
260            // Detect type conflicts across points and widen
261            // numeric types automatically (I64/U64 → F64) rather than
262            // silently using the first-seen type.
263            field_defs
264                .entry(name.to_string())
265                .and_modify(|existing| {
266                    if *existing != ft {
267                        // Widen numeric types to F64 (the most general
268                        // numeric representation). Non-numeric conflicts
269                        // (e.g. Bool vs String) default to String.
270                        *existing = match (*existing, ft) {
271                            (FieldType::I64, FieldType::F64)
272                            | (FieldType::F64, FieldType::I64)
273                            | (FieldType::U64, FieldType::F64)
274                            | (FieldType::F64, FieldType::U64)
275                            | (FieldType::I64, FieldType::U64)
276                            | (FieldType::U64, FieldType::I64) => FieldType::F64,
277                            // Two scales of the same decimal column are not
278                            // a conflict: the wider one holds both, exactly.
279                            // (The write path normalises, so this only
280                            // arises for points that never went through it.)
281                            (FieldType::Decimal(a), FieldType::Decimal(b)) => {
282                                FieldType::Decimal(a.max(b))
283                            }
284                            _ => FieldType::String,
285                        };
286                    }
287                })
288                .or_insert(ft);
289        }
290    }
291
292    let mut columns = Vec::new();
293
294    // Timestamp first
295    columns.push(Column {
296        name: chronix_core::TIME_COLUMN.to_string(),
297        role: ColumnRole::Timestamp,
298    });
299
300    // Tags sorted
301    for key in &tag_keys {
302        columns.push(Column {
303            name: key.clone(),
304            role: ColumnRole::Tag,
305        });
306    }
307
308    // Fields sorted
309    for (name, ft) in &field_defs {
310        columns.push(Column {
311            name: name.clone(),
312            role: ColumnRole::Field(*ft),
313        });
314    }
315
316    columns
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use std::collections::BTreeMap;
323
324    use chronix_core::types::SeriesKey;
325
326    fn make_point(measurement: &str, host: &str, ts: i64, value: f64) -> Point {
327        let tags: BTreeMap<String, String> = [("host".to_string(), host.to_string())]
328            .into_iter()
329            .collect();
330        let series_key = SeriesKey::new(measurement.to_string(), tags).unwrap();
331        let fields: BTreeMap<String, FieldValue> = [("value".to_string(), FieldValue::F64(value))]
332            .into_iter()
333            .collect();
334        Point::new(series_key, fields, ts).unwrap()
335    }
336
337    #[test]
338    fn empty_points_returns_empty_batch() {
339        let batch = points_to_record_batch(&[]).unwrap();
340        assert_eq!(batch.num_rows(), 0);
341    }
342
343    #[test]
344    fn single_point_roundtrip() {
345        let points = vec![make_point("cpu", "server-1", 1000, 42.5)];
346        let batch = points_to_record_batch(&points).unwrap();
347
348        assert_eq!(batch.num_rows(), 1);
349        assert_eq!(batch.num_columns(), 3); // timestamp, host, value
350
351        let ts = batch
352            .column(0)
353            .as_any()
354            .downcast_ref::<Int64Array>()
355            .unwrap();
356        assert_eq!(ts.value(0), 1000);
357
358        let host = batch
359            .column(1)
360            .as_any()
361            .downcast_ref::<StringArray>()
362            .unwrap();
363        assert_eq!(host.value(0), "server-1");
364
365        let value = batch
366            .column(2)
367            .as_any()
368            .downcast_ref::<Float64Array>()
369            .unwrap();
370        assert!((value.value(0) - 42.5).abs() < f64::EPSILON);
371    }
372
373    #[test]
374    #[allow(clippy::cast_precision_loss)]
375    fn multiple_points_same_series() {
376        let points: Vec<Point> = (0..10)
377            .map(|i| make_point("cpu", "a", i * 100, i as f64))
378            .collect();
379        let batch = points_to_record_batch(&points).unwrap();
380
381        assert_eq!(batch.num_rows(), 10);
382        assert_eq!(batch.num_columns(), 3);
383    }
384
385    #[test]
386    fn multiple_series_different_tags() {
387        let points = vec![
388            make_point("cpu", "a", 100, 1.0),
389            make_point("cpu", "b", 200, 2.0),
390        ];
391        let batch = points_to_record_batch(&points).unwrap();
392
393        assert_eq!(batch.num_rows(), 2);
394        let host = batch
395            .column(1)
396            .as_any()
397            .downcast_ref::<StringArray>()
398            .unwrap();
399        assert_eq!(host.value(0), "a");
400        assert_eq!(host.value(1), "b");
401    }
402
403    #[test]
404    fn multiple_field_types() {
405        let tags: BTreeMap<String, String> = BTreeMap::new();
406        let key = SeriesKey::new("m".to_string(), tags).unwrap();
407        let fields: BTreeMap<String, FieldValue> = [
408            ("f_i64".to_string(), FieldValue::I64(42)),
409            ("f_u64".to_string(), FieldValue::U64(99)),
410            ("f_f64".to_string(), FieldValue::F64(3.125)),
411            ("f_bool".to_string(), FieldValue::Bool(true)),
412            ("f_str".to_string(), FieldValue::String("hello".into())),
413        ]
414        .into_iter()
415        .collect();
416        let point = Point::new(key, fields, 1000).unwrap();
417
418        let batch = points_to_record_batch(&[point]).unwrap();
419        assert_eq!(batch.num_rows(), 1);
420        // timestamp + 5 fields = 6 columns (no tags)
421        assert_eq!(batch.num_columns(), 6);
422
423        // Verify schema field types
424        let schema = batch.schema();
425        assert_eq!(schema.field(0).data_type(), &DataType::Int64); // timestamp
426        assert_eq!(schema.field(1).data_type(), &DataType::Boolean); // f_bool
427        assert_eq!(schema.field(2).data_type(), &DataType::Float64); // f_f64
428        assert_eq!(schema.field(3).data_type(), &DataType::Int64); // f_i64
429        assert_eq!(schema.field(4).data_type(), &DataType::Utf8); // f_str
430        assert_eq!(schema.field(5).data_type(), &DataType::UInt64); // f_u64
431    }
432
433    #[test]
434    fn schema_order_is_deterministic() {
435        let tags: BTreeMap<String, String> = [
436            ("dc".to_string(), "us".to_string()),
437            ("host".to_string(), "a".to_string()),
438        ]
439        .into_iter()
440        .collect();
441        let key = SeriesKey::new("cpu".to_string(), tags).unwrap();
442        let fields: BTreeMap<String, FieldValue> = [
443            ("z_field".to_string(), FieldValue::F64(1.0)),
444            ("a_field".to_string(), FieldValue::F64(2.0)),
445        ]
446        .into_iter()
447        .collect();
448        let point = Point::new(key, fields, 1000).unwrap();
449
450        let batch = points_to_record_batch(&[point]).unwrap();
451        let schema = batch.schema();
452
453        // Order: timestamp, dc, host (tags sorted), a_field, z_field (fields sorted)
454        assert_eq!(schema.field(0).name(), chronix_core::TIME_COLUMN);
455        assert_eq!(schema.field(1).name(), "dc");
456        assert_eq!(schema.field(2).name(), "host");
457        assert_eq!(schema.field(3).name(), "a_field");
458        assert_eq!(schema.field(4).name(), "z_field");
459    }
460}