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