1use std::sync::Arc;
26
27use arrow::array::{Array, ArrayRef, Float64Array, Int64Array, StringArray, UInt64Array};
28use arrow::compute::kernels::aggregate as arrow_agg;
29use arrow::datatypes::{DataType, Field, Schema};
30use arrow::record_batch::RecordBatch;
31
32use crate::error::{QueryError, Result};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum AggFn {
37 Count,
39 Sum,
41 Min,
43 Max,
45 Avg,
47 First,
63 Last,
79}
80
81macro_rules! impl_aggregate {
87 ($name:ident, $arr_ty:ty, $conv:expr) => {
88 #[allow(clippy::cast_precision_loss)]
89 fn $name(values: &$arr_ty, function: AggFn) -> Option<f64> {
90 if values.is_empty() {
92 return if function == AggFn::Count {
93 Some(0.0)
94 } else {
95 None
96 };
97 }
98 match function {
99 AggFn::Count => Some((values.len() - values.null_count()) as f64),
102 AggFn::Sum => arrow_agg::sum(values).map($conv),
103 AggFn::Min => arrow_agg::min(values).map($conv),
104 AggFn::Max => arrow_agg::max(values).map($conv),
105 AggFn::Avg => {
106 let sum = arrow_agg::sum(values).map($conv)?;
107 let count = values.len() - values.null_count();
108 if count == 0 {
109 None
110 } else {
111 Some(sum / count as f64)
112 }
113 }
114 AggFn::First => values.iter().flatten().next().map($conv),
115 AggFn::Last => values.iter().flatten().last().map($conv),
116 }
117 }
118 };
119}
120
121impl_aggregate!(aggregate_f64, Float64Array, |v| v);
122impl_aggregate!(aggregate_i64, Int64Array, |v| v as f64);
123impl_aggregate!(aggregate_u64, UInt64Array, |v| v as f64);
124
125pub fn aggregate_batch(
133 batch: &RecordBatch,
134 functions: &[AggFn],
135 field_columns: &[&str],
136 memory_tracker: Option<&crate::memory::MemoryTracker>,
137) -> Result<RecordBatch> {
138 let mut result_fields = Vec::new();
139 let mut result_columns: Vec<ArrayRef> = Vec::new();
140
141 let ts_col = batch
143 .column_by_name(chronix_core::TIME_COLUMN)
144 .and_then(|c| c.as_any().downcast_ref::<Int64Array>());
145
146 for &field_name in field_columns {
147 let col = batch
148 .column_by_name(field_name)
149 .ok_or_else(|| QueryError::Validation(format!("column '{field_name}' not found")))?;
150
151 for func in functions {
152 let func_name = format!("{field_name}_{}", agg_fn_name(*func));
153 let value = aggregate_column_ts(col, *func, ts_col);
154
155 result_fields.push(Field::new(&func_name, DataType::Float64, true));
156 result_columns.push(Arc::new(Float64Array::from(vec![value])));
157 }
158 }
159
160 if result_fields.is_empty() {
161 return Err(QueryError::Validation("no columns to aggregate".into()));
162 }
163
164 let schema = Arc::new(Schema::new(result_fields));
165 let batch = RecordBatch::try_new(schema, result_columns)?;
166 if let Some(tracker) = memory_tracker {
167 tracker.try_allocate(batch.get_array_memory_size())?;
168 }
169 Ok(batch)
170}
171
172pub fn aggregate_grouped(
197 batch: &RecordBatch,
198 functions: &[AggFn],
199 field_columns: &[&str],
200 group_by: &[&str],
201 memory_tracker: Option<&crate::memory::MemoryTracker>,
202) -> Result<RecordBatch> {
203 if group_by.is_empty() {
204 return aggregate_batch(batch, functions, field_columns, memory_tracker);
205 }
206
207 let group_cols: Vec<&StringArray> = group_by
209 .iter()
210 .map(|name| {
211 batch
212 .column_by_name(name)
213 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
214 .ok_or_else(|| {
215 QueryError::Validation(format!(
216 "group-by column '{name}' not found or not string"
217 ))
218 })
219 })
220 .collect::<Result<Vec<_>>>()?;
221
222 let field_cols: Vec<&dyn Array> = field_columns
224 .iter()
225 .map(|name| {
226 batch
227 .column_by_name(name)
228 .map(std::convert::AsRef::as_ref)
229 .ok_or_else(|| QueryError::Validation(format!("column '{name}' not found")))
230 })
231 .collect::<Result<Vec<_>>>()?;
232
233 let typed_cols: Vec<TypedColumn<'_>> = field_cols
235 .iter()
236 .map(|c| TypedColumn::from_array(*c))
237 .collect();
238
239 let ts_col = batch
240 .column_by_name(chronix_core::TIME_COLUMN)
241 .and_then(|c| c.as_any().downcast_ref::<Int64Array>());
242
243 let num_fields = field_columns.len();
247
248 let mut groups: Vec<(Vec<Option<&str>>, Vec<IncrementalAccumulator>)> = Vec::new();
250 let mut group_index: std::collections::HashMap<u64, Vec<usize>> =
251 std::collections::HashMap::new();
252
253 for row in 0..batch.num_rows() {
254 let ts: Option<i64> = ts_col.and_then(|t| {
255 if t.is_null(row) {
256 None
257 } else {
258 Some(t.value(row))
259 }
260 });
261
262 let mut hasher = fnv::FnvHasher::default();
264 for col in &group_cols {
265 if col.is_null(row) {
266 std::hash::Hash::hash(&0u8, &mut hasher);
267 } else {
268 std::hash::Hash::hash(&1u8, &mut hasher);
269 std::hash::Hash::hash(col.value(row), &mut hasher);
270 }
271 }
272 let hash = std::hash::Hasher::finish(&hasher);
273
274 let key: Vec<Option<&str>> = group_cols
276 .iter()
277 .map(|col| {
278 if col.is_null(row) {
279 None
280 } else {
281 Some(col.value(row))
282 }
283 })
284 .collect();
285
286 let bucket = group_index.entry(hash).or_default();
288 if bucket.len() >= 1024 {
290 return Err(QueryError::Validation(
291 "group-by hash chain length exceeded limit (possible collision attack)".into(),
292 ));
293 }
294 let gidx = {
295 let mut found = None;
296 for &idx in bucket.iter() {
297 if groups[idx].0 == key {
298 found = Some(idx);
299 break;
300 }
301 }
302 match found {
303 Some(idx) => idx,
304 None => {
305 if let Some(tracker) = memory_tracker {
307 let estimated_group_bytes = 24 + group_by.len() * 16 + num_fields * std::mem::size_of::<IncrementalAccumulator>()
309 + 64; tracker.try_allocate(estimated_group_bytes)?;
311 }
312 let idx = groups.len();
313 let accs = (0..num_fields)
314 .map(|_| IncrementalAccumulator::new())
315 .collect();
316 groups.push((key, accs));
317 bucket.push(idx);
318 idx
319 }
320 }
321 };
322
323 for (fi, typed) in typed_cols.iter().enumerate() {
327 if let Some(v) = typed.value_f64(row) {
328 groups[gidx].1[fi].update(v, ts);
329 }
330 }
331 }
332
333 let mut result_fields: Vec<Field> = group_by
335 .iter()
336 .map(|name| Field::new(*name, DataType::Utf8, true))
337 .collect();
338
339 for &field_name in field_columns {
340 for func in functions {
341 let func_name = format!("{field_name}_{}", agg_fn_name(*func));
342 result_fields.push(Field::new(&func_name, DataType::Float64, true));
343 }
344 }
345
346 let num_groups = groups.len();
348 let mut result_columns: Vec<ArrayRef> = Vec::new();
349
350 for col_idx in 0..group_by.len() {
352 let vals: Vec<Option<&str>> = groups.iter().map(|(key, _)| key[col_idx]).collect();
353 let arr: StringArray = vals.into_iter().collect();
354 result_columns.push(Arc::new(arr));
355 }
356
357 for fi in 0..num_fields {
360 for func in functions {
361 let vals: Vec<Option<f64>> = (0..num_groups)
362 .map(|gi| groups[gi].1[fi].finalize(*func))
363 .collect();
364 result_columns.push(Arc::new(Float64Array::from(vals)));
365 }
366 }
367
368 let schema = Arc::new(Schema::new(result_fields));
369 let batch = RecordBatch::try_new(schema, result_columns)?;
370 if let Some(tracker) = memory_tracker {
371 tracker.try_allocate(batch.get_array_memory_size())?;
372 }
373 Ok(batch)
374}
375
376#[allow(clippy::cast_precision_loss)]
380fn aggregate_column_ts(
381 col: &dyn Array,
382 func: AggFn,
383 timestamps: Option<&Int64Array>,
384) -> Option<f64> {
385 if let Some(ts) = timestamps {
388 if matches!(func, AggFn::First | AggFn::Last) {
389 return first_last_by_ts(col, ts, func);
390 }
391 }
392
393 if let Some(f64_col) = col.as_any().downcast_ref::<Float64Array>() {
394 aggregate_f64(f64_col, func)
395 } else if let Some(i64_col) = col.as_any().downcast_ref::<Int64Array>() {
396 aggregate_i64(i64_col, func)
397 } else if let Some(u64_col) = col.as_any().downcast_ref::<UInt64Array>() {
398 aggregate_u64(u64_col, func)
399 } else {
400 match func {
401 AggFn::Count => Some((col.len() - col.null_count()) as f64),
402 _ => None,
403 }
404 }
405}
406
407#[allow(clippy::cast_precision_loss)]
410fn first_last_by_ts(col: &dyn Array, ts: &Int64Array, func: AggFn) -> Option<f64> {
411 let find_idx = |cmp: fn(i64, i64) -> bool| -> Option<usize> {
412 let mut best_idx: Option<usize> = None;
413 let mut best_ts: i64 = 0;
414 for i in 0..ts.len() {
415 if ts.is_null(i) || col.is_null(i) {
416 continue;
417 }
418 let t = ts.value(i);
419 if best_idx.is_none() || cmp(t, best_ts) {
420 best_idx = Some(i);
421 best_ts = t;
422 }
423 }
424 best_idx
425 };
426
427 let idx = match func {
428 AggFn::First => find_idx(|t, best| t < best),
429 AggFn::Last => find_idx(|t, best| t >= best),
433 _ => return None,
434 }?;
435
436 if let Some(f64_col) = col.as_any().downcast_ref::<Float64Array>() {
438 Some(f64_col.value(idx))
439 } else if let Some(i64_col) = col.as_any().downcast_ref::<Int64Array>() {
440 Some(i64_col.value(idx) as f64)
441 } else {
442 col.as_any()
443 .downcast_ref::<UInt64Array>()
444 .map(|u64_col| u64_col.value(idx) as f64)
445 }
446}
447
448#[must_use]
454pub fn agg_fn_name(func: AggFn) -> &'static str {
455 match func {
456 AggFn::Count => "count",
457 AggFn::Sum => "sum",
458 AggFn::Min => "min",
459 AggFn::Max => "max",
460 AggFn::Avg => "avg",
461 AggFn::First => "first",
462 AggFn::Last => "last",
463 }
464}
465
466#[derive(Clone, Copy)]
471enum TypedColumn<'a> {
472 Float64(&'a Float64Array),
473 Int64(&'a Int64Array),
474 UInt64(&'a UInt64Array),
475 Other,
476}
477
478impl<'a> TypedColumn<'a> {
479 fn from_array(arr: &'a dyn Array) -> Self {
480 if let Some(a) = arr.as_any().downcast_ref::<Float64Array>() {
481 Self::Float64(a)
482 } else if let Some(a) = arr.as_any().downcast_ref::<Int64Array>() {
483 Self::Int64(a)
484 } else if let Some(a) = arr.as_any().downcast_ref::<UInt64Array>() {
485 Self::UInt64(a)
486 } else {
487 Self::Other
488 }
489 }
490
491 #[allow(clippy::cast_precision_loss)]
492 fn value_f64(&self, row: usize) -> Option<f64> {
493 match self {
494 Self::Float64(a) => {
495 if a.is_null(row) {
496 None
497 } else {
498 Some(a.value(row))
499 }
500 }
501 Self::Int64(a) => {
502 if a.is_null(row) {
503 None
504 } else {
505 Some(a.value(row) as f64)
506 }
507 }
508 Self::UInt64(a) => {
509 if a.is_null(row) {
510 None
511 } else {
512 Some(a.value(row) as f64)
513 }
514 }
515 Self::Other => None,
516 }
517 }
518}
519
520#[derive(Debug, Clone)]
523struct IncrementalAccumulator {
524 count: u64,
525 sum: f64,
526 min: f64,
527 max: f64,
528 first_ts: i64,
530 first_val: f64,
531 first_set: bool,
532 last_ts: i64,
534 last_val: f64,
535 last_set: bool,
536}
537
538impl IncrementalAccumulator {
539 fn new() -> Self {
540 Self {
541 count: 0,
542 sum: 0.0,
543 min: f64::INFINITY,
544 max: f64::NEG_INFINITY,
545 first_ts: i64::MAX,
546 first_val: 0.0,
547 first_set: false,
548 last_ts: i64::MIN,
549 last_val: 0.0,
550 last_set: false,
551 }
552 }
553
554 fn update(&mut self, value: f64, timestamp: Option<i64>) {
555 self.count += 1;
556 self.sum += value;
557 if value < self.min {
558 self.min = value;
559 }
560 if value > self.max {
561 self.max = value;
562 }
563 if let Some(ts) = timestamp {
567 if ts < self.first_ts || !self.first_set {
568 self.first_ts = ts;
569 self.first_val = value;
570 self.first_set = true;
571 }
572 if ts >= self.last_ts || !self.last_set {
575 self.last_ts = ts;
576 self.last_val = value;
577 self.last_set = true;
578 }
579 }
580 }
581
582 #[allow(clippy::cast_precision_loss)]
583 fn finalize(&self, func: AggFn) -> Option<f64> {
584 if self.count == 0 {
585 return if func == AggFn::Count {
586 Some(0.0)
587 } else {
588 None
589 };
590 }
591 match func {
592 AggFn::Count => Some(self.count as f64),
593 AggFn::Sum => Some(self.sum),
594 AggFn::Min => Some(self.min),
595 AggFn::Max => Some(self.max),
596 AggFn::Avg => Some(self.sum / self.count as f64),
597 AggFn::First => Some(self.first_val),
598 AggFn::Last => Some(self.last_val),
599 }
600 }
601}
602
603#[derive(Debug, Clone, Copy, PartialEq, Eq)]
615pub enum AggregationStrategy {
616 Hash,
618 Sort,
620}
621
622const SORT_CARDINALITY_THRESHOLD: usize = 1024;
627
628#[must_use]
634pub fn choose_strategy(estimated_cardinality: Option<usize>) -> AggregationStrategy {
635 match estimated_cardinality {
636 Some(c) if c <= SORT_CARDINALITY_THRESHOLD => AggregationStrategy::Sort,
637 _ => AggregationStrategy::Hash,
638 }
639}
640
641pub fn aggregate_sorted(
654 batch: &RecordBatch,
655 functions: &[AggFn],
656 field_columns: &[&str],
657 group_by: &[&str],
658 memory_tracker: Option<&crate::memory::MemoryTracker>,
659) -> Result<RecordBatch> {
660 use arrow::row::{RowConverter, SortField};
661
662 if group_by.is_empty() {
663 return aggregate_batch(batch, functions, field_columns, memory_tracker);
664 }
665
666 let group_arrays: Vec<ArrayRef> = group_by
668 .iter()
669 .map(|name| {
670 batch.column_by_name(name).cloned().ok_or_else(|| {
671 QueryError::Validation(format!("group-by column '{name}' not found"))
672 })
673 })
674 .collect::<Result<Vec<_>>>()?;
675
676 let sort_fields: Vec<SortField> = group_arrays
677 .iter()
678 .map(|a| SortField::new(a.data_type().clone()))
679 .collect();
680
681 let converter = RowConverter::new(sort_fields)?;
682 let rows = converter.convert_columns(&group_arrays)?;
683
684 let mut indices: Vec<u32> = (0..batch.num_rows() as u32).collect();
686 indices.sort_by(|&a, &b| rows.row(a as usize).cmp(&rows.row(b as usize)));
687
688 let field_cols: Vec<&dyn Array> = field_columns
690 .iter()
691 .map(|name| {
692 batch
693 .column_by_name(name)
694 .map(std::convert::AsRef::as_ref)
695 .ok_or_else(|| QueryError::Validation(format!("column '{name}' not found")))
696 })
697 .collect::<Result<Vec<_>>>()?;
698 let typed_cols: Vec<TypedColumn<'_>> = field_cols
699 .iter()
700 .map(|c| TypedColumn::from_array(*c))
701 .collect();
702
703 let ts_col = batch
704 .column_by_name(chronix_core::TIME_COLUMN)
705 .and_then(|c| c.as_any().downcast_ref::<Int64Array>());
706
707 let group_cols: Vec<&StringArray> = group_by
708 .iter()
709 .map(|name| {
710 batch
711 .column_by_name(name)
712 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
713 .ok_or_else(|| {
714 QueryError::Validation(format!(
715 "group-by column '{name}' not found or not string"
716 ))
717 })
718 })
719 .collect::<Result<Vec<_>>>()?;
720
721 let num_fields = field_columns.len();
722 let mut groups: Vec<(Vec<Option<String>>, Vec<IncrementalAccumulator>)> = Vec::new();
723
724 let mut run_start = 0usize;
726 while run_start < indices.len() {
727 let anchor_row = rows.row(indices[run_start] as usize);
728 let mut run_end = run_start + 1;
729 while run_end < indices.len() && rows.row(indices[run_end] as usize) == anchor_row {
730 run_end += 1;
731 }
732
733 let first_idx = indices[run_start] as usize;
735 let key: Vec<Option<String>> = group_cols
736 .iter()
737 .map(|col| {
738 if col.is_null(first_idx) {
739 None
740 } else {
741 Some(col.value(first_idx).to_owned())
742 }
743 })
744 .collect();
745
746 if let Some(tracker) = memory_tracker {
747 let est = 24
748 + group_by.len() * 48
749 + num_fields * std::mem::size_of::<IncrementalAccumulator>()
750 + 64;
751 tracker.try_allocate(est)?;
752 }
753
754 let mut accs: Vec<IncrementalAccumulator> = (0..num_fields)
755 .map(|_| IncrementalAccumulator::new())
756 .collect();
757
758 for &idx in &indices[run_start..run_end] {
759 let row = idx as usize;
760 let ts: Option<i64> = ts_col.and_then(|t| {
761 if t.is_null(row) {
762 None
763 } else {
764 Some(t.value(row))
765 }
766 });
767 for (fi, typed) in typed_cols.iter().enumerate() {
768 if let Some(v) = typed.value_f64(row) {
769 accs[fi].update(v, ts);
770 }
771 }
772 }
773
774 groups.push((key, accs));
775 run_start = run_end;
776 }
777
778 let mut result_fields: Vec<Field> = group_by
780 .iter()
781 .map(|name| Field::new(*name, DataType::Utf8, true))
782 .collect();
783 for &field_name in field_columns {
784 for func in functions {
785 let func_name = format!("{field_name}_{}", agg_fn_name(*func));
786 result_fields.push(Field::new(&func_name, DataType::Float64, true));
787 }
788 }
789
790 let num_groups = groups.len();
791 let mut result_columns: Vec<ArrayRef> = Vec::new();
792
793 for col_idx in 0..group_by.len() {
794 let vals: Vec<Option<&str>> = groups
795 .iter()
796 .map(|(key, _)| key[col_idx].as_deref())
797 .collect();
798 let arr: StringArray = vals.into_iter().collect();
799 result_columns.push(Arc::new(arr));
800 }
801
802 for fi in 0..num_fields {
803 for func in functions {
804 let vals: Vec<Option<f64>> = (0..num_groups)
805 .map(|gi| groups[gi].1[fi].finalize(*func))
806 .collect();
807 result_columns.push(Arc::new(Float64Array::from(vals)));
808 }
809 }
810
811 let schema = Arc::new(Schema::new(result_fields));
812 let batch = RecordBatch::try_new(schema, result_columns)?;
813 if let Some(tracker) = memory_tracker {
814 tracker.try_allocate(batch.get_array_memory_size())?;
815 }
816 Ok(batch)
817}
818
819pub fn aggregate_grouped_streaming(
830 batches: &[RecordBatch],
831 functions: &[AggFn],
832 field_columns: &[&str],
833 group_by: &[&str],
834 memory_tracker: Option<&crate::memory::MemoryTracker>,
835) -> Result<RecordBatch> {
836 if batches.is_empty() {
837 return Err(QueryError::Validation("no batches to aggregate".into()));
838 }
839 let mut agg = StreamingAggregator::new(functions, field_columns, group_by);
840 for batch in batches {
841 agg.push(batch, memory_tracker)?;
842 }
843 agg.finish(memory_tracker)
844}
845
846pub struct StreamingAggregator {
858 functions: Vec<AggFn>,
859 field_columns: Vec<String>,
860 group_by: Vec<String>,
861 state: AggState,
862}
863
864enum AggState {
865 Grouped {
867 groups: Vec<(Vec<Option<String>>, Vec<IncrementalAccumulator>)>,
868 index: std::collections::HashMap<u64, Vec<usize>>,
870 },
871 Ungrouped(UngroupedState),
873}
874
875struct UngroupedState {
877 counts: Vec<u64>,
878 sums: Vec<f64>,
879 mins: Vec<f64>,
880 maxs: Vec<f64>,
881 first_ts: Vec<i64>,
882 first_val: Vec<f64>,
883 first_set: Vec<bool>,
884 last_ts: Vec<i64>,
885 last_val: Vec<f64>,
886 last_set: Vec<bool>,
887 needs_first_last: bool,
888}
889
890impl StreamingAggregator {
891 #[must_use]
894 pub fn new(functions: &[AggFn], field_columns: &[&str], group_by: &[&str]) -> Self {
895 let num_fields = field_columns.len();
896 let state = if group_by.is_empty() {
897 AggState::Ungrouped(UngroupedState {
898 counts: vec![0; num_fields],
899 sums: vec![0.0; num_fields],
900 mins: vec![f64::INFINITY; num_fields],
901 maxs: vec![f64::NEG_INFINITY; num_fields],
902 first_ts: vec![i64::MAX; num_fields],
903 first_val: vec![0.0; num_fields],
904 first_set: vec![false; num_fields],
905 last_ts: vec![i64::MIN; num_fields],
906 last_val: vec![0.0; num_fields],
907 last_set: vec![false; num_fields],
908 needs_first_last: functions
909 .iter()
910 .any(|f| matches!(f, AggFn::First | AggFn::Last)),
911 })
912 } else {
913 AggState::Grouped {
914 groups: Vec::new(),
915 index: std::collections::HashMap::new(),
916 }
917 };
918 Self {
919 functions: functions.to_vec(),
920 field_columns: field_columns.iter().map(|s| (*s).to_string()).collect(),
921 group_by: group_by.iter().map(|s| (*s).to_string()).collect(),
922 state,
923 }
924 }
925
926 pub fn push(
933 &mut self,
934 batch: &RecordBatch,
935 memory_tracker: Option<&crate::memory::MemoryTracker>,
936 ) -> Result<()> {
937 let field_columns: Vec<&str> = self.field_columns.iter().map(String::as_str).collect();
938 match &mut self.state {
939 AggState::Ungrouped(st) => {
940 st.push(batch, &field_columns);
941 Ok(())
942 }
943 AggState::Grouped { groups, index } => {
944 let group_by: Vec<&str> = self.group_by.iter().map(String::as_str).collect();
945 push_grouped(
946 batch,
947 &field_columns,
948 &group_by,
949 groups,
950 index,
951 memory_tracker,
952 )
953 }
954 }
955 }
956
957 pub fn finish(
964 self,
965 memory_tracker: Option<&crate::memory::MemoryTracker>,
966 ) -> Result<RecordBatch> {
967 let field_columns: Vec<&str> = self.field_columns.iter().map(String::as_str).collect();
968 let batch = match self.state {
969 AggState::Ungrouped(st) => st.finish(&self.functions, &field_columns)?,
970 AggState::Grouped { groups, .. } => {
971 let group_by: Vec<&str> = self.group_by.iter().map(String::as_str).collect();
972 finish_grouped(&groups, &self.functions, &field_columns, &group_by)?
973 }
974 };
975 if let Some(tracker) = memory_tracker {
976 tracker.try_allocate(batch.get_array_memory_size())?;
977 }
978 Ok(batch)
979 }
980}
981
982fn push_grouped(
984 batch: &RecordBatch,
985 field_columns: &[&str],
986 group_by: &[&str],
987 groups: &mut Vec<(Vec<Option<String>>, Vec<IncrementalAccumulator>)>,
988 group_index: &mut std::collections::HashMap<u64, Vec<usize>>,
989 memory_tracker: Option<&crate::memory::MemoryTracker>,
990) -> Result<()> {
991 let num_fields = field_columns.len();
992
993 let group_cols: Vec<Option<&StringArray>> = group_by
994 .iter()
995 .map(|name| {
996 batch
997 .column_by_name(name)
998 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
999 })
1000 .collect();
1001
1002 let ts_col = batch
1003 .column_by_name(chronix_core::TIME_COLUMN)
1004 .and_then(|c| c.as_any().downcast_ref::<Int64Array>());
1005
1006 let typed_cols: Vec<Option<TypedColumn<'_>>> = field_columns
1008 .iter()
1009 .map(|name| {
1010 batch
1011 .column_by_name(name)
1012 .map(|c| TypedColumn::from_array(c.as_ref()))
1013 })
1014 .collect();
1015
1016 for row in 0..batch.num_rows() {
1017 let ts: Option<i64> = ts_col.and_then(|t| {
1018 if t.is_null(row) {
1019 None
1020 } else {
1021 Some(t.value(row))
1022 }
1023 });
1024
1025 let mut hasher = fnv::FnvHasher::default();
1027 for col in &group_cols {
1028 match col {
1029 Some(c) if !c.is_null(row) => {
1030 std::hash::Hash::hash(&1u8, &mut hasher);
1031 std::hash::Hash::hash(c.value(row), &mut hasher);
1032 }
1033 _ => std::hash::Hash::hash(&0u8, &mut hasher),
1034 }
1035 }
1036 let hash = std::hash::Hasher::finish(&hasher);
1037
1038 let key: Vec<Option<&str>> = group_cols
1039 .iter()
1040 .map(|col| match col {
1041 Some(c) if !c.is_null(row) => Some(c.value(row)),
1042 _ => None,
1043 })
1044 .collect();
1045
1046 let bucket = group_index.entry(hash).or_default();
1049 let gidx = {
1050 let mut found = None;
1051 for &idx in bucket.iter() {
1052 let existing: Vec<Option<&str>> =
1053 groups[idx].0.iter().map(|o| o.as_deref()).collect();
1054 if existing == key {
1055 found = Some(idx);
1056 break;
1057 }
1058 }
1059 match found {
1060 Some(idx) => idx,
1061 None => {
1062 if let Some(tracker) = memory_tracker {
1064 let estimated_bytes = group_by.len() * 48 + num_fields * std::mem::size_of::<IncrementalAccumulator>()
1066 + 48; tracker.try_allocate(estimated_bytes)?;
1068 }
1069 let idx = groups.len();
1070 let owned_key: Vec<Option<String>> =
1071 key.iter().map(|o| o.map(String::from)).collect();
1072 let accs = (0..num_fields)
1073 .map(|_| IncrementalAccumulator::new())
1074 .collect();
1075 groups.push((owned_key, accs));
1076 bucket.push(idx);
1077 idx
1078 }
1079 }
1080 };
1081
1082 for (fi, typed) in typed_cols.iter().enumerate() {
1085 if let Some(v) = typed.and_then(|tc| tc.value_f64(row)) {
1086 groups[gidx].1[fi].update(v, ts);
1087 }
1088 }
1089 }
1090
1091 Ok(())
1092}
1093
1094fn finish_grouped(
1096 groups: &[(Vec<Option<String>>, Vec<IncrementalAccumulator>)],
1097 functions: &[AggFn],
1098 field_columns: &[&str],
1099 group_by: &[&str],
1100) -> Result<RecordBatch> {
1101 let num_groups = groups.len();
1102 let mut result_fields: Vec<Field> = group_by
1103 .iter()
1104 .map(|name| Field::new(*name, DataType::Utf8, true))
1105 .collect();
1106 for &field_name in field_columns {
1107 for func in functions {
1108 let name = format!("{field_name}_{}", agg_fn_name(*func));
1109 result_fields.push(Field::new(&name, DataType::Float64, true));
1110 }
1111 }
1112
1113 let mut result_columns: Vec<ArrayRef> = Vec::new();
1114
1115 for col_idx in 0..group_by.len() {
1117 let vals: Vec<Option<&str>> = groups
1118 .iter()
1119 .map(|(key, _)| key[col_idx].as_deref())
1120 .collect();
1121 let arr: StringArray = vals.into_iter().collect();
1122 result_columns.push(Arc::new(arr));
1123 }
1124
1125 for fi in 0..field_columns.len() {
1127 for func in functions {
1128 let vals: Vec<Option<f64>> = (0..num_groups)
1129 .map(|gi| groups[gi].1[fi].finalize(*func))
1130 .collect();
1131 result_columns.push(Arc::new(Float64Array::from(vals)));
1132 }
1133 }
1134
1135 let schema = Arc::new(Schema::new(result_fields));
1136 RecordBatch::try_new(schema, result_columns).map_err(Into::into)
1137}
1138
1139impl UngroupedState {
1140 #[allow(clippy::cast_precision_loss)]
1144 fn push(&mut self, batch: &RecordBatch, field_columns: &[&str]) {
1145 let ts_col = batch
1146 .column_by_name(chronix_core::TIME_COLUMN)
1147 .and_then(|c| c.as_any().downcast_ref::<Int64Array>());
1148
1149 for (fi, &field_name) in field_columns.iter().enumerate() {
1150 let Some(col) = batch.column_by_name(field_name) else {
1151 continue;
1152 };
1153
1154 self.counts[fi] += (col.len() - col.null_count()) as u64;
1156
1157 let typed = TypedColumn::from_array(col.as_ref());
1159 match typed {
1160 TypedColumn::Float64(a) => {
1161 if let Some(s) = arrow_agg::sum(a) {
1162 self.sums[fi] += s;
1163 }
1164 if let Some(m) = arrow_agg::min(a) {
1165 if m < self.mins[fi] {
1166 self.mins[fi] = m;
1167 }
1168 }
1169 if let Some(m) = arrow_agg::max(a) {
1170 if m > self.maxs[fi] {
1171 self.maxs[fi] = m;
1172 }
1173 }
1174 }
1175 TypedColumn::Int64(a) => {
1176 if let Some(s) = arrow_agg::sum(a) {
1177 self.sums[fi] += s as f64;
1178 }
1179 if let Some(m) = arrow_agg::min(a) {
1180 if (m as f64) < self.mins[fi] {
1181 self.mins[fi] = m as f64;
1182 }
1183 }
1184 if let Some(m) = arrow_agg::max(a) {
1185 if (m as f64) > self.maxs[fi] {
1186 self.maxs[fi] = m as f64;
1187 }
1188 }
1189 }
1190 TypedColumn::UInt64(a) => {
1191 if let Some(s) = arrow_agg::sum(a) {
1192 self.sums[fi] += s as f64;
1193 }
1194 if let Some(m) = arrow_agg::min(a) {
1195 if (m as f64) < self.mins[fi] {
1196 self.mins[fi] = m as f64;
1197 }
1198 }
1199 if let Some(m) = arrow_agg::max(a) {
1200 if (m as f64) > self.maxs[fi] {
1201 self.maxs[fi] = m as f64;
1202 }
1203 }
1204 }
1205 TypedColumn::Other => {}
1206 }
1207
1208 if self.needs_first_last {
1212 if let Some(ts) = ts_col {
1213 let n = ts.len().min(col.len());
1214 for i in 0..n {
1215 if ts.is_null(i) || col.is_null(i) {
1216 continue;
1217 }
1218 let t = ts.value(i);
1219 if let Some(v) = typed.value_f64(i) {
1220 if t < self.first_ts[fi] || !self.first_set[fi] {
1221 self.first_ts[fi] = t;
1222 self.first_val[fi] = v;
1223 self.first_set[fi] = true;
1224 }
1225 if t >= self.last_ts[fi] || !self.last_set[fi] {
1227 self.last_ts[fi] = t;
1228 self.last_val[fi] = v;
1229 self.last_set[fi] = true;
1230 }
1231 }
1232 }
1233 }
1234 }
1235 }
1236 }
1237
1238 #[allow(clippy::cast_precision_loss)]
1239 fn finish(&self, functions: &[AggFn], field_columns: &[&str]) -> Result<RecordBatch> {
1240 let mut result_fields = Vec::new();
1241 let mut result_columns: Vec<ArrayRef> = Vec::new();
1242 for (fi, &field_name) in field_columns.iter().enumerate() {
1243 for func in functions {
1244 let name = format!("{field_name}_{}", agg_fn_name(*func));
1245 result_fields.push(Field::new(&name, DataType::Float64, true));
1246 let value = if self.counts[fi] == 0 {
1247 if *func == AggFn::Count {
1248 Some(0.0)
1249 } else {
1250 None
1251 }
1252 } else {
1253 match func {
1254 AggFn::Count => Some(self.counts[fi] as f64),
1255 AggFn::Sum => Some(self.sums[fi]),
1256 AggFn::Min => Some(self.mins[fi]),
1257 AggFn::Max => Some(self.maxs[fi]),
1258 AggFn::Avg => Some(self.sums[fi] / self.counts[fi] as f64),
1259 AggFn::First => self.first_set[fi].then_some(self.first_val[fi]),
1260 AggFn::Last => self.last_set[fi].then_some(self.last_val[fi]),
1261 }
1262 };
1263 result_columns.push(Arc::new(Float64Array::from(vec![value])));
1264 }
1265 }
1266
1267 let schema = Arc::new(Schema::new(result_fields));
1268 RecordBatch::try_new(schema, result_columns).map_err(Into::into)
1269 }
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274 use super::*;
1275
1276 fn test_batch() -> RecordBatch {
1277 let schema = Arc::new(Schema::new(vec![
1278 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
1279 Field::new("host", DataType::Utf8, false),
1280 Field::new("value", DataType::Float64, false),
1281 ]));
1282
1283 let timestamps = Arc::new(Int64Array::from(vec![100, 200, 300, 400, 500]));
1284 let hosts = Arc::new(StringArray::from(vec!["a", "a", "b", "b", "a"]));
1285 let values = Arc::new(Float64Array::from(vec![10.0, 20.0, 30.0, 40.0, 50.0]));
1286
1287 RecordBatch::try_new(schema, vec![timestamps, hosts, values]).unwrap()
1288 }
1289
1290 #[test]
1291 fn aggregate_count() {
1292 let batch = test_batch();
1293 let result = aggregate_batch(&batch, &[AggFn::Count], &["value"], None).unwrap();
1294 assert_eq!(result.num_rows(), 1);
1295 let col = result
1296 .column(0)
1297 .as_any()
1298 .downcast_ref::<Float64Array>()
1299 .unwrap();
1300 assert!((col.value(0) - 5.0).abs() < f64::EPSILON);
1301 }
1302
1303 #[test]
1304 fn aggregate_sum() {
1305 let batch = test_batch();
1306 let result = aggregate_batch(&batch, &[AggFn::Sum], &["value"], None).unwrap();
1307 let col = result
1308 .column(0)
1309 .as_any()
1310 .downcast_ref::<Float64Array>()
1311 .unwrap();
1312 assert!((col.value(0) - 150.0).abs() < f64::EPSILON);
1313 }
1314
1315 #[test]
1316 fn aggregate_min_max() {
1317 let batch = test_batch();
1318 let result = aggregate_batch(&batch, &[AggFn::Min, AggFn::Max], &["value"], None).unwrap();
1319 let min_col = result
1320 .column(0)
1321 .as_any()
1322 .downcast_ref::<Float64Array>()
1323 .unwrap();
1324 let max_col = result
1325 .column(1)
1326 .as_any()
1327 .downcast_ref::<Float64Array>()
1328 .unwrap();
1329 assert!((min_col.value(0) - 10.0).abs() < f64::EPSILON);
1330 assert!((max_col.value(0) - 50.0).abs() < f64::EPSILON);
1331 }
1332
1333 #[test]
1334 fn aggregate_avg() {
1335 let batch = test_batch();
1336 let result = aggregate_batch(&batch, &[AggFn::Avg], &["value"], None).unwrap();
1337 let col = result
1338 .column(0)
1339 .as_any()
1340 .downcast_ref::<Float64Array>()
1341 .unwrap();
1342 assert!((col.value(0) - 30.0).abs() < f64::EPSILON);
1343 }
1344
1345 #[test]
1346 fn aggregate_first_last() {
1347 let batch = test_batch();
1348 let result =
1349 aggregate_batch(&batch, &[AggFn::First, AggFn::Last], &["value"], None).unwrap();
1350 let first = result
1351 .column(0)
1352 .as_any()
1353 .downcast_ref::<Float64Array>()
1354 .unwrap();
1355 let last = result
1356 .column(1)
1357 .as_any()
1358 .downcast_ref::<Float64Array>()
1359 .unwrap();
1360 assert!((first.value(0) - 10.0).abs() < f64::EPSILON);
1361 assert!((last.value(0) - 50.0).abs() < f64::EPSILON);
1362 }
1363
1364 #[test]
1365 fn aggregate_grouped_by_host() {
1366 let batch = test_batch();
1367 let result = aggregate_grouped(
1368 &batch,
1369 &[AggFn::Sum, AggFn::Count],
1370 &["value"],
1371 &["host"],
1372 None,
1373 )
1374 .unwrap();
1375
1376 assert_eq!(result.num_rows(), 2); let hosts = result
1379 .column(0)
1380 .as_any()
1381 .downcast_ref::<StringArray>()
1382 .unwrap();
1383 let sums = result
1384 .column(1)
1385 .as_any()
1386 .downcast_ref::<Float64Array>()
1387 .unwrap();
1388 let counts = result
1389 .column(2)
1390 .as_any()
1391 .downcast_ref::<Float64Array>()
1392 .unwrap();
1393
1394 assert_eq!(hosts.value(0), "a");
1396 assert!((sums.value(0) - 80.0).abs() < f64::EPSILON); assert!((counts.value(0) - 3.0).abs() < f64::EPSILON);
1398
1399 assert_eq!(hosts.value(1), "b");
1400 assert!((sums.value(1) - 70.0).abs() < f64::EPSILON); assert!((counts.value(1) - 2.0).abs() < f64::EPSILON);
1402 }
1403
1404 #[test]
1405 fn all_functions_on_known_data() {
1406 let batch = test_batch();
1407 let result = aggregate_batch(
1408 &batch,
1409 &[
1410 AggFn::Count,
1411 AggFn::Sum,
1412 AggFn::Min,
1413 AggFn::Max,
1414 AggFn::Avg,
1415 AggFn::First,
1416 AggFn::Last,
1417 ],
1418 &["value"],
1419 None,
1420 )
1421 .unwrap();
1422
1423 assert_eq!(result.num_columns(), 7);
1424 let cols: Vec<f64> = (0..7)
1425 .map(|i| {
1426 result
1427 .column(i)
1428 .as_any()
1429 .downcast_ref::<Float64Array>()
1430 .unwrap()
1431 .value(0)
1432 })
1433 .collect();
1434
1435 assert!((cols[0] - 5.0).abs() < f64::EPSILON); assert!((cols[1] - 150.0).abs() < f64::EPSILON); assert!((cols[2] - 10.0).abs() < f64::EPSILON); assert!((cols[3] - 50.0).abs() < f64::EPSILON); assert!((cols[4] - 30.0).abs() < f64::EPSILON); assert!((cols[5] - 10.0).abs() < f64::EPSILON); assert!((cols[6] - 50.0).abs() < f64::EPSILON); }
1443
1444 fn u64_batch() -> RecordBatch {
1447 let schema = Arc::new(Schema::new(vec![
1448 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
1449 Field::new("count", DataType::UInt64, false),
1450 ]));
1451 let timestamps = Arc::new(Int64Array::from(vec![100, 200, 300, 400, 500]));
1452 let counts = Arc::new(UInt64Array::from(vec![10u64, 20, 30, 40, 50]));
1453 RecordBatch::try_new(schema, vec![timestamps, counts]).unwrap()
1454 }
1455
1456 #[test]
1457 fn aggregate_u64_sum() {
1458 let batch = u64_batch();
1459 let result = aggregate_batch(&batch, &[AggFn::Sum], &["count"], None).unwrap();
1460 let col = result
1461 .column(0)
1462 .as_any()
1463 .downcast_ref::<Float64Array>()
1464 .unwrap();
1465 assert!((col.value(0) - 150.0).abs() < f64::EPSILON);
1466 }
1467
1468 #[test]
1469 fn aggregate_u64_min_max() {
1470 let batch = u64_batch();
1471 let result = aggregate_batch(&batch, &[AggFn::Min, AggFn::Max], &["count"], None).unwrap();
1472 let min_col = result
1473 .column(0)
1474 .as_any()
1475 .downcast_ref::<Float64Array>()
1476 .unwrap();
1477 let max_col = result
1478 .column(1)
1479 .as_any()
1480 .downcast_ref::<Float64Array>()
1481 .unwrap();
1482 assert!((min_col.value(0) - 10.0).abs() < f64::EPSILON);
1483 assert!((max_col.value(0) - 50.0).abs() < f64::EPSILON);
1484 }
1485
1486 #[test]
1487 fn aggregate_u64_avg() {
1488 let batch = u64_batch();
1489 let result = aggregate_batch(&batch, &[AggFn::Avg], &["count"], None).unwrap();
1490 let col = result
1491 .column(0)
1492 .as_any()
1493 .downcast_ref::<Float64Array>()
1494 .unwrap();
1495 assert!((col.value(0) - 30.0).abs() < f64::EPSILON);
1496 }
1497
1498 #[test]
1499 fn aggregate_u64_first_last() {
1500 let batch = u64_batch();
1501 let result =
1502 aggregate_batch(&batch, &[AggFn::First, AggFn::Last], &["count"], None).unwrap();
1503 let first = result
1504 .column(0)
1505 .as_any()
1506 .downcast_ref::<Float64Array>()
1507 .unwrap();
1508 let last = result
1509 .column(1)
1510 .as_any()
1511 .downcast_ref::<Float64Array>()
1512 .unwrap();
1513 assert!((first.value(0) - 10.0).abs() < f64::EPSILON);
1514 assert!((last.value(0) - 50.0).abs() < f64::EPSILON);
1515 }
1516
1517 #[test]
1518 fn aggregate_u64_count() {
1519 let batch = u64_batch();
1520 let result = aggregate_batch(&batch, &[AggFn::Count], &["count"], None).unwrap();
1521 let col = result
1522 .column(0)
1523 .as_any()
1524 .downcast_ref::<Float64Array>()
1525 .unwrap();
1526 assert!((col.value(0) - 5.0).abs() < f64::EPSILON);
1527 }
1528
1529 #[test]
1530 fn aggregate_respects_memory_tracker() {
1531 let batch = test_batch();
1532 let tracker = crate::memory::MemoryTracker::new(1_000_000);
1533 let result = aggregate_batch(&batch, &[AggFn::Sum], &["value"], Some(&tracker)).unwrap();
1534 assert!(tracker.allocated() > 0);
1535 let col = result
1536 .column(0)
1537 .as_any()
1538 .downcast_ref::<Float64Array>()
1539 .unwrap();
1540 assert!((col.value(0) - 150.0).abs() < f64::EPSILON);
1541 }
1542
1543 #[test]
1544 fn aggregate_exceeds_memory_budget() {
1545 let batch = test_batch();
1546 let tracker = crate::memory::MemoryTracker::new(1); let err = aggregate_batch(&batch, &[AggFn::Sum], &["value"], Some(&tracker)).unwrap_err();
1548 assert!(matches!(
1549 err,
1550 crate::error::QueryError::QueryMemoryExceeded { .. }
1551 ));
1552 }
1553
1554 #[test]
1555 fn grouped_aggregate_respects_memory_tracker() {
1556 let batch = test_batch();
1557 let tracker = crate::memory::MemoryTracker::new(1_000_000);
1558 let result =
1559 aggregate_grouped(&batch, &[AggFn::Sum], &["value"], &["host"], Some(&tracker))
1560 .unwrap();
1561 assert!(tracker.allocated() > 0);
1562 assert_eq!(result.num_rows(), 2);
1563 }
1564
1565 #[test]
1568 fn first_last_out_of_order_timestamps() {
1569 let schema = Arc::new(Schema::new(vec![
1570 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
1571 Field::new("host", DataType::Utf8, false),
1572 Field::new("value", DataType::Float64, false),
1573 ]));
1574 let ts = Int64Array::from(vec![300, 100, 500, 200, 400]);
1575 let host = StringArray::from(vec!["a", "a", "a", "a", "a"]);
1576 let value = Float64Array::from(vec![3.0, 1.0, 5.0, 2.0, 4.0]);
1577 let batch =
1578 RecordBatch::try_new(schema, vec![Arc::new(ts), Arc::new(host), Arc::new(value)])
1579 .unwrap();
1580
1581 let result =
1582 aggregate_batch(&batch, &[AggFn::First, AggFn::Last], &["value"], None).unwrap();
1583 let first = result
1584 .column(0)
1585 .as_any()
1586 .downcast_ref::<Float64Array>()
1587 .unwrap();
1588 let last = result
1589 .column(1)
1590 .as_any()
1591 .downcast_ref::<Float64Array>()
1592 .unwrap();
1593 assert!(
1595 (first.value(0) - 1.0).abs() < f64::EPSILON,
1596 "First should be 1.0, got {}",
1597 first.value(0)
1598 );
1599 assert!(
1601 (last.value(0) - 5.0).abs() < f64::EPSILON,
1602 "Last should be 5.0, got {}",
1603 last.value(0)
1604 );
1605 }
1606
1607 #[test]
1609 fn first_last_timestamp_ties() {
1610 let schema = Arc::new(Schema::new(vec![
1611 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
1612 Field::new("host", DataType::Utf8, false),
1613 Field::new("value", DataType::Float64, false),
1614 ]));
1615 let ts = Int64Array::from(vec![100, 200, 100, 200]);
1617 let host = StringArray::from(vec!["a", "a", "a", "a"]);
1618 let value = Float64Array::from(vec![1.0, 2.0, 3.0, 4.0]);
1619 let batch =
1620 RecordBatch::try_new(schema, vec![Arc::new(ts), Arc::new(host), Arc::new(value)])
1621 .unwrap();
1622
1623 let result =
1624 aggregate_batch(&batch, &[AggFn::First, AggFn::Last], &["value"], None).unwrap();
1625 let first = result
1626 .column(0)
1627 .as_any()
1628 .downcast_ref::<Float64Array>()
1629 .unwrap();
1630 let last = result
1631 .column(1)
1632 .as_any()
1633 .downcast_ref::<Float64Array>()
1634 .unwrap();
1635 assert!((first.value(0) - 1.0).abs() < f64::EPSILON);
1637 assert!((last.value(0) - 4.0).abs() < f64::EPSILON);
1639 }
1640
1641 #[test]
1643 fn grouped_first_last_by_timestamp() {
1644 let schema = Arc::new(Schema::new(vec![
1645 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
1646 Field::new("host", DataType::Utf8, false),
1647 Field::new("value", DataType::Float64, false),
1648 ]));
1649 let ts = Int64Array::from(vec![200, 100, 300, 100, 200]);
1652 let host = StringArray::from(vec!["a", "a", "b", "b", "b"]);
1653 let value = Float64Array::from(vec![20.0, 10.0, 30.0, 10.0, 20.0]);
1654 let batch =
1655 RecordBatch::try_new(schema, vec![Arc::new(ts), Arc::new(host), Arc::new(value)])
1656 .unwrap();
1657
1658 let result = aggregate_grouped(
1659 &batch,
1660 &[AggFn::First, AggFn::Last],
1661 &["value"],
1662 &["host"],
1663 None,
1664 )
1665 .unwrap();
1666
1667 let host_col = result.column_by_name("host").unwrap();
1669 let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
1670 let first_col = result.column_by_name("value_first").unwrap();
1671 let first_arr = first_col.as_any().downcast_ref::<Float64Array>().unwrap();
1672 let last_col = result.column_by_name("value_last").unwrap();
1673 let last_arr = last_col.as_any().downcast_ref::<Float64Array>().unwrap();
1674
1675 for i in 0..result.num_rows() {
1676 match host_arr.value(i) {
1677 "a" => {
1678 assert!(
1679 (first_arr.value(i) - 10.0).abs() < f64::EPSILON,
1680 "host=a first"
1681 );
1682 assert!(
1683 (last_arr.value(i) - 20.0).abs() < f64::EPSILON,
1684 "host=a last"
1685 );
1686 }
1687 "b" => {
1688 assert!(
1689 (first_arr.value(i) - 10.0).abs() < f64::EPSILON,
1690 "host=b first"
1691 );
1692 assert!(
1693 (last_arr.value(i) - 30.0).abs() < f64::EPSILON,
1694 "host=b last"
1695 );
1696 }
1697 other => panic!("unexpected host: {other}"),
1698 }
1699 }
1700 }
1701
1702 #[test]
1704 fn grouped_last_timestamp_tie_breaking() {
1705 let schema = Arc::new(Schema::new(vec![
1706 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
1707 Field::new("host", DataType::Utf8, false),
1708 Field::new("value", DataType::Float64, false),
1709 ]));
1710 let ts = Int64Array::from(vec![100, 100]);
1712 let host = StringArray::from(vec!["a", "a"]);
1713 let value = Float64Array::from(vec![1.0, 2.0]);
1714 let batch =
1715 RecordBatch::try_new(schema, vec![Arc::new(ts), Arc::new(host), Arc::new(value)])
1716 .unwrap();
1717
1718 let result =
1719 aggregate_grouped(&batch, &[AggFn::Last], &["value"], &["host"], None).unwrap();
1720
1721 let last_col = result.column_by_name("value_last").unwrap();
1722 let last_arr = last_col.as_any().downcast_ref::<Float64Array>().unwrap();
1723 assert!(
1724 (last_arr.value(0) - 2.0).abs() < f64::EPSILON,
1725 "Last with tie should keep last-encountered (2.0), got {}",
1726 last_arr.value(0)
1727 );
1728 }
1729
1730 #[test]
1732 fn first_last_single_row() {
1733 let schema = Arc::new(Schema::new(vec![
1734 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
1735 Field::new("host", DataType::Utf8, false),
1736 Field::new("value", DataType::Float64, false),
1737 ]));
1738 let ts = Int64Array::from(vec![100]);
1739 let host = StringArray::from(vec!["a"]);
1740 let value = Float64Array::from(vec![42.0]);
1741 let batch =
1742 RecordBatch::try_new(schema, vec![Arc::new(ts), Arc::new(host), Arc::new(value)])
1743 .unwrap();
1744
1745 let result =
1746 aggregate_batch(&batch, &[AggFn::First, AggFn::Last], &["value"], None).unwrap();
1747 let first = result
1748 .column(0)
1749 .as_any()
1750 .downcast_ref::<Float64Array>()
1751 .unwrap();
1752 let last = result
1753 .column(1)
1754 .as_any()
1755 .downcast_ref::<Float64Array>()
1756 .unwrap();
1757 assert!((first.value(0) - 42.0).abs() < f64::EPSILON);
1758 assert!((last.value(0) - 42.0).abs() < f64::EPSILON);
1759 }
1760
1761 #[test]
1764 fn sort_aggregate_matches_hash() {
1765 let batch = test_batch();
1766 let hash_result = aggregate_grouped(
1767 &batch,
1768 &[AggFn::Sum, AggFn::Count, AggFn::Min, AggFn::Max, AggFn::Avg],
1769 &["value"],
1770 &["host"],
1771 None,
1772 )
1773 .unwrap();
1774 let sort_result = aggregate_sorted(
1775 &batch,
1776 &[AggFn::Sum, AggFn::Count, AggFn::Min, AggFn::Max, AggFn::Avg],
1777 &["value"],
1778 &["host"],
1779 None,
1780 )
1781 .unwrap();
1782
1783 assert_eq!(hash_result.num_rows(), sort_result.num_rows());
1785
1786 let hash_hosts = hash_result
1788 .column_by_name("host")
1789 .unwrap()
1790 .as_any()
1791 .downcast_ref::<StringArray>()
1792 .unwrap();
1793 let sort_hosts = sort_result
1794 .column_by_name("host")
1795 .unwrap()
1796 .as_any()
1797 .downcast_ref::<StringArray>()
1798 .unwrap();
1799
1800 for host in &["a", "b"] {
1801 let hi = (0..hash_result.num_rows())
1802 .find(|&i| hash_hosts.value(i) == *host)
1803 .unwrap();
1804 let si = (0..sort_result.num_rows())
1805 .find(|&i| sort_hosts.value(i) == *host)
1806 .unwrap();
1807
1808 for col_name in &[
1809 "value_sum",
1810 "value_count",
1811 "value_min",
1812 "value_max",
1813 "value_avg",
1814 ] {
1815 let hv = hash_result
1816 .column_by_name(col_name)
1817 .unwrap()
1818 .as_any()
1819 .downcast_ref::<Float64Array>()
1820 .unwrap()
1821 .value(hi);
1822 let sv = sort_result
1823 .column_by_name(col_name)
1824 .unwrap()
1825 .as_any()
1826 .downcast_ref::<Float64Array>()
1827 .unwrap()
1828 .value(si);
1829 assert!(
1830 (hv - sv).abs() < f64::EPSILON,
1831 "mismatch for host={host} col={col_name}: hash={hv}, sort={sv}"
1832 );
1833 }
1834 }
1835 }
1836
1837 #[test]
1838 fn sort_aggregate_first_last() {
1839 let batch = test_batch();
1840 let result = aggregate_sorted(
1841 &batch,
1842 &[AggFn::First, AggFn::Last],
1843 &["value"],
1844 &["host"],
1845 None,
1846 )
1847 .unwrap();
1848
1849 let host_col = result
1850 .column_by_name("host")
1851 .unwrap()
1852 .as_any()
1853 .downcast_ref::<StringArray>()
1854 .unwrap();
1855 let first_col = result
1856 .column_by_name("value_first")
1857 .unwrap()
1858 .as_any()
1859 .downcast_ref::<Float64Array>()
1860 .unwrap();
1861 let last_col = result
1862 .column_by_name("value_last")
1863 .unwrap()
1864 .as_any()
1865 .downcast_ref::<Float64Array>()
1866 .unwrap();
1867
1868 for i in 0..result.num_rows() {
1869 match host_col.value(i) {
1870 "a" => {
1871 assert!((first_col.value(i) - 10.0).abs() < f64::EPSILON);
1873 assert!((last_col.value(i) - 50.0).abs() < f64::EPSILON);
1874 }
1875 "b" => {
1876 assert!((first_col.value(i) - 30.0).abs() < f64::EPSILON);
1878 assert!((last_col.value(i) - 40.0).abs() < f64::EPSILON);
1879 }
1880 other => panic!("unexpected host: {other}"),
1881 }
1882 }
1883 }
1884
1885 #[test]
1886 fn sort_aggregate_produces_sorted_output() {
1887 let schema = Arc::new(Schema::new(vec![
1888 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
1889 Field::new("region", DataType::Utf8, false),
1890 Field::new("value", DataType::Float64, false),
1891 ]));
1892 let ts = Int64Array::from(vec![100, 200, 300]);
1893 let regions = StringArray::from(vec!["eu", "us", "ap"]);
1894 let values = Float64Array::from(vec![1.0, 2.0, 3.0]);
1895 let batch = RecordBatch::try_new(
1896 schema,
1897 vec![Arc::new(ts), Arc::new(regions), Arc::new(values)],
1898 )
1899 .unwrap();
1900
1901 let result =
1902 aggregate_sorted(&batch, &[AggFn::Sum], &["value"], &["region"], None).unwrap();
1903
1904 let region_col = result
1905 .column_by_name("region")
1906 .unwrap()
1907 .as_any()
1908 .downcast_ref::<StringArray>()
1909 .unwrap();
1910 let regions: Vec<&str> = (0..result.num_rows())
1912 .map(|i| region_col.value(i))
1913 .collect();
1914 assert_eq!(
1915 regions,
1916 vec!["ap", "eu", "us"],
1917 "output should be sorted by group key"
1918 );
1919 }
1920
1921 #[test]
1922 fn choose_strategy_defaults_to_hash() {
1923 assert_eq!(choose_strategy(None), AggregationStrategy::Hash);
1924 assert_eq!(choose_strategy(Some(2000)), AggregationStrategy::Hash);
1925 }
1926
1927 #[test]
1928 fn choose_strategy_low_cardinality_uses_sort() {
1929 assert_eq!(choose_strategy(Some(5)), AggregationStrategy::Sort);
1930 assert_eq!(choose_strategy(Some(1024)), AggregationStrategy::Sort);
1931 }
1932
1933 #[test]
1934 fn sort_aggregate_no_group_by_delegates() {
1935 let batch = test_batch();
1936 let result = aggregate_sorted(&batch, &[AggFn::Sum], &["value"], &[], None).unwrap();
1937 let col = result
1938 .column(0)
1939 .as_any()
1940 .downcast_ref::<Float64Array>()
1941 .unwrap();
1942 assert!((col.value(0) - 150.0).abs() < f64::EPSILON);
1943 }
1944}