Skip to main content

dora_runtime_api/
operator.rs

1use dora_core::{
2    config::{DataId, NodeId},
3    descriptor::{Descriptor, OperatorDefinition},
4};
5use dora_node_api::{DoraArray, EncodedSample, Event, MetadataParameters, SampleAllocator};
6use eyre::Result;
7use std::any::Any;
8use std::sync::{Arc, OnceLock};
9use tokio::sync::{mpsc::Sender, oneshot};
10
11/// Shared slot holding the node's [`SampleAllocator`].
12///
13/// The operator threads are started *before* `DoraNode::init` runs (the node
14/// only reports ready once every operator has initialized), so the allocator
15/// cannot be passed in at spawn time. The runtime fills this slot as soon as the
16/// node exists, which is strictly before any input — and therefore any output —
17/// can reach an operator.
18pub type SharedAllocator = Arc<OnceLock<SampleAllocator>>;
19
20/// An operator's side of the runtime: where to send events, and where to get
21/// output samples from.
22#[derive(Clone)]
23pub struct RuntimeHandle {
24    events_tx: Sender<OperatorEvent>,
25    allocator: SharedAllocator,
26}
27
28impl RuntimeHandle {
29    pub fn new(events_tx: Sender<OperatorEvent>, allocator: SharedAllocator) -> Self {
30        Self {
31            events_tx,
32            allocator,
33        }
34    }
35
36    /// Encode `array` into a dora-owned sample **on the calling (operator)
37    /// thread** and hand that to the runtime.
38    ///
39    /// This is the only place an `OperatorEvent::Output` is built, so the
40    /// ownership invariant that fixes dora-rs/dora#2742 holds for every operator
41    /// backend: `array` is borrowed, never sent, and the caller drops it while
42    /// it still holds whatever its language runtime needs to free it.
43    pub fn send_output(
44        &self,
45        output_id: DataId,
46        parameters: MetadataParameters,
47        array: &DoraArray,
48    ) -> Result<()> {
49        let allocator = self
50            .allocator
51            .get()
52            .ok_or_else(|| eyre::eyre!("cannot send an output before the node is initialized"))?;
53        let encoded = allocator.encode_arrow(array)?;
54        self.events_tx
55            .blocking_send(OperatorEvent::Output {
56                output_id,
57                parameters,
58                encoded,
59            })
60            .map_err(|_| eyre::eyre!("failed to send output to runtime"))
61    }
62
63    /// Report a lifecycle event (finished, error, panic) to the runtime.
64    pub fn report(&self, event: OperatorEvent) {
65        let _ = self.events_tx.blocking_send(event);
66    }
67}
68
69/// A language/ABI-specific operator backend.
70///
71/// Each runtime backend (shared-library, Python, WASM, third-party, …)
72/// implements this trait and hands it to [`crate::main`], which drives the
73/// language-neutral event loop. The implementation is invoked once, on the
74/// **main thread** (PyO3 and `libloading` both want a dedicated thread), and is
75/// responsible for loading the operator described by `operator` and running it
76/// until it stops.
77///
78/// The runtime↔operator contract is language-neutral: consume
79/// [`dora_node_api::Event`]s off `incoming_events`, emit outputs and lifecycle
80/// events through `handle`, and signal readiness (or an init failure) exactly
81/// once on `init_done`.
82///
83/// A backend that cannot host `operator`'s source kind must return an `Err`
84/// **without** signalling `init_done`, so the failure surfaces as a spawn error
85/// rather than a runtime hang (dora-rs/dora#2595).
86pub trait OperatorRunner {
87    fn run_operator(
88        &self,
89        node_id: &NodeId,
90        operator: OperatorDefinition,
91        incoming_events: flume::Receiver<Event>,
92        handle: RuntimeHandle,
93        init_done: oneshot::Sender<Result<()>>,
94        dataflow_descriptor: &Descriptor,
95    ) -> eyre::Result<RunnerGuard>;
96}
97
98/// A backend-owned resource that must stay alive until the runtime's event loop
99/// has joined.
100///
101/// The shared-library backend returns its loaded `libloading::Library` here.
102/// Since dora-rs/dora#2742 the main loop no longer holds Arrow arrays exported
103/// by the operator — outputs are encoded into dora-owned samples on the operator
104/// thread (see [`RuntimeHandle::send_output`]) — but other values can still
105/// carry `.so`-resident vtables across the channel, most notably an
106/// [`OperatorEvent::Panic`] payload. Unloading the library while the loop may
107/// still hold one dangles those, so [`crate::main`] binds the guard for the
108/// whole run and drops it last.
109///
110/// It is deliberately opaque (`Box<dyn Any>`) so `dora-runtime-api` stays
111/// language-neutral — it never needs to name `libloading` or any other
112/// backend-specific type. Backends with nothing to keep alive return `None`.
113pub type RunnerGuard = Option<Box<dyn Any>>;
114
115#[derive(Debug)]
116#[allow(clippy::large_enum_variant)]
117pub enum OperatorEvent {
118    Output {
119        output_id: DataId,
120        parameters: MetadataParameters,
121        /// The payload, already IPC-encoded into a dora-owned sample **by the
122        /// operator thread** (see [`RuntimeHandle::send_output`]).
123        ///
124        /// Deliberately not an `ArrayData`: an operator's array may be backed by
125        /// memory its own language runtime owns, and releasing that memory can
126        /// need the owning runtime (a `pyarrow` array over a numpy buffer takes
127        /// the GIL to drop). Freeing it here would let an operator that holds
128        /// the GIL stall the runtime's event loop — dora-rs/dora#2742.
129        encoded: EncodedSample,
130    },
131    Error(eyre::Error),
132    Panic(Box<dyn Any + Send>),
133    Finished {
134        reason: StopReason,
135    },
136}
137
138#[derive(Debug)]
139pub enum StopReason {
140    InputsClosed,
141    ExplicitStop,
142    ExplicitStopAll,
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    /// The #2742 invariant, at the level the fix actually lives: an operator's
150    /// array is *borrowed* by `send_output`, never sent. By the time the event
151    /// is on the channel the source payload has been released — on this thread,
152    /// where the operator still holds whatever its language runtime needs to
153    /// free it — and what crosses is a dora-owned `EncodedSample`.
154    #[test]
155    fn send_output_releases_the_operator_payload_before_it_crosses_the_channel() {
156        use arrow::buffer::Buffer;
157        use std::ptr::NonNull;
158        use std::sync::atomic::{AtomicBool, Ordering};
159
160        /// Stands in for a foreign owner of the payload — numpy behind pyarrow,
161        /// or a buffer owned by an operator's `.so`.
162        struct ForeignOwner {
163            released: Arc<AtomicBool>,
164            _backing: Vec<u8>,
165        }
166        impl Drop for ForeignOwner {
167            fn drop(&mut self) {
168                self.released.store(true, Ordering::SeqCst);
169            }
170        }
171
172        let backing = vec![0xCDu8; 4096];
173        let ptr = NonNull::new(backing.as_ptr() as *mut u8).expect("non-null");
174        let released = Arc::new(AtomicBool::new(false));
175        let owner = Arc::new(ForeignOwner {
176            released: released.clone(),
177            _backing: backing,
178        });
179        let buffer = unsafe { Buffer::from_custom_allocation(ptr, 4096, owner) };
180        let array = dora_node_api::DoraArray::from_array(arrow::array::make_array(
181            arrow::array::ArrayData::builder(dora_node_api::arrow_v59::datatypes::DataType::UInt8)
182                .len(4096)
183                .add_buffer(buffer)
184                .build()
185                .expect("valid UInt8 array"),
186        ));
187
188        let (events_tx, mut events_rx) = tokio::sync::mpsc::channel(1);
189        let allocator: SharedAllocator = Arc::new(OnceLock::new());
190        allocator
191            .set(SampleAllocator::heap())
192            .expect("fresh OnceLock");
193        let handle = RuntimeHandle::new(events_tx, allocator);
194
195        handle
196            .send_output(
197                DataId::from("out".to_string()),
198                MetadataParameters::default(),
199                &array,
200            )
201            .expect("send_output");
202
203        // The operator drops its array here, on its own thread.
204        drop(array);
205        assert!(
206            released.load(Ordering::SeqCst),
207            "the queued event must not keep the operator's payload alive"
208        );
209
210        match events_rx.try_recv().expect("an output event was queued") {
211            OperatorEvent::Output { encoded, .. } => {
212                assert!(
213                    encoded.as_bytes().len() > 4096,
214                    "the sample must hold the encoded payload, not a reference to it"
215                );
216            }
217            other => panic!("expected an Output event, got {other:?}"),
218        }
219    }
220
221    /// Sending before the node exists must fail with a clear message rather
222    /// than panic.
223    #[test]
224    fn send_output_before_node_init_is_a_clear_error() {
225        let (events_tx, _events_rx) = tokio::sync::mpsc::channel(1);
226        let handle = RuntimeHandle::new(events_tx, SharedAllocator::default());
227        let array = dora_node_api::DoraArray::from_array(arrow::array::NullArray::new(1));
228
229        let err = handle
230            .send_output(
231                DataId::from("out".to_string()),
232                MetadataParameters::default(),
233                &array,
234            )
235            .expect_err("no allocator yet");
236        assert!(
237            err.to_string().contains("before the node is initialized"),
238            "unexpected error: {err}"
239        );
240    }
241}