Skip to main content

orbit_counter/
lib.rs

1//! Keyed fleet counters backed by one authoritative SHM table.
2//!
3//! A counter is current state, not history: no ring frame is published and
4//! no cursor exists. The owner of a fleet generation calls [`Counter::reset_all`]
5//! during quiescent boot; everything else is lock-free atomics on
6//! immutable-address slots.
7//!
8//! Keys are installed once per fleet generation under a short,
9//! process-recoverable structural lock. Once installed, their values are
10//! immutable-address, signed 64-bit atomics: reads and updates do not publish
11//! ring frames and do not acquire an OS lock.
12
13use std::collections::HashMap;
14use std::fmt;
15use std::sync::atomic::{AtomicI64, AtomicU8, AtomicU16, AtomicU64, Ordering};
16use std::sync::{Arc, LazyLock, Mutex, MutexGuard, Weak};
17
18use orbit_core::Fleet;
19#[cfg(unix)]
20use orbit_core::shm::{ShmRegion, ring_segment_name};
21
22/// Reserved Orbit SHM kind for the keyed counter table.
23pub const COUNTER_STATE_KIND: u8 = 230;
24/// Maximum number of distinct keys installed during one fleet generation.
25///
26/// Compile-time geometry: `ORBIT_COUNTER_CAPACITY` in the application's
27/// `.cargo/config.toml` overrides the default. Must be a power of two. Peers
28/// built with a different value are refused when they open the table.
29pub const COUNTER_CAPACITY: usize =
30    orbit_core::compile::usize_from_env(option_env!("ORBIT_COUNTER_CAPACITY"), 1_024);
31/// Maximum counter key length in bytes.
32///
33/// Compile-time geometry: `ORBIT_COUNTER_KEY_MAX`. It sizes every slot, so it
34/// is part of the same wire contract as the capacity.
35pub const COUNTER_KEY_MAX: usize =
36    orbit_core::compile::usize_from_env(option_env!("ORBIT_COUNTER_KEY_MAX"), 240);
37
38const SLOT_EMPTY: u8 = 0;
39const SLOT_OCCUPIED: u8 = 1;
40const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
41const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
42
43#[cfg(unix)]
44const STATE_MAGIC: u32 = 0x43_43_4E_54; // "CCNT"
45#[cfg(unix)]
46const STATE_VERSION: u16 = 1;
47
48pub type Result<T, E = Error> = std::result::Result<T, E>;
49
50/// Additions are expected: a cause discovered later lands here rather
51/// than in a new major version, so a caller matches what it handles and
52/// leaves the rest to a catch-all.
53#[non_exhaustive]
54#[derive(Debug)]
55pub enum Error {
56    KeyEmpty,
57    KeyTooLarge { len: usize, max: usize },
58    NegativeAmount(i64),
59    Overflow,
60    StateFull { capacity: usize },
61    Io(std::io::Error)
62}
63
64impl fmt::Display for Error {
65    fn fmt(
66        &self,
67        f: &mut fmt::Formatter<'_>
68    ) -> fmt::Result {
69        match self {
70            Self::KeyEmpty => f.write_str("counter key must not be empty"),
71            Self::KeyTooLarge { len, max } => {
72                write!(f, "counter key is too large: len={len} max={max}")
73            }
74            Self::NegativeAmount(value) => {
75                write!(f, "counter amount must not be negative: {value}")
76            }
77            Self::Overflow => f.write_str("counter value is outside the signed 64-bit range"),
78            Self::StateFull { capacity } => {
79                write!(f, "counter state table is full: capacity={capacity}")
80            }
81            Self::Io(error) => write!(f, "Orbit counter io error: {error}")
82        }
83    }
84}
85
86impl std::error::Error for Error {
87    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
88        match self {
89            Self::Io(error) => Some(error),
90            _ => None
91        }
92    }
93}
94
95impl From<std::io::Error> for Error {
96    fn from(value: std::io::Error) -> Self {
97        Self::Io(value)
98    }
99}
100
101/// Fleet-wide keyed signed counters.
102///
103/// A missing key starts at zero for `increment` and `decrement`. Both methods
104/// return the value after their atomic update. `reset` installs a missing key
105/// or atomically returns an existing value to zero.
106#[derive(Clone)]
107pub struct Counter {
108    backend: CounterBackend
109}
110
111#[derive(Clone)]
112enum CounterBackend {
113    InMemory(Arc<MemoryCounterTable>),
114    #[cfg(unix)]
115    Shm(Arc<ShmCounterTable>)
116}
117
118impl Counter {
119    pub fn new(fleet: Arc<Fleet>) -> Result<Self> {
120        let backend = if fleet.is_shm() {
121            #[cfg(unix)]
122            {
123                CounterBackend::Shm(Arc::new(ShmCounterTable::open_or_create(&ring_segment_name(
124                    fleet.name(),
125                    COUNTER_STATE_KIND
126                ))?))
127            }
128            #[cfg(not(unix))]
129            unreachable!("non-Unix fleets cannot use POSIX SHM")
130        } else {
131            CounterBackend::InMemory(memory_table(&fleet))
132        };
133        Ok(Self { backend })
134    }
135
136    pub fn get(
137        &self,
138        key: &str
139    ) -> Result<Option<i64>> {
140        validate_key(key)?;
141        let hash = counter_hash(key.as_bytes());
142        match &self.backend {
143            CounterBackend::InMemory(table) => {
144                let slots = lock_unpoisoned(&table.slots);
145                Ok(find_slot(&slots, hash, key.as_bytes())
146                    .map(|slot| slot.value.load(Ordering::Acquire)))
147            }
148            #[cfg(unix)]
149            CounterBackend::Shm(table) => Ok(find_slot(table.slots(), hash, key.as_bytes())
150                .map(|slot| slot.value.load(Ordering::Acquire)))
151        }
152    }
153
154    pub fn increment(
155        &self,
156        key: &str,
157        by: i64
158    ) -> Result<i64> {
159        validate_amount(by)?;
160        self.update(key, by)
161    }
162
163    pub fn decrement(
164        &self,
165        key: &str,
166        by: i64
167    ) -> Result<i64> {
168        validate_amount(by)?;
169        self.update(key, -by)
170    }
171
172    /// Atomically set a key to zero, installing it when necessary.
173    pub fn reset(
174        &self,
175        key: &str
176    ) -> Result<()> {
177        validate_key(key)?;
178        let hash = counter_hash(key.as_bytes());
179        match &self.backend {
180            CounterBackend::InMemory(table) => {
181                let slots = lock_unpoisoned(&table.slots);
182                reset_or_install(&slots, hash, key.as_bytes())
183            }
184            #[cfg(unix)]
185            CounterBackend::Shm(table) => {
186                if let Some(slot) = find_slot(table.slots(), hash, key.as_bytes()) {
187                    slot.value.store(0, Ordering::Release);
188                    return Ok(());
189                }
190                table.with_structure(|slots| reset_or_install(slots, hash, key.as_bytes()))
191            }
192        }
193    }
194
195    /// Clear the complete table during quiescent owner boot.
196    pub fn reset_all(&self) -> Result<()> {
197        match &self.backend {
198            CounterBackend::InMemory(table) => {
199                let slots = lock_unpoisoned(&table.slots);
200                clear_slots(&slots);
201                Ok(())
202            }
203            #[cfg(unix)]
204            CounterBackend::Shm(table) => table.with_structure(|slots| {
205                clear_slots(slots);
206                Ok(())
207            })
208        }
209    }
210
211    /// Remove the counter SHM object. Existing mappings remain valid until
212    /// their processes release them.
213    #[cfg(unix)]
214    pub fn unlink(&self) -> Result<()> {
215        match &self.backend {
216            CounterBackend::InMemory(_) => self.reset_all(),
217            CounterBackend::Shm(table) => table.region.unlink().map_err(Error::Io)
218        }
219    }
220
221    fn update(
222        &self,
223        key: &str,
224        delta: i64
225    ) -> Result<i64> {
226        validate_key(key)?;
227        let hash = counter_hash(key.as_bytes());
228        match &self.backend {
229            CounterBackend::InMemory(table) => {
230                let slots = lock_unpoisoned(&table.slots);
231                update_or_install(&slots, hash, key.as_bytes(), delta)
232            }
233            #[cfg(unix)]
234            CounterBackend::Shm(table) => {
235                if let Some(slot) = find_slot(table.slots(), hash, key.as_bytes()) {
236                    return update_value(slot, delta);
237                }
238                table.with_structure(|slots| update_or_install(slots, hash, key.as_bytes(), delta))
239            }
240        }
241    }
242}
243
244fn update_or_install(
245    slots: &[CounterSlot],
246    hash: u64,
247    key: &[u8],
248    delta: i64
249) -> Result<i64> {
250    for offset in 0..slots.len() {
251        let index = probe_index(hash, offset, slots.len());
252        let slot = &slots[index];
253        match slot.state.load(Ordering::Acquire) {
254            SLOT_EMPTY => {
255                slot.install(hash, key, delta);
256                return Ok(delta);
257            }
258            SLOT_OCCUPIED if slot.matches(hash, key) => return update_value(slot, delta),
259            _ => {}
260        }
261    }
262    Err(Error::StateFull { capacity: slots.len() })
263}
264
265fn find_slot<'a>(
266    slots: &'a [CounterSlot],
267    hash: u64,
268    key: &[u8]
269) -> Option<&'a CounterSlot> {
270    for offset in 0..slots.len() {
271        let slot = &slots[probe_index(hash, offset, slots.len())];
272        match slot.state.load(Ordering::Acquire) {
273            SLOT_EMPTY => return None,
274            SLOT_OCCUPIED if slot.matches(hash, key) => return Some(slot),
275            _ => {}
276        }
277    }
278    None
279}
280
281fn reset_or_install(
282    slots: &[CounterSlot],
283    hash: u64,
284    key: &[u8]
285) -> Result<()> {
286    for offset in 0..slots.len() {
287        let index = probe_index(hash, offset, slots.len());
288        let slot = &slots[index];
289        match slot.state.load(Ordering::Acquire) {
290            SLOT_EMPTY => {
291                slot.install(hash, key, 0);
292                return Ok(());
293            }
294            SLOT_OCCUPIED if slot.matches(hash, key) => {
295                slot.value.store(0, Ordering::Release);
296                return Ok(());
297            }
298            _ => {}
299        }
300    }
301    Err(Error::StateFull { capacity: slots.len() })
302}
303
304fn clear_slots(slots: &[CounterSlot]) {
305    for slot in slots {
306        slot.value.store(0, Ordering::Relaxed);
307        slot.state.store(SLOT_EMPTY, Ordering::Release);
308    }
309}
310
311fn update_value(
312    slot: &CounterSlot,
313    delta: i64
314) -> Result<i64> {
315    slot.value
316        .try_update(Ordering::AcqRel, Ordering::Acquire, |current| current.checked_add(delta))
317        .map(|previous| previous + delta)
318        .map_err(|_| Error::Overflow)
319}
320
321fn validate_key(key: &str) -> Result<()> {
322    if key.is_empty() {
323        return Err(Error::KeyEmpty);
324    }
325    if key.len() > COUNTER_KEY_MAX {
326        return Err(Error::KeyTooLarge { len: key.len(), max: COUNTER_KEY_MAX });
327    }
328    Ok(())
329}
330
331fn validate_amount(by: i64) -> Result<()> {
332    if by < 0 {
333        return Err(Error::NegativeAmount(by));
334    }
335    Ok(())
336}
337
338fn counter_hash(key: &[u8]) -> u64 {
339    let mut hash = FNV_OFFSET_BASIS;
340    for byte in key {
341        hash ^= u64::from(*byte);
342        hash = hash.wrapping_mul(FNV_PRIME);
343    }
344    hash
345}
346
347fn probe_index(
348    hash: u64,
349    offset: usize,
350    capacity: usize
351) -> usize {
352    debug_assert!(capacity.is_power_of_two());
353    (hash as usize).wrapping_add(offset) & (capacity - 1)
354}
355
356fn lock_unpoisoned<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
357    mutex.lock().unwrap_or_else(|error| error.into_inner())
358}
359
360struct MemoryCounterTable {
361    /// Held so the fleet's address, which keys the registry, cannot be
362    /// reused by another fleet while this table lives.
363    _fleet: Arc<Fleet>,
364    slots: Mutex<Vec<CounterSlot>>
365}
366
367impl MemoryCounterTable {
368    fn new(fleet: Arc<Fleet>) -> Self {
369        Self {
370            _fleet: fleet,
371            slots: Mutex::new((0..COUNTER_CAPACITY).map(|_| CounterSlot::empty()).collect())
372        }
373    }
374}
375
376type MemoryRegistry = HashMap<usize, Weak<MemoryCounterTable>>;
377static MEMORY_TABLES: LazyLock<Mutex<MemoryRegistry>> =
378    LazyLock::new(|| Mutex::new(HashMap::new()));
379
380fn memory_table(fleet: &Arc<Fleet>) -> Arc<MemoryCounterTable> {
381    let fleet_identity = Arc::as_ptr(fleet) as usize;
382    let mut tables = lock_unpoisoned(&MEMORY_TABLES);
383    tables.retain(|_, table| table.strong_count() > 0);
384    if let Some(table) = tables.get(&fleet_identity).and_then(Weak::upgrade) {
385        return table;
386    }
387    let table = Arc::new(MemoryCounterTable::new(Arc::clone(fleet)));
388    tables.insert(fleet_identity, Arc::downgrade(&table));
389    table
390}
391
392#[repr(C, align(64))]
393struct CounterStateHeader {
394    #[cfg(unix)]
395    magic: u32,
396    #[cfg(not(unix))]
397    _magic: u32,
398    #[cfg(unix)]
399    version: u16,
400    #[cfg(not(unix))]
401    _version: u16,
402    header_size: u16,
403    capacity: u32,
404    slot_size: u32,
405    _reserved: [u8; 48]
406}
407
408impl CounterStateHeader {
409    fn new() -> Self {
410        Self {
411            #[cfg(unix)]
412            magic: STATE_MAGIC,
413            #[cfg(not(unix))]
414            _magic: 0,
415            #[cfg(unix)]
416            version: STATE_VERSION,
417            #[cfg(not(unix))]
418            _version: 0,
419            header_size: size_of::<Self>() as u16,
420            capacity: COUNTER_CAPACITY as u32,
421            slot_size: size_of::<CounterSlot>() as u32,
422            _reserved: [0; 48]
423        }
424    }
425}
426
427/// One keyed counter, laid out for shared memory.
428///
429/// Every field is atomic, including the ones only ever written under the
430/// structural lock. The lock makes the writes mutually exclusive, but it does
431/// not make a `&mut [CounterSlot]` legal while readers hold `&[CounterSlot]`
432/// over the same mapping — and they do, on the lock-free read path. Atomics
433/// remove the need for the `&mut` entirely, which is what makes the aliasing
434/// question go away rather than merely become unlikely.
435///
436/// `AtomicU8`, `AtomicU16` and `AtomicU64` match the size and alignment of the
437/// integers they replace, so the on-disk layout this struct describes — and the
438/// `slot_size` the header validates — is unchanged.
439#[repr(C, align(64))]
440struct CounterSlot {
441    state: AtomicU8,
442    _reserved: [u8; 7],
443    key_hash: AtomicU64,
444    value: AtomicI64,
445    key_len: AtomicU16,
446    _padding: [u8; 6],
447    key: [AtomicU8; COUNTER_KEY_MAX]
448}
449
450impl CounterSlot {
451    fn empty() -> Self {
452        Self {
453            state: AtomicU8::new(SLOT_EMPTY),
454            _reserved: [0; 7],
455            key_hash: AtomicU64::new(0),
456            value: AtomicI64::new(0),
457            key_len: AtomicU16::new(0),
458            _padding: [0; 6],
459            key: std::array::from_fn(|_| AtomicU8::new(0))
460        }
461    }
462
463    fn matches(
464        &self,
465        hash: u64,
466        key: &[u8]
467    ) -> bool {
468        // The `Acquire` on `state` is what publishes the fields below: `install`
469        // writes them and then releases `state`, so a slot seen as occupied has
470        // its key fully visible. They are therefore read `Relaxed`.
471        self.state.load(Ordering::Acquire) == SLOT_OCCUPIED
472            && self.key_hash.load(Ordering::Relaxed) == hash
473            && usize::from(self.key_len.load(Ordering::Relaxed)) == key.len()
474            && self.key[..key.len()]
475                .iter()
476                .zip(key)
477                .all(|(stored, expected)| stored.load(Ordering::Relaxed) == *expected)
478    }
479
480    /// Takes `&self`, not `&mut self`: callers hold the structural lock, which
481    /// is what makes this exclusive, and the shared reference is what keeps the
482    /// mapping free of `&mut` while readers are looking at it.
483    fn install(
484        &self,
485        hash: u64,
486        key: &[u8],
487        value: i64
488    ) {
489        debug_assert!(key.len() <= COUNTER_KEY_MAX);
490        self.key_hash.store(hash, Ordering::Relaxed);
491        self.key_len.store(key.len() as u16, Ordering::Relaxed);
492        for (index, byte) in self.key.iter().enumerate() {
493            byte.store(key.get(index).copied().unwrap_or(0), Ordering::Relaxed);
494        }
495        self.value.store(value, Ordering::Relaxed);
496        // Releases everything above to any reader that sees the slot occupied.
497        self.state.store(SLOT_OCCUPIED, Ordering::Release);
498    }
499}
500
501#[cfg(unix)]
502struct ShmCounterTable {
503    region: ShmRegion,
504    structural_lock: Mutex<()>
505}
506
507#[cfg(unix)]
508impl ShmCounterTable {
509    fn open_or_create(name: &str) -> Result<Self> {
510        use std::{io, ptr};
511
512        let (region, _initialization_lock) =
513            ShmRegion::open_or_create_locked(name, shm_segment_size())?;
514        if region.created() {
515            unsafe {
516                ptr::write(region.as_ptr().cast::<CounterStateHeader>(), CounterStateHeader::new());
517                let slots = region.as_ptr().add(size_of::<CounterStateHeader>());
518                ptr::write_bytes(slots, 0, COUNTER_CAPACITY * size_of::<CounterSlot>());
519            }
520        } else {
521            let header = unsafe { &*region.as_ptr().cast::<CounterStateHeader>() };
522            if header.magic != STATE_MAGIC {
523                return Err(Error::Io(io::Error::new(
524                    io::ErrorKind::InvalidData,
525                    format!(
526                        "SHM segment {name} has wrong magic 0x{:08X} (expected 0x{STATE_MAGIC:08X})",
527                        header.magic
528                    )
529                )));
530            }
531            if header.version != STATE_VERSION
532                || usize::from(header.header_size) != size_of::<CounterStateHeader>()
533                || header.capacity as usize != COUNTER_CAPACITY
534                || header.slot_size as usize != size_of::<CounterSlot>()
535            {
536                return Err(Error::Io(io::Error::new(
537                    io::ErrorKind::InvalidData,
538                    format!("SHM segment {name} has an incompatible counter-state layout")
539                )));
540            }
541        }
542
543        Ok(Self { region, structural_lock: Mutex::new(()) })
544    }
545
546    fn slots(&self) -> &[CounterSlot] {
547        unsafe {
548            std::slice::from_raw_parts(
549                self.region.as_ptr().add(size_of::<CounterStateHeader>()).cast::<CounterSlot>(),
550                COUNTER_CAPACITY
551            )
552        }
553    }
554
555    fn with_structure<T>(
556        &self,
557        operation: impl FnOnce(&[CounterSlot]) -> Result<T>
558    ) -> Result<T> {
559        let _local = lock_unpoisoned(&self.structural_lock);
560        let _process = self.region.lock_exclusive()?;
561        // Shared, never mutable: the locks above provide the exclusion, and
562        // every field is atomic, so nothing here needs a `&mut` over memory
563        // other processes are reading.
564        operation(self.slots())
565    }
566}
567
568#[cfg(unix)]
569fn shm_segment_size() -> usize {
570    size_of::<CounterStateHeader>() + COUNTER_CAPACITY * size_of::<CounterSlot>()
571}
572
573const _: () = assert!(COUNTER_CAPACITY.is_power_of_two());
574const _: () = assert!(COUNTER_KEY_MAX > 0 && COUNTER_KEY_MAX <= u16::MAX as usize);
575const _: () = assert!(size_of::<CounterStateHeader>() == 64);
576// 32 bytes of fixed fields, then the key, padded to the 64-byte slot alignment.
577const _: () = assert!(size_of::<CounterSlot>() == (32 + COUNTER_KEY_MAX).next_multiple_of(64));