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    /// Per-column encryption: what to write with, and what to read with.
64    ///
65    /// Kept as one field rather than two, so the two cannot be configured
66    /// independently — see [`with_field_encryption`](Self::with_field_encryption).
67    #[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    /// Create an executor with custom settings.
92    #[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    /// Open an input segment with this executor's decryption keys.
114    ///
115    /// Every read of a segment's **data** goes through here; the one place
116    /// that reads only its metadata says so and opens plainly.
117    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    /// Give the executor this database's per-column encryption.
128    ///
129    /// **Both halves, or neither.** A compaction reads input segments and
130    /// writes a new one, so a key provider without the writer config
131    /// decrypts on the way in and writes the column back in **plaintext** —
132    /// a compaction pass that silently undoes the encryption, hours after
133    /// the write that asked for it, with no error anywhere. The writer
134    /// config alone is the mirror image: the read fails closed, which is
135    /// merely broken rather than dangerous.
136    #[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    /// Set the maximum wall-clock time for a single compaction task.
148    #[must_use]
149    pub fn with_task_timeout(mut self, timeout: Duration) -> Self {
150        self.task_timeout = timeout;
151        self
152    }
153
154    /// Set the maximum total bytes for input segments.
155    ///
156    /// When the cumulative in-memory size of loaded `RecordBatch`es
157    /// exceeds this threshold the compaction task is rejected early
158    /// with [`CompactionError::InputTooLarge`].  Pass 0 for unlimited.
159    #[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    /// Execute a compaction task.
166    ///
167    /// Reads all input segments, merges their data, deduplicates on
168    /// `(series_key_hash, timestamp, canonical)`, removes tombstoned series,
169    /// and writes an optimized output segment.
170    ///
171    /// # K-way Merge Architecture
172    ///
173    /// Each segment's row indices are sorted independently by
174    /// `(series_hash, timestamp, canonical_id)`, then merged using a
175    /// K-element `BinaryHeap` (min-heap) in O(N log K) time — versus
176    /// the previous O(N log N) global sort. Deduplication is inline:
177    /// duplicate `(hash, ts, canonical)` keys are resolved by keeping
178    /// only the row from the newest segment (last-write-wins).
179    ///
180    /// Output is assembled column-by-column via `arrow::compute::interleave`
181    /// in row-group-sized chunks, bounding peak memory to
182    /// O(chunk_size × columns) for the output stage.
183    ///
184    /// # Errors
185    ///
186    /// Returns an error if no eligible segments exist, reading/writing
187    /// segments fails, or deduplication encounters an internal error.
188    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        // 1. Read all input segments as RecordBatches.
201        // Sort by segment_id ascending so that later segments (higher IDs,
202        // written more recently) get higher segment_ord in the K-way merge.
203        // This guarantees deterministic last-write-wins dedup semantics:
204        //   - segment_ids are assigned sequentially at flush time
205        //   - the K-way merge heap breaks ties by segment_ord
206        //   - per-key, only the row from the newest segment is emitted
207        // ⚠  Invariant: segment_id must increase monotonically with write time.
208        let mut sorted_entries = task.input_segments.clone();
209        sorted_entries.sort_by_key(|e| e.segment_id);
210
211        // Validate that segment IDs are strictly monotonic.
212        // Duplicate IDs would violate last-write-wins dedup semantics.
213        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                // Reject early if cumulative input exceeds budget.
231                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        // 2. Unify schemas across all batches
251        let unified_schema = unify_schemas(&all_batches)?;
252
253        // 3. Align all batches to the unified schema (consumes originals)
254        let aligned: Vec<RecordBatch> = all_batches
255            .into_iter()
256            .map(|b| align_to_schema(&b, &unified_schema))
257            .collect::<Result<Vec<_>>>()?;
258
259        // 4. Compute metadata across batches WITHOUT concatenation.
260        //    Hashes, canonicals, and timestamps are flattened into parallel
261        //    vectors indexed by a global row number.
262        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        // 5. Assign sort-preserving canonical IDs for O(1) comparison
296        //    during K-way merge. Number of unique canonicals = series count,
297        //    typically ≪ total_rows.
298        let canonical_ids = assign_canonical_ids(&canonicals);
299
300        // 6. Per-segment sort: sort each segment's indices independently
301        //    by (hash, ts, canonical_id). Uses sort_unstable since
302        //    segment_ord in the merge heap handles dedup ordering.
303        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(&timestamps[b]))
319                        .then(canonical_ids[a].cmp(&canonical_ids[b]))
320                });
321                segment_streams.push(local_indices);
322                cursor = end;
323            }
324        }
325
326        // 7. K-way merge with inline dedup — O(N log K) where K = segment
327        //    count, vs previous O(N log N) global sort. Uses a BinaryHeap
328        //    (min-heap) to merge K pre-sorted streams. For duplicate
329        //    (hash, ts, canonical) keys, only the row from the newest
330        //    segment (highest segment_ord) is emitted.
331        //
332        // Stream the merge directly into chunked output
333        // instead of materializing the full sorted_kept Vec first.
334        // This eliminates O(N) memory for the index vector and avoids
335        // accumulating all output chunks before writing.
336        let chunk_size = self.row_group_size.max(8192);
337        let mut merge_iter = KWayMergeIter::new(
338            &segment_streams,
339            &hashes,
340            &timestamps,
341            &canonical_ids,
342            &canonicals,
343            tombstones,
344            &segment_ids,
345        );
346
347        // 8. Build SORTED output directly from source batches (no
348        //    concat_batches). Collect chunk_size indices at a time from
349        //    the streaming merge, then interleave into output RecordBatches.
350        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        // Pass pre-chunked output directly to finalize_batches,
427        // avoiding the concat_batches → finalize_batch double-materialization.
428        // Each chunk is already row_group_size rows, so writing is streaming.
429        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            // Re-encrypt on the way out. Without this the merge decrypts its
443            // inputs and writes the column back in plaintext — an
444            // encryption that a background pass quietly undoes.
445            #[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                // Clean up partial output file to prevent orphaned/corrupt segments.
472                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
487/// Map a global row index to `(batch_index, local_row)`.
488fn 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
501/// Unify schemas from multiple batches into a single superset schema.
502///
503/// Returns an error if two batches define the same column with different
504/// data types (schema evolution conflict).
505///
506/// Nullable flags are widened: a column is nullable in the unified schema
507/// if it is nullable in ANY batch, or if it is absent from ANY batch
508/// (since `align_to_schema` fills missing columns with nulls).
509fn 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                    // Widen to nullable if either side allows nulls
525                    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    // Mark any column absent from at least one batch as nullable,
538    // since align_to_schema fills missing columns with null arrays.
539    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
558/// Align a batch to a target schema, adding null columns for missing fields.
559fn 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            // Add null column
568            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
577/// Create an array of nulls with the given data type and length.
578///
579/// Uses Arrow's built-in `new_null_array` which handles all data types
580/// correctly, including `Timestamp`, `Date`, `Decimal`, etc.
581fn make_null_array(dt: &DataType, len: usize) -> ArrayRef {
582    arrow::array::new_null_array(dt, len)
583}
584
585/// Identify which column names are tags by reading segment column metadata.
586///
587/// Uses the authoritative `role` field from segment metadata (role=1 is TAG)
588/// instead of guessing from Arrow data types, which would misclassify string
589/// fields as tags.
590///
591/// Unions tag columns across **all** readable segments to handle schema
592/// evolution (tags present in later segments but absent from earlier ones).
593fn 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        // No key provider: this reads **column metadata** only — names and
597        // roles — and metadata is never encrypted.
598        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
609/// Compute FNV-1a series hash AND canonical form for each row.
610///
611/// Returns `(hashes, canonicals)` so that the dedup step can compare
612/// canonical forms when hashes collide, preventing cross-series data loss.
613fn 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    // Sort tag columns for deterministic hashing (BTreeMap order)
623    let mut sorted_tags: Vec<&str> = tag_columns.iter().map(String::as_str).collect();
624    sorted_tags.sort_unstable();
625
626    // Pre-fetch tag arrays
627    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    // Cache series key computations — many rows share
637    // the same tag combination, so we avoid redundant BTreeMap + SeriesKey
638    // + hash allocations. Keyed on a collision-free tag-byte string built
639    // directly from the sorted raw column values (avoids the u64 FNV
640    // fingerprint that could silently merge distinct series on collision).
641    let mut cache: HashMap<String, (u64, String)> = HashMap::new();
642
643    for row in 0..num_rows {
644        // Build a collision-free cache key from raw tag bytes.
645        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'); // separator
656        }
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        // Cache miss — build full SeriesKey
665        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        // Construct SeriesKey and compute hash + canonical form
678        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                // Fallback: use row index to avoid dedup collision when
688                // multiple rows fail (should never happen in practice).
689                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
702/// Assign sort-preserving `u32` IDs to canonical series forms.
703///
704/// The returned vector maps each index in `canonicals` to a `u32` that
705/// preserves lexicographic order: `ids[a] < ids[b]` iff
706/// `canonicals[a] < canonicals[b]`. This enables O(1) comparisons in
707/// the K-way merge heap instead of per-byte string comparisons.
708fn 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    // Checked cast prevents silent truncation if >4B unique series.
713    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/// Entry in the K-way merge min-heap.
727///
728/// Ordered by `(hash, ts, canonical_id, segment_ord)`. Wrapped in
729/// `Reverse` so that `BinaryHeap` (a max-heap) yields the smallest
730/// entry first.
731#[derive(Eq, PartialEq)]
732struct MergeEntry {
733    hash: u64,
734    ts: i64,
735    canonical_id: u32,
736    /// Segment ordinal: 0 = oldest, K−1 = newest (dedup priority).
737    segment_ord: usize,
738    /// Global row index into the flattened metadata arrays.
739    global_idx: usize,
740    /// Which sorted stream this entry came from.
741    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
760/// K-way merge of pre-sorted segment streams with inline deduplication.
761///
762/// Each input segment's indices are pre-sorted by `(hash, ts, canonical_id)`.
763/// A `BinaryHeap` (min-heap via `Reverse`) merges K sorted streams in
764/// O(N log K) time. Dedup is inline: for duplicate `(hash, ts, canonical_id)`
765/// tuples, only the row from the newest segment (highest `segment_ord`) is
766/// emitted — achieving last-write-wins semantics.
767///
768/// Rows masked by a tombstone issued against their segment are skipped.
769///
770/// Returns a streaming iterator that yields one global
771/// index at a time instead of materializing the entire merged result.
772/// The caller collects chunk-sized batches for streaming output.
773struct 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    /// TombstoneSet supports both full-series and ranged deletes.
780    tombstones: &'a TombstoneSet,
781    /// The catalog id of each stream's segment: a tombstone masks a row
782    /// only if it was issued against the segment the row comes from.
783    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            // Advance the stream that produced this entry.
838            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            // Check ranged tombstones — supports both full-series and time-range deletes.
854            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            // Dedup: if the next heap entry has the same (hash, ts, canonical_id),
863            // a newer version exists — skip this older one.
864            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    /// Write a segment where a real flush would put it — `<root>/shard_0/` —
895    /// and return the catalog's view of it, which is relative to `root`.
896    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        // Segment 1: host=srv1 timestamps 100, 200, 300
943        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        // Segment 2: host=srv1 timestamps 200 (dup!), 400; host=srv2 ts=100
951        let pts2 = vec![
952            make_point("srv1", 99.0, 200), // duplicate with s1
953            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        // Segment 3: host=srv2 timestamps 200
959        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        // 3 + 3 + 1 = 7 input rows, minus 1 duplicate = 6
986        assert_eq!(meta.row_count, 6);
987        assert_eq!(meta.series_count, 2);
988
989        // Read back and verify
990        let reader = SegmentReader::open(task.output_path()).unwrap();
991        let batch = reader.read_all().unwrap();
992        assert_eq!(batch.num_rows(), 6);
993
994        // Verify dedup: srv1@200 should have value 99.0 (last-write-wins = segment 2)
995        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                // Last-write-wins: the value from segment 2 (99.0) wins
1005                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        // Tombstone srv1
1027        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        // Only srv2 rows should remain
1051        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    /// Verify that compacted output has series grouped together with
1109    /// monotonically increasing timestamps within each series.
1110    #[test]
1111    fn compact_output_sorted_by_series_then_time() {
1112        let dir = tempfile::tempdir().unwrap();
1113
1114        // Interleave two series across two segments so concatenation
1115        // order would NOT naturally produce sorted output.
1116        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        // Use small row_group_size to exercise multi-RG path.
1140        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        // Read back and verify within-series timestamp monotonicity.
1146        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        // Collect timestamps per series.
1155        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        // Verify data is grouped: all rows of one series appear before
1177        // the other (same-series rows must be contiguous in the batch).
1178        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    /// Integration stress test: write many segments with overlapping data,
1193    /// compact, and verify every surviving row is correct.
1194    #[test]
1195    fn compact_stress_many_segments_many_series() {
1196        let dir = tempfile::tempdir().unwrap();
1197
1198        // Generate 8 segments × 5 series × varying timestamps with overlaps.
1199        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                // Each segment writes 10 timestamps with some overlap across segments.
1209                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                    // Last-write-wins: later segment (higher seg_idx) wins
1215                    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        // Verify row count matches expected deduplicated set.
1237        assert_eq!(
1238            meta.row_count as usize,
1239            expected.len(),
1240            "compacted row count should match deduplicated expected set"
1241        );
1242
1243        // Read back and verify every row.
1244        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        // Verify per-series timestamps are sorted ascending.
1270        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        // Zero timeout — should trigger immediately after first segment read
1337        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    /// Verify K-way merge correctly deduplicates the same key across many
1354    /// segments, keeping only the newest segment's value.
1355    #[test]
1356    fn kway_merge_dedup_many_segments_same_key() {
1357        let dir = tempfile::tempdir().unwrap();
1358
1359        // 10 segments all writing the same (host=srv1, ts=100) with different values.
1360        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    /// Single segment compaction should pass through without data loss.
1400    #[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    /// Tombstones applied during K-way merge should remove ALL versions of
1432    /// a series, not just the oldest.
1433    #[test]
1434    fn kway_merge_tombstone_removes_all_versions() {
1435        let dir = tempfile::tempdir().unwrap();
1436
1437        // Three segments all writing srv1 data.
1438        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        // Only srv2 rows should remain (3 rows across 3 segments, no dups).
1480        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    /// K-way merge with all data tombstoned should produce an empty result.
1496    #[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    /// `max_input_bytes` rejects compaction tasks that would
1535    /// exceed the configured memory budget.
1536    #[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        // Set an impossibly small limit (1 byte).
1553        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    /// unlimited (0) allows any input size.
1571    #[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); // unlimited
1595        let result = executor.execute(&task, &TombstoneSet::new());
1596        assert!(result.is_ok(), "unlimited should succeed: {result:?}");
1597    }
1598}