use std::sync::Arc;
use arrow::array::{Array, BooleanArray, Int64Array, StringArray};
use arrow::compute;
use arrow::compute::kernels::boolean as arrow_bool;
use arrow::datatypes::{DataType, Int32Type};
use arrow::record_batch::RecordBatch;
use crate::error::{QueryError, Result};
use chronix_core::TombstoneSet;
pub fn time_range_mask(timestamps: &Int64Array, start: i64, end: i64) -> Result<BooleanArray> {
let gte = compute::kernels::cmp::gt_eq(timestamps, &Int64Array::new_scalar(start))?;
let lte = compute::kernels::cmp::lt_eq(timestamps, &Int64Array::new_scalar(end))?;
let combined = arrow_bool::and(>e, <e)?;
Ok(combined)
}
pub fn tag_equality_mask(column: &StringArray, value: &str) -> Result<BooleanArray> {
let scalar = StringArray::new_scalar(value);
let mask = compute::kernels::cmp::eq(column, &scalar)?;
Ok(mask)
}
pub fn dict_equality_mask(col: &dyn Array, value: &str) -> Result<BooleanArray> {
if let Some(dict) = col
.as_any()
.downcast_ref::<arrow::array::DictionaryArray<Int32Type>>()
{
let scalar = StringArray::new_scalar(value);
let mask = compute::kernels::cmp::eq(dict, &scalar)?;
return Ok(mask);
}
let casted = compute::cast(col, &DataType::Utf8)?;
let str_arr = casted
.as_any()
.downcast_ref::<StringArray>()
.ok_or_else(|| {
QueryError::Validation("dictionary column could not be cast to Utf8".into())
})?;
tag_equality_mask(str_arr, value)
}
pub fn combine_masks(masks: &[BooleanArray]) -> Result<BooleanArray> {
if masks.is_empty() {
return Err(QueryError::Validation("no masks to combine".into()));
}
let mut result = masks[0].clone();
for mask in &masks[1..] {
result = arrow_bool::and(&result, mask)?;
}
Ok(result)
}
pub fn apply_filter(batch: &RecordBatch, mask: &BooleanArray) -> Result<RecordBatch> {
let filtered_columns: std::result::Result<Vec<_>, _> = batch
.columns()
.iter()
.map(|col| compute::filter(col.as_ref(), mask))
.collect();
let filtered_columns = filtered_columns?;
let batch = RecordBatch::try_new(batch.schema(), filtered_columns)?;
Ok(batch)
}
pub fn filter_batch(
batch: &RecordBatch,
time_start: i64,
time_end: i64,
tag_filters: &[(&str, &str)],
) -> Result<RecordBatch> {
if batch.num_rows() == 0 {
return Ok(batch.clone());
}
let mut masks: Vec<BooleanArray> = Vec::new();
let time_col = batch
.column_by_name(chronix_core::TIME_COLUMN)
.ok_or_else(|| QueryError::Validation("'timestamp' column not found".into()))?;
let timestamps = time_col
.as_any()
.downcast_ref::<Int64Array>()
.ok_or_else(|| QueryError::Validation("'timestamp' column is not Int64".into()))?;
if time_start != i64::MIN || time_end != i64::MAX {
masks.push(time_range_mask(timestamps, time_start, time_end)?);
}
for (key, value) in tag_filters {
if let Some(col) = batch.column_by_name(key) {
let mask = match col.data_type() {
DataType::Utf8 => col
.as_any()
.downcast_ref::<StringArray>()
.map(|s| tag_equality_mask(s, value)),
DataType::Dictionary(_, _) => Some(dict_equality_mask(col.as_ref(), value)),
DataType::LargeUtf8 | DataType::Utf8View => {
compute::cast(col.as_ref(), &DataType::Utf8)
.ok()
.and_then(|arr| arr.as_any().downcast_ref::<StringArray>().cloned())
.map(|s| tag_equality_mask(&s, value))
}
_ => None,
};
match mask {
Some(Ok(m)) => masks.push(m),
Some(Err(e)) => return Err(e),
None => {
return apply_filter(batch, &BooleanArray::from(vec![false; batch.num_rows()]));
}
}
} else {
return apply_filter(batch, &BooleanArray::from(vec![false; batch.num_rows()]));
}
}
if masks.is_empty() {
return Ok(batch.clone());
}
let combined = combine_masks(&masks)?;
apply_filter(batch, &combined)
}
pub fn project_batch(batch: &RecordBatch, projection: &[String]) -> Result<RecordBatch> {
if projection.is_empty() {
return Ok(batch.clone());
}
let mut col_names: Vec<&str> = vec![chronix_core::TIME_COLUMN];
for name in projection {
if name != chronix_core::TIME_COLUMN {
col_names.push(name);
}
}
let mut fields = Vec::new();
let mut columns: Vec<Arc<dyn Array>> = Vec::new();
for name in &col_names {
if let Ok(idx) = batch.schema().index_of(name) {
fields.push(batch.schema().field(idx).clone());
columns.push(Arc::clone(batch.column(idx)));
}
}
if fields.is_empty() {
return Ok(batch.clone());
}
let schema = Arc::new(arrow::datatypes::Schema::new(fields));
let projected = RecordBatch::try_new(schema, columns)?;
Ok(projected)
}
pub fn filter_tombstoned(
batch: &RecordBatch,
measurement: &str,
tombstones: &TombstoneSet,
tag_col_names: Option<&[&str]>,
segment_id: u64,
) -> Result<RecordBatch> {
if tombstones.is_empty() || batch.num_rows() == 0 {
return Ok(batch.clone());
}
let schema = batch.schema();
let tag_columns: Vec<(usize, &str)> = if let Some(names) = tag_col_names {
names
.iter()
.filter_map(|&name| schema.index_of(name).ok().map(|idx| (idx, name)))
.collect()
} else {
schema
.fields()
.iter()
.enumerate()
.filter(|(_, f)| is_string_like(f.data_type()) && f.name() != chronix_core::TIME_COLUMN)
.map(|(i, f)| (i, f.name().as_str()))
.collect()
};
let ts_col = batch
.column_by_name(chronix_core::TIME_COLUMN)
.and_then(|c| c.as_any().downcast_ref::<Int64Array>());
let keep: Vec<bool> = (0..batch.num_rows())
.map(|row| {
let canonical = compute_series_canonical(measurement, &tag_columns, batch, row);
let ts = ts_col.map_or(0, |a| a.value(row));
!tombstones.is_tombstoned_in(&canonical, ts, segment_id)
})
.collect();
if keep.iter().all(|&k| k) {
return Ok(batch.clone());
}
let mask = BooleanArray::from(keep);
apply_filter(batch, &mask)
}
fn is_string_like(dt: &DataType) -> bool {
matches!(
dt,
DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View | DataType::Dictionary(_, _)
)
}
fn extract_string_value(arr: &dyn Array, row: usize) -> Option<&str> {
if arr.is_null(row) {
return None;
}
if let Some(s) = arr.as_any().downcast_ref::<StringArray>() {
return Some(s.value(row));
}
if let Some(dict) = arr
.as_any()
.downcast_ref::<arrow::array::DictionaryArray<Int32Type>>()
{
let values = dict.values().as_any().downcast_ref::<StringArray>()?;
let key = dict.keys().value(row);
return Some(values.value(key as usize));
}
None
}
fn compute_series_canonical(
measurement: &str,
tag_columns: &[(usize, &str)],
batch: &RecordBatch,
row: usize,
) -> String {
let mut tag_pairs: Vec<(&str, &str)> = tag_columns
.iter()
.filter_map(|&(col_idx, col_name)| {
let arr = batch.column(col_idx);
let value = extract_string_value(arr.as_ref(), row)?;
Some((col_name, value))
})
.collect();
tag_pairs.sort_unstable_by_key(|&(name, _)| name);
chronix_core::canonical_from_pairs(measurement, tag_pairs.iter().copied())
}
#[cfg(test)]
mod tests {
use super::*;
use arrow::array::Float64Array;
use arrow::datatypes::{DataType, Field, Schema};
fn test_batch() -> RecordBatch {
let schema = Arc::new(Schema::new(vec![
Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
Field::new("host", DataType::Utf8, false),
Field::new("value", DataType::Float64, false),
]));
let timestamps = Arc::new(Int64Array::from(vec![100, 200, 300, 400, 500]));
let hosts = Arc::new(StringArray::from(vec!["a", "b", "a", "b", "a"]));
let values = Arc::new(Float64Array::from(vec![1.0, 2.0, 3.0, 4.0, 5.0]));
RecordBatch::try_new(schema, vec![timestamps, hosts, values]).unwrap()
}
#[test]
fn time_range_filter() {
let batch = test_batch();
let filtered = filter_batch(&batch, 200, 400, &[]).unwrap();
assert_eq!(filtered.num_rows(), 3);
let times = filtered
.column(0)
.as_any()
.downcast_ref::<Int64Array>()
.unwrap();
assert_eq!(times.value(0), 200);
assert_eq!(times.value(1), 300);
assert_eq!(times.value(2), 400);
}
#[test]
fn tag_equality_filter() {
let batch = test_batch();
let filtered = filter_batch(&batch, i64::MIN, i64::MAX, &[("host", "a")]).unwrap();
assert_eq!(filtered.num_rows(), 3);
let hosts = filtered
.column(1)
.as_any()
.downcast_ref::<StringArray>()
.unwrap();
for i in 0..3 {
assert_eq!(hosts.value(i), "a");
}
}
#[test]
fn combined_time_and_tag_filter() {
let batch = test_batch();
let filtered = filter_batch(&batch, 200, 400, &[("host", "a")]).unwrap();
assert_eq!(filtered.num_rows(), 1);
let times = filtered
.column(0)
.as_any()
.downcast_ref::<Int64Array>()
.unwrap();
assert_eq!(times.value(0), 300);
}
#[test]
fn filter_empty_batch() {
let schema = Arc::new(Schema::new(vec![Field::new(
chronix_core::TIME_COLUMN,
DataType::Int64,
false,
)]));
let batch = RecordBatch::new_empty(schema);
let result = filter_batch(&batch, 0, 100, &[]).unwrap();
assert_eq!(result.num_rows(), 0);
}
#[test]
fn project_columns() {
let batch = test_batch();
let projected = project_batch(&batch, &["value".to_string()]).unwrap();
assert_eq!(projected.num_columns(), 2); assert_eq!(
projected.schema().field(0).name(),
chronix_core::TIME_COLUMN
);
assert_eq!(projected.schema().field(1).name(), "value");
}
#[test]
fn project_empty_returns_all() {
let batch = test_batch();
let projected = project_batch(&batch, &[]).unwrap();
assert_eq!(projected.num_columns(), batch.num_columns());
}
#[test]
fn time_range_mask_correctness() {
let ts = Int64Array::from(vec![10, 20, 30, 40, 50]);
let mask = time_range_mask(&ts, 20, 40).unwrap();
let expected = BooleanArray::from(vec![false, true, true, true, false]);
assert_eq!(mask, expected);
}
#[test]
fn tag_equality_mask_correctness() {
let col = StringArray::from(vec!["a", "b", "a", "c", "a"]);
let mask = tag_equality_mask(&col, "a").unwrap();
let expected = BooleanArray::from(vec![true, false, true, false, true]);
assert_eq!(mask, expected);
}
#[test]
fn combine_two_masks() {
let m1 = BooleanArray::from(vec![true, true, false, true]);
let m2 = BooleanArray::from(vec![true, false, false, true]);
let result = combine_masks(&[m1, m2]).unwrap();
let expected = BooleanArray::from(vec![true, false, false, true]);
assert_eq!(result, expected);
}
#[test]
fn dict_equality_mask_matches() {
use arrow::array::DictionaryArray;
use arrow::datatypes::Int32Type;
let dict: DictionaryArray<Int32Type> = vec!["a", "b", "a", "c", "a"].into_iter().collect();
let mask = dict_equality_mask(&dict, "a").unwrap();
let expected = BooleanArray::from(vec![true, false, true, false, true]);
assert_eq!(mask, expected);
}
#[test]
fn dict_equality_mask_no_match() {
use arrow::array::DictionaryArray;
use arrow::datatypes::Int32Type;
let dict: DictionaryArray<Int32Type> = vec!["a", "b", "c"].into_iter().collect();
let mask = dict_equality_mask(&dict, "z").unwrap();
let expected = BooleanArray::from(vec![false, false, false]);
assert_eq!(mask, expected);
}
#[test]
fn filter_batch_with_dict_column() {
use arrow::array::DictionaryArray;
use arrow::datatypes::Int32Type;
let timestamps = Int64Array::from(vec![100, 200, 300, 400, 500]);
let hosts: DictionaryArray<Int32Type> = vec!["a", "b", "a", "b", "a"].into_iter().collect();
let values = arrow::array::Float64Array::from(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
let schema = Arc::new(Schema::new(vec![
Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
Field::new("host", hosts.data_type().clone(), false),
Field::new("value", DataType::Float64, false),
]));
let batch = RecordBatch::try_new(
schema,
vec![Arc::new(timestamps), Arc::new(hosts), Arc::new(values)],
)
.unwrap();
let filtered = filter_batch(&batch, i64::MIN, i64::MAX, &[("host", "a")]).unwrap();
assert_eq!(filtered.num_rows(), 3);
}
#[test]
fn tombstone_filter_with_dict_column() {
use arrow::array::DictionaryArray;
use arrow::datatypes::Int32Type;
let timestamps = Int64Array::from(vec![100, 200, 300]);
let hosts: DictionaryArray<Int32Type> =
vec!["host-a", "host-b", "host-a"].into_iter().collect();
let values = Float64Array::from(vec![1.0, 2.0, 3.0]);
let schema = Arc::new(Schema::new(vec![
Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
Field::new("host", hosts.data_type().clone(), false),
Field::new("value", DataType::Float64, false),
]));
let batch = RecordBatch::try_new(
schema,
vec![Arc::new(timestamps), Arc::new(hosts), Arc::new(values)],
)
.unwrap();
let mut tombstones = TombstoneSet::new();
tombstones.insert(
chronix_core::Tombstone::all_time(chronix_core::canonical_from_pairs(
"cpu",
[("host", "host-a")],
))
.with_segments([1]),
);
let result = filter_tombstoned(&batch, "cpu", &tombstones, Some(&["host"]), 1).unwrap();
assert_eq!(result.num_rows(), 1);
}
#[test]
fn tombstone_filter_heuristic_detects_dict_columns() {
use arrow::array::DictionaryArray;
use arrow::datatypes::Int32Type;
let timestamps = Int64Array::from(vec![100, 200]);
let hosts: DictionaryArray<Int32Type> = vec!["host-x", "host-y"].into_iter().collect();
let values = Float64Array::from(vec![1.0, 2.0]);
let schema = Arc::new(Schema::new(vec![
Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
Field::new("host", hosts.data_type().clone(), false),
Field::new("value", DataType::Float64, false),
]));
let batch = RecordBatch::try_new(
schema,
vec![Arc::new(timestamps), Arc::new(hosts), Arc::new(values)],
)
.unwrap();
let mut tombstones = TombstoneSet::new();
tombstones.insert(
chronix_core::Tombstone::all_time(chronix_core::canonical_from_pairs(
"cpu",
[("host", "host-x")],
))
.with_segments([1]),
);
let result = filter_tombstoned(&batch, "cpu", &tombstones, None, 1).unwrap();
assert_eq!(result.num_rows(), 1);
}
}