Rust WASM Guest SDK
The nervix-wasm-sdk crate is the supported way to write Nervix WASM
processor guests in Rust. A guest implements the Processor trait for one
branch-local type and registers it with export_processor!; the SDK owns the
complete guest side of the WASM processor ABI:
the reusable linear-memory buffer, FlatBuffers envelope encoding and decoding,
the emit queue, the global-error channel, panic conversion, error-state
latching, and guest snapshot plumbing. Processor code only sees typed inputs,
typed outputs, and the branch configuration.
Use this chapter to write a Rust guest. The WASM Processor Guests chapter remains the authoritative wire contract the SDK implements and the reference for guests written in other languages.
Installing The SDK
The SDK is not published to crates.io yet. Install it as a git dependency from the Nervix repository and pin a revision so builds stay reproducible:
[package]
edition = "2024"
name = "my-wasm-guest"
version = "0.1.0"
[lib]
crate-type = ["cdylib"]
[dependencies]
arrow-array = "58"
error-stack = "0.5"
nervix-wasm-sdk = { git = "https://github.com/nervix-io/nervix", rev = "<commit>" }
[profile.release]
codegen-units = 1
lto = true
opt-level = 3
panic = "abort"
strip = true
Guests are native wasm32-unknown-unknown modules without WASI:
rustup target add wasm32-unknown-unknown
cargo build --target wasm32-unknown-unknown --release
The finished module is
target/wasm32-unknown-unknown/release/my_wasm_guest.wasm.
A Minimal Guest
The SDK’s representation properties exercise all current scalar and nested Arrow types, null validity and float bits, retained input buffers, shared output columns and ordered emissions. Snapshot properties preserve raw application bytes, accept a domain rename with the same branch contract, and reject another branch configuration before calling application restore. They run through the production SDK APIs in both ordinary Bolero tests and registered sanitizer targets; the WASM representation register states the projections and verification boundaries.
This guest passes every input field through unchanged and appends one
generated bucket column. It assumes the destination schema lists the input
fields first and a required bucket STRING field last:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use arrow_array::{Array, ArrayRef, Int32Array, StringArray};
use error_stack::{Report, Result};
use nervix_wasm_sdk::{
BranchContext, GuestContext, GuestError, InputBatch, OutputColumnRef, OutputEnvelope,
Processor,
};
struct SeverityBucketer;
impl Processor for SeverityBucketer {
fn create(branch: &BranchContext) -> Result<Self, GuestError> {
if branch.output_schemas().len() != 1 {
return Err(GuestError::failed(
"severity_bucketer expects exactly one TO relay",
));
}
Ok(Self)
}
fn process_batch(
&mut self,
ctx: &mut GuestContext<'_>,
input: InputBatch,
) -> Result<(), GuestError> {
let mut buckets = Vec::new();
for batch in input.batches() {
let values = batch
.column(0)
.as_any()
.downcast_ref::<Int32Array>()
.ok_or_else(|| GuestError::failed("input column 0 must be a required I32"))?;
for row in 0..values.len() {
buckets.push(if values.value(row) >= 100 { "HIGH" } else { "LOW" });
}
}
let mut output = OutputEnvelope::new();
let bucket = output.add_generated_column(
Arc::new(StringArray::from(buckets)) as ArrayRef,
false,
);
let input_fields = u32::try_from(ctx.branch().input_schema().fields.len())
.map_err(|_| Report::new(GuestError::InvalidSize))?;
let mut columns = (0..input_fields)
.map(|column_index| OutputColumnRef::Input { column_index })
.collect::<Vec<_>>();
columns.push(OutputColumnRef::Generated {
column_index: bucket,
});
let relay = ctx.branch().output_schemas()[0].name.clone();
output.add_route(relay, columns, input.acks().clone());
ctx.emit(output)
}
}
nervix_wasm_sdk::export_processor!(SeverityBucketer);
}
Reusing input.acks().clone() preserves the complete host row sidecar: every
output row keeps its tokens and source_token, so input-column references and
route expressions such as input.field keep working, and pre-existing
terminal decisions pass through unchanged.
Lifecycle
The host creates one guest instance per concrete branch. The SDK maps the ABI callbacks onto the trait:
| Trait method | When the host calls it |
|---|---|
create | Branch initialization, with the decoded BranchInit payload as a BranchContext: domain, the concrete branch key, which is absent for the unbranched instance of an unbranched processor, and the exact input and output schemas. |
process_batch | One input envelope, decoded into an InputBatch: Arrow record batches and the ACK sidecar. |
on_timeout | A previously requested domain-clock timeout fired. |
flush | The runtime is quiescing this branch: for an entity or domain pause, an ownership move, a coordinated reset, or a shutdown. Emit everything the processor still buffers; whatever it keeps stays unacknowledged until the branch resumes. |
save_state | After every process_batch and on_timeout call, whether it succeeded or failed, unless it exhausted a limit or requested a new state lifetime; after a flush that emitted output; and when the host checkpoints the branch for an ownership handoff. Return the processor’s durable computation state, or an error when it cannot be serialized. |
restore | The host recreates the branch instance, for example after a restart or on the node a branch moved to, and hands it the application state of the checkpoint the branch continues from. |
Keep all state branch-local. Never aggregate across branch keys inside one guest, and reject init payloads whose schemas the guest does not implement instead of accepting them silently.
Building Output
OutputEnvelope builds one output group:
add_generated_column(array, optional)places one immutable Arrow array in the common generated pool and returns the index used byOutputColumnRef::Generatedreferences. The SDK encodes the pool with the required empty field names;optionalmust match the nullability of every destination field that references the column. Several routes, or several fields in one route, may reference the same index.add_route(relay, columns, acks)adds one routed output.columnsalign positionally with the destination relay schema:OutputColumnRef::Input { column_index }references an unchanged input column without re-serializing its values,Generatedreferences the pool, andUninitializedleaves an optional destination field to materialize as typed NULLs.GuestContext::emitencodes the envelope and queues it; the host collects every queued envelope after the callback returns. Routes that need different generated row counts or ordering belong in separate emitted envelopes.
Row lineage follows the ABI contract: preserve the complete row sidecar for
rows you keep, put dropped rows’ tokens in acked, failed rows in nacked
with a reason, and rows for the processor’s ON MESSAGE ERROR policy in
message_errors. The full validation rules are in
WASM Processor Guests.
ProcessorTypeArrow::arrow_data_type maps ABI schema types to the exact Arrow
types Nervix compares, including Datetime as
Timestamp(Nanosecond, "+00:00") and Bytes as Arrow Binary. Use it when constructing generated arrays
for non-trivial destination fields.
Error Handling
Three error levels map onto the processor’s declared policies:
The Processor callbacks and fallible SDK methods return
error_stack::Result<T, GuestError>. Create a report where a failure occurs;
keep a source error in that report and add context when the guest gives it a
processor-specific meaning. The SDK renders the report only when it crosses the
guest ABI, so its Rust callers can still inspect typed causes.
- Message errors are lineage: route the affected tokens through the
message_errorssidecar of an emitted envelope. The host appliesON MESSAGE ERROR. GuestContext::report_global_error(reason)reports a global processor error while the current callback still succeeds. The host appliesON GLOBAL ERRORand the guest latches into error state.- Returning
Err(GuestError::failed(reason))from a callback reports the reason through the global-error channel, fails the callback, and latches the guest into error state.GuestError::failedcreates the report for that failure. OtherGuestErrorvariants return their negative ABI code without latching.
Panics are converted into latched global errors; with panic = "abort" they
surface as Wasmtime traps, which the host also treats as global errors.
Guest State
The runtime persists and replicates guest state so it can recreate a branch
instance; WASM State And Recovery describes how. save_state
returns the processor’s durable computation state:
whatever a recreated instance needs to continue the computation, such as
counters, aggregates, or open windows. The SDK stores those application bytes
in its snapshot together with the branch configuration the instance was
initialized with, and restore receives the application bytes back.
Save computation state, never execution state. The input a processor still
buffers, its ACK tokens and row sidecars, output envelopes it has not emitted,
and timeout handles belong to the live instance and mean nothing to one created
later. Release buffered input from flush; ACK tokens are host-local runtime
capabilities that are never persisted or replicated, and when ACK state is lost
with a processor owner, the upstream ingestor reacts according to its delivery
mode and retry policy. The SDK keeps its own execution state out of the
snapshot as well: a recreated instance starts without error state, with an
empty emit queue, and without pending timeouts.
save_state is fallible. Return an error when the state cannot be serialized:
Nervix reports a state snapshot
failure and keeps the state
saved last, so a recreated instance restores that state rather than a save that
never completed. The error follows the same rules as a callback error:
GuestError::failed puts its reason on the global-error channel and latches
the instance into error state.
restore must stay strict: reject bytes the current build cannot interpret
instead of silently resetting. Empty application bytes are the valid state of a
stateless processor, not a request to discard state: the default save_state
returns them, the SDK still wraps them in a snapshot, and the default restore
accepts them and rejects anything else.
The SDK reports every restore verdict with the reserved
nervix_load_state codes: a
snapshot it cannot decode, including its branch configuration, or a snapshot
taken under a different branch configuration than the one the instance was
just initialized with, is a rejected snapshot envelope, and any error restore
returns is a rejected application state with that error as the reason. Nervix
keeps the saved state after a rejection and reports it under the
snapshot envelope decoding or application state restoration
failure stage, so return an
error from restore only when the saved state itself is unusable.
Requesting A New State Lifetime
GuestContext::request_state_reset() asks Nervix to replace the complete guest-state lifetime of
the branch the callback is running in. Nervix performs the replacement after the callback returns
and never calls back into the guest to do it, so the SDK is never re-entered. The request selects
nothing: it is scoped to the calling branch and to the generation it runs in, and it is routed
through the same coordinated reset an operator triggers, with the same durability and replica
guarantees.
The request is terminal for the callback that made it. Output queued with GuestContext::emit is
discarded, the input the branch holds is left unacknowledged for its source to redeliver, no
checkpoint is taken, and the instance is dropped with its pending timeouts. Effects earlier
callbacks published stand, because their checkpoints completed. The processor is then created by
Processor::create in the new lifetime, without a Processor::restore call, and it is that
initialization — not the returned Ok(()) — that tells the guest the new lifetime is durable.
Asking repeatedly, in one callback or across the callbacks of one lifetime, replaces that lifetime
exactly once. The call returns the host’s own answer, StateResetRequestAnswer. A GuestContext
exists only inside process_batch, on_timeout, and flush, which are exactly the callbacks
Nervix accepts a request from, so a processor sees Accepted unless the host it runs on disagrees
about which operation is in progress.
Use it when the processor’s own state is unusable and must be abandoned, not to clear fields. Clearing fields is an ordinary application-state mutation that the next checkpoint saves.
fn process_batch(
&mut self,
ctx: &mut GuestContext<'_>,
input: InputBatch,
) -> Result<(), GuestError> {
if self.counters_are_unusable(&input)? {
if ctx.request_state_reset() == StateResetRequestAnswer::Refused {
return Err(GuestError::failed("host refused the state reset request"));
}
return Ok(());
}
// ... ordinary processing
Ok(())
}
Timeouts
GuestContext::request_timeout(delay) asks the host for a domain-clock
timeout and returns its handle; when the domain clock reaches the requested
time the host calls on_timeout with that handle. Output emitted from the
timeout callback is routed exactly like batch output. WASM processors have no
FLUSH clause — guest-requested timeouts and emitted envelopes own the output
cadence.
Timeouts belong to the branch instance that requested them and are never part
of its saved state. A recreated instance starts without pending timeouts, so a
processor whose restored state needs a timer requests it again from its next
process_batch or on_timeout call.
Deploying A Guest
Upload the built module as a resource version and reference it from the processor declaration:
CREATE RESOURCE normalizer;
UPLOAD RESOURCE normalizer VERSION 'path/to/resource-directory';
CREATE WASM PROCESSOR normalize_events
FROM events
USING RESOURCE normalizer VERSION 1
FILE "processor.wasm"
MAX FUEL 1000000000
MAX MEMORY 64MiB
BRANCHED BY by_tenant
TO normalized_events
SET tenant = tenant,
normalized = normalized
ON MESSAGE ERROR LOG
ON GLOBAL ERROR LOG;
VERSION is mandatory. VERSION LATEST binds the highest completed version when the statement is
applied; either form stores one concrete version, so a later upload does not replace the module a
processor runs.
See Resources for uploads and
Runtime Nodes for the complete CREATE WASM PROCESSOR
grammar.
Reference Guest
The complete reference guest — global-row filtering across buffered batches, a
generated column shared by multiple routes, message errors, global errors, a
failing state save, and a row ordinal as its only saved state — is
examples/wasm-processors/rust-guest in the Nervix repository. Build it with:
cargo build \
--manifest-path examples/wasm-processors/rust-guest/Cargo.toml \
--target wasm32-unknown-unknown \
--release
The module is produced at
examples/wasm-processors/rust-guest/target/wasm32-unknown-unknown/release/nervix_wasm_processor_rust_guest.wasm.