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}