1use 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#[allow(clippy::cast_precision_loss)]
37fn extract_f64(col: &dyn Array, index: usize) -> Option<f64> {
38 crate::extract_f64(col, index)
39}
40
41#[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 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 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 let bucket_start = ts - ts.rem_euclid(interval);
116 buckets.entry(bucket_start).or_default().push(i);
117 }
118
119 let mut sorted_keys: Vec<i64> = buckets.keys().copied().collect();
121 sorted_keys.sort_unstable();
122
123 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 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 AggFn::Count => raw_pairs.iter().filter(|(_, v)| !v.is_nan()).count() as f64,
147 _ => {
148 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 AggFn::First => clean
167 .iter()
168 .min_by_key(|(ts, _)| *ts)
169 .map(|(_, v)| *v)
170 .expect("clean is non-empty"),
171 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 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
211pub struct StreamingDownsampler {
233 value_column: String,
234 interval: i64,
235 function: AggFn,
236 open_bucket: Option<i64>,
238 acc: BucketAcc,
240 pending: Vec<(i64, f64)>,
242 chunk_rows: usize,
244}
245
246#[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 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 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 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 const DEFAULT_CHUNK_ROWS: usize = 65_536;
317
318 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 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 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 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 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 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 out.push(build_batch(&self.value_column, &rows)?);
445 }
446 Ok(out)
447 }
448}
449
450fn 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 assert_eq!(times.value(0), 0);
512 assert!((values.value(0) - 1.5).abs() < f64::EPSILON);
513
514 assert_eq!(times.value(1), 10);
516 assert!((values.value(1) - 3.5).abs() < f64::EPSILON);
517
518 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); assert!((values.value(1) - 1.0).abs() < f64::EPSILON); assert!((values.value(2) - 4.0).abs() < f64::EPSILON); }
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); assert!((min_vals.value(1) - 2.0).abs() < f64::EPSILON); assert!((max_vals.value(0) - 3.0).abs() < f64::EPSILON); assert!((max_vals.value(1) - 4.0).abs() < f64::EPSILON); }
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}