dynamic_config/registry.rs
1//! Per-monomorphization storage for generic configuration types.
2//!
3//! A non-generic config type keeps its snapshot in a `static`, which costs one
4//! atomic load to read. A generic one cannot: Rust has no generic statics, and
5//! `Config<Postgres>` and `Config<Mysql>` need separate snapshots.
6//!
7//! The way out is a registry keyed by [`TypeId`], which *is* per
8//! monomorphization. A `static Registry` inside a generic function is shared by
9//! every instantiation — items in a function body are not monomorphized — so
10//! one registry serves them all and the key tells them apart.
11//!
12//! ```text
13//! non-generic: static CELL: ConfigCell<T> → one atomic load
14//! generic: REGISTRY.entry::<Config<D>, _>() → read lock + hash + downcast
15//! ```
16//!
17//! That difference is why the macro emits the `static` whenever it can, and the
18//! registry only for the types that have no alternative. `benches/read_path.rs`
19//! measures both.
20
21use std::any::{Any, TypeId};
22use std::collections::HashMap;
23use std::hash::{BuildHasherDefault, Hasher};
24use std::sync::OnceLock;
25
26use arc_swap::ArcSwap;
27
28/// The table itself. Behind an [`ArcSwap`], so a read takes no lock at all.
29type Slots = HashMap<TypeId, &'static (dyn Any + Send + Sync), BuildHasherDefault<TypeIdHasher>>;
30
31/// Passes a [`TypeId`] straight through instead of hashing it.
32///
33/// `TypeId` is already a high-quality 128-bit value; running SipHash over it
34/// was measurably the largest part of a lookup, and buys nothing a compiler-
35/// generated identifier does not already have.
36#[derive(Default)]
37struct TypeIdHasher {
38 hash: u64,
39}
40
41impl Hasher for TypeIdHasher {
42 fn write(&mut self, bytes: &[u8]) {
43 // `TypeId`'s `Hash` writes its bytes in one go; taking the low eight is
44 // enough to spread the handful of keys a program actually has.
45 for chunk in bytes.chunks(8) {
46 let mut buffer = [0u8; 8];
47 buffer[..chunk.len()].copy_from_slice(chunk);
48
49 self.hash ^= u64::from_ne_bytes(buffer);
50 }
51 }
52
53 fn write_u64(&mut self, value: u64) {
54 self.hash ^= value;
55 }
56
57 fn write_u128(&mut self, value: u128) {
58 self.hash ^= (value as u64) ^ ((value >> 64) as u64);
59 }
60
61 fn finish(&self) -> u64 {
62 self.hash
63 }
64}
65
66/// One slot per type, allocated on first use and never freed.
67///
68/// Each generated accessor has its own `Registry`, so the snapshot, the two
69/// runtime layers and the diff baseline do not collide on a shared key.
70///
71/// # Example
72///
73/// ```
74/// use dynamic_config::{ConfigCell, Registry};
75///
76/// fn cell<T: Send + Sync + 'static>() -> &'static ConfigCell<T> {
77/// // Shared by every instantiation: a `static` in a function body is not
78/// // monomorphized.
79/// static REGISTRY: Registry = Registry::new();
80///
81/// REGISTRY.entry::<T, ConfigCell<T>>()
82/// }
83///
84/// cell::<u16>().store(8080);
85/// assert_eq!(*cell::<u16>().load().unwrap(), 8080);
86///
87/// // A different type is a different slot.
88/// assert!(cell::<String>().load().is_none());
89/// ```
90#[derive(Default)]
91pub struct Registry {
92 entries: OnceLock<ArcSwap<Slots>>,
93}
94
95impl Registry {
96 /// An empty registry.
97 #[must_use]
98 pub const fn new() -> Self {
99 Self {
100 entries: OnceLock::new(),
101 }
102 }
103
104 /// The slot for `K`, created on first use.
105 ///
106 /// The value is leaked deliberately. A configuration snapshot lives as long
107 /// as the process, and the alternative — handing out an `Arc` and reference
108 /// counting on every read — would cost more than the lookup it replaced.
109 /// The leak is bounded by the number of monomorphizations, which is fixed
110 /// at compile time.
111 pub fn entry<K, V>(&self) -> &'static V
112 where
113 K: 'static,
114 V: Default + Send + Sync + 'static,
115 {
116 let entries = self
117 .entries
118 .get_or_init(|| ArcSwap::from_pointee(Slots::default()));
119 let key = TypeId::of::<K>();
120
121 // The common case by far, and the one the benchmark measures: no lock,
122 // no hashing beyond a load, no allocation.
123 if let Some(found) = entries.load().get(&key) {
124 return downcast(*found);
125 }
126
127 // Missing: rebuild the table with the new slot in it. This happens once
128 // per monomorphization, so copying a table with a handful of entries is
129 // cheaper than making every read pay for a lock.
130 let leaked: &'static V = Box::leak(Box::new(V::default()));
131 let mut installed: Option<&'static (dyn Any + Send + Sync)> = None;
132
133 entries.rcu(|current| {
134 let mut next = Slots::clone(current);
135 // `or_insert` rather than `insert`: another thread may have won the
136 // race, and two slots for one type would mean two snapshots.
137 installed = Some(*next.entry(key).or_insert(leaked));
138
139 next
140 });
141
142 downcast(installed.expect("`rcu` runs its closure at least once"))
143 }
144}
145
146/// Recovers the concrete type a slot was created with.
147///
148/// The key is `TypeId::of::<K>()` and each registry serves exactly one `V`, so
149/// a mismatch would mean two different `V`s reached the same registry — a bug
150/// in the generated code rather than anything a caller can cause.
151fn downcast<V: 'static>(slot: &'static (dyn Any + Send + Sync)) -> &'static V {
152 slot.downcast_ref()
153 .expect("a registry slot holds exactly one type")
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use std::sync::atomic::{AtomicUsize, Ordering};
160 use std::thread;
161
162 #[derive(Default)]
163 struct Slot(AtomicUsize);
164
165 fn registry() -> &'static Registry {
166 static REGISTRY: Registry = Registry::new();
167
168 ®ISTRY
169 }
170
171 #[test]
172 fn the_same_key_always_returns_the_same_slot() {
173 let first = registry().entry::<u8, Slot>();
174 first.0.store(7, Ordering::SeqCst);
175
176 let second = registry().entry::<u8, Slot>();
177
178 assert!(std::ptr::eq(first, second));
179 assert_eq!(second.0.load(Ordering::SeqCst), 7);
180 }
181
182 #[test]
183 fn different_keys_get_different_slots() {
184 let one = registry().entry::<u16, Slot>();
185 let two = registry().entry::<u32, Slot>();
186
187 assert!(!std::ptr::eq(one, two));
188 }
189
190 #[test]
191 fn concurrent_first_use_hands_out_one_slot() {
192 struct Contended;
193
194 let handles: Vec<_> = (0..8)
195 .map(|_| {
196 thread::spawn(|| {
197 // A raw pointer is not `Send`, so the address travels back
198 // as an integer instead.
199 registry().entry::<Contended, Slot>() as *const Slot as usize
200 })
201 })
202 .collect();
203
204 let slots: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
205
206 assert!(
207 slots.windows(2).all(|pair| pair[0] == pair[1]),
208 "every thread must see the same slot"
209 );
210 }
211}