Skip to main content

orbit_core/fleet/
mod.rs

1//! `Fleet` — the per-process handle that represents membership in
2//! a fleet of peers.
3
4use std::sync::Arc;
5use std::sync::atomic::{AtomicU64, Ordering};
6
7use bytes::Bytes;
8use dashmap::DashMap;
9
10use crate::OrbitTyped;
11use crate::error::{Error, Result};
12use crate::id::NetId64;
13#[cfg(any(target_os = "linux", target_os = "freebsd"))]
14use crate::ring::RingEventFd;
15#[cfg(unix)]
16use crate::ring::shm::{ShmRing, ShmRingRegistry};
17use crate::ring::{Frame, Ring, RingRegistry, RingTopology};
18
19mod cursor;
20pub use cursor::{FleetLaneCursor, FleetLanePoll};
21
22/// A node's slot inside the fleet — assigned at `join` time.
23///
24/// V0: assigned monotonically by a process-local counter (always
25/// `0` for a single-process test). V1+: assigned by the SHM-backed
26/// fleet header so peers see consistent values.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28#[repr(transparent)]
29pub struct NodeId(pub u16);
30
31impl NodeId {
32    pub const ZERO: Self = Self(0);
33
34    pub const fn new(value: u16) -> Self {
35        Self(value)
36    }
37
38    pub const fn get(self) -> u16 {
39        self.0
40    }
41}
42
43impl std::fmt::Display for NodeId {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        write!(f, "node:{}", self.0)
46    }
47}
48
49/// Per-process handle into the fleet. Cheap to clone — the inner
50/// state is `Arc`-shared.
51#[derive(Clone)]
52pub struct Fleet {
53    inner: Arc<FleetInner>,
54}
55
56struct FleetInner {
57    name: &'static str,
58    fleet_size: u8,
59    node_id: NodeId,
60    /// Per-KIND counter for `next_id` calls that don't go through a
61    /// ring (i.e. when the caller wants a fleet-unique id without
62    /// allocating a ring slot). V0: process-local atomic. V1: still
63    /// here, parallel to the ring's own write-position.
64    id_counters: DashMap<u8, Arc<AtomicU64>>,
65    /// Per-KIND ring buffers — orbit's runtime substrate. Either
66    /// in-process for unit-test / single-process use, or POSIX SHM
67    /// for real cross-process visibility (V1, master+worker fleet).
68    backing: RingBacking,
69}
70
71/// Backing storage for the fleet's ring buffers — chosen at
72/// `Fleet::join` / `Fleet::join_shm` time and frozen for the
73/// fleet's lifetime.
74enum RingBacking {
75    /// Process-local DashMap of `Ring` instances. No cross-process
76    /// visibility — peers running other processes do not see this
77    /// fleet's writes. Useful for unit tests and embedded scenarios.
78    InMemory(RingRegistry),
79    /// POSIX-SHM-backed `ShmRing` instances. Multiple processes
80    /// joining the same fleet name share the same kernel-level
81    /// memory; writes from one are visible to all immediately.
82    #[cfg(unix)]
83    Shm(ShmRingRegistry),
84}
85
86impl Fleet {
87    /// Join (or create) a fleet under `name` with `fleet_size` total
88    /// expected members. In V0 every call returns a fresh local
89    /// fleet; the backing slot table is process-local.
90    pub fn join(name: &'static str, fleet_size: u8) -> Result<Self> {
91        Self::join_as(name, fleet_size, NodeId::ZERO)
92    }
93
94    /// Join (or create) a process-local fleet with an explicit node id.
95    pub fn join_as(name: &'static str, fleet_size: u8, node_id: NodeId) -> Result<Self> {
96        if fleet_size == 0 {
97            return Err(Error::EmptyFleet);
98        }
99        if node_id.get() >= u16::from(fleet_size) {
100            return Err(Error::NodeOutsideFleet {
101                node_id: node_id.get(),
102                fleet_size,
103            });
104        }
105        Ok(Self {
106            inner: Arc::new(FleetInner {
107                name,
108                fleet_size,
109                node_id,
110                id_counters: DashMap::new(),
111                backing: RingBacking::InMemory(RingRegistry::new(fleet_size)),
112            }),
113        })
114    }
115
116    /// Join (or create) a fleet whose ring storage is backed by
117    /// POSIX shared memory. Multiple processes calling this with
118    /// the same `name` share the same kernel-level
119    /// segments — the fleet sees each other's writes.
120    ///
121    /// Each `OrbitTyped` kind gets its own SHM segment whose layout is
122    /// declared by `OrbitTyped::RING_SPEC`.
123    ///
124    /// Cross-process naming: segments are `/orbit-{name}-{kind}-{uid}`.
125    /// macOS limits POSIX SHM names to 31 chars (PSHMNAMLEN); a
126    /// short fleet name is required there.
127    #[cfg(unix)]
128    pub fn join_shm(name: &'static str, fleet_size: u8) -> Result<Self> {
129        Self::join_shm_as(name, fleet_size, NodeId::ZERO)
130    }
131
132    /// Join (or create) a SHM-backed fleet with an explicit node id.
133    ///
134    /// Per-node rings require one active process membership per node id.
135    /// Orbit validates the id range but does not own process lifecycle and
136    /// therefore cannot prevent duplicate live memberships.
137    #[cfg(unix)]
138    pub fn join_shm_as(name: &'static str, fleet_size: u8, node_id: NodeId) -> Result<Self> {
139        if fleet_size == 0 {
140            return Err(Error::EmptyFleet);
141        }
142        if node_id.get() >= u16::from(fleet_size) {
143            return Err(Error::NodeOutsideFleet {
144                node_id: node_id.get(),
145                fleet_size,
146            });
147        }
148        Ok(Self {
149            inner: Arc::new(FleetInner {
150                name,
151                fleet_size,
152                node_id,
153                id_counters: DashMap::new(),
154                backing: RingBacking::Shm(ShmRingRegistry::new(name, fleet_size)),
155            }),
156        })
157    }
158
159    pub fn name(&self) -> &'static str {
160        self.inner.name
161    }
162
163    pub fn fleet_size(&self) -> u8 {
164        self.inner.fleet_size
165    }
166
167    pub fn node_id(&self) -> NodeId {
168        self.inner.node_id
169    }
170
171    /// Mint a fresh fleet-unique [`NetId64`] for type `T` *without*
172    /// publishing anything. Use this when the caller only needs the
173    /// id (e.g. minting an id to attach to data being persisted to
174    /// DB before going through the ring).
175    ///
176    /// For most use cases prefer [`Fleet::publish`] — it mints AND
177    /// stores in a single atomic step.
178    pub fn next_id<T: OrbitTyped>(&self) -> NetId64 {
179        let counter_arc = self
180            .inner
181            .id_counters
182            .entry(T::KIND)
183            .or_insert_with(|| Arc::new(AtomicU64::new(0)))
184            .clone();
185        let counter = counter_arc.fetch_add(1, Ordering::Relaxed);
186        NetId64::make(T::KIND, self.node_id().get(), counter)
187    }
188
189    /// True when this fleet's ring storage is backed by POSIX SHM
190    /// (visible across processes). False for in-memory fleets.
191    pub fn is_shm(&self) -> bool {
192        #[cfg(unix)]
193        {
194            matches!(self.inner.backing, RingBacking::Shm(_))
195        }
196        #[cfg(not(unix))]
197        {
198            false
199        }
200    }
201
202    /// Get-or-create the in-memory ring for type `T`. Only valid
203    /// for fleets created via [`Fleet::join`]; SHM-backed fleets
204    /// should use [`Fleet::shm_ring`] instead.
205    ///
206    /// # Panics
207    ///
208    /// Panics if called on a SHM-backed fleet.
209    pub fn ring<T: OrbitTyped>(&self) -> Arc<Ring> {
210        match &self.inner.backing {
211            RingBacking::InMemory(r) => r.get_or_create::<T>(),
212            #[cfg(unix)]
213            RingBacking::Shm(_) => {
214                panic!("Fleet::ring called on SHM-backed fleet — use Fleet::shm_ring instead");
215            }
216        }
217    }
218
219    /// Get-or-create the SHM ring for type `T`. Only valid on fleets
220    /// created via [`Fleet::join_shm`].
221    ///
222    /// # Errors
223    ///
224    /// Returns an `io::Error` if the SHM segment cannot be opened
225    /// (permissions, name too long, etc.).
226    ///
227    /// # Panics
228    ///
229    /// Panics if called on an in-memory fleet.
230    #[cfg(unix)]
231    pub fn shm_ring<T: OrbitTyped>(&self) -> std::io::Result<Arc<ShmRing>> {
232        match &self.inner.backing {
233            RingBacking::Shm(r) => r.get_or_create_for::<T>(),
234            RingBacking::InMemory(_) => {
235                panic!("Fleet::shm_ring called on in-memory fleet — use Fleet::ring instead");
236            }
237        }
238    }
239
240    /// Publish a payload to the ring for type `T`. Mints a [`NetId64`],
241    /// writes the [`Frame`] to the appropriate shared or node-owned lane,
242    /// and returns the id.
243    ///
244    /// # Panics
245    ///
246    /// Panics if the ring cannot be opened or the payload exceeds
247    /// `T::RING_SPEC.payload_capacity`. Ring failures are
248    /// operator-visible, not silently ignored.
249    pub fn publish<T: OrbitTyped>(&self, frame_kind: u8, ver: u64, payload: Bytes) -> NetId64 {
250        match &self.inner.backing {
251            RingBacking::InMemory(r) => {
252                let ring = r.get_or_create::<T>();
253                ring.write(self.node_id(), frame_kind, ver, payload)
254            }
255            #[cfg(unix)]
256            RingBacking::Shm(r) => {
257                let ring = r
258                    .get_or_create_for::<T>()
259                    .expect("SHM ring open failed — fleet unusable");
260                ring.write(self.node_id(), frame_kind, ver, payload)
261                    .expect("SHM ring write failed")
262            }
263        }
264    }
265
266    /// Publish a contiguous batch into one ring lane.
267    ///
268    /// The returned ids are ordered and consecutive. For per-node rings the
269    /// lane head becomes visible only after every frame in the batch has been
270    /// committed. Semantic layers can use this to publish a multi-slot blob,
271    /// then publish a separate descriptor that references the first id and
272    /// frame count.
273    ///
274    /// # Panics
275    ///
276    /// Panics if the ring cannot be opened, a payload exceeds the declared
277    /// slot capacity, or the batch itself is larger than the ring.
278    pub fn publish_batch<T: OrbitTyped>(
279        &self,
280        frame_kind: u8,
281        ver: u64,
282        payloads: Vec<Bytes>,
283    ) -> Vec<NetId64> {
284        match &self.inner.backing {
285            RingBacking::InMemory(r) => {
286                let ring = r.get_or_create::<T>();
287                ring.write_batch(self.node_id(), frame_kind, ver, payloads)
288            }
289            #[cfg(unix)]
290            RingBacking::Shm(r) => {
291                let ring = r
292                    .get_or_create_for::<T>()
293                    .expect("SHM ring open failed — fleet unusable");
294                ring.write_batch(self.node_id(), frame_kind, ver, payloads)
295                    .expect("SHM ring batch write failed")
296            }
297        }
298    }
299
300    /// Look up a previously-published frame by its id. Returns the
301    /// frame if its slot still holds the same id (i.e. the ring has
302    /// not wrapped past it).
303    pub fn read(&self, id: NetId64) -> Option<Frame> {
304        match &self.inner.backing {
305            RingBacking::InMemory(r) => r.lookup(id.kind())?.read(id),
306            #[cfg(unix)]
307            RingBacking::Shm(r) => r.lookup(id.kind())?.read(id),
308        }
309    }
310
311    /// Read the most recent frame for type `T`. Per-node rings read this
312    /// fleet handle's local lane; shared rings read their sole lane.
313    pub fn read_head<T: OrbitTyped>(&self) -> Option<Frame> {
314        if T::RING_SPEC.topology == RingTopology::PerNode {
315            let head = self.lane_head::<T>(self.node_id());
316            return (head > 0)
317                .then(|| self.read_lane_at::<T>(self.node_id(), head - 1))
318                .flatten();
319        }
320        match &self.inner.backing {
321            RingBacking::InMemory(r) => {
322                let ring = r.get_or_create::<T>();
323                ring.read_head()
324            }
325            #[cfg(unix)]
326            RingBacking::Shm(r) => {
327                let ring = r.get_or_create_for::<T>().ok()?;
328                ring.read_head()
329            }
330        }
331    }
332
333    /// Current head for type `T`'s ring. Per-node rings report this fleet
334    /// handle's local committed head; shared rings report their sole lane's
335    /// visible head.
336    /// Lazily
337    /// creates / attaches the ring on first access — important for
338    /// cross-process readers, where a child process may need to
339    /// attach to a SHM segment a peer already populated. Returns 0
340    /// when the ring is fresh / no counters have been claimed.
341    pub fn head<T: OrbitTyped>(&self) -> u64 {
342        if T::RING_SPEC.topology == RingTopology::PerNode {
343            return self.lane_head::<T>(self.node_id());
344        }
345        match &self.inner.backing {
346            RingBacking::InMemory(r) => r.get_or_create::<T>().head(),
347            #[cfg(unix)]
348            RingBacking::Shm(r) => r
349                .get_or_create_for::<T>()
350                .map(|ring| ring.head())
351                .unwrap_or(0),
352        }
353    }
354
355    /// Read whatever frame currently occupies `counter % capacity`.
356    /// Per-node rings read this fleet handle's local lane. Lazily attaches
357    /// the ring on first access (same rationale as [`Fleet::head`]).
358    /// Returns `None` if the slot is empty/torn or attach fails.
359    ///
360    /// Used by walking readers; for typed handle-based reads,
361    /// prefer [`Fleet::read`].
362    pub fn read_at<T: OrbitTyped>(&self, counter: u64) -> Option<Frame> {
363        if T::RING_SPEC.topology == RingTopology::PerNode {
364            return self.read_lane_at::<T>(self.node_id(), counter);
365        }
366        match &self.inner.backing {
367            RingBacking::InMemory(r) => r.get_or_create::<T>().read_at(counter),
368            #[cfg(unix)]
369            RingBacking::Shm(r) => r.get_or_create_for::<T>().ok()?.read_at(counter),
370        }
371    }
372
373    pub(crate) fn read_state_at<T: OrbitTyped>(
374        &self,
375        counter: u64,
376    ) -> crate::ring::cursor::RingRead {
377        if T::RING_SPEC.topology == RingTopology::PerNode {
378            return self.read_lane_state_at::<T>(self.node_id(), counter);
379        }
380        match &self.inner.backing {
381            RingBacking::InMemory(r) => r.get_or_create::<T>().read_state_at(counter),
382            #[cfg(unix)]
383            RingBacking::Shm(r) => r
384                .get_or_create_for::<T>()
385                .map(|ring| ring.read_state_at(counter))
386                .unwrap_or(crate::ring::cursor::RingRead::Unavailable),
387        }
388    }
389
390    /// Current head for one physical node lane.
391    ///
392    /// On a shared ring every node id addresses the sole shared lane.
393    pub fn lane_head<T: OrbitTyped>(&self, node_id: NodeId) -> u64 {
394        match &self.inner.backing {
395            RingBacking::InMemory(r) => r.get_or_create::<T>().lane_head(node_id),
396            #[cfg(unix)]
397            RingBacking::Shm(r) => r
398                .get_or_create_for::<T>()
399                .map(|ring| ring.lane_head(node_id))
400                .unwrap_or(0),
401        }
402    }
403
404    /// Read the frame currently occupying one node lane's counter slot.
405    pub fn read_lane_at<T: OrbitTyped>(&self, node_id: NodeId, counter: u64) -> Option<Frame> {
406        match &self.inner.backing {
407            RingBacking::InMemory(r) => r.get_or_create::<T>().read_lane_at(node_id, counter),
408            #[cfg(unix)]
409            RingBacking::Shm(r) => r
410                .get_or_create_for::<T>()
411                .ok()?
412                .read_lane_at(node_id, counter),
413        }
414    }
415
416    pub(crate) fn read_lane_state_at<T: OrbitTyped>(
417        &self,
418        node_id: NodeId,
419        counter: u64,
420    ) -> crate::ring::cursor::RingRead {
421        match &self.inner.backing {
422            RingBacking::InMemory(r) => r.get_or_create::<T>().read_lane_state_at(node_id, counter),
423            #[cfg(unix)]
424            RingBacking::Shm(r) => r
425                .get_or_create_for::<T>()
426                .map(|ring| ring.read_lane_state_at(node_id, counter))
427                .unwrap_or(crate::ring::cursor::RingRead::Unavailable),
428        }
429    }
430
431    /// Capacity of the ring for type `T`. Lazily attaches the ring
432    /// on first access. Falls back to `T::RING_SPEC.capacity` when
433    /// SHM attach fails.
434    pub fn ring_capacity<T: OrbitTyped>(&self) -> usize {
435        match &self.inner.backing {
436            RingBacking::InMemory(r) => r.get_or_create::<T>().capacity(),
437            #[cfg(unix)]
438            RingBacking::Shm(r) => r
439                .get_or_create_for::<T>()
440                .map(|ring| ring.capacity())
441                .unwrap_or(T::RING_SPEC.capacity),
442        }
443    }
444
445    /// Allocate one semantic version shared by every writer lane of `T`.
446    ///
447    /// This is separate from each lane's physical frame counter. It is useful
448    /// for semantic layers that retain per-node write scalability but require
449    /// a deterministic fleet-wide last-write-wins order.
450    pub fn next_ring_version<T: OrbitTyped>(&self) -> u64 {
451        match &self.inner.backing {
452            RingBacking::InMemory(r) => r.get_or_create::<T>().next_version(),
453            #[cfg(unix)]
454            RingBacking::Shm(r) => r
455                .get_or_create_for::<T>()
456                .expect("SHM ring open failed — fleet unusable")
457                .next_version(),
458        }
459    }
460
461    /// Return the last semantic version allocated for `T` without advancing it.
462    pub fn current_ring_version<T: OrbitTyped>(&self) -> u64 {
463        match &self.inner.backing {
464            RingBacking::InMemory(r) => r.get_or_create::<T>().current_version(),
465            #[cfg(unix)]
466            RingBacking::Shm(r) => r
467                .get_or_create_for::<T>()
468                .expect("SHM ring open failed — fleet unusable")
469                .current_version(),
470        }
471    }
472
473    /// Clear every lane for `T` and reset all heads to zero.
474    ///
475    /// This is an owner-side boot cleanup primitive. It is safe for
476    /// runtime state such as events and periodic metrics when the
477    /// embedding application calls it before peer processes begin
478    /// publishing. It is not a coordination protocol; callers must not
479    /// reset a ring while other fleet members are actively writing it.
480    pub fn reset_ring<T: OrbitTyped>(&self) -> std::io::Result<()> {
481        match &self.inner.backing {
482            RingBacking::InMemory(r) => {
483                r.get_or_create::<T>().reset();
484                Ok(())
485            }
486            #[cfg(unix)]
487            RingBacking::Shm(r) => {
488                r.get_or_create_for::<T>()?.reset();
489                Ok(())
490            }
491        }
492    }
493
494    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
495    /// Create a process-local readiness fd for one notified SHM ring.
496    ///
497    /// The fd only signals that the ring generation changed. After draining
498    /// it, callers must poll the ring with their own cursor. Multiple writes
499    /// may coalesce into one readiness notification.
500    pub fn ring_event_fd<T: OrbitTyped>(&self) -> std::io::Result<RingEventFd> {
501        match &self.inner.backing {
502            RingBacking::Shm(rings) => RingEventFd::new(rings.get_or_create_for::<T>()?),
503            RingBacking::InMemory(_) => Err(std::io::Error::new(
504                std::io::ErrorKind::Unsupported,
505                "Orbit eventfd requires a shared-memory fleet",
506            )),
507        }
508    }
509
510    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
511    /// Publish one frame and notify native waiters after it commits.
512    pub fn publish_notified<T: OrbitTyped>(
513        &self,
514        frame_kind: u8,
515        ver: u64,
516        payload: Bytes,
517    ) -> std::io::Result<NetId64> {
518        match &self.inner.backing {
519            RingBacking::Shm(rings) => {
520                let ring = rings.get_or_create_for::<T>()?;
521                let id = ring.write(self.node_id(), frame_kind, ver, payload)?;
522                RingEventFd::notify(&ring)?;
523                Ok(id)
524            }
525            // Process-local fleets do not need a kernel wake bridge.
526            RingBacking::InMemory(rings) => {
527                Ok(rings
528                    .get_or_create::<T>()
529                    .write(self.node_id(), frame_kind, ver, payload))
530            }
531        }
532    }
533
534    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
535    /// Publish one contiguous batch and notify native waiters once after the
536    /// complete batch commits.
537    pub fn publish_batch_notified<T: OrbitTyped>(
538        &self,
539        frame_kind: u8,
540        ver: u64,
541        payloads: Vec<Bytes>,
542    ) -> std::io::Result<Vec<NetId64>> {
543        match &self.inner.backing {
544            RingBacking::Shm(rings) => {
545                let ring = rings.get_or_create_for::<T>()?;
546                let ids = ring.write_batch(self.node_id(), frame_kind, ver, payloads)?;
547                if !ids.is_empty() {
548                    RingEventFd::notify(&ring)?;
549                }
550                Ok(ids)
551            }
552            RingBacking::InMemory(rings) => Ok(rings.get_or_create::<T>().write_batch(
553                self.node_id(),
554                frame_kind,
555                ver,
556                payloads,
557            )),
558        }
559    }
560}
561
562impl std::fmt::Debug for Fleet {
563    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
564        f.debug_struct("Fleet")
565            .field("name", &self.inner.name)
566            .field("fleet_size", &self.inner.fleet_size)
567            .field("node_id", &self.inner.node_id)
568            .field("id_counters", &self.inner.id_counters.len())
569            .finish()
570    }
571}