1use std::cmp::Ordering;
44use std::collections::BinaryHeap;
45use std::sync::Arc;
46
47use arrow::array::{
48 new_null_array, Array, ArrayRef, Int64Array, StringArray, UInt32Array, UInt64Array,
49};
50use arrow::compute;
51use arrow::datatypes::{DataType, Field, Schema};
52use arrow::record_batch::RecordBatch;
53use fnv::FnvHasher;
54use std::hash::Hasher;
55
56use crate::error::{QueryError, Result};
57
58fn build_union_schema(batches: &[RecordBatch]) -> Arc<Schema> {
61 let mut fields: Vec<Field> = Vec::new();
62 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
63
64 for batch in batches {
65 for field in batch.schema().fields() {
66 if seen.insert(field.name().clone()) {
67 let f = if batches.len() > 1 {
72 Field::new(field.name(), field.data_type().clone(), true)
73 .with_metadata(field.metadata().clone())
74 } else {
75 field.as_ref().clone()
76 };
77 fields.push(f);
78 }
79 }
80 }
81
82 Arc::new(Schema::new(fields))
83}
84
85fn align_batch(batch: &RecordBatch, target: &Arc<Schema>) -> Result<RecordBatch> {
91 let cols: Vec<ArrayRef> = target
92 .fields()
93 .iter()
94 .map(|field| {
95 match batch.schema().index_of(field.name()) {
96 Ok(idx) => Arc::clone(batch.column(idx)),
97 Err(_) => {
98 new_null_array(field.data_type(), batch.num_rows())
102 }
103 }
104 })
105 .collect();
106
107 RecordBatch::try_new(target.clone(), cols).map_err(QueryError::Arrow)
108}
109
110pub fn sort_merge_dedup(
138 batches: Vec<RecordBatch>,
139 measurement: &str,
140 memory_tracker: Option<&crate::memory::MemoryTracker>,
141) -> Result<RecordBatch> {
142 if batches.is_empty() {
143 return Err(QueryError::Validation("no batches to deduplicate".into()));
144 }
145
146 if batches.len() == 1 {
148 return batches
149 .into_iter()
150 .next()
151 .ok_or_else(|| QueryError::Validation("expected single batch".into()));
152 }
153
154 let (schema, plan) = plan_merge(batches, measurement, memory_tracker)?;
155 let Some(plan) = plan else {
156 return Ok(RecordBatch::new_empty(schema));
157 };
158
159 Ok(merge_chunks(&plan, usize::MAX, memory_tracker)?
161 .pop()
162 .unwrap_or_else(|| RecordBatch::new_empty(schema)))
163}
164
165pub fn sort_merge_dedup_chunked(
180 batches: Vec<RecordBatch>,
181 measurement: &str,
182 chunk_size: usize,
183 memory_tracker: Option<&crate::memory::MemoryTracker>,
184) -> Result<Vec<RecordBatch>> {
185 if batches.is_empty() {
186 return Err(QueryError::Validation("no batches to deduplicate".into()));
187 }
188
189 if batches.len() == 1 {
198 let batch = batches
199 .into_iter()
200 .next()
201 .ok_or_else(|| QueryError::Validation("expected single batch".into()))?;
202 if batch.num_rows() == 0 {
203 return Ok(vec![]);
204 }
205 let sorted = sort_batch_by_timestamp(&batch)?;
206 return Ok(chunk_batch(&sorted, chunk_size));
207 }
208
209 let (_schema, plan) = plan_merge(batches, measurement, memory_tracker)?;
210 let Some(plan) = plan else {
211 return Ok(vec![]);
212 };
213 merge_chunks(&plan, chunk_size, memory_tracker)
214}
215
216fn sort_batch_by_timestamp(batch: &RecordBatch) -> Result<RecordBatch> {
222 let Some(ts) = batch
223 .column_by_name(chronix_core::TIME_COLUMN)
224 .and_then(|c| c.as_any().downcast_ref::<Int64Array>())
225 else {
226 return Ok(batch.clone());
229 };
230 if ts.values().windows(2).all(|w| w[0] <= w[1]) {
231 return Ok(batch.clone());
232 }
233 let mut order: Vec<u32> = (0..batch.num_rows() as u32).collect();
234 order.sort_by_key(|&i| (ts.value(i as usize), i));
235 let indices = arrow::array::UInt32Array::from(order);
236 let columns = batch
237 .columns()
238 .iter()
239 .map(|c| arrow::compute::take(c.as_ref(), &indices, None))
240 .collect::<std::result::Result<Vec<_>, _>>()?;
241 Ok(RecordBatch::try_new(batch.schema(), columns)?)
242}
243
244struct MergeCursor {
246 batch_idx: usize,
248 timestamps: Vec<i64>,
250 series_hashes: Vec<u64>,
252 group_ids: Vec<u32>,
254 num_rows: usize,
256}
257
258#[derive(Eq, PartialEq)]
263struct HeapEntry {
264 timestamp: i64,
265 series_hash: u64,
266 group_id: u32,
267 batch_idx: usize,
269 cursor_idx: usize,
271 row: usize,
273}
274
275impl Ord for HeapEntry {
276 fn cmp(&self, other: &Self) -> Ordering {
277 let self_key = (
283 self.timestamp,
284 self.series_hash,
285 self.group_id,
286 self.batch_idx,
287 );
288 let other_key = (
289 other.timestamp,
290 other.series_hash,
291 other.group_id,
292 other.batch_idx,
293 );
294 other_key.cmp(&self_key)
295 }
296}
297
298impl PartialOrd for HeapEntry {
299 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
300 Some(self.cmp(other))
301 }
302}
303
304struct MergePlan {
307 schema: Arc<Schema>,
308 cursors: Vec<MergeCursor>,
309 sorted_batches: Vec<RecordBatch>,
310}
311
312fn plan_merge(
317 batches: Vec<RecordBatch>,
318 measurement: &str,
319 memory_tracker: Option<&crate::memory::MemoryTracker>,
320) -> Result<(Arc<Schema>, Option<MergePlan>)> {
321 let schema = build_union_schema(&batches);
322
323 if let Some(tracker) = memory_tracker {
326 let total_rows: usize = batches
327 .iter()
328 .map(arrow::array::RecordBatch::num_rows)
329 .sum();
330 let estimated_row_bytes = schema.fields().len() * 8 + 16; tracker.try_allocate(total_rows * estimated_row_bytes)?;
332 }
333
334 let aligned: Vec<(usize, RecordBatch)> = batches
339 .into_iter()
340 .map(|batch| {
341 if batch.schema() == schema {
342 Ok(batch)
343 } else {
344 align_batch(&batch, &schema)
345 }
346 })
347 .collect::<Result<Vec<_>>>()?
348 .into_iter()
349 .enumerate()
350 .filter(|(_, b)| b.num_rows() > 0)
351 .collect();
352
353 if aligned.is_empty() {
354 return Ok((schema, None));
355 }
356
357 let time_idx = schema
358 .index_of(chronix_core::TIME_COLUMN)
359 .map_err(|_| QueryError::Validation("'timestamp' column not found".into()))?;
360 let tag_columns = resolve_tag_columns(&schema);
361
362 let mut group_map: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
366 let mut next_group_id: u32 = 0;
367 let mut cursors: Vec<MergeCursor> = Vec::with_capacity(aligned.len());
368 let mut sorted_batches: Vec<RecordBatch> = Vec::with_capacity(aligned.len());
369
370 for (batch_idx, batch) in &aligned {
371 let hashes = compute_series_hashes(batch, measurement, &tag_columns);
372 let group_ids = compute_series_group_ids_into(
373 batch,
374 measurement,
375 &tag_columns,
376 &mut group_map,
377 &mut next_group_id,
378 );
379
380 let hash_array: UInt64Array = hashes.iter().copied().collect();
384 let gid_array: UInt32Array = group_ids.iter().copied().collect();
385 let ascending = Some(compute::SortOptions {
386 descending: false,
387 nulls_first: false,
388 });
389 let sort_columns = vec![
390 compute::SortColumn {
391 values: Arc::clone(batch.column(time_idx)),
392 options: ascending,
393 },
394 compute::SortColumn {
395 values: Arc::new(hash_array) as ArrayRef,
396 options: ascending,
397 },
398 compute::SortColumn {
399 values: Arc::new(gid_array) as ArrayRef,
400 options: ascending,
401 },
402 ];
403
404 let sort_indices = compute::lexsort_to_indices(&sort_columns, None)?;
405 let indices_values = sort_indices.values();
406
407 let sorted_cols: std::result::Result<Vec<ArrayRef>, _> = batch
408 .columns()
409 .iter()
410 .map(|col| compute::take(col.as_ref(), &sort_indices, None))
411 .collect();
412 let sorted_batch = RecordBatch::try_new(schema.clone(), sorted_cols?)?;
413
414 let sorted_timestamps: Vec<i64> = {
415 let ts = sorted_batch
416 .column(time_idx)
417 .as_any()
418 .downcast_ref::<Int64Array>()
419 .ok_or_else(|| QueryError::Validation("timestamp column must be Int64".into()))?;
420 ts.values().to_vec()
421 };
422 let sorted_hashes: Vec<u64> = indices_values.iter().map(|&i| hashes[i as usize]).collect();
423 let sorted_gids: Vec<u32> = indices_values
424 .iter()
425 .map(|&i| group_ids[i as usize])
426 .collect();
427
428 cursors.push(MergeCursor {
429 batch_idx: *batch_idx,
430 timestamps: sorted_timestamps,
431 series_hashes: sorted_hashes,
432 group_ids: sorted_gids,
433 num_rows: sorted_batch.num_rows(),
434 });
435 sorted_batches.push(sorted_batch);
436 }
437
438 Ok((
439 schema.clone(),
440 Some(MergePlan {
441 schema,
442 cursors,
443 sorted_batches,
444 }),
445 ))
446}
447
448fn merge_chunks(
458 plan: &MergePlan,
459 chunk_size: usize,
460 memory_tracker: Option<&crate::memory::MemoryTracker>,
461) -> Result<Vec<RecordBatch>> {
462 let MergePlan {
463 schema,
464 cursors,
465 sorted_batches,
466 } = plan;
467
468 let mut heap = BinaryHeap::with_capacity(cursors.len());
469 for (ci, cursor) in cursors.iter().enumerate() {
470 if cursor.num_rows > 0 {
471 heap.push(HeapEntry {
472 timestamp: cursor.timestamps[0],
473 series_hash: cursor.series_hashes[0],
474 group_id: cursor.group_ids[0],
475 batch_idx: cursor.batch_idx,
476 cursor_idx: ci,
477 row: 0,
478 });
479 }
480 }
481
482 let mut cursor_pos: Vec<usize> = vec![0; cursors.len()];
485 let mut chunks: Vec<RecordBatch> = Vec::new();
486 let mut kept: Vec<(usize, usize)> = Vec::with_capacity(chunk_size.min(65_536));
487
488 macro_rules! advance {
490 ($ci:expr) => {{
491 let ci = $ci;
492 cursor_pos[ci] += 1;
493 let nr = cursor_pos[ci];
494 if nr < cursors[ci].num_rows {
495 heap.push(HeapEntry {
496 timestamp: cursors[ci].timestamps[nr],
497 series_hash: cursors[ci].series_hashes[nr],
498 group_id: cursors[ci].group_ids[nr],
499 batch_idx: cursors[ci].batch_idx,
500 cursor_idx: ci,
501 row: nr,
502 });
503 }
504 }};
505 }
506
507 while let Some(entry) = heap.pop() {
508 let dedup_key = (entry.timestamp, entry.group_id);
509 advance!(entry.cursor_idx);
510
511 let mut best_cursor = entry.cursor_idx;
516 let mut best_row = entry.row;
517 let mut best_batch = entry.batch_idx;
518
519 while let Some(next) = heap.peek() {
520 if (next.timestamp, next.group_id) != dedup_key {
521 break;
522 }
523 let next = heap
524 .pop()
525 .ok_or_else(|| QueryError::Validation("heap peek and pop disagreed".into()))?;
526 advance!(next.cursor_idx);
527
528 if next.batch_idx > best_batch || (next.batch_idx == best_batch && next.row > best_row)
531 {
532 best_cursor = next.cursor_idx;
533 best_row = next.row;
534 best_batch = next.batch_idx;
535 }
536 }
537
538 kept.push((best_cursor, best_row));
539
540 if kept.len() >= chunk_size {
541 chunks.push(materialize_chunk(
542 &kept,
543 sorted_batches,
544 schema,
545 memory_tracker,
546 )?);
547 kept.clear();
548 }
549 }
550
551 if !kept.is_empty() {
552 chunks.push(materialize_chunk(
553 &kept,
554 sorted_batches,
555 schema,
556 memory_tracker,
557 )?);
558 }
559
560 Ok(chunks)
561}
562
563fn materialize_chunk(
565 pairs: &[(usize, usize)],
566 sorted_batches: &[RecordBatch],
567 schema: &Arc<Schema>,
568 memory_tracker: Option<&crate::memory::MemoryTracker>,
569) -> Result<RecordBatch> {
570 let mut result_columns: Vec<ArrayRef> = Vec::with_capacity(schema.fields().len());
571 for col_idx in 0..schema.fields().len() {
572 let col_arrays: Vec<&dyn Array> = sorted_batches
573 .iter()
574 .map(|b| b.column(col_idx).as_ref())
575 .collect();
576 result_columns.push(compute::interleave(&col_arrays, pairs)?);
577 }
578
579 let batch = RecordBatch::try_new(schema.clone(), result_columns).map_err(QueryError::Arrow)?;
580 if let Some(tracker) = memory_tracker {
581 tracker.try_allocate(batch.get_array_memory_size())?;
582 }
583 Ok(batch)
584}
585
586fn chunk_batch(batch: &RecordBatch, chunk_size: usize) -> Vec<RecordBatch> {
588 let total = batch.num_rows();
589 if total <= chunk_size {
590 return vec![batch.clone()];
591 }
592 let mut chunks = Vec::with_capacity(total.div_ceil(chunk_size));
593 let mut offset = 0;
594 while offset < total {
595 let len = (total - offset).min(chunk_size);
596 chunks.push(batch.slice(offset, len));
597 offset += len;
598 }
599 chunks
600}
601
602fn resolve_tag_columns(schema: &Schema) -> Vec<(usize, String)> {
609 let explicit: Vec<(usize, String)> = schema
611 .fields()
612 .iter()
613 .enumerate()
614 .filter(|(_, f)| f.metadata().get("role").map(std::string::String::as_str) == Some("tag"))
615 .map(|(i, f)| (i, f.name().clone()))
616 .collect();
617 if !explicit.is_empty() {
618 return explicit;
619 }
620 schema
622 .fields()
623 .iter()
624 .enumerate()
625 .filter(|(_, f)| f.data_type() == &DataType::Utf8 && f.name() != chronix_core::TIME_COLUMN)
626 .map(|(i, f)| (i, f.name().clone()))
627 .collect()
628}
629
630fn compute_series_hashes(
635 batch: &RecordBatch,
636 measurement: &str,
637 tag_columns: &[(usize, String)],
638) -> Vec<u64> {
639 let num_rows = batch.num_rows();
640 let mut hashes = Vec::with_capacity(num_rows);
641
642 let mut sorted_tags: Vec<(usize, &str)> = tag_columns
644 .iter()
645 .map(|(idx, name)| (*idx, name.as_str()))
646 .collect();
647 sorted_tags.sort_unstable_by_key(|&(_, name)| name);
648
649 let tag_arrays: Vec<Option<&StringArray>> = sorted_tags
651 .iter()
652 .map(|&(col_idx, _)| batch.column(col_idx).as_any().downcast_ref::<StringArray>())
653 .collect();
654
655 for row in 0..num_rows {
656 let mut hasher = FnvHasher::default();
657 hasher.write(measurement.as_bytes());
658
659 for (tag_idx, &(_, tag_name)) in sorted_tags.iter().enumerate() {
660 if let Some(arr) = tag_arrays[tag_idx] {
661 if !arr.is_null(row) {
662 hasher.write_u8(0);
663 hasher.write(tag_name.as_bytes());
664 hasher.write_u8(b'=');
665 hasher.write(arr.value(row).as_bytes());
666 }
667 }
668 }
669
670 hashes.push(hasher.finish());
671 }
672
673 hashes
674}
675
676fn compute_series_group_ids_into(
688 batch: &RecordBatch,
689 measurement: &str,
690 tag_columns: &[(usize, String)],
691 group_map: &mut std::collections::HashMap<String, u32>,
692 next_id: &mut u32,
693) -> Vec<u32> {
694 let num_rows = batch.num_rows();
695 let mut group_ids = Vec::with_capacity(num_rows);
696
697 let mut sorted_tags: Vec<(usize, &str)> = tag_columns
699 .iter()
700 .map(|(idx, name)| (*idx, name.as_str()))
701 .collect();
702 sorted_tags.sort_unstable_by_key(|&(_, name)| name);
703
704 let tag_arrays: Vec<Option<&StringArray>> = sorted_tags
706 .iter()
707 .map(|&(col_idx, _)| batch.column(col_idx).as_any().downcast_ref::<StringArray>())
708 .collect();
709
710 let mut scratch = String::new();
711
712 for row in 0..num_rows {
713 scratch.clear();
714 chronix_core::push_canonical(
717 &mut scratch,
718 measurement,
719 sorted_tags
720 .iter()
721 .enumerate()
722 .filter_map(|(tag_idx, &(_, tag_name))| {
723 let arr = tag_arrays[tag_idx]?;
724 (!arr.is_null(row)).then(|| (tag_name, arr.value(row)))
725 }),
726 );
727
728 let id = if let Some(&id) = group_map.get(&*scratch) {
729 id
730 } else {
731 let id = *next_id;
732 *next_id += 1;
733 group_map.insert(scratch.clone(), id);
734 id
735 };
736 group_ids.push(id);
737 }
738
739 group_ids
740}
741
742#[cfg(test)]
743mod tests {
744 use super::*;
745 use arrow::array::Float64Array;
746 use arrow::datatypes::{DataType, Field, Schema};
747
748 fn make_batch(times: Vec<i64>, values: Vec<f64>) -> RecordBatch {
749 let schema = Arc::new(Schema::new(vec![
750 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
751 Field::new("value", DataType::Float64, false),
752 ]));
753
754 RecordBatch::try_new(
755 schema,
756 vec![
757 Arc::new(Int64Array::from(times)),
758 Arc::new(Float64Array::from(values)),
759 ],
760 )
761 .unwrap()
762 }
763
764 #[test]
765 fn single_batch_passthrough() {
766 let batch = make_batch(vec![100, 200, 300], vec![1.0, 2.0, 3.0]);
767 let result = sort_merge_dedup(vec![batch.clone()], "cpu", None).unwrap();
768 assert_eq!(result.num_rows(), 3);
769 }
770
771 #[test]
772 fn merge_no_overlap() {
773 let b1 = make_batch(vec![100, 200], vec![1.0, 2.0]);
774 let b2 = make_batch(vec![300, 400], vec![3.0, 4.0]);
775
776 let result = sort_merge_dedup(vec![b1, b2], "cpu", None).unwrap();
777 assert_eq!(result.num_rows(), 4);
778
779 let times = result
780 .column(0)
781 .as_any()
782 .downcast_ref::<Int64Array>()
783 .unwrap();
784 assert_eq!(times.values(), &[100, 200, 300, 400]);
785 }
786
787 #[test]
788 fn dedup_overlapping_last_write_wins() {
789 let b1 = make_batch(vec![100, 200, 300], vec![1.0, 2.0, 3.0]);
791 let b2 = make_batch(vec![200, 300, 400], vec![22.0, 33.0, 44.0]);
792
793 let result = sort_merge_dedup(vec![b1, b2], "cpu", None).unwrap();
795 assert_eq!(result.num_rows(), 4); let times = result
798 .column(0)
799 .as_any()
800 .downcast_ref::<Int64Array>()
801 .unwrap();
802 let values = result
803 .column(1)
804 .as_any()
805 .downcast_ref::<Float64Array>()
806 .unwrap();
807
808 assert_eq!(times.values(), &[100, 200, 300, 400]);
809 assert!((values.value(0) - 1.0).abs() < f64::EPSILON); assert!((values.value(1) - 22.0).abs() < f64::EPSILON); assert!((values.value(2) - 33.0).abs() < f64::EPSILON); assert!((values.value(3) - 44.0).abs() < f64::EPSILON); }
815
816 #[test]
817 fn empty_batches_error() {
818 let result = sort_merge_dedup(vec![], "cpu", None);
819 assert!(result.is_err());
820 }
821
822 #[test]
823 fn schema_evolution_missing_column_filled_with_nulls() {
824 use arrow::array::StringArray;
825
826 let b1 = make_batch(vec![100, 200], vec![1.0, 2.0]);
828
829 let schema2 = Arc::new(Schema::new(vec![
831 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
832 Field::new("value", DataType::Float64, false),
833 Field::new("extra_field", DataType::Utf8, true),
834 ]));
835 let b2 = RecordBatch::try_new(
836 schema2,
837 vec![
838 Arc::new(Int64Array::from(vec![300, 400])),
839 Arc::new(Float64Array::from(vec![3.0, 4.0])),
840 Arc::new(StringArray::from(vec![Some("hello"), Some("world")])),
841 ],
842 )
843 .unwrap();
844
845 let result = sort_merge_dedup(vec![b1, b2], "cpu", None).unwrap();
846 assert_eq!(result.num_columns(), 3);
848 assert_eq!(result.num_rows(), 4);
849
850 let times = result
851 .column(0)
852 .as_any()
853 .downcast_ref::<Int64Array>()
854 .unwrap();
855 assert_eq!(times.values(), &[100, 200, 300, 400]);
856 }
857
858 #[test]
859 fn different_column_order_aligned() {
860 let schema1 = Arc::new(Schema::new(vec![
862 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
863 Field::new("host", DataType::Utf8, true),
864 Field::new("region", DataType::Utf8, true),
865 Field::new("value", DataType::Float64, false),
866 ]));
867 let b1 = RecordBatch::try_new(
868 schema1,
869 vec![
870 Arc::new(Int64Array::from(vec![100])),
871 Arc::new(arrow::array::StringArray::from(vec!["srv1"])),
872 Arc::new(arrow::array::StringArray::from(vec!["us-east"])),
873 Arc::new(Float64Array::from(vec![1.0])),
874 ],
875 )
876 .unwrap();
877
878 let schema2 = Arc::new(Schema::new(vec![
880 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
881 Field::new("region", DataType::Utf8, true),
882 Field::new("host", DataType::Utf8, true),
883 Field::new("value", DataType::Float64, false),
884 ]));
885 let b2 = RecordBatch::try_new(
886 schema2,
887 vec![
888 Arc::new(Int64Array::from(vec![200])),
889 Arc::new(arrow::array::StringArray::from(vec!["eu-west"])),
890 Arc::new(arrow::array::StringArray::from(vec!["srv2"])),
891 Arc::new(Float64Array::from(vec![2.0])),
892 ],
893 )
894 .unwrap();
895
896 let result = sort_merge_dedup(vec![b1, b2], "cpu", None).unwrap();
897 assert_eq!(result.num_rows(), 2);
898 assert_eq!(result.num_columns(), 4);
899
900 assert_eq!(result.schema().field(1).name(), "host");
902 assert_eq!(result.schema().field(2).name(), "region");
903
904 let hosts = result
905 .column(1)
906 .as_any()
907 .downcast_ref::<arrow::array::StringArray>()
908 .unwrap();
909 assert_eq!(hosts.value(0), "srv1");
910 assert_eq!(hosts.value(1), "srv2");
911 }
912
913 #[test]
914 fn multi_series_same_timestamp_preserved() {
915 let schema = Arc::new(Schema::new(vec![
918 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
919 Field::new("host", DataType::Utf8, true)
920 .with_metadata([("role".into(), "tag".into())].into()),
921 Field::new("value", DataType::Float64, false),
922 ]));
923
924 let b1 = RecordBatch::try_new(
925 schema.clone(),
926 vec![
927 Arc::new(Int64Array::from(vec![100, 200])),
928 Arc::new(StringArray::from(vec!["srv1", "srv1"])),
929 Arc::new(Float64Array::from(vec![1.0, 2.0])),
930 ],
931 )
932 .unwrap();
933
934 let b2 = RecordBatch::try_new(
935 schema,
936 vec![
937 Arc::new(Int64Array::from(vec![100, 200])),
938 Arc::new(StringArray::from(vec!["srv2", "srv2"])),
939 Arc::new(Float64Array::from(vec![10.0, 20.0])),
940 ],
941 )
942 .unwrap();
943
944 let result = sort_merge_dedup(vec![b1, b2], "cpu", None).unwrap();
945 assert_eq!(result.num_rows(), 4);
947
948 let times = result
949 .column(0)
950 .as_any()
951 .downcast_ref::<Int64Array>()
952 .unwrap();
953 assert_eq!(times.value(0), 100);
955 assert_eq!(times.value(1), 100);
956 assert_eq!(times.value(2), 200);
957 assert_eq!(times.value(3), 200);
958
959 let hosts = result
960 .column(1)
961 .as_any()
962 .downcast_ref::<StringArray>()
963 .unwrap();
964 let host_set: std::collections::HashSet<&str> = (0..4).map(|i| hosts.value(i)).collect();
966 assert!(host_set.contains("srv1"));
967 assert!(host_set.contains("srv2"));
968 }
969
970 #[test]
971 fn dedup_respects_memory_tracker() {
972 let b1 = make_batch(vec![100, 200], vec![1.0, 2.0]);
973 let b2 = make_batch(vec![300, 400], vec![3.0, 4.0]);
974
975 let tracker = crate::memory::MemoryTracker::new(1_000_000);
977 let result = sort_merge_dedup(vec![b1.clone(), b2.clone()], "cpu", Some(&tracker)).unwrap();
978 assert_eq!(result.num_rows(), 4);
979 assert!(
980 tracker.allocated() > 0,
981 "tracker should have charged memory"
982 );
983
984 let tiny_tracker = crate::memory::MemoryTracker::new(1);
986 let err = sort_merge_dedup(vec![b1, b2], "cpu", Some(&tiny_tracker));
987 assert!(err.is_err(), "should fail when memory budget exceeded");
988 }
989}
990
991#[cfg(test)]
992mod ordering_tests {
993 use super::*;
994 use arrow::array::{Float64Array, StringArray};
995 use arrow::datatypes::{DataType, Field, Schema};
996 use std::sync::Arc;
997
998 #[test]
1008 fn a_single_series_major_batch_comes_back_in_time_order() {
1009 let schema = Arc::new(Schema::new(vec![
1010 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
1011 Field::new("host", DataType::Utf8, true),
1012 Field::new("v", DataType::Float64, true),
1013 ]));
1014 let batch = RecordBatch::try_new(
1016 schema,
1017 vec![
1018 Arc::new(Int64Array::from(vec![1, 2, 3, 1, 2, 3])),
1019 Arc::new(StringArray::from(vec!["a", "a", "a", "b", "b", "b"])),
1020 Arc::new(Float64Array::from(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])),
1021 ],
1022 )
1023 .unwrap();
1024
1025 let out = sort_merge_dedup_chunked(vec![batch], "m", 1024, None).unwrap();
1026 assert_eq!(out.len(), 1);
1027 let ts = out[0]
1028 .column_by_name(chronix_core::TIME_COLUMN)
1029 .unwrap()
1030 .as_any()
1031 .downcast_ref::<Int64Array>()
1032 .unwrap();
1033 assert_eq!(ts.values(), &[1, 1, 2, 2, 3, 3]);
1034 assert_eq!(out[0].num_rows(), 6);
1037 }
1038
1039 #[test]
1041 fn an_ordered_batch_is_not_reshuffled() {
1042 let schema = Arc::new(Schema::new(vec![
1043 Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
1044 Field::new("v", DataType::Float64, true),
1045 ]));
1046 let batch = RecordBatch::try_new(
1047 schema,
1048 vec![
1049 Arc::new(Int64Array::from(vec![10, 20, 30])),
1050 Arc::new(Float64Array::from(vec![1.0, 2.0, 3.0])),
1051 ],
1052 )
1053 .unwrap();
1054 let out = sort_merge_dedup_chunked(vec![batch], "m", 1024, None).unwrap();
1055 let v = out[0]
1056 .column_by_name("v")
1057 .unwrap()
1058 .as_any()
1059 .downcast_ref::<Float64Array>()
1060 .unwrap();
1061 assert_eq!(v.values(), &[1.0, 2.0, 3.0]);
1062 }
1063}