Skip to main content

orbit_invoke/
lib.rs

1//! Bounded, fleet-local invocation transport over a dedicated Orbit ring.
2//!
3//! This crate owns transport facts only: invocation identity, operation
4//! routing, opaque payload bytes, retained cursors and loss reporting. It does
5//! not know what an operation runs, who runs it, or what happens when it
6//! fails. Those belong to the runtime adapter consuming an invocation.
7//!
8//! Publication proves only that a frame was committed. The ring is not a
9//! durable queue and provides no acknowledgement, claim, retry, response or
10//! exactly-once guarantee; cursors report overwritten frames as lag. A claim
11//! or result protocol must add its own authoritative state rather than change
12//! the meaning of this request ring.
13//!
14//! The `tokio` feature adds [`InvocationSubscription`], an async adapter that
15//! waits on the ring's native readiness fd where the platform provides one and
16//! polls otherwise.
17
18#[cfg(feature = "tokio")]
19use std::collections::VecDeque;
20use std::sync::Arc;
21#[cfg(feature = "tokio")]
22use std::time::Duration;
23
24use bytes::{BufMut, Bytes, BytesMut};
25#[cfg(any(
26    target_os = "linux",
27    target_os = "freebsd",
28    target_os = "macos"
29))]
30use orbit_core::RingEventFd;
31use orbit_core::fleet::FleetLaneCursor;
32use orbit_core::{Fleet, NetId64, NodeId, OrbitEpoch, OrbitTyped, RingSpec};
33
34pub type Result<T, E = Error> = std::result::Result<T, E>;
35
36/// Ring kind reserved for Orbit invocations.
37pub const INVOCATION_RING_KIND: u8 = 241;
38/// Number of retained invocation frames in each fleet-node lane.
39pub const INVOCATION_RING_CAPACITY: usize = orbit_core::compile::usize_from_env(
40    option_env!("ORBIT_INVOKE_RING_CAPACITY"),
41    256,
42);
43/// Maximum bytes available to one invocation envelope.
44pub const INVOCATION_RING_PAYLOAD_CAPACITY: usize =
45    orbit_core::compile::usize_from_env(
46        option_env!("ORBIT_INVOKE_RING_PAYLOAD_CAPACITY"),
47        8_192,
48    );
49pub const INVOCATION_RING_SPEC: RingSpec = RingSpec::per_node(
50    INVOCATION_RING_CAPACITY,
51    INVOCATION_RING_PAYLOAD_CAPACITY,
52);
53pub const INVOCATION_PAYLOAD_MAX: usize = INVOCATION_RING_SPEC.payload_capacity;
54
55const FRAME_KIND_INVOCATION: u8 = 1;
56const HEADER_LEN: usize = 2 + 4;
57#[cfg(feature = "tokio")]
58const FALLBACK_POLL_INTERVAL: Duration = Duration::from_millis(10);
59
60const _: () = assert!(INVOCATION_RING_CAPACITY.is_power_of_two());
61const _: () = assert!(INVOCATION_RING_PAYLOAD_CAPACITY >= HEADER_LEN);
62const _: () = assert!(INVOCATION_RING_PAYLOAD_CAPACITY <= u32::MAX as usize);
63
64#[derive(Clone, Debug)]
65struct InvocationRecord;
66
67impl OrbitTyped for InvocationRecord {
68    const KIND: u8 = INVOCATION_RING_KIND;
69    const RING_SPEC: RingSpec = INVOCATION_RING_SPEC;
70}
71
72/// Identity assigned when an invocation is committed to the ring.
73///
74/// Its embedded node is the submitting fleet node. It identifies the
75/// invocation, not the executor that may later handle it.
76#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
77pub struct InvocationId(NetId64);
78
79impl InvocationId {
80    pub const fn from_net_id(id: NetId64) -> Self {
81        Self(id)
82    }
83
84    pub const fn net_id(self) -> NetId64 {
85        self.0
86    }
87
88    pub const fn node(self) -> u16 {
89        self.0.node()
90    }
91}
92
93impl std::fmt::Display for InvocationId {
94    fn fmt(
95        &self,
96        formatter: &mut std::fmt::Formatter<'_>,
97    ) -> std::fmt::Result {
98        self.0.fmt(formatter)
99    }
100}
101
102/// One retained invocation with an opaque runtime-owned payload.
103#[derive(Clone, Debug, Eq, PartialEq)]
104pub struct Invocation {
105    pub id: InvocationId,
106    pub operation: String,
107    pub payload: Vec<u8>,
108    /// Publication time carried by the Orbit frame version.
109    pub submitted_at: OrbitEpoch,
110}
111
112/// A runtime-owned typed payload codec.
113///
114/// Orbit selects no serializer. Implementations may use a compact binary
115/// schema, a language value codec or an existing framework wire format.
116pub trait InvocationCodec: Sized {
117    const OPERATION: &'static str;
118
119    fn encode_invocation(&self) -> std::result::Result<Vec<u8>, String>;
120
121    fn decode_invocation(payload: &[u8]) -> std::result::Result<Self, String>;
122}
123
124impl Invocation {
125    pub fn decode<C: InvocationCodec>(&self) -> Result<C> {
126        if self.operation != C::OPERATION {
127            return Err(Error::OperationMismatch {
128                expected: C::OPERATION,
129                actual: self.operation.clone(),
130            });
131        }
132        C::decode_invocation(&self.payload).map_err(Error::Codec)
133    }
134}
135
136/// Result of advancing one caller-owned invocation cursor.
137#[derive(Clone, Debug, Default, Eq, PartialEq)]
138pub struct InvocationPoll {
139    pub invocations: Vec<Invocation>,
140    /// Frames no longer available or invalid for this invocation ring.
141    pub lagged: u64,
142}
143
144impl InvocationPoll {
145    pub fn is_empty(&self) -> bool {
146        self.invocations.is_empty() && self.lagged == 0
147    }
148}
149
150/// Caller-owned positions for every fleet-node lane.
151#[derive(Clone, Debug, PartialEq, Eq)]
152pub struct InvocationCursor {
153    inner: FleetLaneCursor,
154}
155
156/// Cheap fleet-local handle for publishing and polling invocations.
157#[derive(Clone)]
158pub struct InvocationBus {
159    fleet: Arc<Fleet>,
160}
161
162impl InvocationBus {
163    pub fn new(fleet: Arc<Fleet>) -> Self {
164        Self { fleet }
165    }
166
167    pub fn node_id(&self) -> NodeId {
168        self.fleet.node_id()
169    }
170
171    pub fn cursor_at_head(&self) -> InvocationCursor {
172        InvocationCursor {
173            inner: self.fleet.lane_cursor_at_head::<InvocationRecord>(),
174        }
175    }
176
177    pub fn cursor_from_start(&self) -> InvocationCursor {
178        InvocationCursor {
179            inner: self.fleet.lane_cursor_from_start::<InvocationRecord>(),
180        }
181    }
182
183    /// Subscribe to future invocations for one operation.
184    ///
185    /// Every subscription owns an independent cursor. This is filtering, not
186    /// work claiming: executor placement remains the caller's responsibility.
187    #[cfg(feature = "tokio")]
188    pub fn subscribe(
189        self: &Arc<Self>,
190        operation: impl Into<String>,
191    ) -> Result<InvocationSubscription> {
192        let operation = operation.into();
193        validate_operation(&operation)?;
194        #[cfg(any(
195            target_os = "linux",
196            target_os = "freebsd",
197            target_os = "macos"
198        ))]
199        let event_fd = self.subscription_event_fd()?;
200        Ok(InvocationSubscription {
201            cursor: self.cursor_at_head(),
202            bus: Arc::clone(self),
203            operation,
204            pending: VecDeque::new(),
205            #[cfg(any(
206                target_os = "linux",
207                target_os = "freebsd",
208                target_os = "macos"
209            ))]
210            event_fd,
211        })
212    }
213
214    /// Clear the invocation ring during quiescent owner boot.
215    pub fn reset_ring(&self) -> Result<()> {
216        self.fleet.reset_ring::<InvocationRecord>().map_err(Error::Io)
217    }
218
219    /// Create this process' readiness fd for the invocation ring.
220    #[cfg(any(
221        target_os = "linux",
222        target_os = "freebsd",
223        target_os = "macos"
224    ))]
225    pub fn event_fd(&self) -> Result<RingEventFd> {
226        self.fleet.ring_event_fd::<InvocationRecord>().map_err(Error::Io)
227    }
228
229    #[cfg(all(
230        feature = "tokio",
231        any(target_os = "linux", target_os = "freebsd", target_os = "macos")
232    ))]
233    fn subscription_event_fd(
234        &self
235    ) -> Result<Option<tokio::io::unix::AsyncFd<RingEventFd>>> {
236        if !self.fleet.is_shm() {
237            return Ok(None);
238        }
239        match self.event_fd() {
240            Ok(event_fd) => Ok(Some(
241                // SAFETY: RingEventFd owns its stable descriptor until drop;
242                // the subscription only drains it, without replacing it.
243                unsafe { tokio::io::unix::AsyncFd::register(event_fd) }
244                    .map_err(std::io::Error::from)
245                    .map_err(Error::Io)?,
246            )),
247            #[cfg(target_os = "macos")]
248            Err(Error::Io(error))
249                if error.kind() == std::io::ErrorKind::Unsupported =>
250            {
251                Ok(None)
252            }
253            Err(error) => Err(error),
254        }
255    }
256
257    /// Commit a raw invocation. Success means publication, not execution.
258    pub fn submit(
259        &self,
260        operation: &str,
261        payload: &[u8],
262    ) -> Result<InvocationId> {
263        validate_operation(operation)?;
264        let submitted_at = OrbitEpoch::now();
265        let frame = encode_frame(operation.as_bytes(), payload)?;
266        #[cfg(any(
267            target_os = "linux",
268            target_os = "freebsd",
269            target_os = "macos"
270        ))]
271        let id = self
272            .fleet
273            .publish_notified::<InvocationRecord>(
274                FRAME_KIND_INVOCATION,
275                submitted_at.as_unix_ms(),
276                frame,
277            )
278            .map_err(Error::Io)?;
279        #[cfg(not(any(
280            target_os = "linux",
281            target_os = "freebsd",
282            target_os = "macos"
283        )))]
284        let id = self.fleet.publish::<InvocationRecord>(
285            FRAME_KIND_INVOCATION,
286            submitted_at.as_unix_ms(),
287            frame,
288        );
289        Ok(InvocationId(id))
290    }
291
292    pub fn submit_typed<C: InvocationCodec>(
293        &self,
294        invocation: &C,
295    ) -> Result<InvocationId> {
296        let payload = invocation.encode_invocation().map_err(Error::Codec)?;
297        self.submit(C::OPERATION, &payload)
298    }
299
300    /// Advance every node lane once. Ordering is lane-local, not fleet-global.
301    pub fn poll(
302        &self,
303        cursor: &mut InvocationCursor,
304    ) -> InvocationPoll {
305        let poll = self.fleet.poll_lanes::<InvocationRecord>(&mut cursor.inner);
306        let mut lagged = poll.loss.total();
307        let mut invocations = Vec::with_capacity(poll.frames.len());
308        for frame in poll.frames {
309            if frame.kind != FRAME_KIND_INVOCATION {
310                lagged = lagged.saturating_add(1);
311                continue;
312            }
313            let Some(decoded) = decode_frame(&frame.payload) else {
314                lagged = lagged.saturating_add(1);
315                continue;
316            };
317            let Ok(operation) = std::str::from_utf8(decoded.operation) else {
318                lagged = lagged.saturating_add(1);
319                continue;
320            };
321            invocations.push(Invocation {
322                id: InvocationId(frame.id),
323                operation: operation.to_owned(),
324                payload: decoded.payload.to_vec(),
325                submitted_at: OrbitEpoch::from_unix_ms(frame.ver),
326            });
327        }
328        InvocationPoll { invocations, lagged }
329    }
330
331    /// Poll one operation while still advancing past every ring frame.
332    pub fn poll_operation(
333        &self,
334        cursor: &mut InvocationCursor,
335        operation: &str,
336    ) -> InvocationPoll {
337        let mut poll = self.poll(cursor);
338        poll.invocations.retain(|invocation| invocation.operation == operation);
339        poll
340    }
341}
342
343/// Async readiness adapter over one operation-filtered invocation cursor.
344#[cfg(feature = "tokio")]
345pub struct InvocationSubscription {
346    bus: Arc<InvocationBus>,
347    operation: String,
348    cursor: InvocationCursor,
349    pending: VecDeque<Invocation>,
350    #[cfg(any(
351        target_os = "linux",
352        target_os = "freebsd",
353        target_os = "macos"
354    ))]
355    event_fd: Option<tokio::io::unix::AsyncFd<RingEventFd>>,
356}
357
358#[cfg(feature = "tokio")]
359impl InvocationSubscription {
360    /// Wait for the next retained invocation.
361    ///
362    /// Cancellation is deliberately external: callers may select this future
363    /// against the lifecycle token they own without teaching Orbit about that
364    /// lifecycle.
365    pub async fn receive(&mut self) -> Result<Invocation> {
366        loop {
367            if let Some(invocation) = self.pending.pop_front() {
368                return Ok(invocation);
369            }
370
371            let poll =
372                self.bus.poll_operation(&mut self.cursor, &self.operation);
373            self.pending.extend(poll.invocations);
374            if poll.lagged > 0 {
375                return Err(Error::Lagged(poll.lagged));
376            }
377            if let Some(invocation) = self.pending.pop_front() {
378                return Ok(invocation);
379            }
380
381            #[cfg(any(
382                target_os = "linux",
383                target_os = "freebsd",
384                target_os = "macos"
385            ))]
386            if let Some(event_fd) = &self.event_fd {
387                let mut ready = event_fd.readable().await.map_err(Error::Io)?;
388                event_fd.get_ref().drain().map_err(Error::Io)?;
389                ready.clear_ready();
390                continue;
391            }
392
393            tokio::time::sleep(FALLBACK_POLL_INTERVAL).await;
394        }
395    }
396}
397
398#[derive(Debug)]
399pub enum Error {
400    EmptyOperation,
401    OperationTooLong(usize),
402    FrameTooLarge { operation_len: usize, payload_len: usize, max: usize },
403    OperationMismatch { expected: &'static str, actual: String },
404    Lagged(u64),
405    Codec(String),
406    Io(std::io::Error),
407}
408
409impl std::fmt::Display for Error {
410    fn fmt(
411        &self,
412        formatter: &mut std::fmt::Formatter<'_>,
413    ) -> std::fmt::Result {
414        match self {
415            Self::EmptyOperation => {
416                formatter.write_str("invocation operation cannot be empty")
417            }
418            Self::OperationTooLong(len) => {
419                write!(
420                    formatter,
421                    "invocation operation is too long: len={len} max=65535"
422                )
423            }
424            Self::FrameTooLarge { operation_len, payload_len, max } => write!(
425                formatter,
426                "invocation frame is too large: operation_len={operation_len} payload_len={payload_len} max={max}"
427            ),
428            Self::OperationMismatch { expected, actual } => write!(
429                formatter,
430                "invocation operation mismatch: expected {expected}, got {actual}"
431            ),
432            Self::Lagged(count) => {
433                write!(
434                    formatter,
435                    "invocation subscription lagged by {count} frame(s)"
436                )
437            }
438            Self::Codec(error) => {
439                write!(formatter, "invocation codec failed: {error}")
440            }
441            Self::Io(error) => {
442                write!(formatter, "invocation ring io error: {error}")
443            }
444        }
445    }
446}
447
448impl std::error::Error for Error {
449    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
450        match self {
451            Self::Io(error) => Some(error),
452            _ => None,
453        }
454    }
455}
456
457struct DecodedFrame<'a> {
458    operation: &'a [u8],
459    payload: &'a [u8],
460}
461
462fn validate_operation(operation: &str) -> Result<()> {
463    if operation.trim().is_empty() {
464        return Err(Error::EmptyOperation);
465    }
466    if operation.len() > usize::from(u16::MAX) {
467        return Err(Error::OperationTooLong(operation.len()));
468    }
469    Ok(())
470}
471
472fn encode_frame(
473    operation: &[u8],
474    payload: &[u8],
475) -> Result<Bytes> {
476    let total = HEADER_LEN
477        .checked_add(operation.len())
478        .and_then(|len| len.checked_add(payload.len()))
479        .ok_or(Error::FrameTooLarge {
480            operation_len: operation.len(),
481            payload_len: payload.len(),
482            max: INVOCATION_PAYLOAD_MAX,
483        })?;
484    if operation.len() > usize::from(u16::MAX)
485        || payload.len() > u32::MAX as usize
486        || total > INVOCATION_PAYLOAD_MAX
487    {
488        return Err(Error::FrameTooLarge {
489            operation_len: operation.len(),
490            payload_len: payload.len(),
491            max: INVOCATION_PAYLOAD_MAX,
492        });
493    }
494    let mut frame = BytesMut::with_capacity(total);
495    frame.put_u16_le(operation.len() as u16);
496    frame.put_u32_le(payload.len() as u32);
497    frame.put_slice(operation);
498    frame.put_slice(payload);
499    Ok(frame.freeze())
500}
501
502fn decode_frame(frame: &Bytes) -> Option<DecodedFrame<'_>> {
503    if frame.len() < HEADER_LEN {
504        return None;
505    }
506    let operation_len =
507        u16::from_le_bytes(frame[0..2].try_into().ok()?) as usize;
508    let payload_len = u32::from_le_bytes(frame[2..6].try_into().ok()?) as usize;
509    let operation_end = HEADER_LEN.checked_add(operation_len)?;
510    let payload_end = operation_end.checked_add(payload_len)?;
511    if payload_end != frame.len() {
512        return None;
513    }
514    Some(DecodedFrame {
515        operation: &frame[HEADER_LEN..operation_end],
516        payload: &frame[operation_end..payload_end],
517    })
518}