Skip to main content

chronix_query/
aggregate.rs

1//! Aggregation functions for query results.
2//!
3//! Implements core aggregation operations on Arrow arrays:
4//! `count`, `sum`, `min`, `max`, `avg`, `first`, `last`.
5//!
6//! Supports group-by aggregation partitioned by tag columns.
7//!
8//! # Memory model
9//!
10//! [`StreamingAggregator`] is the form to reach for over a lazy scan: batches
11//! are pushed in and dropped, and the state held is one accumulator set per
12//! group. Peak memory is therefore `O(groups × fields)` and independent of
13//! how many rows were aggregated — a `SELECT avg(x)` over a week of 1 s data
14//! produces its single row without ever holding the week.
15//!
16//! The whole-slice entry points ([`aggregate_batch`], [`aggregate_grouped`],
17//! [`aggregate_grouped_streaming`]) are conveniences for callers that already
18//! hold their input.
19//!
20//! The *output* is still a single `RecordBatch`, so a group-by over a very
21//! high-cardinality column is bounded by the group count rather than the row
22//! count. Pass a [`MemoryTracker`](crate::memory::MemoryTracker) to fail early
23//! rather than OOM in that case.
24
25use 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/// Aggregation function type.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum AggFn {
37    /// Count of non-null values.
38    Count,
39    /// Sum of values.
40    Sum,
41    /// Minimum value.
42    Min,
43    /// Maximum value.
44    Max,
45    /// Average (mean) value.
46    Avg,
47    /// First value — the value at the minimum timestamp.
48    ///
49    /// When a timestamp column is available (the normal case), this scans
50    /// for the row with the smallest timestamp. The data need not be sorted.
51    /// Only falls back to array position if no timestamp column is present.
52    ///
53    /// # Edge cases
54    ///
55    /// - **Empty bucket / all nulls**: returns `None` (NULL in SQL).
56    /// - **Ties** (multiple rows with the same minimum timestamp): the
57    ///   first row encountered during the linear scan wins — i.e.
58    ///   the result is deterministic for a given input order but
59    ///   **not** stable across different physical orderings.
60    /// - **Null timestamp**: the row is skipped; only non-null
61    ///   timestamps participate in the min/max search.
62    First,
63    /// Last value — the value at the maximum timestamp.
64    ///
65    /// When a timestamp column is available (the normal case), this scans
66    /// for the row with the largest timestamp. The data need not be sorted.
67    /// Only falls back to array position if no timestamp column is present.
68    ///
69    /// # Edge cases
70    ///
71    /// - **Empty bucket / all nulls**: returns `None` (NULL in SQL).
72    /// - **Ties** (multiple rows with the same maximum timestamp): the
73    ///   last row encountered during the linear scan wins — i.e.
74    ///   the result is deterministic for a given input order but
75    ///   **not** stable across different physical orderings.
76    /// - **Null timestamp**: the row is skipped; only non-null
77    ///   timestamps participate in the min/max search.
78    Last,
79}
80
81/// Generate a typed aggregation function that applies `AggFn` to an Arrow
82/// primitive array and returns the result as `Option<f64>`.
83///
84/// For `Float64Array` values are used directly; for integer types they are
85/// cast to `f64` via the `$conv` expression.
86macro_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            // COUNT on empty input returns 0 (SQL semantics), not NULL.
91            if values.is_empty() {
92                return if function == AggFn::Count {
93                    Some(0.0)
94                } else {
95                    None
96                };
97            }
98            match function {
99                // O(1) non-null count via Arrow metadata instead
100                // of iterating all values.
101                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
125/// Apply aggregation to a `RecordBatch` without grouping.
126///
127/// Returns a single-row `RecordBatch` with one column per field per function.
128///
129/// # Errors
130///
131/// Returns an error if the batch is empty or columns have unexpected types.
132pub 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    // Extract timestamp column for timestamp-aware First/Last.
142    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
172/// Apply aggregation with group-by support.
173///
174/// Groups rows by the specified tag columns, then applies each aggregation
175/// function to each field column within each group.
176///
177/// # Vectorized design
178///
179/// Uses a single-pass incremental accumulator approach instead of
180/// per-group `compute::take()` + scalar aggregation. Each row is
181/// processed exactly once with zero intermediate array allocations:
182///
183/// 1. Hash group keys (FNV-1a, zero-copy)
184/// 2. Look up or create `IncrementalAccumulator` per (group, field)
185/// 3. Update accumulator with the row's value and timestamp
186/// 4. Finalize all accumulators into result arrays
187///
188/// This eliminates O(N_groups × N_fields) `compute::take()` allocations
189/// and replaces them with O(1) accumulator updates per row. One
190/// accumulator per (group, field) supports all `AggFn` variants
191/// simultaneously.
192///
193/// # Errors
194///
195/// Returns an error if columns are not found.
196pub 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    // Build group keys by combining group-by column values
208    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    // Pre-resolve field columns and extract typed arrays ONCE.
223    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    // Pre-downcast to eliminate per-row dynamic dispatch.
234    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    // One accumulator per (group, field_column) — a single
244    // IncrementalAccumulator tracks count/sum/min/max/first/last
245    // simultaneously, so we never need separate accumulators per function.
246    let num_fields = field_columns.len();
247
248    // Per-group state: key + one accumulator per field column.
249    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        // Hash the group key without allocating Strings
263        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        // Build a zero-copy key for collision checking
275        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        // Find or create the group
287        let bucket = group_index.entry(hash).or_default();
288        // Cap chain length to prevent hash-collision DoS.
289        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                    // Check memory budget before adding a new group.
306                    if let Some(tracker) = memory_tracker {
307                        let estimated_group_bytes = 24 + group_by.len() * 16  // key Vec header + elements
308                            + num_fields * std::mem::size_of::<IncrementalAccumulator>()
309                            + 64; // HashMap entry overhead
310                        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        // Update one accumulator per field — single pass, no
324        // intermediate array allocations. TypedColumn pre-downcast eliminates
325        // per-row dynamic dispatch.
326        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    // Build result schema
334    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    // Build result arrays
347    let num_groups = groups.len();
348    let mut result_columns: Vec<ArrayRef> = Vec::new();
349
350    // Group-by value columns.
351    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    // Finalize accumulators — each IncrementalAccumulator
358    // produces results for any requested AggFn in O(1).
359    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/// Aggregate a single column using the given function.
377///
378/// Timestamp-aware column aggregation.
379#[allow(clippy::cast_precision_loss)]
380fn aggregate_column_ts(
381    col: &dyn Array,
382    func: AggFn,
383    timestamps: Option<&Int64Array>,
384) -> Option<f64> {
385    // For First/Last with a timestamp column, find the index of the
386    // min/max timestamp and return the value at that index.
387    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/// Return the value at the row with the minimum (First) or maximum (Last)
408/// timestamp.  Works for any numeric column type.
409#[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        // Use `>=` so that on ties, the last row encountered wins.
430        // This makes Last deterministic: for a given input order, the
431        // latest-inserted row at the max timestamp is always selected.
432        AggFn::Last => find_idx(|t, best| t >= best),
433        _ => return None,
434    }?;
435
436    // Extract the value at the selected index.
437    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// take_indices() removed — grouped aggregation now uses
449// IncrementalAccumulator per (group, field) in a single pass,
450// eliminating per-group compute::take() allocations.
451
452/// Human-readable function name for column naming.
453#[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// ── Streaming (incremental) aggregation ─────────────────────────
467
468/// Pre-downcasted column reference — eliminates per-row `as_any().downcast_ref()`
469/// calls in the inner loop. The downcast is performed once per column.
470#[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/// Per-field incremental accumulator that supports all `AggFn` operations
521/// without materializing the input.
522#[derive(Debug, Clone)]
523struct IncrementalAccumulator {
524    count: u64,
525    sum: f64,
526    min: f64,
527    max: f64,
528    /// Value at the minimum timestamp (First).
529    first_ts: i64,
530    first_val: f64,
531    first_set: bool,
532    /// Value at the maximum timestamp (Last).
533    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        // Skip First/Last tracking when timestamp is null (consistent
564        // with the non-grouped `first_last_by_ts` path which also skips
565        // null-timestamp rows).
566        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            // Use `>=` so that on ties, the last row encountered wins
573            // (consistent with the non-grouped `first_last_by_ts` path).
574            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/// Aggregation strategy chosen based on estimated group-key cardinality.
604///
605/// When cardinality metadata is available from segment column
606/// statistics, the engine selects the strategy that minimises overhead:
607///
608/// - **Hash**: O(N) single-pass with FNV-1a hash table. Best for high
609///   cardinality (many distinct groups) where sort cost dominates.
610/// - **Sort**: Sort on group-by columns via Arrow `sort_to_indices`, then
611///   linear scan over contiguous runs. Best for low cardinality where the
612///   sort is cheap and avoids hash-table memory overhead. Produces sorted
613///   output — advantageous for downstream ORDER BY.
614#[derive(Debug, Clone, Copy, PartialEq, Eq)]
615pub enum AggregationStrategy {
616    /// FNV-1a hash-table grouping (default).
617    Hash,
618    /// Sort group-by columns first, then linear-scan contiguous runs.
619    Sort,
620}
621
622/// Cardinality threshold below which sort-based aggregation is preferred.
623/// Empirically, sort wins when group count is ≤ ~1024 because the O(N log N)
624/// sort cost is offset by better cache locality, no hash collisions, and
625/// sorted output for free.
626const SORT_CARDINALITY_THRESHOLD: usize = 1024;
627
628/// Choose an aggregation strategy based on estimated cardinality.
629///
630/// - `None` → Hash (conservative default, universal).
631/// - `Some(c)` where `c ≤ SORT_CARDINALITY_THRESHOLD` → Sort.
632/// - `Some(c)` where `c > SORT_CARDINALITY_THRESHOLD` → Hash.
633#[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
641/// Sort-based group-by aggregation.
642///
643/// Sorts the batch on group-by columns, then iterates over contiguous runs
644/// of equal keys, accumulating into `IncrementalAccumulator` per field.
645/// Produces output in sorted group-key order.
646///
647/// Preferred for low-cardinality group-by where sort cost is small and the
648/// sorted output is often useful for downstream processing.
649///
650/// # Errors
651///
652/// Returns an error if required columns are missing.
653pub 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    // Build Arrow SortFields for group-by columns.
667    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    // Build sort indices from row-encoded keys.
685    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    // Pre-resolve field columns.
689    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    // Linear scan over sorted indices — contiguous equal keys form groups.
725    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        // Extract group key from the first row in the run.
734        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    // Build result schema and arrays.
779    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
819/// Streaming group-by aggregation over multiple `RecordBatch`es.
820///
821/// Convenience wrapper over [`StreamingAggregator`] for callers that already
822/// hold every batch. Prefer pushing batches into the aggregator directly when
823/// they arrive from a lazy source — that is what keeps peak memory
824/// proportional to the number of *groups* rather than the number of rows.
825///
826/// # Errors
827///
828/// Returns an error if required columns are missing or `batches` is empty.
829pub 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
846/// Constant-memory aggregation state that batches are pushed into.
847///
848/// Aggregation does not need to see its input all at once: every supported
849/// function has an incremental form, so the state is `O(groups × fields)` and
850/// each batch can be dropped as soon as it has been folded in. Holding the
851/// input instead would make a `SELECT avg(x) FROM m` over a week of 1 s data
852/// cost gigabytes for a single output row.
853///
854/// Rows must carry the group-by and field columns by name; a batch missing a
855/// column simply contributes nothing for it, which is how schema evolution
856/// across segments is tolerated.
857pub struct StreamingAggregator {
858    functions: Vec<AggFn>,
859    field_columns: Vec<String>,
860    group_by: Vec<String>,
861    state: AggState,
862}
863
864enum AggState {
865    /// One accumulator set per distinct group key.
866    Grouped {
867        groups: Vec<(Vec<Option<String>>, Vec<IncrementalAccumulator>)>,
868        /// Group-key hash → candidate indices into `groups` (collision chain).
869        index: std::collections::HashMap<u64, Vec<usize>>,
870    },
871    /// Whole dataset is one group — folded with Arrow kernels per batch.
872    Ungrouped(UngroupedState),
873}
874
875/// Per-field partial state for the ungrouped fold.
876struct 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    /// Create an aggregator for `functions` over `field_columns`, grouped by
892    /// `group_by` (empty for a whole-dataset aggregate).
893    #[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    /// Fold one batch into the running state. The batch may be dropped after.
927    ///
928    /// # Errors
929    ///
930    /// Returns an error if the optional `memory_tracker` budget is exceeded by
931    /// newly discovered groups.
932    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    /// Finalise the accumulated state into a result batch.
958    ///
959    /// # Errors
960    ///
961    /// Returns an error if the result batch cannot be built or the optional
962    /// `memory_tracker` budget is exceeded.
963    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
982/// Fold one batch into the grouped accumulator state.
983fn 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    // Resolve field columns for this batch — pre-downcast once.
1007    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        // Hash the group key.
1026        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        // Find or create the group. The hash is only a bucket selector — the
1047        // full key is compared, so a collision cannot merge two series.
1048        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                    // Check memory budget before adding a new group.
1063                    if let Some(tracker) = memory_tracker {
1064                        let estimated_bytes = group_by.len() * 48  // owned Strings
1065                            + num_fields * std::mem::size_of::<IncrementalAccumulator>()
1066                            + 48; // HashMap entry + Vec overhead
1067                        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        // Update one accumulator per field — single pass, no
1083        // redundant accumulator updates across function variants.
1084        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
1094/// Build the result batch from grouped accumulator state.
1095fn 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    // Group-by value columns.
1116    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    // Aggregation result columns — one accumulator per field.
1126    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    /// Fold one batch, using Arrow SIMD kernels for count/sum/min/max.
1141    /// First/Last still require a linear scan to find the row at the
1142    /// min/max timestamp.
1143    #[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            // Vectorized count via O(1) Arrow metadata.
1155            self.counts[fi] += (col.len() - col.null_count()) as u64;
1156
1157            // Vectorized Sum/Min/Max using Arrow SIMD kernels.
1158            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            // First/Last: find value at the row with min/max timestamp.
1209            // This is inherently a scatter operation (argmin/argmax + value
1210            // lookup), so a single linear scan per batch is optimal.
1211            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                            // `>=` so last-encountered wins on ties.
1226                            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); // two groups: a, b
1377
1378        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        // Groups maintain insertion order (first-seen order)
1395        assert_eq!(hosts.value(0), "a");
1396        assert!((sums.value(0) - 80.0).abs() < f64::EPSILON); // 10+20+50
1397        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); // 30+40
1401        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); // count
1436        assert!((cols[1] - 150.0).abs() < f64::EPSILON); // sum
1437        assert!((cols[2] - 10.0).abs() < f64::EPSILON); // min
1438        assert!((cols[3] - 50.0).abs() < f64::EPSILON); // max
1439        assert!((cols[4] - 30.0).abs() < f64::EPSILON); // avg
1440        assert!((cols[5] - 10.0).abs() < f64::EPSILON); // first
1441        assert!((cols[6] - 50.0).abs() < f64::EPSILON); // last
1442    }
1443
1444    // ── U64 aggregation tests ─────────────────────────────────────
1445
1446    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); // 1 byte budget
1547        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    /// Out-of-order timestamps: First/Last should use timestamp ordering,
1566    /// not positional ordering.
1567    #[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        // First = value at min timestamp (100) = 1.0
1594        assert!(
1595            (first.value(0) - 1.0).abs() < f64::EPSILON,
1596            "First should be 1.0, got {}",
1597            first.value(0)
1598        );
1599        // Last = value at max timestamp (500) = 5.0
1600        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    /// Timestamp ties: Last should keep the last-encountered row.
1608    #[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        // Two rows at ts=100 (first wins = 1.0), two rows at ts=200 (last-encountered wins = 4.0).
1616        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        // First: first-encountered at min-ts (100) = 1.0
1636        assert!((first.value(0) - 1.0).abs() < f64::EPSILON);
1637        // Last: last-encountered at max-ts (200) = 4.0
1638        assert!((last.value(0) - 4.0).abs() < f64::EPSILON);
1639    }
1640
1641    /// Grouped First/Last should also use timestamps correctly.
1642    #[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        // host=a: ts=[200,100] vals=[20,10] → first=10, last=20
1650        // host=b: ts=[300,100,200] vals=[30,10,20] → first=10, last=30
1651        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        // Find host=a and host=b rows in result
1668        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    /// Grouped Last with timestamp ties should use last-encountered.
1703    #[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        // host=a: two rows at ts=100, values [1.0, 2.0] → last-encountered wins = 2.0
1711        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    /// Single-row batch: First and Last should return the same value.
1731    #[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    // ── Sort-based aggregation tests ─────────────────
1762
1763    #[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        // Same number of groups.
1784        assert_eq!(hash_result.num_rows(), sort_result.num_rows());
1785
1786        // Collect results by host for order-independent comparison.
1787        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                    // ts=[100,200,500] vals=[10,20,50] → first=10, last=50
1872                    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                    // ts=[300,400] vals=[30,40] → first=30, last=40
1877                    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        // Sort-based aggregation should produce sorted group keys.
1911        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}