Skip to main content

chronix_engine/index/
inverted.rs

1//! Inverted tag index — maps `"tag_key=tag_value"` → `Vec<SegmentId>`.
2//!
3//! Used to quickly prune segments that cannot contain a queried tag value.
4//! Rebuilt from segment metadata on startup; updated on segment creation
5//! and deletion.
6
7use std::collections::{HashMap, HashSet};
8
9use parking_lot::RwLock;
10
11use chronix_core::SegmentId;
12
13/// Build a tag index key without `format!` overhead.
14///
15/// Uses [`chronix_core::KV_SEPARATOR`] rather than `=` because `=` is legal in
16/// both tag keys and tag values: `("a=b", "c")` and `("a", "b=c")` would
17/// otherwise produce the same index key. That only ever caused false
18/// positives (extra segments scanned, then filtered), but the separator is
19/// reserved and free, so the ambiguity does not need to exist.
20#[inline]
21fn make_tag_key(key: &str, value: &str) -> String {
22    let mut s = String::with_capacity(key.len() + 1 + value.len());
23    s.push_str(key);
24    s.push(chronix_core::KV_SEPARATOR);
25    s.push_str(value);
26    s
27}
28
29/// Thread-safe inverted tag index.
30///
31/// Maps `"tag_key=tag_value"` → set of segment IDs that contain that
32/// tag value. This enables O(1) lookup to determine which segments
33/// could possibly match a tag filter, avoiding full segment scans.
34///
35/// A reverse index (`segment_id` → set of tag keys) enables O(k) segment
36/// removal where k is the number of tag values for that segment, instead
37/// of scanning all entries.
38///
39/// # Thread Safety
40///
41/// A single `RwLock<InvertedState>` is used intentionally —
42/// `remove_segment` must atomically update all three maps (index, reverse,
43/// key_values) to prevent partial state on crash/panic. `parking_lot::RwLock`
44/// allows concurrent readers; writers only hold the lock during short
45/// segment catalog mutations (add/remove), which are infrequent relative
46/// to the query read-path.
47#[derive(Debug)]
48pub struct TagInvertedIndex {
49    state: RwLock<InvertedState>,
50    /// Maximum number of distinct tag values tracked per tag key.
51    ///
52    /// When a key exceeds this cardinality, new values for that key are
53    /// silently dropped and a warning is emitted. This prevents unbounded
54    /// memory growth on high-cardinality workloads.
55    max_tag_cardinality: usize,
56}
57
58/// Inner state for the inverted tag index, guarded by a single lock.
59#[derive(Debug, Default)]
60struct InvertedState {
61    /// Map from "key=value" → set of segment IDs.
62    index: HashMap<String, HashSet<SegmentId>>,
63    /// Reverse map: segment ID → set of "key=value" strings.
64    reverse: HashMap<SegmentId, HashSet<String>>,
65    /// Secondary index mapping tag key → set of distinct values.
66    key_values: HashMap<String, HashSet<String>>,
67}
68
69impl TagInvertedIndex {
70    /// Create an empty inverted tag index.
71    #[must_use]
72    pub fn new() -> Self {
73        Self {
74            state: RwLock::new(InvertedState::default()),
75            max_tag_cardinality: 1_000_000,
76        }
77    }
78
79    /// Create an empty inverted tag index with a custom cardinality limit.
80    ///
81    /// Limits the number of distinct values tracked per tag key.
82    #[must_use]
83    pub fn with_max_cardinality(max_tag_cardinality: usize) -> Self {
84        Self {
85            state: RwLock::new(InvertedState::default()),
86            max_tag_cardinality,
87        }
88    }
89
90    /// Register a segment's tag values in the index.
91    ///
92    /// For each tag column in the segment's catalog entry, the distinct
93    /// values (if available via column stats or direct metadata) are
94    /// mapped to the segment ID.
95    pub fn add_segment(&self, segment_id: SegmentId, tag_values: &[(&str, &str)]) {
96        let mut s = self.state.write();
97        let mut rev_keys = Vec::with_capacity(tag_values.len());
98        for &(key, value) in tag_values {
99            let entry_key = make_tag_key(key, value);
100            s.index
101                .entry(entry_key.clone())
102                .or_default()
103                .insert(segment_id);
104            rev_keys.push(entry_key);
105            // Maintain key→values secondary index
106            // Enforce cardinality limit per tag key.
107            let kv_entry = s.key_values.entry(key.to_string()).or_default();
108            if kv_entry.len() < self.max_tag_cardinality {
109                kv_entry.insert(value.to_string());
110            } else if !kv_entry.contains(value) {
111                tracing::warn!(
112                    key,
113                    cardinality = kv_entry.len(),
114                    limit = self.max_tag_cardinality,
115                    "tag cardinality limit reached — new values dropped"
116                );
117                metrics::counter!("chronix_tag_cardinality_limit_reached_total", "key" => key.to_string())
118                    .increment(1);
119            }
120        }
121        s.reverse.entry(segment_id).or_default().extend(rev_keys);
122    }
123
124    /// Remove a segment from the index in O(k) time where k is the
125    /// number of tag values for that segment.
126    pub fn remove_segment(&self, segment_id: SegmentId) {
127        let mut s = self.state.write();
128
129        if let Some(keys) = s.reverse.remove(&segment_id) {
130            for key in &keys {
131                if let Some(segments) = s.index.get_mut(key) {
132                    segments.remove(&segment_id);
133                    if segments.is_empty() {
134                        s.index.remove(key);
135                        // Remove from key_values when no segments
136                        // reference this key=value pair any more.
137                        if let Some(pos) = key.find(chronix_core::KV_SEPARATOR) {
138                            let tag_key = &key[..pos];
139                            let tag_val = &key[pos + 1..];
140                            if let Some(vals) = s.key_values.get_mut(tag_key) {
141                                vals.remove(tag_val);
142                                if vals.is_empty() {
143                                    s.key_values.remove(tag_key);
144                                }
145                            }
146                        }
147                    }
148                }
149            }
150        }
151    }
152
153    /// Find segments that contain a specific tag value.
154    #[must_use]
155    pub fn segments_for_tag(&self, key: &str, value: &str) -> Vec<SegmentId> {
156        let entry_key = make_tag_key(key, value);
157        let s = self.state.read();
158        let mut result: Vec<SegmentId> = s
159            .index
160            .get(&entry_key)
161            .map(|s| s.iter().copied().collect())
162            .unwrap_or_default();
163        result.sort();
164        result
165    }
166
167    /// Find segments matching ALL tag filters (intersection).
168    ///
169    /// Returns the intersection of segments across all tag filters.
170    /// If filters is empty, returns an empty Vec.
171    #[must_use]
172    pub fn segments_for_tags(&self, filters: &[(&str, &str)]) -> Vec<SegmentId> {
173        if filters.is_empty() {
174            return Vec::new();
175        }
176
177        let s = self.state.read();
178        let mut result: Option<HashSet<SegmentId>> = None;
179
180        for &(key, value) in filters {
181            let entry_key = make_tag_key(key, value);
182            let matching = s.index.get(&entry_key).cloned().unwrap_or_default();
183
184            result = Some(match result {
185                Some(existing) => existing.intersection(&matching).copied().collect(),
186                None => matching,
187            });
188        }
189
190        let mut out: Vec<SegmentId> = result.map_or_else(Vec::new, |s| s.into_iter().collect());
191        out.sort();
192        out
193    }
194
195    /// Atomically replace multiple old segments with a single new segment.
196    ///
197    /// Used during compaction to avoid a window where the old segments have
198    /// been removed but the new one hasn't been added yet, which would cause
199    /// concurrent tag-filtered queries to miss data.
200    pub fn replace_segments(
201        &self,
202        old_segment_ids: &[SegmentId],
203        new_segment_id: SegmentId,
204        new_tag_values: &[(&str, &str)],
205    ) {
206        let mut s = self.state.write();
207
208        // Remove old segments
209        for &old_id in old_segment_ids {
210            if let Some(keys) = s.reverse.remove(&old_id) {
211                for key in &keys {
212                    if let Some(segments) = s.index.get_mut(key) {
213                        segments.remove(&old_id);
214                        if segments.is_empty() {
215                            s.index.remove(key);
216                            if let Some(pos) = key.find(chronix_core::KV_SEPARATOR) {
217                                let tag_key = &key[..pos];
218                                let tag_val = &key[pos + 1..];
219                                if let Some(vals) = s.key_values.get_mut(tag_key) {
220                                    vals.remove(tag_val);
221                                    if vals.is_empty() {
222                                        s.key_values.remove(tag_key);
223                                    }
224                                }
225                            }
226                        }
227                    }
228                }
229            }
230        }
231
232        // Add new segment
233        let mut rev_keys = Vec::with_capacity(new_tag_values.len());
234        for &(key, value) in new_tag_values {
235            let entry_key = make_tag_key(key, value);
236            s.index
237                .entry(entry_key.clone())
238                .or_default()
239                .insert(new_segment_id);
240            rev_keys.push(entry_key);
241            s.key_values
242                .entry(key.to_string())
243                .or_default()
244                .insert(value.to_string());
245        }
246        s.reverse
247            .entry(new_segment_id)
248            .or_default()
249            .extend(rev_keys);
250    }
251
252    /// Clear the entire index (e.g., for a full rebuild).
253    pub fn clear(&self) {
254        let mut s = self.state.write();
255        s.index.clear();
256        s.reverse.clear();
257        s.key_values.clear();
258    }
259
260    /// Returns the total number of indexed tag-value pairs.
261    #[must_use]
262    pub fn entry_count(&self) -> usize {
263        self.state.read().index.len()
264    }
265
266    /// Returns all distinct values for a given tag key.
267    ///
268    /// O(1) lookup via the secondary `key_values` index
269    /// instead of scanning all entries with `strip_prefix`.
270    #[must_use]
271    pub fn values_for_key(&self, key: &str) -> Vec<String> {
272        let s = self.state.read();
273        let mut values: Vec<String> = s
274            .key_values
275            .get(key)
276            .map(|s| s.iter().cloned().collect())
277            .unwrap_or_default();
278        values.sort();
279        values
280    }
281
282    /// Returns all distinct tag keys present in the index.
283    ///
284    /// O(1) lookup via the secondary `key_values` index
285    /// instead of scanning all entries.
286    #[must_use]
287    pub fn all_keys(&self) -> Vec<String> {
288        let s = self.state.read();
289        let mut keys: Vec<String> = s.key_values.keys().cloned().collect();
290        keys.sort();
291        keys
292    }
293}
294
295impl Default for TagInvertedIndex {
296    fn default() -> Self {
297        Self::new()
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn add_and_query_tag() {
307        let idx = TagInvertedIndex::new();
308        idx.add_segment(SegmentId(1), &[("host", "srv1"), ("region", "us-east")]);
309        idx.add_segment(SegmentId(2), &[("host", "srv2"), ("region", "us-east")]);
310        idx.add_segment(SegmentId(3), &[("host", "srv1"), ("region", "eu-west")]);
311
312        let segs = idx.segments_for_tag("host", "srv1");
313        assert_eq!(segs.len(), 2);
314        assert!(segs.contains(&SegmentId(1)));
315        assert!(segs.contains(&SegmentId(3)));
316    }
317
318    #[test]
319    fn intersection_multi_tag() {
320        let idx = TagInvertedIndex::new();
321        idx.add_segment(SegmentId(1), &[("host", "srv1"), ("region", "us-east")]);
322        idx.add_segment(SegmentId(2), &[("host", "srv1"), ("region", "eu-west")]);
323        idx.add_segment(SegmentId(3), &[("host", "srv2"), ("region", "us-east")]);
324
325        let segs = idx.segments_for_tags(&[("host", "srv1"), ("region", "us-east")]);
326        assert_eq!(segs.len(), 1);
327        assert!(segs.contains(&SegmentId(1)));
328    }
329
330    #[test]
331    fn remove_segment() {
332        let idx = TagInvertedIndex::new();
333        idx.add_segment(SegmentId(1), &[("host", "srv1")]);
334        idx.add_segment(SegmentId(2), &[("host", "srv1")]);
335
336        idx.remove_segment(SegmentId(1));
337        let segs = idx.segments_for_tag("host", "srv1");
338        assert_eq!(segs.len(), 1);
339        assert!(segs.contains(&SegmentId(2)));
340    }
341
342    #[test]
343    fn missing_tag_returns_empty() {
344        let idx = TagInvertedIndex::new();
345        idx.add_segment(SegmentId(1), &[("host", "srv1")]);
346
347        assert!(idx.segments_for_tag("datacenter", "dc1").is_empty());
348    }
349
350    #[test]
351    fn clear_empties_index() {
352        let idx = TagInvertedIndex::new();
353        idx.add_segment(SegmentId(1), &[("host", "srv1")]);
354        idx.clear();
355        assert_eq!(idx.entry_count(), 0);
356    }
357
358    #[test]
359    fn empty_filters_returns_empty() {
360        let idx = TagInvertedIndex::new();
361        idx.add_segment(SegmentId(1), &[("host", "srv1")]);
362        assert!(idx.segments_for_tags(&[]).is_empty());
363    }
364
365    #[test]
366    fn values_for_key_returns_sorted_distinct() {
367        let idx = TagInvertedIndex::new();
368        idx.add_segment(SegmentId(1), &[("host", "srv1"), ("region", "us-east")]);
369        idx.add_segment(SegmentId(2), &[("host", "srv2"), ("region", "us-east")]);
370        idx.add_segment(SegmentId(3), &[("host", "srv1"), ("region", "eu-west")]);
371
372        let hosts = idx.values_for_key("host");
373        assert_eq!(hosts, vec!["srv1", "srv2"]);
374
375        let regions = idx.values_for_key("region");
376        assert_eq!(regions, vec!["eu-west", "us-east"]);
377
378        // Non-existent key returns empty
379        assert!(idx.values_for_key("datacenter").is_empty());
380    }
381
382    #[test]
383    fn all_keys_returns_sorted_distinct() {
384        let idx = TagInvertedIndex::new();
385        idx.add_segment(SegmentId(1), &[("host", "srv1"), ("region", "us-east")]);
386        idx.add_segment(SegmentId(2), &[("env", "prod"), ("host", "srv2")]);
387
388        let keys = idx.all_keys();
389        assert_eq!(keys, vec!["env", "host", "region"]);
390    }
391
392    #[test]
393    fn all_keys_empty_index() {
394        let idx = TagInvertedIndex::new();
395        assert!(idx.all_keys().is_empty());
396        assert!(idx.values_for_key("any").is_empty());
397    }
398
399    #[test]
400    fn remove_segment_cleans_up_reverse_index() {
401        let idx = TagInvertedIndex::new();
402        idx.add_segment(SegmentId(1), &[("host", "srv1"), ("region", "us-east")]);
403        idx.add_segment(SegmentId(2), &[("host", "srv2"), ("region", "eu-west")]);
404
405        // Verify reverse index populated
406        assert_eq!(idx.state.read().reverse.len(), 2);
407
408        // Remove segment 1 — only its entries should be affected.
409        idx.remove_segment(SegmentId(1));
410        assert_eq!(idx.state.read().reverse.len(), 1);
411        assert!(idx.segments_for_tag("host", "srv1").is_empty());
412        assert!(idx.segments_for_tag("region", "us-east").is_empty());
413        assert_eq!(idx.segments_for_tag("host", "srv2").len(), 1);
414        assert_eq!(idx.segments_for_tag("region", "eu-west").len(), 1);
415    }
416
417    #[test]
418    fn remove_nonexistent_segment_is_noop() {
419        let idx = TagInvertedIndex::new();
420        idx.add_segment(SegmentId(1), &[("host", "srv1")]);
421        idx.remove_segment(SegmentId(999)); // should not panic or corrupt
422        assert_eq!(idx.segments_for_tag("host", "srv1").len(), 1);
423    }
424
425    /// `=` is legal in both tag keys and tag values, so an index key built
426    /// with `=` could not tell `("a=b", "c")` from `("a", "b=c")`. Both the
427    /// lookup and the `key_values` cleanup would then act on the wrong tag.
428    #[test]
429    fn equals_in_tag_key_or_value_is_unambiguous() {
430        let idx = TagInvertedIndex::new();
431        idx.add_segment(SegmentId(1), &[("a=b", "c")]);
432        idx.add_segment(SegmentId(2), &[("a", "b=c")]);
433
434        assert_eq!(idx.segments_for_tag("a=b", "c"), vec![SegmentId(1)]);
435        assert_eq!(idx.segments_for_tag("a", "b=c"), vec![SegmentId(2)]);
436
437        // Cleanup must target the right tag key.
438        idx.remove_segment(SegmentId(1));
439        assert!(idx.segments_for_tag("a=b", "c").is_empty());
440        assert_eq!(
441            idx.segments_for_tag("a", "b=c"),
442            vec![SegmentId(2)],
443            "removing one segment corrupted the other tag's entry"
444        );
445    }
446
447    #[test]
448    fn key_values_index_cleanup_on_remove() {
449        let idx = TagInvertedIndex::new();
450        idx.add_segment(SegmentId(1), &[("host", "srv1"), ("region", "us-east")]);
451        idx.add_segment(SegmentId(2), &[("host", "srv2"), ("region", "us-east")]);
452
453        // Both host values present
454        assert_eq!(idx.values_for_key("host"), vec!["srv1", "srv2"]);
455
456        // Remove segment 1 — "srv1" should disappear since no other segment uses it
457        idx.remove_segment(SegmentId(1));
458        assert_eq!(idx.values_for_key("host"), vec!["srv2"]);
459        // "us-east" still referenced by segment 2
460        assert_eq!(idx.values_for_key("region"), vec!["us-east"]);
461
462        // Remove segment 2 — all values should be gone
463        idx.remove_segment(SegmentId(2));
464        assert!(idx.values_for_key("host").is_empty());
465        assert!(idx.values_for_key("region").is_empty());
466        assert!(idx.all_keys().is_empty());
467    }
468
469    #[test]
470    fn replace_segments_atomic_swap() {
471        let idx = TagInvertedIndex::new();
472        idx.add_segment(SegmentId(1), &[("host", "srv1"), ("region", "us-east")]);
473        idx.add_segment(SegmentId(2), &[("host", "srv2"), ("region", "us-east")]);
474        idx.add_segment(SegmentId(3), &[("host", "srv3"), ("region", "eu-west")]);
475
476        // Replace segments 1 and 2 with a new compacted segment 10
477        idx.replace_segments(
478            &[SegmentId(1), SegmentId(2)],
479            SegmentId(10),
480            &[("host", "srv1"), ("host", "srv2"), ("region", "us-east")],
481        );
482
483        // Old segments should be gone
484        assert!(idx
485            .segments_for_tag("host", "srv1")
486            .contains(&SegmentId(10)));
487        assert!(!idx.segments_for_tag("host", "srv1").contains(&SegmentId(1)));
488        assert!(idx
489            .segments_for_tag("host", "srv2")
490            .contains(&SegmentId(10)));
491        assert!(!idx.segments_for_tag("host", "srv2").contains(&SegmentId(2)));
492
493        // Segment 3 should be unaffected
494        assert_eq!(idx.segments_for_tag("host", "srv3"), vec![SegmentId(3)]);
495        assert!(idx
496            .segments_for_tag("region", "eu-west")
497            .contains(&SegmentId(3)));
498
499        // New segment should appear in us-east
500        assert!(idx
501            .segments_for_tag("region", "us-east")
502            .contains(&SegmentId(10)));
503
504        // key_values should be consistent
505        let mut hosts = idx.values_for_key("host");
506        hosts.sort();
507        assert_eq!(hosts, vec!["srv1", "srv2", "srv3"]);
508    }
509
510    #[test]
511    fn replace_segments_empty_old_list() {
512        let idx = TagInvertedIndex::new();
513        idx.add_segment(SegmentId(1), &[("host", "srv1")]);
514
515        // Replace with no old segments — effectively just an add
516        idx.replace_segments(&[], SegmentId(2), &[("host", "srv2")]);
517
518        assert_eq!(idx.segments_for_tag("host", "srv1"), vec![SegmentId(1)]);
519        assert_eq!(idx.segments_for_tag("host", "srv2"), vec![SegmentId(2)]);
520    }
521}