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. **Bloom pruning** — Exclude segments that cannot contain the series key
5//! 2. **Stats pruning** — Exclude segments where column statistics exclude the predicate
6//!
7//! Time and tag-index pruning happen one level up, in `Chronix::prune_segments`,
8//! where the candidate set is already scoped to one measurement: the catalog
9//! entry carries `min_timestamp`/`max_timestamp`, so a separate time index
10//! answered nothing the catalog could not.
11//!
12//! **Every level here may only ever fail towards more work.** A segment with
13//! no bloom, or with no statistics for the filtered tag, is kept — both
14//! structures are derived from a segment's sidecar and can lag the catalog,
15//! and pruning what has not been seen loses rows with no error anywhere.
16
17use chronix_engine::index::{SegmentCatalogEntry, SeriesBloomFilter};
18
19/// Statistics about segment pruning during query execution.
20#[derive(Debug, Clone, Default)]
21pub struct PruningStats {
22    /// Total segments considered.
23    pub segments_total: usize,
24    /// Segments pruned by time range.
25    pub pruned_by_time: usize,
26    /// Segments pruned by bloom filter.
27    pub pruned_by_bloom: usize,
28    /// Segments pruned by column statistics.
29    pub pruned_by_stats: usize,
30    /// Segments that passed all pruning levels.
31    pub segments_remaining: usize,
32}
33
34impl PruningStats {
35    /// Total segments pruned across all levels.
36    #[must_use]
37    pub fn total_pruned(&self) -> usize {
38        self.pruned_by_time + self.pruned_by_bloom + self.pruned_by_stats
39    }
40}
41
42/// Result of the pruning pipeline: surviving segment entries and statistics.
43#[derive(Debug)]
44pub struct PruningResult {
45    /// Segments that passed all pruning levels.
46    pub segments: Vec<PrunedSegment>,
47    /// Pruning statistics for metrics.
48    pub stats: PruningStats,
49}
50
51/// A segment that survived pruning.
52///
53/// It used to carry a `path` beside the entry as well. Nothing ever read it —
54/// every caller went to `entry` — and it was a second copy of the one fact
55/// the entry already states.
56#[derive(Debug, Clone)]
57pub struct PrunedSegment {
58    /// Catalog entry for this segment.
59    pub entry: SegmentCatalogEntry,
60}
61
62/// Prune a pre-filtered set of segment entries through bloom and stats levels.
63///
64/// This is the workhorse used by both `prune_segments()` (after time-index
65/// filtering) and the `Chronix::execute()` / `execute_stream()` pipelines
66/// (which filter by measurement + time range inline before calling this).
67///
68/// # Arguments
69///
70/// - `entries` — Segment entries that already passed time + measurement filtering
71/// - `bloom_lookup` — Closure that returns the bloom filter for a given segment id
72/// - `series_key` — Canonical series key (for bloom), or `None` if no tag filters
73/// - `tag_filter_keys` — Tag column names used in equality filters (for stats pruning)
74#[must_use]
75pub fn prune_entries<'a, F>(
76    entries: Vec<&'a SegmentCatalogEntry>,
77    bloom_lookup: F,
78    series_key: Option<&chronix_core::SeriesKey>,
79    tag_filter_keys: &[&str],
80) -> PruningResult
81where
82    F: Fn(u64) -> Option<&'a SeriesBloomFilter>,
83{
84    let input_count = entries.len();
85    let mut stats = PruningStats::default();
86
87    // Level 2: Bloom filter pruning
88    let mut surviving: Vec<PrunedSegment> = Vec::with_capacity(input_count);
89
90    for entry in entries {
91        if let Some(series_key) = series_key {
92            if let Some(bloom) = bloom_lookup(entry.segment_id.0) {
93                if !bloom.may_contain(series_key) {
94                    stats.pruned_by_bloom += 1;
95                    continue;
96                }
97            }
98        }
99
100        surviving.push(PrunedSegment {
101            entry: entry.clone(),
102        });
103    }
104
105    // Level 3: Column stats pruning
106    // Exclude segments where a required tag column is present but has
107    // zero non-null values (all-null column cannot match equality filter).
108    if !tag_filter_keys.is_empty() {
109        surviving.retain(|ps| {
110            for tag_key in tag_filter_keys {
111                if let Some(cs) = ps.entry.column_stats.iter().find(|c| c.name == *tag_key) {
112                    if cs.stats.distinct_count == 0 && cs.stats.value_count == 0 {
113                        stats.pruned_by_stats += 1;
114                        return false;
115                    }
116                }
117            }
118            true
119        });
120    }
121
122    stats.segments_remaining = surviving.len();
123
124    PruningResult {
125        segments: surviving,
126        stats,
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use chronix_core::{SegmentFile, SegmentId, SegmentState, ShardId};
134
135    fn make_entry(id: u64, shard: i64, min_ts: i64, max_ts: i64) -> SegmentCatalogEntry {
136        SegmentCatalogEntry {
137            segment_id: SegmentId(id),
138            shard_id: ShardId(shard),
139            measurement: "cpu".to_string(),
140            file: SegmentFile::new(ShardId(shard), &format!("seg_{id}.csx")).unwrap(),
141            min_timestamp: min_ts,
142            max_timestamp: max_ts,
143            row_count: 1000,
144            series_count: 10,
145            byte_size: 4096,
146            row_group_count: 1,
147            column_count: 5,
148            column_stats: Vec::new(),
149            state: SegmentState::default(),
150        }
151    }
152
153    fn tag_stats(
154        name: &str,
155        distinct: u32,
156        values: u64,
157    ) -> chronix_engine::index::CatalogColumnStats {
158        chronix_engine::index::CatalogColumnStats {
159            name: name.to_string(),
160            data_type: 1, // STRING
161            role: 1,      // TAG
162            decimal_scale: None,
163            stats: chronix_engine::segment::stats::ColumnStats {
164                min_value: 0,
165                max_value: 0,
166                min_value_u64: 0,
167                max_value_u64: 0,
168                null_count: 0,
169                value_count: values,
170                sum: 0.0,
171                sum_i128: 0,
172                distinct_count: distinct,
173            },
174        }
175    }
176
177    #[test]
178    fn nothing_is_pruned_without_a_series_key_or_a_tag_filter() {
179        let entries = [make_entry(1, 0, 100, 200), make_entry(2, 0, 300, 400)];
180        let refs: Vec<&SegmentCatalogEntry> = entries.iter().collect();
181        let result = prune_entries(refs, |_| None, None, &[]);
182        assert_eq!(result.segments.len(), 2);
183        assert_eq!(result.stats.total_pruned(), 0);
184    }
185
186    /// A missing bloom keeps its segment.
187    ///
188    /// A pruning structure may only ever fail towards more work: the blooms
189    /// are rebuilt from sidecars and a segment can be in the catalog before
190    /// its bloom is, so "no bloom" has to mean "may match".
191    #[test]
192    fn a_segment_without_a_bloom_is_kept() {
193        use chronix_engine::index::SeriesBloomFilter;
194        let entries = [make_entry(1, 0, 100, 200), make_entry(2, 0, 300, 400)];
195        let refs: Vec<&SegmentCatalogEntry> = entries.iter().collect();
196
197        let key = chronix_core::SeriesKey::new(
198            "cpu",
199            [("host".to_string(), "a".to_string())]
200                .into_iter()
201                .collect::<std::collections::BTreeMap<_, _>>(),
202        )
203        .unwrap();
204
205        // Segment 1 has a bloom that does not hold the key; segment 2 has none.
206        let mut bloom = SeriesBloomFilter::new(8, 0.01);
207        bloom.insert(
208            &chronix_core::SeriesKey::new(
209                "cpu",
210                [("host".to_string(), "z".to_string())]
211                    .into_iter()
212                    .collect::<std::collections::BTreeMap<_, _>>(),
213            )
214            .unwrap(),
215        );
216
217        let result = prune_entries(refs, |id| (id == 1).then_some(&bloom), Some(&key), &[]);
218        assert_eq!(result.stats.pruned_by_bloom, 1);
219        assert_eq!(result.segments.len(), 1);
220        assert_eq!(result.segments[0].entry.segment_id, SegmentId(2));
221    }
222
223    /// A tag column that exists and is entirely null cannot match an equality
224    /// filter on it.
225    #[test]
226    fn an_all_null_tag_column_is_pruned_by_its_statistics() {
227        let mut with_values = make_entry(1, 0, 100, 200);
228        with_values.column_stats = vec![tag_stats("host", 2, 10)];
229        let mut all_null = make_entry(2, 0, 300, 400);
230        all_null.column_stats = vec![tag_stats("host", 0, 0)];
231
232        let entries = [with_values, all_null];
233        let refs: Vec<&SegmentCatalogEntry> = entries.iter().collect();
234        let result = prune_entries(refs, |_| None, None, &["host"]);
235
236        assert_eq!(result.stats.pruned_by_stats, 1);
237        assert_eq!(result.segments.len(), 1);
238        assert_eq!(result.segments[0].entry.segment_id, SegmentId(1));
239    }
240
241    /// A segment carrying no statistics for the filtered tag is kept.
242    #[test]
243    fn a_segment_with_no_statistics_for_the_tag_is_kept() {
244        let entries = [make_entry(1, 0, 100, 200)];
245        let refs: Vec<&SegmentCatalogEntry> = entries.iter().collect();
246        let result = prune_entries(refs, |_| None, None, &["host"]);
247        assert_eq!(result.segments.len(), 1);
248        assert_eq!(result.stats.pruned_by_stats, 0);
249    }
250}