Skip to main content

chronix_query/
dedup.rs

1//! K-way merge deduplication for overlapping segments.
2//!
3//! When multiple segments for the same shard may overlap in time, their
4//! `RecordBatch` results must be merged and deduplicated. The dedup strategy
5//! is **last-write-wins**: on duplicate `(series_key, timestamp)`, the
6//! value from the segment with the highest WAL sequence number is kept.
7//!
8//! # Streaming Merge Architecture
9//!
10//! The k-way merge uses a `BinaryHeap` of per-batch cursors and
11//! materialises the final result via `arrow::compute::interleave`
12//! instead of `concat_batches` + `take`. This avoids creating a full
13//! concatenated backing batch, cutting peak memory roughly in half.
14//!
15//! ## Algorithm
16//!
17//! Instead of concatenating all batches and performing a full O(N log N)
18//! lexsort, this module uses a streaming **k-way merge** with a min-heap
19//! of segment cursors:
20//!
21//! 1. Each input `RecordBatch` is internally sorted by
22//!    `(timestamp, series_hash, series_group_id)`.
23//! 2. A `BinaryHeap` (used as a min-heap) maintains one cursor per batch,
24//!    keyed on the current row's sort key.
25//! 3. On each step, the minimum entry is popped. If subsequent entries share
26//!    the same dedup key `(timestamp, group_id)`, they are also popped and
27//!    the entry from the **latest** batch (highest `batch_idx`) wins
28//!    (last-write-wins semantics).
29//! 4. The merged result is collected as `(cursor_idx, row_idx)` pairs and
30//!    materialised into a final `RecordBatch` via Arrow `take` on a
31//!    concatenated backing batch.
32//!
33//! Complexity: O(N log K) where K = number of overlapping batches, vs the
34//! previous O(N log N) full-sort approach. When segments are already sorted
35//! by timestamp (the common case), intra-batch sorting is nearly free.
36//!
37//! Series identity is determined by the canonical form
38//! (`measurement\0tag1=v1\0tag2=v2`) — the FNV-1a hash is used as a fast
39//! grouping key for sort order, and unique canonical forms are mapped to
40//! integer group IDs to avoid O(N) heap-allocated strings while
41//! still preventing false-positive dedup from hash collisions.
42
43use std::cmp::Ordering;
44use std::collections::BinaryHeap;
45use std::sync::Arc;
46
47use arrow::array::{
48    Array, ArrayRef, Int64Array, StringArray, UInt32Array, UInt64Array, new_null_array,
49};
50use arrow::compute;
51use arrow::datatypes::{DataType, Field, Schema};
52use arrow::record_batch::RecordBatch;
53use fnv::FnvHasher;
54use std::hash::Hasher;
55
56use crate::error::{QueryError, Result};
57
58/// Build a union schema from all batches, preserving field order from the
59/// first batch and appending any new fields discovered in later batches.
60fn build_union_schema(batches: &[RecordBatch]) -> Arc<Schema> {
61    let mut fields: Vec<Field> = Vec::new();
62    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
63
64    for batch in batches {
65        for field in batch.schema().fields() {
66            if seen.insert(field.name().clone()) {
67                // Mark as nullable since not all batches may have this
68                // column. `with_metadata` rather than a fresh `Field`: the
69                // role travels on the field, and rebuilding it here dropped
70                // the one thing a consumer cannot recover from the type.
71                let f = if batches.len() > 1 {
72                    Field::new(field.name(), field.data_type().clone(), true)
73                        .with_metadata(field.metadata().clone())
74                } else {
75                    field.as_ref().clone()
76                };
77                fields.push(f);
78            }
79        }
80    }
81
82    Arc::new(Schema::new(fields))
83}
84
85/// Align a single batch to the target schema.
86///
87/// Columns present in the target but missing from the batch are filled
88/// with typed null arrays of the batch's row count. Columns in the batch
89/// but not in the target are dropped (the target schema is the authority).
90fn align_batch(batch: &RecordBatch, target: &Arc<Schema>) -> Result<RecordBatch> {
91    let cols: Vec<ArrayRef> = target
92        .fields()
93        .iter()
94        .map(|field| {
95            match batch.schema().index_of(field.name()) {
96                Ok(idx) => Arc::clone(batch.column(idx)),
97                Err(_) => {
98                    // Column missing — create a typed null array matching the
99                    // target field's data type so the RecordBatch constructor
100                    // accepts it.
101                    new_null_array(field.data_type(), batch.num_rows())
102                }
103            }
104        })
105        .collect();
106
107    RecordBatch::try_new(target.clone(), cols).map_err(QueryError::Arrow)
108}
109
110/// Merge and deduplicate multiple `RecordBatch`es using k-way merge.
111///
112/// Deduplication key is `(series_canonical, timestamp)` where the series
113/// canonical is `measurement\0tag1=v1\0tag2=v2`, matching
114/// [`chronix_core::SeriesKey::canonical_form()`]. An FNV-1a hash is used
115/// as a fast grouping key during sort, but the canonical string is the
116/// authoritative identity — preventing hash-collision data loss.
117///
118/// Tag columns are identified by Arrow field metadata (`role=tag`), or as a
119/// fallback, all `Utf8` columns except `_time`.
120///
121/// When multiple rows share the same `(series_canonical, timestamp)`, the
122/// row from the batch appearing **later** in the input list wins
123/// (last-write-wins semantics, since newer segments are listed last).
124///
125/// If only one batch is provided, it is returned as-is (fast path).
126///
127/// # Algorithm
128///
129/// Each input batch is internally sorted by `(timestamp, series_hash,
130/// group_id)`, then a min-heap merges all batches in O(N log K) time
131/// (K = batch count), compared to a O(N log N) full sort.
132///
133/// # Errors
134///
135/// Returns an error if schema merging or sorting fails, or if the
136/// optional `memory_tracker` budget is exceeded.
137pub fn sort_merge_dedup(
138    batches: Vec<RecordBatch>,
139    measurement: &str,
140    memory_tracker: Option<&crate::memory::MemoryTracker>,
141) -> Result<RecordBatch> {
142    if batches.is_empty() {
143        return Err(QueryError::Validation("no batches to deduplicate".into()));
144    }
145
146    // Fast path: single batch, no dedup needed.
147    if batches.len() == 1 {
148        return batches
149            .into_iter()
150            .next()
151            .ok_or_else(|| QueryError::Validation("expected single batch".into()));
152    }
153
154    let (schema, plan) = plan_merge(batches, measurement, memory_tracker)?;
155    let Some(plan) = plan else {
156        return Ok(RecordBatch::new_empty(schema));
157    };
158
159    // `usize::MAX` chunking emits the whole merge as a single batch.
160    Ok(merge_chunks(&plan, usize::MAX, memory_tracker)?
161        .pop()
162        .unwrap_or_else(|| RecordBatch::new_empty(schema)))
163}
164
165/// Streaming chunked variant of [`sort_merge_dedup`].
166///
167/// Instead of materialising a single giant `RecordBatch` from the k-way
168/// merge, this emits output in row-group-sized chunks of `chunk_size` rows,
169/// bounding peak output memory to `O(chunk_size × columns)` regardless of how
170/// many rows survive dedup.
171///
172/// Merge and dedup semantics (last-write-wins, FNV + canonical identity) are
173/// identical to [`sort_merge_dedup`].
174///
175/// # Errors
176///
177/// Returns an error if schema merging or sorting fails, or if the optional
178/// `memory_tracker` budget is exceeded.
179pub fn sort_merge_dedup_chunked(
180    batches: Vec<RecordBatch>,
181    measurement: &str,
182    chunk_size: usize,
183    memory_tracker: Option<&crate::memory::MemoryTracker>,
184) -> Result<Vec<RecordBatch>> {
185    if batches.is_empty() {
186        return Err(QueryError::Validation("no batches to deduplicate".into()));
187    }
188
189    // Fast path: one input, so nothing can be a duplicate of anything —
190    // but the rows still have to come out in timestamp order, because that
191    // is what this function promises and what every consumer assumes. A
192    // segment is written series-major, so returning it as it lies handed
193    // back rows that jumped backwards in time whenever a segment held more
194    // than one series: the streaming rollup accumulator closed a bucket on
195    // the first series and then saw the second, and last-write-wins kept
196    // one series' worth of a twenty-series aggregate.
197    if batches.len() == 1 {
198        let batch = batches
199            .into_iter()
200            .next()
201            .ok_or_else(|| QueryError::Validation("expected single batch".into()))?;
202        if batch.num_rows() == 0 {
203            return Ok(vec![]);
204        }
205        let sorted = sort_batch_by_timestamp(&batch)?;
206        return Ok(chunk_batch(&sorted, chunk_size));
207    }
208
209    let (_schema, plan) = plan_merge(batches, measurement, memory_tracker)?;
210    let Some(plan) = plan else {
211        return Ok(vec![]);
212    };
213    merge_chunks(&plan, chunk_size, memory_tracker)
214}
215
216/// Sort a batch ascending by timestamp, stably, so equal timestamps keep
217/// their relative order (which is write order, and so last-write-wins).
218///
219/// A no-op when the batch is already ordered, which is the common case:
220/// the memtable and every merged output are ordered already.
221fn sort_batch_by_timestamp(batch: &RecordBatch) -> Result<RecordBatch> {
222    let Some(ts) = batch
223        .column_by_name(chronix_core::TIME_COLUMN)
224        .and_then(|c| c.as_any().downcast_ref::<Int64Array>())
225    else {
226        // No timestamp column to order by (a projection that dropped it);
227        // the caller gets what it asked for.
228        return Ok(batch.clone());
229    };
230    if ts.values().windows(2).all(|w| w[0] <= w[1]) {
231        return Ok(batch.clone());
232    }
233    let mut order: Vec<u32> = (0..batch.num_rows() as u32).collect();
234    order.sort_by_key(|&i| (ts.value(i as usize), i));
235    let indices = arrow::array::UInt32Array::from(order);
236    let columns = batch
237        .columns()
238        .iter()
239        .map(|c| arrow::compute::take(c.as_ref(), &indices, None))
240        .collect::<std::result::Result<Vec<_>, _>>()?;
241    Ok(RecordBatch::try_new(batch.schema(), columns)?)
242}
243
244/// Metadata cursor for a batch participating in k-way merge.
245struct MergeCursor {
246    /// Original position in the input batch list (higher = newer for LWW).
247    batch_idx: usize,
248    /// Pre-extracted timestamps for O(1) comparison.
249    timestamps: Vec<i64>,
250    /// Pre-computed FNV-1a series hashes for sort ordering.
251    series_hashes: Vec<u64>,
252    /// Pre-computed collision-proof integer group IDs (shared across batches).
253    group_ids: Vec<u32>,
254    /// Total number of rows in the (sorted) batch.
255    num_rows: usize,
256}
257
258/// Entry in the min-heap for k-way merge.
259///
260/// Ordering is reversed so that `BinaryHeap` (a max-heap) acts as a min-heap
261/// over `(timestamp, series_hash, group_id, batch_idx)`.
262#[derive(Eq, PartialEq)]
263struct HeapEntry {
264    timestamp: i64,
265    series_hash: u64,
266    group_id: u32,
267    /// Original batch position (for last-write-wins tie-breaking).
268    batch_idx: usize,
269    /// Index into the `cursors` / `sorted_batches` arrays.
270    cursor_idx: usize,
271    /// Row position within the cursor's sorted batch.
272    row: usize,
273}
274
275impl Ord for HeapEntry {
276    fn cmp(&self, other: &Self) -> Ordering {
277        // Reversed comparison → `BinaryHeap` (max-heap) becomes a min-heap.
278        //
279        // Primary sort: (timestamp, series_hash, group_id) ascending.
280        // Tie-breaker: batch_idx ascending — earlier batches are popped first
281        // so that later batches can overwrite during last-write-wins dedup.
282        let self_key = (
283            self.timestamp,
284            self.series_hash,
285            self.group_id,
286            self.batch_idx,
287        );
288        let other_key = (
289            other.timestamp,
290            other.series_hash,
291            other.group_id,
292            other.batch_idx,
293        );
294        other_key.cmp(&self_key)
295    }
296}
297
298impl PartialOrd for HeapEntry {
299    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
300        Some(self.cmp(other))
301    }
302}
303
304/// Everything the k-way merge needs: the union schema, one cursor per
305/// non-empty input, and the inputs sorted into merge order.
306struct MergePlan {
307    schema: Arc<Schema>,
308    cursors: Vec<MergeCursor>,
309    sorted_batches: Vec<RecordBatch>,
310}
311
312/// Align the inputs to a union schema and sort each one into merge order.
313///
314/// Returns `Ok(None)` when every input is empty, in which case the caller
315/// only needs the union schema (also returned) to build an empty result.
316fn plan_merge(
317    batches: Vec<RecordBatch>,
318    measurement: &str,
319    memory_tracker: Option<&crate::memory::MemoryTracker>,
320) -> Result<(Arc<Schema>, Option<MergePlan>)> {
321    let schema = build_union_schema(&batches);
322
323    // Pre-check memory budget against estimated output size
324    // (total input rows × schema byte-width).
325    if let Some(tracker) = memory_tracker {
326        let total_rows: usize = batches
327            .iter()
328            .map(arrow::array::RecordBatch::num_rows)
329            .sum();
330        let estimated_row_bytes = schema.fields().len() * 8 + 16; // rough per-row estimate
331        tracker.try_allocate(total_rows * estimated_row_bytes)?;
332    }
333
334    // Align all batches to the union schema by column name. Missing columns
335    // are filled with null arrays, handling schema evolution (e.g. a field
336    // added to a new segment but absent in older segments) and cardinality-
337    // based column order differences.
338    let aligned: Vec<(usize, RecordBatch)> = batches
339        .into_iter()
340        .map(|batch| {
341            if batch.schema() == schema {
342                Ok(batch)
343            } else {
344                align_batch(&batch, &schema)
345            }
346        })
347        .collect::<Result<Vec<_>>>()?
348        .into_iter()
349        .enumerate()
350        .filter(|(_, b)| b.num_rows() > 0)
351        .collect();
352
353    if aligned.is_empty() {
354        return Ok((schema, None));
355    }
356
357    let time_idx = schema
358        .index_of(chronix_core::TIME_COLUMN)
359        .map_err(|_| QueryError::Validation("'timestamp' column not found".into()))?;
360    let tag_columns = resolve_tag_columns(&schema);
361
362    // A shared group map ensures the same canonical series string receives
363    // the same integer group ID across all batches, which is essential for
364    // correct cross-batch dedup.
365    let mut group_map: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
366    let mut next_group_id: u32 = 0;
367    let mut cursors: Vec<MergeCursor> = Vec::with_capacity(aligned.len());
368    let mut sorted_batches: Vec<RecordBatch> = Vec::with_capacity(aligned.len());
369
370    for (batch_idx, batch) in &aligned {
371        let hashes = compute_series_hashes(batch, measurement, &tag_columns);
372        let group_ids = compute_series_group_ids_into(
373            batch,
374            measurement,
375            &tag_columns,
376            &mut group_map,
377            &mut next_group_id,
378        );
379
380        // Sort each batch internally by (timestamp, series_hash, group_id).
381        // For the common case of pre-sorted single-series segments this is
382        // essentially a no-op (already sorted).
383        let hash_array: UInt64Array = hashes.iter().copied().collect();
384        let gid_array: UInt32Array = group_ids.iter().copied().collect();
385        let ascending = Some(compute::SortOptions {
386            descending: false,
387            nulls_first: false,
388        });
389        let sort_columns = vec![
390            compute::SortColumn {
391                values: Arc::clone(batch.column(time_idx)),
392                options: ascending,
393            },
394            compute::SortColumn {
395                values: Arc::new(hash_array) as ArrayRef,
396                options: ascending,
397            },
398            compute::SortColumn {
399                values: Arc::new(gid_array) as ArrayRef,
400                options: ascending,
401            },
402        ];
403
404        let sort_indices = compute::lexsort_to_indices(&sort_columns, None)?;
405        let indices_values = sort_indices.values();
406
407        let sorted_cols: std::result::Result<Vec<ArrayRef>, _> = batch
408            .columns()
409            .iter()
410            .map(|col| compute::take(col.as_ref(), &sort_indices, None))
411            .collect();
412        let sorted_batch = RecordBatch::try_new(schema.clone(), sorted_cols?)?;
413
414        let sorted_timestamps: Vec<i64> = {
415            let ts = sorted_batch
416                .column(time_idx)
417                .as_any()
418                .downcast_ref::<Int64Array>()
419                .ok_or_else(|| QueryError::Validation("timestamp column must be Int64".into()))?;
420            ts.values().to_vec()
421        };
422        let sorted_hashes: Vec<u64> = indices_values.iter().map(|&i| hashes[i as usize]).collect();
423        let sorted_gids: Vec<u32> = indices_values
424            .iter()
425            .map(|&i| group_ids[i as usize])
426            .collect();
427
428        cursors.push(MergeCursor {
429            batch_idx: *batch_idx,
430            timestamps: sorted_timestamps,
431            series_hashes: sorted_hashes,
432            group_ids: sorted_gids,
433            num_rows: sorted_batch.num_rows(),
434        });
435        sorted_batches.push(sorted_batch);
436    }
437
438    Ok((
439        schema.clone(),
440        Some(MergePlan {
441            schema,
442            cursors,
443            sorted_batches,
444        }),
445    ))
446}
447
448/// Run the k-way merge, emitting a `RecordBatch` every `chunk_size` rows.
449///
450/// `chunk_size == usize::MAX` yields at most one batch, which is how the
451/// non-chunked [`sort_merge_dedup`] is expressed.
452///
453/// Surviving rows are recorded as `(cursor, row)` pairs, exactly the form
454/// `arrow::compute::interleave` consumes — the merge already knows both, so
455/// there is nothing to pack into a flat index and nothing to search back out
456/// of one.
457fn merge_chunks(
458    plan: &MergePlan,
459    chunk_size: usize,
460    memory_tracker: Option<&crate::memory::MemoryTracker>,
461) -> Result<Vec<RecordBatch>> {
462    let MergePlan {
463        schema,
464        cursors,
465        sorted_batches,
466    } = plan;
467
468    let mut heap = BinaryHeap::with_capacity(cursors.len());
469    for (ci, cursor) in cursors.iter().enumerate() {
470        if cursor.num_rows > 0 {
471            heap.push(HeapEntry {
472                timestamp: cursor.timestamps[0],
473                series_hash: cursor.series_hashes[0],
474                group_id: cursor.group_ids[0],
475                batch_idx: cursor.batch_idx,
476                cursor_idx: ci,
477                row: 0,
478            });
479        }
480    }
481
482    // Track current row position per cursor (starts at 0, the row already
483    // pushed to the heap).
484    let mut cursor_pos: Vec<usize> = vec![0; cursors.len()];
485    let mut chunks: Vec<RecordBatch> = Vec::new();
486    let mut kept: Vec<(usize, usize)> = Vec::with_capacity(chunk_size.min(65_536));
487
488    // Push the cursor's next row onto the heap, if it has one.
489    macro_rules! advance {
490        ($ci:expr) => {{
491            let ci = $ci;
492            cursor_pos[ci] += 1;
493            let nr = cursor_pos[ci];
494            if nr < cursors[ci].num_rows {
495                heap.push(HeapEntry {
496                    timestamp: cursors[ci].timestamps[nr],
497                    series_hash: cursors[ci].series_hashes[nr],
498                    group_id: cursors[ci].group_ids[nr],
499                    batch_idx: cursors[ci].batch_idx,
500                    cursor_idx: ci,
501                    row: nr,
502                });
503            }
504        }};
505    }
506
507    while let Some(entry) = heap.pop() {
508        let dedup_key = (entry.timestamp, entry.group_id);
509        advance!(entry.cursor_idx);
510
511        // Determine the winner for this dedup key. The current entry is the
512        // initial candidate; any subsequent entries with the same key are
513        // popped and compared. Because the heap pops lower batch_idx first
514        // for equal keys, the last one popped is the "newest" write.
515        let mut best_cursor = entry.cursor_idx;
516        let mut best_row = entry.row;
517        let mut best_batch = entry.batch_idx;
518
519        while let Some(next) = heap.peek() {
520            if (next.timestamp, next.group_id) != dedup_key {
521                break;
522            }
523            let next = heap
524                .pop()
525                .ok_or_else(|| QueryError::Validation("heap peek and pop disagreed".into()))?;
526            advance!(next.cursor_idx);
527
528            // Last-write-wins: higher batch_idx wins; within the same batch
529            // the later occurrence wins.
530            if next.batch_idx > best_batch || (next.batch_idx == best_batch && next.row > best_row)
531            {
532                best_cursor = next.cursor_idx;
533                best_row = next.row;
534                best_batch = next.batch_idx;
535            }
536        }
537
538        kept.push((best_cursor, best_row));
539
540        if kept.len() >= chunk_size {
541            chunks.push(materialize_chunk(
542                &kept,
543                sorted_batches,
544                schema,
545                memory_tracker,
546            )?);
547            kept.clear();
548        }
549    }
550
551    if !kept.is_empty() {
552        chunks.push(materialize_chunk(
553            &kept,
554            sorted_batches,
555            schema,
556            memory_tracker,
557        )?);
558    }
559
560    Ok(chunks)
561}
562
563/// Materialise `(cursor, row)` pairs into a `RecordBatch` via interleave.
564fn materialize_chunk(
565    pairs: &[(usize, usize)],
566    sorted_batches: &[RecordBatch],
567    schema: &Arc<Schema>,
568    memory_tracker: Option<&crate::memory::MemoryTracker>,
569) -> Result<RecordBatch> {
570    let mut result_columns: Vec<ArrayRef> = Vec::with_capacity(schema.fields().len());
571    for col_idx in 0..schema.fields().len() {
572        let col_arrays: Vec<&dyn Array> = sorted_batches
573            .iter()
574            .map(|b| b.column(col_idx).as_ref())
575            .collect();
576        result_columns.push(compute::interleave(&col_arrays, pairs)?);
577    }
578
579    let batch = RecordBatch::try_new(schema.clone(), result_columns).map_err(QueryError::Arrow)?;
580    if let Some(tracker) = memory_tracker {
581        tracker.try_allocate(batch.get_array_memory_size())?;
582    }
583    Ok(batch)
584}
585
586/// Split a single `RecordBatch` into chunks of at most `chunk_size` rows.
587fn chunk_batch(batch: &RecordBatch, chunk_size: usize) -> Vec<RecordBatch> {
588    let total = batch.num_rows();
589    if total <= chunk_size {
590        return vec![batch.clone()];
591    }
592    let mut chunks = Vec::with_capacity(total.div_ceil(chunk_size));
593    let mut offset = 0;
594    while offset < total {
595        let len = (total - offset).min(chunk_size);
596        chunks.push(batch.slice(offset, len));
597        offset += len;
598    }
599    chunks
600}
601
602/// Resolve which columns in the schema are tag columns.
603///
604/// Prefers the Arrow field metadata (`role=tag`) annotation when
605/// available. Falls back to treating all `Utf8` columns except
606/// `_time` as tags — this matches the heuristic used elsewhere
607/// in the codebase.
608fn resolve_tag_columns(schema: &Schema) -> Vec<(usize, String)> {
609    // First try explicit metadata
610    let explicit: Vec<(usize, String)> = schema
611        .fields()
612        .iter()
613        .enumerate()
614        .filter(|(_, f)| f.metadata().get("role").map(std::string::String::as_str) == Some("tag"))
615        .map(|(i, f)| (i, f.name().clone()))
616        .collect();
617    if !explicit.is_empty() {
618        return explicit;
619    }
620    // Fallback: all Utf8 columns except timestamp
621    schema
622        .fields()
623        .iter()
624        .enumerate()
625        .filter(|(_, f)| f.data_type() == &DataType::Utf8 && f.name() != chronix_core::TIME_COLUMN)
626        .map(|(i, f)| (i, f.name().clone()))
627        .collect()
628}
629
630/// Compute per-row FNV-1a series hashes matching `SeriesKey::hash_fnv()`.
631///
632/// The canonical form is `measurement\0tag1=v1\0tag2=v2` with tags sorted
633/// by name.
634fn compute_series_hashes(
635    batch: &RecordBatch,
636    measurement: &str,
637    tag_columns: &[(usize, String)],
638) -> Vec<u64> {
639    let num_rows = batch.num_rows();
640    let mut hashes = Vec::with_capacity(num_rows);
641
642    // Sort tag columns by name for deterministic hashing (BTreeMap order)
643    let mut sorted_tags: Vec<(usize, &str)> = tag_columns
644        .iter()
645        .map(|(idx, name)| (*idx, name.as_str()))
646        .collect();
647    sorted_tags.sort_unstable_by_key(|&(_, name)| name);
648
649    // Pre-fetch tag arrays
650    let tag_arrays: Vec<Option<&StringArray>> = sorted_tags
651        .iter()
652        .map(|&(col_idx, _)| batch.column(col_idx).as_any().downcast_ref::<StringArray>())
653        .collect();
654
655    for row in 0..num_rows {
656        let mut hasher = FnvHasher::default();
657        hasher.write(measurement.as_bytes());
658
659        for (tag_idx, &(_, tag_name)) in sorted_tags.iter().enumerate() {
660            if let Some(arr) = tag_arrays[tag_idx]
661                && !arr.is_null(row)
662            {
663                hasher.write_u8(0);
664                hasher.write(tag_name.as_bytes());
665                hasher.write_u8(b'=');
666                hasher.write(arr.value(row).as_bytes());
667            }
668        }
669
670        hashes.push(hasher.finish());
671    }
672
673    hashes
674}
675
676/// Compute per-row integer group IDs for series identity.
677///
678/// Builds canonical strings using a single scratch buffer, assigns a
679/// unique `u32` group ID to each distinct canonical via a shared `HashMap`,
680/// and returns one group ID per row.  Only K `String` objects are allocated
681/// (K = number of unique series), not N, making this O(N) in time but
682/// O(K) in heap-allocation count.
683///
684/// The shared `group_map` / `next_id` parameters allow consistent group IDs
685/// across multiple batches (essential for cross-batch dedup in the k-way
686/// merge).
687fn compute_series_group_ids_into(
688    batch: &RecordBatch,
689    measurement: &str,
690    tag_columns: &[(usize, String)],
691    group_map: &mut std::collections::HashMap<String, u32>,
692    next_id: &mut u32,
693) -> Vec<u32> {
694    let num_rows = batch.num_rows();
695    let mut group_ids = Vec::with_capacity(num_rows);
696
697    // Sort tag columns by name for deterministic ordering (BTreeMap order)
698    let mut sorted_tags: Vec<(usize, &str)> = tag_columns
699        .iter()
700        .map(|(idx, name)| (*idx, name.as_str()))
701        .collect();
702    sorted_tags.sort_unstable_by_key(|&(_, name)| name);
703
704    // Pre-fetch tag arrays
705    let tag_arrays: Vec<Option<&StringArray>> = sorted_tags
706        .iter()
707        .map(|&(col_idx, _)| batch.column(col_idx).as_any().downcast_ref::<StringArray>())
708        .collect();
709
710    let mut scratch = String::new();
711
712    for row in 0..num_rows {
713        scratch.clear();
714        // Shares one canonical-format definition with `SeriesKey` — see
715        // `chronix_core::push_canonical`.
716        chronix_core::push_canonical(
717            &mut scratch,
718            measurement,
719            sorted_tags
720                .iter()
721                .enumerate()
722                .filter_map(|(tag_idx, &(_, tag_name))| {
723                    let arr = tag_arrays[tag_idx]?;
724                    (!arr.is_null(row)).then(|| (tag_name, arr.value(row)))
725                }),
726        );
727
728        let id = if let Some(&id) = group_map.get(&*scratch) {
729            id
730        } else {
731            let id = *next_id;
732            *next_id += 1;
733            group_map.insert(scratch.clone(), id);
734            id
735        };
736        group_ids.push(id);
737    }
738
739    group_ids
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745    use arrow::array::Float64Array;
746    use arrow::datatypes::{DataType, Field, Schema};
747
748    fn make_batch(times: Vec<i64>, values: Vec<f64>) -> RecordBatch {
749        let schema = Arc::new(Schema::new(vec![
750            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
751            Field::new("value", DataType::Float64, false),
752        ]));
753
754        RecordBatch::try_new(
755            schema,
756            vec![
757                Arc::new(Int64Array::from(times)),
758                Arc::new(Float64Array::from(values)),
759            ],
760        )
761        .unwrap()
762    }
763
764    #[test]
765    fn single_batch_passthrough() {
766        let batch = make_batch(vec![100, 200, 300], vec![1.0, 2.0, 3.0]);
767        let result = sort_merge_dedup(vec![batch.clone()], "cpu", None).unwrap();
768        assert_eq!(result.num_rows(), 3);
769    }
770
771    #[test]
772    fn merge_no_overlap() {
773        let b1 = make_batch(vec![100, 200], vec![1.0, 2.0]);
774        let b2 = make_batch(vec![300, 400], vec![3.0, 4.0]);
775
776        let result = sort_merge_dedup(vec![b1, b2], "cpu", None).unwrap();
777        assert_eq!(result.num_rows(), 4);
778
779        let times = result
780            .column(0)
781            .as_any()
782            .downcast_ref::<Int64Array>()
783            .unwrap();
784        assert_eq!(times.values(), &[100, 200, 300, 400]);
785    }
786
787    #[test]
788    fn dedup_overlapping_last_write_wins() {
789        // b1 is "older" (seg 1), b2 is "newer" (seg 2)
790        let b1 = make_batch(vec![100, 200, 300], vec![1.0, 2.0, 3.0]);
791        let b2 = make_batch(vec![200, 300, 400], vec![22.0, 33.0, 44.0]);
792
793        // b2 listed LAST → it wins on conflict
794        let result = sort_merge_dedup(vec![b1, b2], "cpu", None).unwrap();
795        assert_eq!(result.num_rows(), 4); // 100, 200, 300, 400
796
797        let times = result
798            .column(0)
799            .as_any()
800            .downcast_ref::<Int64Array>()
801            .unwrap();
802        let values = result
803            .column(1)
804            .as_any()
805            .downcast_ref::<Float64Array>()
806            .unwrap();
807
808        assert_eq!(times.values(), &[100, 200, 300, 400]);
809        // ts=200 and ts=300 should have values from b2 (last-write-wins)
810        assert!((values.value(0) - 1.0).abs() < f64::EPSILON); // ts=100 from b1
811        assert!((values.value(1) - 22.0).abs() < f64::EPSILON); // ts=200 from b2
812        assert!((values.value(2) - 33.0).abs() < f64::EPSILON); // ts=300 from b2
813        assert!((values.value(3) - 44.0).abs() < f64::EPSILON); // ts=400 from b2
814    }
815
816    #[test]
817    fn empty_batches_error() {
818        let result = sort_merge_dedup(vec![], "cpu", None);
819        assert!(result.is_err());
820    }
821
822    #[test]
823    fn schema_evolution_missing_column_filled_with_nulls() {
824        use arrow::array::StringArray;
825
826        // b1 has (timestamp, value)
827        let b1 = make_batch(vec![100, 200], vec![1.0, 2.0]);
828
829        // b2 has (timestamp, value, extra_field) — schema evolution
830        let schema2 = Arc::new(Schema::new(vec![
831            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
832            Field::new("value", DataType::Float64, false),
833            Field::new("extra_field", DataType::Utf8, true),
834        ]));
835        let b2 = RecordBatch::try_new(
836            schema2,
837            vec![
838                Arc::new(Int64Array::from(vec![300, 400])),
839                Arc::new(Float64Array::from(vec![3.0, 4.0])),
840                Arc::new(StringArray::from(vec![Some("hello"), Some("world")])),
841            ],
842        )
843        .unwrap();
844
845        let result = sort_merge_dedup(vec![b1, b2], "cpu", None).unwrap();
846        // Union schema should have 3 columns
847        assert_eq!(result.num_columns(), 3);
848        assert_eq!(result.num_rows(), 4);
849
850        let times = result
851            .column(0)
852            .as_any()
853            .downcast_ref::<Int64Array>()
854            .unwrap();
855        assert_eq!(times.values(), &[100, 200, 300, 400]);
856    }
857
858    #[test]
859    fn different_column_order_aligned() {
860        // b1: (timestamp, host, region, value)
861        let schema1 = Arc::new(Schema::new(vec![
862            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
863            Field::new("host", DataType::Utf8, true),
864            Field::new("region", DataType::Utf8, true),
865            Field::new("value", DataType::Float64, false),
866        ]));
867        let b1 = RecordBatch::try_new(
868            schema1,
869            vec![
870                Arc::new(Int64Array::from(vec![100])),
871                Arc::new(arrow::array::StringArray::from(vec!["srv1"])),
872                Arc::new(arrow::array::StringArray::from(vec!["us-east"])),
873                Arc::new(Float64Array::from(vec![1.0])),
874            ],
875        )
876        .unwrap();
877
878        // b2: (timestamp, region, host, value) — different order
879        let schema2 = Arc::new(Schema::new(vec![
880            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
881            Field::new("region", DataType::Utf8, true),
882            Field::new("host", DataType::Utf8, true),
883            Field::new("value", DataType::Float64, false),
884        ]));
885        let b2 = RecordBatch::try_new(
886            schema2,
887            vec![
888                Arc::new(Int64Array::from(vec![200])),
889                Arc::new(arrow::array::StringArray::from(vec!["eu-west"])),
890                Arc::new(arrow::array::StringArray::from(vec!["srv2"])),
891                Arc::new(Float64Array::from(vec![2.0])),
892            ],
893        )
894        .unwrap();
895
896        let result = sort_merge_dedup(vec![b1, b2], "cpu", None).unwrap();
897        assert_eq!(result.num_rows(), 2);
898        assert_eq!(result.num_columns(), 4);
899
900        // Verify columns are in b1's order: timestamp, host, region, value
901        assert_eq!(result.schema().field(1).name(), "host");
902        assert_eq!(result.schema().field(2).name(), "region");
903
904        let hosts = result
905            .column(1)
906            .as_any()
907            .downcast_ref::<arrow::array::StringArray>()
908            .unwrap();
909        assert_eq!(hosts.value(0), "srv1");
910        assert_eq!(hosts.value(1), "srv2");
911    }
912
913    #[test]
914    fn multi_series_same_timestamp_preserved() {
915        // Two different series (host=srv1 vs host=srv2) at the SAME timestamp
916        // must both be preserved after dedup.
917        let schema = Arc::new(Schema::new(vec![
918            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
919            Field::new("host", DataType::Utf8, true)
920                .with_metadata([("role".into(), "tag".into())].into()),
921            Field::new("value", DataType::Float64, false),
922        ]));
923
924        let b1 = RecordBatch::try_new(
925            schema.clone(),
926            vec![
927                Arc::new(Int64Array::from(vec![100, 200])),
928                Arc::new(StringArray::from(vec!["srv1", "srv1"])),
929                Arc::new(Float64Array::from(vec![1.0, 2.0])),
930            ],
931        )
932        .unwrap();
933
934        let b2 = RecordBatch::try_new(
935            schema,
936            vec![
937                Arc::new(Int64Array::from(vec![100, 200])),
938                Arc::new(StringArray::from(vec!["srv2", "srv2"])),
939                Arc::new(Float64Array::from(vec![10.0, 20.0])),
940            ],
941        )
942        .unwrap();
943
944        let result = sort_merge_dedup(vec![b1, b2], "cpu", None).unwrap();
945        // All 4 rows must survive — different series even though timestamps match
946        assert_eq!(result.num_rows(), 4);
947
948        let times = result
949            .column(0)
950            .as_any()
951            .downcast_ref::<Int64Array>()
952            .unwrap();
953        // Sorted by (timestamp, series_hash)
954        assert_eq!(times.value(0), 100);
955        assert_eq!(times.value(1), 100);
956        assert_eq!(times.value(2), 200);
957        assert_eq!(times.value(3), 200);
958
959        let hosts = result
960            .column(1)
961            .as_any()
962            .downcast_ref::<StringArray>()
963            .unwrap();
964        // Both srv1 and srv2 must appear at each timestamp
965        let host_set: std::collections::HashSet<&str> = (0..4).map(|i| hosts.value(i)).collect();
966        assert!(host_set.contains("srv1"));
967        assert!(host_set.contains("srv2"));
968    }
969
970    #[test]
971    fn dedup_respects_memory_tracker() {
972        let b1 = make_batch(vec![100, 200], vec![1.0, 2.0]);
973        let b2 = make_batch(vec![300, 400], vec![3.0, 4.0]);
974
975        // Tracker with generous budget — should succeed.
976        let tracker = crate::memory::MemoryTracker::new(1_000_000);
977        let result = sort_merge_dedup(vec![b1.clone(), b2.clone()], "cpu", Some(&tracker)).unwrap();
978        assert_eq!(result.num_rows(), 4);
979        assert!(
980            tracker.allocated() > 0,
981            "tracker should have charged memory"
982        );
983
984        // Tracker with tiny budget — should fail.
985        let tiny_tracker = crate::memory::MemoryTracker::new(1);
986        let err = sort_merge_dedup(vec![b1, b2], "cpu", Some(&tiny_tracker));
987        assert!(err.is_err(), "should fail when memory budget exceeded");
988    }
989}
990
991#[cfg(test)]
992mod ordering_tests {
993    use super::*;
994    use arrow::array::{Float64Array, StringArray};
995    use arrow::datatypes::{DataType, Field, Schema};
996    use std::sync::Arc;
997
998    /// A single input is returned in timestamp order, not in the order it
999    /// happens to lie on disk.
1000    ///
1001    /// A segment is written series-major, so its rows jump backwards in
1002    /// time at every series boundary. Handing that back unsorted broke
1003    /// every consumer that assumed the promised order — the streaming
1004    /// rollup accumulator closed a bucket on the first series and then saw
1005    /// the second, and last-write-wins kept one series' share of the
1006    /// aggregate.
1007    #[test]
1008    fn a_single_series_major_batch_comes_back_in_time_order() {
1009        let schema = Arc::new(Schema::new(vec![
1010            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
1011            Field::new("host", DataType::Utf8, true),
1012            Field::new("v", DataType::Float64, true),
1013        ]));
1014        // host a at t=1,2,3 then host b at t=1,2,3 — series-major.
1015        let batch = RecordBatch::try_new(
1016            schema,
1017            vec![
1018                Arc::new(Int64Array::from(vec![1, 2, 3, 1, 2, 3])),
1019                Arc::new(StringArray::from(vec!["a", "a", "a", "b", "b", "b"])),
1020                Arc::new(Float64Array::from(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])),
1021            ],
1022        )
1023        .unwrap();
1024
1025        let out = sort_merge_dedup_chunked(vec![batch], "m", 1024, None).unwrap();
1026        assert_eq!(out.len(), 1);
1027        let ts = out[0]
1028            .column_by_name(chronix_core::TIME_COLUMN)
1029            .unwrap()
1030            .as_any()
1031            .downcast_ref::<Int64Array>()
1032            .unwrap();
1033        assert_eq!(ts.values(), &[1, 1, 2, 2, 3, 3]);
1034        // Both series survive: this sorts, it does not deduplicate across
1035        // series that merely share a timestamp.
1036        assert_eq!(out[0].num_rows(), 6);
1037    }
1038
1039    /// An already-ordered batch is passed through untouched.
1040    #[test]
1041    fn an_ordered_batch_is_not_reshuffled() {
1042        let schema = Arc::new(Schema::new(vec![
1043            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
1044            Field::new("v", DataType::Float64, true),
1045        ]));
1046        let batch = RecordBatch::try_new(
1047            schema,
1048            vec![
1049                Arc::new(Int64Array::from(vec![10, 20, 30])),
1050                Arc::new(Float64Array::from(vec![1.0, 2.0, 3.0])),
1051            ],
1052        )
1053        .unwrap();
1054        let out = sort_merge_dedup_chunked(vec![batch], "m", 1024, None).unwrap();
1055        let v = out[0]
1056            .column_by_name("v")
1057            .unwrap()
1058            .as_any()
1059            .downcast_ref::<Float64Array>()
1060            .unwrap();
1061        assert_eq!(v.values(), &[1.0, 2.0, 3.0]);
1062    }
1063}