Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 methodWhen the host calls it
createBranch 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_batchOne input envelope, decoded into an InputBatch: Arrow record batches and the ACK sidecar.
on_timeoutA previously requested domain-clock timeout fired.
flushThe 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_stateAfter 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.
restoreThe 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 by OutputColumnRef::Generated references. The SDK encodes the pool with the required empty field names; optional must 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. columns align positionally with the destination relay schema: OutputColumnRef::Input { column_index } references an unchanged input column without re-serializing its values, Generated references the pool, and Uninitialized leaves an optional destination field to materialize as typed NULLs.
  • GuestContext::emit encodes 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_errors sidecar of an emitted envelope. The host applies ON MESSAGE ERROR.
  • GuestContext::report_global_error(reason) reports a global processor error while the current callback still succeeds. The host applies ON GLOBAL ERROR and 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::failed creates the report for that failure. Other GuestError variants 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.