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    /// Borrow the index for one query's worth of pruning decisions.
168    ///
169    /// The lock is taken once and held for the whole candidate sweep, so a
170    /// pass over N segments costs one acquisition rather than N.
171    ///
172    /// # Panics
173    ///
174    /// Never: the returned [`TagPruner`] only reads.
175    #[must_use]
176    pub fn pruner<'a>(&'a self, filters: &[(&str, &str)]) -> TagPruner<'a> {
177        TagPruner {
178            keys: filters.iter().map(|&(k, v)| make_tag_key(k, v)).collect(),
179            state: self.state.read(),
180        }
181    }
182
183    /// Atomically replace multiple old segments with a single new segment.
184    ///
185    /// Used during compaction to avoid a window where the old segments have
186    /// been removed but the new one hasn't been added yet, which would cause
187    /// concurrent tag-filtered queries to miss data.
188    pub fn replace_segments(
189        &self,
190        old_segment_ids: &[SegmentId],
191        new_segment_id: SegmentId,
192        new_tag_values: &[(&str, &str)],
193    ) {
194        let mut s = self.state.write();
195
196        // Remove old segments
197        for &old_id in old_segment_ids {
198            if let Some(keys) = s.reverse.remove(&old_id) {
199                for key in &keys {
200                    if let Some(segments) = s.index.get_mut(key) {
201                        segments.remove(&old_id);
202                        if segments.is_empty() {
203                            s.index.remove(key);
204                            if let Some(pos) = key.find(chronix_core::KV_SEPARATOR) {
205                                let tag_key = &key[..pos];
206                                let tag_val = &key[pos + 1..];
207                                if let Some(vals) = s.key_values.get_mut(tag_key) {
208                                    vals.remove(tag_val);
209                                    if vals.is_empty() {
210                                        s.key_values.remove(tag_key);
211                                    }
212                                }
213                            }
214                        }
215                    }
216                }
217            }
218        }
219
220        // Add new segment
221        let mut rev_keys = Vec::with_capacity(new_tag_values.len());
222        for &(key, value) in new_tag_values {
223            let entry_key = make_tag_key(key, value);
224            s.index
225                .entry(entry_key.clone())
226                .or_default()
227                .insert(new_segment_id);
228            rev_keys.push(entry_key);
229            s.key_values
230                .entry(key.to_string())
231                .or_default()
232                .insert(value.to_string());
233        }
234        s.reverse
235            .entry(new_segment_id)
236            .or_default()
237            .extend(rev_keys);
238    }
239
240    /// Clear the entire index (e.g., for a full rebuild).
241    pub fn clear(&self) {
242        let mut s = self.state.write();
243        s.index.clear();
244        s.reverse.clear();
245        s.key_values.clear();
246    }
247
248    /// Returns the total number of indexed tag-value pairs.
249    #[must_use]
250    pub fn entry_count(&self) -> usize {
251        self.state.read().index.len()
252    }
253
254    /// Returns all distinct values for a given tag key.
255    ///
256    /// O(1) lookup via the secondary `key_values` index
257    /// instead of scanning all entries with `strip_prefix`.
258    #[must_use]
259    pub fn values_for_key(&self, key: &str) -> Vec<String> {
260        let s = self.state.read();
261        let mut values: Vec<String> = s
262            .key_values
263            .get(key)
264            .map(|s| s.iter().cloned().collect())
265            .unwrap_or_default();
266        values.sort();
267        values
268    }
269
270    /// Returns all distinct tag keys present in the index.
271    ///
272    /// O(1) lookup via the secondary `key_values` index
273    /// instead of scanning all entries.
274    #[must_use]
275    pub fn all_keys(&self) -> Vec<String> {
276        let s = self.state.read();
277        let mut keys: Vec<String> = s.key_values.keys().cloned().collect();
278        keys.sort();
279        keys
280    }
281}
282
283impl Default for TagInvertedIndex {
284    fn default() -> Self {
285        Self::new()
286    }
287}
288
289/// One query's view of the index, used to eliminate segments that cannot
290/// match its tag filters.
291///
292/// # Why this answers "excluded" rather than "matching"
293///
294/// The index is *derived* state: it is updated after the catalog, from a
295/// segment's series sidecar, and a segment can be in the catalog while the
296/// index has never heard of it — for the window between a compaction's
297/// catalog swap and its index swap, and for as long as a sidecar cannot be
298/// read. A lookup that returns "the segments that match" cannot express that
299/// gap: everything it does not return is pruned, so a segment the index has
300/// not *seen* is indistinguishable from one it has seen and rejected.
301///
302/// Asking the opposite question makes the gap safe. Pruning happens only when
303/// the index holds the segment's tag set and that set is missing a filtered
304/// pair, so an unindexed segment is scanned — slower, never wrong. This is
305/// the same rule the zone maps follow: a pruning structure may only ever fail
306/// towards more work.
307pub struct TagPruner<'a> {
308    state: parking_lot::RwLockReadGuard<'a, InvertedState>,
309    keys: Vec<String>,
310}
311
312impl TagPruner<'_> {
313    /// Whether the index can *prove* that `segment_id` does not carry every
314    /// filtered tag pair.
315    ///
316    /// `false` whenever there are no filters, or the segment is not indexed.
317    #[must_use]
318    pub fn excludes(&self, segment_id: SegmentId) -> bool {
319        if self.keys.is_empty() {
320            return false;
321        }
322        let Some(have) = self.state.reverse.get(&segment_id) else {
323            return false;
324        };
325        !self.keys.iter().all(|k| have.contains(k))
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn add_and_query_tag() {
335        let idx = TagInvertedIndex::new();
336        idx.add_segment(SegmentId(1), &[("host", "srv1"), ("region", "us-east")]);
337        idx.add_segment(SegmentId(2), &[("host", "srv2"), ("region", "us-east")]);
338        idx.add_segment(SegmentId(3), &[("host", "srv1"), ("region", "eu-west")]);
339
340        let segs = idx.segments_for_tag("host", "srv1");
341        assert_eq!(segs.len(), 2);
342        assert!(segs.contains(&SegmentId(1)));
343        assert!(segs.contains(&SegmentId(3)));
344    }
345
346    #[test]
347    fn a_segment_missing_any_filtered_pair_is_excluded() {
348        let idx = TagInvertedIndex::new();
349        idx.add_segment(SegmentId(1), &[("host", "srv1"), ("region", "us-east")]);
350        idx.add_segment(SegmentId(2), &[("host", "srv1"), ("region", "eu-west")]);
351        idx.add_segment(SegmentId(3), &[("host", "srv2"), ("region", "us-east")]);
352
353        let pruner = idx.pruner(&[("host", "srv1"), ("region", "us-east")]);
354        assert!(!pruner.excludes(SegmentId(1)));
355        assert!(pruner.excludes(SegmentId(2)));
356        assert!(pruner.excludes(SegmentId(3)));
357    }
358
359    /// The index is updated after the catalog, so a segment can be queryable
360    /// and unindexed — the window between a compaction's catalog swap and its
361    /// index swap. Answering "matching segments" made that window prune
362    /// *everything*: a tag-filtered query returned no rows at all.
363    #[test]
364    fn a_segment_the_index_has_never_seen_is_never_excluded() {
365        let idx = TagInvertedIndex::new();
366        idx.add_segment(SegmentId(1), &[("host", "srv1")]);
367
368        let pruner = idx.pruner(&[("host", "srv1")]);
369        assert!(
370            !pruner.excludes(SegmentId(99)),
371            "an unindexed segment must be scanned, not pruned"
372        );
373    }
374
375    #[test]
376    fn no_filters_excludes_nothing() {
377        let idx = TagInvertedIndex::new();
378        idx.add_segment(SegmentId(1), &[("host", "srv1")]);
379        let pruner = idx.pruner(&[]);
380        assert!(!pruner.excludes(SegmentId(1)));
381        assert!(!pruner.excludes(SegmentId(2)));
382    }
383
384    #[test]
385    fn remove_segment() {
386        let idx = TagInvertedIndex::new();
387        idx.add_segment(SegmentId(1), &[("host", "srv1")]);
388        idx.add_segment(SegmentId(2), &[("host", "srv1")]);
389
390        idx.remove_segment(SegmentId(1));
391        let segs = idx.segments_for_tag("host", "srv1");
392        assert_eq!(segs.len(), 1);
393        assert!(segs.contains(&SegmentId(2)));
394    }
395
396    #[test]
397    fn missing_tag_returns_empty() {
398        let idx = TagInvertedIndex::new();
399        idx.add_segment(SegmentId(1), &[("host", "srv1")]);
400
401        assert!(idx.segments_for_tag("datacenter", "dc1").is_empty());
402    }
403
404    #[test]
405    fn clear_empties_index() {
406        let idx = TagInvertedIndex::new();
407        idx.add_segment(SegmentId(1), &[("host", "srv1")]);
408        idx.clear();
409        assert_eq!(idx.entry_count(), 0);
410    }
411
412    #[test]
413    fn values_for_key_returns_sorted_distinct() {
414        let idx = TagInvertedIndex::new();
415        idx.add_segment(SegmentId(1), &[("host", "srv1"), ("region", "us-east")]);
416        idx.add_segment(SegmentId(2), &[("host", "srv2"), ("region", "us-east")]);
417        idx.add_segment(SegmentId(3), &[("host", "srv1"), ("region", "eu-west")]);
418
419        let hosts = idx.values_for_key("host");
420        assert_eq!(hosts, vec!["srv1", "srv2"]);
421
422        let regions = idx.values_for_key("region");
423        assert_eq!(regions, vec!["eu-west", "us-east"]);
424
425        // Non-existent key returns empty
426        assert!(idx.values_for_key("datacenter").is_empty());
427    }
428
429    #[test]
430    fn all_keys_returns_sorted_distinct() {
431        let idx = TagInvertedIndex::new();
432        idx.add_segment(SegmentId(1), &[("host", "srv1"), ("region", "us-east")]);
433        idx.add_segment(SegmentId(2), &[("env", "prod"), ("host", "srv2")]);
434
435        let keys = idx.all_keys();
436        assert_eq!(keys, vec!["env", "host", "region"]);
437    }
438
439    #[test]
440    fn all_keys_empty_index() {
441        let idx = TagInvertedIndex::new();
442        assert!(idx.all_keys().is_empty());
443        assert!(idx.values_for_key("any").is_empty());
444    }
445
446    #[test]
447    fn remove_segment_cleans_up_reverse_index() {
448        let idx = TagInvertedIndex::new();
449        idx.add_segment(SegmentId(1), &[("host", "srv1"), ("region", "us-east")]);
450        idx.add_segment(SegmentId(2), &[("host", "srv2"), ("region", "eu-west")]);
451
452        // Verify reverse index populated
453        assert_eq!(idx.state.read().reverse.len(), 2);
454
455        // Remove segment 1 — only its entries should be affected.
456        idx.remove_segment(SegmentId(1));
457        assert_eq!(idx.state.read().reverse.len(), 1);
458        assert!(idx.segments_for_tag("host", "srv1").is_empty());
459        assert!(idx.segments_for_tag("region", "us-east").is_empty());
460        assert_eq!(idx.segments_for_tag("host", "srv2").len(), 1);
461        assert_eq!(idx.segments_for_tag("region", "eu-west").len(), 1);
462    }
463
464    #[test]
465    fn remove_nonexistent_segment_is_noop() {
466        let idx = TagInvertedIndex::new();
467        idx.add_segment(SegmentId(1), &[("host", "srv1")]);
468        idx.remove_segment(SegmentId(999)); // should not panic or corrupt
469        assert_eq!(idx.segments_for_tag("host", "srv1").len(), 1);
470    }
471
472    /// `=` is legal in both tag keys and tag values, so an index key built
473    /// with `=` could not tell `("a=b", "c")` from `("a", "b=c")`. Both the
474    /// lookup and the `key_values` cleanup would then act on the wrong tag.
475    #[test]
476    fn equals_in_tag_key_or_value_is_unambiguous() {
477        let idx = TagInvertedIndex::new();
478        idx.add_segment(SegmentId(1), &[("a=b", "c")]);
479        idx.add_segment(SegmentId(2), &[("a", "b=c")]);
480
481        assert_eq!(idx.segments_for_tag("a=b", "c"), vec![SegmentId(1)]);
482        assert_eq!(idx.segments_for_tag("a", "b=c"), vec![SegmentId(2)]);
483
484        // Cleanup must target the right tag key.
485        idx.remove_segment(SegmentId(1));
486        assert!(idx.segments_for_tag("a=b", "c").is_empty());
487        assert_eq!(
488            idx.segments_for_tag("a", "b=c"),
489            vec![SegmentId(2)],
490            "removing one segment corrupted the other tag's entry"
491        );
492    }
493
494    #[test]
495    fn key_values_index_cleanup_on_remove() {
496        let idx = TagInvertedIndex::new();
497        idx.add_segment(SegmentId(1), &[("host", "srv1"), ("region", "us-east")]);
498        idx.add_segment(SegmentId(2), &[("host", "srv2"), ("region", "us-east")]);
499
500        // Both host values present
501        assert_eq!(idx.values_for_key("host"), vec!["srv1", "srv2"]);
502
503        // Remove segment 1 — "srv1" should disappear since no other segment uses it
504        idx.remove_segment(SegmentId(1));
505        assert_eq!(idx.values_for_key("host"), vec!["srv2"]);
506        // "us-east" still referenced by segment 2
507        assert_eq!(idx.values_for_key("region"), vec!["us-east"]);
508
509        // Remove segment 2 — all values should be gone
510        idx.remove_segment(SegmentId(2));
511        assert!(idx.values_for_key("host").is_empty());
512        assert!(idx.values_for_key("region").is_empty());
513        assert!(idx.all_keys().is_empty());
514    }
515
516    #[test]
517    fn replace_segments_atomic_swap() {
518        let idx = TagInvertedIndex::new();
519        idx.add_segment(SegmentId(1), &[("host", "srv1"), ("region", "us-east")]);
520        idx.add_segment(SegmentId(2), &[("host", "srv2"), ("region", "us-east")]);
521        idx.add_segment(SegmentId(3), &[("host", "srv3"), ("region", "eu-west")]);
522
523        // Replace segments 1 and 2 with a new compacted segment 10
524        idx.replace_segments(
525            &[SegmentId(1), SegmentId(2)],
526            SegmentId(10),
527            &[("host", "srv1"), ("host", "srv2"), ("region", "us-east")],
528        );
529
530        // Old segments should be gone
531        assert!(
532            idx.segments_for_tag("host", "srv1")
533                .contains(&SegmentId(10))
534        );
535        assert!(!idx.segments_for_tag("host", "srv1").contains(&SegmentId(1)));
536        assert!(
537            idx.segments_for_tag("host", "srv2")
538                .contains(&SegmentId(10))
539        );
540        assert!(!idx.segments_for_tag("host", "srv2").contains(&SegmentId(2)));
541
542        // Segment 3 should be unaffected
543        assert_eq!(idx.segments_for_tag("host", "srv3"), vec![SegmentId(3)]);
544        assert!(
545            idx.segments_for_tag("region", "eu-west")
546                .contains(&SegmentId(3))
547        );
548
549        // New segment should appear in us-east
550        assert!(
551            idx.segments_for_tag("region", "us-east")
552                .contains(&SegmentId(10))
553        );
554
555        // key_values should be consistent
556        let mut hosts = idx.values_for_key("host");
557        hosts.sort();
558        assert_eq!(hosts, vec!["srv1", "srv2", "srv3"]);
559    }
560
561    #[test]
562    fn replace_segments_empty_old_list() {
563        let idx = TagInvertedIndex::new();
564        idx.add_segment(SegmentId(1), &[("host", "srv1")]);
565
566        // Replace with no old segments — effectively just an add
567        idx.replace_segments(&[], SegmentId(2), &[("host", "srv2")]);
568
569        assert_eq!(idx.segments_for_tag("host", "srv1"), vec![SegmentId(1)]);
570        assert_eq!(idx.segments_for_tag("host", "srv2"), vec![SegmentId(2)]);
571    }
572}