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 ///
112 /// # Panics
113 ///
114 /// If one registry is used with two different `V` types for the same `K`
115 /// — the generated code never does this; hand-written callers must keep
116 /// one value type per registry.
117 pub fn entry<K, V>(&self) -> &'static V
118 where
119 K: 'static,
120 V: Default + Send + Sync + 'static,
121 {
122 let entries = self
123 .entries
124 .get_or_init(|| ArcSwap::from_pointee(Slots::default()));
125 let key = TypeId::of::<K>();
126
127 // The common case by far, and the one the benchmark measures: no lock,
128 // no hashing beyond a load, no allocation.
129 if let Some(found) = entries.load().get(&key) {
130 return downcast(*found);
131 }
132
133 // Missing: rebuild the table with the new slot in it. This happens once
134 // per monomorphization, so copying a table with a handful of entries is
135 // cheaper than making every read pay for a lock. A lost race leaks
136 // this one allocation — bounded by the same monomorphization count as
137 // the deliberate leak above, so it is a footnote, not a leak *rate*.
138 let leaked: &'static V = Box::leak(Box::new(V::default()));
139 let mut installed: Option<&'static (dyn Any + Send + Sync)> = None;
140
141 entries.rcu(|current| {
142 let mut next = Slots::clone(current);
143 // `or_insert` rather than `insert`: another thread may have won the
144 // race, and two slots for one type would mean two snapshots.
145 installed = Some(*next.entry(key).or_insert(leaked));
146
147 next
148 });
149
150 downcast(installed.expect("`rcu` runs its closure at least once"))
151 }
152}
153
154/// Recovers the concrete type a slot was created with.
155///
156/// The key is `TypeId::of::<K>()` and each registry serves exactly one `V`, so
157/// a mismatch would mean two different `V`s reached the same registry — a bug
158/// in the generated code rather than anything a caller can cause.
159fn downcast<V: 'static>(slot: &'static (dyn Any + Send + Sync)) -> &'static V {
160 slot.downcast_ref()
161 .expect("a registry slot holds exactly one type")
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use std::sync::atomic::{AtomicUsize, Ordering};
168 use std::thread;
169
170 #[derive(Default)]
171 struct Slot(AtomicUsize);
172
173 fn registry() -> &'static Registry {
174 static REGISTRY: Registry = Registry::new();
175
176 ®ISTRY
177 }
178
179 #[test]
180 fn the_same_key_always_returns_the_same_slot() {
181 let first = registry().entry::<u8, Slot>();
182 first.0.store(7, Ordering::SeqCst);
183
184 let second = registry().entry::<u8, Slot>();
185
186 assert!(std::ptr::eq(first, second));
187 assert_eq!(second.0.load(Ordering::SeqCst), 7);
188 }
189
190 #[test]
191 fn different_keys_get_different_slots() {
192 let one = registry().entry::<u16, Slot>();
193 let two = registry().entry::<u32, Slot>();
194
195 assert!(!std::ptr::eq(one, two));
196 }
197
198 #[test]
199 fn concurrent_first_use_hands_out_one_slot() {
200 struct Contended;
201
202 let handles: Vec<_> = (0..8)
203 .map(|_| {
204 thread::spawn(|| {
205 // A raw pointer is not `Send`, so the address travels back
206 // as an integer instead.
207 registry().entry::<Contended, Slot>() as *const Slot as usize
208 })
209 })
210 .collect();
211
212 let slots: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
213
214 assert!(
215 slots.windows(2).all(|pair| pair[0] == pair[1]),
216 "every thread must see the same slot"
217 );
218 }
219}