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                    if let Err(rm_err) = std::fs::remove_file(task.output_path()) {
474                        tracing::warn!(
475                            path = %task.output_path().display(),
476                            error = %rm_err,
477                            "failed to clean up partial compaction output"
478                        );
479                    }
480                }
481                Err(e)
482            }
483        }
484    }
485}
486
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                if let Some(f) = fields.get_mut(name) {
547                    if !f.is_nullable() {
548                        *f = f.clone().with_nullable(true);
549                    }
550                }
551            }
552        }
553    }
554
555    let field_vec: Vec<Field> = fields.into_values().collect();
556    Ok(Arc::new(Schema::new(field_vec)))
557}
558
559/// Align a batch to a target schema, adding null columns for missing fields.
560fn align_to_schema(batch: &RecordBatch, target: &Arc<Schema>) -> Result<RecordBatch> {
561    let num_rows = batch.num_rows();
562    let mut columns: Vec<ArrayRef> = Vec::with_capacity(target.fields().len());
563
564    for field in target.fields() {
565        if let Some(col) = batch.column_by_name(field.name()) {
566            columns.push(col.clone());
567        } else {
568            // Add null column
569            let null_col = make_null_array(field.data_type(), num_rows);
570            columns.push(null_col);
571        }
572    }
573
574    RecordBatch::try_new(target.clone(), columns)
575        .map_err(|e| CompactionError::Internal(format!("Failed to align batch to schema: {e}")))
576}
577
578/// Create an array of nulls with the given data type and length.
579///
580/// Uses Arrow's built-in `new_null_array` which handles all data types
581/// correctly, including `Timestamp`, `Date`, `Decimal`, etc.
582fn make_null_array(dt: &DataType, len: usize) -> ArrayRef {
583    arrow::array::new_null_array(dt, len)
584}
585
586/// Identify which column names are tags by reading segment column metadata.
587///
588/// Uses the authoritative `role` field from segment metadata (role=1 is TAG)
589/// instead of guessing from Arrow data types, which would misclassify string
590/// fields as tags.
591///
592/// Unions tag columns across **all** readable segments to handle schema
593/// evolution (tags present in later segments but absent from earlier ones).
594fn find_tag_columns_from_segments(task: &CompactionTask) -> Vec<String> {
595    let mut all_tags = std::collections::BTreeSet::new();
596    for entry in &task.input_segments {
597        // No key provider: this reads **column metadata** only — names and
598        // roles — and metadata is never encrypted.
599        if let Ok(reader) = SegmentReader::open(entry.file.resolve(&task.segments_dir)) {
600            for c in reader.column_metadata() {
601                if c.role == roles::TAG {
602                    all_tags.insert(c.name.clone());
603                }
604            }
605        }
606    }
607    all_tags.into_iter().collect()
608}
609
610/// Compute FNV-1a series hash AND canonical form for each row.
611///
612/// Returns `(hashes, canonicals)` so that the dedup step can compare
613/// canonical forms when hashes collide, preventing cross-series data loss.
614fn compute_row_hashes_and_canonicals(
615    batch: &RecordBatch,
616    measurement: &str,
617    tag_columns: &[String],
618) -> (Vec<u64>, Vec<String>) {
619    let num_rows = batch.num_rows();
620    let mut hashes = Vec::with_capacity(num_rows);
621    let mut canonicals = Vec::with_capacity(num_rows);
622
623    // Sort tag columns for deterministic hashing (BTreeMap order)
624    let mut sorted_tags: Vec<&str> = tag_columns.iter().map(String::as_str).collect();
625    sorted_tags.sort_unstable();
626
627    // Pre-fetch tag arrays
628    let tag_arrays: Vec<Option<&StringArray>> = sorted_tags
629        .iter()
630        .map(|name| {
631            batch
632                .column_by_name(name)
633                .and_then(|c| c.as_any().downcast_ref::<StringArray>())
634        })
635        .collect();
636
637    // Cache series key computations — many rows share
638    // the same tag combination, so we avoid redundant BTreeMap + SeriesKey
639    // + hash allocations. Keyed on a collision-free tag-byte string built
640    // directly from the sorted raw column values (avoids the u64 FNV
641    // fingerprint that could silently merge distinct series on collision).
642    let mut cache: HashMap<String, (u64, String)> = HashMap::new();
643
644    for row in 0..num_rows {
645        // Build a collision-free cache key from raw tag bytes.
646        let mut cache_key = String::new();
647        for (tag_idx, _) in sorted_tags.iter().enumerate() {
648            if let Some(arr) = tag_arrays[tag_idx] {
649                if !arr.is_null(row) {
650                    let val = arr.value(row);
651                    if !val.is_empty() {
652                        cache_key.push_str(val);
653                    }
654                }
655            }
656            cache_key.push('\0'); // separator
657        }
658
659        if let Some((h, c)) = cache.get(&cache_key) {
660            hashes.push(*h);
661            canonicals.push(c.clone());
662            continue;
663        }
664
665        // Cache miss — build full SeriesKey
666        let mut tags = BTreeMap::new();
667        for (tag_idx, tag_name) in sorted_tags.iter().enumerate() {
668            if let Some(arr) = tag_arrays[tag_idx] {
669                if !arr.is_null(row) {
670                    let val = arr.value(row);
671                    if !val.is_empty() {
672                        tags.insert((*tag_name).to_string(), val.to_string());
673                    }
674                }
675            }
676        }
677
678        // Construct SeriesKey and compute hash + canonical form
679        match chronix_core::SeriesKey::new(measurement, tags) {
680            Ok(key) => {
681                let h = key.hash_fnv();
682                let c = key.canonical_form().to_string();
683                cache.insert(cache_key, (h, c.clone()));
684                hashes.push(h);
685                canonicals.push(c);
686            }
687            Err(_) => {
688                // Fallback: use row index to avoid dedup collision when
689                // multiple rows fail (should never happen in practice).
690                use std::hash::{Hash, Hasher};
691                let mut h = fnv::FnvHasher::default();
692                row.hash(&mut h);
693                measurement.hash(&mut h);
694                hashes.push(h.finish());
695                canonicals.push(format!("__fallback_{row}"));
696            }
697        }
698    }
699
700    (hashes, canonicals)
701}
702
703/// Assign sort-preserving `u32` IDs to canonical series forms.
704///
705/// The returned vector maps each index in `canonicals` to a `u32` that
706/// preserves lexicographic order: `ids[a] < ids[b]` iff
707/// `canonicals[a] < canonicals[b]`. This enables O(1) comparisons in
708/// the K-way merge heap instead of per-byte string comparisons.
709fn assign_canonical_ids(canonicals: &[String]) -> Vec<u32> {
710    let mut unique: Vec<&str> = canonicals.iter().map(String::as_str).collect();
711    unique.sort_unstable();
712    unique.dedup();
713    // Checked cast prevents silent truncation if >4B unique series.
714    let id_map: HashMap<&str, u32> = unique
715        .iter()
716        .enumerate()
717        .map(|(i, &s)| {
718            (
719                s,
720                u32::try_from(i).expect("more than u32::MAX unique canonical series"),
721            )
722        })
723        .collect();
724    canonicals.iter().map(|s| id_map[s.as_str()]).collect()
725}
726
727/// Entry in the K-way merge min-heap.
728///
729/// Ordered by `(hash, ts, canonical_id, segment_ord)`. Wrapped in
730/// `Reverse` so that `BinaryHeap` (a max-heap) yields the smallest
731/// entry first.
732#[derive(Eq, PartialEq)]
733struct MergeEntry {
734    hash: u64,
735    ts: i64,
736    canonical_id: u32,
737    /// Segment ordinal: 0 = oldest, K−1 = newest (dedup priority).
738    segment_ord: usize,
739    /// Global row index into the flattened metadata arrays.
740    global_idx: usize,
741    /// Which sorted stream this entry came from.
742    stream_idx: usize,
743}
744
745impl Ord for MergeEntry {
746    fn cmp(&self, other: &Self) -> Ordering {
747        self.hash
748            .cmp(&other.hash)
749            .then(self.ts.cmp(&other.ts))
750            .then(self.canonical_id.cmp(&other.canonical_id))
751            .then(self.segment_ord.cmp(&other.segment_ord))
752    }
753}
754
755impl PartialOrd for MergeEntry {
756    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
757        Some(self.cmp(other))
758    }
759}
760
761/// K-way merge of pre-sorted segment streams with inline deduplication.
762///
763/// Each input segment's indices are pre-sorted by `(hash, ts, canonical_id)`.
764/// A `BinaryHeap` (min-heap via `Reverse`) merges K sorted streams in
765/// O(N log K) time. Dedup is inline: for duplicate `(hash, ts, canonical_id)`
766/// tuples, only the row from the newest segment (highest `segment_ord`) is
767/// emitted — achieving last-write-wins semantics.
768///
769/// Rows masked by a tombstone issued against their segment are skipped.
770///
771/// Returns a streaming iterator that yields one global
772/// index at a time instead of materializing the entire merged result.
773/// The caller collects chunk-sized batches for streaming output.
774struct KWayMergeIter<'a> {
775    segment_streams: &'a [Vec<usize>],
776    hashes: &'a [u64],
777    timestamps: &'a [i64],
778    canonical_ids: &'a [u32],
779    canonicals: &'a [String],
780    /// TombstoneSet supports both full-series and ranged deletes.
781    tombstones: &'a TombstoneSet,
782    /// The catalog id of each stream's segment: a tombstone masks a row
783    /// only if it was issued against the segment the row comes from.
784    segment_ids: &'a [u64],
785    positions: Vec<usize>,
786    heap: BinaryHeap<Reverse<MergeEntry>>,
787}
788
789impl<'a> KWayMergeIter<'a> {
790    fn new(
791        segment_streams: &'a [Vec<usize>],
792        hashes: &'a [u64],
793        timestamps: &'a [i64],
794        canonical_ids: &'a [u32],
795        canonicals: &'a [String],
796        tombstones: &'a TombstoneSet,
797        segment_ids: &'a [u64],
798    ) -> Self {
799        let k = segment_streams.len();
800        let mut positions = vec![0usize; k];
801        let mut heap = BinaryHeap::with_capacity(k);
802
803        for (stream_idx, stream) in segment_streams.iter().enumerate() {
804            if let Some(&gi) = stream.first() {
805                heap.push(Reverse(MergeEntry {
806                    hash: hashes[gi],
807                    ts: timestamps[gi],
808                    canonical_id: canonical_ids[gi],
809                    segment_ord: stream_idx,
810                    global_idx: gi,
811                    stream_idx,
812                }));
813                positions[stream_idx] = 1;
814            }
815        }
816
817        Self {
818            segment_streams,
819            hashes,
820            timestamps,
821            canonical_ids,
822            canonicals,
823            tombstones,
824            segment_ids,
825            positions,
826            heap,
827        }
828    }
829}
830
831impl Iterator for KWayMergeIter<'_> {
832    type Item = usize;
833
834    fn next(&mut self) -> Option<usize> {
835        loop {
836            let Reverse(entry) = self.heap.pop()?;
837
838            // Advance the stream that produced this entry.
839            let stream = &self.segment_streams[entry.stream_idx];
840            let pos = &mut self.positions[entry.stream_idx];
841            if *pos < stream.len() {
842                let gi = stream[*pos];
843                *pos += 1;
844                self.heap.push(Reverse(MergeEntry {
845                    hash: self.hashes[gi],
846                    ts: self.timestamps[gi],
847                    canonical_id: self.canonical_ids[gi],
848                    segment_ord: entry.stream_idx,
849                    global_idx: gi,
850                    stream_idx: entry.stream_idx,
851                }));
852            }
853
854            // Check ranged tombstones — supports both full-series and time-range deletes.
855            if self.tombstones.is_tombstoned_in(
856                &self.canonicals[entry.global_idx],
857                self.timestamps[entry.global_idx],
858                self.segment_ids[entry.stream_idx],
859            ) {
860                continue;
861            }
862
863            // Dedup: if the next heap entry has the same (hash, ts, canonical_id),
864            // a newer version exists — skip this older one.
865            if let Some(Reverse(next)) = self.heap.peek() {
866                if entry.hash == next.hash
867                    && entry.ts == next.ts
868                    && entry.canonical_id == next.canonical_id
869                {
870                    continue;
871                }
872            }
873
874            return Some(entry.global_idx);
875        }
876    }
877}
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882    use crate::compaction::picker::CompactionTask;
883    use crate::index::SegmentCatalogEntry;
884    use chronix_core::{
885        FieldValue, Point, SegmentFile, SegmentId, SegmentState, SeriesKey, ShardId,
886    };
887    use std::path::{Path, PathBuf};
888
889    fn make_point(host: &str, value: f64, ts: i64) -> Point {
890        let tags = BTreeMap::from([("host".to_string(), host.to_string())]);
891        let fields = BTreeMap::from([("cpu".to_string(), FieldValue::F64(value))]);
892        let key = SeriesKey::new("cpu_usage", tags).unwrap();
893        Point::new(key, fields, ts).unwrap()
894    }
895
896    /// Write a segment where a real flush would put it — `<root>/shard_0/` —
897    /// and return the catalog's view of it, which is relative to `root`.
898    fn write_segment(root: &Path, name: &str, points: &[Point]) -> (SegmentFile, SegmentMeta) {
899        let file = SegmentFile::new(ShardId(0), name).unwrap();
900        let path = file.resolve(root);
901        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
902        let config = SegmentWriterConfig {
903            row_group_size: 1000,
904            compress: false,
905            float_encoding: FloatEncoding::Gorilla,
906            compression_codec: chronix_core::CompressionCodec::Lz4,
907            zstd_level: 3,
908            column_codec_overrides: std::collections::HashMap::new(),
909            ..Default::default()
910        };
911        let mut writer = SegmentWriter::new(&path, config).unwrap();
912        writer.write_rows(points).unwrap();
913        let meta = writer.finalize().unwrap();
914        (file, meta)
915    }
916
917    fn make_entry(
918        id: u64,
919        shard: i64,
920        file: SegmentFile,
921        meta: &SegmentMeta,
922    ) -> SegmentCatalogEntry {
923        SegmentCatalogEntry {
924            segment_id: SegmentId(id),
925            shard_id: ShardId(shard),
926            measurement: "cpu_usage".to_string(),
927            file,
928            min_timestamp: meta.min_timestamp,
929            max_timestamp: meta.max_timestamp,
930            row_count: meta.row_count,
931            series_count: meta.series_count,
932            byte_size: meta.byte_size,
933            row_group_count: meta.row_group_count,
934            column_count: meta.column_count,
935            column_stats: Vec::new(),
936            state: SegmentState::default(),
937        }
938    }
939
940    #[test]
941    fn compact_three_overlapping_segments() {
942        let dir = tempfile::tempdir().unwrap();
943
944        // Segment 1: host=srv1 timestamps 100, 200, 300
945        let pts1 = vec![
946            make_point("srv1", 1.0, 100),
947            make_point("srv1", 2.0, 200),
948            make_point("srv1", 3.0, 300),
949        ];
950        let (p1, m1) = write_segment(dir.path(), "s1.csx", &pts1);
951
952        // Segment 2: host=srv1 timestamps 200 (dup!), 400; host=srv2 ts=100
953        let pts2 = vec![
954            make_point("srv1", 99.0, 200), // duplicate with s1
955            make_point("srv1", 4.0, 400),
956            make_point("srv2", 10.0, 100),
957        ];
958        let (p2, m2) = write_segment(dir.path(), "s2.csx", &pts2);
959
960        // Segment 3: host=srv2 timestamps 200
961        let pts3 = vec![make_point("srv2", 20.0, 200)];
962        let (p3, m3) = write_segment(dir.path(), "s3.csx", &pts3);
963
964        let task = CompactionTask {
965            shard_id: ShardId(0),
966            input_segments: vec![
967                make_entry(1, 0, p1, &m1),
968                make_entry(2, 0, p2, &m2),
969                make_entry(3, 0, p3, &m3),
970            ],
971            source_level: crate::compaction::CompactionLevel::L0,
972            target_level: crate::compaction::CompactionLevel::L1,
973            segments_dir: dir.path().to_path_buf(),
974            output: SegmentFile::new(ShardId(0), "compacted.csx").unwrap(),
975        };
976
977        let executor = CompactionExecutor::new(
978            false,
979            FloatEncoding::Gorilla,
980            1000,
981            CompressionCodec::Lz4,
982            3,
983        );
984        let tombstones = TombstoneSet::new();
985        let meta = executor.execute(&task, &tombstones).unwrap();
986
987        // 3 + 3 + 1 = 7 input rows, minus 1 duplicate = 6
988        assert_eq!(meta.row_count, 6);
989        assert_eq!(meta.series_count, 2);
990
991        // Read back and verify
992        let reader = SegmentReader::open(task.output_path()).unwrap();
993        let batch = reader.read_all().unwrap();
994        assert_eq!(batch.num_rows(), 6);
995
996        // Verify dedup: srv1@200 should have value 99.0 (last-write-wins = segment 2)
997        let host_col = batch.column_by_name("host").unwrap();
998        let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
999        let ts_col = batch.column_by_name(chronix_core::TIME_COLUMN).unwrap();
1000        let ts_arr = ts_col.as_any().downcast_ref::<Int64Array>().unwrap();
1001        let cpu_col = batch.column_by_name("cpu").unwrap();
1002        let cpu_arr = cpu_col.as_any().downcast_ref::<Float64Array>().unwrap();
1003
1004        for i in 0..batch.num_rows() {
1005            if host_arr.value(i) == "srv1" && ts_arr.value(i) == 200 {
1006                // Last-write-wins: the value from segment 2 (99.0) wins
1007                assert!(
1008                    (cpu_arr.value(i) - 99.0).abs() < f64::EPSILON,
1009                    "Expected 99.0 for srv1@200, got {}",
1010                    cpu_arr.value(i)
1011                );
1012            }
1013        }
1014    }
1015
1016    #[test]
1017    fn compact_with_tombstone_cleanup() {
1018        let dir = tempfile::tempdir().unwrap();
1019
1020        let pts = vec![
1021            make_point("srv1", 1.0, 100),
1022            make_point("srv1", 2.0, 200),
1023            make_point("srv2", 10.0, 100),
1024            make_point("srv2", 20.0, 200),
1025        ];
1026        let (path, meta) = write_segment(dir.path(), "seg.csx", &pts);
1027
1028        // Tombstone srv1
1029        let srv1_key = SeriesKey::new(
1030            "cpu_usage",
1031            BTreeMap::from([("host".to_string(), "srv1".to_string())]),
1032        )
1033        .unwrap();
1034        let mut tombstones = TombstoneSet::new();
1035        tombstones.insert(
1036            chronix_core::Tombstone::all_time(srv1_key.canonical_form().to_string())
1037                .with_segments(0..1000),
1038        );
1039
1040        let task = CompactionTask {
1041            shard_id: ShardId(0),
1042            input_segments: vec![make_entry(1, 0, path, &meta)],
1043            source_level: crate::compaction::CompactionLevel::L0,
1044            target_level: crate::compaction::CompactionLevel::L1,
1045            segments_dir: dir.path().to_path_buf(),
1046            output: SegmentFile::new(ShardId(0), "cleaned.csx").unwrap(),
1047        };
1048
1049        let executor = CompactionExecutor::default();
1050        let result = executor.execute(&task, &tombstones).unwrap();
1051
1052        // Only srv2 rows should remain
1053        assert_eq!(result.row_count, 2);
1054
1055        let reader = SegmentReader::open(task.output_path()).unwrap();
1056        let batch = reader.read_all().unwrap();
1057        let host_col = batch.column_by_name("host").unwrap();
1058        let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
1059        for i in 0..batch.num_rows() {
1060            assert_eq!(host_arr.value(i), "srv2");
1061        }
1062    }
1063
1064    #[test]
1065    fn compact_empty_segments_returns_error() {
1066        let task = CompactionTask {
1067            shard_id: ShardId(0),
1068            input_segments: vec![],
1069            source_level: crate::compaction::CompactionLevel::L0,
1070            target_level: crate::compaction::CompactionLevel::L1,
1071            segments_dir: PathBuf::from("/tmp"),
1072            output: SegmentFile::new(ShardId(0), "empty.csx").unwrap(),
1073        };
1074
1075        let executor = CompactionExecutor::default();
1076        let result = executor.execute(&task, &TombstoneSet::new());
1077        assert!(result.is_err());
1078    }
1079
1080    #[test]
1081    fn compact_preserves_all_data_when_no_duplicates() {
1082        let dir = tempfile::tempdir().unwrap();
1083
1084        let pts1 = vec![make_point("srv1", 1.0, 100), make_point("srv1", 2.0, 200)];
1085        let pts2 = vec![make_point("srv1", 3.0, 300), make_point("srv1", 4.0, 400)];
1086
1087        let (p1, m1) = write_segment(dir.path(), "s1.csx", &pts1);
1088        let (p2, m2) = write_segment(dir.path(), "s2.csx", &pts2);
1089
1090        let task = CompactionTask {
1091            shard_id: ShardId(0),
1092            input_segments: vec![make_entry(1, 0, p1, &m1), make_entry(2, 0, p2, &m2)],
1093            source_level: crate::compaction::CompactionLevel::L0,
1094            target_level: crate::compaction::CompactionLevel::L1,
1095            segments_dir: dir.path().to_path_buf(),
1096            output: SegmentFile::new(ShardId(0), "no_dup.csx").unwrap(),
1097        };
1098
1099        let executor = CompactionExecutor::new(
1100            false,
1101            FloatEncoding::Gorilla,
1102            1000,
1103            CompressionCodec::Lz4,
1104            3,
1105        );
1106        let meta = executor.execute(&task, &TombstoneSet::new()).unwrap();
1107        assert_eq!(meta.row_count, 4);
1108    }
1109
1110    /// Verify that compacted output has series grouped together with
1111    /// monotonically increasing timestamps within each series.
1112    #[test]
1113    fn compact_output_sorted_by_series_then_time() {
1114        let dir = tempfile::tempdir().unwrap();
1115
1116        // Interleave two series across two segments so concatenation
1117        // order would NOT naturally produce sorted output.
1118        let pts1 = vec![
1119            make_point("srv1", 1.0, 300),
1120            make_point("srv2", 2.0, 100),
1121            make_point("srv1", 3.0, 100),
1122        ];
1123        let pts2 = vec![
1124            make_point("srv2", 4.0, 400),
1125            make_point("srv1", 5.0, 500),
1126            make_point("srv2", 6.0, 200),
1127        ];
1128
1129        let (p1, m1) = write_segment(dir.path(), "s1.csx", &pts1);
1130        let (p2, m2) = write_segment(dir.path(), "s2.csx", &pts2);
1131
1132        let task = CompactionTask {
1133            shard_id: ShardId(0),
1134            input_segments: vec![make_entry(1, 0, p1, &m1), make_entry(2, 0, p2, &m2)],
1135            source_level: crate::compaction::CompactionLevel::L0,
1136            target_level: crate::compaction::CompactionLevel::L1,
1137            segments_dir: dir.path().to_path_buf(),
1138            output: SegmentFile::new(ShardId(0), "sorted.csx").unwrap(),
1139        };
1140
1141        // Use small row_group_size to exercise multi-RG path.
1142        let executor =
1143            CompactionExecutor::new(false, FloatEncoding::Gorilla, 3, CompressionCodec::Lz4, 3);
1144        let meta = executor.execute(&task, &TombstoneSet::new()).unwrap();
1145        assert_eq!(meta.row_count, 6);
1146
1147        // Read back and verify within-series timestamp monotonicity.
1148        let reader = SegmentReader::open(task.output_path()).unwrap();
1149        let batch = reader.read_all().unwrap();
1150
1151        let host_col = batch.column_by_name("host").unwrap();
1152        let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
1153        let ts_col = batch.column_by_name(chronix_core::TIME_COLUMN).unwrap();
1154        let ts_arr = ts_col.as_any().downcast_ref::<Int64Array>().unwrap();
1155
1156        // Collect timestamps per series.
1157        let mut srv1_ts = Vec::new();
1158        let mut srv2_ts = Vec::new();
1159        for i in 0..batch.num_rows() {
1160            match host_arr.value(i) {
1161                "srv1" => srv1_ts.push(ts_arr.value(i)),
1162                "srv2" => srv2_ts.push(ts_arr.value(i)),
1163                other => panic!("unexpected host: {other}"),
1164            }
1165        }
1166
1167        assert_eq!(
1168            srv1_ts,
1169            vec![100, 300, 500],
1170            "srv1 timestamps should be sorted"
1171        );
1172        assert_eq!(
1173            srv2_ts,
1174            vec![100, 200, 400],
1175            "srv2 timestamps should be sorted"
1176        );
1177
1178        // Verify data is grouped: all rows of one series appear before
1179        // the other (same-series rows must be contiguous in the batch).
1180        let mut seen_series: Vec<String> = Vec::new();
1181        for i in 0..batch.num_rows() {
1182            let h = host_arr.value(i).to_string();
1183            if seen_series.last() != Some(&h) {
1184                seen_series.push(h);
1185            }
1186        }
1187        assert_eq!(
1188            seen_series.len(),
1189            2,
1190            "series should be grouped (got transitions: {seen_series:?})"
1191        );
1192    }
1193
1194    /// Integration stress test: write many segments with overlapping data,
1195    /// compact, and verify every surviving row is correct.
1196    #[test]
1197    fn compact_stress_many_segments_many_series() {
1198        let dir = tempfile::tempdir().unwrap();
1199
1200        // Generate 8 segments × 5 series × varying timestamps with overlaps.
1201        let hosts = ["web1", "web2", "db1", "db2", "cache1"];
1202        let num_segments = 8;
1203        let mut segments = Vec::new();
1204        let mut expected: std::collections::HashMap<(String, i64), f64> =
1205            std::collections::HashMap::new();
1206
1207        for seg_idx in 0..num_segments {
1208            let mut points = Vec::new();
1209            for (h_idx, host) in hosts.iter().enumerate() {
1210                // Each segment writes 10 timestamps with some overlap across segments.
1211                let base_ts = (seg_idx * 5 + h_idx * 3) as i64 * 1000;
1212                for t in 0..10 {
1213                    let ts = base_ts + t * 100;
1214                    let value = seg_idx as f64 * 1000.0 + h_idx as f64 * 100.0 + t as f64;
1215                    points.push(make_point(host, value, ts));
1216                    // Last-write-wins: later segment (higher seg_idx) wins
1217                    expected.insert((host.to_string(), ts), value);
1218                }
1219            }
1220            let name = format!("seg_{seg_idx}.csx");
1221            let (path, meta) = write_segment(dir.path(), &name, &points);
1222            segments.push(make_entry(seg_idx as u64, 0, path, &meta));
1223        }
1224
1225        let task = CompactionTask {
1226            shard_id: ShardId(0),
1227            input_segments: segments,
1228            source_level: crate::compaction::CompactionLevel::L0,
1229            target_level: crate::compaction::CompactionLevel::L1,
1230            segments_dir: dir.path().to_path_buf(),
1231            output: SegmentFile::new(ShardId(0), "stress.csx").unwrap(),
1232        };
1233
1234        let executor =
1235            CompactionExecutor::new(true, FloatEncoding::Chimp, 50, CompressionCodec::Lz4, 3);
1236        let meta = executor.execute(&task, &TombstoneSet::new()).unwrap();
1237
1238        // Verify row count matches expected deduplicated set.
1239        assert_eq!(
1240            meta.row_count as usize,
1241            expected.len(),
1242            "compacted row count should match deduplicated expected set"
1243        );
1244
1245        // Read back and verify every row.
1246        let reader = SegmentReader::open(task.output_path()).unwrap();
1247        let batch = reader.read_all().unwrap();
1248        assert_eq!(batch.num_rows(), expected.len());
1249
1250        let host_col = batch.column_by_name("host").unwrap();
1251        let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
1252        let ts_col = batch.column_by_name(chronix_core::TIME_COLUMN).unwrap();
1253        let ts_arr = ts_col.as_any().downcast_ref::<Int64Array>().unwrap();
1254        let cpu_col = batch.column_by_name("cpu").unwrap();
1255        let cpu_arr = cpu_col.as_any().downcast_ref::<Float64Array>().unwrap();
1256
1257        for i in 0..batch.num_rows() {
1258            let key = (host_arr.value(i).to_string(), ts_arr.value(i));
1259            let expected_val = expected
1260                .get(&key)
1261                .unwrap_or_else(|| panic!("unexpected row: host={}, ts={}", key.0, key.1));
1262            assert!(
1263                (cpu_arr.value(i) - expected_val).abs() < f64::EPSILON,
1264                "row {i}: host={}, ts={}: expected {expected_val}, got {}",
1265                key.0,
1266                key.1,
1267                cpu_arr.value(i),
1268            );
1269        }
1270
1271        // Verify per-series timestamps are sorted ascending.
1272        let mut series_ts: std::collections::HashMap<String, Vec<i64>> =
1273            std::collections::HashMap::new();
1274        for i in 0..batch.num_rows() {
1275            series_ts
1276                .entry(host_arr.value(i).to_string())
1277                .or_default()
1278                .push(ts_arr.value(i));
1279        }
1280        for (host, timestamps) in &series_ts {
1281            for w in timestamps.windows(2) {
1282                assert!(
1283                    w[0] <= w[1],
1284                    "series {host} is not sorted: {} > {}",
1285                    w[0],
1286                    w[1]
1287                );
1288            }
1289        }
1290        assert_eq!(series_ts.len(), hosts.len(), "all series should be present");
1291    }
1292
1293    #[test]
1294    fn compact_succeeds_within_generous_timeout() {
1295        let dir = tempfile::tempdir().unwrap();
1296
1297        let pts = vec![make_point("srv1", 1.0, 100)];
1298        let (p1, m1) = write_segment(dir.path(), "seg.csx", &pts);
1299
1300        let task = CompactionTask {
1301            shard_id: ShardId(0),
1302            input_segments: vec![make_entry(1, 0, p1, &m1)],
1303            source_level: crate::compaction::CompactionLevel::L0,
1304            target_level: crate::compaction::CompactionLevel::L1,
1305            segments_dir: dir.path().to_path_buf(),
1306            output: SegmentFile::new(ShardId(0), "out.csx").unwrap(),
1307        };
1308
1309        let executor = CompactionExecutor::new(
1310            false,
1311            FloatEncoding::Gorilla,
1312            1000,
1313            CompressionCodec::Lz4,
1314            3,
1315        )
1316        .with_task_timeout(Duration::from_secs(60));
1317
1318        let result = executor.execute(&task, &TombstoneSet::new());
1319        assert!(result.is_ok(), "compaction should finish within 60s");
1320    }
1321
1322    #[test]
1323    fn compact_timeout_zero_triggers_task_timeout() {
1324        let dir = tempfile::tempdir().unwrap();
1325
1326        let pts = vec![make_point("srv1", 1.0, 100)];
1327        let (p1, m1) = write_segment(dir.path(), "seg.csx", &pts);
1328
1329        let task = CompactionTask {
1330            shard_id: ShardId(0),
1331            input_segments: vec![make_entry(1, 0, p1, &m1)],
1332            source_level: crate::compaction::CompactionLevel::L0,
1333            target_level: crate::compaction::CompactionLevel::L1,
1334            segments_dir: dir.path().to_path_buf(),
1335            output: SegmentFile::new(ShardId(0), "out.csx").unwrap(),
1336        };
1337
1338        // Zero timeout — should trigger immediately after first segment read
1339        let executor = CompactionExecutor::new(
1340            false,
1341            FloatEncoding::Gorilla,
1342            1000,
1343            CompressionCodec::Lz4,
1344            3,
1345        )
1346        .with_task_timeout(Duration::ZERO);
1347
1348        let result = executor.execute(&task, &TombstoneSet::new());
1349        assert!(
1350            matches!(result, Err(CompactionError::TaskTimeout(_))),
1351            "expected TaskTimeout, got {result:?}"
1352        );
1353    }
1354
1355    /// Verify K-way merge correctly deduplicates the same key across many
1356    /// segments, keeping only the newest segment's value.
1357    #[test]
1358    fn kway_merge_dedup_many_segments_same_key() {
1359        let dir = tempfile::tempdir().unwrap();
1360
1361        // 10 segments all writing the same (host=srv1, ts=100) with different values.
1362        let mut segments = Vec::new();
1363        for seg_idx in 0u64..10 {
1364            let pts = vec![make_point("srv1", seg_idx as f64, 100)];
1365            let name = format!("dup_{seg_idx}.csx");
1366            let (path, meta) = write_segment(dir.path(), &name, &pts);
1367            segments.push(make_entry(seg_idx, 0, path, &meta));
1368        }
1369
1370        let task = CompactionTask {
1371            shard_id: ShardId(0),
1372            input_segments: segments,
1373            source_level: crate::compaction::CompactionLevel::L0,
1374            target_level: crate::compaction::CompactionLevel::L1,
1375            segments_dir: dir.path().to_path_buf(),
1376            output: SegmentFile::new(ShardId(0), "dedup_many.csx").unwrap(),
1377        };
1378
1379        let executor = CompactionExecutor::new(
1380            false,
1381            FloatEncoding::Gorilla,
1382            1000,
1383            CompressionCodec::Lz4,
1384            3,
1385        );
1386        let meta = executor.execute(&task, &TombstoneSet::new()).unwrap();
1387
1388        assert_eq!(meta.row_count, 1, "should keep exactly one row after dedup");
1389
1390        let reader = SegmentReader::open(task.output_path()).unwrap();
1391        let batch = reader.read_all().unwrap();
1392        let cpu_col = batch.column_by_name("cpu").unwrap();
1393        let cpu_arr = cpu_col.as_any().downcast_ref::<Float64Array>().unwrap();
1394        assert!(
1395            (cpu_arr.value(0) - 9.0).abs() < f64::EPSILON,
1396            "expected value 9.0 from newest segment, got {}",
1397            cpu_arr.value(0)
1398        );
1399    }
1400
1401    /// Single segment compaction should pass through without data loss.
1402    #[test]
1403    fn kway_merge_single_segment_passthrough() {
1404        let dir = tempfile::tempdir().unwrap();
1405
1406        let pts = vec![
1407            make_point("srv1", 1.0, 100),
1408            make_point("srv1", 2.0, 200),
1409            make_point("srv2", 3.0, 100),
1410        ];
1411        let (path, meta) = write_segment(dir.path(), "single.csx", &pts);
1412
1413        let task = CompactionTask {
1414            shard_id: ShardId(0),
1415            input_segments: vec![make_entry(1, 0, path, &meta)],
1416            source_level: crate::compaction::CompactionLevel::L0,
1417            target_level: crate::compaction::CompactionLevel::L1,
1418            segments_dir: dir.path().to_path_buf(),
1419            output: SegmentFile::new(ShardId(0), "single_out.csx").unwrap(),
1420        };
1421
1422        let executor = CompactionExecutor::new(
1423            false,
1424            FloatEncoding::Gorilla,
1425            1000,
1426            CompressionCodec::Lz4,
1427            3,
1428        );
1429        let result = executor.execute(&task, &TombstoneSet::new()).unwrap();
1430        assert_eq!(result.row_count, 3);
1431    }
1432
1433    /// Tombstones applied during K-way merge should remove ALL versions of
1434    /// a series, not just the oldest.
1435    #[test]
1436    fn kway_merge_tombstone_removes_all_versions() {
1437        let dir = tempfile::tempdir().unwrap();
1438
1439        // Three segments all writing srv1 data.
1440        let pts1 = vec![make_point("srv1", 1.0, 100), make_point("srv2", 10.0, 100)];
1441        let pts2 = vec![make_point("srv1", 2.0, 100), make_point("srv2", 20.0, 200)];
1442        let pts3 = vec![make_point("srv1", 3.0, 200), make_point("srv2", 30.0, 300)];
1443
1444        let (p1, m1) = write_segment(dir.path(), "t1.csx", &pts1);
1445        let (p2, m2) = write_segment(dir.path(), "t2.csx", &pts2);
1446        let (p3, m3) = write_segment(dir.path(), "t3.csx", &pts3);
1447
1448        let srv1_key = SeriesKey::new(
1449            "cpu_usage",
1450            BTreeMap::from([("host".to_string(), "srv1".to_string())]),
1451        )
1452        .unwrap();
1453        let mut tombstones = TombstoneSet::new();
1454        tombstones.insert(
1455            chronix_core::Tombstone::all_time(srv1_key.canonical_form().to_string())
1456                .with_segments(0..1000),
1457        );
1458
1459        let task = CompactionTask {
1460            shard_id: ShardId(0),
1461            input_segments: vec![
1462                make_entry(1, 0, p1, &m1),
1463                make_entry(2, 0, p2, &m2),
1464                make_entry(3, 0, p3, &m3),
1465            ],
1466            source_level: crate::compaction::CompactionLevel::L0,
1467            target_level: crate::compaction::CompactionLevel::L1,
1468            segments_dir: dir.path().to_path_buf(),
1469            output: SegmentFile::new(ShardId(0), "tombstone_all.csx").unwrap(),
1470        };
1471
1472        let executor = CompactionExecutor::new(
1473            false,
1474            FloatEncoding::Gorilla,
1475            1000,
1476            CompressionCodec::Lz4,
1477            3,
1478        );
1479        let result = executor.execute(&task, &tombstones).unwrap();
1480
1481        // Only srv2 rows should remain (3 rows across 3 segments, no dups).
1482        assert_eq!(result.row_count, 3);
1483
1484        let reader = SegmentReader::open(task.output_path()).unwrap();
1485        let batch = reader.read_all().unwrap();
1486        let host_col = batch.column_by_name("host").unwrap();
1487        let host_arr = host_col.as_any().downcast_ref::<StringArray>().unwrap();
1488        for i in 0..batch.num_rows() {
1489            assert_eq!(
1490                host_arr.value(i),
1491                "srv2",
1492                "tombstoned srv1 row leaked through"
1493            );
1494        }
1495    }
1496
1497    /// K-way merge with all data tombstoned should produce an empty result.
1498    #[test]
1499    fn kway_merge_all_tombstoned_returns_empty() {
1500        let dir = tempfile::tempdir().unwrap();
1501
1502        let pts = vec![make_point("srv1", 1.0, 100)];
1503        let (path, meta) = write_segment(dir.path(), "all_tomb.csx", &pts);
1504
1505        let srv1_key = SeriesKey::new(
1506            "cpu_usage",
1507            BTreeMap::from([("host".to_string(), "srv1".to_string())]),
1508        )
1509        .unwrap();
1510        let mut tombstones = TombstoneSet::new();
1511        tombstones.insert(
1512            chronix_core::Tombstone::all_time(srv1_key.canonical_form().to_string())
1513                .with_segments(0..1000),
1514        );
1515
1516        let task = CompactionTask {
1517            shard_id: ShardId(0),
1518            input_segments: vec![make_entry(1, 0, path, &meta)],
1519            source_level: crate::compaction::CompactionLevel::L0,
1520            target_level: crate::compaction::CompactionLevel::L1,
1521            segments_dir: dir.path().to_path_buf(),
1522            output: SegmentFile::new(ShardId(0), "all_tomb_out.csx").unwrap(),
1523        };
1524
1525        let executor = CompactionExecutor::new(
1526            false,
1527            FloatEncoding::Gorilla,
1528            1000,
1529            CompressionCodec::Lz4,
1530            3,
1531        );
1532        let result = executor.execute(&task, &tombstones).unwrap();
1533        assert_eq!(result.row_count, 0);
1534    }
1535
1536    /// `max_input_bytes` rejects compaction tasks that would
1537    /// exceed the configured memory budget.
1538    #[test]
1539    fn max_input_bytes_rejects_oversized_task() {
1540        let dir = tempfile::tempdir().unwrap();
1541
1542        let pts = vec![make_point("srv1", 1.0, 100), make_point("srv1", 2.0, 200)];
1543        let (p1, m1) = write_segment(dir.path(), "big1.csx", &pts);
1544
1545        let task = CompactionTask {
1546            shard_id: ShardId(0),
1547            input_segments: vec![make_entry(1, 0, p1, &m1)],
1548            source_level: crate::compaction::CompactionLevel::L0,
1549            target_level: crate::compaction::CompactionLevel::L1,
1550            segments_dir: dir.path().to_path_buf(),
1551            output: SegmentFile::new(ShardId(0), "bounded.csx").unwrap(),
1552        };
1553
1554        // Set an impossibly small limit (1 byte).
1555        let executor = CompactionExecutor::new(
1556            false,
1557            FloatEncoding::Gorilla,
1558            1000,
1559            CompressionCodec::Lz4,
1560            3,
1561        )
1562        .with_max_input_bytes(1);
1563        let result = executor.execute(&task, &TombstoneSet::new());
1564        assert!(result.is_err());
1565        let err = result.unwrap_err();
1566        assert!(
1567            matches!(err, CompactionError::InputTooLarge { .. }),
1568            "expected InputTooLarge, got: {err}"
1569        );
1570    }
1571
1572    /// unlimited (0) allows any input size.
1573    #[test]
1574    fn max_input_bytes_zero_is_unlimited() {
1575        let dir = tempfile::tempdir().unwrap();
1576
1577        let pts = vec![make_point("srv1", 1.0, 100), make_point("srv1", 2.0, 200)];
1578        let (p1, m1) = write_segment(dir.path(), "unlim.csx", &pts);
1579
1580        let task = CompactionTask {
1581            shard_id: ShardId(0),
1582            input_segments: vec![make_entry(1, 0, p1, &m1)],
1583            source_level: crate::compaction::CompactionLevel::L0,
1584            target_level: crate::compaction::CompactionLevel::L1,
1585            segments_dir: dir.path().to_path_buf(),
1586            output: SegmentFile::new(ShardId(0), "unlim_out.csx").unwrap(),
1587        };
1588
1589        let executor = CompactionExecutor::new(
1590            false,
1591            FloatEncoding::Gorilla,
1592            1000,
1593            CompressionCodec::Lz4,
1594            3,
1595        )
1596        .with_max_input_bytes(0); // unlimited
1597        let result = executor.execute(&task, &TombstoneSet::new());
1598        assert!(result.is_ok(), "unlimited should succeed: {result:?}");
1599    }
1600}