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 => {}
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 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 let bucket_start = ts - ts.rem_euclid(interval);
115 buckets.entry(bucket_start).or_default().push(i);
116 }
117
118 let mut sorted_keys: Vec<i64> = buckets.keys().copied().collect();
120 sorted_keys.sort_unstable();
121
122 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 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 AggFn::Count => raw_pairs.iter().filter(|(_, v)| !v.is_nan()).count() as f64,
146 _ => {
147 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 AggFn::First => clean
166 .iter()
167 .min_by_key(|(ts, _)| *ts)
168 .map(|(_, v)| *v)
169 .expect("clean is non-empty"),
170 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 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
210pub struct StreamingDownsampler {
232 value_column: String,
233 interval: i64,
234 function: AggFn,
235 open_bucket: Option<i64>,
237 acc: BucketAcc,
239 pending: Vec<(i64, f64)>,
241 chunk_rows: usize,
243}
244
245#[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 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 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 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 const DEFAULT_CHUNK_ROWS: usize = 65_536;
316
317 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 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 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 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 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 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 out.push(build_batch(&self.value_column, &rows)?);
442 }
443 Ok(out)
444 }
445}
446
447fn 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 assert_eq!(times.value(0), 0);
509 assert!((values.value(0) - 1.5).abs() < f64::EPSILON);
510
511 assert_eq!(times.value(1), 10);
513 assert!((values.value(1) - 3.5).abs() < f64::EPSILON);
514
515 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); assert!((values.value(1) - 1.0).abs() < f64::EPSILON); assert!((values.value(2) - 4.0).abs() < f64::EPSILON); }
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); 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); }
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}