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}