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