Skip to main content

chronix_engine/compaction/
executor.rs

1//! Compaction executor — merge-sort compaction with deduplication.
2//!
3//! Reads input segments, merges their data via sort-merge, deduplicates on
4//! `(series_key, timestamp)` keeping the latest value, applies tombstone
5//! cleanup, and writes an optimized output segment.
6
7use 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
28/// Default compaction task timeout: 10 minutes.
29const DEFAULT_TASK_TIMEOUT: Duration = Duration::from_secs(600);
30
31/// Compaction executor that performs merge-sort compaction.
32///
33/// Reads all input segments, merge-sorts by `(series_key, timestamp)`,
34/// deduplicates, optionally removes tombstoned data, and writes an
35/// optimized output segment.
36///
37/// # Tombstone lookup complexity
38///
39/// Tombstones are passed as `&TombstoneSet`, which uses a `HashMap` keyed
40/// by canonical series name for O(1) amortised per-row lookup in
41/// [`execute`](Self::execute).  Each entry supports both full-series and
42/// ranged (time-window) deletes.  The overall cleanup pass is therefore
43/// **O(rows)**, not O(rows × tombstones).
44pub struct CompactionExecutor {
45    /// Compression enabled for output segment.
46    compress: bool,
47    /// Float encoding strategy.
48    float_encoding: FloatEncoding,
49    /// Row group size for output segment.
50    row_group_size: usize,
51    /// Compression codec for output segment.
52    compression_codec: CompressionCodec,
53    /// Zstd compression level (used when codec is Zstd).
54    zstd_level: i32,
55    /// Maximum wall-clock time allowed for a single compaction task.
56    task_timeout: Duration,
57    /// Maximum total bytes allowed for input segments in a
58    /// single compaction task.  When the running total of
59    /// `RecordBatch::get_array_memory_size()` exceeds this limit, the
60    /// task is rejected with `CompactionError::InputTooLarge`.
61    /// 0 = unlimited.
62    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    /// Create an executor with custom settings.
81    #[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    /// Set the maximum wall-clock time for a single compaction task.
101    #[must_use]
102    pub fn with_task_timeout(mut self, timeout: Duration) -> Self {
103        self.task_timeout = timeout;
104        self
105    }
106
107    /// Set the maximum total bytes for input segments.
108    ///
109    /// When the cumulative in-memory size of loaded `RecordBatch`es
110    /// exceeds this threshold the compaction task is rejected early
111    /// with [`CompactionError::InputTooLarge`].  Pass 0 for unlimited.
112    #[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    /// Execute a compaction task.
119    ///
120    /// Reads all input segments, merges their data, deduplicates on
121    /// `(series_key_hash, timestamp, canonical)`, removes tombstoned series,
122    /// and writes an optimized output segment.
123    ///
124    /// # K-way Merge Architecture
125    ///
126    /// Each segment's row indices are sorted independently by
127    /// `(series_hash, timestamp, canonical_id)`, then merged using a
128    /// K-element `BinaryHeap` (min-heap) in O(N log K) time — versus
129    /// the previous O(N log N) global sort. Deduplication is inline:
130    /// duplicate `(hash, ts, canonical)` keys are resolved by keeping
131    /// only the row from the newest segment (last-write-wins).
132    ///
133    /// Output is assembled column-by-column via `arrow::compute::interleave`
134    /// in row-group-sized chunks, bounding peak memory to
135    /// O(chunk_size × columns) for the output stage.
136    ///
137    /// # Errors
138    ///
139    /// Returns an error if no eligible segments exist, reading/writing
140    /// segments fails, or deduplication encounters an internal error.
141    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        // 1. Read all input segments as RecordBatches.
154        // Sort by segment_id ascending so that later segments (higher IDs,
155        // written more recently) get higher segment_ord in the K-way merge.
156        // This guarantees deterministic last-write-wins dedup semantics:
157        //   - segment_ids are assigned sequentially at flush time
158        //   - the K-way merge heap breaks ties by segment_ord
159        //   - per-key, only the row from the newest segment is emitted
160        // ⚠  Invariant: segment_id must increase monotonically with write time.
161        let mut sorted_entries = task.input_segments.clone();
162        sorted_entries.sort_by_key(|e| e.segment_id);
163
164        // Validate that segment IDs are strictly monotonic.
165        // Duplicate IDs would violate last-write-wins dedup semantics.
166        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                // Reject early if cumulative input exceeds budget.
187                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        // 2. Unify schemas across all batches
207        let unified_schema = unify_schemas(&all_batches)?;
208
209        // 3. Align all batches to the unified schema (consumes originals)
210        let aligned: Vec<RecordBatch> = all_batches
211            .into_iter()
212            .map(|b| align_to_schema(&b, &unified_schema))
213            .collect::<Result<Vec<_>>>()?;
214
215        // 4. Compute metadata across batches WITHOUT concatenation.
216        //    Hashes, canonicals, and timestamps are flattened into parallel
217        //    vectors indexed by a global row number.
218        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        // 5. Assign sort-preserving canonical IDs for O(1) comparison
252        //    during K-way merge. Number of unique canonicals = series count,
253        //    typically ≪ total_rows.
254        let canonical_ids = assign_canonical_ids(&canonicals);
255
256        // 6. Per-segment sort: sort each segment's indices independently
257        //    by (hash, ts, canonical_id). Uses sort_unstable since
258        //    segment_ord in the merge heap handles dedup ordering.
259        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(&timestamps[b]))
275                        .then(canonical_ids[a].cmp(&canonical_ids[b]))
276                });
277                segment_streams.push(local_indices);
278                cursor = end;
279            }
280        }
281
282        // 7. K-way merge with inline dedup — O(N log K) where K = segment
283        //    count, vs previous O(N log N) global sort. Uses a BinaryHeap
284        //    (min-heap) to merge K pre-sorted streams. For duplicate
285        //    (hash, ts, canonical) keys, only the row from the newest
286        //    segment (highest segment_ord) is emitted.
287        //
288        // Stream the merge directly into chunked output
289        // instead of materializing the full sorted_kept Vec first.
290        // This eliminates O(N) memory for the index vector and avoids
291        // accumulating all output chunks before writing.
292        let chunk_size = self.row_group_size.max(8192);
293        let mut merge_iter = KWayMergeIter::new(
294            &segment_streams,
295            &hashes,
296            &timestamps,
297            &canonical_ids,
298            &canonicals,
299            tombstones,
300            &segment_ids,
301        );
302
303        // 8. Build SORTED output directly from source batches (no
304        //    concat_batches). Collect chunk_size indices at a time from
305        //    the streaming merge, then interleave into output RecordBatches.
306        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        // Pass pre-chunked output directly to finalize_batches,
383        // avoiding the concat_batches → finalize_batch double-materialization.
384        // Each chunk is already row_group_size rows, so writing is streaming.
385        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                // Clean up partial output file to prevent orphaned/corrupt segments.
419                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
434/// Map a global row index to `(batch_index, local_row)`.
435fn 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
448/// Unify schemas from multiple batches into a single superset schema.
449///
450/// Returns an error if two batches define the same column with different
451/// data types (schema evolution conflict).
452///
453/// Nullable flags are widened: a column is nullable in the unified schema
454/// if it is nullable in ANY batch, or if it is absent from ANY batch
455/// (since `align_to_schema` fills missing columns with nulls).
456fn 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                    // Widen to nullable if either side allows nulls
472                    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    // Mark any column absent from at least one batch as nullable,
485    // since align_to_schema fills missing columns with null arrays.
486    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
506/// Align a batch to a target schema, adding null columns for missing fields.
507fn 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            // Add null column
516            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
525/// Create an array of nulls with the given data type and length.
526///
527/// Uses Arrow's built-in `new_null_array` which handles all data types
528/// correctly, including `Timestamp`, `Date`, `Decimal`, etc.
529fn make_null_array(dt: &DataType, len: usize) -> ArrayRef {
530    arrow::array::new_null_array(dt, len)
531}
532
533/// Identify which column names are tags by reading segment column metadata.
534///
535/// Uses the authoritative `role` field from segment metadata (role=1 is TAG)
536/// instead of guessing from Arrow data types, which would misclassify string
537/// fields as tags.
538///
539/// Unions tag columns across **all** readable segments to handle schema
540/// evolution (tags present in later segments but absent from earlier ones).
541fn 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
555/// Compute FNV-1a series hash AND canonical form for each row.
556///
557/// Returns `(hashes, canonicals)` so that the dedup step can compare
558/// canonical forms when hashes collide, preventing cross-series data loss.
559fn 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    // Sort tag columns for deterministic hashing (BTreeMap order)
569    let mut sorted_tags: Vec<&str> = tag_columns.iter().map(String::as_str).collect();
570    sorted_tags.sort_unstable();
571
572    // Pre-fetch tag arrays
573    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    // Cache series key computations — many rows share
583    // the same tag combination, so we avoid redundant BTreeMap + SeriesKey
584    // + hash allocations. Keyed on a collision-free tag-byte string built
585    // directly from the sorted raw column values (avoids the u64 FNV
586    // fingerprint that could silently merge distinct series on collision).
587    let mut cache: HashMap<String, (u64, String)> = HashMap::new();
588
589    for row in 0..num_rows {
590        // Build a collision-free cache key from raw tag bytes.
591        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'); // separator
602        }
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        // Cache miss — build full SeriesKey
611        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        // Construct SeriesKey and compute hash + canonical form
624        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                // Fallback: use row index to avoid dedup collision when
634                // multiple rows fail (should never happen in practice).
635                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
648/// Assign sort-preserving `u32` IDs to canonical series forms.
649///
650/// The returned vector maps each index in `canonicals` to a `u32` that
651/// preserves lexicographic order: `ids[a] < ids[b]` iff
652/// `canonicals[a] < canonicals[b]`. This enables O(1) comparisons in
653/// the K-way merge heap instead of per-byte string comparisons.
654fn 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    // Checked cast prevents silent truncation if >4B unique series.
659    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/// Entry in the K-way merge min-heap.
673///
674/// Ordered by `(hash, ts, canonical_id, segment_ord)`. Wrapped in
675/// `Reverse` so that `BinaryHeap` (a max-heap) yields the smallest
676/// entry first.
677#[derive(Eq, PartialEq)]
678struct MergeEntry {
679    hash: u64,
680    ts: i64,
681    canonical_id: u32,
682    /// Segment ordinal: 0 = oldest, K−1 = newest (dedup priority).
683    segment_ord: usize,
684    /// Global row index into the flattened metadata arrays.
685    global_idx: usize,
686    /// Which sorted stream this entry came from.
687    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
706/// K-way merge of pre-sorted segment streams with inline deduplication.
707///
708/// Each input segment's indices are pre-sorted by `(hash, ts, canonical_id)`.
709/// A `BinaryHeap` (min-heap via `Reverse`) merges K sorted streams in
710/// O(N log K) time. Dedup is inline: for duplicate `(hash, ts, canonical_id)`
711/// tuples, only the row from the newest segment (highest `segment_ord`) is
712/// emitted — achieving last-write-wins semantics.
713///
714/// Rows masked by a tombstone issued against their segment are skipped.
715///
716/// Returns a streaming iterator that yields one global
717/// index at a time instead of materializing the entire merged result.
718/// The caller collects chunk-sized batches for streaming output.
719struct 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    /// TombstoneSet supports both full-series and ranged deletes.
726    tombstones: &'a TombstoneSet,
727    /// The catalog id of each stream's segment: a tombstone masks a row
728    /// only if it was issued against the segment the row comes from.
729    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            // Advance the stream that produced this entry.
784            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            // Check ranged tombstones — supports both full-series and time-range deletes.
800            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            // Dedup: if the next heap entry has the same (hash, ts, canonical_id),
809            // a newer version exists — skip this older one.
810            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        // Segment 1: host=srv1 timestamps 100, 200, 300
881        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        // Segment 2: host=srv1 timestamps 200 (dup!), 400; host=srv2 ts=100
889        let pts2 = vec![
890            make_point("srv1", 99.0, 200), // duplicate with s1
891            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        // Segment 3: host=srv2 timestamps 200
897        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        // 3 + 3 + 1 = 7 input rows, minus 1 duplicate = 6
923        assert_eq!(meta.row_count, 6);
924        assert_eq!(meta.series_count, 2);
925
926        // Read back and verify
927        let reader = SegmentReader::open(&task.output_path).unwrap();
928        let batch = reader.read_all().unwrap();
929        assert_eq!(batch.num_rows(), 6);
930
931        // Verify dedup: srv1@200 should have value 99.0 (last-write-wins = segment 2)
932        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                // Last-write-wins: the value from segment 2 (99.0) wins
942                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        // Tombstone srv1
966        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        // Only srv2 rows should remain
989        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    /// Verify that compacted output has series grouped together with
1047    /// monotonically increasing timestamps within each series.
1048    #[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        // Interleave two series across two segments so concatenation
1055        // order would NOT naturally produce sorted output.
1056        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        // Use small row_group_size to exercise multi-RG path.
1079        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        // Read back and verify within-series timestamp monotonicity.
1085        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        // Collect timestamps per series.
1094        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        // Verify data is grouped: all rows of one series appear before
1116        // the other (same-series rows must be contiguous in the batch).
1117        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    /// Integration stress test: write many segments with overlapping data,
1132    /// compact, and verify every surviving row is correct.
1133    #[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        // Generate 8 segments × 5 series × varying timestamps with overlaps.
1140        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                // Each segment writes 10 timestamps with some overlap across segments.
1150                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                    // Last-write-wins: later segment (higher seg_idx) wins
1156                    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        // Verify row count matches expected deduplicated set.
1177        assert_eq!(
1178            meta.row_count as usize,
1179            expected.len(),
1180            "compacted row count should match deduplicated expected set"
1181        );
1182
1183        // Read back and verify every row.
1184        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        // Verify per-series timestamps are sorted ascending.
1210        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        // Zero timeout — should trigger immediately after first segment read
1279        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    /// Verify K-way merge correctly deduplicates the same key across many
1296    /// segments, keeping only the newest segment's value.
1297    #[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        // 10 segments all writing the same (host=srv1, ts=100) with different values.
1304        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    /// Single segment compaction should pass through without data loss.
1343    #[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    /// Tombstones applied during K-way merge should remove ALL versions of
1376    /// a series, not just the oldest.
1377    #[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        // Three segments all writing srv1 data.
1384        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        // Only srv2 rows should remain (3 rows across 3 segments, no dups).
1425        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    /// K-way merge with all data tombstoned should produce an empty result.
1441    #[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    /// `max_input_bytes` rejects compaction tasks that would
1481    /// exceed the configured memory budget.
1482    #[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        // Set an impossibly small limit (1 byte).
1500        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    /// unlimited (0) allows any input size.
1518    #[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); // unlimited
1543        let result = executor.execute(&task, &TombstoneSet::new());
1544        assert!(result.is_ok(), "unlimited should succeed: {result:?}");
1545    }
1546}