Skip to main content

chronix_query/
downsample.rs

1//! Time-bucket downsampling engine.
2//!
3//! Groups data points into fixed-width time buckets and applies an
4//! aggregation function per bucket. This enables efficient data reduction
5//! for dashboards, alerting queries, and long-term storage.
6//!
7//! # Two entry points
8//!
9//! [`downsample`] takes a whole `RecordBatch` and returns every bucket in it.
10//! It is correct only when the batch contains *all* the rows for the buckets
11//! it covers.
12//!
13//! [`StreamingDownsampler`] is the one to use over a chunked scan. Bucket
14//! boundaries do not line up with batch boundaries, so downsampling each
15//! batch independently emits the straddling interval twice — once per side —
16//! which a caller then sums, averages or plots as two points. The
17//! streaming form keeps one open bucket across batches and closes it only
18//! when a row from a later bucket arrives, so a bucket is emitted exactly
19//! once no matter how the input was chunked. Peak memory is one bucket's
20//! accumulator, not one batch's worth of rows.
21
22use std::sync::Arc;
23
24use fnv::FnvHashMap;
25
26use arrow::array::{Array, ArrayRef, Float64Array, Int64Array};
27use arrow::datatypes::{DataType, Field, Schema};
28use arrow::record_batch::RecordBatch;
29
30use crate::aggregate::AggFn;
31use crate::error::{QueryError, Result};
32
33/// Extract the value at `index` from a numeric column as `f64`.
34///
35/// Returns `None` if the value is null.
36#[allow(clippy::cast_precision_loss)]
37fn extract_f64(col: &dyn Array, index: usize) -> Option<f64> {
38    crate::extract_f64(col, index)
39}
40
41/// Downsample a `RecordBatch` into fixed-width time buckets.
42///
43/// For each bucket `[bucket_start, bucket_start + interval)`, the
44/// aggregation function is applied to the values in the `value_column`.
45///
46/// Returns a new `RecordBatch` with two columns: `_time` (bucket start)
47/// and the aggregated value column.
48///
49/// # Arguments
50///
51/// * `batch` – input data (must have a `_time` Int64 column)
52/// * `value_column` – name of the column to aggregate
53/// * `interval` – bucket width in the same unit as timestamps (e.g. nanos)
54/// * `function` – aggregation function to apply per bucket
55///
56/// # Errors
57///
58/// Returns an error if columns are missing or have unexpected types.
59#[allow(clippy::cast_precision_loss)]
60pub fn downsample(
61    batch: &RecordBatch,
62    value_column: &str,
63    interval: i64,
64    function: &AggFn,
65    memory_tracker: Option<&crate::memory::MemoryTracker>,
66) -> Result<RecordBatch> {
67    if interval <= 0 {
68        return Err(QueryError::Validation(
69            "downsample interval must be positive".into(),
70        ));
71    }
72
73    let schema = batch.schema();
74
75    let time_idx = schema
76        .index_of(chronix_core::TIME_COLUMN)
77        .map_err(|_| QueryError::Validation("'timestamp' column not found".into()))?;
78    let val_idx = schema
79        .index_of(value_column)
80        .map_err(|_| QueryError::FieldNotFound {
81            measurement: String::new(),
82            field: value_column.to_string(),
83        })?;
84
85    let timestamps = batch
86        .column(time_idx)
87        .as_any()
88        .downcast_ref::<Int64Array>()
89        .ok_or_else(|| QueryError::Validation("'timestamp' column is not Int64".into()))?;
90
91    let value_col = batch.column(val_idx).as_ref();
92
93    // Validate the value column is a supported numeric type
94    match value_col.data_type() {
95        DataType::Float64 | DataType::Int64 | DataType::UInt64 => {}
96        dt => {
97            return Err(QueryError::Validation(format!(
98                "'{value_column}' column has unsupported type {dt} (expected Float64, Int64, or UInt64)"
99            )));
100        }
101    }
102
103    // Group indices by bucket – use a hash map for O(1) inserts,
104    // then sort the keys once at the end to restore time order.
105    let mut buckets: FnvHashMap<i64, Vec<usize>> = FnvHashMap::default();
106    for i in 0..timestamps.len() {
107        if timestamps.is_null(i) {
108            continue;
109        }
110        let ts = timestamps.value(i);
111        // Overflow-safe bucket alignment: subtracting the remainder avoids
112        // the `div_euclid * interval` multiplication that can overflow i64
113        // for extreme negative timestamps.
114        let bucket_start = ts - ts.rem_euclid(interval);
115        buckets.entry(bucket_start).or_default().push(i);
116    }
117
118    // Sort bucket keys so the output is ordered by time.
119    let mut sorted_keys: Vec<i64> = buckets.keys().copied().collect();
120    sorted_keys.sort_unstable();
121
122    // Compute aggregate per bucket
123    let mut out_times = Vec::with_capacity(buckets.len());
124    let mut out_values = Vec::with_capacity(buckets.len());
125
126    for bucket_start in &sorted_keys {
127        let indices = &buckets[bucket_start];
128        // Collect (timestamp, value) pairs for this bucket.
129        // Timestamps are needed for First/Last semantics.
130        let raw_pairs: Vec<(i64, f64)> = indices
131            .iter()
132            .filter_map(|&i| {
133                let val = extract_f64(value_col, i)?;
134                Some((timestamps.value(i), val))
135            })
136            .collect();
137
138        if raw_pairs.is_empty() {
139            continue;
140        }
141
142        let agg_value = match function {
143            // Count counts all non-null, non-NaN rows for consistency
144            // with the other aggregation functions.
145            AggFn::Count => raw_pairs.iter().filter(|(_, v)| !v.is_nan()).count() as f64,
146            _ => {
147                // For numeric aggregations, filter out NaN
148                let clean: Vec<(i64, f64)> =
149                    raw_pairs.into_iter().filter(|(_, v)| !v.is_nan()).collect();
150                if clean.is_empty() {
151                    continue;
152                }
153                match function {
154                    AggFn::Sum => clean.iter().map(|(_, v)| v).sum(),
155                    AggFn::Min => clean.iter().map(|(_, v)| *v).fold(f64::INFINITY, f64::min),
156                    AggFn::Max => clean
157                        .iter()
158                        .map(|(_, v)| *v)
159                        .fold(f64::NEG_INFINITY, f64::max),
160                    AggFn::Avg => {
161                        let sum: f64 = clean.iter().map(|(_, v)| v).sum();
162                        sum / clean.len() as f64
163                    }
164                    // First: value with the smallest timestamp in the bucket
165                    AggFn::First => clean
166                        .iter()
167                        .min_by_key(|(ts, _)| *ts)
168                        .map(|(_, v)| *v)
169                        .expect("clean is non-empty"),
170                    // Last: value with the largest timestamp in the bucket
171                    AggFn::Last => clean
172                        .iter()
173                        .max_by_key(|(ts, _)| *ts)
174                        .map(|(_, v)| *v)
175                        .expect("clean is non-empty"),
176                    AggFn::Count => {
177                        // Count is handled in the outer branch; defensive error.
178                        return Err(QueryError::Validation(
179                            "Count aggregation reached inner branch unexpectedly".into(),
180                        ));
181                    }
182                }
183            }
184        };
185
186        out_times.push(*bucket_start);
187        out_values.push(agg_value);
188    }
189
190    let out_schema = Arc::new(Schema::new(vec![
191        Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
192        Field::new(value_column, DataType::Float64, false),
193    ]));
194
195    let result = RecordBatch::try_new(
196        out_schema,
197        vec![
198            Arc::new(Int64Array::from(out_times)) as ArrayRef,
199            Arc::new(Float64Array::from(out_values)) as ArrayRef,
200        ],
201    )?;
202
203    if let Some(tracker) = memory_tracker {
204        tracker.try_allocate(result.get_array_memory_size())?;
205    }
206
207    Ok(result)
208}
209
210/// Boundary-safe downsampling over a sequence of time-ordered batches.
211///
212/// Rows must arrive ascending by timestamp — which is what the query engine's
213/// merge guarantees — so a bucket is complete as soon as a row belonging to a
214/// later bucket is seen. Buckets are emitted in order and exactly once.
215///
216/// # Example
217///
218/// ```no_run
219/// # use chronix_query::downsample::StreamingDownsampler;
220/// # use chronix_query::aggregate::AggFn;
221/// # use arrow::record_batch::RecordBatch;
222/// # fn batches() -> Vec<RecordBatch> { vec![] }
223/// let mut ds = StreamingDownsampler::new("value", 60_000_000_000, AggFn::Avg)?;
224/// let mut out = Vec::new();
225/// for batch in batches() {
226///     out.extend(ds.push(&batch)?);
227/// }
228/// out.extend(ds.finish()?);
229/// # Ok::<(), chronix_query::QueryError>(())
230/// ```
231pub struct StreamingDownsampler {
232    value_column: String,
233    interval: i64,
234    function: AggFn,
235    /// Start timestamp of the bucket currently being accumulated.
236    open_bucket: Option<i64>,
237    /// Accumulated state for the open bucket.
238    acc: BucketAcc,
239    /// Completed buckets not yet drained into an output batch.
240    pending: Vec<(i64, f64)>,
241    /// Rows per emitted batch.
242    chunk_rows: usize,
243}
244
245/// Running state for one time bucket.
246#[derive(Default)]
247struct BucketAcc {
248    count: u64,
249    sum: f64,
250    min: f64,
251    max: f64,
252    first_ts: i64,
253    first_val: f64,
254    last_ts: i64,
255    last_val: f64,
256}
257
258impl BucketAcc {
259    fn reset(&mut self) {
260        self.count = 0;
261        self.sum = 0.0;
262        self.min = f64::INFINITY;
263        self.max = f64::NEG_INFINITY;
264        self.first_ts = i64::MAX;
265        self.first_val = 0.0;
266        self.last_ts = i64::MIN;
267        self.last_val = 0.0;
268    }
269
270    fn update(&mut self, ts: i64, v: f64) {
271        // NaN is excluded, matching `downsample`'s per-bucket filtering.
272        if v.is_nan() {
273            return;
274        }
275        self.count += 1;
276        self.sum += v;
277        if v < self.min {
278            self.min = v;
279        }
280        if v > self.max {
281            self.max = v;
282        }
283        if ts < self.first_ts {
284            self.first_ts = ts;
285            self.first_val = v;
286        }
287        // `>=` so the last-encountered value wins on ties.
288        if ts >= self.last_ts {
289            self.last_ts = ts;
290            self.last_val = v;
291        }
292    }
293
294    #[allow(clippy::cast_precision_loss)]
295    fn finalize(&self, function: AggFn) -> Option<f64> {
296        if self.count == 0 {
297            // `Count` still reports a bucket that had only NaN rows as absent,
298            // matching `downsample`, which skips such buckets entirely.
299            return None;
300        }
301        Some(match function {
302            AggFn::Count => self.count as f64,
303            AggFn::Sum => self.sum,
304            AggFn::Min => self.min,
305            AggFn::Max => self.max,
306            AggFn::Avg => self.sum / self.count as f64,
307            AggFn::First => self.first_val,
308            AggFn::Last => self.last_val,
309        })
310    }
311}
312
313impl StreamingDownsampler {
314    /// Rows per emitted batch.
315    const DEFAULT_CHUNK_ROWS: usize = 65_536;
316
317    /// Create a downsampler for `value_column` over `interval`-wide buckets.
318    ///
319    /// # Errors
320    ///
321    /// Returns an error if `interval` is not positive.
322    pub fn new(value_column: &str, interval: i64, function: AggFn) -> Result<Self> {
323        if interval <= 0 {
324            return Err(QueryError::Validation(
325                "downsample interval must be positive".into(),
326            ));
327        }
328        let mut acc = BucketAcc::default();
329        acc.reset();
330        Ok(Self {
331            value_column: value_column.to_string(),
332            interval,
333            function,
334            open_bucket: None,
335            acc,
336            pending: Vec::new(),
337            chunk_rows: Self::DEFAULT_CHUNK_ROWS,
338        })
339    }
340
341    /// Fold one batch in, returning any batches of completed buckets.
342    ///
343    /// # Errors
344    ///
345    /// Returns an error if the timestamp or value column is missing or has an
346    /// unsupported type, or if a row arrives out of timestamp order.
347    pub fn push(&mut self, batch: &RecordBatch) -> Result<Vec<RecordBatch>> {
348        if batch.num_rows() == 0 {
349            return Ok(Vec::new());
350        }
351        let schema = batch.schema();
352        let time_idx = schema
353            .index_of(chronix_core::TIME_COLUMN)
354            .map_err(|_| QueryError::Validation("'timestamp' column not found".into()))?;
355        let val_idx =
356            schema
357                .index_of(&self.value_column)
358                .map_err(|_| QueryError::FieldNotFound {
359                    measurement: String::new(),
360                    field: self.value_column.clone(),
361                })?;
362
363        let timestamps = batch
364            .column(time_idx)
365            .as_any()
366            .downcast_ref::<Int64Array>()
367            .ok_or_else(|| QueryError::Validation("'timestamp' column is not Int64".into()))?;
368        let value_col = batch.column(val_idx).as_ref();
369        match value_col.data_type() {
370            DataType::Float64 | DataType::Int64 | DataType::UInt64 => {}
371            dt => {
372                return Err(QueryError::Validation(format!(
373                    "'{}' column has unsupported type {dt} (expected Float64, Int64, or UInt64)",
374                    self.value_column
375                )));
376            }
377        }
378
379        for i in 0..timestamps.len() {
380            if timestamps.is_null(i) {
381                continue;
382            }
383            let ts = timestamps.value(i);
384            // Overflow-safe bucket alignment: subtracting the remainder avoids
385            // the `div_euclid * interval` multiplication that can overflow i64
386            // for extreme negative timestamps.
387            let bucket = ts - ts.rem_euclid(self.interval);
388
389            match self.open_bucket {
390                Some(open) if bucket == open => {}
391                Some(open) if bucket < open => {
392                    // Out-of-order input would silently corrupt a closed
393                    // bucket, so say so instead of producing a wrong number.
394                    return Err(QueryError::Validation(format!(
395                        "downsample input is not ascending by timestamp: bucket {bucket} \
396                         arrived after {open} was closed"
397                    )));
398                }
399                Some(_) => self.close_open_bucket(),
400                None => {}
401            }
402            self.open_bucket = Some(bucket);
403
404            if let Some(v) = extract_f64(value_col, i) {
405                self.acc.update(ts, v);
406            }
407        }
408
409        self.drain(false)
410    }
411
412    /// Close the final bucket and return any remaining output.
413    ///
414    /// # Errors
415    ///
416    /// Returns an error if the output batch cannot be built.
417    pub fn finish(mut self) -> Result<Vec<RecordBatch>> {
418        if self.open_bucket.is_some() {
419            self.close_open_bucket();
420        }
421        self.drain(true)
422    }
423
424    fn close_open_bucket(&mut self) {
425        if let (Some(start), Some(v)) = (self.open_bucket, self.acc.finalize(self.function)) {
426            self.pending.push((start, v));
427        }
428        self.acc.reset();
429        self.open_bucket = None;
430    }
431
432    /// Turn completed buckets into output batches. Unless `flush`, a partial
433    /// chunk is kept back so emitted batches stay a uniform size.
434    fn drain(&mut self, flush: bool) -> Result<Vec<RecordBatch>> {
435        let mut out = Vec::new();
436        while self.pending.len() >= self.chunk_rows || (flush && !self.pending.is_empty()) {
437            let take = self.chunk_rows.min(self.pending.len());
438            let rows: Vec<(i64, f64)> = self.pending.drain(..take).collect();
439            // Propagate rather than skip: dropping a failed batch here would
440            // silently lose completed buckets.
441            out.push(build_batch(&self.value_column, &rows)?);
442        }
443        Ok(out)
444    }
445}
446
447/// Build the two-column output batch for a run of completed buckets.
448fn build_batch(value_column: &str, rows: &[(i64, f64)]) -> Result<RecordBatch> {
449    let schema = Arc::new(Schema::new(vec![
450        Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
451        Field::new(value_column, DataType::Float64, false),
452    ]));
453    let times: Vec<i64> = rows.iter().map(|(t, _)| *t).collect();
454    let values: Vec<f64> = rows.iter().map(|(_, v)| *v).collect();
455    RecordBatch::try_new(
456        schema,
457        vec![
458            Arc::new(Int64Array::from(times)) as ArrayRef,
459            Arc::new(Float64Array::from(values)) as ArrayRef,
460        ],
461    )
462    .map_err(Into::into)
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use arrow::array::UInt64Array;
469
470    fn make_batch(times: Vec<i64>, values: Vec<f64>) -> RecordBatch {
471        let schema = Arc::new(Schema::new(vec![
472            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
473            Field::new("value", DataType::Float64, false),
474        ]));
475
476        RecordBatch::try_new(
477            schema,
478            vec![
479                Arc::new(Int64Array::from(times)),
480                Arc::new(Float64Array::from(values)),
481            ],
482        )
483        .unwrap()
484    }
485
486    #[test]
487    fn downsample_avg_10s_buckets() {
488        let batch = make_batch(
489            vec![0, 5, 10, 15, 20, 25],
490            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
491        );
492
493        let result = downsample(&batch, "value", 10, &AggFn::Avg, None).unwrap();
494        assert_eq!(result.num_rows(), 3);
495
496        let times = result
497            .column(0)
498            .as_any()
499            .downcast_ref::<Int64Array>()
500            .unwrap();
501        let values = result
502            .column(1)
503            .as_any()
504            .downcast_ref::<Float64Array>()
505            .unwrap();
506
507        // Bucket [0, 10): ts=0, 5 → avg 1.5
508        assert_eq!(times.value(0), 0);
509        assert!((values.value(0) - 1.5).abs() < f64::EPSILON);
510
511        // Bucket [10, 20): ts=10, 15 → avg 3.5
512        assert_eq!(times.value(1), 10);
513        assert!((values.value(1) - 3.5).abs() < f64::EPSILON);
514
515        // Bucket [20, 30): ts=20, 25 → avg 5.5
516        assert_eq!(times.value(2), 20);
517        assert!((values.value(2) - 5.5).abs() < f64::EPSILON);
518    }
519
520    #[test]
521    fn downsample_sum() {
522        let batch = make_batch(vec![0, 5, 10, 15], vec![10.0, 20.0, 30.0, 40.0]);
523
524        let result = downsample(&batch, "value", 10, &AggFn::Sum, None).unwrap();
525        assert_eq!(result.num_rows(), 2);
526
527        let values = result
528            .column(1)
529            .as_any()
530            .downcast_ref::<Float64Array>()
531            .unwrap();
532
533        assert!((values.value(0) - 30.0).abs() < f64::EPSILON);
534        assert!((values.value(1) - 70.0).abs() < f64::EPSILON);
535    }
536
537    #[test]
538    fn downsample_count() {
539        let batch = make_batch(vec![0, 1, 2, 10, 20, 21, 22, 23], vec![1.0; 8]);
540
541        let result = downsample(&batch, "value", 10, &AggFn::Count, None).unwrap();
542        assert_eq!(result.num_rows(), 3);
543
544        let values = result
545            .column(1)
546            .as_any()
547            .downcast_ref::<Float64Array>()
548            .unwrap();
549
550        assert!((values.value(0) - 3.0).abs() < f64::EPSILON); // [0,10): 3 points
551        assert!((values.value(1) - 1.0).abs() < f64::EPSILON); // [10,20): 1 point
552        assert!((values.value(2) - 4.0).abs() < f64::EPSILON); // [20,30): 4 points
553    }
554
555    #[test]
556    fn downsample_min_max() {
557        let batch = make_batch(vec![0, 5, 10, 15], vec![3.0, 1.0, 4.0, 2.0]);
558
559        let min_result = downsample(&batch, "value", 10, &AggFn::Min, None).unwrap();
560        let max_result = downsample(&batch, "value", 10, &AggFn::Max, None).unwrap();
561
562        let min_vals = min_result
563            .column(1)
564            .as_any()
565            .downcast_ref::<Float64Array>()
566            .unwrap();
567        let max_vals = max_result
568            .column(1)
569            .as_any()
570            .downcast_ref::<Float64Array>()
571            .unwrap();
572
573        assert!((min_vals.value(0) - 1.0).abs() < f64::EPSILON); // min(3, 1)
574        assert!((min_vals.value(1) - 2.0).abs() < f64::EPSILON); // min(4, 2)
575        assert!((max_vals.value(0) - 3.0).abs() < f64::EPSILON); // max(3, 1)
576        assert!((max_vals.value(1) - 4.0).abs() < f64::EPSILON); // max(4, 2)
577    }
578
579    #[test]
580    fn downsample_first_last() {
581        let batch = make_batch(vec![0, 5, 10, 15], vec![10.0, 20.0, 30.0, 40.0]);
582
583        let first = downsample(&batch, "value", 10, &AggFn::First, None).unwrap();
584        let last = downsample(&batch, "value", 10, &AggFn::Last, None).unwrap();
585
586        let first_vals = first
587            .column(1)
588            .as_any()
589            .downcast_ref::<Float64Array>()
590            .unwrap();
591        let last_vals = last
592            .column(1)
593            .as_any()
594            .downcast_ref::<Float64Array>()
595            .unwrap();
596
597        assert!((first_vals.value(0) - 10.0).abs() < f64::EPSILON);
598        assert!((first_vals.value(1) - 30.0).abs() < f64::EPSILON);
599        assert!((last_vals.value(0) - 20.0).abs() < f64::EPSILON);
600        assert!((last_vals.value(1) - 40.0).abs() < f64::EPSILON);
601    }
602
603    #[test]
604    fn zero_interval_fails() {
605        let batch = make_batch(vec![0], vec![1.0]);
606        assert!(downsample(&batch, "value", 0, &AggFn::Avg, None).is_err());
607    }
608
609    #[test]
610    fn empty_batch() {
611        let batch = make_batch(vec![], vec![]);
612        let result = downsample(&batch, "value", 10, &AggFn::Avg, None).unwrap();
613        assert_eq!(result.num_rows(), 0);
614    }
615
616    #[test]
617    fn downsample_i64_column() {
618        let schema = Arc::new(Schema::new(vec![
619            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
620            Field::new("value", DataType::Int64, false),
621        ]));
622        let batch = RecordBatch::try_new(
623            schema,
624            vec![
625                Arc::new(Int64Array::from(vec![0, 5, 10, 15])) as ArrayRef,
626                Arc::new(Int64Array::from(vec![10, 20, 30, 40])) as ArrayRef,
627            ],
628        )
629        .unwrap();
630
631        let result = downsample(&batch, "value", 10, &AggFn::Sum, None).unwrap();
632        assert_eq!(result.num_rows(), 2);
633
634        let values = result
635            .column(1)
636            .as_any()
637            .downcast_ref::<Float64Array>()
638            .unwrap();
639        assert!((values.value(0) - 30.0).abs() < f64::EPSILON);
640        assert!((values.value(1) - 70.0).abs() < f64::EPSILON);
641    }
642
643    #[test]
644    fn downsample_u64_column() {
645        let schema = Arc::new(Schema::new(vec![
646            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
647            Field::new("value", DataType::UInt64, false),
648        ]));
649        let batch = RecordBatch::try_new(
650            schema,
651            vec![
652                Arc::new(Int64Array::from(vec![0, 5, 10, 15])) as ArrayRef,
653                Arc::new(UInt64Array::from(vec![100u64, 200, 300, 400])) as ArrayRef,
654            ],
655        )
656        .unwrap();
657
658        let result = downsample(&batch, "value", 10, &AggFn::Avg, None).unwrap();
659        assert_eq!(result.num_rows(), 2);
660
661        let values = result
662            .column(1)
663            .as_any()
664            .downcast_ref::<Float64Array>()
665            .unwrap();
666        assert!((values.value(0) - 150.0).abs() < f64::EPSILON);
667        assert!((values.value(1) - 350.0).abs() < f64::EPSILON);
668    }
669
670    #[test]
671    fn downsample_unsupported_type_fails() {
672        let schema = Arc::new(Schema::new(vec![
673            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
674            Field::new("value", DataType::Boolean, false),
675        ]));
676        let batch = RecordBatch::try_new(
677            schema,
678            vec![
679                Arc::new(Int64Array::from(vec![0i64])) as ArrayRef,
680                Arc::new(arrow::array::BooleanArray::from(vec![true])) as ArrayRef,
681            ],
682        )
683        .unwrap();
684
685        let err = downsample(&batch, "value", 10, &AggFn::Avg, None).unwrap_err();
686        let msg = format!("{err}");
687        assert!(
688            msg.contains("unsupported"),
689            "expected unsupported type error, got: {msg}"
690        );
691    }
692
693    #[test]
694    fn downsample_respects_memory_tracker() {
695        let batch = make_batch(vec![0, 5, 10, 15], vec![1.0, 2.0, 3.0, 4.0]);
696        let tracker = crate::memory::MemoryTracker::new(1_000_000);
697        let result = downsample(&batch, "value", 10, &AggFn::Avg, Some(&tracker)).unwrap();
698        assert!(tracker.allocated() > 0);
699        assert_eq!(result.num_rows(), 2);
700    }
701
702    #[test]
703    fn downsample_exceeds_memory_budget() {
704        let batch = make_batch(vec![0, 5, 10, 15], vec![1.0, 2.0, 3.0, 4.0]);
705        let tracker = crate::memory::MemoryTracker::new(1);
706        let err = downsample(&batch, "value", 10, &AggFn::Avg, Some(&tracker)).unwrap_err();
707        assert!(matches!(
708            err,
709            crate::error::QueryError::QueryMemoryExceeded { .. }
710        ));
711    }
712}