Skip to main content

chronix_query/
filter.rs

1//! Vectorized predicate filtering on Arrow arrays.
2//!
3//! Applies time-range and tag-equality filters to Arrow `RecordBatch`es,
4//! producing filtered batches containing only matching rows.
5
6use std::sync::Arc;
7
8use arrow::array::{Array, BooleanArray, Int64Array, StringArray};
9use arrow::compute;
10use arrow::compute::kernels::boolean as arrow_bool;
11use arrow::datatypes::{DataType, Int32Type};
12use arrow::record_batch::RecordBatch;
13
14use crate::error::{QueryError, Result};
15use chronix_core::TombstoneSet;
16
17/// Apply a time range filter: `timestamp >= start AND timestamp <= end`.
18///
19/// Returns a `BooleanArray` mask where `true` indicates a matching row.
20///
21/// # Errors
22///
23/// Returns an error if the column is not an `Int64Array`.
24pub fn time_range_mask(timestamps: &Int64Array, start: i64, end: i64) -> Result<BooleanArray> {
25    let gte = compute::kernels::cmp::gt_eq(timestamps, &Int64Array::new_scalar(start))?;
26    let lte = compute::kernels::cmp::lt_eq(timestamps, &Int64Array::new_scalar(end))?;
27    let combined = arrow_bool::and(&gte, &lte)?;
28    Ok(combined)
29}
30
31/// Apply a tag equality filter: `column == value`.
32///
33/// Returns a `BooleanArray` mask where `true` indicates a matching row.
34///
35/// # Errors
36///
37/// Returns an error if the column is not a `StringArray`.
38pub fn tag_equality_mask(column: &StringArray, value: &str) -> Result<BooleanArray> {
39    let scalar = StringArray::new_scalar(value);
40    let mask = compute::kernels::cmp::eq(column, &scalar)?;
41    Ok(mask)
42}
43
44/// Dictionary-encoded equality filter using Arrow's vectorized `eq` kernel.
45///
46/// Arrow's comparison kernels handle `DictionaryArray` natively — they
47/// resolve dictionary indices internally and use SIMD-friendly comparison
48/// paths.  This replaces the previous manual row-by-row index matching
49/// with a single kernel call.
50///
51/// Falls back to cast→StringArray if the dictionary value type is not Utf8 or
52/// the key type is not Int32.
53pub fn dict_equality_mask(col: &dyn Array, value: &str) -> Result<BooleanArray> {
54    // Fast path for the common Dictionary<Int32, Utf8> case:
55    // Arrow's eq kernel handles DictionaryArray directly.
56    if let Some(dict) = col
57        .as_any()
58        .downcast_ref::<arrow::array::DictionaryArray<Int32Type>>()
59    {
60        let scalar = StringArray::new_scalar(value);
61        let mask = compute::kernels::cmp::eq(dict, &scalar)?;
62        return Ok(mask);
63    }
64
65    // Fallback: cast to Utf8 for other dictionary key/value type combos.
66    let casted = compute::cast(col, &DataType::Utf8)?;
67    let str_arr = casted
68        .as_any()
69        .downcast_ref::<StringArray>()
70        .ok_or_else(|| {
71            QueryError::Validation("dictionary column could not be cast to Utf8".into())
72        })?;
73    tag_equality_mask(str_arr, value)
74}
75
76/// Combine multiple boolean masks with AND.
77///
78/// Returns the intersection of all masks.
79///
80/// # Errors
81///
82/// Returns an error if masks have different lengths.
83pub fn combine_masks(masks: &[BooleanArray]) -> Result<BooleanArray> {
84    if masks.is_empty() {
85        return Err(QueryError::Validation("no masks to combine".into()));
86    }
87
88    let mut result = masks[0].clone();
89    for mask in &masks[1..] {
90        result = arrow_bool::and(&result, mask)?;
91    }
92    Ok(result)
93}
94
95/// Apply a boolean mask to a `RecordBatch`, returning only matching rows.
96///
97/// # Errors
98///
99/// Returns an error if the filter operation fails.
100pub fn apply_filter(batch: &RecordBatch, mask: &BooleanArray) -> Result<RecordBatch> {
101    let filtered_columns: std::result::Result<Vec<_>, _> = batch
102        .columns()
103        .iter()
104        .map(|col| compute::filter(col.as_ref(), mask))
105        .collect();
106    let filtered_columns = filtered_columns?;
107
108    let batch = RecordBatch::try_new(batch.schema(), filtered_columns)?;
109    Ok(batch)
110}
111
112/// Apply time range and tag filters to a `RecordBatch`.
113///
114/// This is the main entry point for the filter pipeline. It:
115/// 1. Applies the time range filter on the `_time` column
116/// 2. Applies tag equality filters on the specified columns
117/// 3. Combines all masks with AND
118/// 4. Filters the batch
119///
120/// Returns the filtered `RecordBatch`.
121///
122/// # Errors
123///
124/// Returns an error if columns are not found or have wrong types.
125pub fn filter_batch(
126    batch: &RecordBatch,
127    time_start: i64,
128    time_end: i64,
129    tag_filters: &[(&str, &str)],
130) -> Result<RecordBatch> {
131    if batch.num_rows() == 0 {
132        return Ok(batch.clone());
133    }
134
135    let mut masks: Vec<BooleanArray> = Vec::new();
136
137    // Time range filter
138    let time_col = batch
139        .column_by_name(chronix_core::TIME_COLUMN)
140        .ok_or_else(|| QueryError::Validation("'timestamp' column not found".into()))?;
141    let timestamps = time_col
142        .as_any()
143        .downcast_ref::<Int64Array>()
144        .ok_or_else(|| QueryError::Validation("'timestamp' column is not Int64".into()))?;
145
146    // Only apply time filter if the range isn't "everything"
147    if time_start != i64::MIN || time_end != i64::MAX {
148        masks.push(time_range_mask(timestamps, time_start, time_end)?);
149    }
150
151    // Tag equality filters
152    for (key, value) in tag_filters {
153        if let Some(col) = batch.column_by_name(key) {
154            // Handle tag filtering on various string
155            // representations. For Dictionary-encoded columns, operate on
156            // encoded indices directly to avoid string materialization.
157            let mask = match col.data_type() {
158                DataType::Utf8 => col
159                    .as_any()
160                    .downcast_ref::<StringArray>()
161                    .map(|s| tag_equality_mask(s, value)),
162                DataType::Dictionary(_, _) => Some(dict_equality_mask(col.as_ref(), value)),
163                DataType::LargeUtf8 | DataType::Utf8View => {
164                    compute::cast(col.as_ref(), &DataType::Utf8)
165                        .ok()
166                        .and_then(|arr| arr.as_any().downcast_ref::<StringArray>().cloned())
167                        .map(|s| tag_equality_mask(&s, value))
168                }
169                _ => None,
170            };
171            match mask {
172                Some(Ok(m)) => masks.push(m),
173                Some(Err(e)) => return Err(e),
174                None => {
175                    // Column is not any string type — no row can match.
176                    return apply_filter(batch, &BooleanArray::from(vec![false; batch.num_rows()]));
177                }
178            }
179        } else {
180            // Column does not exist in batch — no row can match this filter.
181            // Return an empty batch immediately.
182            return apply_filter(batch, &BooleanArray::from(vec![false; batch.num_rows()]));
183        }
184    }
185
186    if masks.is_empty() {
187        return Ok(batch.clone());
188    }
189
190    let combined = combine_masks(&masks)?;
191    apply_filter(batch, &combined)
192}
193
194/// Build a `RecordBatch` with the specified column projection.
195///
196/// Selects only the named columns from the input batch. Always includes `_time`.
197///
198/// # Errors
199///
200/// Returns an error if the batch cannot be constructed.
201pub fn project_batch(batch: &RecordBatch, projection: &[String]) -> Result<RecordBatch> {
202    if projection.is_empty() {
203        return Ok(batch.clone());
204    }
205
206    // Always include timestamp column
207    let mut col_names: Vec<&str> = vec![chronix_core::TIME_COLUMN];
208    for name in projection {
209        if name != chronix_core::TIME_COLUMN {
210            col_names.push(name);
211        }
212    }
213
214    let mut fields = Vec::new();
215    let mut columns: Vec<Arc<dyn Array>> = Vec::new();
216
217    for name in &col_names {
218        if let Ok(idx) = batch.schema().index_of(name) {
219            fields.push(batch.schema().field(idx).clone());
220            columns.push(Arc::clone(batch.column(idx)));
221        }
222    }
223
224    if fields.is_empty() {
225        return Ok(batch.clone());
226    }
227
228    let schema = Arc::new(arrow::datatypes::Schema::new(fields));
229    let projected = RecordBatch::try_new(schema, columns)?;
230    Ok(projected)
231}
232
233/// Filter out rows belonging to tombstoned series.
234///
235/// For each row, the canonical series key is computed from `(measurement, tag columns)`
236/// using the same format as [`chronix_core::SeriesKey::canonical_form()`]:
237/// `measurement\0tag1=v1\0tag2=v2` (tags sorted by name).
238/// Rows whose canonical key appears in `tombstones` are excluded.
239///
240/// When `tag_col_names` is provided, only those columns are treated as tags.
241/// This is critical when string-typed field columns exist — without explicit
242/// tag names, string fields would be misidentified as tags, producing
243/// incorrect canonical keys that don't match [`chronix_core::SeriesKey::canonical_form()`].
244///
245/// If no tombstones exist, the batch is returned unchanged (fast path).
246///
247/// # Errors
248///
249/// Returns an error if the filter operation fails.
250pub fn filter_tombstoned(
251    batch: &RecordBatch,
252    measurement: &str,
253    tombstones: &TombstoneSet,
254    tag_col_names: Option<&[&str]>,
255    segment_id: u64,
256) -> Result<RecordBatch> {
257    if tombstones.is_empty() || batch.num_rows() == 0 {
258        return Ok(batch.clone());
259    }
260
261    // Resolve tag columns — prefer explicit names when available,
262    // fall back to heuristic (all string-like columns except `_time`).
263    let schema = batch.schema();
264    let tag_columns: Vec<(usize, &str)> = if let Some(names) = tag_col_names {
265        names
266            .iter()
267            .filter_map(|&name| schema.index_of(name).ok().map(|idx| (idx, name)))
268            .collect()
269    } else {
270        schema
271            .fields()
272            .iter()
273            .enumerate()
274            .filter(|(_, f)| is_string_like(f.data_type()) && f.name() != chronix_core::TIME_COLUMN)
275            .map(|(i, f)| (i, f.name().as_str()))
276            .collect()
277    };
278
279    // Build per-row boolean mask using canonical series key for
280    // collision-proof tombstone matching.
281    // Support ranged tombstones via timestamp lookup.
282    let ts_col = batch
283        .column_by_name(chronix_core::TIME_COLUMN)
284        .and_then(|c| c.as_any().downcast_ref::<Int64Array>());
285    let keep: Vec<bool> = (0..batch.num_rows())
286        .map(|row| {
287            let canonical = compute_series_canonical(measurement, &tag_columns, batch, row);
288            let ts = ts_col.map_or(0, |a| a.value(row));
289            !tombstones.is_tombstoned_in(&canonical, ts, segment_id)
290        })
291        .collect();
292
293    // Fast path: if all rows survive, return unchanged
294    if keep.iter().all(|&k| k) {
295        return Ok(batch.clone());
296    }
297
298    let mask = BooleanArray::from(keep);
299    apply_filter(batch, &mask)
300}
301
302/// Returns `true` if the data type is a string-like type (Utf8, LargeUtf8,
303/// Utf8View, or Dictionary with a string value type).
304fn is_string_like(dt: &DataType) -> bool {
305    matches!(
306        dt,
307        DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View | DataType::Dictionary(_, _)
308    )
309}
310
311/// Extract the string value at `row` from a column that may be `Utf8` or
312/// `Dictionary<Int32, Utf8>`.
313///
314/// Returns `None` if the value is null or the column type is unsupported.
315fn extract_string_value(arr: &dyn Array, row: usize) -> Option<&str> {
316    if arr.is_null(row) {
317        return None;
318    }
319    if let Some(s) = arr.as_any().downcast_ref::<StringArray>() {
320        return Some(s.value(row));
321    }
322    if let Some(dict) = arr
323        .as_any()
324        .downcast_ref::<arrow::array::DictionaryArray<Int32Type>>()
325    {
326        let values = dict.values().as_any().downcast_ref::<StringArray>()?;
327        let key = dict.keys().value(row);
328        return Some(values.value(key as usize));
329    }
330    None
331}
332
333/// Compute the canonical series key string for a single row.
334///
335/// Returns `measurement\0tag1=v1\0tag2=v2` (tags sorted by name),
336/// matching [`chronix_core::SeriesKey::canonical_form()`] exactly.
337/// Used for collision-proof tombstone matching.
338///
339/// Handles `Utf8` and `Dictionary<Int32, Utf8>` column types.
340fn compute_series_canonical(
341    measurement: &str,
342    tag_columns: &[(usize, &str)],
343    batch: &RecordBatch,
344    row: usize,
345) -> String {
346    let mut tag_pairs: Vec<(&str, &str)> = tag_columns
347        .iter()
348        .filter_map(|&(col_idx, col_name)| {
349            let arr = batch.column(col_idx);
350            let value = extract_string_value(arr.as_ref(), row)?;
351            Some((col_name, value))
352        })
353        .collect();
354    tag_pairs.sort_unstable_by_key(|&(name, _)| name);
355
356    // The canonical format has exactly one definition — see
357    // `chronix_core::push_canonical`. Inlining it here is what let tombstone
358    // matching break silently when the separators changed.
359    chronix_core::canonical_from_pairs(measurement, tag_pairs.iter().copied())
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use arrow::array::Float64Array;
366    use arrow::datatypes::{DataType, Field, Schema};
367
368    fn test_batch() -> RecordBatch {
369        let schema = Arc::new(Schema::new(vec![
370            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
371            Field::new("host", DataType::Utf8, false),
372            Field::new("value", DataType::Float64, false),
373        ]));
374
375        let timestamps = Arc::new(Int64Array::from(vec![100, 200, 300, 400, 500]));
376        let hosts = Arc::new(StringArray::from(vec!["a", "b", "a", "b", "a"]));
377        let values = Arc::new(Float64Array::from(vec![1.0, 2.0, 3.0, 4.0, 5.0]));
378
379        RecordBatch::try_new(schema, vec![timestamps, hosts, values]).unwrap()
380    }
381
382    #[test]
383    fn time_range_filter() {
384        let batch = test_batch();
385        let filtered = filter_batch(&batch, 200, 400, &[]).unwrap();
386        assert_eq!(filtered.num_rows(), 3);
387
388        let times = filtered
389            .column(0)
390            .as_any()
391            .downcast_ref::<Int64Array>()
392            .unwrap();
393        assert_eq!(times.value(0), 200);
394        assert_eq!(times.value(1), 300);
395        assert_eq!(times.value(2), 400);
396    }
397
398    #[test]
399    fn tag_equality_filter() {
400        let batch = test_batch();
401        let filtered = filter_batch(&batch, i64::MIN, i64::MAX, &[("host", "a")]).unwrap();
402        assert_eq!(filtered.num_rows(), 3);
403
404        let hosts = filtered
405            .column(1)
406            .as_any()
407            .downcast_ref::<StringArray>()
408            .unwrap();
409        for i in 0..3 {
410            assert_eq!(hosts.value(i), "a");
411        }
412    }
413
414    #[test]
415    fn combined_time_and_tag_filter() {
416        let batch = test_batch();
417        let filtered = filter_batch(&batch, 200, 400, &[("host", "a")]).unwrap();
418        assert_eq!(filtered.num_rows(), 1);
419
420        let times = filtered
421            .column(0)
422            .as_any()
423            .downcast_ref::<Int64Array>()
424            .unwrap();
425        assert_eq!(times.value(0), 300);
426    }
427
428    #[test]
429    fn filter_empty_batch() {
430        let schema = Arc::new(Schema::new(vec![Field::new(
431            chronix_core::TIME_COLUMN,
432            DataType::Int64,
433            false,
434        )]));
435        let batch = RecordBatch::new_empty(schema);
436        let result = filter_batch(&batch, 0, 100, &[]).unwrap();
437        assert_eq!(result.num_rows(), 0);
438    }
439
440    #[test]
441    fn project_columns() {
442        let batch = test_batch();
443        let projected = project_batch(&batch, &["value".to_string()]).unwrap();
444        assert_eq!(projected.num_columns(), 2); // timestamp + value
445        assert_eq!(
446            projected.schema().field(0).name(),
447            chronix_core::TIME_COLUMN
448        );
449        assert_eq!(projected.schema().field(1).name(), "value");
450    }
451
452    #[test]
453    fn project_empty_returns_all() {
454        let batch = test_batch();
455        let projected = project_batch(&batch, &[]).unwrap();
456        assert_eq!(projected.num_columns(), batch.num_columns());
457    }
458
459    #[test]
460    fn time_range_mask_correctness() {
461        let ts = Int64Array::from(vec![10, 20, 30, 40, 50]);
462        let mask = time_range_mask(&ts, 20, 40).unwrap();
463        let expected = BooleanArray::from(vec![false, true, true, true, false]);
464        assert_eq!(mask, expected);
465    }
466
467    #[test]
468    fn tag_equality_mask_correctness() {
469        let col = StringArray::from(vec!["a", "b", "a", "c", "a"]);
470        let mask = tag_equality_mask(&col, "a").unwrap();
471        let expected = BooleanArray::from(vec![true, false, true, false, true]);
472        assert_eq!(mask, expected);
473    }
474
475    #[test]
476    fn combine_two_masks() {
477        let m1 = BooleanArray::from(vec![true, true, false, true]);
478        let m2 = BooleanArray::from(vec![true, false, false, true]);
479        let result = combine_masks(&[m1, m2]).unwrap();
480        let expected = BooleanArray::from(vec![true, false, false, true]);
481        assert_eq!(result, expected);
482    }
483
484    #[test]
485    fn dict_equality_mask_matches() {
486        use arrow::array::DictionaryArray;
487        use arrow::datatypes::Int32Type;
488        // Build a Dict<Int32, Utf8> array: ["a", "b", "a", "c", "a"]
489        let dict: DictionaryArray<Int32Type> = vec!["a", "b", "a", "c", "a"].into_iter().collect();
490        let mask = dict_equality_mask(&dict, "a").unwrap();
491        let expected = BooleanArray::from(vec![true, false, true, false, true]);
492        assert_eq!(mask, expected);
493    }
494
495    #[test]
496    fn dict_equality_mask_no_match() {
497        use arrow::array::DictionaryArray;
498        use arrow::datatypes::Int32Type;
499        let dict: DictionaryArray<Int32Type> = vec!["a", "b", "c"].into_iter().collect();
500        let mask = dict_equality_mask(&dict, "z").unwrap();
501        let expected = BooleanArray::from(vec![false, false, false]);
502        assert_eq!(mask, expected);
503    }
504
505    #[test]
506    fn filter_batch_with_dict_column() {
507        use arrow::array::DictionaryArray;
508        use arrow::datatypes::Int32Type;
509        let timestamps = Int64Array::from(vec![100, 200, 300, 400, 500]);
510        let hosts: DictionaryArray<Int32Type> = vec!["a", "b", "a", "b", "a"].into_iter().collect();
511        let values = arrow::array::Float64Array::from(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
512        let schema = Arc::new(Schema::new(vec![
513            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
514            Field::new("host", hosts.data_type().clone(), false),
515            Field::new("value", DataType::Float64, false),
516        ]));
517        let batch = RecordBatch::try_new(
518            schema,
519            vec![Arc::new(timestamps), Arc::new(hosts), Arc::new(values)],
520        )
521        .unwrap();
522        let filtered = filter_batch(&batch, i64::MIN, i64::MAX, &[("host", "a")]).unwrap();
523        assert_eq!(filtered.num_rows(), 3);
524    }
525
526    #[test]
527    fn tombstone_filter_with_dict_column() {
528        use arrow::array::DictionaryArray;
529        use arrow::datatypes::Int32Type;
530
531        let timestamps = Int64Array::from(vec![100, 200, 300]);
532        let hosts: DictionaryArray<Int32Type> =
533            vec!["host-a", "host-b", "host-a"].into_iter().collect();
534        let values = Float64Array::from(vec![1.0, 2.0, 3.0]);
535        let schema = Arc::new(Schema::new(vec![
536            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
537            Field::new("host", hosts.data_type().clone(), false),
538            Field::new("value", DataType::Float64, false),
539        ]));
540        let batch = RecordBatch::try_new(
541            schema,
542            vec![Arc::new(timestamps), Arc::new(hosts), Arc::new(values)],
543        )
544        .unwrap();
545
546        // Tombstone for the canonical form of cpu{host=host-a}
547        let mut tombstones = TombstoneSet::new();
548        tombstones.insert(
549            chronix_core::Tombstone::all_time(chronix_core::canonical_from_pairs(
550                "cpu",
551                [("host", "host-a")],
552            ))
553            .with_segments([1]),
554        );
555
556        let result = filter_tombstoned(&batch, "cpu", &tombstones, Some(&["host"]), 1).unwrap();
557        // Only "host-b" rows should survive
558        assert_eq!(result.num_rows(), 1);
559    }
560
561    #[test]
562    fn tombstone_filter_heuristic_detects_dict_columns() {
563        use arrow::array::DictionaryArray;
564        use arrow::datatypes::Int32Type;
565
566        let timestamps = Int64Array::from(vec![100, 200]);
567        let hosts: DictionaryArray<Int32Type> = vec!["host-x", "host-y"].into_iter().collect();
568        let values = Float64Array::from(vec![1.0, 2.0]);
569        let schema = Arc::new(Schema::new(vec![
570            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
571            Field::new("host", hosts.data_type().clone(), false),
572            Field::new("value", DataType::Float64, false),
573        ]));
574        let batch = RecordBatch::try_new(
575            schema,
576            vec![Arc::new(timestamps), Arc::new(hosts), Arc::new(values)],
577        )
578        .unwrap();
579
580        // Use heuristic (no explicit tag_col_names)
581        let mut tombstones = TombstoneSet::new();
582        tombstones.insert(
583            chronix_core::Tombstone::all_time(chronix_core::canonical_from_pairs(
584                "cpu",
585                [("host", "host-x")],
586            ))
587            .with_segments([1]),
588        );
589
590        let result = filter_tombstoned(&batch, "cpu", &tombstones, None, 1).unwrap();
591        assert_eq!(result.num_rows(), 1);
592    }
593}