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