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