Skip to main content

reddb_server/storage/timeseries/
chunk.rs

1//! Time-Series Chunk — grouped storage of metric data points
2//!
3//! Points are grouped by (metric, tags) into chunks. Each chunk stores
4//! compressed timestamps and values for efficient range queries.
5
6use std::collections::HashMap;
7
8use super::compression::{
9    delta_decode_timestamps, delta_encode_timestamps, xor_decode_values, xor_encode_values,
10};
11use crate::catalog::AnalyticalStorageConfig;
12use crate::storage::index::{BloomSegment, HasBloom, ZoneDecision, ZoneMap, ZonePredicate};
13use crate::storage::schema::types::DataType;
14use crate::storage::unified::column_block::{
15    read_column_block, write_column_block, ColumnBlockError, ColumnInput,
16};
17use crate::storage::unified::segment_codec::ColumnSemantics;
18
19/// Stable column id of the timestamp column within a v1 columnar chunk.
20pub const COLUMNAR_TS_COLUMN_ID: u32 = 0;
21/// Stable column id of the value column within a v1 columnar chunk.
22pub const COLUMNAR_VALUE_COLUMN_ID: u32 = 1;
23/// Default sparse-granule-index stride: one min/max mark per ~8192 rows
24/// (PRD #850 Phase 1, #854). Configurable per seal via
25/// [`TimeSeriesChunk::seal_columnar_with_granule_size`].
26pub const DEFAULT_GRANULE_SIZE: u32 = 8192;
27
28/// A single time-series data point
29#[derive(Debug, Clone, PartialEq)]
30pub struct TimeSeriesPoint {
31    pub timestamp_ns: u64,
32    pub value: f64,
33}
34
35/// A chunk of time-series data for a single metric + tag combination.
36///
37/// Points are stored in timestamp order. The chunk can be in either
38/// "open" (uncompressed, accepting writes) or "sealed" (compressed) state.
39pub struct TimeSeriesChunk {
40    /// Metric name (e.g., "cpu.idle")
41    pub metric: String,
42    /// Dimensional tags (e.g., {"host": "srv1", "region": "us-east"})
43    pub tags: HashMap<String, String>,
44    /// Raw timestamps (nanoseconds since epoch)
45    timestamps: Vec<u64>,
46    /// Raw values
47    values: Vec<f64>,
48    /// Maximum points before auto-seal
49    max_points: usize,
50    /// Whether the chunk is sealed (compressed, immutable)
51    sealed: bool,
52    /// Compressed timestamp data (populated on seal)
53    compressed_timestamps: Option<Vec<i64>>,
54    /// Compressed value data (populated on seal)
55    compressed_values: Option<Vec<u64>>,
56    /// Bloom filter over timestamps for O(1) negative point lookups.
57    /// Query planners can skip a chunk when a wanted timestamp is definitely
58    /// absent, even when it falls inside the chunk's min/max range.
59    bloom: BloomSegment,
60    /// Zone map over the chunk's timestamp column — BRIN MINMAX equivalent.
61    /// Tracks min/max timestamp plus a HyperLogLog distinct estimate.
62    /// Enables O(1) skip decisions: if chunk's max_ts < query_start or
63    /// min_ts > query_end the chunk is definitively outside the window.
64    timestamp_zone: ZoneMap,
65    /// Zone map over the chunk's value column (not timestamps — those are
66    /// already ordered). Enables skip-scan for value predicates
67    /// (e.g. "show chunks where cpu > 95%") and surfaces a HyperLogLog
68    /// distinct estimate for the planner.
69    value_zone: ZoneMap,
70}
71
72impl HasBloom for TimeSeriesChunk {
73    fn bloom_segment(&self) -> Option<&BloomSegment> {
74        Some(&self.bloom)
75    }
76}
77
78impl TimeSeriesChunk {
79    /// Create a new open chunk
80    pub fn new(metric: impl Into<String>, tags: HashMap<String, String>) -> Self {
81        Self {
82            metric: metric.into(),
83            tags,
84            timestamps: Vec::new(),
85            values: Vec::new(),
86            max_points: 1024,
87            sealed: false,
88            compressed_timestamps: None,
89            compressed_values: None,
90            bloom: BloomSegment::with_capacity(1024),
91            timestamp_zone: ZoneMap::with_capacity(1024),
92            value_zone: ZoneMap::with_capacity(1024),
93        }
94    }
95
96    /// Create with custom max points
97    pub fn with_max_points(
98        metric: impl Into<String>,
99        tags: HashMap<String, String>,
100        max_points: usize,
101    ) -> Self {
102        let mut chunk = Self::new(metric, tags);
103        chunk.max_points = max_points;
104        chunk.timestamp_zone = ZoneMap::with_capacity(max_points);
105        chunk
106    }
107
108    /// Append a data point. Returns false if the chunk is sealed or full.
109    pub fn append(&mut self, timestamp_ns: u64, value: f64) -> bool {
110        if self.sealed || self.timestamps.len() >= self.max_points {
111            return false;
112        }
113        self.bloom.insert(&timestamp_ns.to_le_bytes());
114        self.timestamp_zone.observe(&timestamp_ns.to_be_bytes()); // big-endian for correct byte-wise ordering
115        self.value_zone.observe(&value.to_le_bytes());
116        self.timestamps.push(timestamp_ns);
117        self.values.push(value);
118        true
119    }
120
121    /// Fast-path check: might this chunk contain a point at `timestamp_ns`?
122    ///
123    /// Returns `false` only when the bloom filter *proves* the timestamp is
124    /// absent. A `true` response still requires a real lookup.
125    pub fn may_contain_timestamp(&self, timestamp_ns: u64) -> bool {
126        !self.bloom.definitely_absent(&timestamp_ns.to_le_bytes())
127    }
128
129    /// BRIN MINMAX equivalent: "can this chunk possibly overlap `[start_ns, end_ns]`?"
130    ///
131    /// Returns `true` (skip) only when the chunk's min/max timestamp range
132    /// definitively does not intersect the query window — O(1) with no
133    /// decompression. Timestamps are stored big-endian so byte-wise min/max
134    /// comparisons are numerically correct.
135    pub fn timestamp_range_skip(&self, start_ns: u64, end_ns: u64) -> bool {
136        let start_b = start_ns.to_be_bytes();
137        let end_b = end_ns.to_be_bytes();
138        matches!(
139            self.timestamp_zone.block_skip(&ZonePredicate::Range {
140                start: Some(&start_b),
141                end: Some(&end_b),
142            }),
143            ZoneDecision::Skip
144        )
145    }
146
147    /// Value-range planner helper. Answers "can this chunk possibly contain
148    /// a point with value in `[lo, hi]`?" without decoding the chunk.
149    ///
150    /// Values are compared as raw `f64::to_le_bytes()`, which gives correct
151    /// ordering for non-negative finite floats. Callers with negative
152    /// ranges should still read the chunk — this is a best-effort prune.
153    pub fn value_range_skip(&self, lo: f64, hi: f64) -> bool {
154        let lo_b = lo.to_le_bytes();
155        let hi_b = hi.to_le_bytes();
156        matches!(
157            self.value_zone.block_skip(&ZonePredicate::Range {
158                start: Some(&lo_b),
159                end: Some(&hi_b),
160            }),
161            ZoneDecision::Skip
162        )
163    }
164
165    /// Estimated distinct values observed in this chunk (HLL-backed).
166    pub fn distinct_value_estimate(&self) -> u64 {
167        self.value_zone.distinct_estimate()
168    }
169
170    /// Number of data points
171    pub fn len(&self) -> usize {
172        self.timestamps.len()
173    }
174
175    /// Whether the chunk is empty
176    pub fn is_empty(&self) -> bool {
177        self.timestamps.is_empty()
178    }
179
180    /// Whether the chunk is full
181    pub fn is_full(&self) -> bool {
182        self.timestamps.len() >= self.max_points
183    }
184
185    /// Whether the chunk is sealed
186    pub fn is_sealed(&self) -> bool {
187        self.sealed
188    }
189
190    /// Minimum timestamp in the chunk
191    pub fn min_timestamp(&self) -> Option<u64> {
192        self.timestamps.first().copied()
193    }
194
195    /// Maximum timestamp in the chunk
196    pub fn max_timestamp(&self) -> Option<u64> {
197        self.timestamps.last().copied()
198    }
199
200    /// Seal the chunk: compress data and prevent further writes
201    pub fn seal(&mut self) {
202        if self.sealed {
203            return;
204        }
205        // Sort by timestamp if not already sorted
206        if !self.timestamps.windows(2).all(|w| w[0] <= w[1]) {
207            let mut indices: Vec<usize> = (0..self.timestamps.len()).collect();
208            indices.sort_by_key(|&i| self.timestamps[i]);
209            let sorted_ts: Vec<u64> = indices.iter().map(|&i| self.timestamps[i]).collect();
210            let sorted_vals: Vec<f64> = indices.iter().map(|&i| self.values[i]).collect();
211            self.timestamps = sorted_ts;
212            self.values = sorted_vals;
213        }
214        self.compressed_timestamps = Some(delta_encode_timestamps(&self.timestamps));
215        self.compressed_values = Some(xor_encode_values(&self.values));
216        self.sealed = true;
217    }
218
219    /// Seal the chunk and emit its **columnar** on-disk form: an `RDCC`
220    /// [`ColumnBlock`](crate::storage::engine::PageType::ColumnBlock)
221    /// transposing the live `(ts, value)` columns into per-column codec
222    /// streams — delta-of-delta+ZSTD for the monotonic timestamps,
223    /// Gorilla/XOR+ZSTD for the float gauge (#853, PRD #850 Phase 1).
224    /// Sealing first (via [`seal`](Self::seal)) guarantees timestamp
225    /// order, so the columns are written sorted.
226    ///
227    /// `chunk_id` / `schema_ref` are recorded verbatim in the block header
228    /// so a reader is self-describing. The returned bytes are written into
229    /// a `ColumnBlock` page by the caller, which records the resulting
230    /// [`PageLocation`](crate::storage::engine::PageLocation) in
231    /// `ChunkMeta.columnar_page`.
232    pub fn seal_columnar(
233        &mut self,
234        chunk_id: u64,
235        schema_ref: u64,
236    ) -> Result<Vec<u8>, ColumnBlockError> {
237        self.seal_columnar_with_granule_size(chunk_id, schema_ref, DEFAULT_GRANULE_SIZE)
238    }
239
240    /// As [`seal_columnar`](Self::seal_columnar), but with an explicit
241    /// sparse-granule-index stride (#854). The writer records one min/max
242    /// mark per `granule_size` rows for each numeric column (timestamp +
243    /// value) in the block's footer; a reader prunes granules that cannot
244    /// match a range predicate. `granule_size == 0` writes no index.
245    pub fn seal_columnar_with_granule_size(
246        &mut self,
247        chunk_id: u64,
248        schema_ref: u64,
249        granule_size: u32,
250    ) -> Result<Vec<u8>, ColumnBlockError> {
251        if !self.sealed {
252            self.seal();
253        }
254        let ts_bytes: Vec<u8> = self
255            .timestamps
256            .iter()
257            .flat_map(|t| t.to_le_bytes())
258            .collect();
259        let val_bytes: Vec<u8> = self.values.iter().flat_map(|v| v.to_le_bytes()).collect();
260        write_column_block(
261            chunk_id,
262            schema_ref,
263            self.timestamps.len() as u64,
264            self.min_timestamp().unwrap_or(0),
265            self.max_timestamp().unwrap_or(0),
266            granule_size,
267            &[
268                ColumnInput {
269                    column_id: COLUMNAR_TS_COLUMN_ID,
270                    logical_type: DataType::UnsignedInteger.to_byte(),
271                    // Monotonic, sealed-sorted timestamps → delta-of-delta.
272                    semantics: ColumnSemantics::Timestamp,
273                    data: &ts_bytes,
274                },
275                ColumnInput {
276                    column_id: COLUMNAR_VALUE_COLUMN_ID,
277                    logical_type: DataType::Float.to_byte(),
278                    // Floating-point gauge readings → Gorilla/XOR.
279                    semantics: ColumnSemantics::Gauge,
280                    data: &val_bytes,
281                },
282            ],
283        )
284    }
285
286    /// Query points within a time range [start_ns, end_ns] inclusive.
287    ///
288    /// BRIN-style pre-filter: if the chunk's timestamp zone map proves no
289    /// overlap with [start_ns, end_ns], return immediately without touching
290    /// the point data.
291    ///
292    /// Sealed chunks have sorted timestamps (sort happens in `seal()`), so
293    /// `partition_point` binary-searches to the first relevant point — O(log n)
294    /// + `take_while` early-exits at end_ns. Open chunks may be unsorted
295    ///   (out-of-order appends are legal pre-seal), so we fall back to a
296    ///   linear filter to preserve correctness.
297    pub fn query_range(&self, start_ns: u64, end_ns: u64) -> Vec<TimeSeriesPoint> {
298        // Zone-map fast-reject (BRIN MINMAX equivalent).
299        if self.timestamp_range_skip(start_ns, end_ns) {
300            return Vec::new();
301        }
302        if self.sealed {
303            // Sorted: binary search + early termination.
304            let start_idx = self.timestamps.partition_point(|&ts| ts < start_ns);
305            self.timestamps[start_idx..]
306                .iter()
307                .zip(self.values[start_idx..].iter())
308                .take_while(|(&ts, _)| ts <= end_ns)
309                .map(|(&ts, &val)| TimeSeriesPoint {
310                    timestamp_ns: ts,
311                    value: val,
312                })
313                .collect()
314        } else {
315            // Open chunk may be unsorted — linear filter.
316            self.timestamps
317                .iter()
318                .zip(self.values.iter())
319                .filter(|(&ts, _)| ts >= start_ns && ts <= end_ns)
320                .map(|(&ts, &val)| TimeSeriesPoint {
321                    timestamp_ns: ts,
322                    value: val,
323                })
324                .collect()
325        }
326    }
327
328    /// Get all points
329    pub fn points(&self) -> Vec<TimeSeriesPoint> {
330        self.timestamps
331            .iter()
332            .zip(self.values.iter())
333            .map(|(&ts, &val)| TimeSeriesPoint {
334                timestamp_ns: ts,
335                value: val,
336            })
337            .collect()
338    }
339
340    /// Approximate memory usage in bytes
341    pub fn memory_bytes(&self) -> usize {
342        let mut size = std::mem::size_of::<Self>();
343        size += self.timestamps.len() * 8;
344        size += self.values.len() * 8;
345        if let Some(ref ct) = self.compressed_timestamps {
346            size += ct.len() * 8;
347        }
348        if let Some(ref cv) = self.compressed_values {
349            size += cv.len() * 8;
350        }
351        for (k, v) in &self.tags {
352            size += k.len() + v.len();
353        }
354        size += self.metric.len();
355        size
356    }
357
358    /// Compression ratio (sealed only): compressed_size / raw_size
359    pub fn compression_ratio(&self) -> Option<f64> {
360        if !self.sealed {
361            return None;
362        }
363        let raw = (self.timestamps.len() * 8 + self.values.len() * 8) as f64;
364        let compressed = self
365            .compressed_timestamps
366            .as_ref()
367            .map_or(0, |v| v.len() * 8) as f64
368            + self.compressed_values.as_ref().map_or(0, |v| v.len() * 8) as f64;
369        if raw > 0.0 {
370            Some(compressed / raw)
371        } else {
372            None
373        }
374    }
375
376    /// Decompress and verify integrity (sealed chunks only)
377    pub fn verify(&self) -> bool {
378        if !self.sealed {
379            return true;
380        }
381        if let (Some(ct), Some(cv)) = (&self.compressed_timestamps, &self.compressed_values) {
382            let decoded_ts = delta_decode_timestamps(ct);
383            let decoded_vals = xor_decode_values(cv);
384            decoded_ts == self.timestamps && decoded_vals == self.values
385        } else {
386            false
387        }
388    }
389}
390
391/// Outcome of routing a chunk seal through the analytical-storage seam.
392#[derive(Debug)]
393pub enum SealedChunkStorage {
394    /// The collection was flagged columnar — the sealed chunk's `RDCC`
395    /// `ColumnBlock` bytes, ready to write into a `ColumnBlock` page.
396    Columnar(Vec<u8>),
397    /// Default row engine — the chunk was sealed in place; nothing
398    /// columnar was emitted.
399    Row,
400}
401
402/// Seal `chunk`, routing to the columnar `ColumnBlock` writer when the
403/// collection's [`AnalyticalStorageConfig`] flags it columnar; otherwise
404/// the row engine stays the default and the chunk is sealed in place
405/// (PRD #850, Phase 1 — acceptance criterion 1).
406pub fn seal_chunk_with_config(
407    chunk: &mut TimeSeriesChunk,
408    config: Option<&AnalyticalStorageConfig>,
409    chunk_id: u64,
410    schema_ref: u64,
411) -> Result<SealedChunkStorage, ColumnBlockError> {
412    if config.map(|c| c.columnar).unwrap_or(false) {
413        Ok(SealedChunkStorage::Columnar(
414            chunk.seal_columnar(chunk_id, schema_ref)?,
415        ))
416    } else {
417        chunk.seal();
418        Ok(SealedChunkStorage::Row)
419    }
420}
421
422/// Decode a sealed columnar chunk's `RDCC` block back into points,
423/// transposing the two column streams into `(timestamp_ns, value)` rows.
424/// The inverse of [`TimeSeriesChunk::seal_columnar`].
425pub fn points_from_column_block(bytes: &[u8]) -> Result<Vec<TimeSeriesPoint>, ColumnBlockError> {
426    let block = read_column_block(bytes)?;
427    let ts_col = block
428        .columns
429        .iter()
430        .find(|c| c.column_id == COLUMNAR_TS_COLUMN_ID)
431        .ok_or(ColumnBlockError::BadDirectory)?;
432    let val_col = block
433        .columns
434        .iter()
435        .find(|c| c.column_id == COLUMNAR_VALUE_COLUMN_ID)
436        .ok_or(ColumnBlockError::BadDirectory)?;
437    if ts_col.data.len() % 8 != 0 || val_col.data.len() % 8 != 0 {
438        return Err(ColumnBlockError::BadDirectory);
439    }
440    let points = ts_col
441        .data
442        .chunks_exact(8)
443        .zip(val_col.data.chunks_exact(8))
444        .map(|(t, v)| TimeSeriesPoint {
445            timestamp_ns: u64::from_le_bytes(t.try_into().unwrap()),
446            value: f64::from_le_bytes(v.try_into().unwrap()),
447        })
448        .collect();
449    Ok(points)
450}
451
452/// Result of a granule-pruned range scan over a sealed columnar chunk.
453/// `granules_total` vs `granules_scanned` makes pruning observable: a
454/// selective predicate decodes fewer granules than the chunk holds
455/// (PRD #850 Phase 1, #854 — criterion 2).
456#[derive(Debug, Clone, PartialEq)]
457pub struct PrunedColumnScan {
458    /// Matching points, in timestamp order, drawn only from surviving
459    /// granules and filtered to the query window.
460    pub points: Vec<TimeSeriesPoint>,
461    /// Total granule marks in the timestamp column's sparse index.
462    pub granules_total: usize,
463    /// Granules that survived pruning and were materialised.
464    pub granules_scanned: usize,
465}
466
467/// Range query `[start_ns, end_ns]` (inclusive) over a sealed columnar
468/// chunk that uses the timestamp column's **sparse granule index** to skip
469/// granules whose min/max prove they cannot intersect the window. Only
470/// surviving granules are materialised into points; rows within a
471/// surviving granule are still filtered to the window (granule boundaries
472/// are coarse). The inverse-with-pruning of [`points_from_column_block`].
473///
474/// **Soundness**: a granule is kept whenever `granule_min <= end_ns &&
475/// granule_max >= start_ns`, i.e. exactly when it *could* hold a matching
476/// row — so pruning never drops a matching row regardless of where the
477/// granule boundaries fall (#854 — criterion 3). When the block carries no
478/// granule index, every row is scanned (`granules_total == granules_scanned
479/// == 1`).
480pub fn query_column_block_range(
481    bytes: &[u8],
482    start_ns: u64,
483    end_ns: u64,
484) -> Result<PrunedColumnScan, ColumnBlockError> {
485    let block = read_column_block(bytes)?;
486    let ts_col = block
487        .columns
488        .iter()
489        .find(|c| c.column_id == COLUMNAR_TS_COLUMN_ID)
490        .ok_or(ColumnBlockError::BadDirectory)?;
491    let val_col = block
492        .columns
493        .iter()
494        .find(|c| c.column_id == COLUMNAR_VALUE_COLUMN_ID)
495        .ok_or(ColumnBlockError::BadDirectory)?;
496    if !ts_col.data.len().is_multiple_of(8) || !val_col.data.len().is_multiple_of(8) {
497        return Err(ColumnBlockError::BadDirectory);
498    }
499    let n = ts_col.data.len() / 8;
500    if val_col.data.len() / 8 != n {
501        return Err(ColumnBlockError::BadDirectory);
502    }
503
504    let ts_at =
505        |i: usize| -> u64 { u64::from_le_bytes(ts_col.data[i * 8..i * 8 + 8].try_into().unwrap()) };
506    let val_at = |i: usize| -> f64 {
507        f64::from_le_bytes(val_col.data[i * 8..i * 8 + 8].try_into().unwrap())
508    };
509    let take_row = |i: usize, out: &mut Vec<TimeSeriesPoint>| {
510        let ts = ts_at(i);
511        if ts >= start_ns && ts <= end_ns {
512            out.push(TimeSeriesPoint {
513                timestamp_ns: ts,
514                value: val_at(i),
515            });
516        }
517    };
518
519    let mut points = Vec::new();
520    let (granules_total, granules_scanned) = match &ts_col.granule_index {
521        Some(gi) if gi.granule_count() > 0 => {
522            // Keep a granule when its [min, max] timestamp interval can
523            // intersect the query window — the BRIN MINMAX skip rule.
524            let survivors = gi.surviving_granules(|min, max| {
525                if min.len() < 8 || max.len() < 8 {
526                    return true; // malformed mark → conservative keep
527                }
528                let gmin = u64::from_le_bytes(min[..8].try_into().unwrap());
529                let gmax = u64::from_le_bytes(max[..8].try_into().unwrap());
530                gmin <= end_ns && gmax >= start_ns
531            });
532            for &g in &survivors {
533                let (s, e) = gi.row_range(g, n);
534                for i in s..e {
535                    take_row(i, &mut points);
536                }
537            }
538            (gi.granule_count(), survivors.len())
539        }
540        // No granule index → full scan (conservative, still correct).
541        _ => {
542            for i in 0..n {
543                take_row(i, &mut points);
544            }
545            (1, 1)
546        }
547    };
548
549    Ok(PrunedColumnScan {
550        points,
551        granules_total,
552        granules_scanned,
553    })
554}
555
556/// Point query: all points whose **value** equals `target` in a sealed
557/// columnar chunk, using the value column's **per-granule bloom skip index**
558/// (#855) to skip granules that provably cannot contain `target`. Only
559/// surviving granules are materialised; rows within a surviving granule are
560/// still compared exactly to `target` (the bloom over-includes). The
561/// equality counterpart of [`query_column_block_range`]'s min/max range skip.
562///
563/// **Soundness**: a split-block bloom never reports a false negative, so a
564/// granule that actually holds a row equal to `target` always probes true and
565/// survives — equality pruning therefore never drops a matching row (PRD #850
566/// Phase 1, #855 — false-positives-only contract). When the block carries no
567/// bloom, every row is scanned (`granules_total == granules_scanned == 1`).
568/// Equality is on the raw 8-byte encoding, matching how the bloom was built.
569pub fn query_column_block_value_eq(
570    bytes: &[u8],
571    target: f64,
572) -> Result<PrunedColumnScan, ColumnBlockError> {
573    let block = read_column_block(bytes)?;
574    let ts_col = block
575        .columns
576        .iter()
577        .find(|c| c.column_id == COLUMNAR_TS_COLUMN_ID)
578        .ok_or(ColumnBlockError::BadDirectory)?;
579    let val_col = block
580        .columns
581        .iter()
582        .find(|c| c.column_id == COLUMNAR_VALUE_COLUMN_ID)
583        .ok_or(ColumnBlockError::BadDirectory)?;
584    if !ts_col.data.len().is_multiple_of(8) || !val_col.data.len().is_multiple_of(8) {
585        return Err(ColumnBlockError::BadDirectory);
586    }
587    let n = val_col.data.len() / 8;
588    if ts_col.data.len() / 8 != n {
589        return Err(ColumnBlockError::BadDirectory);
590    }
591
592    let ts_at =
593        |i: usize| -> u64 { u64::from_le_bytes(ts_col.data[i * 8..i * 8 + 8].try_into().unwrap()) };
594    let val_bytes = |i: usize| -> [u8; 8] { val_col.data[i * 8..i * 8 + 8].try_into().unwrap() };
595    let target_bytes = target.to_le_bytes();
596    let take_row = |i: usize, out: &mut Vec<TimeSeriesPoint>| {
597        if val_bytes(i) == target_bytes {
598            out.push(TimeSeriesPoint {
599                timestamp_ns: ts_at(i),
600                value: target,
601            });
602        }
603    };
604
605    let mut points = Vec::new();
606    let (granules_total, granules_scanned) = match &val_col.granule_bloom {
607        Some(gb) if gb.granule_count() > 0 => {
608            // Keep only granules whose bloom may hold `target`; the rest are
609            // proven absent. Survivors are still compared exactly per row.
610            let survivors = gb.surviving_granules(&target_bytes);
611            for &g in &survivors {
612                let (s, e) = gb.row_range(g, n);
613                for i in s..e {
614                    take_row(i, &mut points);
615                }
616            }
617            (gb.granule_count(), survivors.len())
618        }
619        // No bloom → full scan (conservative, still correct).
620        _ => {
621            for i in 0..n {
622                take_row(i, &mut points);
623            }
624            (1, 1)
625        }
626    };
627
628    Ok(PrunedColumnScan {
629        points,
630        granules_total,
631        granules_scanned,
632    })
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638    use proptest::prelude::*;
639
640    fn make_tags(host: &str) -> HashMap<String, String> {
641        let mut tags = HashMap::new();
642        tags.insert("host".to_string(), host.to_string());
643        tags
644    }
645
646    #[test]
647    fn test_chunk_basic() {
648        let mut chunk = TimeSeriesChunk::new("cpu.idle", make_tags("srv1"));
649        assert!(chunk.append(1000, 95.2));
650        assert!(chunk.append(2000, 94.8));
651        assert!(chunk.append(3000, 96.1));
652
653        assert_eq!(chunk.len(), 3);
654        assert_eq!(chunk.min_timestamp(), Some(1000));
655        assert_eq!(chunk.max_timestamp(), Some(3000));
656    }
657
658    #[test]
659    fn test_chunk_range_query() {
660        let mut chunk = TimeSeriesChunk::new("cpu.idle", make_tags("srv1"));
661        for i in 0..10 {
662            chunk.append(i * 1000, 90.0 + i as f64);
663        }
664
665        let results = chunk.query_range(3000, 6000);
666        assert_eq!(results.len(), 4); // 3000, 4000, 5000, 6000
667        assert_eq!(results[0].timestamp_ns, 3000);
668        assert_eq!(results[3].timestamp_ns, 6000);
669    }
670
671    #[test]
672    fn test_chunk_seal_and_verify() {
673        let mut chunk = TimeSeriesChunk::new("mem.used", make_tags("srv1"));
674        for i in 0..100 {
675            chunk.append(1_000_000 + i * 60_000, 72.5 + (i as f64) * 0.1);
676        }
677
678        assert!(!chunk.is_sealed());
679        chunk.seal();
680        assert!(chunk.is_sealed());
681        assert!(chunk.verify());
682
683        // Cannot append after seal
684        assert!(!chunk.append(9_999_999, 99.0));
685    }
686
687    #[test]
688    fn test_chunk_max_points() {
689        let mut chunk = TimeSeriesChunk::with_max_points("test", HashMap::new(), 5);
690        for i in 0..5 {
691            assert!(chunk.append(i, i as f64));
692        }
693        assert!(chunk.is_full());
694        assert!(!chunk.append(5, 5.0));
695    }
696
697    #[test]
698    fn test_chunk_bloom_point_lookup() {
699        let mut chunk = TimeSeriesChunk::new("cpu.idle", make_tags("srv1"));
700        for ts in [1000u64, 2000, 3000, 4000] {
701            chunk.append(ts, 1.0);
702        }
703        // Inserted timestamps are always "possibly present".
704        assert!(chunk.may_contain_timestamp(1000));
705        assert!(chunk.may_contain_timestamp(4000));
706        // Unseen timestamp: bloom may prune it. If pruned, range query must
707        // also return empty — which it does either way because it's not in
708        // the data.
709        let _ = chunk.may_contain_timestamp(9999);
710        assert!(chunk.query_range(9999, 9999).is_empty());
711    }
712
713    // -----------------------------------------------------------------
714    // Columnar sealed-chunk layout v1 (PRD #850, Phase 1)
715    // -----------------------------------------------------------------
716
717    #[test]
718    fn seal_columnar_round_trips_points_value_for_value() {
719        let mut chunk = TimeSeriesChunk::new("cpu.idle", make_tags("srv1"));
720        let expected: Vec<TimeSeriesPoint> = (0..200)
721            .map(|i| TimeSeriesPoint {
722                timestamp_ns: 1_700_000_000_000 + i * 1_000_000,
723                value: 95.0 + (i % 11) as f64 * 0.125,
724            })
725            .collect();
726        for p in &expected {
727            assert!(chunk.append(p.timestamp_ns, p.value));
728        }
729
730        let block = chunk.seal_columnar(7, 42).expect("seal columnar");
731        assert!(chunk.is_sealed());
732
733        let decoded = points_from_column_block(&block).expect("decode block");
734        assert_eq!(decoded.len(), expected.len());
735        assert_eq!(decoded, expected, "lossless value-for-value round-trip");
736    }
737
738    #[test]
739    fn seal_chunk_with_config_routes_columnar_vs_row() {
740        // Columnar-flagged → ColumnBlock bytes.
741        let mut columnar = TimeSeriesChunk::new("m", HashMap::new());
742        for i in 0..50 {
743            columnar.append(i * 1000, i as f64);
744        }
745        let cfg = AnalyticalStorageConfig {
746            columnar: true,
747            time_key: "ts".to_string(),
748            order_by_key: None,
749        };
750        let routed = seal_chunk_with_config(&mut columnar, Some(&cfg), 1, 0).unwrap();
751        match routed {
752            SealedChunkStorage::Columnar(bytes) => {
753                assert_eq!(points_from_column_block(&bytes).unwrap().len(), 50);
754            }
755            SealedChunkStorage::Row => panic!("columnar flag must route to ColumnBlock writer"),
756        }
757
758        // No config / columnar=false → row engine stays the default.
759        let mut row = TimeSeriesChunk::new("m", HashMap::new());
760        for i in 0..50 {
761            row.append(i * 1000, i as f64);
762        }
763        assert!(matches!(
764            seal_chunk_with_config(&mut row, None, 1, 0).unwrap(),
765            SealedChunkStorage::Row
766        ));
767        assert!(row.is_sealed());
768
769        let mut row_off = TimeSeriesChunk::new("m", HashMap::new());
770        row_off.append(1, 1.0);
771        let off = AnalyticalStorageConfig {
772            columnar: false,
773            time_key: "ts".to_string(),
774            order_by_key: None,
775        };
776        assert!(matches!(
777            seal_chunk_with_config(&mut row_off, Some(&off), 1, 0).unwrap(),
778            SealedChunkStorage::Row
779        ));
780    }
781
782    #[test]
783    fn columnar_chunk_round_trips_through_durable_column_block_page() {
784        use crate::storage::engine::{PageLocation, PageType, Pager};
785
786        // Build + seal a columnar chunk.
787        let mut chunk = TimeSeriesChunk::new("mem.used", make_tags("srv1"));
788        for i in 0..200u64 {
789            chunk.append(1_000_000 + i * 60_000, 72.5 + (i as f64) * 0.1);
790        }
791        let block = chunk.seal_columnar(99, 3).expect("seal columnar");
792
793        // Emit a *durable* PageType::ColumnBlock page through a real Pager.
794        let path = std::env::temp_dir().join(format!(
795            "reddb-columnblock-{}-{}.rdb",
796            std::process::id(),
797            crate::utils::now_unix_nanos()
798        ));
799        let pager = Pager::open_default(&path).expect("open pager");
800        let mut page = pager
801            .allocate_page(PageType::ColumnBlock)
802            .expect("allocate column block page");
803        assert_eq!(page.page_type().unwrap(), PageType::ColumnBlock);
804        let page_id = page.page_id();
805        page.content_mut()[..block.len()].copy_from_slice(&block);
806        pager.write_page(page_id, page).expect("write page");
807
808        // The discriminant a sealed chunk records in ChunkMeta.columnar_page.
809        let loc = PageLocation::new(page_id, 0, block.len() as u32);
810
811        // Read it back from disk and decode by the recorded location.
812        let read = pager.read_page(loc.page_id).expect("read page");
813        assert_eq!(read.page_type().unwrap(), PageType::ColumnBlock);
814        let start = loc.offset as usize;
815        let stored = &read.content()[start..start + loc.length as usize];
816        let points = points_from_column_block(stored).expect("decode page block");
817
818        // "Query the collection" end-to-end: same rows back, value-for-value.
819        assert_eq!(points, chunk.points());
820        // And a range query over the reconstructed points matches the source.
821        let reconstructed = {
822            let mut c = TimeSeriesChunk::new("mem.used", make_tags("srv1"));
823            for p in &points {
824                c.append(p.timestamp_ns, p.value);
825            }
826            c.seal();
827            c
828        };
829        assert_eq!(
830            reconstructed.query_range(1_000_000, 1_000_000 + 50 * 60_000),
831            chunk.query_range(1_000_000, 1_000_000 + 50 * 60_000)
832        );
833
834        drop(pager);
835        let _ = std::fs::remove_file(&path);
836    }
837
838    // -----------------------------------------------------------------
839    // Sparse granule index + min/max skip (PRD #850, Phase 1 — #854)
840    // -----------------------------------------------------------------
841
842    /// Criterion 1: a sealed columnar chunk carries granule marks +
843    /// per-granule min/max in the footer, for both numeric columns.
844    #[test]
845    fn sealed_columnar_chunk_carries_granule_index_in_footer() {
846        let mut chunk = TimeSeriesChunk::with_max_points("cpu.idle", make_tags("srv1"), 1000);
847        for i in 0..1000u64 {
848            chunk.append(1_000 + i, 50.0 + (i % 7) as f64);
849        }
850        let block = chunk
851            .seal_columnar_with_granule_size(1, 0, 100)
852            .expect("seal columnar");
853
854        let decoded = read_column_block(&block).expect("decode");
855        for col in &decoded.columns {
856            let gi = col
857                .granule_index
858                .as_ref()
859                .expect("both numeric columns carry a granule index");
860            assert_eq!(gi.granule_size, 100);
861            assert_eq!(gi.granule_count(), 10); // 1000 / 100
862            assert_eq!(gi.granules.len(), 10);
863            // Each mark has a real min/max (8-byte values).
864            assert!(gi
865                .granules
866                .iter()
867                .all(|g| g.min.len() == 8 && g.max.len() == 8));
868        }
869    }
870
871    /// Criterion 2: a selective range query reads only the granules that
872    /// can match, and the pruning is observable (fewer granules scanned).
873    #[test]
874    fn range_query_prunes_non_matching_granules() {
875        let mut chunk = TimeSeriesChunk::with_max_points("mem.used", make_tags("srv1"), 1000);
876        // Monotonic timestamps 0,10,20,… → granule g covers [g*1000, …].
877        for i in 0..1000u64 {
878            chunk.append(i * 10, i as f64);
879        }
880        let block = chunk
881            .seal_columnar_with_granule_size(1, 0, 100)
882            .expect("seal columnar");
883
884        // Window [2050, 2150] lands wholly inside the 3rd granule
885        // (rows 200..300 → ts 2000..2990).
886        let scan = query_column_block_range(&block, 2_050, 2_150).expect("scan");
887        assert_eq!(scan.granules_total, 10);
888        assert!(
889            scan.granules_scanned < scan.granules_total,
890            "pruning must skip granules: scanned {} of {}",
891            scan.granules_scanned,
892            scan.granules_total
893        );
894        assert_eq!(scan.granules_scanned, 1);
895        // ts 2050,2060,…,2150 (multiples of 10 in the window) → 11 points.
896        assert_eq!(scan.points.len(), 11);
897        assert!(scan
898            .points
899            .iter()
900            .all(|p| p.timestamp_ns >= 2_050 && p.timestamp_ns <= 2_150));
901        // Points come back in timestamp order.
902        assert!(scan
903            .points
904            .windows(2)
905            .all(|w| w[0].timestamp_ns <= w[1].timestamp_ns));
906
907        // A window past the end prunes everything.
908        let empty = query_column_block_range(&block, 100_000, 200_000).expect("scan");
909        assert_eq!(empty.granules_scanned, 0);
910        assert!(empty.points.is_empty());
911    }
912
913    /// Criterion 3: pruning NEVER drops a row that matches the predicate,
914    /// regardless of granule boundaries. The pruned scan must equal a full
915    /// scan filtered to the same window — as a multiset of points.
916    fn sort_points(mut v: Vec<TimeSeriesPoint>) -> Vec<TimeSeriesPoint> {
917        v.sort_by(|a, b| {
918            a.timestamp_ns
919                .cmp(&b.timestamp_ns)
920                .then_with(|| a.value.to_bits().cmp(&b.value.to_bits()))
921        });
922        v
923    }
924
925    proptest! {
926        #![proptest_config(ProptestConfig::with_cases(256))]
927
928        #[test]
929        fn granule_pruning_never_drops_a_matching_row(
930            rows in prop::collection::vec(
931                (0u64..5_000, prop::num::f64::NORMAL),
932                0..400,
933            ),
934            granule_size in 1u32..50,
935            a in 0u64..5_000,
936            b in 0u64..5_000,
937        ) {
938            let (start, end) = (a.min(b), a.max(b));
939
940            let mut chunk = TimeSeriesChunk::with_max_points("m", HashMap::new(), 512);
941            for (ts, v) in &rows {
942                chunk.append(*ts, *v);
943            }
944            let block = chunk
945                .seal_columnar_with_granule_size(1, 0, granule_size)
946                .expect("seal columnar");
947
948            let scan = query_column_block_range(&block, start, end).expect("pruned scan");
949
950            // Reference: full decode, filtered to the window. seal() sorted
951            // the points, so this is the ground-truth match set.
952            let expected: Vec<TimeSeriesPoint> = points_from_column_block(&block)
953                .expect("full decode")
954                .into_iter()
955                .filter(|p| p.timestamp_ns >= start && p.timestamp_ns <= end)
956                .collect();
957
958            prop_assert_eq!(
959                sort_points(scan.points.clone()),
960                sort_points(expected),
961                "granule pruning dropped or invented a row for [{}, {}] @ g={}",
962                start, end, granule_size
963            );
964            prop_assert!(scan.granules_scanned <= scan.granules_total);
965        }
966    }
967
968    #[test]
969    fn value_eq_pruning_skips_granules_via_bloom() {
970        // 300 rows, 4 distinct value levels cycling, 50 rows/granule → the
971        // timestamps are monotonic but values repeat, so min/max can't prune
972        // equality — the bloom does. A value that appears keeps ≥1 granule.
973        let mut chunk = TimeSeriesChunk::with_max_points("m", HashMap::new(), 512);
974        for i in 0..300u64 {
975            chunk.append(1_000 + i * 10, (i % 4) as f64 * 100.0);
976        }
977        let block = chunk
978            .seal_columnar_with_granule_size(1, 0, 50)
979            .expect("seal columnar");
980
981        // 300/50 = 6 granules. Value 300.0 (i%4==3) appears in every granule
982        // (each 50-row span covers a full 0..4 cycle), so all survive but the
983        // pruner still returns exactly the matching rows.
984        let hit = query_column_block_value_eq(&block, 300.0).expect("scan");
985        assert_eq!(hit.granules_total, 6);
986        assert!(hit.points.iter().all(|p| p.value == 300.0));
987        assert_eq!(hit.points.len(), 300 / 4);
988
989        // A value never written must be definitely absent — the bloom should
990        // prune most granules and the result is empty.
991        let miss = query_column_block_value_eq(&block, 12_345.0).expect("scan");
992        assert!(miss.points.is_empty());
993        assert!(
994            miss.granules_scanned <= miss.granules_total,
995            "scanned more granules than exist"
996        );
997    }
998
999    proptest! {
1000        #![proptest_config(ProptestConfig::with_cases(256))]
1001
1002        /// Criterion 1 + 2: equality pruning over the value column's bloom
1003        /// returns EXACTLY the rows whose value equals the target — never
1004        /// dropping a match (the bloom has no false negatives) and never
1005        /// inventing one. Proven against a full-scan reference, through the
1006        /// real seal→serialize→read path.
1007        #[test]
1008        fn value_eq_pruning_never_drops_a_matching_row(
1009            rows in prop::collection::vec(
1010                (0u64..5_000, prop::sample::select(vec![0.0f64, 1.0, 2.0, 3.0, 4.0, 5.0])),
1011                0..400,
1012            ),
1013            granule_size in 1u32..50,
1014            target in prop::sample::select(vec![0.0f64, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0]),
1015        ) {
1016            let mut chunk = TimeSeriesChunk::with_max_points("m", HashMap::new(), 512);
1017            for (ts, v) in &rows {
1018                chunk.append(*ts, *v);
1019            }
1020            let block = chunk
1021                .seal_columnar_with_granule_size(1, 0, granule_size)
1022                .expect("seal columnar");
1023
1024            let scan = query_column_block_value_eq(&block, target).expect("pruned scan");
1025
1026            // Reference: full decode, filtered to value == target.
1027            let expected: Vec<TimeSeriesPoint> = points_from_column_block(&block)
1028                .expect("full decode")
1029                .into_iter()
1030                .filter(|p| p.value == target)
1031                .collect();
1032
1033            prop_assert_eq!(
1034                sort_points(scan.points.clone()),
1035                sort_points(expected),
1036                "bloom equality pruning dropped or invented a row for value {} @ g={}",
1037                target, granule_size
1038            );
1039            prop_assert!(scan.granules_scanned <= scan.granules_total);
1040        }
1041    }
1042
1043    #[test]
1044    fn test_chunk_compression_ratio() {
1045        let mut chunk = TimeSeriesChunk::new("regular", HashMap::new());
1046        // Regular 1-second intervals with similar values → compresses well
1047        for i in 0..100 {
1048            chunk.append(
1049                1_000_000_000 + i * 1_000_000_000,
1050                95.0 + (i % 3) as f64 * 0.1,
1051            );
1052        }
1053        chunk.seal();
1054
1055        let ratio = chunk.compression_ratio().unwrap();
1056        // Compressed data stored alongside raw, so ratio is of compressed vs raw
1057        assert!(ratio > 0.0);
1058        assert!(ratio <= 1.0);
1059    }
1060}