1use std::cmp::{Ordering, Reverse};
8use std::collections::{BTreeMap, BinaryHeap, HashMap};
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11
12#[cfg(test)]
13use arrow::array::Float64Array;
14use arrow::array::{Array, ArrayRef, Int64Array, RecordBatch, StringArray};
15use arrow::compute;
16use arrow::datatypes::{DataType, Field, Schema};
17use tracing::info;
18
19use crate::segment::metadata::roles;
20use crate::segment::reader::SegmentReader;
21use crate::segment::writer::{SegmentMeta, SegmentWriter, SegmentWriterConfig};
22use chronix_core::config::FloatEncoding;
23use chronix_core::{CompressionCodec, TombstoneSet};
24
25use crate::compaction::error::{CompactionError, Result};
26use crate::compaction::picker::CompactionTask;
27
28const DEFAULT_TASK_TIMEOUT: Duration = Duration::from_secs(600);
30
31pub struct CompactionExecutor {
45 compress: bool,
47 float_encoding: FloatEncoding,
49 row_group_size: usize,
51 compression_codec: CompressionCodec,
53 zstd_level: i32,
55 task_timeout: Duration,
57 max_input_bytes: u64,
63}
64
65impl Default for CompactionExecutor {
66 fn default() -> Self {
67 Self {
68 compress: true,
69 float_encoding: FloatEncoding::Chimp,
70 row_group_size: 65_536,
71 compression_codec: CompressionCodec::default(),
72 zstd_level: 3,
73 task_timeout: DEFAULT_TASK_TIMEOUT,
74 max_input_bytes: 0,
75 }
76 }
77}
78
79impl CompactionExecutor {
80 #[must_use]
82 pub fn new(
83 compress: bool,
84 float_encoding: FloatEncoding,
85 row_group_size: usize,
86 compression_codec: CompressionCodec,
87 zstd_level: i32,
88 ) -> Self {
89 Self {
90 compress,
91 float_encoding,
92 row_group_size,
93 compression_codec,
94 zstd_level,
95 task_timeout: DEFAULT_TASK_TIMEOUT,
96 max_input_bytes: 0,
97 }
98 }
99
100 #[must_use]
102 pub fn with_task_timeout(mut self, timeout: Duration) -> Self {
103 self.task_timeout = timeout;
104 self
105 }
106
107 #[must_use]
113 pub fn with_max_input_bytes(mut self, limit: u64) -> Self {
114 self.max_input_bytes = limit;
115 self
116 }
117
118 pub fn execute(&self, task: &CompactionTask, tombstones: &TombstoneSet) -> Result<SegmentMeta> {
142 if task.input_segments.is_empty() {
143 return Err(CompactionError::NoEligibleSegments);
144 }
145 let deadline = Instant::now() + self.task_timeout;
146 info!(
147 shard = %task.shard_id,
148 input_segments = task.input_segments.len(),
149 output = %task.output_path.display(),
150 "Starting compaction"
151 );
152
153 let mut sorted_entries = task.input_segments.clone();
162 sorted_entries.sort_by_key(|e| e.segment_id);
163
164 for w in sorted_entries.windows(2) {
167 if w[0].segment_id >= w[1].segment_id {
168 return Err(CompactionError::Internal(format!(
169 "segment_id monotonicity violation: segments {} and {} have \
170 non-strictly-increasing IDs ({} >= {})",
171 w[0].path.display(),
172 w[1].path.display(),
173 w[0].segment_id,
174 w[1].segment_id,
175 )));
176 }
177 }
178 let segment_ids: Vec<u64> = sorted_entries.iter().map(|e| e.segment_id.0).collect();
179 let mut all_batches: Vec<RecordBatch> = Vec::new();
180 let mut loaded_bytes: u64 = 0;
181 for entry in &sorted_entries {
182 let reader = SegmentReader::open(&entry.path)?;
183 let batch = reader.read_all()?;
184 if batch.num_rows() > 0 {
185 loaded_bytes += batch.get_array_memory_size() as u64;
186 if self.max_input_bytes > 0 && loaded_bytes > self.max_input_bytes {
188 return Err(CompactionError::InputTooLarge {
189 loaded_bytes,
190 limit_bytes: self.max_input_bytes,
191 });
192 }
193 all_batches.push(batch);
194 }
195 if Instant::now() > deadline {
196 return Err(CompactionError::TaskTimeout(
197 self.task_timeout.as_millis() as u64
198 ));
199 }
200 }
201
202 if all_batches.is_empty() {
203 return Err(CompactionError::NoEligibleSegments);
204 }
205
206 let unified_schema = unify_schemas(&all_batches)?;
208
209 let aligned: Vec<RecordBatch> = all_batches
211 .into_iter()
212 .map(|b| align_to_schema(&b, &unified_schema))
213 .collect::<Result<Vec<_>>>()?;
214
215 let total_rows: usize = aligned
219 .iter()
220 .map(arrow::array::RecordBatch::num_rows)
221 .sum();
222 if total_rows == 0 {
223 return Err(CompactionError::NoEligibleSegments);
224 }
225 let batch_sizes: Vec<usize> = aligned
226 .iter()
227 .map(arrow::array::RecordBatch::num_rows)
228 .collect();
229
230 let tag_columns = find_tag_columns_from_segments(task);
231 let measurement = &task.input_segments[0].measurement;
232
233 let mut hashes = Vec::with_capacity(total_rows);
234 let mut canonicals = Vec::with_capacity(total_rows);
235 let mut timestamps = Vec::with_capacity(total_rows);
236
237 for batch in &aligned {
238 let (h, c) = compute_row_hashes_and_canonicals(batch, measurement, &tag_columns);
239 hashes.extend(h);
240 canonicals.extend(c);
241
242 let ts_col = batch
243 .column_by_name(chronix_core::TIME_COLUMN)
244 .and_then(|c| c.as_any().downcast_ref::<Int64Array>())
245 .ok_or_else(|| CompactionError::Internal("missing timestamp column".into()))?;
246 for i in 0..batch.num_rows() {
247 timestamps.push(ts_col.value(i));
248 }
249 }
250
251 let canonical_ids = assign_canonical_ids(&canonicals);
255
256 if Instant::now() > deadline {
260 return Err(CompactionError::TaskTimeout(
261 self.task_timeout.as_millis() as u64
262 ));
263 }
264 let mut segment_streams: Vec<Vec<usize>> = Vec::with_capacity(batch_sizes.len());
265 {
266 let mut cursor = 0usize;
267 for &bs in &batch_sizes {
268 let start = cursor;
269 let end = cursor + bs;
270 let mut local_indices: Vec<usize> = (start..end).collect();
271 local_indices.sort_unstable_by(|&a, &b| {
272 hashes[a]
273 .cmp(&hashes[b])
274 .then(timestamps[a].cmp(×tamps[b]))
275 .then(canonical_ids[a].cmp(&canonical_ids[b]))
276 });
277 segment_streams.push(local_indices);
278 cursor = end;
279 }
280 }
281
282 let chunk_size = self.row_group_size.max(8192);
293 let mut merge_iter = KWayMergeIter::new(
294 &segment_streams,
295 &hashes,
296 ×tamps,
297 &canonical_ids,
298 &canonicals,
299 tombstones,
300 &segment_ids,
301 );
302
303 let mut output_chunks: Vec<RecordBatch> = Vec::new();
307 let mut chunk_buf: Vec<usize> = Vec::with_capacity(chunk_size);
308
309 loop {
310 chunk_buf.clear();
311 for _ in 0..chunk_size {
312 match merge_iter.next() {
313 Some(gi) => chunk_buf.push(gi),
314 None => break,
315 }
316 }
317 if chunk_buf.is_empty() {
318 break;
319 }
320
321 let interleave_pairs: Vec<(usize, usize)> = chunk_buf
322 .iter()
323 .map(|&gi| global_to_batch_local(&batch_sizes, gi))
324 .collect::<Result<Vec<_>>>()?;
325
326 let num_cols = unified_schema.fields().len();
327 let mut result_columns: Vec<ArrayRef> = Vec::with_capacity(num_cols);
328
329 for col_idx in 0..num_cols {
330 let col_arrays: Vec<&dyn Array> =
331 aligned.iter().map(|b| b.column(col_idx).as_ref()).collect();
332 let interleaved = compute::interleave(&col_arrays, &interleave_pairs)
333 .map_err(|e| CompactionError::Internal(format!("interleave failed: {e}")))?;
334 result_columns.push(interleaved);
335 }
336
337 let chunk_batch = RecordBatch::try_new(unified_schema.clone(), result_columns)
338 .map_err(|e| {
339 CompactionError::Internal(format!("failed to build chunk batch: {e}"))
340 })?;
341 output_chunks.push(chunk_batch);
342
343 if Instant::now() > deadline {
344 return Err(CompactionError::TaskTimeout(
345 self.task_timeout.as_millis() as u64
346 ));
347 }
348 }
349
350 if output_chunks.is_empty() {
351 info!(
352 shard = %task.shard_id,
353 "Compaction: all rows tombstoned — no output segment"
354 );
355 return Ok(SegmentMeta {
356 path: task.output_path.clone(),
357 min_timestamp: 0,
358 max_timestamp: 0,
359 row_count: 0,
360 series_count: 0,
361 series_keys: Vec::new(),
362 byte_size: 0,
363 row_group_count: 0,
364 column_count: 0,
365 uncompressed_bytes: 0,
366 header: crate::segment::header::SegmentHeader {
367 version: 0,
368 flags: 0,
369 created_at: 0,
370 min_timestamp: 0,
371 max_timestamp: 0,
372 row_count: 0,
373 column_count: 0,
374 series_count: 0,
375 compression: 0,
376 sort_order: 0,
377 },
378 column_metas: Vec::new(),
379 });
380 }
381
382 if Instant::now() > deadline {
386 return Err(CompactionError::TaskTimeout(
387 self.task_timeout.as_millis() as u64
388 ));
389 }
390
391 let config = SegmentWriterConfig {
392 row_group_size: self.row_group_size,
393 compress: self.compress,
394 float_encoding: self.float_encoding,
395 compression_codec: self.compression_codec,
396 zstd_level: self.zstd_level,
397 column_codec_overrides: std::collections::HashMap::new(),
398 ..Default::default()
399 };
400
401 let mut writer = SegmentWriter::new(&task.output_path, config)?;
402 let meta: std::result::Result<_, CompactionError> =
403 (|| Ok(writer.finalize_batches(&output_chunks, measurement, &tag_columns)?))();
404
405 match meta {
406 Ok(meta) => {
407 info!(
408 shard = %task.shard_id,
409 input_rows = total_rows,
410 output_rows = meta.row_count,
411 dedup_removed = (total_rows as u64).saturating_sub(meta.row_count),
412 output = %meta.path.display(),
413 "Compaction complete"
414 );
415 Ok(meta)
416 }
417 Err(e) => {
418 if task.output_path.exists() {
420 if let Err(rm_err) = std::fs::remove_file(&task.output_path) {
421 tracing::warn!(
422 path = %task.output_path.display(),
423 error = %rm_err,
424 "failed to clean up partial compaction output"
425 );
426 }
427 }
428 Err(e)
429 }
430 }
431 }
432}
433
434fn global_to_batch_local(batch_sizes: &[usize], global: usize) -> Result<(usize, usize)> {
436 let mut offset = 0;
437 for (bi, &bs) in batch_sizes.iter().enumerate() {
438 if global < offset + bs {
439 return Ok((bi, global - offset));
440 }
441 offset += bs;
442 }
443 Err(CompactionError::Internal(format!(
444 "global index {global} out of range (total rows: {offset})"
445 )))
446}
447
448fn unify_schemas(batches: &[RecordBatch]) -> Result<Arc<Schema>> {
457 let mut fields: BTreeMap<String, Field> = BTreeMap::new();
458
459 for batch in batches {
460 for field in batch.schema().fields() {
461 match fields.entry(field.name().clone()) {
462 std::collections::btree_map::Entry::Occupied(mut existing) => {
463 if existing.get().data_type() != field.data_type() {
464 return Err(CompactionError::Internal(format!(
465 "column '{}' type conflict across segments: {:?} vs {:?}",
466 field.name(),
467 existing.get().data_type(),
468 field.data_type(),
469 )));
470 }
471 if field.is_nullable() && !existing.get().is_nullable() {
473 let widened = existing.get().clone().with_nullable(true);
474 existing.insert(widened);
475 }
476 }
477 std::collections::btree_map::Entry::Vacant(v) => {
478 v.insert(field.as_ref().clone());
479 }
480 }
481 }
482 }
483
484 let all_names: Vec<String> = fields.keys().cloned().collect();
487 for batch in batches {
488 let schema = batch.schema();
489 let batch_names: std::collections::HashSet<&str> =
490 schema.fields().iter().map(|f| f.name().as_str()).collect();
491 for name in &all_names {
492 if !batch_names.contains(name.as_str()) {
493 if let Some(f) = fields.get_mut(name) {
494 if !f.is_nullable() {
495 *f = f.clone().with_nullable(true);
496 }
497 }
498 }
499 }
500 }
501
502 let field_vec: Vec<Field> = fields.into_values().collect();
503 Ok(Arc::new(Schema::new(field_vec)))
504}
505
506fn align_to_schema(batch: &RecordBatch, target: &Arc<Schema>) -> Result<RecordBatch> {
508 let num_rows = batch.num_rows();
509 let mut columns: Vec<ArrayRef> = Vec::with_capacity(target.fields().len());
510
511 for field in target.fields() {
512 if let Some(col) = batch.column_by_name(field.name()) {
513 columns.push(col.clone());
514 } else {
515 let null_col = make_null_array(field.data_type(), num_rows);
517 columns.push(null_col);
518 }
519 }
520
521 RecordBatch::try_new(target.clone(), columns)
522 .map_err(|e| CompactionError::Internal(format!("Failed to align batch to schema: {e}")))
523}
524
525fn make_null_array(dt: &DataType, len: usize) -> ArrayRef {
530 arrow::array::new_null_array(dt, len)
531}
532
533fn find_tag_columns_from_segments(task: &CompactionTask) -> Vec<String> {
542 let mut all_tags = std::collections::BTreeSet::new();
543 for entry in &task.input_segments {
544 if let Ok(reader) = SegmentReader::open(&entry.path) {
545 for c in reader.column_metadata() {
546 if c.role == roles::TAG {
547 all_tags.insert(c.name.clone());
548 }
549 }
550 }
551 }
552 all_tags.into_iter().collect()
553}
554
555fn compute_row_hashes_and_canonicals(
560 batch: &RecordBatch,
561 measurement: &str,
562 tag_columns: &[String],
563) -> (Vec<u64>, Vec<String>) {
564 let num_rows = batch.num_rows();
565 let mut hashes = Vec::with_capacity(num_rows);
566 let mut canonicals = Vec::with_capacity(num_rows);
567
568 let mut sorted_tags: Vec<&str> = tag_columns.iter().map(String::as_str).collect();
570 sorted_tags.sort_unstable();
571
572 let tag_arrays: Vec<Option<&StringArray>> = sorted_tags
574 .iter()
575 .map(|name| {
576 batch
577 .column_by_name(name)
578 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
579 })
580 .collect();
581
582 let mut cache: HashMap<String, (u64, String)> = HashMap::new();
588
589 for row in 0..num_rows {
590 let mut cache_key = String::new();
592 for (tag_idx, _) in sorted_tags.iter().enumerate() {
593 if let Some(arr) = tag_arrays[tag_idx] {
594 if !arr.is_null(row) {
595 let val = arr.value(row);
596 if !val.is_empty() {
597 cache_key.push_str(val);
598 }
599 }
600 }
601 cache_key.push('\0'); }
603
604 if let Some((h, c)) = cache.get(&cache_key) {
605 hashes.push(*h);
606 canonicals.push(c.clone());
607 continue;
608 }
609
610 let mut tags = BTreeMap::new();
612 for (tag_idx, tag_name) in sorted_tags.iter().enumerate() {
613 if let Some(arr) = tag_arrays[tag_idx] {
614 if !arr.is_null(row) {
615 let val = arr.value(row);
616 if !val.is_empty() {
617 tags.insert((*tag_name).to_string(), val.to_string());
618 }
619 }
620 }
621 }
622
623 match chronix_core::SeriesKey::new(measurement, tags) {
625 Ok(key) => {
626 let h = key.hash_fnv();
627 let c = key.canonical_form().to_string();
628 cache.insert(cache_key, (h, c.clone()));
629 hashes.push(h);
630 canonicals.push(c);
631 }
632 Err(_) => {
633 use std::hash::{Hash, Hasher};
636 let mut h = fnv::FnvHasher::default();
637 row.hash(&mut h);
638 measurement.hash(&mut h);
639 hashes.push(h.finish());
640 canonicals.push(format!("__fallback_{row}"));
641 }
642 }
643 }
644
645 (hashes, canonicals)
646}
647
648fn assign_canonical_ids(canonicals: &[String]) -> Vec<u32> {
655 let mut unique: Vec<&str> = canonicals.iter().map(String::as_str).collect();
656 unique.sort_unstable();
657 unique.dedup();
658 let id_map: HashMap<&str, u32> = unique
660 .iter()
661 .enumerate()
662 .map(|(i, &s)| {
663 (
664 s,
665 u32::try_from(i).expect("more than u32::MAX unique canonical series"),
666 )
667 })
668 .collect();
669 canonicals.iter().map(|s| id_map[s.as_str()]).collect()
670}
671
672#[derive(Eq, PartialEq)]
678struct MergeEntry {
679 hash: u64,
680 ts: i64,
681 canonical_id: u32,
682 segment_ord: usize,
684 global_idx: usize,
686 stream_idx: usize,
688}
689
690impl Ord for MergeEntry {
691 fn cmp(&self, other: &Self) -> Ordering {
692 self.hash
693 .cmp(&other.hash)
694 .then(self.ts.cmp(&other.ts))
695 .then(self.canonical_id.cmp(&other.canonical_id))
696 .then(self.segment_ord.cmp(&other.segment_ord))
697 }
698}
699
700impl PartialOrd for MergeEntry {
701 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
702 Some(self.cmp(other))
703 }
704}
705
706struct KWayMergeIter<'a> {
720 segment_streams: &'a [Vec<usize>],
721 hashes: &'a [u64],
722 timestamps: &'a [i64],
723 canonical_ids: &'a [u32],
724 canonicals: &'a [String],
725 tombstones: &'a TombstoneSet,
727 segment_ids: &'a [u64],
730 positions: Vec<usize>,
731 heap: BinaryHeap<Reverse<MergeEntry>>,
732}
733
734impl<'a> KWayMergeIter<'a> {
735 fn new(
736 segment_streams: &'a [Vec<usize>],
737 hashes: &'a [u64],
738 timestamps: &'a [i64],
739 canonical_ids: &'a [u32],
740 canonicals: &'a [String],
741 tombstones: &'a TombstoneSet,
742 segment_ids: &'a [u64],
743 ) -> Self {
744 let k = segment_streams.len();
745 let mut positions = vec![0usize; k];
746 let mut heap = BinaryHeap::with_capacity(k);
747
748 for (stream_idx, stream) in segment_streams.iter().enumerate() {
749 if let Some(&gi) = stream.first() {
750 heap.push(Reverse(MergeEntry {
751 hash: hashes[gi],
752 ts: timestamps[gi],
753 canonical_id: canonical_ids[gi],
754 segment_ord: stream_idx,
755 global_idx: gi,
756 stream_idx,
757 }));
758 positions[stream_idx] = 1;
759 }
760 }
761
762 Self {
763 segment_streams,
764 hashes,
765 timestamps,
766 canonical_ids,
767 canonicals,
768 tombstones,
769 segment_ids,
770 positions,
771 heap,
772 }
773 }
774}
775
776impl Iterator for KWayMergeIter<'_> {
777 type Item = usize;
778
779 fn next(&mut self) -> Option<usize> {
780 loop {
781 let Reverse(entry) = self.heap.pop()?;
782
783 let stream = &self.segment_streams[entry.stream_idx];
785 let pos = &mut self.positions[entry.stream_idx];
786 if *pos < stream.len() {
787 let gi = stream[*pos];
788 *pos += 1;
789 self.heap.push(Reverse(MergeEntry {
790 hash: self.hashes[gi],
791 ts: self.timestamps[gi],
792 canonical_id: self.canonical_ids[gi],
793 segment_ord: entry.stream_idx,
794 global_idx: gi,
795 stream_idx: entry.stream_idx,
796 }));
797 }
798
799 if self.tombstones.is_tombstoned_in(
801 &self.canonicals[entry.global_idx],
802 self.timestamps[entry.global_idx],
803 self.segment_ids[entry.stream_idx],
804 ) {
805 continue;
806 }
807
808 if let Some(Reverse(next)) = self.heap.peek() {
811 if entry.hash == next.hash
812 && entry.ts == next.ts
813 && entry.canonical_id == next.canonical_id
814 {
815 continue;
816 }
817 }
818
819 return Some(entry.global_idx);
820 }
821 }
822}
823
824#[cfg(test)]
825mod tests {
826 use super::*;
827 use crate::compaction::picker::CompactionTask;
828 use crate::index::SegmentCatalogEntry;
829 use chronix_core::{FieldValue, Point, SegmentId, SegmentState, SeriesKey, ShardId};
830 use std::path::{Path, PathBuf};
831
832 fn make_point(host: &str, value: f64, ts: i64) -> Point {
833 let tags = BTreeMap::from([("host".to_string(), host.to_string())]);
834 let fields = BTreeMap::from([("cpu".to_string(), FieldValue::F64(value))]);
835 let key = SeriesKey::new("cpu_usage", tags).unwrap();
836 Point::new(key, fields, ts).unwrap()
837 }
838
839 fn write_segment(dir: &Path, name: &str, points: &[Point]) -> (PathBuf, SegmentMeta) {
840 let path = dir.join(name);
841 let config = SegmentWriterConfig {
842 row_group_size: 1000,
843 compress: false,
844 float_encoding: FloatEncoding::Gorilla,
845 compression_codec: chronix_core::CompressionCodec::Lz4,
846 zstd_level: 3,
847 column_codec_overrides: std::collections::HashMap::new(),
848 ..Default::default()
849 };
850 let mut writer = SegmentWriter::new(&path, config).unwrap();
851 writer.write_rows(points).unwrap();
852 let meta = writer.finalize().unwrap();
853 (path, meta)
854 }
855
856 fn make_entry(id: u64, shard: i64, path: PathBuf, meta: &SegmentMeta) -> SegmentCatalogEntry {
857 SegmentCatalogEntry {
858 segment_id: SegmentId(id),
859 shard_id: ShardId(shard),
860 measurement: "cpu_usage".to_string(),
861 path,
862 min_timestamp: meta.min_timestamp,
863 max_timestamp: meta.max_timestamp,
864 row_count: meta.row_count,
865 series_count: meta.series_count,
866 byte_size: meta.byte_size,
867 row_group_count: meta.row_group_count,
868 column_count: meta.column_count,
869 column_stats: Vec::new(),
870 state: SegmentState::default(),
871 }
872 }
873
874 #[test]
875 fn compact_three_overlapping_segments() {
876 let dir = tempfile::tempdir().unwrap();
877 let out_dir = dir.path().join("output");
878 std::fs::create_dir_all(&out_dir).unwrap();
879
880 let pts1 = vec![
882 make_point("srv1", 1.0, 100),
883 make_point("srv1", 2.0, 200),
884 make_point("srv1", 3.0, 300),
885 ];
886 let (p1, m1) = write_segment(dir.path(), "s1.csx", &pts1);
887
888 let pts2 = vec![
890 make_point("srv1", 99.0, 200), make_point("srv1", 4.0, 400),
892 make_point("srv2", 10.0, 100),
893 ];
894 let (p2, m2) = write_segment(dir.path(), "s2.csx", &pts2);
895
896 let pts3 = vec![make_point("srv2", 20.0, 200)];
898 let (p3, m3) = write_segment(dir.path(), "s3.csx", &pts3);
899
900 let task = CompactionTask {
901 shard_id: ShardId(0),
902 input_segments: vec![
903 make_entry(1, 0, p1, &m1),
904 make_entry(2, 0, p2, &m2),
905 make_entry(3, 0, p3, &m3),
906 ],
907 source_level: crate::compaction::CompactionLevel::L0,
908 target_level: crate::compaction::CompactionLevel::L1,
909 output_path: out_dir.join("compacted.csx"),
910 };
911
912 let executor = CompactionExecutor::new(
913 false,
914 FloatEncoding::Gorilla,
915 1000,
916 CompressionCodec::Lz4,
917 3,
918 );
919 let tombstones = TombstoneSet::new();
920 let meta = executor.execute(&task, &tombstones).unwrap();
921
922 assert_eq!(meta.row_count, 6);
924 assert_eq!(meta.series_count, 2);
925
926 let reader = SegmentReader::open(&task.output_path).unwrap();
928 let batch = reader.read_all().unwrap();
929 assert_eq!(batch.num_rows(), 6);
930
931 let host_col = batch.column_by_name("host").unwrap();
933 let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
934 let ts_col = batch.column_by_name(chronix_core::TIME_COLUMN).unwrap();
935 let ts_arr = ts_col.as_any().downcast_ref::<Int64Array>().unwrap();
936 let cpu_col = batch.column_by_name("cpu").unwrap();
937 let cpu_arr = cpu_col.as_any().downcast_ref::<Float64Array>().unwrap();
938
939 for i in 0..batch.num_rows() {
940 if host_arr.value(i) == "srv1" && ts_arr.value(i) == 200 {
941 assert!(
943 (cpu_arr.value(i) - 99.0).abs() < f64::EPSILON,
944 "Expected 99.0 for srv1@200, got {}",
945 cpu_arr.value(i)
946 );
947 }
948 }
949 }
950
951 #[test]
952 fn compact_with_tombstone_cleanup() {
953 let dir = tempfile::tempdir().unwrap();
954 let out_dir = dir.path().join("output");
955 std::fs::create_dir_all(&out_dir).unwrap();
956
957 let pts = vec![
958 make_point("srv1", 1.0, 100),
959 make_point("srv1", 2.0, 200),
960 make_point("srv2", 10.0, 100),
961 make_point("srv2", 20.0, 200),
962 ];
963 let (path, meta) = write_segment(dir.path(), "seg.csx", &pts);
964
965 let srv1_key = SeriesKey::new(
967 "cpu_usage",
968 BTreeMap::from([("host".to_string(), "srv1".to_string())]),
969 )
970 .unwrap();
971 let mut tombstones = TombstoneSet::new();
972 tombstones.insert(
973 chronix_core::Tombstone::all_time(srv1_key.canonical_form().to_string())
974 .with_segments(0..1000),
975 );
976
977 let task = CompactionTask {
978 shard_id: ShardId(0),
979 input_segments: vec![make_entry(1, 0, path, &meta)],
980 source_level: crate::compaction::CompactionLevel::L0,
981 target_level: crate::compaction::CompactionLevel::L1,
982 output_path: out_dir.join("cleaned.csx"),
983 };
984
985 let executor = CompactionExecutor::default();
986 let result = executor.execute(&task, &tombstones).unwrap();
987
988 assert_eq!(result.row_count, 2);
990
991 let reader = SegmentReader::open(&task.output_path).unwrap();
992 let batch = reader.read_all().unwrap();
993 let host_col = batch.column_by_name("host").unwrap();
994 let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
995 for i in 0..batch.num_rows() {
996 assert_eq!(host_arr.value(i), "srv2");
997 }
998 }
999
1000 #[test]
1001 fn compact_empty_segments_returns_error() {
1002 let task = CompactionTask {
1003 shard_id: ShardId(0),
1004 input_segments: vec![],
1005 source_level: crate::compaction::CompactionLevel::L0,
1006 target_level: crate::compaction::CompactionLevel::L1,
1007 output_path: PathBuf::from("/tmp/empty.csx"),
1008 };
1009
1010 let executor = CompactionExecutor::default();
1011 let result = executor.execute(&task, &TombstoneSet::new());
1012 assert!(result.is_err());
1013 }
1014
1015 #[test]
1016 fn compact_preserves_all_data_when_no_duplicates() {
1017 let dir = tempfile::tempdir().unwrap();
1018 let out_dir = dir.path().join("output");
1019 std::fs::create_dir_all(&out_dir).unwrap();
1020
1021 let pts1 = vec![make_point("srv1", 1.0, 100), make_point("srv1", 2.0, 200)];
1022 let pts2 = vec![make_point("srv1", 3.0, 300), make_point("srv1", 4.0, 400)];
1023
1024 let (p1, m1) = write_segment(dir.path(), "s1.csx", &pts1);
1025 let (p2, m2) = write_segment(dir.path(), "s2.csx", &pts2);
1026
1027 let task = CompactionTask {
1028 shard_id: ShardId(0),
1029 input_segments: vec![make_entry(1, 0, p1, &m1), make_entry(2, 0, p2, &m2)],
1030 source_level: crate::compaction::CompactionLevel::L0,
1031 target_level: crate::compaction::CompactionLevel::L1,
1032 output_path: out_dir.join("no_dup.csx"),
1033 };
1034
1035 let executor = CompactionExecutor::new(
1036 false,
1037 FloatEncoding::Gorilla,
1038 1000,
1039 CompressionCodec::Lz4,
1040 3,
1041 );
1042 let meta = executor.execute(&task, &TombstoneSet::new()).unwrap();
1043 assert_eq!(meta.row_count, 4);
1044 }
1045
1046 #[test]
1049 fn compact_output_sorted_by_series_then_time() {
1050 let dir = tempfile::tempdir().unwrap();
1051 let out_dir = dir.path().join("output");
1052 std::fs::create_dir_all(&out_dir).unwrap();
1053
1054 let pts1 = vec![
1057 make_point("srv1", 1.0, 300),
1058 make_point("srv2", 2.0, 100),
1059 make_point("srv1", 3.0, 100),
1060 ];
1061 let pts2 = vec![
1062 make_point("srv2", 4.0, 400),
1063 make_point("srv1", 5.0, 500),
1064 make_point("srv2", 6.0, 200),
1065 ];
1066
1067 let (p1, m1) = write_segment(dir.path(), "s1.csx", &pts1);
1068 let (p2, m2) = write_segment(dir.path(), "s2.csx", &pts2);
1069
1070 let task = CompactionTask {
1071 shard_id: ShardId(0),
1072 input_segments: vec![make_entry(1, 0, p1, &m1), make_entry(2, 0, p2, &m2)],
1073 source_level: crate::compaction::CompactionLevel::L0,
1074 target_level: crate::compaction::CompactionLevel::L1,
1075 output_path: out_dir.join("sorted.csx"),
1076 };
1077
1078 let executor =
1080 CompactionExecutor::new(false, FloatEncoding::Gorilla, 3, CompressionCodec::Lz4, 3);
1081 let meta = executor.execute(&task, &TombstoneSet::new()).unwrap();
1082 assert_eq!(meta.row_count, 6);
1083
1084 let reader = SegmentReader::open(&task.output_path).unwrap();
1086 let batch = reader.read_all().unwrap();
1087
1088 let host_col = batch.column_by_name("host").unwrap();
1089 let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
1090 let ts_col = batch.column_by_name(chronix_core::TIME_COLUMN).unwrap();
1091 let ts_arr = ts_col.as_any().downcast_ref::<Int64Array>().unwrap();
1092
1093 let mut srv1_ts = Vec::new();
1095 let mut srv2_ts = Vec::new();
1096 for i in 0..batch.num_rows() {
1097 match host_arr.value(i) {
1098 "srv1" => srv1_ts.push(ts_arr.value(i)),
1099 "srv2" => srv2_ts.push(ts_arr.value(i)),
1100 other => panic!("unexpected host: {other}"),
1101 }
1102 }
1103
1104 assert_eq!(
1105 srv1_ts,
1106 vec![100, 300, 500],
1107 "srv1 timestamps should be sorted"
1108 );
1109 assert_eq!(
1110 srv2_ts,
1111 vec![100, 200, 400],
1112 "srv2 timestamps should be sorted"
1113 );
1114
1115 let mut seen_series: Vec<String> = Vec::new();
1118 for i in 0..batch.num_rows() {
1119 let h = host_arr.value(i).to_string();
1120 if seen_series.last() != Some(&h) {
1121 seen_series.push(h);
1122 }
1123 }
1124 assert_eq!(
1125 seen_series.len(),
1126 2,
1127 "series should be grouped (got transitions: {seen_series:?})"
1128 );
1129 }
1130
1131 #[test]
1134 fn compact_stress_many_segments_many_series() {
1135 let dir = tempfile::tempdir().unwrap();
1136 let out_dir = dir.path().join("output");
1137 std::fs::create_dir_all(&out_dir).unwrap();
1138
1139 let hosts = ["web1", "web2", "db1", "db2", "cache1"];
1141 let num_segments = 8;
1142 let mut segments = Vec::new();
1143 let mut expected: std::collections::HashMap<(String, i64), f64> =
1144 std::collections::HashMap::new();
1145
1146 for seg_idx in 0..num_segments {
1147 let mut points = Vec::new();
1148 for (h_idx, host) in hosts.iter().enumerate() {
1149 let base_ts = (seg_idx * 5 + h_idx * 3) as i64 * 1000;
1151 for t in 0..10 {
1152 let ts = base_ts + t * 100;
1153 let value = seg_idx as f64 * 1000.0 + h_idx as f64 * 100.0 + t as f64;
1154 points.push(make_point(host, value, ts));
1155 expected.insert((host.to_string(), ts), value);
1157 }
1158 }
1159 let name = format!("seg_{seg_idx}.csx");
1160 let (path, meta) = write_segment(dir.path(), &name, &points);
1161 segments.push(make_entry(seg_idx as u64, 0, path, &meta));
1162 }
1163
1164 let task = CompactionTask {
1165 shard_id: ShardId(0),
1166 input_segments: segments,
1167 source_level: crate::compaction::CompactionLevel::L0,
1168 target_level: crate::compaction::CompactionLevel::L1,
1169 output_path: out_dir.join("stress.csx"),
1170 };
1171
1172 let executor =
1173 CompactionExecutor::new(true, FloatEncoding::Chimp, 50, CompressionCodec::Lz4, 3);
1174 let meta = executor.execute(&task, &TombstoneSet::new()).unwrap();
1175
1176 assert_eq!(
1178 meta.row_count as usize,
1179 expected.len(),
1180 "compacted row count should match deduplicated expected set"
1181 );
1182
1183 let reader = SegmentReader::open(&task.output_path).unwrap();
1185 let batch = reader.read_all().unwrap();
1186 assert_eq!(batch.num_rows(), expected.len());
1187
1188 let host_col = batch.column_by_name("host").unwrap();
1189 let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
1190 let ts_col = batch.column_by_name(chronix_core::TIME_COLUMN).unwrap();
1191 let ts_arr = ts_col.as_any().downcast_ref::<Int64Array>().unwrap();
1192 let cpu_col = batch.column_by_name("cpu").unwrap();
1193 let cpu_arr = cpu_col.as_any().downcast_ref::<Float64Array>().unwrap();
1194
1195 for i in 0..batch.num_rows() {
1196 let key = (host_arr.value(i).to_string(), ts_arr.value(i));
1197 let expected_val = expected
1198 .get(&key)
1199 .unwrap_or_else(|| panic!("unexpected row: host={}, ts={}", key.0, key.1));
1200 assert!(
1201 (cpu_arr.value(i) - expected_val).abs() < f64::EPSILON,
1202 "row {i}: host={}, ts={}: expected {expected_val}, got {}",
1203 key.0,
1204 key.1,
1205 cpu_arr.value(i),
1206 );
1207 }
1208
1209 let mut series_ts: std::collections::HashMap<String, Vec<i64>> =
1211 std::collections::HashMap::new();
1212 for i in 0..batch.num_rows() {
1213 series_ts
1214 .entry(host_arr.value(i).to_string())
1215 .or_default()
1216 .push(ts_arr.value(i));
1217 }
1218 for (host, timestamps) in &series_ts {
1219 for w in timestamps.windows(2) {
1220 assert!(
1221 w[0] <= w[1],
1222 "series {host} is not sorted: {} > {}",
1223 w[0],
1224 w[1]
1225 );
1226 }
1227 }
1228 assert_eq!(series_ts.len(), hosts.len(), "all series should be present");
1229 }
1230
1231 #[test]
1232 fn compact_succeeds_within_generous_timeout() {
1233 let dir = tempfile::tempdir().unwrap();
1234 let out_dir = dir.path().join("output");
1235 std::fs::create_dir_all(&out_dir).unwrap();
1236
1237 let pts = vec![make_point("srv1", 1.0, 100)];
1238 let (p1, m1) = write_segment(dir.path(), "seg.csx", &pts);
1239
1240 let task = CompactionTask {
1241 shard_id: ShardId(0),
1242 input_segments: vec![make_entry(1, 0, p1, &m1)],
1243 source_level: crate::compaction::CompactionLevel::L0,
1244 target_level: crate::compaction::CompactionLevel::L1,
1245 output_path: out_dir.join("out.csx"),
1246 };
1247
1248 let executor = CompactionExecutor::new(
1249 false,
1250 FloatEncoding::Gorilla,
1251 1000,
1252 CompressionCodec::Lz4,
1253 3,
1254 )
1255 .with_task_timeout(Duration::from_secs(60));
1256
1257 let result = executor.execute(&task, &TombstoneSet::new());
1258 assert!(result.is_ok(), "compaction should finish within 60s");
1259 }
1260
1261 #[test]
1262 fn compact_timeout_zero_triggers_task_timeout() {
1263 let dir = tempfile::tempdir().unwrap();
1264 let out_dir = dir.path().join("output");
1265 std::fs::create_dir_all(&out_dir).unwrap();
1266
1267 let pts = vec![make_point("srv1", 1.0, 100)];
1268 let (p1, m1) = write_segment(dir.path(), "seg.csx", &pts);
1269
1270 let task = CompactionTask {
1271 shard_id: ShardId(0),
1272 input_segments: vec![make_entry(1, 0, p1, &m1)],
1273 source_level: crate::compaction::CompactionLevel::L0,
1274 target_level: crate::compaction::CompactionLevel::L1,
1275 output_path: out_dir.join("out.csx"),
1276 };
1277
1278 let executor = CompactionExecutor::new(
1280 false,
1281 FloatEncoding::Gorilla,
1282 1000,
1283 CompressionCodec::Lz4,
1284 3,
1285 )
1286 .with_task_timeout(Duration::ZERO);
1287
1288 let result = executor.execute(&task, &TombstoneSet::new());
1289 assert!(
1290 matches!(result, Err(CompactionError::TaskTimeout(_))),
1291 "expected TaskTimeout, got {result:?}"
1292 );
1293 }
1294
1295 #[test]
1298 fn kway_merge_dedup_many_segments_same_key() {
1299 let dir = tempfile::tempdir().unwrap();
1300 let out_dir = dir.path().join("output");
1301 std::fs::create_dir_all(&out_dir).unwrap();
1302
1303 let mut segments = Vec::new();
1305 for seg_idx in 0u64..10 {
1306 let pts = vec![make_point("srv1", seg_idx as f64, 100)];
1307 let name = format!("dup_{seg_idx}.csx");
1308 let (path, meta) = write_segment(dir.path(), &name, &pts);
1309 segments.push(make_entry(seg_idx, 0, path, &meta));
1310 }
1311
1312 let task = CompactionTask {
1313 shard_id: ShardId(0),
1314 input_segments: segments,
1315 source_level: crate::compaction::CompactionLevel::L0,
1316 target_level: crate::compaction::CompactionLevel::L1,
1317 output_path: out_dir.join("dedup_many.csx"),
1318 };
1319
1320 let executor = CompactionExecutor::new(
1321 false,
1322 FloatEncoding::Gorilla,
1323 1000,
1324 CompressionCodec::Lz4,
1325 3,
1326 );
1327 let meta = executor.execute(&task, &TombstoneSet::new()).unwrap();
1328
1329 assert_eq!(meta.row_count, 1, "should keep exactly one row after dedup");
1330
1331 let reader = SegmentReader::open(&task.output_path).unwrap();
1332 let batch = reader.read_all().unwrap();
1333 let cpu_col = batch.column_by_name("cpu").unwrap();
1334 let cpu_arr = cpu_col.as_any().downcast_ref::<Float64Array>().unwrap();
1335 assert!(
1336 (cpu_arr.value(0) - 9.0).abs() < f64::EPSILON,
1337 "expected value 9.0 from newest segment, got {}",
1338 cpu_arr.value(0)
1339 );
1340 }
1341
1342 #[test]
1344 fn kway_merge_single_segment_passthrough() {
1345 let dir = tempfile::tempdir().unwrap();
1346 let out_dir = dir.path().join("output");
1347 std::fs::create_dir_all(&out_dir).unwrap();
1348
1349 let pts = vec![
1350 make_point("srv1", 1.0, 100),
1351 make_point("srv1", 2.0, 200),
1352 make_point("srv2", 3.0, 100),
1353 ];
1354 let (path, meta) = write_segment(dir.path(), "single.csx", &pts);
1355
1356 let task = CompactionTask {
1357 shard_id: ShardId(0),
1358 input_segments: vec![make_entry(1, 0, path, &meta)],
1359 source_level: crate::compaction::CompactionLevel::L0,
1360 target_level: crate::compaction::CompactionLevel::L1,
1361 output_path: out_dir.join("single_out.csx"),
1362 };
1363
1364 let executor = CompactionExecutor::new(
1365 false,
1366 FloatEncoding::Gorilla,
1367 1000,
1368 CompressionCodec::Lz4,
1369 3,
1370 );
1371 let result = executor.execute(&task, &TombstoneSet::new()).unwrap();
1372 assert_eq!(result.row_count, 3);
1373 }
1374
1375 #[test]
1378 fn kway_merge_tombstone_removes_all_versions() {
1379 let dir = tempfile::tempdir().unwrap();
1380 let out_dir = dir.path().join("output");
1381 std::fs::create_dir_all(&out_dir).unwrap();
1382
1383 let pts1 = vec![make_point("srv1", 1.0, 100), make_point("srv2", 10.0, 100)];
1385 let pts2 = vec![make_point("srv1", 2.0, 100), make_point("srv2", 20.0, 200)];
1386 let pts3 = vec![make_point("srv1", 3.0, 200), make_point("srv2", 30.0, 300)];
1387
1388 let (p1, m1) = write_segment(dir.path(), "t1.csx", &pts1);
1389 let (p2, m2) = write_segment(dir.path(), "t2.csx", &pts2);
1390 let (p3, m3) = write_segment(dir.path(), "t3.csx", &pts3);
1391
1392 let srv1_key = SeriesKey::new(
1393 "cpu_usage",
1394 BTreeMap::from([("host".to_string(), "srv1".to_string())]),
1395 )
1396 .unwrap();
1397 let mut tombstones = TombstoneSet::new();
1398 tombstones.insert(
1399 chronix_core::Tombstone::all_time(srv1_key.canonical_form().to_string())
1400 .with_segments(0..1000),
1401 );
1402
1403 let task = CompactionTask {
1404 shard_id: ShardId(0),
1405 input_segments: vec![
1406 make_entry(1, 0, p1, &m1),
1407 make_entry(2, 0, p2, &m2),
1408 make_entry(3, 0, p3, &m3),
1409 ],
1410 source_level: crate::compaction::CompactionLevel::L0,
1411 target_level: crate::compaction::CompactionLevel::L1,
1412 output_path: out_dir.join("tombstone_all.csx"),
1413 };
1414
1415 let executor = CompactionExecutor::new(
1416 false,
1417 FloatEncoding::Gorilla,
1418 1000,
1419 CompressionCodec::Lz4,
1420 3,
1421 );
1422 let result = executor.execute(&task, &tombstones).unwrap();
1423
1424 assert_eq!(result.row_count, 3);
1426
1427 let reader = SegmentReader::open(&task.output_path).unwrap();
1428 let batch = reader.read_all().unwrap();
1429 let host_col = batch.column_by_name("host").unwrap();
1430 let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
1431 for i in 0..batch.num_rows() {
1432 assert_eq!(
1433 host_arr.value(i),
1434 "srv2",
1435 "tombstoned srv1 row leaked through"
1436 );
1437 }
1438 }
1439
1440 #[test]
1442 fn kway_merge_all_tombstoned_returns_empty() {
1443 let dir = tempfile::tempdir().unwrap();
1444 let out_dir = dir.path().join("output");
1445 std::fs::create_dir_all(&out_dir).unwrap();
1446
1447 let pts = vec![make_point("srv1", 1.0, 100)];
1448 let (path, meta) = write_segment(dir.path(), "all_tomb.csx", &pts);
1449
1450 let srv1_key = SeriesKey::new(
1451 "cpu_usage",
1452 BTreeMap::from([("host".to_string(), "srv1".to_string())]),
1453 )
1454 .unwrap();
1455 let mut tombstones = TombstoneSet::new();
1456 tombstones.insert(
1457 chronix_core::Tombstone::all_time(srv1_key.canonical_form().to_string())
1458 .with_segments(0..1000),
1459 );
1460
1461 let task = CompactionTask {
1462 shard_id: ShardId(0),
1463 input_segments: vec![make_entry(1, 0, path, &meta)],
1464 source_level: crate::compaction::CompactionLevel::L0,
1465 target_level: crate::compaction::CompactionLevel::L1,
1466 output_path: out_dir.join("all_tomb_out.csx"),
1467 };
1468
1469 let executor = CompactionExecutor::new(
1470 false,
1471 FloatEncoding::Gorilla,
1472 1000,
1473 CompressionCodec::Lz4,
1474 3,
1475 );
1476 let result = executor.execute(&task, &tombstones).unwrap();
1477 assert_eq!(result.row_count, 0);
1478 }
1479
1480 #[test]
1483 fn max_input_bytes_rejects_oversized_task() {
1484 let dir = tempfile::tempdir().unwrap();
1485 let out_dir = dir.path().join("output");
1486 std::fs::create_dir_all(&out_dir).unwrap();
1487
1488 let pts = vec![make_point("srv1", 1.0, 100), make_point("srv1", 2.0, 200)];
1489 let (p1, m1) = write_segment(dir.path(), "big1.csx", &pts);
1490
1491 let task = CompactionTask {
1492 shard_id: ShardId(0),
1493 input_segments: vec![make_entry(1, 0, p1, &m1)],
1494 source_level: crate::compaction::CompactionLevel::L0,
1495 target_level: crate::compaction::CompactionLevel::L1,
1496 output_path: out_dir.join("bounded.csx"),
1497 };
1498
1499 let executor = CompactionExecutor::new(
1501 false,
1502 FloatEncoding::Gorilla,
1503 1000,
1504 CompressionCodec::Lz4,
1505 3,
1506 )
1507 .with_max_input_bytes(1);
1508 let result = executor.execute(&task, &TombstoneSet::new());
1509 assert!(result.is_err());
1510 let err = result.unwrap_err();
1511 assert!(
1512 matches!(err, CompactionError::InputTooLarge { .. }),
1513 "expected InputTooLarge, got: {err}"
1514 );
1515 }
1516
1517 #[test]
1519 fn max_input_bytes_zero_is_unlimited() {
1520 let dir = tempfile::tempdir().unwrap();
1521 let out_dir = dir.path().join("output");
1522 std::fs::create_dir_all(&out_dir).unwrap();
1523
1524 let pts = vec![make_point("srv1", 1.0, 100), make_point("srv1", 2.0, 200)];
1525 let (p1, m1) = write_segment(dir.path(), "unlim.csx", &pts);
1526
1527 let task = CompactionTask {
1528 shard_id: ShardId(0),
1529 input_segments: vec![make_entry(1, 0, p1, &m1)],
1530 source_level: crate::compaction::CompactionLevel::L0,
1531 target_level: crate::compaction::CompactionLevel::L1,
1532 output_path: out_dir.join("unlim_out.csx"),
1533 };
1534
1535 let executor = CompactionExecutor::new(
1536 false,
1537 FloatEncoding::Gorilla,
1538 1000,
1539 CompressionCodec::Lz4,
1540 3,
1541 )
1542 .with_max_input_bytes(0); let result = executor.execute(&task, &TombstoneSet::new());
1544 assert!(result.is_ok(), "unlimited should succeed: {result:?}");
1545 }
1546}