Skip to main content

chronix_query/
pruning.rs

1//! Multi-level segment pruning pipeline.
2//!
3//! Eliminates segments from query processing before any data is decoded:
4//! 1. **Time pruning** — Exclude segments whose time range doesn't overlap
5//! 2. **Bloom pruning** — Exclude segments that don't contain the series key
6//! 3. **Stats pruning** — Exclude segments where column statistics exclude the predicate
7
8use std::collections::HashMap;
9use std::hash::BuildHasher;
10use std::path::PathBuf;
11
12use chronix_engine::index::{SegmentCatalog, SegmentCatalogEntry, SeriesBloomFilter, TimeIndex};
13
14/// Statistics about segment pruning during query execution.
15#[derive(Debug, Clone, Default)]
16pub struct PruningStats {
17    /// Total segments considered.
18    pub segments_total: usize,
19    /// Segments pruned by time range.
20    pub pruned_by_time: usize,
21    /// Segments pruned by bloom filter.
22    pub pruned_by_bloom: usize,
23    /// Segments pruned by column statistics.
24    pub pruned_by_stats: usize,
25    /// Segments that passed all pruning levels.
26    pub segments_remaining: usize,
27}
28
29impl PruningStats {
30    /// Total segments pruned across all levels.
31    #[must_use]
32    pub fn total_pruned(&self) -> usize {
33        self.pruned_by_time + self.pruned_by_bloom + self.pruned_by_stats
34    }
35}
36
37/// Result of the pruning pipeline: surviving segment entries and statistics.
38#[derive(Debug)]
39pub struct PruningResult {
40    /// Segments that passed all pruning levels.
41    pub segments: Vec<PrunedSegment>,
42    /// Pruning statistics for metrics.
43    pub stats: PruningStats,
44}
45
46/// A segment that survived pruning, with its catalog entry and file path.
47#[derive(Debug, Clone)]
48pub struct PrunedSegment {
49    /// Catalog entry for this segment.
50    pub entry: SegmentCatalogEntry,
51    /// Filesystem path to the segment file.
52    pub path: PathBuf,
53}
54
55/// Run the multi-level pruning pipeline.
56///
57/// # Levels
58///
59/// 1. **Time pruning**: Uses `TimeIndex` to find segments overlapping `[start, end]`
60/// 2. **Bloom pruning**: Uses bloom filters to exclude segments missing the series key
61/// 3. **Stats pruning**: Uses column statistics to exclude segments where
62///    required tag columns have zero `distinct_count` (column exists but is
63///    all-null — cannot possibly match a tag equality filter).
64///
65/// # Arguments
66///
67/// - `time_index` — Time range index
68/// - `catalog` — Segment catalog for metadata lookup
69/// - `bloom_filters` — Per-segment bloom filters (`segment_id` → filter)
70/// - `start_ts`, `end_ts` — Query time range
71/// - `series_key` — Canonical series key (for bloom), or `None`
72/// - `tag_filter_keys` — Tag column names used in equality filters (for stats pruning)
73#[must_use]
74pub fn prune_segments<S: BuildHasher>(
75    time_index: &TimeIndex,
76    catalog: &SegmentCatalog,
77    bloom_filters: &HashMap<u64, SeriesBloomFilter, S>,
78    start_ts: i64,
79    end_ts: i64,
80    series_key: Option<&chronix_core::SeriesKey>,
81    tag_filter_keys: &[&str],
82) -> PruningResult {
83    let segments_total = catalog.segment_count();
84
85    // Level 1: Time pruning
86    let time_matching_ids = time_index.segments_for_range(start_ts, end_ts);
87    let pruned_by_time = segments_total - time_matching_ids.len();
88
89    // Resolve IDs to catalog entries via HashMap for O(1) lookup per ID
90    let entries: Vec<&SegmentCatalogEntry> = {
91        let all = catalog.all_segments();
92        let id_map: std::collections::HashMap<u64, &SegmentCatalogEntry> =
93            all.iter().map(|e| (e.segment_id.0, *e)).collect();
94        time_matching_ids
95            .iter()
96            .filter_map(|seg_id| id_map.get(&seg_id.0).copied())
97            .collect()
98    };
99
100    let bloom_lookup = |seg_id: u64| bloom_filters.get(&seg_id);
101
102    let mut result = prune_entries(entries, bloom_lookup, series_key, tag_filter_keys);
103    result.stats.segments_total = segments_total;
104    result.stats.pruned_by_time = pruned_by_time;
105    result
106}
107
108/// Prune a pre-filtered set of segment entries through bloom and stats levels.
109///
110/// This is the workhorse used by both `prune_segments()` (after time-index
111/// filtering) and the `Chronix::execute()` / `execute_stream()` pipelines
112/// (which filter by measurement + time range inline before calling this).
113///
114/// # Arguments
115///
116/// - `entries` — Segment entries that already passed time + measurement filtering
117/// - `bloom_lookup` — Closure that returns the bloom filter for a given segment id
118/// - `series_key` — Canonical series key (for bloom), or `None` if no tag filters
119/// - `tag_filter_keys` — Tag column names used in equality filters (for stats pruning)
120#[must_use]
121pub fn prune_entries<'a, F>(
122    entries: Vec<&'a SegmentCatalogEntry>,
123    bloom_lookup: F,
124    series_key: Option<&chronix_core::SeriesKey>,
125    tag_filter_keys: &[&str],
126) -> PruningResult
127where
128    F: Fn(u64) -> Option<&'a SeriesBloomFilter>,
129{
130    let input_count = entries.len();
131    let mut stats = PruningStats::default();
132
133    // Level 2: Bloom filter pruning
134    let mut surviving: Vec<PrunedSegment> = Vec::with_capacity(input_count);
135
136    for entry in entries {
137        if let Some(series_key) = series_key {
138            if let Some(bloom) = bloom_lookup(entry.segment_id.0) {
139                if !bloom.may_contain(series_key) {
140                    stats.pruned_by_bloom += 1;
141                    continue;
142                }
143            }
144        }
145
146        surviving.push(PrunedSegment {
147            path: entry.path.clone(),
148            entry: entry.clone(),
149        });
150    }
151
152    // Level 3: Column stats pruning
153    // Exclude segments where a required tag column is present but has
154    // zero non-null values (all-null column cannot match equality filter).
155    if !tag_filter_keys.is_empty() {
156        surviving.retain(|ps| {
157            for tag_key in tag_filter_keys {
158                if let Some(cs) = ps.entry.column_stats.iter().find(|c| c.name == *tag_key) {
159                    if cs.stats.distinct_count == 0 && cs.stats.value_count == 0 {
160                        stats.pruned_by_stats += 1;
161                        return false;
162                    }
163                }
164            }
165            true
166        });
167    }
168
169    stats.segments_remaining = surviving.len();
170
171    PruningResult {
172        segments: surviving,
173        stats,
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use chronix_core::{SegmentId, SegmentState, ShardId};
181    use chronix_engine::index::time_index::TimeIndexEntry;
182    use std::collections::BTreeMap;
183
184    fn make_entry(id: u64, shard: i64, min_ts: i64, max_ts: i64) -> SegmentCatalogEntry {
185        SegmentCatalogEntry {
186            segment_id: SegmentId(id),
187            shard_id: ShardId(shard),
188            measurement: "cpu".to_string(),
189            path: PathBuf::from(format!("shard_{shard}/seg_{id}.csx")),
190            min_timestamp: min_ts,
191            max_timestamp: max_ts,
192            row_count: 1000,
193            series_count: 10,
194            byte_size: 4096,
195            row_group_count: 1,
196            column_count: 5,
197            column_stats: Vec::new(),
198            state: SegmentState::default(),
199        }
200    }
201
202    fn setup() -> (TimeIndex, SegmentCatalog) {
203        let dir = tempfile::tempdir().unwrap();
204        let time_index = TimeIndex::new();
205        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
206
207        // Add 4 segments with different time ranges
208        let entries = vec![
209            make_entry(1, 0, 100, 200),
210            make_entry(2, 0, 300, 400),
211            make_entry(3, 0, 500, 600),
212            make_entry(4, 0, 700, 800),
213        ];
214
215        for entry in &entries {
216            time_index.add_segment(TimeIndexEntry {
217                segment_id: entry.segment_id,
218                min_ts: entry.min_timestamp,
219                max_ts: entry.max_timestamp,
220            });
221            catalog.add_segment(entry.clone()).unwrap();
222        }
223
224        (time_index, catalog)
225    }
226
227    #[test]
228    fn time_pruning() {
229        let (time_index, catalog) = setup();
230        let blooms = HashMap::new();
231
232        let result = prune_segments(
233            &time_index,
234            &catalog,
235            &blooms,
236            250,
237            550, // should match segments 2 and 3
238            None,
239            &[],
240        );
241
242        assert_eq!(result.stats.segments_total, 4);
243        assert_eq!(result.stats.pruned_by_time, 2);
244        assert_eq!(result.segments.len(), 2);
245        assert_eq!(result.segments[0].entry.segment_id, SegmentId(2));
246        assert_eq!(result.segments[1].entry.segment_id, SegmentId(3));
247    }
248
249    #[test]
250    fn bloom_pruning() {
251        let (time_index, catalog) = setup();
252
253        let key1 = chronix_core::SeriesKey::new(
254            "cpu",
255            BTreeMap::from([("host".to_string(), "srv1".to_string())]),
256        )
257        .unwrap();
258
259        let key2 = chronix_core::SeriesKey::new(
260            "cpu",
261            BTreeMap::from([("host".to_string(), "srv2".to_string())]),
262        )
263        .unwrap();
264
265        // Create bloom filters: only segment 2 contains key1
266        let mut bloom2 = SeriesBloomFilter::new(100, 0.01);
267        bloom2.insert(&key1);
268
269        let mut bloom3 = SeriesBloomFilter::new(100, 0.01);
270        bloom3.insert(&key2); // doesn't contain key1
271
272        let mut bloom_map = HashMap::new();
273        bloom_map.insert(2, bloom2);
274        bloom_map.insert(3, bloom3);
275
276        let result = prune_segments(
277            &time_index,
278            &catalog,
279            &bloom_map,
280            250,
281            550,
282            Some(&key1),
283            &["host"],
284        );
285
286        // Segment 3 should be pruned by bloom
287        assert_eq!(result.stats.pruned_by_bloom, 1);
288        assert_eq!(result.segments.len(), 1);
289        assert_eq!(result.segments[0].entry.segment_id, SegmentId(2));
290    }
291
292    #[test]
293    fn no_pruning_when_all_match() {
294        let (time_index, catalog) = setup();
295        let blooms = HashMap::new();
296
297        let result = prune_segments(&time_index, &catalog, &blooms, 0, 1000, None, &[]);
298
299        assert_eq!(result.stats.pruned_by_time, 0);
300        assert_eq!(result.stats.pruned_by_bloom, 0);
301        assert_eq!(result.segments.len(), 4);
302    }
303
304    #[test]
305    fn all_pruned() {
306        let (time_index, catalog) = setup();
307        let blooms = HashMap::new();
308
309        let result = prune_segments(
310            &time_index,
311            &catalog,
312            &blooms,
313            900,
314            1000, // no segments in this range
315            None,
316            &[],
317        );
318
319        assert_eq!(result.segments.len(), 0);
320        assert_eq!(result.stats.pruned_by_time, 4);
321    }
322
323    #[test]
324    fn stats_pruning_by_all_null_tag() {
325        use chronix_engine::index::CatalogColumnStats;
326        use chronix_engine::segment::stats::ColumnStats;
327
328        let dir = tempfile::tempdir().unwrap();
329        let time_index = TimeIndex::new();
330        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
331
332        // Segment 1: has "host" tag with real data (distinct_count > 0)
333        let mut entry1 = make_entry(1, 0, 100, 200);
334        entry1.column_stats = vec![CatalogColumnStats {
335            name: "host".to_string(),
336            data_type: 1, // STRING
337            role: 1,      // TAG
338            decimal_scale: None,
339            stats: ColumnStats {
340                min_value: 0,
341                max_value: 0,
342                min_value_u64: 0,
343                max_value_u64: 0,
344                null_count: 0,
345                value_count: 10,
346                sum: 0.0,
347                sum_i128: 0,
348                distinct_count: 2, // has real values
349            },
350        }];
351
352        // Segment 2: has "host" tag but all null (distinct_count == 0)
353        let mut entry2 = make_entry(2, 0, 300, 400);
354        entry2.column_stats = vec![CatalogColumnStats {
355            name: "host".to_string(),
356            data_type: 1,
357            role: 1,
358            decimal_scale: None,
359            stats: ColumnStats {
360                min_value: 0,
361                max_value: 0,
362                min_value_u64: 0,
363                max_value_u64: 0,
364                null_count: 10,
365                value_count: 0,
366                sum: 0.0,
367                sum_i128: 0,
368                distinct_count: 0, // all null
369            },
370        }];
371
372        for entry in [&entry1, &entry2] {
373            time_index.add_segment(TimeIndexEntry {
374                segment_id: entry.segment_id,
375                min_ts: entry.min_timestamp,
376                max_ts: entry.max_timestamp,
377            });
378            catalog.add_segment(entry.clone()).unwrap();
379        }
380
381        let blooms = HashMap::new();
382        let result = prune_segments(
383            &time_index,
384            &catalog,
385            &blooms,
386            0,
387            500,
388            None,
389            &["host"], // filtering on "host" tag
390        );
391
392        // Segment 2 should be pruned by stats (all-null host column)
393        assert_eq!(result.stats.pruned_by_stats, 1);
394        assert_eq!(result.segments.len(), 1);
395        assert_eq!(result.segments[0].entry.segment_id, SegmentId(1));
396    }
397}