Skip to main content
A node is a Rust struct annotated with #[cerulion_node(...)] paired with an adjacent impl block annotated with #[cerulion_node_impl]. Together they turn your struct into a node that cerulion node build compiles and cerulion graph run loads — you never call the generated code yourself. Import everything from the prelude:
The prelude re-exports cerulion_node, cerulion_node_impl, NodeError, the event types, FillFrom/SliceSource, and the transport types.

The two macros

The macros are used as a pair:
  • #[cerulion_node(...)] on the struct declares the node type, its ports (via field attributes), and its trigger policy.
  • #[cerulion_node_impl] on an adjacent impl <Name> { … } block lets tick() read inputs and write outputs through ordinary field access (self.<port>.…) — reads and writes go directly to shared memory, zero-copy.
#[cerulion_node_impl] takes no arguments. The struct must appear before the impl block.
The impl block must define tick(&mut self) -> Result<(), NodeError>. It may also define init(&mut self, ctx: &mut NodeContext) -> Result<(), NodeError> (runs once before the first tick) and shutdown(&mut self) -> Result<(), NodeError> (runs once on a clean exit).

Complete example

Build it with cerulion node build safety_controller, then wire it into a graph with cerulion node stage safety_controller.

Node-level attributes

Exactly one trigger policy applies — either an attribute below, or an inferred data trigger from a field marked #[input(trigger)]. A node that declares ports but neither is a compile error.

Mutual exclusion

unbounded_sync excludes sync_window_ms, period_ms, and external. throttle_ms excludes period_ms (a period already pins the rate). tick_within_ms stacks with every trigger policy.

Rejected attributes

#[input(...)] attribute

Declares an input port; the field type is the schema. That is the complete surface: trigger, depth, backpressure, expect_within_ms. Any other key fails the build. The diagnostic names the unrecognized key and lists the accepted set.

Trigger inputs versus context inputs

A plain #[input] that is not the node’s trigger is a latest-value context read: in tick() it yields the most recent message on that topic, held across ticks. Until it has delivered at least once, the tick is a no-op — Cerulion never fabricates a default value for data that has not arrived. That means a 1 Hz map does not starve a 100 Hz consumer, and it also means a producer that stalls without disconnecting keeps serving its last value; add expect_within_ms if you need to notice.

#[output(...)] attribute

#[output] takes no field list. A form such as #[output(data, encoding)] is rejected with guidance to assign self.<port>.<field> = expr instead. Only the two forms above are accepted.

Inside tick()

Publishing rules worth memorizing

  • Only the outputs you write are published. An output your tick never touches is not published and costs nothing.
  • A tick that returns Err publishes nothing. Every output it had written is released without sending.
  • A variable-schema output must have all of its variable fields written on a tick that publishes it, at every nesting depth, or the frame is discarded with a loud error.
  • Do not read your own output fields — reading one loans a frame you did not mean to send. Keep state in plain struct fields.

Helpers and closures must be fallible

Every port-field assignment carries an injected ?, so any method or closure that writes a port field has to return a Result:
The same applies inside closures: hoist the value out, or use a fallible combinator such as try_for_each.
Port fields cannot be used inside a macro argument (format!, tracing::info!). Assign to a local first, then log the local.

fill_from: zero-copy producer writes

For producers that write into a destination buffer — camera drivers, codecs, file readers, sockets — fill_from passes the shared-memory destination (&mut [T]) straight to the producer.
Accepted producers are a closure FnMut(&mut [T]) -> Result<usize, TransportError>, SliceSource::new(slice), or any type implementing FillFrom<T>. Typed arrays get the element type directly, for example &mut [f32] for a float32[] field. If the producer returns Err, nothing is published that tick.

#[on_event] callbacks

Annotate a method in the #[cerulion_node_impl] block. The event parameter type selects the event, and the filter must name a declared port of the matching scope. Handlers run at the end of a successful tick, in declaration order.
All four events are edge-triggered: one event per regime, not one per occurrence. Two handlers on the same port are fine if their event types differ; two with the same port and type are a compile error, and a mismatched scope is a compile error too.
Handlers dispatch on the node’s own tick. A purely data-triggered node stops ticking when its input goes silent, so it may never run its LivelinessEvent Lost handler. Give such a node a period trigger if you depend on hearing about disconnects.

Error type

tick(), init(), and shutdown() return Result<(), NodeError>. The variants you construct are NodeError::Logic(String), NodeError::InvalidInput { input, reason }, NodeError::Fatal(String), and NodeError::Custom(Box<dyn Error + Send + Sync>). Transport and I/O errors convert automatically, so ? works on them.

Trigger policies

The --policy grammar and the defaulting matrix.

Backpressure and deadlines

Pick a queue policy and set arrival deadlines.

Define a node

The end-to-end workflow for a new node type.