#[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:
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 adjacentimpl <Name> { … }block letstick()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.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
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
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
Errpublishes 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:
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.
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.
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.
Related
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.