Skip to main content

chronix_engine/index/
time_index.rs

1//! Time-range index for segment pruning.
2//!
3//! The [`TimeIndex`] maintains a sorted collection of segment time ranges,
4//! enabling O(log N) lookup to find segments that overlap a query's time
5//! window. This is the first level of segment pruning.
6//!
7//! Internally backed by a `BTreeSet` keyed on `(min_ts, segment_id)` for
8//! O(log N) insert and remove, replacing the former `Vec`-based approach
9//! that required O(N) element shifting.
10
11use std::collections::{BTreeSet, HashMap};
12use std::ops::Bound;
13use std::sync::Arc;
14
15use parking_lot::RwLock;
16
17use chronix_core::{SegmentId, Timestamp};
18
19/// An entry in the time index representing a segment's time range.
20#[derive(Debug, Clone, Eq)]
21pub struct TimeIndexEntry {
22    /// Unique segment identifier.
23    pub segment_id: SegmentId,
24    /// Minimum timestamp in the segment (inclusive).
25    pub min_ts: Timestamp,
26    /// Maximum timestamp in the segment (inclusive).
27    pub max_ts: Timestamp,
28}
29
30impl PartialEq for TimeIndexEntry {
31    fn eq(&self, other: &Self) -> bool {
32        self.min_ts == other.min_ts && self.segment_id == other.segment_id
33    }
34}
35
36impl PartialOrd for TimeIndexEntry {
37    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
38        Some(self.cmp(other))
39    }
40}
41
42impl Ord for TimeIndexEntry {
43    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
44        self.min_ts
45            .cmp(&other.min_ts)
46            .then_with(|| self.segment_id.cmp(&other.segment_id))
47    }
48}
49
50impl TimeIndexEntry {
51    /// Check whether this entry's time range overlaps with `[start, end]`.
52    #[inline]
53    #[must_use]
54    pub fn overlaps(&self, start: Timestamp, end: Timestamp) -> bool {
55        self.min_ts <= end && self.max_ts >= start
56    }
57}
58
59/// Sorted time-range index for fast segment pruning.
60///
61/// Entries are kept in a `BTreeSet` ordered by `(min_ts, segment_id)` for
62/// O(log N) insert and remove. Range queries use a BTree range scan.
63/// Thread-safe via `Arc<RwLock<>>`.
64///
65/// # Complexity
66///
67/// - `segments_for_range`: O(log N + K) where K is the number of matching segments
68/// - `add_segment`: O(log N) insert
69/// - `remove_segment`: O(log N) remove
70#[derive(Debug, Clone)]
71pub struct TimeIndex {
72    entries: Arc<RwLock<BTreeSet<TimeIndexEntry>>>,
73    /// O(1) segment ID → (min_ts, max_ts) lookup for fast removal.
74    segment_meta: Arc<RwLock<HashMap<SegmentId, (Timestamp, Timestamp)>>>,
75}
76
77impl TimeIndex {
78    /// Create an empty time index.
79    #[must_use]
80    pub fn new() -> Self {
81        Self {
82            entries: Arc::new(RwLock::new(BTreeSet::new())),
83            segment_meta: Arc::new(RwLock::new(HashMap::new())),
84        }
85    }
86
87    /// Find all segments whose time range overlaps `[start_ts, end_ts]`.
88    ///
89    /// Uses a BTree range scan: iterates entries with `min_ts <= end_ts`
90    /// and filters by `max_ts >= start_ts`. Entries with `min_ts > end_ts`
91    /// are skipped entirely via the range upper bound.
92    /// There is deliberately no bounded variant. One existed —
93    /// `segments_for_range_limited`, documented for "pagination or preview
94    /// queries" — whose only caller passed `usize::MAX`. Dropping segments
95    /// from a scan is not pagination: it returns an arbitrary subset of the
96    /// rows with no error and no way for the caller to tell.
97    #[must_use]
98    pub fn segments_for_range(&self, start_ts: Timestamp, end_ts: Timestamp) -> Vec<SegmentId> {
99        let entries = self.entries.read();
100        if entries.is_empty() {
101            return Vec::new();
102        }
103        // Every entry with `min_ts <= end_ts`; `max_ts >= start_ts` filters
104        // the rest.
105        let upper = TimeIndexEntry {
106            segment_id: SegmentId(u64::MAX),
107            min_ts: end_ts,
108            max_ts: Timestamp::MAX,
109        };
110        entries
111            .range((Bound::Unbounded, Bound::Included(&upper)))
112            .filter(|e| e.max_ts >= start_ts)
113            .map(|e| e.segment_id)
114            .collect()
115    }
116
117    /// Add a segment to the index.
118    ///
119    /// Acquires locks in canonical order (entries → meta)
120    /// to prevent deadlocks with `remove_segment()`.
121    pub fn add_segment(&self, entry: TimeIndexEntry) {
122        let seg_id = entry.segment_id;
123        let meta_val = (entry.min_ts, entry.max_ts);
124        let mut entries = self.entries.write();
125        let mut meta = self.segment_meta.write();
126        entries.insert(entry);
127        meta.insert(seg_id, meta_val);
128    }
129
130    /// Remove a segment from the index by its ID.
131    ///
132    /// Acquires locks in canonical order (entries → meta),
133    /// matching `add_segment()`, to prevent deadlocks. Both structures
134    /// are updated atomically under their respective write locks.
135    /// Returns `true` if the segment was found and removed.
136    #[must_use]
137    pub fn remove_segment(&self, segment_id: SegmentId) -> bool {
138        let mut entries = self.entries.write();
139        let mut meta = self.segment_meta.write();
140        if let Some(&(min_ts, max_ts)) = meta.get(&segment_id) {
141            let key = TimeIndexEntry {
142                segment_id,
143                min_ts,
144                max_ts,
145            };
146            let removed = entries.remove(&key);
147            if removed {
148                meta.remove(&segment_id);
149            }
150            removed
151        } else {
152            false
153        }
154    }
155
156    /// Returns the number of entries in the index.
157    #[must_use]
158    pub fn len(&self) -> usize {
159        self.entries.read().len()
160    }
161
162    /// Returns `true` if the index is empty.
163    #[must_use]
164    pub fn is_empty(&self) -> bool {
165        self.entries.read().is_empty()
166    }
167
168    /// Returns all entries sorted by `(min_ts, segment_id)` (for serialization/persistence).
169    #[must_use]
170    pub fn all_entries(&self) -> Vec<TimeIndexEntry> {
171        self.entries.read().iter().cloned().collect()
172    }
173}
174
175impl Default for TimeIndex {
176    fn default() -> Self {
177        Self::new()
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    fn entry(id: u64, min: i64, max: i64) -> TimeIndexEntry {
186        TimeIndexEntry {
187            segment_id: SegmentId(id),
188            min_ts: min,
189            max_ts: max,
190        }
191    }
192
193    #[test]
194    fn empty_index_returns_nothing() {
195        let idx = TimeIndex::new();
196        let result = idx.segments_for_range(0, 1000);
197        assert!(result.is_empty());
198    }
199
200    #[test]
201    fn single_segment_match() {
202        let idx = TimeIndex::new();
203        idx.add_segment(entry(1, 100, 200));
204
205        // Exact overlap
206        assert_eq!(idx.segments_for_range(100, 200), vec![SegmentId(1)]);
207        // Partial overlap
208        assert_eq!(idx.segments_for_range(150, 300), vec![SegmentId(1)]);
209        // No overlap (before)
210        assert!(idx.segments_for_range(0, 99).is_empty());
211        // No overlap (after)
212        assert!(idx.segments_for_range(201, 300).is_empty());
213        // Touch at boundary
214        assert_eq!(idx.segments_for_range(200, 300), vec![SegmentId(1)]);
215        assert_eq!(idx.segments_for_range(0, 100), vec![SegmentId(1)]);
216    }
217
218    #[test]
219    fn multiple_segments_pruning() {
220        let idx = TimeIndex::new();
221        // Segments: [100,200], [300,400], [500,600], [700,800]
222        idx.add_segment(entry(1, 100, 200));
223        idx.add_segment(entry(2, 300, 400));
224        idx.add_segment(entry(3, 500, 600));
225        idx.add_segment(entry(4, 700, 800));
226
227        // Query [250,550] should match segments 2 and 3
228        let result = idx.segments_for_range(250, 550);
229        assert_eq!(result, vec![SegmentId(2), SegmentId(3)]);
230
231        // Query spanning all
232        let result = idx.segments_for_range(0, 1000);
233        assert_eq!(
234            result,
235            vec![SegmentId(1), SegmentId(2), SegmentId(3), SegmentId(4)]
236        );
237
238        // Query matching none
239        assert!(idx.segments_for_range(900, 1000).is_empty());
240    }
241
242    #[test]
243    fn overlapping_segments() {
244        let idx = TimeIndex::new();
245        // Segments overlap: [100,300], [200,400], [350,500]
246        idx.add_segment(entry(1, 100, 300));
247        idx.add_segment(entry(2, 200, 400));
248        idx.add_segment(entry(3, 350, 500));
249
250        let result = idx.segments_for_range(250, 350);
251        assert_eq!(result, vec![SegmentId(1), SegmentId(2), SegmentId(3)]);
252    }
253
254    #[test]
255    fn add_maintains_sort_order() {
256        let idx = TimeIndex::new();
257        idx.add_segment(entry(3, 500, 600));
258        idx.add_segment(entry(1, 100, 200));
259        idx.add_segment(entry(2, 300, 400));
260
261        let entries = idx.all_entries();
262        assert_eq!(entries[0].min_ts, 100);
263        assert_eq!(entries[1].min_ts, 300);
264        assert_eq!(entries[2].min_ts, 500);
265    }
266
267    #[test]
268    fn remove_segment() {
269        let idx = TimeIndex::new();
270        idx.add_segment(entry(1, 100, 200));
271        idx.add_segment(entry(2, 300, 400));
272
273        assert!(idx.remove_segment(SegmentId(1)));
274        assert_eq!(idx.len(), 1);
275        assert!(!idx.remove_segment(SegmentId(99)));
276    }
277
278    #[test]
279    fn hundred_segments_pruning() {
280        let idx = TimeIndex::new();
281
282        // Add 100 segments: [i*1000, i*1000+500] for i in 0..100
283        for i in 0u64..100 {
284            #[allow(clippy::cast_possible_wrap)]
285            let start = (i as i64) * 1000;
286            idx.add_segment(entry(i, start, start + 500));
287        }
288        assert_eq!(idx.len(), 100);
289
290        // Query [5000, 7500] should match segments 5,6,7
291        // seg 5: [5000,5500], seg 6: [6000,6500], seg 7: [7000,7500]
292        let result = idx.segments_for_range(5000, 7500);
293        assert_eq!(result, vec![SegmentId(5), SegmentId(6), SegmentId(7)]);
294
295        // Query for a single point
296        let result = idx.segments_for_range(50250, 50250);
297        assert_eq!(result, vec![SegmentId(50)]);
298    }
299
300    #[test]
301    fn entry_overlaps() {
302        let e = entry(1, 100, 200);
303        assert!(e.overlaps(100, 200)); // exact
304        assert!(e.overlaps(50, 150)); // left overlap
305        assert!(e.overlaps(150, 250)); // right overlap
306        assert!(e.overlaps(120, 180)); // contained
307        assert!(e.overlaps(50, 250)); // spanning
308        assert!(!e.overlaps(0, 99)); // before
309        assert!(!e.overlaps(201, 300)); // after
310    }
311}