Skip to main content

dora_node_api_c/
lib.rs

1#![deny(unsafe_op_in_unsafe_fn)]
2
3use arrow_array::UInt8Array;
4use dora_node_api::{DoraNode, Event, EventStream, arrow_v59::array::AsArray};
5use eyre::Context;
6use std::{
7    ffi::{c_int, c_void},
8    ptr, slice,
9};
10
11pub const HEADER_NODE_API: &str = include_str!("../node_api.h");
12
13struct DoraContext {
14    node: &'static mut DoraNode,
15    events: EventStream,
16}
17
18/// Initializes a dora context from the environment variables that were set by
19/// the dora-coordinator.
20///
21/// Returns a pointer to the dora context on success. This pointer can be
22/// used to call dora API functions that expect a `context` argument. Any
23/// other use is prohibited. To free the dora context when it is no longer
24/// needed, use the [`free_dora_context`] function.
25///
26/// On error, a null pointer is returned.
27#[unsafe(no_mangle)]
28pub extern "C" fn init_dora_context_from_env() -> *mut c_void {
29    let context = || {
30        let (node, events) = DoraNode::init_from_env()?;
31        let node = Box::leak(Box::new(node));
32        Result::<_, eyre::Report>::Ok(DoraContext { node, events })
33    };
34    let context = match context().context("failed to initialize node") {
35        Ok(n) => n,
36        Err(err) => {
37            let err: eyre::Error = err;
38            tracing::error!("{err:?}");
39            return ptr::null_mut();
40        }
41    };
42
43    Box::into_raw(Box::new(context)).cast()
44}
45
46/// Frees the given dora context.
47///
48/// ## Safety
49///
50/// Only pointers created through [`init_dora_context_from_env`] are allowed
51/// as arguments. Each context pointer must be freed exactly once. After
52/// freeing, the pointer must not be used anymore.
53#[unsafe(no_mangle)]
54pub unsafe extern "C" fn free_dora_context(context: *mut c_void) {
55    let context: Box<DoraContext> = unsafe { Box::from_raw(context.cast()) };
56    // drop all fields except for `node`
57    let DoraContext { node, .. } = *context;
58    // convert the `'static` reference back to a Box, then drop it
59    let _ = unsafe { Box::from_raw(node as *const DoraNode as *mut DoraNode) };
60}
61
62/// Waits for the next incoming event for the node.
63///
64/// Returns a pointer to the event on success. This pointer must not be used
65/// directly. Instead, use the `read_dora_event_*` functions to read out the
66/// type and payload of the event. When the event is not needed anymore, use
67/// [`free_dora_event`] to free it again.
68///
69/// Returns a null pointer when all event streams were closed. This means that
70/// no more event will be available. Nodes typically react by stopping.
71///
72/// ## Safety
73///
74/// The `context` argument must be a dora context created through
75/// [`init_dora_context_from_env`]. The context must be still valid, i.e., not
76/// freed yet.
77#[unsafe(no_mangle)]
78pub unsafe extern "C" fn dora_next_event(context: *mut c_void) -> *mut c_void {
79    let context: &mut DoraContext = unsafe { &mut *context.cast() };
80    match context.events.recv() {
81        Some(event) => Box::into_raw(Box::new(event)).cast(),
82        None => ptr::null_mut(),
83    }
84}
85
86/// Reads out the type of the given event.
87///
88/// ## Safety
89///
90/// The `event` argument must be a dora event received through
91/// [`dora_next_event`]. The event must be still valid, i.e., not
92/// freed yet.
93#[unsafe(no_mangle)]
94pub unsafe extern "C" fn read_dora_event_type(event: *const ()) -> EventType {
95    let event: &Event = unsafe { &*event.cast() };
96    match event {
97        Event::Stop(_) => EventType::Stop,
98        Event::Input { .. } => EventType::Input,
99        Event::InputClosed { .. } => EventType::InputClosed,
100        Event::Error(_) => EventType::Error,
101        _ => EventType::Unknown,
102    }
103}
104
105#[repr(C)]
106pub enum EventType {
107    Stop,
108    Input,
109    InputClosed,
110    Error,
111    Unknown,
112}
113
114/// Reads out the ID of the given input event.
115///
116/// Writes the `out_ptr` and `out_len` with the start pointer and length of the
117/// ID string of the input. The ID is guaranteed to be valid UTF-8.
118///
119/// Writes a null pointer and length `0` if the given event is not an input event.
120///
121/// ## Safety
122///
123/// - The `event` argument must be a dora event received through
124///   [`dora_next_event`]. The event must be still valid, i.e., not
125///   freed yet. The returned `out_ptr` must not be used after
126///   freeing the `event`, since it points directly into the event's
127///   memory.
128///
129/// - Note: `Out_ptr` is not a null-terminated string. The length of the string
130///   is given by `out_len`.
131#[unsafe(no_mangle)]
132pub unsafe extern "C" fn read_dora_input_id(
133    event: *const (),
134    out_ptr: *mut *const u8,
135    out_len: *mut usize,
136) {
137    let event: &Event = unsafe { &*event.cast() };
138    match event {
139        Event::Input { id, .. } => {
140            let id = id.as_str().as_bytes();
141            let ptr = id.as_ptr();
142            let len = id.len();
143            unsafe {
144                *out_ptr = ptr;
145                *out_len = len;
146            }
147        }
148        _ => unsafe {
149            *out_ptr = ptr::null();
150            *out_len = 0;
151        },
152    }
153}
154
155/// Reads out the data of the given input event.
156///
157/// Writes the `out_ptr` and `out_len` with the start pointer and length of the
158/// input's data array. The data array is a raw byte array, whose format
159/// depends on the source operator/node.
160///
161/// Writes a null pointer and length `0` if the given event is not an input event
162/// or when an input event has no associated data.
163///
164/// The raw-byte C API can only expose `UInt8` (and empty `Null`) payloads.
165/// A payload of any other Arrow type (for example an `Int32`/`Float` array
166/// from another node) also yields a null pointer and length `0` (and logs an
167/// error), so a null result is not by itself proof that the message carried no
168/// data.
169///
170/// ## Safety
171///
172/// The `event` argument must be a dora event received through
173/// [`dora_next_event`]. The event must be still valid, i.e., not
174/// freed yet. The returned `out_ptr` must not be used after
175/// freeing the `event`, since it points directly into the event's
176/// memory.
177#[unsafe(no_mangle)]
178pub unsafe extern "C" fn read_dora_input_data(
179    event: *const (),
180    out_ptr: *mut *const u8,
181    out_len: *mut usize,
182) {
183    let event: &Event = unsafe { &*event.cast() };
184    match event {
185        // The payload is decoded from a self-describing Arrow IPC stream, so the
186        // type is read from the array itself.
187        Event::Input { data, .. } => match data.as_array().data_type() {
188            dora_node_api::arrow_v59::datatypes::DataType::UInt8 => {
189                let array: &UInt8Array = data.as_array().as_primitive();
190                let values = array.values();
191                // A zero-length payload carries no data. `values().as_ptr()`
192                // returns a non-null, dangling-but-aligned pointer for an empty
193                // buffer, so report it as "no data" (null pointer, length 0) to
194                // honor the documented `out_ptr == NULL` contract and match the
195                // `Null` arm below; otherwise expose the actual buffer.
196                let (ptr, len) = if values.is_empty() {
197                    (ptr::null(), 0)
198                } else {
199                    (values.as_ptr(), values.len())
200                };
201                unsafe {
202                    *out_ptr = ptr;
203                    *out_len = len;
204                }
205            }
206            dora_node_api::arrow_v59::datatypes::DataType::Null => unsafe {
207                *out_ptr = ptr::null();
208                *out_len = 0;
209            },
210            other => {
211                // The raw-byte C API only supports UInt8 payloads. For any
212                // other Arrow type (a routine cross-language case, e.g. an
213                // Int32/Float payload from another node) we cannot expose a
214                // raw `u8` view without an Arrow-FFI escape hatch. Instead of
215                // aborting the process via `todo!()`, signal "no data" to the
216                // caller (out_ptr == NULL, out_len == 0) and log the type.
217                tracing::error!(
218                    "read_dora_input_data: unsupported input arrow type {other:?}; \
219                     only UInt8 is supported by the raw-byte C API"
220                );
221                unsafe {
222                    *out_ptr = ptr::null();
223                    *out_len = 0;
224                }
225            }
226        },
227        _ => unsafe {
228            *out_ptr = ptr::null();
229            *out_len = 0;
230        },
231    }
232}
233
234/// Reads out the timestamp of the given input event from metadata.
235///
236/// Returns `0` if the given event is not an input event.
237///
238/// ## Safety
239///
240/// The `event` argument must be a dora event received through
241/// [`dora_next_event`]. The event must be still valid, i.e., not
242/// freed yet.
243#[unsafe(no_mangle)]
244pub unsafe extern "C" fn read_dora_input_timestamp(event: *const ()) -> core::ffi::c_ulonglong {
245    let event: &Event = unsafe { &*event.cast() };
246    match event {
247        Event::Input { metadata, .. } => metadata.timestamp().get_time().as_u64(),
248        _ => 0,
249    }
250}
251
252/// Frees the given dora event.
253///
254/// ## Safety
255///
256/// Only pointers created through [`dora_next_event`] are allowed
257/// as arguments. Each context pointer must be freed exactly once. After
258/// freeing, the pointer and all derived pointers must not be used anymore.
259/// This also applies to the `read_dora_event_*` functions, which return
260/// pointers into the original event structure.
261#[unsafe(no_mangle)]
262pub unsafe extern "C" fn free_dora_event(event: *mut c_void) {
263    let _: Box<Event> = unsafe { Box::from_raw(event.cast()) };
264}
265
266/// Sends the given output to subscribed dora nodes/operators.
267///
268/// The `id_ptr` and `id_len` fields must be the start pointer and length of an
269/// UTF8-encoded string. The ID string must correspond to one of the node's
270/// outputs specified in the dataflow YAML file.
271///
272/// The `data_ptr` and `data_len` fields must be the start pointer and length
273/// a byte array. The dora API sends this data as-is, without any processing.
274///
275/// ## Safety
276///
277/// - The `id_ptr` and `id_len` fields must be the start pointer and length of an
278///   UTF8-encoded string.
279/// - The `data_ptr` and `data_len` fields must be the start pointer and length
280///   a byte array.
281#[unsafe(no_mangle)]
282pub unsafe extern "C" fn dora_send_output(
283    context: *mut c_void,
284    id_ptr: *const u8,
285    id_len: usize,
286    data_ptr: *const u8,
287    data_len: usize,
288) -> c_int {
289    match unsafe { try_send_output(context, id_ptr, id_len, data_ptr, data_len) } {
290        Ok(()) => 0,
291        Err(err) => {
292            tracing::error!("{err:?}");
293            -1
294        }
295    }
296}
297
298unsafe fn try_send_output(
299    context: *mut c_void,
300    id_ptr: *const u8,
301    id_len: usize,
302    data_ptr: *const u8,
303    data_len: usize,
304) -> eyre::Result<()> {
305    if context.is_null() || id_ptr.is_null() {
306        eyre::bail!("null pointer passed to dora_send_output");
307    }
308    let context: &mut DoraContext = unsafe { &mut *context.cast() };
309    let id = std::str::from_utf8(unsafe { slice::from_raw_parts(id_ptr, id_len) })?;
310    // Parse via `FromStr` instead of the panicking `From<String>`: an invalid
311    // id (e.g. a typo containing a space) must surface as a `-1` return, not
312    // unwind across the `extern "C"` boundary and abort the node process.
313    // `DataId::from(String)` is documented as panicking on invalid characters
314    // (see `libraries/message/src/id.rs`, `# Panics`).
315    let output_id = id
316        .parse::<dora_node_api::dora_core::config::DataId>()
317        .map_err(|e| eyre::eyre!("invalid output id `{id}`: {e}"))?;
318    let data = unsafe { data_slice(data_ptr, data_len) }?;
319    Ok(context
320        .node
321        .send_output_raw(output_id, Default::default(), data.len(), |out| {
322            out.copy_from_slice(data);
323        })?)
324}
325
326/// Resolve a C `(ptr, len)` payload pair into a slice, accepting the standard
327/// `(NULL, 0)` idiom for an empty message.
328///
329/// `slice::from_raw_parts` is UB when the pointer is null even for length 0
330/// (the pointer must be non-null and well-aligned regardless of length), so an
331/// empty payload must be handled without dereferencing the pointer. This
332/// mirrors the operator FFI (`dora_send_operator_output`), which already
333/// accepts `(NULL, 0)`; previously a C node emitting an empty output via the
334/// idiomatic `dora_send_output(ctx, id, id_len, NULL, 0)` was rejected.
335///
336/// # Safety
337///
338/// When `data_len > 0`, `data_ptr` must point to `data_len` initialized bytes
339/// valid for the duration of the returned borrow.
340unsafe fn data_slice<'a>(data_ptr: *const u8, data_len: usize) -> eyre::Result<&'a [u8]> {
341    if data_len == 0 {
342        Ok(&[])
343    } else if data_ptr.is_null() {
344        eyre::bail!("dora_send_output: data_ptr is null with non-zero data_len");
345    } else {
346        Ok(unsafe { slice::from_raw_parts(data_ptr, data_len) })
347    }
348}
349
350/// Sends a structured log message from a C node.
351///
352/// The `level_ptr`/`level_len` fields must point to a UTF-8 string containing
353/// one of: "error", "warn", "info", "debug", "trace".
354///
355/// The `msg_ptr`/`msg_len` fields must point to a UTF-8 string with the log
356/// message.
357///
358/// Returns 0 on success, -1 on error.
359///
360/// ## Safety
361///
362/// - The `context` argument must be a valid dora context from
363///   [`init_dora_context_from_env`].
364/// - The pointer/length pairs must describe valid UTF-8 byte slices.
365#[unsafe(no_mangle)]
366pub unsafe extern "C" fn dora_log(
367    context: *mut c_void,
368    level_ptr: *const u8,
369    level_len: usize,
370    msg_ptr: *const u8,
371    msg_len: usize,
372) -> c_int {
373    match unsafe { try_log(context, level_ptr, level_len, msg_ptr, msg_len) } {
374        Ok(()) => 0,
375        Err(err) => {
376            tracing::error!("{err:?}");
377            -1
378        }
379    }
380}
381
382unsafe fn try_log(
383    context: *mut c_void,
384    level_ptr: *const u8,
385    level_len: usize,
386    msg_ptr: *const u8,
387    msg_len: usize,
388) -> eyre::Result<()> {
389    if context.is_null() || level_ptr.is_null() || msg_ptr.is_null() {
390        eyre::bail!("null pointer passed to dora_log");
391    }
392    let context: &mut DoraContext = unsafe { &mut *context.cast() };
393    let level = std::str::from_utf8(unsafe { slice::from_raw_parts(level_ptr, level_len) })?;
394    let message = std::str::from_utf8(unsafe { slice::from_raw_parts(msg_ptr, msg_len) })?;
395    context.node.log(level, message, None);
396    Ok(())
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use dora_node_api::{
403        DoraArray, Metadata,
404        arrow_v59::array::{ArrayRef, Int32Array},
405        uhlc::HLC,
406    };
407    use std::sync::Arc;
408
409    /// Regression test for #2030: a non-UInt8 input (e.g. Int32 from another
410    /// node) must not abort the process. The caller should instead observe
411    /// `out_ptr == NULL` and `out_len == 0`.
412    #[test]
413    fn read_dora_input_data_non_uint8_returns_null() {
414        let array: ArrayRef = Arc::new(Int32Array::from(vec![1, 2, 3]));
415        let event = Event::Input {
416            id: "my_input".into(),
417            metadata: Metadata::new(HLC::default().new_timestamp()),
418            data: DoraArray::from(array),
419        };
420        let event_ptr: *const () = (&event as *const Event).cast();
421
422        // Seed the out-params with non-null sentinels so we can prove the
423        // function actually overwrites them to null/0 (rather than aborting).
424        let sentinel: u8 = 0;
425        let mut out_ptr: *const u8 = &sentinel;
426        let mut out_len: usize = 42;
427        unsafe {
428            read_dora_input_data(event_ptr, &mut out_ptr, &mut out_len);
429        }
430
431        assert!(
432            out_ptr.is_null(),
433            "expected null out_ptr for non-UInt8 input"
434        );
435        assert_eq!(out_len, 0, "expected zero out_len for non-UInt8 input");
436    }
437
438    /// An empty `UInt8` input payload (a node emitting a zero-length byte
439    /// array) must report "no data" the same way the `Null` arm does: a null
440    /// `out_ptr` and `out_len == 0`. `UInt8Array::values().as_ptr()` returns a
441    /// non-null dangling pointer for an empty buffer, so without the explicit
442    /// zero-length guard a caller following the documented `out_ptr == NULL`
443    /// convention would misread the empty message as carrying data.
444    #[test]
445    fn read_dora_input_data_empty_uint8_returns_null() {
446        let array: ArrayRef = Arc::new(UInt8Array::from(Vec::<u8>::new()));
447        let event = Event::Input {
448            id: "my_input".into(),
449            metadata: Metadata::new(HLC::default().new_timestamp()),
450            data: DoraArray::from(array),
451        };
452        let event_ptr: *const () = (&event as *const Event).cast();
453
454        // Seed with non-null sentinels so we can prove the function overwrites them.
455        let sentinel: u8 = 0;
456        let mut out_ptr: *const u8 = &sentinel;
457        let mut out_len: usize = 42;
458        unsafe {
459            read_dora_input_data(event_ptr, &mut out_ptr, &mut out_len);
460        }
461
462        assert!(
463            out_ptr.is_null(),
464            "expected null out_ptr for an empty UInt8 payload"
465        );
466        assert_eq!(
467            out_len, 0,
468            "expected zero out_len for an empty UInt8 payload"
469        );
470    }
471
472    #[test]
473    fn data_slice_accepts_null_zero_idiom() {
474        // A C node sending an empty output: `dora_send_output(.., NULL, 0)`.
475        let data =
476            unsafe { data_slice(std::ptr::null(), 0) }.expect("(NULL, 0) is a valid empty payload");
477        assert!(data.is_empty());
478    }
479
480    #[test]
481    fn data_slice_rejects_null_with_nonzero_len() {
482        let err = unsafe { data_slice(std::ptr::null(), 4) }
483            .expect_err("null pointer with a non-zero length must be rejected");
484        assert!(err.to_string().contains("null"), "got: {err}");
485    }
486
487    #[test]
488    fn data_slice_reads_valid_pointer() {
489        let buf = [1u8, 2, 3, 4];
490        let data =
491            unsafe { data_slice(buf.as_ptr(), buf.len()) }.expect("valid pointer yields a slice");
492        assert_eq!(data, &buf);
493    }
494}