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 #[cfg(feature = "field-encryption")]
68 field_encryption: Option<(
69 crate::segment::field_encryption::FieldEncryptionConfig,
70 std::sync::Arc<dyn crate::segment::field_encryption::FieldKeyProvider>,
71 )>,
72}
73
74impl Default for CompactionExecutor {
75 fn default() -> Self {
76 Self {
77 compress: true,
78 float_encoding: FloatEncoding::Chimp,
79 row_group_size: 65_536,
80 compression_codec: CompressionCodec::default(),
81 zstd_level: 3,
82 task_timeout: DEFAULT_TASK_TIMEOUT,
83 max_input_bytes: 0,
84 #[cfg(feature = "field-encryption")]
85 field_encryption: None,
86 }
87 }
88}
89
90impl CompactionExecutor {
91 #[must_use]
93 pub fn new(
94 compress: bool,
95 float_encoding: FloatEncoding,
96 row_group_size: usize,
97 compression_codec: CompressionCodec,
98 zstd_level: i32,
99 ) -> Self {
100 Self {
101 compress,
102 float_encoding,
103 row_group_size,
104 compression_codec,
105 zstd_level,
106 task_timeout: DEFAULT_TASK_TIMEOUT,
107 max_input_bytes: 0,
108 #[cfg(feature = "field-encryption")]
109 field_encryption: None,
110 }
111 }
112
113 fn open_input(&self, path: std::path::PathBuf) -> Result<SegmentReader> {
118 #[allow(unused_mut)]
119 let mut reader = SegmentReader::open(path)?;
120 #[cfg(feature = "field-encryption")]
121 if let Some((_, provider)) = &self.field_encryption {
122 reader.set_key_provider(std::sync::Arc::clone(provider));
123 }
124 Ok(reader)
125 }
126
127 #[cfg(feature = "field-encryption")]
137 #[must_use]
138 pub fn with_field_encryption(
139 mut self,
140 writer: crate::segment::field_encryption::FieldEncryptionConfig,
141 reader: std::sync::Arc<dyn crate::segment::field_encryption::FieldKeyProvider>,
142 ) -> Self {
143 self.field_encryption = Some((writer, reader));
144 self
145 }
146
147 #[must_use]
149 pub fn with_task_timeout(mut self, timeout: Duration) -> Self {
150 self.task_timeout = timeout;
151 self
152 }
153
154 #[must_use]
160 pub fn with_max_input_bytes(mut self, limit: u64) -> Self {
161 self.max_input_bytes = limit;
162 self
163 }
164
165 pub fn execute(&self, task: &CompactionTask, tombstones: &TombstoneSet) -> Result<SegmentMeta> {
189 if task.input_segments.is_empty() {
190 return Err(CompactionError::NoEligibleSegments);
191 }
192 let deadline = Instant::now() + self.task_timeout;
193 info!(
194 shard = %task.shard_id,
195 input_segments = task.input_segments.len(),
196 output = %task.output_path().display(),
197 "Starting compaction"
198 );
199
200 let mut sorted_entries = task.input_segments.clone();
209 sorted_entries.sort_by_key(|e| e.segment_id);
210
211 for w in sorted_entries.windows(2) {
214 if w[0].segment_id >= w[1].segment_id {
215 return Err(CompactionError::Internal(format!(
216 "segment_id monotonicity violation: segments {} and {} have \
217 non-strictly-increasing IDs ({} >= {})",
218 w[0].file, w[1].file, w[0].segment_id, w[1].segment_id,
219 )));
220 }
221 }
222 let segment_ids: Vec<u64> = sorted_entries.iter().map(|e| e.segment_id.0).collect();
223 let mut all_batches: Vec<RecordBatch> = Vec::new();
224 let mut loaded_bytes: u64 = 0;
225 for entry in &sorted_entries {
226 let reader = self.open_input(entry.file.resolve(&task.segments_dir))?;
227 let batch = reader.read_all()?;
228 if batch.num_rows() > 0 {
229 loaded_bytes += batch.get_array_memory_size() as u64;
230 if self.max_input_bytes > 0 && loaded_bytes > self.max_input_bytes {
232 return Err(CompactionError::InputTooLarge {
233 loaded_bytes,
234 limit_bytes: self.max_input_bytes,
235 });
236 }
237 all_batches.push(batch);
238 }
239 if Instant::now() > deadline {
240 return Err(CompactionError::TaskTimeout(
241 self.task_timeout.as_millis() as u64
242 ));
243 }
244 }
245
246 if all_batches.is_empty() {
247 return Err(CompactionError::NoEligibleSegments);
248 }
249
250 let unified_schema = unify_schemas(&all_batches)?;
252
253 let aligned: Vec<RecordBatch> = all_batches
255 .into_iter()
256 .map(|b| align_to_schema(&b, &unified_schema))
257 .collect::<Result<Vec<_>>>()?;
258
259 let total_rows: usize = aligned
263 .iter()
264 .map(arrow::array::RecordBatch::num_rows)
265 .sum();
266 if total_rows == 0 {
267 return Err(CompactionError::NoEligibleSegments);
268 }
269 let batch_sizes: Vec<usize> = aligned
270 .iter()
271 .map(arrow::array::RecordBatch::num_rows)
272 .collect();
273
274 let tag_columns = find_tag_columns_from_segments(task);
275 let measurement = &task.input_segments[0].measurement;
276
277 let mut hashes = Vec::with_capacity(total_rows);
278 let mut canonicals = Vec::with_capacity(total_rows);
279 let mut timestamps = Vec::with_capacity(total_rows);
280
281 for batch in &aligned {
282 let (h, c) = compute_row_hashes_and_canonicals(batch, measurement, &tag_columns);
283 hashes.extend(h);
284 canonicals.extend(c);
285
286 let ts_col = batch
287 .column_by_name(chronix_core::TIME_COLUMN)
288 .and_then(|c| c.as_any().downcast_ref::<Int64Array>())
289 .ok_or_else(|| CompactionError::Internal("missing timestamp column".into()))?;
290 for i in 0..batch.num_rows() {
291 timestamps.push(ts_col.value(i));
292 }
293 }
294
295 let canonical_ids = assign_canonical_ids(&canonicals);
299
300 if Instant::now() > deadline {
304 return Err(CompactionError::TaskTimeout(
305 self.task_timeout.as_millis() as u64
306 ));
307 }
308 let mut segment_streams: Vec<Vec<usize>> = Vec::with_capacity(batch_sizes.len());
309 {
310 let mut cursor = 0usize;
311 for &bs in &batch_sizes {
312 let start = cursor;
313 let end = cursor + bs;
314 let mut local_indices: Vec<usize> = (start..end).collect();
315 local_indices.sort_unstable_by(|&a, &b| {
316 hashes[a]
317 .cmp(&hashes[b])
318 .then(timestamps[a].cmp(×tamps[b]))
319 .then(canonical_ids[a].cmp(&canonical_ids[b]))
320 });
321 segment_streams.push(local_indices);
322 cursor = end;
323 }
324 }
325
326 let chunk_size = self.row_group_size.max(8192);
337 let mut merge_iter = KWayMergeIter::new(
338 &segment_streams,
339 &hashes,
340 ×tamps,
341 &canonical_ids,
342 &canonicals,
343 tombstones,
344 &segment_ids,
345 );
346
347 let mut output_chunks: Vec<RecordBatch> = Vec::new();
351 let mut chunk_buf: Vec<usize> = Vec::with_capacity(chunk_size);
352
353 loop {
354 chunk_buf.clear();
355 for _ in 0..chunk_size {
356 match merge_iter.next() {
357 Some(gi) => chunk_buf.push(gi),
358 None => break,
359 }
360 }
361 if chunk_buf.is_empty() {
362 break;
363 }
364
365 let interleave_pairs: Vec<(usize, usize)> = chunk_buf
366 .iter()
367 .map(|&gi| global_to_batch_local(&batch_sizes, gi))
368 .collect::<Result<Vec<_>>>()?;
369
370 let num_cols = unified_schema.fields().len();
371 let mut result_columns: Vec<ArrayRef> = Vec::with_capacity(num_cols);
372
373 for col_idx in 0..num_cols {
374 let col_arrays: Vec<&dyn Array> =
375 aligned.iter().map(|b| b.column(col_idx).as_ref()).collect();
376 let interleaved = compute::interleave(&col_arrays, &interleave_pairs)
377 .map_err(|e| CompactionError::Internal(format!("interleave failed: {e}")))?;
378 result_columns.push(interleaved);
379 }
380
381 let chunk_batch = RecordBatch::try_new(unified_schema.clone(), result_columns)
382 .map_err(|e| {
383 CompactionError::Internal(format!("failed to build chunk batch: {e}"))
384 })?;
385 output_chunks.push(chunk_batch);
386
387 if Instant::now() > deadline {
388 return Err(CompactionError::TaskTimeout(
389 self.task_timeout.as_millis() as u64
390 ));
391 }
392 }
393
394 if output_chunks.is_empty() {
395 info!(
396 shard = %task.shard_id,
397 "Compaction: all rows tombstoned — no output segment"
398 );
399 return Ok(SegmentMeta {
400 path: task.output_path(),
401 min_timestamp: 0,
402 max_timestamp: 0,
403 row_count: 0,
404 series_count: 0,
405 series_keys: Vec::new(),
406 byte_size: 0,
407 row_group_count: 0,
408 column_count: 0,
409 uncompressed_bytes: 0,
410 header: crate::segment::header::SegmentHeader {
411 version: 0,
412 flags: 0,
413 created_at: 0,
414 min_timestamp: 0,
415 max_timestamp: 0,
416 row_count: 0,
417 column_count: 0,
418 series_count: 0,
419 compression: 0,
420 sort_order: 0,
421 },
422 column_metas: Vec::new(),
423 });
424 }
425
426 if Instant::now() > deadline {
430 return Err(CompactionError::TaskTimeout(
431 self.task_timeout.as_millis() as u64
432 ));
433 }
434
435 let config = SegmentWriterConfig {
436 row_group_size: self.row_group_size,
437 compress: self.compress,
438 float_encoding: self.float_encoding,
439 compression_codec: self.compression_codec,
440 zstd_level: self.zstd_level,
441 column_codec_overrides: std::collections::HashMap::new(),
442 #[cfg(feature = "field-encryption")]
446 field_encryption: self
447 .field_encryption
448 .as_ref()
449 .map(|(w, _)| w.clone())
450 .unwrap_or_default(),
451 ..Default::default()
452 };
453
454 let mut writer = SegmentWriter::new(task.output_path(), config)?;
455 let meta: std::result::Result<_, CompactionError> =
456 (|| Ok(writer.finalize_batches(&output_chunks, measurement, &tag_columns)?))();
457
458 match meta {
459 Ok(meta) => {
460 info!(
461 shard = %task.shard_id,
462 input_rows = total_rows,
463 output_rows = meta.row_count,
464 dedup_removed = (total_rows as u64).saturating_sub(meta.row_count),
465 output = %meta.path.display(),
466 "Compaction complete"
467 );
468 Ok(meta)
469 }
470 Err(e) => {
471 if task.output_path().exists() {
473 if let Err(rm_err) = std::fs::remove_file(task.output_path()) {
474 tracing::warn!(
475 path = %task.output_path().display(),
476 error = %rm_err,
477 "failed to clean up partial compaction output"
478 );
479 }
480 }
481 Err(e)
482 }
483 }
484 }
485}
486
487fn global_to_batch_local(batch_sizes: &[usize], global: usize) -> Result<(usize, usize)> {
489 let mut offset = 0;
490 for (bi, &bs) in batch_sizes.iter().enumerate() {
491 if global < offset + bs {
492 return Ok((bi, global - offset));
493 }
494 offset += bs;
495 }
496 Err(CompactionError::Internal(format!(
497 "global index {global} out of range (total rows: {offset})"
498 )))
499}
500
501fn unify_schemas(batches: &[RecordBatch]) -> Result<Arc<Schema>> {
510 let mut fields: BTreeMap<String, Field> = BTreeMap::new();
511
512 for batch in batches {
513 for field in batch.schema().fields() {
514 match fields.entry(field.name().clone()) {
515 std::collections::btree_map::Entry::Occupied(mut existing) => {
516 if existing.get().data_type() != field.data_type() {
517 return Err(CompactionError::Internal(format!(
518 "column '{}' type conflict across segments: {:?} vs {:?}",
519 field.name(),
520 existing.get().data_type(),
521 field.data_type(),
522 )));
523 }
524 if field.is_nullable() && !existing.get().is_nullable() {
526 let widened = existing.get().clone().with_nullable(true);
527 existing.insert(widened);
528 }
529 }
530 std::collections::btree_map::Entry::Vacant(v) => {
531 v.insert(field.as_ref().clone());
532 }
533 }
534 }
535 }
536
537 let all_names: Vec<String> = fields.keys().cloned().collect();
540 for batch in batches {
541 let schema = batch.schema();
542 let batch_names: std::collections::HashSet<&str> =
543 schema.fields().iter().map(|f| f.name().as_str()).collect();
544 for name in &all_names {
545 if !batch_names.contains(name.as_str()) {
546 if let Some(f) = fields.get_mut(name) {
547 if !f.is_nullable() {
548 *f = f.clone().with_nullable(true);
549 }
550 }
551 }
552 }
553 }
554
555 let field_vec: Vec<Field> = fields.into_values().collect();
556 Ok(Arc::new(Schema::new(field_vec)))
557}
558
559fn align_to_schema(batch: &RecordBatch, target: &Arc<Schema>) -> Result<RecordBatch> {
561 let num_rows = batch.num_rows();
562 let mut columns: Vec<ArrayRef> = Vec::with_capacity(target.fields().len());
563
564 for field in target.fields() {
565 if let Some(col) = batch.column_by_name(field.name()) {
566 columns.push(col.clone());
567 } else {
568 let null_col = make_null_array(field.data_type(), num_rows);
570 columns.push(null_col);
571 }
572 }
573
574 RecordBatch::try_new(target.clone(), columns)
575 .map_err(|e| CompactionError::Internal(format!("Failed to align batch to schema: {e}")))
576}
577
578fn make_null_array(dt: &DataType, len: usize) -> ArrayRef {
583 arrow::array::new_null_array(dt, len)
584}
585
586fn find_tag_columns_from_segments(task: &CompactionTask) -> Vec<String> {
595 let mut all_tags = std::collections::BTreeSet::new();
596 for entry in &task.input_segments {
597 if let Ok(reader) = SegmentReader::open(entry.file.resolve(&task.segments_dir)) {
600 for c in reader.column_metadata() {
601 if c.role == roles::TAG {
602 all_tags.insert(c.name.clone());
603 }
604 }
605 }
606 }
607 all_tags.into_iter().collect()
608}
609
610fn compute_row_hashes_and_canonicals(
615 batch: &RecordBatch,
616 measurement: &str,
617 tag_columns: &[String],
618) -> (Vec<u64>, Vec<String>) {
619 let num_rows = batch.num_rows();
620 let mut hashes = Vec::with_capacity(num_rows);
621 let mut canonicals = Vec::with_capacity(num_rows);
622
623 let mut sorted_tags: Vec<&str> = tag_columns.iter().map(String::as_str).collect();
625 sorted_tags.sort_unstable();
626
627 let tag_arrays: Vec<Option<&StringArray>> = sorted_tags
629 .iter()
630 .map(|name| {
631 batch
632 .column_by_name(name)
633 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
634 })
635 .collect();
636
637 let mut cache: HashMap<String, (u64, String)> = HashMap::new();
643
644 for row in 0..num_rows {
645 let mut cache_key = String::new();
647 for (tag_idx, _) in sorted_tags.iter().enumerate() {
648 if let Some(arr) = tag_arrays[tag_idx] {
649 if !arr.is_null(row) {
650 let val = arr.value(row);
651 if !val.is_empty() {
652 cache_key.push_str(val);
653 }
654 }
655 }
656 cache_key.push('\0'); }
658
659 if let Some((h, c)) = cache.get(&cache_key) {
660 hashes.push(*h);
661 canonicals.push(c.clone());
662 continue;
663 }
664
665 let mut tags = BTreeMap::new();
667 for (tag_idx, tag_name) in sorted_tags.iter().enumerate() {
668 if let Some(arr) = tag_arrays[tag_idx] {
669 if !arr.is_null(row) {
670 let val = arr.value(row);
671 if !val.is_empty() {
672 tags.insert((*tag_name).to_string(), val.to_string());
673 }
674 }
675 }
676 }
677
678 match chronix_core::SeriesKey::new(measurement, tags) {
680 Ok(key) => {
681 let h = key.hash_fnv();
682 let c = key.canonical_form().to_string();
683 cache.insert(cache_key, (h, c.clone()));
684 hashes.push(h);
685 canonicals.push(c);
686 }
687 Err(_) => {
688 use std::hash::{Hash, Hasher};
691 let mut h = fnv::FnvHasher::default();
692 row.hash(&mut h);
693 measurement.hash(&mut h);
694 hashes.push(h.finish());
695 canonicals.push(format!("__fallback_{row}"));
696 }
697 }
698 }
699
700 (hashes, canonicals)
701}
702
703fn assign_canonical_ids(canonicals: &[String]) -> Vec<u32> {
710 let mut unique: Vec<&str> = canonicals.iter().map(String::as_str).collect();
711 unique.sort_unstable();
712 unique.dedup();
713 let id_map: HashMap<&str, u32> = unique
715 .iter()
716 .enumerate()
717 .map(|(i, &s)| {
718 (
719 s,
720 u32::try_from(i).expect("more than u32::MAX unique canonical series"),
721 )
722 })
723 .collect();
724 canonicals.iter().map(|s| id_map[s.as_str()]).collect()
725}
726
727#[derive(Eq, PartialEq)]
733struct MergeEntry {
734 hash: u64,
735 ts: i64,
736 canonical_id: u32,
737 segment_ord: usize,
739 global_idx: usize,
741 stream_idx: usize,
743}
744
745impl Ord for MergeEntry {
746 fn cmp(&self, other: &Self) -> Ordering {
747 self.hash
748 .cmp(&other.hash)
749 .then(self.ts.cmp(&other.ts))
750 .then(self.canonical_id.cmp(&other.canonical_id))
751 .then(self.segment_ord.cmp(&other.segment_ord))
752 }
753}
754
755impl PartialOrd for MergeEntry {
756 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
757 Some(self.cmp(other))
758 }
759}
760
761struct KWayMergeIter<'a> {
775 segment_streams: &'a [Vec<usize>],
776 hashes: &'a [u64],
777 timestamps: &'a [i64],
778 canonical_ids: &'a [u32],
779 canonicals: &'a [String],
780 tombstones: &'a TombstoneSet,
782 segment_ids: &'a [u64],
785 positions: Vec<usize>,
786 heap: BinaryHeap<Reverse<MergeEntry>>,
787}
788
789impl<'a> KWayMergeIter<'a> {
790 fn new(
791 segment_streams: &'a [Vec<usize>],
792 hashes: &'a [u64],
793 timestamps: &'a [i64],
794 canonical_ids: &'a [u32],
795 canonicals: &'a [String],
796 tombstones: &'a TombstoneSet,
797 segment_ids: &'a [u64],
798 ) -> Self {
799 let k = segment_streams.len();
800 let mut positions = vec![0usize; k];
801 let mut heap = BinaryHeap::with_capacity(k);
802
803 for (stream_idx, stream) in segment_streams.iter().enumerate() {
804 if let Some(&gi) = stream.first() {
805 heap.push(Reverse(MergeEntry {
806 hash: hashes[gi],
807 ts: timestamps[gi],
808 canonical_id: canonical_ids[gi],
809 segment_ord: stream_idx,
810 global_idx: gi,
811 stream_idx,
812 }));
813 positions[stream_idx] = 1;
814 }
815 }
816
817 Self {
818 segment_streams,
819 hashes,
820 timestamps,
821 canonical_ids,
822 canonicals,
823 tombstones,
824 segment_ids,
825 positions,
826 heap,
827 }
828 }
829}
830
831impl Iterator for KWayMergeIter<'_> {
832 type Item = usize;
833
834 fn next(&mut self) -> Option<usize> {
835 loop {
836 let Reverse(entry) = self.heap.pop()?;
837
838 let stream = &self.segment_streams[entry.stream_idx];
840 let pos = &mut self.positions[entry.stream_idx];
841 if *pos < stream.len() {
842 let gi = stream[*pos];
843 *pos += 1;
844 self.heap.push(Reverse(MergeEntry {
845 hash: self.hashes[gi],
846 ts: self.timestamps[gi],
847 canonical_id: self.canonical_ids[gi],
848 segment_ord: entry.stream_idx,
849 global_idx: gi,
850 stream_idx: entry.stream_idx,
851 }));
852 }
853
854 if self.tombstones.is_tombstoned_in(
856 &self.canonicals[entry.global_idx],
857 self.timestamps[entry.global_idx],
858 self.segment_ids[entry.stream_idx],
859 ) {
860 continue;
861 }
862
863 if let Some(Reverse(next)) = self.heap.peek() {
866 if entry.hash == next.hash
867 && entry.ts == next.ts
868 && entry.canonical_id == next.canonical_id
869 {
870 continue;
871 }
872 }
873
874 return Some(entry.global_idx);
875 }
876 }
877}
878
879#[cfg(test)]
880mod tests {
881 use super::*;
882 use crate::compaction::picker::CompactionTask;
883 use crate::index::SegmentCatalogEntry;
884 use chronix_core::{
885 FieldValue, Point, SegmentFile, SegmentId, SegmentState, SeriesKey, ShardId,
886 };
887 use std::path::{Path, PathBuf};
888
889 fn make_point(host: &str, value: f64, ts: i64) -> Point {
890 let tags = BTreeMap::from([("host".to_string(), host.to_string())]);
891 let fields = BTreeMap::from([("cpu".to_string(), FieldValue::F64(value))]);
892 let key = SeriesKey::new("cpu_usage", tags).unwrap();
893 Point::new(key, fields, ts).unwrap()
894 }
895
896 fn write_segment(root: &Path, name: &str, points: &[Point]) -> (SegmentFile, SegmentMeta) {
899 let file = SegmentFile::new(ShardId(0), name).unwrap();
900 let path = file.resolve(root);
901 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
902 let config = SegmentWriterConfig {
903 row_group_size: 1000,
904 compress: false,
905 float_encoding: FloatEncoding::Gorilla,
906 compression_codec: chronix_core::CompressionCodec::Lz4,
907 zstd_level: 3,
908 column_codec_overrides: std::collections::HashMap::new(),
909 ..Default::default()
910 };
911 let mut writer = SegmentWriter::new(&path, config).unwrap();
912 writer.write_rows(points).unwrap();
913 let meta = writer.finalize().unwrap();
914 (file, meta)
915 }
916
917 fn make_entry(
918 id: u64,
919 shard: i64,
920 file: SegmentFile,
921 meta: &SegmentMeta,
922 ) -> SegmentCatalogEntry {
923 SegmentCatalogEntry {
924 segment_id: SegmentId(id),
925 shard_id: ShardId(shard),
926 measurement: "cpu_usage".to_string(),
927 file,
928 min_timestamp: meta.min_timestamp,
929 max_timestamp: meta.max_timestamp,
930 row_count: meta.row_count,
931 series_count: meta.series_count,
932 byte_size: meta.byte_size,
933 row_group_count: meta.row_group_count,
934 column_count: meta.column_count,
935 column_stats: Vec::new(),
936 state: SegmentState::default(),
937 }
938 }
939
940 #[test]
941 fn compact_three_overlapping_segments() {
942 let dir = tempfile::tempdir().unwrap();
943
944 let pts1 = vec![
946 make_point("srv1", 1.0, 100),
947 make_point("srv1", 2.0, 200),
948 make_point("srv1", 3.0, 300),
949 ];
950 let (p1, m1) = write_segment(dir.path(), "s1.csx", &pts1);
951
952 let pts2 = vec![
954 make_point("srv1", 99.0, 200), make_point("srv1", 4.0, 400),
956 make_point("srv2", 10.0, 100),
957 ];
958 let (p2, m2) = write_segment(dir.path(), "s2.csx", &pts2);
959
960 let pts3 = vec![make_point("srv2", 20.0, 200)];
962 let (p3, m3) = write_segment(dir.path(), "s3.csx", &pts3);
963
964 let task = CompactionTask {
965 shard_id: ShardId(0),
966 input_segments: vec![
967 make_entry(1, 0, p1, &m1),
968 make_entry(2, 0, p2, &m2),
969 make_entry(3, 0, p3, &m3),
970 ],
971 source_level: crate::compaction::CompactionLevel::L0,
972 target_level: crate::compaction::CompactionLevel::L1,
973 segments_dir: dir.path().to_path_buf(),
974 output: SegmentFile::new(ShardId(0), "compacted.csx").unwrap(),
975 };
976
977 let executor = CompactionExecutor::new(
978 false,
979 FloatEncoding::Gorilla,
980 1000,
981 CompressionCodec::Lz4,
982 3,
983 );
984 let tombstones = TombstoneSet::new();
985 let meta = executor.execute(&task, &tombstones).unwrap();
986
987 assert_eq!(meta.row_count, 6);
989 assert_eq!(meta.series_count, 2);
990
991 let reader = SegmentReader::open(task.output_path()).unwrap();
993 let batch = reader.read_all().unwrap();
994 assert_eq!(batch.num_rows(), 6);
995
996 let host_col = batch.column_by_name("host").unwrap();
998 let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
999 let ts_col = batch.column_by_name(chronix_core::TIME_COLUMN).unwrap();
1000 let ts_arr = ts_col.as_any().downcast_ref::<Int64Array>().unwrap();
1001 let cpu_col = batch.column_by_name("cpu").unwrap();
1002 let cpu_arr = cpu_col.as_any().downcast_ref::<Float64Array>().unwrap();
1003
1004 for i in 0..batch.num_rows() {
1005 if host_arr.value(i) == "srv1" && ts_arr.value(i) == 200 {
1006 assert!(
1008 (cpu_arr.value(i) - 99.0).abs() < f64::EPSILON,
1009 "Expected 99.0 for srv1@200, got {}",
1010 cpu_arr.value(i)
1011 );
1012 }
1013 }
1014 }
1015
1016 #[test]
1017 fn compact_with_tombstone_cleanup() {
1018 let dir = tempfile::tempdir().unwrap();
1019
1020 let pts = vec![
1021 make_point("srv1", 1.0, 100),
1022 make_point("srv1", 2.0, 200),
1023 make_point("srv2", 10.0, 100),
1024 make_point("srv2", 20.0, 200),
1025 ];
1026 let (path, meta) = write_segment(dir.path(), "seg.csx", &pts);
1027
1028 let srv1_key = SeriesKey::new(
1030 "cpu_usage",
1031 BTreeMap::from([("host".to_string(), "srv1".to_string())]),
1032 )
1033 .unwrap();
1034 let mut tombstones = TombstoneSet::new();
1035 tombstones.insert(
1036 chronix_core::Tombstone::all_time(srv1_key.canonical_form().to_string())
1037 .with_segments(0..1000),
1038 );
1039
1040 let task = CompactionTask {
1041 shard_id: ShardId(0),
1042 input_segments: vec![make_entry(1, 0, path, &meta)],
1043 source_level: crate::compaction::CompactionLevel::L0,
1044 target_level: crate::compaction::CompactionLevel::L1,
1045 segments_dir: dir.path().to_path_buf(),
1046 output: SegmentFile::new(ShardId(0), "cleaned.csx").unwrap(),
1047 };
1048
1049 let executor = CompactionExecutor::default();
1050 let result = executor.execute(&task, &tombstones).unwrap();
1051
1052 assert_eq!(result.row_count, 2);
1054
1055 let reader = SegmentReader::open(task.output_path()).unwrap();
1056 let batch = reader.read_all().unwrap();
1057 let host_col = batch.column_by_name("host").unwrap();
1058 let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
1059 for i in 0..batch.num_rows() {
1060 assert_eq!(host_arr.value(i), "srv2");
1061 }
1062 }
1063
1064 #[test]
1065 fn compact_empty_segments_returns_error() {
1066 let task = CompactionTask {
1067 shard_id: ShardId(0),
1068 input_segments: vec![],
1069 source_level: crate::compaction::CompactionLevel::L0,
1070 target_level: crate::compaction::CompactionLevel::L1,
1071 segments_dir: PathBuf::from("/tmp"),
1072 output: SegmentFile::new(ShardId(0), "empty.csx").unwrap(),
1073 };
1074
1075 let executor = CompactionExecutor::default();
1076 let result = executor.execute(&task, &TombstoneSet::new());
1077 assert!(result.is_err());
1078 }
1079
1080 #[test]
1081 fn compact_preserves_all_data_when_no_duplicates() {
1082 let dir = tempfile::tempdir().unwrap();
1083
1084 let pts1 = vec![make_point("srv1", 1.0, 100), make_point("srv1", 2.0, 200)];
1085 let pts2 = vec![make_point("srv1", 3.0, 300), make_point("srv1", 4.0, 400)];
1086
1087 let (p1, m1) = write_segment(dir.path(), "s1.csx", &pts1);
1088 let (p2, m2) = write_segment(dir.path(), "s2.csx", &pts2);
1089
1090 let task = CompactionTask {
1091 shard_id: ShardId(0),
1092 input_segments: vec![make_entry(1, 0, p1, &m1), make_entry(2, 0, p2, &m2)],
1093 source_level: crate::compaction::CompactionLevel::L0,
1094 target_level: crate::compaction::CompactionLevel::L1,
1095 segments_dir: dir.path().to_path_buf(),
1096 output: SegmentFile::new(ShardId(0), "no_dup.csx").unwrap(),
1097 };
1098
1099 let executor = CompactionExecutor::new(
1100 false,
1101 FloatEncoding::Gorilla,
1102 1000,
1103 CompressionCodec::Lz4,
1104 3,
1105 );
1106 let meta = executor.execute(&task, &TombstoneSet::new()).unwrap();
1107 assert_eq!(meta.row_count, 4);
1108 }
1109
1110 #[test]
1113 fn compact_output_sorted_by_series_then_time() {
1114 let dir = tempfile::tempdir().unwrap();
1115
1116 let pts1 = vec![
1119 make_point("srv1", 1.0, 300),
1120 make_point("srv2", 2.0, 100),
1121 make_point("srv1", 3.0, 100),
1122 ];
1123 let pts2 = vec![
1124 make_point("srv2", 4.0, 400),
1125 make_point("srv1", 5.0, 500),
1126 make_point("srv2", 6.0, 200),
1127 ];
1128
1129 let (p1, m1) = write_segment(dir.path(), "s1.csx", &pts1);
1130 let (p2, m2) = write_segment(dir.path(), "s2.csx", &pts2);
1131
1132 let task = CompactionTask {
1133 shard_id: ShardId(0),
1134 input_segments: vec![make_entry(1, 0, p1, &m1), make_entry(2, 0, p2, &m2)],
1135 source_level: crate::compaction::CompactionLevel::L0,
1136 target_level: crate::compaction::CompactionLevel::L1,
1137 segments_dir: dir.path().to_path_buf(),
1138 output: SegmentFile::new(ShardId(0), "sorted.csx").unwrap(),
1139 };
1140
1141 let executor =
1143 CompactionExecutor::new(false, FloatEncoding::Gorilla, 3, CompressionCodec::Lz4, 3);
1144 let meta = executor.execute(&task, &TombstoneSet::new()).unwrap();
1145 assert_eq!(meta.row_count, 6);
1146
1147 let reader = SegmentReader::open(task.output_path()).unwrap();
1149 let batch = reader.read_all().unwrap();
1150
1151 let host_col = batch.column_by_name("host").unwrap();
1152 let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
1153 let ts_col = batch.column_by_name(chronix_core::TIME_COLUMN).unwrap();
1154 let ts_arr = ts_col.as_any().downcast_ref::<Int64Array>().unwrap();
1155
1156 let mut srv1_ts = Vec::new();
1158 let mut srv2_ts = Vec::new();
1159 for i in 0..batch.num_rows() {
1160 match host_arr.value(i) {
1161 "srv1" => srv1_ts.push(ts_arr.value(i)),
1162 "srv2" => srv2_ts.push(ts_arr.value(i)),
1163 other => panic!("unexpected host: {other}"),
1164 }
1165 }
1166
1167 assert_eq!(
1168 srv1_ts,
1169 vec![100, 300, 500],
1170 "srv1 timestamps should be sorted"
1171 );
1172 assert_eq!(
1173 srv2_ts,
1174 vec![100, 200, 400],
1175 "srv2 timestamps should be sorted"
1176 );
1177
1178 let mut seen_series: Vec<String> = Vec::new();
1181 for i in 0..batch.num_rows() {
1182 let h = host_arr.value(i).to_string();
1183 if seen_series.last() != Some(&h) {
1184 seen_series.push(h);
1185 }
1186 }
1187 assert_eq!(
1188 seen_series.len(),
1189 2,
1190 "series should be grouped (got transitions: {seen_series:?})"
1191 );
1192 }
1193
1194 #[test]
1197 fn compact_stress_many_segments_many_series() {
1198 let dir = tempfile::tempdir().unwrap();
1199
1200 let hosts = ["web1", "web2", "db1", "db2", "cache1"];
1202 let num_segments = 8;
1203 let mut segments = Vec::new();
1204 let mut expected: std::collections::HashMap<(String, i64), f64> =
1205 std::collections::HashMap::new();
1206
1207 for seg_idx in 0..num_segments {
1208 let mut points = Vec::new();
1209 for (h_idx, host) in hosts.iter().enumerate() {
1210 let base_ts = (seg_idx * 5 + h_idx * 3) as i64 * 1000;
1212 for t in 0..10 {
1213 let ts = base_ts + t * 100;
1214 let value = seg_idx as f64 * 1000.0 + h_idx as f64 * 100.0 + t as f64;
1215 points.push(make_point(host, value, ts));
1216 expected.insert((host.to_string(), ts), value);
1218 }
1219 }
1220 let name = format!("seg_{seg_idx}.csx");
1221 let (path, meta) = write_segment(dir.path(), &name, &points);
1222 segments.push(make_entry(seg_idx as u64, 0, path, &meta));
1223 }
1224
1225 let task = CompactionTask {
1226 shard_id: ShardId(0),
1227 input_segments: segments,
1228 source_level: crate::compaction::CompactionLevel::L0,
1229 target_level: crate::compaction::CompactionLevel::L1,
1230 segments_dir: dir.path().to_path_buf(),
1231 output: SegmentFile::new(ShardId(0), "stress.csx").unwrap(),
1232 };
1233
1234 let executor =
1235 CompactionExecutor::new(true, FloatEncoding::Chimp, 50, CompressionCodec::Lz4, 3);
1236 let meta = executor.execute(&task, &TombstoneSet::new()).unwrap();
1237
1238 assert_eq!(
1240 meta.row_count as usize,
1241 expected.len(),
1242 "compacted row count should match deduplicated expected set"
1243 );
1244
1245 let reader = SegmentReader::open(task.output_path()).unwrap();
1247 let batch = reader.read_all().unwrap();
1248 assert_eq!(batch.num_rows(), expected.len());
1249
1250 let host_col = batch.column_by_name("host").unwrap();
1251 let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
1252 let ts_col = batch.column_by_name(chronix_core::TIME_COLUMN).unwrap();
1253 let ts_arr = ts_col.as_any().downcast_ref::<Int64Array>().unwrap();
1254 let cpu_col = batch.column_by_name("cpu").unwrap();
1255 let cpu_arr = cpu_col.as_any().downcast_ref::<Float64Array>().unwrap();
1256
1257 for i in 0..batch.num_rows() {
1258 let key = (host_arr.value(i).to_string(), ts_arr.value(i));
1259 let expected_val = expected
1260 .get(&key)
1261 .unwrap_or_else(|| panic!("unexpected row: host={}, ts={}", key.0, key.1));
1262 assert!(
1263 (cpu_arr.value(i) - expected_val).abs() < f64::EPSILON,
1264 "row {i}: host={}, ts={}: expected {expected_val}, got {}",
1265 key.0,
1266 key.1,
1267 cpu_arr.value(i),
1268 );
1269 }
1270
1271 let mut series_ts: std::collections::HashMap<String, Vec<i64>> =
1273 std::collections::HashMap::new();
1274 for i in 0..batch.num_rows() {
1275 series_ts
1276 .entry(host_arr.value(i).to_string())
1277 .or_default()
1278 .push(ts_arr.value(i));
1279 }
1280 for (host, timestamps) in &series_ts {
1281 for w in timestamps.windows(2) {
1282 assert!(
1283 w[0] <= w[1],
1284 "series {host} is not sorted: {} > {}",
1285 w[0],
1286 w[1]
1287 );
1288 }
1289 }
1290 assert_eq!(series_ts.len(), hosts.len(), "all series should be present");
1291 }
1292
1293 #[test]
1294 fn compact_succeeds_within_generous_timeout() {
1295 let dir = tempfile::tempdir().unwrap();
1296
1297 let pts = vec![make_point("srv1", 1.0, 100)];
1298 let (p1, m1) = write_segment(dir.path(), "seg.csx", &pts);
1299
1300 let task = CompactionTask {
1301 shard_id: ShardId(0),
1302 input_segments: vec![make_entry(1, 0, p1, &m1)],
1303 source_level: crate::compaction::CompactionLevel::L0,
1304 target_level: crate::compaction::CompactionLevel::L1,
1305 segments_dir: dir.path().to_path_buf(),
1306 output: SegmentFile::new(ShardId(0), "out.csx").unwrap(),
1307 };
1308
1309 let executor = CompactionExecutor::new(
1310 false,
1311 FloatEncoding::Gorilla,
1312 1000,
1313 CompressionCodec::Lz4,
1314 3,
1315 )
1316 .with_task_timeout(Duration::from_secs(60));
1317
1318 let result = executor.execute(&task, &TombstoneSet::new());
1319 assert!(result.is_ok(), "compaction should finish within 60s");
1320 }
1321
1322 #[test]
1323 fn compact_timeout_zero_triggers_task_timeout() {
1324 let dir = tempfile::tempdir().unwrap();
1325
1326 let pts = vec![make_point("srv1", 1.0, 100)];
1327 let (p1, m1) = write_segment(dir.path(), "seg.csx", &pts);
1328
1329 let task = CompactionTask {
1330 shard_id: ShardId(0),
1331 input_segments: vec![make_entry(1, 0, p1, &m1)],
1332 source_level: crate::compaction::CompactionLevel::L0,
1333 target_level: crate::compaction::CompactionLevel::L1,
1334 segments_dir: dir.path().to_path_buf(),
1335 output: SegmentFile::new(ShardId(0), "out.csx").unwrap(),
1336 };
1337
1338 let executor = CompactionExecutor::new(
1340 false,
1341 FloatEncoding::Gorilla,
1342 1000,
1343 CompressionCodec::Lz4,
1344 3,
1345 )
1346 .with_task_timeout(Duration::ZERO);
1347
1348 let result = executor.execute(&task, &TombstoneSet::new());
1349 assert!(
1350 matches!(result, Err(CompactionError::TaskTimeout(_))),
1351 "expected TaskTimeout, got {result:?}"
1352 );
1353 }
1354
1355 #[test]
1358 fn kway_merge_dedup_many_segments_same_key() {
1359 let dir = tempfile::tempdir().unwrap();
1360
1361 let mut segments = Vec::new();
1363 for seg_idx in 0u64..10 {
1364 let pts = vec![make_point("srv1", seg_idx as f64, 100)];
1365 let name = format!("dup_{seg_idx}.csx");
1366 let (path, meta) = write_segment(dir.path(), &name, &pts);
1367 segments.push(make_entry(seg_idx, 0, path, &meta));
1368 }
1369
1370 let task = CompactionTask {
1371 shard_id: ShardId(0),
1372 input_segments: segments,
1373 source_level: crate::compaction::CompactionLevel::L0,
1374 target_level: crate::compaction::CompactionLevel::L1,
1375 segments_dir: dir.path().to_path_buf(),
1376 output: SegmentFile::new(ShardId(0), "dedup_many.csx").unwrap(),
1377 };
1378
1379 let executor = CompactionExecutor::new(
1380 false,
1381 FloatEncoding::Gorilla,
1382 1000,
1383 CompressionCodec::Lz4,
1384 3,
1385 );
1386 let meta = executor.execute(&task, &TombstoneSet::new()).unwrap();
1387
1388 assert_eq!(meta.row_count, 1, "should keep exactly one row after dedup");
1389
1390 let reader = SegmentReader::open(task.output_path()).unwrap();
1391 let batch = reader.read_all().unwrap();
1392 let cpu_col = batch.column_by_name("cpu").unwrap();
1393 let cpu_arr = cpu_col.as_any().downcast_ref::<Float64Array>().unwrap();
1394 assert!(
1395 (cpu_arr.value(0) - 9.0).abs() < f64::EPSILON,
1396 "expected value 9.0 from newest segment, got {}",
1397 cpu_arr.value(0)
1398 );
1399 }
1400
1401 #[test]
1403 fn kway_merge_single_segment_passthrough() {
1404 let dir = tempfile::tempdir().unwrap();
1405
1406 let pts = vec![
1407 make_point("srv1", 1.0, 100),
1408 make_point("srv1", 2.0, 200),
1409 make_point("srv2", 3.0, 100),
1410 ];
1411 let (path, meta) = write_segment(dir.path(), "single.csx", &pts);
1412
1413 let task = CompactionTask {
1414 shard_id: ShardId(0),
1415 input_segments: vec![make_entry(1, 0, path, &meta)],
1416 source_level: crate::compaction::CompactionLevel::L0,
1417 target_level: crate::compaction::CompactionLevel::L1,
1418 segments_dir: dir.path().to_path_buf(),
1419 output: SegmentFile::new(ShardId(0), "single_out.csx").unwrap(),
1420 };
1421
1422 let executor = CompactionExecutor::new(
1423 false,
1424 FloatEncoding::Gorilla,
1425 1000,
1426 CompressionCodec::Lz4,
1427 3,
1428 );
1429 let result = executor.execute(&task, &TombstoneSet::new()).unwrap();
1430 assert_eq!(result.row_count, 3);
1431 }
1432
1433 #[test]
1436 fn kway_merge_tombstone_removes_all_versions() {
1437 let dir = tempfile::tempdir().unwrap();
1438
1439 let pts1 = vec![make_point("srv1", 1.0, 100), make_point("srv2", 10.0, 100)];
1441 let pts2 = vec![make_point("srv1", 2.0, 100), make_point("srv2", 20.0, 200)];
1442 let pts3 = vec![make_point("srv1", 3.0, 200), make_point("srv2", 30.0, 300)];
1443
1444 let (p1, m1) = write_segment(dir.path(), "t1.csx", &pts1);
1445 let (p2, m2) = write_segment(dir.path(), "t2.csx", &pts2);
1446 let (p3, m3) = write_segment(dir.path(), "t3.csx", &pts3);
1447
1448 let srv1_key = SeriesKey::new(
1449 "cpu_usage",
1450 BTreeMap::from([("host".to_string(), "srv1".to_string())]),
1451 )
1452 .unwrap();
1453 let mut tombstones = TombstoneSet::new();
1454 tombstones.insert(
1455 chronix_core::Tombstone::all_time(srv1_key.canonical_form().to_string())
1456 .with_segments(0..1000),
1457 );
1458
1459 let task = CompactionTask {
1460 shard_id: ShardId(0),
1461 input_segments: vec![
1462 make_entry(1, 0, p1, &m1),
1463 make_entry(2, 0, p2, &m2),
1464 make_entry(3, 0, p3, &m3),
1465 ],
1466 source_level: crate::compaction::CompactionLevel::L0,
1467 target_level: crate::compaction::CompactionLevel::L1,
1468 segments_dir: dir.path().to_path_buf(),
1469 output: SegmentFile::new(ShardId(0), "tombstone_all.csx").unwrap(),
1470 };
1471
1472 let executor = CompactionExecutor::new(
1473 false,
1474 FloatEncoding::Gorilla,
1475 1000,
1476 CompressionCodec::Lz4,
1477 3,
1478 );
1479 let result = executor.execute(&task, &tombstones).unwrap();
1480
1481 assert_eq!(result.row_count, 3);
1483
1484 let reader = SegmentReader::open(task.output_path()).unwrap();
1485 let batch = reader.read_all().unwrap();
1486 let host_col = batch.column_by_name("host").unwrap();
1487 let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
1488 for i in 0..batch.num_rows() {
1489 assert_eq!(
1490 host_arr.value(i),
1491 "srv2",
1492 "tombstoned srv1 row leaked through"
1493 );
1494 }
1495 }
1496
1497 #[test]
1499 fn kway_merge_all_tombstoned_returns_empty() {
1500 let dir = tempfile::tempdir().unwrap();
1501
1502 let pts = vec![make_point("srv1", 1.0, 100)];
1503 let (path, meta) = write_segment(dir.path(), "all_tomb.csx", &pts);
1504
1505 let srv1_key = SeriesKey::new(
1506 "cpu_usage",
1507 BTreeMap::from([("host".to_string(), "srv1".to_string())]),
1508 )
1509 .unwrap();
1510 let mut tombstones = TombstoneSet::new();
1511 tombstones.insert(
1512 chronix_core::Tombstone::all_time(srv1_key.canonical_form().to_string())
1513 .with_segments(0..1000),
1514 );
1515
1516 let task = CompactionTask {
1517 shard_id: ShardId(0),
1518 input_segments: vec![make_entry(1, 0, path, &meta)],
1519 source_level: crate::compaction::CompactionLevel::L0,
1520 target_level: crate::compaction::CompactionLevel::L1,
1521 segments_dir: dir.path().to_path_buf(),
1522 output: SegmentFile::new(ShardId(0), "all_tomb_out.csx").unwrap(),
1523 };
1524
1525 let executor = CompactionExecutor::new(
1526 false,
1527 FloatEncoding::Gorilla,
1528 1000,
1529 CompressionCodec::Lz4,
1530 3,
1531 );
1532 let result = executor.execute(&task, &tombstones).unwrap();
1533 assert_eq!(result.row_count, 0);
1534 }
1535
1536 #[test]
1539 fn max_input_bytes_rejects_oversized_task() {
1540 let dir = tempfile::tempdir().unwrap();
1541
1542 let pts = vec![make_point("srv1", 1.0, 100), make_point("srv1", 2.0, 200)];
1543 let (p1, m1) = write_segment(dir.path(), "big1.csx", &pts);
1544
1545 let task = CompactionTask {
1546 shard_id: ShardId(0),
1547 input_segments: vec![make_entry(1, 0, p1, &m1)],
1548 source_level: crate::compaction::CompactionLevel::L0,
1549 target_level: crate::compaction::CompactionLevel::L1,
1550 segments_dir: dir.path().to_path_buf(),
1551 output: SegmentFile::new(ShardId(0), "bounded.csx").unwrap(),
1552 };
1553
1554 let executor = CompactionExecutor::new(
1556 false,
1557 FloatEncoding::Gorilla,
1558 1000,
1559 CompressionCodec::Lz4,
1560 3,
1561 )
1562 .with_max_input_bytes(1);
1563 let result = executor.execute(&task, &TombstoneSet::new());
1564 assert!(result.is_err());
1565 let err = result.unwrap_err();
1566 assert!(
1567 matches!(err, CompactionError::InputTooLarge { .. }),
1568 "expected InputTooLarge, got: {err}"
1569 );
1570 }
1571
1572 #[test]
1574 fn max_input_bytes_zero_is_unlimited() {
1575 let dir = tempfile::tempdir().unwrap();
1576
1577 let pts = vec![make_point("srv1", 1.0, 100), make_point("srv1", 2.0, 200)];
1578 let (p1, m1) = write_segment(dir.path(), "unlim.csx", &pts);
1579
1580 let task = CompactionTask {
1581 shard_id: ShardId(0),
1582 input_segments: vec![make_entry(1, 0, p1, &m1)],
1583 source_level: crate::compaction::CompactionLevel::L0,
1584 target_level: crate::compaction::CompactionLevel::L1,
1585 segments_dir: dir.path().to_path_buf(),
1586 output: SegmentFile::new(ShardId(0), "unlim_out.csx").unwrap(),
1587 };
1588
1589 let executor = CompactionExecutor::new(
1590 false,
1591 FloatEncoding::Gorilla,
1592 1000,
1593 CompressionCodec::Lz4,
1594 3,
1595 )
1596 .with_max_input_bytes(0); let result = executor.execute(&task, &TombstoneSet::new());
1598 assert!(result.is_ok(), "unlimited should succeed: {result:?}");
1599 }
1600}