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 && let Err(rm_err) = std::fs::remove_file(task.output_path())
474 {
475 tracing::warn!(
476 path = %task.output_path().display(),
477 error = %rm_err,
478 "failed to clean up partial compaction output"
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 && let Some(f) = fields.get_mut(name)
547 && !f.is_nullable()
548 {
549 *f = f.clone().with_nullable(true);
550 }
551 }
552 }
553
554 let field_vec: Vec<Field> = fields.into_values().collect();
555 Ok(Arc::new(Schema::new(field_vec)))
556}
557
558fn align_to_schema(batch: &RecordBatch, target: &Arc<Schema>) -> Result<RecordBatch> {
560 let num_rows = batch.num_rows();
561 let mut columns: Vec<ArrayRef> = Vec::with_capacity(target.fields().len());
562
563 for field in target.fields() {
564 if let Some(col) = batch.column_by_name(field.name()) {
565 columns.push(col.clone());
566 } else {
567 let null_col = make_null_array(field.data_type(), num_rows);
569 columns.push(null_col);
570 }
571 }
572
573 RecordBatch::try_new(target.clone(), columns)
574 .map_err(|e| CompactionError::Internal(format!("Failed to align batch to schema: {e}")))
575}
576
577fn make_null_array(dt: &DataType, len: usize) -> ArrayRef {
582 arrow::array::new_null_array(dt, len)
583}
584
585fn find_tag_columns_from_segments(task: &CompactionTask) -> Vec<String> {
594 let mut all_tags = std::collections::BTreeSet::new();
595 for entry in &task.input_segments {
596 if let Ok(reader) = SegmentReader::open(entry.file.resolve(&task.segments_dir)) {
599 for c in reader.column_metadata() {
600 if c.role == roles::TAG {
601 all_tags.insert(c.name.clone());
602 }
603 }
604 }
605 }
606 all_tags.into_iter().collect()
607}
608
609fn compute_row_hashes_and_canonicals(
614 batch: &RecordBatch,
615 measurement: &str,
616 tag_columns: &[String],
617) -> (Vec<u64>, Vec<String>) {
618 let num_rows = batch.num_rows();
619 let mut hashes = Vec::with_capacity(num_rows);
620 let mut canonicals = Vec::with_capacity(num_rows);
621
622 let mut sorted_tags: Vec<&str> = tag_columns.iter().map(String::as_str).collect();
624 sorted_tags.sort_unstable();
625
626 let tag_arrays: Vec<Option<&StringArray>> = sorted_tags
628 .iter()
629 .map(|name| {
630 batch
631 .column_by_name(name)
632 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
633 })
634 .collect();
635
636 let mut cache: HashMap<String, (u64, String)> = HashMap::new();
642
643 for row in 0..num_rows {
644 let mut cache_key = String::new();
646 for (tag_idx, _) in sorted_tags.iter().enumerate() {
647 if let Some(arr) = tag_arrays[tag_idx]
648 && !arr.is_null(row)
649 {
650 let val = arr.value(row);
651 if !val.is_empty() {
652 cache_key.push_str(val);
653 }
654 }
655 cache_key.push('\0'); }
657
658 if let Some((h, c)) = cache.get(&cache_key) {
659 hashes.push(*h);
660 canonicals.push(c.clone());
661 continue;
662 }
663
664 let mut tags = BTreeMap::new();
666 for (tag_idx, tag_name) in sorted_tags.iter().enumerate() {
667 if let Some(arr) = tag_arrays[tag_idx]
668 && !arr.is_null(row)
669 {
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 match chronix_core::SeriesKey::new(measurement, tags) {
679 Ok(key) => {
680 let h = key.hash_fnv();
681 let c = key.canonical_form().to_string();
682 cache.insert(cache_key, (h, c.clone()));
683 hashes.push(h);
684 canonicals.push(c);
685 }
686 Err(_) => {
687 use std::hash::{Hash, Hasher};
690 let mut h = fnv::FnvHasher::default();
691 row.hash(&mut h);
692 measurement.hash(&mut h);
693 hashes.push(h.finish());
694 canonicals.push(format!("__fallback_{row}"));
695 }
696 }
697 }
698
699 (hashes, canonicals)
700}
701
702fn assign_canonical_ids(canonicals: &[String]) -> Vec<u32> {
709 let mut unique: Vec<&str> = canonicals.iter().map(String::as_str).collect();
710 unique.sort_unstable();
711 unique.dedup();
712 let id_map: HashMap<&str, u32> = unique
714 .iter()
715 .enumerate()
716 .map(|(i, &s)| {
717 (
718 s,
719 u32::try_from(i).expect("more than u32::MAX unique canonical series"),
720 )
721 })
722 .collect();
723 canonicals.iter().map(|s| id_map[s.as_str()]).collect()
724}
725
726#[derive(Eq, PartialEq)]
732struct MergeEntry {
733 hash: u64,
734 ts: i64,
735 canonical_id: u32,
736 segment_ord: usize,
738 global_idx: usize,
740 stream_idx: usize,
742}
743
744impl Ord for MergeEntry {
745 fn cmp(&self, other: &Self) -> Ordering {
746 self.hash
747 .cmp(&other.hash)
748 .then(self.ts.cmp(&other.ts))
749 .then(self.canonical_id.cmp(&other.canonical_id))
750 .then(self.segment_ord.cmp(&other.segment_ord))
751 }
752}
753
754impl PartialOrd for MergeEntry {
755 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
756 Some(self.cmp(other))
757 }
758}
759
760struct KWayMergeIter<'a> {
774 segment_streams: &'a [Vec<usize>],
775 hashes: &'a [u64],
776 timestamps: &'a [i64],
777 canonical_ids: &'a [u32],
778 canonicals: &'a [String],
779 tombstones: &'a TombstoneSet,
781 segment_ids: &'a [u64],
784 positions: Vec<usize>,
785 heap: BinaryHeap<Reverse<MergeEntry>>,
786}
787
788impl<'a> KWayMergeIter<'a> {
789 fn new(
790 segment_streams: &'a [Vec<usize>],
791 hashes: &'a [u64],
792 timestamps: &'a [i64],
793 canonical_ids: &'a [u32],
794 canonicals: &'a [String],
795 tombstones: &'a TombstoneSet,
796 segment_ids: &'a [u64],
797 ) -> Self {
798 let k = segment_streams.len();
799 let mut positions = vec![0usize; k];
800 let mut heap = BinaryHeap::with_capacity(k);
801
802 for (stream_idx, stream) in segment_streams.iter().enumerate() {
803 if let Some(&gi) = stream.first() {
804 heap.push(Reverse(MergeEntry {
805 hash: hashes[gi],
806 ts: timestamps[gi],
807 canonical_id: canonical_ids[gi],
808 segment_ord: stream_idx,
809 global_idx: gi,
810 stream_idx,
811 }));
812 positions[stream_idx] = 1;
813 }
814 }
815
816 Self {
817 segment_streams,
818 hashes,
819 timestamps,
820 canonical_ids,
821 canonicals,
822 tombstones,
823 segment_ids,
824 positions,
825 heap,
826 }
827 }
828}
829
830impl Iterator for KWayMergeIter<'_> {
831 type Item = usize;
832
833 fn next(&mut self) -> Option<usize> {
834 loop {
835 let Reverse(entry) = self.heap.pop()?;
836
837 let stream = &self.segment_streams[entry.stream_idx];
839 let pos = &mut self.positions[entry.stream_idx];
840 if *pos < stream.len() {
841 let gi = stream[*pos];
842 *pos += 1;
843 self.heap.push(Reverse(MergeEntry {
844 hash: self.hashes[gi],
845 ts: self.timestamps[gi],
846 canonical_id: self.canonical_ids[gi],
847 segment_ord: entry.stream_idx,
848 global_idx: gi,
849 stream_idx: entry.stream_idx,
850 }));
851 }
852
853 if self.tombstones.is_tombstoned_in(
855 &self.canonicals[entry.global_idx],
856 self.timestamps[entry.global_idx],
857 self.segment_ids[entry.stream_idx],
858 ) {
859 continue;
860 }
861
862 if let Some(Reverse(next)) = self.heap.peek()
865 && entry.hash == next.hash
866 && entry.ts == next.ts
867 && entry.canonical_id == next.canonical_id
868 {
869 continue;
870 }
871
872 return Some(entry.global_idx);
873 }
874 }
875}
876
877#[cfg(test)]
878mod tests {
879 use super::*;
880 use crate::compaction::picker::CompactionTask;
881 use crate::index::SegmentCatalogEntry;
882 use chronix_core::{
883 FieldValue, Point, SegmentFile, SegmentId, SegmentState, SeriesKey, ShardId,
884 };
885 use std::path::{Path, PathBuf};
886
887 fn make_point(host: &str, value: f64, ts: i64) -> Point {
888 let tags = BTreeMap::from([("host".to_string(), host.to_string())]);
889 let fields = BTreeMap::from([("cpu".to_string(), FieldValue::F64(value))]);
890 let key = SeriesKey::new("cpu_usage", tags).unwrap();
891 Point::new(key, fields, ts).unwrap()
892 }
893
894 fn write_segment(root: &Path, name: &str, points: &[Point]) -> (SegmentFile, SegmentMeta) {
897 let file = SegmentFile::new(ShardId(0), name).unwrap();
898 let path = file.resolve(root);
899 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
900 let config = SegmentWriterConfig {
901 row_group_size: 1000,
902 compress: false,
903 float_encoding: FloatEncoding::Gorilla,
904 compression_codec: chronix_core::CompressionCodec::Lz4,
905 zstd_level: 3,
906 column_codec_overrides: std::collections::HashMap::new(),
907 ..Default::default()
908 };
909 let mut writer = SegmentWriter::new(&path, config).unwrap();
910 writer.write_rows(points).unwrap();
911 let meta = writer.finalize().unwrap();
912 (file, meta)
913 }
914
915 fn make_entry(
916 id: u64,
917 shard: i64,
918 file: SegmentFile,
919 meta: &SegmentMeta,
920 ) -> SegmentCatalogEntry {
921 SegmentCatalogEntry {
922 segment_id: SegmentId(id),
923 shard_id: ShardId(shard),
924 measurement: "cpu_usage".to_string(),
925 file,
926 min_timestamp: meta.min_timestamp,
927 max_timestamp: meta.max_timestamp,
928 row_count: meta.row_count,
929 series_count: meta.series_count,
930 byte_size: meta.byte_size,
931 row_group_count: meta.row_group_count,
932 column_count: meta.column_count,
933 column_stats: Vec::new(),
934 state: SegmentState::default(),
935 }
936 }
937
938 #[test]
939 fn compact_three_overlapping_segments() {
940 let dir = tempfile::tempdir().unwrap();
941
942 let pts1 = vec![
944 make_point("srv1", 1.0, 100),
945 make_point("srv1", 2.0, 200),
946 make_point("srv1", 3.0, 300),
947 ];
948 let (p1, m1) = write_segment(dir.path(), "s1.csx", &pts1);
949
950 let pts2 = vec![
952 make_point("srv1", 99.0, 200), make_point("srv1", 4.0, 400),
954 make_point("srv2", 10.0, 100),
955 ];
956 let (p2, m2) = write_segment(dir.path(), "s2.csx", &pts2);
957
958 let pts3 = vec![make_point("srv2", 20.0, 200)];
960 let (p3, m3) = write_segment(dir.path(), "s3.csx", &pts3);
961
962 let task = CompactionTask {
963 shard_id: ShardId(0),
964 input_segments: vec![
965 make_entry(1, 0, p1, &m1),
966 make_entry(2, 0, p2, &m2),
967 make_entry(3, 0, p3, &m3),
968 ],
969 source_level: crate::compaction::CompactionLevel::L0,
970 target_level: crate::compaction::CompactionLevel::L1,
971 segments_dir: dir.path().to_path_buf(),
972 output: SegmentFile::new(ShardId(0), "compacted.csx").unwrap(),
973 };
974
975 let executor = CompactionExecutor::new(
976 false,
977 FloatEncoding::Gorilla,
978 1000,
979 CompressionCodec::Lz4,
980 3,
981 );
982 let tombstones = TombstoneSet::new();
983 let meta = executor.execute(&task, &tombstones).unwrap();
984
985 assert_eq!(meta.row_count, 6);
987 assert_eq!(meta.series_count, 2);
988
989 let reader = SegmentReader::open(task.output_path()).unwrap();
991 let batch = reader.read_all().unwrap();
992 assert_eq!(batch.num_rows(), 6);
993
994 let host_col = batch.column_by_name("host").unwrap();
996 let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
997 let ts_col = batch.column_by_name(chronix_core::TIME_COLUMN).unwrap();
998 let ts_arr = ts_col.as_any().downcast_ref::<Int64Array>().unwrap();
999 let cpu_col = batch.column_by_name("cpu").unwrap();
1000 let cpu_arr = cpu_col.as_any().downcast_ref::<Float64Array>().unwrap();
1001
1002 for i in 0..batch.num_rows() {
1003 if host_arr.value(i) == "srv1" && ts_arr.value(i) == 200 {
1004 assert!(
1006 (cpu_arr.value(i) - 99.0).abs() < f64::EPSILON,
1007 "Expected 99.0 for srv1@200, got {}",
1008 cpu_arr.value(i)
1009 );
1010 }
1011 }
1012 }
1013
1014 #[test]
1015 fn compact_with_tombstone_cleanup() {
1016 let dir = tempfile::tempdir().unwrap();
1017
1018 let pts = vec![
1019 make_point("srv1", 1.0, 100),
1020 make_point("srv1", 2.0, 200),
1021 make_point("srv2", 10.0, 100),
1022 make_point("srv2", 20.0, 200),
1023 ];
1024 let (path, meta) = write_segment(dir.path(), "seg.csx", &pts);
1025
1026 let srv1_key = SeriesKey::new(
1028 "cpu_usage",
1029 BTreeMap::from([("host".to_string(), "srv1".to_string())]),
1030 )
1031 .unwrap();
1032 let mut tombstones = TombstoneSet::new();
1033 tombstones.insert(
1034 chronix_core::Tombstone::all_time(srv1_key.canonical_form().to_string())
1035 .with_segments(0..1000),
1036 );
1037
1038 let task = CompactionTask {
1039 shard_id: ShardId(0),
1040 input_segments: vec![make_entry(1, 0, path, &meta)],
1041 source_level: crate::compaction::CompactionLevel::L0,
1042 target_level: crate::compaction::CompactionLevel::L1,
1043 segments_dir: dir.path().to_path_buf(),
1044 output: SegmentFile::new(ShardId(0), "cleaned.csx").unwrap(),
1045 };
1046
1047 let executor = CompactionExecutor::default();
1048 let result = executor.execute(&task, &tombstones).unwrap();
1049
1050 assert_eq!(result.row_count, 2);
1052
1053 let reader = SegmentReader::open(task.output_path()).unwrap();
1054 let batch = reader.read_all().unwrap();
1055 let host_col = batch.column_by_name("host").unwrap();
1056 let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
1057 for i in 0..batch.num_rows() {
1058 assert_eq!(host_arr.value(i), "srv2");
1059 }
1060 }
1061
1062 #[test]
1063 fn compact_empty_segments_returns_error() {
1064 let task = CompactionTask {
1065 shard_id: ShardId(0),
1066 input_segments: vec![],
1067 source_level: crate::compaction::CompactionLevel::L0,
1068 target_level: crate::compaction::CompactionLevel::L1,
1069 segments_dir: PathBuf::from("/tmp"),
1070 output: SegmentFile::new(ShardId(0), "empty.csx").unwrap(),
1071 };
1072
1073 let executor = CompactionExecutor::default();
1074 let result = executor.execute(&task, &TombstoneSet::new());
1075 assert!(result.is_err());
1076 }
1077
1078 #[test]
1079 fn compact_preserves_all_data_when_no_duplicates() {
1080 let dir = tempfile::tempdir().unwrap();
1081
1082 let pts1 = vec![make_point("srv1", 1.0, 100), make_point("srv1", 2.0, 200)];
1083 let pts2 = vec![make_point("srv1", 3.0, 300), make_point("srv1", 4.0, 400)];
1084
1085 let (p1, m1) = write_segment(dir.path(), "s1.csx", &pts1);
1086 let (p2, m2) = write_segment(dir.path(), "s2.csx", &pts2);
1087
1088 let task = CompactionTask {
1089 shard_id: ShardId(0),
1090 input_segments: vec![make_entry(1, 0, p1, &m1), make_entry(2, 0, p2, &m2)],
1091 source_level: crate::compaction::CompactionLevel::L0,
1092 target_level: crate::compaction::CompactionLevel::L1,
1093 segments_dir: dir.path().to_path_buf(),
1094 output: SegmentFile::new(ShardId(0), "no_dup.csx").unwrap(),
1095 };
1096
1097 let executor = CompactionExecutor::new(
1098 false,
1099 FloatEncoding::Gorilla,
1100 1000,
1101 CompressionCodec::Lz4,
1102 3,
1103 );
1104 let meta = executor.execute(&task, &TombstoneSet::new()).unwrap();
1105 assert_eq!(meta.row_count, 4);
1106 }
1107
1108 #[test]
1111 fn compact_output_sorted_by_series_then_time() {
1112 let dir = tempfile::tempdir().unwrap();
1113
1114 let pts1 = vec![
1117 make_point("srv1", 1.0, 300),
1118 make_point("srv2", 2.0, 100),
1119 make_point("srv1", 3.0, 100),
1120 ];
1121 let pts2 = vec![
1122 make_point("srv2", 4.0, 400),
1123 make_point("srv1", 5.0, 500),
1124 make_point("srv2", 6.0, 200),
1125 ];
1126
1127 let (p1, m1) = write_segment(dir.path(), "s1.csx", &pts1);
1128 let (p2, m2) = write_segment(dir.path(), "s2.csx", &pts2);
1129
1130 let task = CompactionTask {
1131 shard_id: ShardId(0),
1132 input_segments: vec![make_entry(1, 0, p1, &m1), make_entry(2, 0, p2, &m2)],
1133 source_level: crate::compaction::CompactionLevel::L0,
1134 target_level: crate::compaction::CompactionLevel::L1,
1135 segments_dir: dir.path().to_path_buf(),
1136 output: SegmentFile::new(ShardId(0), "sorted.csx").unwrap(),
1137 };
1138
1139 let executor =
1141 CompactionExecutor::new(false, FloatEncoding::Gorilla, 3, CompressionCodec::Lz4, 3);
1142 let meta = executor.execute(&task, &TombstoneSet::new()).unwrap();
1143 assert_eq!(meta.row_count, 6);
1144
1145 let reader = SegmentReader::open(task.output_path()).unwrap();
1147 let batch = reader.read_all().unwrap();
1148
1149 let host_col = batch.column_by_name("host").unwrap();
1150 let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
1151 let ts_col = batch.column_by_name(chronix_core::TIME_COLUMN).unwrap();
1152 let ts_arr = ts_col.as_any().downcast_ref::<Int64Array>().unwrap();
1153
1154 let mut srv1_ts = Vec::new();
1156 let mut srv2_ts = Vec::new();
1157 for i in 0..batch.num_rows() {
1158 match host_arr.value(i) {
1159 "srv1" => srv1_ts.push(ts_arr.value(i)),
1160 "srv2" => srv2_ts.push(ts_arr.value(i)),
1161 other => panic!("unexpected host: {other}"),
1162 }
1163 }
1164
1165 assert_eq!(
1166 srv1_ts,
1167 vec![100, 300, 500],
1168 "srv1 timestamps should be sorted"
1169 );
1170 assert_eq!(
1171 srv2_ts,
1172 vec![100, 200, 400],
1173 "srv2 timestamps should be sorted"
1174 );
1175
1176 let mut seen_series: Vec<String> = Vec::new();
1179 for i in 0..batch.num_rows() {
1180 let h = host_arr.value(i).to_string();
1181 if seen_series.last() != Some(&h) {
1182 seen_series.push(h);
1183 }
1184 }
1185 assert_eq!(
1186 seen_series.len(),
1187 2,
1188 "series should be grouped (got transitions: {seen_series:?})"
1189 );
1190 }
1191
1192 #[test]
1195 fn compact_stress_many_segments_many_series() {
1196 let dir = tempfile::tempdir().unwrap();
1197
1198 let hosts = ["web1", "web2", "db1", "db2", "cache1"];
1200 let num_segments = 8;
1201 let mut segments = Vec::new();
1202 let mut expected: std::collections::HashMap<(String, i64), f64> =
1203 std::collections::HashMap::new();
1204
1205 for seg_idx in 0..num_segments {
1206 let mut points = Vec::new();
1207 for (h_idx, host) in hosts.iter().enumerate() {
1208 let base_ts = (seg_idx * 5 + h_idx * 3) as i64 * 1000;
1210 for t in 0..10 {
1211 let ts = base_ts + t * 100;
1212 let value = seg_idx as f64 * 1000.0 + h_idx as f64 * 100.0 + t as f64;
1213 points.push(make_point(host, value, ts));
1214 expected.insert((host.to_string(), ts), value);
1216 }
1217 }
1218 let name = format!("seg_{seg_idx}.csx");
1219 let (path, meta) = write_segment(dir.path(), &name, &points);
1220 segments.push(make_entry(seg_idx as u64, 0, path, &meta));
1221 }
1222
1223 let task = CompactionTask {
1224 shard_id: ShardId(0),
1225 input_segments: segments,
1226 source_level: crate::compaction::CompactionLevel::L0,
1227 target_level: crate::compaction::CompactionLevel::L1,
1228 segments_dir: dir.path().to_path_buf(),
1229 output: SegmentFile::new(ShardId(0), "stress.csx").unwrap(),
1230 };
1231
1232 let executor =
1233 CompactionExecutor::new(true, FloatEncoding::Chimp, 50, CompressionCodec::Lz4, 3);
1234 let meta = executor.execute(&task, &TombstoneSet::new()).unwrap();
1235
1236 assert_eq!(
1238 meta.row_count as usize,
1239 expected.len(),
1240 "compacted row count should match deduplicated expected set"
1241 );
1242
1243 let reader = SegmentReader::open(task.output_path()).unwrap();
1245 let batch = reader.read_all().unwrap();
1246 assert_eq!(batch.num_rows(), expected.len());
1247
1248 let host_col = batch.column_by_name("host").unwrap();
1249 let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
1250 let ts_col = batch.column_by_name(chronix_core::TIME_COLUMN).unwrap();
1251 let ts_arr = ts_col.as_any().downcast_ref::<Int64Array>().unwrap();
1252 let cpu_col = batch.column_by_name("cpu").unwrap();
1253 let cpu_arr = cpu_col.as_any().downcast_ref::<Float64Array>().unwrap();
1254
1255 for i in 0..batch.num_rows() {
1256 let key = (host_arr.value(i).to_string(), ts_arr.value(i));
1257 let expected_val = expected
1258 .get(&key)
1259 .unwrap_or_else(|| panic!("unexpected row: host={}, ts={}", key.0, key.1));
1260 assert!(
1261 (cpu_arr.value(i) - expected_val).abs() < f64::EPSILON,
1262 "row {i}: host={}, ts={}: expected {expected_val}, got {}",
1263 key.0,
1264 key.1,
1265 cpu_arr.value(i),
1266 );
1267 }
1268
1269 let mut series_ts: std::collections::HashMap<String, Vec<i64>> =
1271 std::collections::HashMap::new();
1272 for i in 0..batch.num_rows() {
1273 series_ts
1274 .entry(host_arr.value(i).to_string())
1275 .or_default()
1276 .push(ts_arr.value(i));
1277 }
1278 for (host, timestamps) in &series_ts {
1279 for w in timestamps.windows(2) {
1280 assert!(
1281 w[0] <= w[1],
1282 "series {host} is not sorted: {} > {}",
1283 w[0],
1284 w[1]
1285 );
1286 }
1287 }
1288 assert_eq!(series_ts.len(), hosts.len(), "all series should be present");
1289 }
1290
1291 #[test]
1292 fn compact_succeeds_within_generous_timeout() {
1293 let dir = tempfile::tempdir().unwrap();
1294
1295 let pts = vec![make_point("srv1", 1.0, 100)];
1296 let (p1, m1) = write_segment(dir.path(), "seg.csx", &pts);
1297
1298 let task = CompactionTask {
1299 shard_id: ShardId(0),
1300 input_segments: vec![make_entry(1, 0, p1, &m1)],
1301 source_level: crate::compaction::CompactionLevel::L0,
1302 target_level: crate::compaction::CompactionLevel::L1,
1303 segments_dir: dir.path().to_path_buf(),
1304 output: SegmentFile::new(ShardId(0), "out.csx").unwrap(),
1305 };
1306
1307 let executor = CompactionExecutor::new(
1308 false,
1309 FloatEncoding::Gorilla,
1310 1000,
1311 CompressionCodec::Lz4,
1312 3,
1313 )
1314 .with_task_timeout(Duration::from_secs(60));
1315
1316 let result = executor.execute(&task, &TombstoneSet::new());
1317 assert!(result.is_ok(), "compaction should finish within 60s");
1318 }
1319
1320 #[test]
1321 fn compact_timeout_zero_triggers_task_timeout() {
1322 let dir = tempfile::tempdir().unwrap();
1323
1324 let pts = vec![make_point("srv1", 1.0, 100)];
1325 let (p1, m1) = write_segment(dir.path(), "seg.csx", &pts);
1326
1327 let task = CompactionTask {
1328 shard_id: ShardId(0),
1329 input_segments: vec![make_entry(1, 0, p1, &m1)],
1330 source_level: crate::compaction::CompactionLevel::L0,
1331 target_level: crate::compaction::CompactionLevel::L1,
1332 segments_dir: dir.path().to_path_buf(),
1333 output: SegmentFile::new(ShardId(0), "out.csx").unwrap(),
1334 };
1335
1336 let executor = CompactionExecutor::new(
1338 false,
1339 FloatEncoding::Gorilla,
1340 1000,
1341 CompressionCodec::Lz4,
1342 3,
1343 )
1344 .with_task_timeout(Duration::ZERO);
1345
1346 let result = executor.execute(&task, &TombstoneSet::new());
1347 assert!(
1348 matches!(result, Err(CompactionError::TaskTimeout(_))),
1349 "expected TaskTimeout, got {result:?}"
1350 );
1351 }
1352
1353 #[test]
1356 fn kway_merge_dedup_many_segments_same_key() {
1357 let dir = tempfile::tempdir().unwrap();
1358
1359 let mut segments = Vec::new();
1361 for seg_idx in 0u64..10 {
1362 let pts = vec![make_point("srv1", seg_idx as f64, 100)];
1363 let name = format!("dup_{seg_idx}.csx");
1364 let (path, meta) = write_segment(dir.path(), &name, &pts);
1365 segments.push(make_entry(seg_idx, 0, path, &meta));
1366 }
1367
1368 let task = CompactionTask {
1369 shard_id: ShardId(0),
1370 input_segments: segments,
1371 source_level: crate::compaction::CompactionLevel::L0,
1372 target_level: crate::compaction::CompactionLevel::L1,
1373 segments_dir: dir.path().to_path_buf(),
1374 output: SegmentFile::new(ShardId(0), "dedup_many.csx").unwrap(),
1375 };
1376
1377 let executor = CompactionExecutor::new(
1378 false,
1379 FloatEncoding::Gorilla,
1380 1000,
1381 CompressionCodec::Lz4,
1382 3,
1383 );
1384 let meta = executor.execute(&task, &TombstoneSet::new()).unwrap();
1385
1386 assert_eq!(meta.row_count, 1, "should keep exactly one row after dedup");
1387
1388 let reader = SegmentReader::open(task.output_path()).unwrap();
1389 let batch = reader.read_all().unwrap();
1390 let cpu_col = batch.column_by_name("cpu").unwrap();
1391 let cpu_arr = cpu_col.as_any().downcast_ref::<Float64Array>().unwrap();
1392 assert!(
1393 (cpu_arr.value(0) - 9.0).abs() < f64::EPSILON,
1394 "expected value 9.0 from newest segment, got {}",
1395 cpu_arr.value(0)
1396 );
1397 }
1398
1399 #[test]
1401 fn kway_merge_single_segment_passthrough() {
1402 let dir = tempfile::tempdir().unwrap();
1403
1404 let pts = vec![
1405 make_point("srv1", 1.0, 100),
1406 make_point("srv1", 2.0, 200),
1407 make_point("srv2", 3.0, 100),
1408 ];
1409 let (path, meta) = write_segment(dir.path(), "single.csx", &pts);
1410
1411 let task = CompactionTask {
1412 shard_id: ShardId(0),
1413 input_segments: vec![make_entry(1, 0, path, &meta)],
1414 source_level: crate::compaction::CompactionLevel::L0,
1415 target_level: crate::compaction::CompactionLevel::L1,
1416 segments_dir: dir.path().to_path_buf(),
1417 output: SegmentFile::new(ShardId(0), "single_out.csx").unwrap(),
1418 };
1419
1420 let executor = CompactionExecutor::new(
1421 false,
1422 FloatEncoding::Gorilla,
1423 1000,
1424 CompressionCodec::Lz4,
1425 3,
1426 );
1427 let result = executor.execute(&task, &TombstoneSet::new()).unwrap();
1428 assert_eq!(result.row_count, 3);
1429 }
1430
1431 #[test]
1434 fn kway_merge_tombstone_removes_all_versions() {
1435 let dir = tempfile::tempdir().unwrap();
1436
1437 let pts1 = vec![make_point("srv1", 1.0, 100), make_point("srv2", 10.0, 100)];
1439 let pts2 = vec![make_point("srv1", 2.0, 100), make_point("srv2", 20.0, 200)];
1440 let pts3 = vec![make_point("srv1", 3.0, 200), make_point("srv2", 30.0, 300)];
1441
1442 let (p1, m1) = write_segment(dir.path(), "t1.csx", &pts1);
1443 let (p2, m2) = write_segment(dir.path(), "t2.csx", &pts2);
1444 let (p3, m3) = write_segment(dir.path(), "t3.csx", &pts3);
1445
1446 let srv1_key = SeriesKey::new(
1447 "cpu_usage",
1448 BTreeMap::from([("host".to_string(), "srv1".to_string())]),
1449 )
1450 .unwrap();
1451 let mut tombstones = TombstoneSet::new();
1452 tombstones.insert(
1453 chronix_core::Tombstone::all_time(srv1_key.canonical_form().to_string())
1454 .with_segments(0..1000),
1455 );
1456
1457 let task = CompactionTask {
1458 shard_id: ShardId(0),
1459 input_segments: vec![
1460 make_entry(1, 0, p1, &m1),
1461 make_entry(2, 0, p2, &m2),
1462 make_entry(3, 0, p3, &m3),
1463 ],
1464 source_level: crate::compaction::CompactionLevel::L0,
1465 target_level: crate::compaction::CompactionLevel::L1,
1466 segments_dir: dir.path().to_path_buf(),
1467 output: SegmentFile::new(ShardId(0), "tombstone_all.csx").unwrap(),
1468 };
1469
1470 let executor = CompactionExecutor::new(
1471 false,
1472 FloatEncoding::Gorilla,
1473 1000,
1474 CompressionCodec::Lz4,
1475 3,
1476 );
1477 let result = executor.execute(&task, &tombstones).unwrap();
1478
1479 assert_eq!(result.row_count, 3);
1481
1482 let reader = SegmentReader::open(task.output_path()).unwrap();
1483 let batch = reader.read_all().unwrap();
1484 let host_col = batch.column_by_name("host").unwrap();
1485 let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
1486 for i in 0..batch.num_rows() {
1487 assert_eq!(
1488 host_arr.value(i),
1489 "srv2",
1490 "tombstoned srv1 row leaked through"
1491 );
1492 }
1493 }
1494
1495 #[test]
1497 fn kway_merge_all_tombstoned_returns_empty() {
1498 let dir = tempfile::tempdir().unwrap();
1499
1500 let pts = vec![make_point("srv1", 1.0, 100)];
1501 let (path, meta) = write_segment(dir.path(), "all_tomb.csx", &pts);
1502
1503 let srv1_key = SeriesKey::new(
1504 "cpu_usage",
1505 BTreeMap::from([("host".to_string(), "srv1".to_string())]),
1506 )
1507 .unwrap();
1508 let mut tombstones = TombstoneSet::new();
1509 tombstones.insert(
1510 chronix_core::Tombstone::all_time(srv1_key.canonical_form().to_string())
1511 .with_segments(0..1000),
1512 );
1513
1514 let task = CompactionTask {
1515 shard_id: ShardId(0),
1516 input_segments: vec![make_entry(1, 0, path, &meta)],
1517 source_level: crate::compaction::CompactionLevel::L0,
1518 target_level: crate::compaction::CompactionLevel::L1,
1519 segments_dir: dir.path().to_path_buf(),
1520 output: SegmentFile::new(ShardId(0), "all_tomb_out.csx").unwrap(),
1521 };
1522
1523 let executor = CompactionExecutor::new(
1524 false,
1525 FloatEncoding::Gorilla,
1526 1000,
1527 CompressionCodec::Lz4,
1528 3,
1529 );
1530 let result = executor.execute(&task, &tombstones).unwrap();
1531 assert_eq!(result.row_count, 0);
1532 }
1533
1534 #[test]
1537 fn max_input_bytes_rejects_oversized_task() {
1538 let dir = tempfile::tempdir().unwrap();
1539
1540 let pts = vec![make_point("srv1", 1.0, 100), make_point("srv1", 2.0, 200)];
1541 let (p1, m1) = write_segment(dir.path(), "big1.csx", &pts);
1542
1543 let task = CompactionTask {
1544 shard_id: ShardId(0),
1545 input_segments: vec![make_entry(1, 0, p1, &m1)],
1546 source_level: crate::compaction::CompactionLevel::L0,
1547 target_level: crate::compaction::CompactionLevel::L1,
1548 segments_dir: dir.path().to_path_buf(),
1549 output: SegmentFile::new(ShardId(0), "bounded.csx").unwrap(),
1550 };
1551
1552 let executor = CompactionExecutor::new(
1554 false,
1555 FloatEncoding::Gorilla,
1556 1000,
1557 CompressionCodec::Lz4,
1558 3,
1559 )
1560 .with_max_input_bytes(1);
1561 let result = executor.execute(&task, &TombstoneSet::new());
1562 assert!(result.is_err());
1563 let err = result.unwrap_err();
1564 assert!(
1565 matches!(err, CompactionError::InputTooLarge { .. }),
1566 "expected InputTooLarge, got: {err}"
1567 );
1568 }
1569
1570 #[test]
1572 fn max_input_bytes_zero_is_unlimited() {
1573 let dir = tempfile::tempdir().unwrap();
1574
1575 let pts = vec![make_point("srv1", 1.0, 100), make_point("srv1", 2.0, 200)];
1576 let (p1, m1) = write_segment(dir.path(), "unlim.csx", &pts);
1577
1578 let task = CompactionTask {
1579 shard_id: ShardId(0),
1580 input_segments: vec![make_entry(1, 0, p1, &m1)],
1581 source_level: crate::compaction::CompactionLevel::L0,
1582 target_level: crate::compaction::CompactionLevel::L1,
1583 segments_dir: dir.path().to_path_buf(),
1584 output: SegmentFile::new(ShardId(0), "unlim_out.csx").unwrap(),
1585 };
1586
1587 let executor = CompactionExecutor::new(
1588 false,
1589 FloatEncoding::Gorilla,
1590 1000,
1591 CompressionCodec::Lz4,
1592 3,
1593 )
1594 .with_max_input_bytes(0); let result = executor.execute(&task, &TombstoneSet::new());
1596 assert!(result.is_ok(), "unlimited should succeed: {result:?}");
1597 }
1598}