Skip to main content

chronix_engine/memtable/
interner.rs

1//! Thread-safe string interner for memtable data deduplication.
2//!
3//! Time-series workloads insert millions of points that share identically
4//! named measurements, tag keys, and tag values.  Without interning every
5//! insert allocates a fresh `String` on the heap, wasting both memory and
6//! allocator bandwidth.
7//!
8//! [`StringInterner`] maps each unique string to a single `Arc<str>`.
9//! Subsequent inserts that encounter the same byte sequence receive a
10//! cheap `Arc::clone` instead of a new allocation.
11
12use std::sync::Arc;
13
14use dashmap::DashMap;
15
16/// A thread-safe interner that deduplicates strings into `Arc<str>`.
17///
18/// Internally backed by a [`DashMap`] which provides lock-free concurrent
19/// reads with per-shard locking for writes, eliminating the global
20/// `RwLock` contention bottleneck.
21///
22/// # Example
23///
24/// ```no_run
25/// use chronix_engine::memtable::interner::StringInterner;
26///
27/// let interner = StringInterner::new();
28/// let a = interner.intern("cpu");
29/// let b = interner.intern("cpu");
30/// assert!(std::sync::Arc::ptr_eq(&a, &b));
31/// ```
32#[derive(Debug)]
33pub struct StringInterner {
34    map: DashMap<Arc<str>, ()>,
35}
36
37impl StringInterner {
38    /// Create an empty interner.
39    #[must_use]
40    pub fn new() -> Self {
41        Self {
42            map: DashMap::new(),
43        }
44    }
45
46    /// Intern a string, returning a shared `Arc<str>`.
47    ///
48    /// If the string has been seen before, the existing `Arc` is cloned
49    /// (lock-free read via `DashMap` shard).  Otherwise, a new `Arc<str>`
50    /// is allocated and inserted (per-shard write lock only).
51    pub fn intern(&self, s: &str) -> Arc<str> {
52        // Fast path: already interned — only acquires a read guard on
53        // the relevant shard.
54        if let Some(entry) = self.map.get(s) {
55            return Arc::clone(entry.key());
56        }
57
58        // Slow path: allocate and insert.  `entry` API ensures
59        // at-most-once insertion without a separate double-check.
60        // Always return the *map's* key (not our local Arc) to
61        // guarantee pointer equality across concurrent callers.
62        let arc: Arc<str> = Arc::from(s);
63        let entry = self.map.entry(arc).or_insert(());
64        Arc::clone(entry.key())
65    }
66
67    /// Look up a previously interned string without inserting.
68    ///
69    /// Returns `Some(Arc<str>)` if the string was already interned,
70    /// `None` otherwise.
71    #[must_use]
72    pub fn lookup(&self, s: &str) -> Option<Arc<str>> {
73        self.map.get(s).map(|entry| Arc::clone(entry.key()))
74    }
75
76    /// Number of unique strings interned.
77    #[must_use]
78    pub fn len(&self) -> usize {
79        self.map.len()
80    }
81
82    /// Returns `true` if no strings have been interned.
83    #[must_use]
84    pub fn is_empty(&self) -> bool {
85        self.map.is_empty()
86    }
87
88    /// Approximate heap bytes held by the interner.
89    ///
90    /// The interned strings themselves plus the map's per-entry overhead: an
91    /// `Arc<str>` header (two `usize` counts), the fat pointer the map stores,
92    /// and a hash slot. Interned strings are tag keys, tag values and
93    /// measurement names, so on a high-cardinality workload this is the term
94    /// that grows, and on the gateway it is one of the three that the memtable
95    /// budget does not count.
96    #[must_use]
97    pub fn memory_bytes(&self) -> usize {
98        const ARC_HEADER: usize = 2 * std::mem::size_of::<usize>();
99        const SLOT: usize = std::mem::size_of::<(usize, usize)>() + std::mem::size_of::<usize>();
100
101        self.map
102            .iter()
103            .map(|e| e.key().len() + ARC_HEADER + SLOT)
104            .sum()
105    }
106}
107
108impl Default for StringInterner {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn intern_deduplicates() {
120        let interner = StringInterner::new();
121        let a = interner.intern("cpu");
122        let b = interner.intern("cpu");
123        assert!(Arc::ptr_eq(&a, &b));
124        assert_eq!(interner.len(), 1);
125    }
126
127    #[test]
128    fn intern_distinct_strings() {
129        let interner = StringInterner::new();
130        let a = interner.intern("cpu");
131        let b = interner.intern("mem");
132        assert!(!Arc::ptr_eq(&a, &b));
133        assert_eq!(interner.len(), 2);
134    }
135
136    #[test]
137    fn intern_empty_string() {
138        let interner = StringInterner::new();
139        let a = interner.intern("");
140        let b = interner.intern("");
141        assert!(Arc::ptr_eq(&a, &b));
142        assert_eq!(&*a, "");
143    }
144
145    #[test]
146    fn intern_concurrent() {
147        use std::sync::Arc as StdArc;
148        use std::thread;
149
150        let interner = StdArc::new(StringInterner::new());
151        let mut handles = vec![];
152
153        for _ in 0..8 {
154            let interner = StdArc::clone(&interner);
155            handles.push(thread::spawn(move || {
156                let mut arcs = vec![];
157                for _ in 0..1_000 {
158                    arcs.push(interner.intern("host"));
159                    arcs.push(interner.intern("dc"));
160                    arcs.push(interner.intern("region"));
161                }
162                arcs
163            }));
164        }
165
166        let mut all_arcs: Vec<Arc<str>> = vec![];
167        for h in handles {
168            all_arcs.extend(h.join().expect("thread panicked"));
169        }
170
171        assert_eq!(interner.len(), 3);
172        // All "host" arcs point to the same allocation.
173        let first_host = interner.intern("host");
174        for arc in &all_arcs {
175            if &**arc == "host" {
176                assert!(Arc::ptr_eq(arc, &first_host));
177            }
178        }
179    }
180}