Skip to main content

xlog_core/
symbol.rs

1//! Global symbol interning for reversible string-to-ID mapping.
2
3use arrow::array::{Array, DictionaryArray, StringArray, UInt32Array};
4use arrow::datatypes::UInt32Type;
5use std::collections::HashMap;
6use std::sync::{Arc, OnceLock, RwLock};
7
8static REGISTRY: OnceLock<RwLock<SymbolRegistry>> = OnceLock::new();
9
10struct SymbolRegistry {
11    to_id: HashMap<String, u32>,
12    to_string: Vec<String>,
13}
14
15impl SymbolRegistry {
16    fn new() -> Self {
17        Self {
18            to_id: HashMap::new(),
19            to_string: Vec::new(),
20        }
21    }
22}
23
24fn registry() -> &'static RwLock<SymbolRegistry> {
25    REGISTRY.get_or_init(|| RwLock::new(SymbolRegistry::new()))
26}
27
28/// Intern a string, returning its unique ID.
29/// Thread-safe. Returns existing ID if already interned.
30pub fn intern(s: &str) -> u32 {
31    // Fast path: check if already interned (read lock)
32    {
33        let reg = registry().read().unwrap();
34        if let Some(&id) = reg.to_id.get(s) {
35            return id;
36        }
37    }
38    // Slow path: insert new (write lock)
39    let mut reg = registry().write().unwrap();
40    // Double-check after acquiring write lock
41    if let Some(&id) = reg.to_id.get(s) {
42        return id;
43    }
44    let id = reg.to_string.len() as u32;
45    let owned = s.to_string();
46    reg.to_id.insert(owned.clone(), id);
47    reg.to_string.push(owned);
48    id
49}
50
51/// Resolve an ID to its string. Panics if ID is invalid.
52pub fn resolve(id: u32) -> String {
53    resolve_checked(id).expect("invalid symbol ID: this is a bug")
54}
55
56/// Resolve an ID to its string if present.
57pub fn resolve_checked(id: u32) -> Option<String> {
58    let reg = registry().read().unwrap();
59    reg.to_string.get(id as usize).cloned()
60}
61
62/// Owned symbol meanings copied together from one registry state.
63///
64/// Entries retain request order and duplicates. Their text does not change when
65/// the registry is cleared or an ID is reused. This authenticates the meaning at
66/// snapshot time, not the provenance of an ID supplied before that time.
67#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct SymbolSnapshot {
69    entries: Box<[(u32, Box<str>)]>,
70}
71
72impl SymbolSnapshot {
73    /// Returns immutable `(registry ID, accepted UTF-8 text)` entries.
74    pub fn entries(&self) -> &[(u32, Box<str>)] {
75        &self.entries
76    }
77}
78
79/// A complete symbol snapshot could not be admitted within its caller's bounds.
80#[derive(Clone, Debug, Eq, PartialEq)]
81pub enum SymbolSnapshotError {
82    /// A requested ID is absent from the locked registry state.
83    MissingSymbol {
84        /// Missing registry ID.
85        id: u32,
86    },
87    /// The request contains too many entries, including duplicates.
88    EntryLimit {
89        /// Number of requested entries.
90        requested: usize,
91        /// Maximum admitted entries.
92        limit: usize,
93    },
94    /// Accepted text would exceed the UTF-8 byte budget.
95    ByteLimit {
96        /// Maximum admitted UTF-8 bytes, counting duplicates.
97        limit: usize,
98    },
99    /// A writer previously panicked while holding the registry lock.
100    RegistryPoisoned,
101}
102
103impl std::fmt::Display for SymbolSnapshotError {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        match self {
106            Self::MissingSymbol { id } => write!(f, "symbol ID {id} is not registered"),
107            Self::EntryLimit { requested, limit } => {
108                write!(
109                    f,
110                    "symbol snapshot has {requested} entries, exceeding {limit}"
111                )
112            }
113            Self::ByteLimit { limit } => {
114                write!(f, "symbol snapshot UTF-8 bytes exceed {limit}")
115            }
116            Self::RegistryPoisoned => write!(f, "symbol registry lock is poisoned"),
117        }
118    }
119}
120
121impl std::error::Error for SymbolSnapshotError {}
122
123/// Copies all requested symbols under one read lock, or returns no snapshot.
124///
125/// Count and UTF-8 byte bounds include duplicates. All IDs and byte lengths are
126/// checked before copying any text; no registry entry is added or renumbered.
127pub fn snapshot_checked(
128    ids: &[u32],
129    max_entries: usize,
130    max_utf8_bytes: usize,
131) -> Result<SymbolSnapshot, SymbolSnapshotError> {
132    if ids.len() > max_entries {
133        return Err(SymbolSnapshotError::EntryLimit {
134            requested: ids.len(),
135            limit: max_entries,
136        });
137    }
138    let reg = registry()
139        .read()
140        .map_err(|_| SymbolSnapshotError::RegistryPoisoned)?;
141    let mut remaining = max_utf8_bytes;
142    for &id in ids {
143        let text = reg
144            .to_string
145            .get(id as usize)
146            .ok_or(SymbolSnapshotError::MissingSymbol { id })?;
147        remaining = remaining
148            .checked_sub(text.len())
149            .ok_or(SymbolSnapshotError::ByteLimit {
150                limit: max_utf8_bytes,
151            })?;
152    }
153    let entries = ids
154        .iter()
155        .map(|&id| (id, reg.to_string[id as usize].clone().into_boxed_str()))
156        .collect();
157    Ok(SymbolSnapshot { entries })
158}
159
160/// Clear all symbols. For testing/REPL only.
161/// WARNING: Invalidates all existing symbol IDs.
162pub fn clear() {
163    let mut reg = registry().write().unwrap();
164    reg.to_id.clear();
165    reg.to_string.clear();
166}
167
168/// Number of interned symbols.
169pub fn count() -> usize {
170    registry().read().unwrap().to_string.len()
171}
172
173/// Estimated memory usage in bytes.
174pub fn memory_usage() -> usize {
175    let reg = registry().read().unwrap();
176    let string_bytes: usize = reg.to_string.iter().map(|s| s.len()).sum();
177    let map_overhead =
178        reg.to_id.len() * (std::mem::size_of::<String>() + std::mem::size_of::<u32>());
179    string_bytes + map_overhead
180}
181
182/// Convert a column of symbol IDs to Arrow DictionaryArray.
183pub fn to_arrow(ids: &[u32]) -> DictionaryArray<UInt32Type> {
184    use std::collections::HashSet;
185
186    // Collect unique IDs preserving order
187    let mut seen = HashSet::new();
188    let unique_ids: Vec<u32> = ids.iter().filter(|id| seen.insert(**id)).copied().collect();
189
190    // Build string dictionary
191    let dict_strings: Vec<String> = unique_ids.iter().map(|&id| resolve(id)).collect();
192    let dictionary = StringArray::from(dict_strings);
193
194    // Map original IDs to dictionary indices
195    let id_to_index: HashMap<u32, u32> = unique_ids
196        .iter()
197        .enumerate()
198        .map(|(i, &id)| (id, i as u32))
199        .collect();
200
201    let keys: Vec<u32> = ids.iter().map(|id| *id_to_index.get(id).unwrap()).collect();
202    let keys_array = UInt32Array::from(keys);
203
204    DictionaryArray::try_new(keys_array, Arc::new(dictionary)).unwrap()
205}
206
207/// Convert Arrow DictionaryArray back to symbol IDs.
208pub fn from_arrow(arr: &DictionaryArray<UInt32Type>) -> Vec<u32> {
209    let dict = arr
210        .values()
211        .as_any()
212        .downcast_ref::<StringArray>()
213        .expect("dictionary values must be StringArray");
214
215    // Intern all dictionary values
216    let dict_to_symbol: Vec<u32> = dict
217        .iter()
218        .map(|s| intern(s.expect("null not supported in symbols")))
219        .collect();
220
221    // Map keys through dictionary
222    arr.keys()
223        .iter()
224        .map(|k| {
225            let idx = k.expect("null keys not supported") as usize;
226            dict_to_symbol[idx]
227        })
228        .collect()
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use serial_test::serial;
235
236    // Each test must call setup() to get clean state
237    fn setup() {
238        clear();
239    }
240
241    #[test]
242    #[serial]
243    fn snapshot_preserves_order_duplicates_and_utf8_limits() {
244        setup();
245        let empty = intern("");
246        let unicode = intern("é🎉");
247        let ids = [unicode, empty, unicode];
248        let snapshot = snapshot_checked(&ids, 3, 12).unwrap();
249        let entries: Vec<_> = snapshot
250            .entries()
251            .iter()
252            .map(|(id, text)| (*id, text.as_ref()))
253            .collect();
254        assert_eq!(entries, [(unicode, "é🎉"), (empty, ""), (unicode, "é🎉")]);
255        assert_eq!(
256            snapshot_checked(&ids, 2, 12),
257            Err(SymbolSnapshotError::EntryLimit {
258                requested: 3,
259                limit: 2
260            })
261        );
262        assert_eq!(
263            snapshot_checked(&ids, 3, 11),
264            Err(SymbolSnapshotError::ByteLimit { limit: 11 })
265        );
266        assert!(snapshot_checked(&[], 0, 0).unwrap().entries().is_empty());
267    }
268
269    #[test]
270    #[serial]
271    fn snapshot_missing_symbol_rejects_whole_request_without_mutation() {
272        setup();
273        let valid = intern("present");
274        assert_eq!(
275            snapshot_checked(&[valid, 9, valid], 3, 100),
276            Err(SymbolSnapshotError::MissingSymbol { id: 9 })
277        );
278        assert_eq!(count(), 1);
279        assert_eq!(resolve(valid), "present");
280    }
281
282    #[test]
283    #[serial]
284    fn snapshot_survives_clear_and_reintern_without_reinterpreting_ids() {
285        setup();
286        let id = intern("old");
287        let accepted = snapshot_checked(&[id], 1, 3).unwrap();
288        clear();
289        assert_eq!(intern("new"), id);
290        let current = snapshot_checked(&[id], 1, 3).unwrap();
291        assert_eq!(accepted.entries()[0].1.as_ref(), "old");
292        assert_eq!(current.entries()[0].1.as_ref(), "new");
293        assert_ne!(accepted, current);
294    }
295
296    #[test]
297    #[serial]
298    fn snapshot_never_tears_across_concurrent_registry_replacement() {
299        setup();
300        intern("old-left");
301        intern("old-right");
302        let start = std::sync::Barrier::new(2);
303        std::thread::scope(|scope| {
304            let writer = scope.spawn(|| {
305                start.wait();
306                for index in 0..1000 {
307                    clear();
308                    let (left, right) = if index % 2 == 0 {
309                        ("new-left", "new-right")
310                    } else {
311                        ("old-left", "old-right")
312                    };
313                    intern(left);
314                    intern(right);
315                }
316            });
317            start.wait();
318            for _ in 0..1000 {
319                match snapshot_checked(&[0, 1], 2, 18) {
320                    Ok(snapshot) => {
321                        let entries = snapshot.entries();
322                        let pair = (entries[0].1.as_ref(), entries[1].1.as_ref());
323                        assert!(
324                            pair == ("old-left", "old-right") || pair == ("new-left", "new-right"),
325                            "torn snapshot: {pair:?}"
326                        );
327                    }
328                    Err(SymbolSnapshotError::MissingSymbol { .. }) => {}
329                    other => panic!("unexpected snapshot result: {other:?}"),
330                }
331            }
332            writer.join().unwrap();
333            let final_snapshot = snapshot_checked(&[0, 1], 2, 18).unwrap();
334            assert_eq!(final_snapshot.entries()[0].1.as_ref(), "old-left");
335            assert_eq!(final_snapshot.entries()[1].1.as_ref(), "old-right");
336        });
337    }
338
339    #[test]
340    #[serial]
341    fn test_intern_sequential() {
342        setup();
343        assert_eq!(intern("foo"), 0);
344        assert_eq!(intern("bar"), 1);
345        assert_eq!(intern("baz"), 2);
346    }
347
348    #[test]
349    #[serial]
350    fn test_intern_idempotent() {
351        setup();
352        let id1 = intern("hello");
353        let id2 = intern("hello");
354        assert_eq!(id1, id2);
355        assert_eq!(count(), 1); // only one entry
356    }
357
358    #[test]
359    #[serial]
360    fn test_resolve_roundtrip() {
361        setup();
362        let id = intern("world");
363        assert_eq!(resolve(id), "world");
364    }
365
366    #[test]
367    #[serial]
368    #[should_panic(expected = "invalid symbol ID")]
369    fn test_resolve_invalid() {
370        setup();
371        resolve(9999); // should panic
372    }
373
374    #[test]
375    #[serial]
376    fn test_clear() {
377        setup();
378        intern("a");
379        intern("b");
380        assert_eq!(count(), 2);
381        clear();
382        assert_eq!(count(), 0);
383        assert_eq!(intern("a"), 0); // IDs restart from 0
384    }
385
386    #[test]
387    #[serial]
388    fn test_empty_string() {
389        setup();
390        let id = intern("");
391        assert_eq!(resolve(id), "");
392    }
393
394    #[test]
395    #[serial]
396    fn test_unicode() {
397        setup();
398        let id = intern("日本語");
399        assert_eq!(resolve(id), "日本語");
400
401        let id2 = intern("émoji🎉");
402        assert_eq!(resolve(id2), "émoji🎉");
403    }
404
405    #[test]
406    #[serial]
407    fn test_concurrent_intern() {
408        setup();
409        use std::collections::HashSet;
410        use std::thread;
411
412        let handles: Vec<_> = (0..10)
413            .map(|i| {
414                thread::spawn(move || {
415                    let mut ids = Vec::new();
416                    for j in 0..100 {
417                        let s = format!("thread{}_{}", i, j);
418                        let id = intern(&s);
419                        ids.push((s, id));
420                    }
421                    ids
422                })
423            })
424            .collect();
425
426        let mut all_results = Vec::new();
427        for h in handles {
428            all_results.extend(h.join().unwrap());
429        }
430
431        // Verify all symbols resolve correctly
432        for (s, id) in &all_results {
433            assert_eq!(&resolve(*id), s);
434        }
435
436        // Verify we have 1000 unique symbols
437        assert_eq!(count(), 1000);
438
439        // Verify no duplicate IDs for different strings
440        let unique_ids: HashSet<u32> = all_results.iter().map(|(_, id)| *id).collect();
441        assert_eq!(unique_ids.len(), 1000);
442    }
443
444    #[test]
445    #[serial]
446    fn test_large_scale() {
447        setup();
448        use std::time::Instant;
449
450        let start = Instant::now();
451
452        // Intern 100K unique symbols
453        for i in 0..100_000 {
454            let s = format!("symbol_{:06}", i);
455            let id = intern(&s);
456            assert_eq!(id, i as u32);
457        }
458
459        let intern_time = start.elapsed();
460
461        // Verify all resolve correctly
462        let start = Instant::now();
463        for i in 0..100_000 {
464            let expected = format!("symbol_{:06}", i);
465            assert_eq!(resolve(i as u32), expected);
466        }
467        let resolve_time = start.elapsed();
468
469        // Verify count
470        assert_eq!(count(), 100_000);
471
472        // Log performance (not assertions, just info)
473        println!(
474            "100K intern: {:?}, 100K resolve: {:?}",
475            intern_time, resolve_time
476        );
477
478        // Memory should be reasonable (rough check: < 10MB for 100K symbols)
479        let mem = memory_usage();
480        assert!(mem < 10_000_000, "memory usage {} exceeds 10MB", mem);
481    }
482
483    #[test]
484    #[serial]
485    fn test_arrow_roundtrip() {
486        setup();
487        let ids = vec![
488            intern("apple"),
489            intern("banana"),
490            intern("apple"),
491            intern("cherry"),
492            intern("banana"),
493        ];
494
495        let arrow = to_arrow(&ids);
496        let back = from_arrow(&arrow);
497
498        assert_eq!(ids, back);
499    }
500}