Introduction
Nervix is experimental software in active development. It is intended for evaluation, local testing, and design exploration. It is not suitable for real production workloads.
Nervix is a realtime stream processing system with a robust declarative DSL for defining data-flow graphs.
Nervix stream processing Language (NSPL) is used to declare the schemas, connections, runtime nodes, and external integrations that make up a Nervix graph:
- schemas
- wire schemas
- codecs
- relays
- clients
- ingestors
- processors
- emitters
- vhosts
- endpoints
NSPL has reached alpha. Its period of rapid, broad experimentation is over, so large backward-incompatible language changes are no longer expected. The language will continue to evolve, however, and focused breaking changes may still occur. See Language Stability for the current compatibility policy.
The core runtime model is a graph of connected nodes:
- ingestors bring data into the system
- relay owners route data, while processors, junctions, deduplicators, and reingestors transform it
- emitters push results out to external systems
Current built-in transport integrations include Kafka, Pulsar, HTTP, Prometheus, RabbitMQ, Redis, MQTT, NATS, ZeroMQ, SQS, and WebSockets, with Sentry event-envelope emission over HTTP.
The connections between nodes are expressed through RELAYs.
Input data often mixes tenants, users, devices, accounts, or other business groups in the same
external feed. Nervix uses explicit CREATE BRANCH declarations to process those groups
independently. A branch names the key schema, such as CREATE BRANCH by_tenant_user SCHEMA tenant_user_branch TTL 5m; ingestor and reingestor routes construct concrete keys with BRANCHED BY by_tenant_user SET ....
RELAYs are scheduled runtime nodes between producers and consumers. Each has one owner that holds
its buffer, concrete branch presence, fan-out, and metrics. Relays are declared as BRANCHED BY <branch> or UNBRANCHED; only ingestors and reingestors carry the key mapping that constructs
concrete branch identities. Processing state remains isolated by that identity, while one relay
owner coordinates its cluster-wide buffering and delivery. An emitter drains records out of the
graph. A reingestor can compute a new group and start downstream branches under that grouping.
Nervix already runs clustered deployments, schedules graph nodes across multiple servers, executes codecs in the runtime, replicates selected runtime state, and supports multi-node failover scenarios. It is still evolving, but it is beyond a parser-only prototype.
This book is split into six sections:
- Quickstart: running Nervix and building your first graphs step by step
- Client Tools: the command line client and the browser console
- Manual: how to use Nervix’s public surface
- Rust WASM Guest SDK: writing custom WASM processor guests in Rust
- Architecture And Internals: control-plane, data-plane, and runtime implementation details
- Developing Nervix: working on Nervix itself from a repository clone
Start with the quickstart if you are new to Nervix, and with the manual unless you are specifically trying to understand internals. Agents can start with the portable NSPL Agent Skill.
Nervix is licensed under the Fair Core License (FCL). See License.
Quickstart
Nervix is experimental software in active development. It is intended for evaluation, local testing, and design exploration. It is not suitable for real production workloads.
This section takes you from nothing to a running Nervix graph. It is a hands-on tutorial: each step is a small, complete change that you apply to a live single-node cluster and verify before moving on. You build one graph throughout — it starts as a trivial pass-through pipeline and grows into a branched graph with conditional routing and structured error handling.
- Installation: getting Nervix onto your machine
- Running Nervix: starting a single-node server, connecting a client, and starting the Kafka and Redis containers the tutorial talks to
- Your First Pipeline: Kafka To Redis: read JSON orders from a Kafka topic and publish them to a Redis Pub/Sub channel
- Conditional Routing: split the stream with a junction, route filters, and computed fields
- Branched Processing: partition the stream per customer and deduplicate independently inside each branch
- Error Routes: capture failing records as structured errors on a dead-letter relay
- HTTP Ingestion: POST records straight at the server through a vhost-hosted endpoint
- JAQ Transformations: reshape foreign JSON at the boundary with a jq-style codec
- Protobuf Codecs: decode binary payloads from uploaded
.protodefinitions - User-Defined Functions: package expression logic as a reusable compiled Roto function
- Window Aggregates: summarize the stream with per-customer sliding windows
- Generators: emit periodic records from materialized relay state
- Correlators: join orders with payments across two streams
- Altering A Running Graph: change routing logic and evolve schemas without tearing anything down
- Paced Domains: replay timestamped history on an accelerated domain clock
Every page links the Manual chapter that documents each feature in full: the tutorial shows one working path, and the manual holds the reference.
Installation
Install the server and CLI from GitHub:
cargo install --locked \
--git https://github.com/nervix-io/nervix.git \
nervix-server nervix-cli
Ensure Cargo’s binary directory, $HOME/.cargo/bin by default, is on your PATH.
See Installation for prerequisites, reproducible Git revisions, a three-node Docker deployment, and the Kubernetes Operator installation.
Running Nervix
This page starts everything the tutorial needs on your local machine: one Nervix node, a Kafka broker, and a Redis server.
Start The Server
A Nervix node is a single nervix-server process. From a repository checkout, create the local
development identity and start a fresh single-node cluster:
just generate-dev-tls
nervix-server \
--node-id node-1 \
--interconnect-listen-addr 127.0.0.1:47392 \
--interconnect-advertise-addr 127.0.0.1:47392 \
--interconnect-tls-ca tls/dev/ca.pem \
--interconnect-tls-cert tls/dev/node.pem \
--interconnect-tls-key tls/dev/node-key.pem \
--allow-bootstrap \
--init-default-user-password nervix
--node-idnames this node inside the cluster.- The interconnect address and TLS files configure the mandatory authenticated HTTP/2 listener; a single local node points its listener and advertised address at the same local port.
--allow-bootstraplets this node form a brand-new cluster instead of joining an existing one.--init-default-user-passwordseeds thedefaultuser’s password on the very first startup. Once the user exists, remove the flag from normal startup.
Every flag has a NERVIX_* environment variable twin, such as NERVIX_NODE_ID. Control-plane
state is persisted under ./.nervix-db by default; use --db-path to relocate it. The directory
contains the node database for registry and runtime state and a separate consensus database whose
journal carries only Raft state.
The server exposes:
- the session gRPC API on
127.0.0.1:47391(--addr), used bynervix-cliand the Rust client library - the web console at
http://127.0.0.1:47420/console/, which combines a command line with a live graph view - health and metrics endpoints on port
9090:/livez,/readyz, and/metrics(Metrics And Observability)
Stop The Server
Press Ctrl-C, or send the process SIGTERM, to stop the node. The first SIGINT or SIGTERM
starts graceful shutdown: the node advertises that its process is terminating, stops admitting new
work, closes the client connections it had accepted, and runs its shutdown phases, logging whether
each one completed or abandoned work. The process exits with status 0 once graceful shutdown
finishes, or prints the error and exits with status 1 when a listener or a shutdown step failed.
Graceful shutdown must finish within the shutdown timeout, --shutdown-timeout
(NERVIX_SHUTDOWN_TIMEOUT), which defaults to 50s and is measured from the first signal. When it
passes, the process logs the shutdown phase it had reached and exits at once with status 1.
Nothing extends that deadline: a client that holds an upload or a session open does not, and a
later signal can only end the process sooner.
A second SIGINT or SIGTERM abandons graceful shutdown and ends the process immediately, without
running the phases that remain. The process exits with status 130 when the second signal is
SIGINT and 143 when it is SIGTERM, the statuses a shell reports for a process those signals
terminate. Work still in progress is lost exactly as it is when the process crashes; SIGKILL
always ends the process that way.
The server registers both signals before it starts anything else. A signal that arrives while the node is still starting takes effect as soon as startup completes, while its shutdown deadline runs from the moment the signal arrived. A node that cannot register the signals refuses to start.
Shutdown And Recovery describes the phases, what the node drains before it exits, what each ending preserves, and how to read a shutdown that did not finish.
Connect A Client
nervix-cli is the interactive NSPL client. With no arguments it connects
to http://127.0.0.1:47391 as user default and prompts for the password:
nervix-cli
nervix[default]> SHOW CLUSTER STATUS;
Statements end with ;, and the prompt shows the active domain. The same statements can be typed
into the web console’s command line, or executed one-shot with
nervix-cli --command "<statements>". There is no file-execution mode: to run a saved script,
paste it into the client or pass it through --command "$(cat pipeline.nspl)". To format a saved
script, use nervix-nspl-format.
Start Kafka And Redis
The tutorial reads from Kafka and publishes to Redis. Any reachable Kafka broker and Redis server work; for a throwaway local setup:
docker run -d --name broker -p 9092:9092 apache/kafka:latest
docker run -d --name redis -p 6379:6379 redis:7
If you work from a repository clone, just deps starts a complete local dependency stack instead
(Developing Nervix).
Nervix never creates topics, queues, or any other external entities as a side effect of running a graph. Missing entities surface as initialization or publish errors, so the tutorial provisions its Kafka topic explicitly before ingesting from it.
Continue to Your First Pipeline: Kafka To Redis.
Your First Pipeline: Kafka To Redis
The first graph is deliberately trivial: JSON order records arrive on a Kafka topic, Nervix decodes them, and an emitter publishes them to a Redis Pub/Sub channel. Along the way you meet every building block that later steps reuse: domains, schemas, codecs, relays, clients, ingestors, and emitters.
Provision The Topic
Nervix never creates external entities, so create the Kafka topic first:
docker exec broker /opt/kafka/bin/kafka-topics.sh \
--create --topic orders --bootstrap-server localhost:9092
Create A Domain
Every entity in Nervix lives in an explicit domain — there is no implicit default. Open
nervix-cli and create one, then switch the session to it:
CREATE UNPACED DOMAIN quickstart;
USE quickstart;
An unpaced domain admits records as they arrive, with no domain clock pacing — the right choice
for a first pipeline. See Domains And Time. USE is a
client-local statement that scopes the rest of your session to the domain.
Open A Transaction
Submitting more than one model statement at a time requires a transaction, and it is also how a
graph is assembled atomically — nothing takes effect until COMMIT;. See
NSPL Overview for the statement surface.
BEGIN;
The graph declarations in the rest of this page run inside this transaction.
Describe The Data
Nervix separates what a record looks like inside the graph from how it is serialized on the wire. The internal schema is exact-typed; the wire schema describes the JSON payload in the wire format’s own type names; the codec bridges the two. See Schemas And Codecs.
CREATE SCHEMA order_record (
order_id STRING,
customer STRING,
status STRING,
amount I64,
quantity I64
);
CREATE WIRE JSON SCHEMA order_wire MODE STRICT (
order_id string,
customer string,
status string,
amount integer,
quantity integer
);
CREATE CODEC order_codec
FROM WIRE JSON SCHEMA order_wire
TO SCHEMA order_record;
STRICT rejects payload fields that are not declared; LOOSE would drop them instead
(Wire Schemas). There are no implicit casts anywhere in
Nervix, so the codec is the only place where wire values become typed internal values. A
DATETIME field would additionally need an explicit encoding such as
ENCODE created_at AS RFC3339 (Codecs).
Create A Relay
Relays are the named streams that connect runtime nodes. Each relay carries records of exactly one schema. See Streams And State.
CREATE RELAY orders SCHEMA order_record UNBRANCHED;
UNBRANCHED means one shared stream. Branched Processing
introduces the alternative.
Declare The External Connections
Connection endpoints never live on ingestors or emitters; they are named CLIENT objects that
nodes reference. Kafka client config is passed through to the Kafka driver, so
bootstrap.servers uses its familiar key.
CREATE CLIENT kafka_local
TYPE KAFKA
CONFIG {
'bootstrap.servers' = '127.0.0.1:9092'
};
CREATE CLIENT redis_local
TYPE REDIS
POOL SIZE MIN 1 MAX 4
CONFIG {
'addr' = 'redis://127.0.0.1:6379/'
};
Ingest From Kafka
An ingestor brings external data into the graph. It decodes each payload with the codec and routes the result to a relay:
CREATE KAFKA TOPIC INGESTOR kafka_orders
FROM kafka_local
TOPIC 'orders'
OFFSET BY CONSUMER GROUP 'quickstart'
MODE ACK SEQUENTIAL ACK TIMEOUT 30s RETRY POLICY BACKOFF 200ms MAX 5s
ON QUIESCE SUSPEND DECODE USING order_codec
TO orders
INHERIT ALL
UNBRANCHED
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
Reading top to bottom:
KAFKA TOPIC INGESTORnames the connector and the entity the ingestor reads.FROMnames theTYPE KAFKAclient, and the topic and consumer group are string literals sent to Kafka exactly as written.OFFSET BY CONSUMER GROUPtracks progress in Kafka;MODE ACK SEQUENTIALacknowledges records in order. The full Kafka source grammar is in Supported Ingestor Types.TO ordersstarts a route. Routes begin empty:INHERIT ALLcopies every decoded field into the outgoing record, and later steps showSETconstructing new values. See The Working Message.- Every route needs an explicit flush policy —
FLUSH EACH <duration> MAX BATCH SIZE <bytes>orFLUSH IMMEDIATE. There are no hidden defaults. ON MESSAGE ERROR LOGis the route’s per-record error policy;ON GENERAL ERROR LOGis the node-wide policy (Runtime Node Error Policies).
Emit To Redis
An emitter drains a relay out of the graph. The Redis integration is Pub/Sub (Supported Emitter Sinks), and the same codec that decoded the input encodes it back to JSON:
CREATE EMITTER redis_orders
FROM orders
TO REDIS PUBSUB redis_local CHANNEL orders_out
MODE NO_ACK RETRY POLICY BACKOFF 250ms MAX 30s
ENCODE USING order_codec
INHERIT ALL
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
Redis Pub/Sub exposes no delivery confirmation, so MODE NO_ACK names server acceptance as the
publish boundary. The required retry policy paces connection and other infrastructure failures;
it does not turn subscriber presence into an acknowledgment.
Commit And Start
COMMIT; applies the structural changes: the whole graph is validated and installed atomically.
Nothing processes data yet — that is the job of START;, the domain lifecycle command that begins
actual work (Start And Stop):
COMMIT;
START;
The Complete Script
CREATE UNPACED DOMAIN quickstart;
USE quickstart;
BEGIN;
CREATE SCHEMA order_record (
order_id STRING,
customer STRING,
status STRING,
amount I64,
quantity I64
);
CREATE WIRE JSON SCHEMA order_wire MODE STRICT (
order_id string,
customer string,
status string,
amount integer,
quantity integer
);
CREATE CODEC order_codec
FROM WIRE JSON SCHEMA order_wire
TO SCHEMA order_record;
CREATE RELAY orders SCHEMA order_record UNBRANCHED;
CREATE CLIENT kafka_local
TYPE KAFKA
CONFIG {
'bootstrap.servers' = '127.0.0.1:9092'
};
CREATE CLIENT redis_local
TYPE REDIS
POOL SIZE MIN 1 MAX 4
CONFIG {
'addr' = 'redis://127.0.0.1:6379/'
};
CREATE KAFKA TOPIC INGESTOR kafka_orders
FROM kafka_local
TOPIC 'orders'
OFFSET BY CONSUMER GROUP 'quickstart'
MODE ACK SEQUENTIAL ACK TIMEOUT 30s RETRY POLICY BACKOFF 200ms MAX 5s
ON QUIESCE SUSPEND DECODE USING order_codec
TO orders
INHERIT ALL
UNBRANCHED
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
CREATE EMITTER redis_orders
FROM orders
TO REDIS PUBSUB redis_local CHANNEL orders_out
MODE NO_ACK RETRY POLICY BACKOFF 250ms MAX 30s
ENCODE USING order_codec
INHERIT ALL
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
COMMIT;
START;
Send An Order
Subscribe to the output channel in one terminal:
docker exec redis redis-cli SUBSCRIBE orders_out
Produce an order in another:
docker exec -i broker /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server localhost:9092 --topic orders
{"order_id":"o-1001","customer":"acme","status":"new","amount":1500,"quantity":3}
The Redis subscriber prints the record back as JSON. You can also watch any relay from inside Nervix with a read-only session subscription (Sessions):
nervix-cli --domain quickstart subscribe watch orders
The pipeline works end to end. Next, add logic to it in Conditional Routing.
Conditional Routing
The pass-through pipeline treats every order the same. This step adds a
junction that splits the stream: high-value orders get a computed
tier field and their own Redis channel, routine orders continue separately.
Everything here is additive. The domain keeps running while you apply it — a transaction of pure
CREATE statements rebuilds the schedule without pausing the graph
(Automatic Model-Alteration Quiescing).
The Enriched Record
Construction is exact-typed, so a record with an extra tier field is a different schema, with
its own wire schema and codec for emission — the same pattern as the first pipeline:
BEGIN;
CREATE SCHEMA order_tiered (
order_id STRING,
customer STRING,
status STRING,
amount I64,
quantity I64,
tier STRING
);
CREATE WIRE JSON SCHEMA order_tiered_wire MODE STRICT (
order_id string,
customer string,
status string,
amount integer,
quantity integer,
tier string
);
CREATE CODEC order_tiered_codec
FROM WIRE JSON SCHEMA order_tiered_wire
TO SCHEMA order_tiered;
CREATE RELAY high_value_orders SCHEMA order_tiered UNBRANCHED;
CREATE RELAY routine_orders SCHEMA order_record UNBRANCHED;
Route With A Junction
A junction subscribes to one or more relays and fans records out to routes. Each route is an
independent contract: its own construction, its own WHERE filter over the finalized output, its
own flush policy. See Junction and
The Working Message for the input/output scopes used below.
CREATE JUNCTION route_orders
FROM orders
UNBRANCHED
TO high_value_orders
INHERIT ALL
SET tier = CASE
WHEN input.amount >= 10000 THEN "vip"
ELSE "high"
END
WHERE output.amount >= 1000
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG
TO routine_orders
INHERIT ALL
WHERE output.amount < 1000
FLUSH EACH 1s MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG;
SETinitializes the one fieldINHERIT ALLcannot supply. Assignments run in order, andCASE/IFexpressions are part of the expression language (Conditional Expressions).- The route
WHEREfilters finalized records:output.amountis the value the route just constructed, whileinput.amountinsideSETreads the incoming record. - The
ordersrelay now has two subscribers — the emitter from the first pipeline and this junction. Relays fan out to every subscriber (Streams And State).
Emit The High-Value Stream
CREATE EMITTER redis_high_value
FROM high_value_orders
TO REDIS PUBSUB redis_local CHANNEL high_value_out
MODE NO_ACK RETRY POLICY BACKOFF 250ms MAX 30s
ENCODE USING order_tiered_codec
INHERIT ALL
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
COMMIT;
Watch The Split
Subscribe to the new channel:
docker exec redis redis-cli SUBSCRIBE high_value_out
Produce three orders with different amounts:
{"order_id":"o-1002","customer":"acme","status":"new","amount":250,"quantity":5}
{"order_id":"o-1003","customer":"globex","status":"new","amount":2500,"quantity":1}
{"order_id":"o-1004","customer":"acme","status":"new","amount":25000,"quantity":2}
o-1002 appears only on orders_out. o-1003 also appears on high_value_out with
"tier":"high", and o-1004 with "tier":"vip".
Next: Branched Processing partitions this stream per customer.
Branched Processing
One external feed usually mixes many tenants, customers, or devices. Branches are how Nervix processes those groups independently: when a new group key appears, the branched part of the graph gets its own relay presence and processor state for that group, fully isolated from every other group. See Streams, Branch Keys, And Branches.
This step partitions orders per customer and deduplicates them inside each customer’s branch, so one customer’s duplicates can never suppress another customer’s orders.
Declare The Branch
A branch names its key schema and a TTL. When a branch instance goes idle for the TTL, its branch-local relay and processor state is dropped as a whole (TTL). In an unpaced domain the TTL uses wall clock time.
BEGIN;
CREATE SCHEMA customer_branch (
customer STRING
);
CREATE BRANCH by_customer SCHEMA customer_branch TTL 5m;
CREATE RELAY orders_by_customer SCHEMA order_record BRANCHED BY by_customer;
CREATE RELAY orders_deduped SCHEMA order_record BRANCHED BY by_customer;
Construct The Branch Key
Only ingestors and reingestors construct branch keys. Since orders is already flowing, a
reingestor — a branch-boundary node — re-partitions it without
touching the existing graph:
CREATE REINGESTOR partition_orders
FROM orders
TO orders_by_customer
INHERIT ALL
BRANCHED BY by_customer SET customer = message.customer
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG;
BRANCHED BY by_customer SET customer = message.customer builds the concrete key from each
record. The first order from a customer materializes that customer’s branch; every relay and
processor downstream that declares BRANCHED BY by_customer runs per customer
(Branch Semantics).
Deduplicate Per Branch
A deduplicator drops records whose key it has already seen within a
time window. Declared BRANCHED BY by_customer, its history is branch-local:
CREATE DEDUPLICATOR dedupe_orders
FROM orders_by_customer
DEDUPLICATE ON input.order_id
MAX TIME 5m
BRANCHED BY by_customer
TO orders_deduped
INHERIT ALL
FLUSH EACH 250ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG;
Emit The Deduplicated Stream
An emitter consumes every concrete branch and collapses branch identity at the external boundary (Branch Semantics) — the Redis channel receives all customers:
CREATE EMITTER redis_deduped
FROM orders_deduped
TO REDIS PUBSUB redis_local CHANNEL orders_deduped_out
MODE NO_ACK RETRY POLICY BACKOFF 250ms MAX 30s
ENCODE USING order_codec
INHERIT ALL
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
COMMIT;
Prove The Isolation
Subscribe to orders_deduped_out, then produce the same order_id interleaved across two
customers:
docker exec redis redis-cli SUBSCRIBE orders_deduped_out
{"order_id":"o-2001","customer":"acme","status":"new","amount":900,"quantity":1}
{"order_id":"o-2001","customer":"globex","status":"new","amount":900,"quantity":1}
{"order_id":"o-2001","customer":"acme","status":"new","amount":900,"quantity":1}
The first two records pass — acme and globex each have their own deduplication history. The
third is dropped as a duplicate inside the acme branch only. That is branch isolation: state,
batching, and routing for one group never interfere with another. For sizing branched graphs, see
Capacity Planning For Branched Graphs.
Next: Error Routes handles records that fail instead of dropping them.
Error Routes
So far every failure policy has been LOG. Nervix can instead capture a failing record as a
structured error — with a stable reference, code, message, and the partially constructed output —
and send it to a dead-letter relay for inspection or downstream handling. See
Message Errors.
The Errors And Diagnostics architecture chapter explains how a
runtime failure becomes this structured error and how sensitive data stays out of it.
To provoke real failures, this step computes a per-unit price: amount / quantity fails when a
record arrives with quantity of zero.
The Dead-Letter Model
Error handlers construct an ordinary record on an ordinary relay. Fields taken from
partial_output must be OPTIONAL — the failed operation may not have produced them:
BEGIN;
CREATE SCHEMA priced_order (
order_id STRING,
customer STRING,
amount I64,
quantity I64,
unit_price I64
);
CREATE SCHEMA order_error (
error_reference STRING,
error_code STRING,
error_message STRING,
source_order STRING,
attempted_price I64 OPTIONAL
);
CREATE RELAY priced_orders SCHEMA priced_order UNBRANCHED;
CREATE RELAY order_errors SCHEMA order_error UNBRANCHED;
Send Failures To The Dead Letter
ON MESSAGE ERROR SEND TO replaces LOG on the route. The handler can read the error metadata
(error.*), the original input (input.*), and the partial output of the failed construction
(partial_output.*):
CREATE JUNCTION price_orders
FROM orders
UNBRANCHED
TO priced_orders
INHERIT ALL EXCEPT status
SET unit_price = input.amount / input.quantity
FLUSH IMMEDIATE
ON MESSAGE ERROR SEND TO order_errors
SET error_reference = error.reference,
error_code = error.code,
error_message = error.message,
source_order = input.order_id,
attempted_price = partial_output.unit_price;
COMMIT;
INHERIT ALL EXCEPT status inherits everything but the one field priced_order does not
declare. Error routes preserve the branch in which the failure executed — this junction is
unbranched, so order_errors is unbranched too. The node-level ON GENERAL ERROR policy exists
only on ingestors and emitters
(Runtime Node Error Policies).
Trigger A Failure
Watch the dead-letter relay with a session subscription:
nervix-cli --domain quickstart subscribe errors order_errors
Produce one healthy record and one with quantity of zero:
{"order_id":"o-3001","customer":"acme","status":"new","amount":900,"quantity":3}
{"order_id":"o-3002","customer":"acme","status":"new","amount":900,"quantity":0}
o-3001 flows to priced_orders with unit_price of 300. o-3002 fails the division and
arrives on order_errors as a structured record: a stable error_reference, the machine-readable
error_code, the human-readable error_message, and a null attempted_price — the division
never produced one. Hot-path errors never leak sensitive payload values.
Next: add a second way into the graph in HTTP Ingestion.
HTTP Ingestion
Orders do not have to come through a broker. Nervix hosts its own inbound HTTP endpoints: you
declare a virtual host and a path, attach an ingestor, and POST records straight at the server.
See HTTP ENDPOINT And WEBSOCKETS ENDPOINT.
Inbound endpoints are served by the node itself on its HTTP listener — --http-listen-addr,
0.0.0.0:8080 by default (TLS-enabled vhosts use the separate HTTPS listener instead).
Declare The Endpoint
Three statements, in order: the VHOST names the hostnames served, the ENDPOINT binds a path on
that vhost, and the ingestor consumes the endpoint. An HTTP ENDPOINT ingestor names its connector
and entity in its head and reads only a TYPE HTTP endpoint. The existing order_codec decodes
the same JSON as the Kafka path:
BEGIN;
CREATE RELAY orders_http SCHEMA order_record UNBRANCHED;
CREATE VHOST edge orders.example.com;
CREATE ENDPOINT orders_ingress
ON edge
PATH '/orders'
TYPE HTTP;
CREATE HTTP ENDPOINT INGESTOR http_orders
FROM orders_ingress MODE NO ACK
ON QUIESCE BUFFER MAX SIZE 1MiB DECODE USING order_codec
TO orders_http
INHERIT ALL
UNBRANCHED
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
COMMIT;
MODE NO ACK is the only mode endpoint ingestors support — there is no broker to
acknowledge to. With this schemaful codec each POST body is one record, while a JAQ-backed codec
can unfold one body into several. To merge this
stream with the Kafka-fed orders relay, a junction can consume both
relays with FROM orders, orders_http.
POST An Order
Watch the relay first:
nervix-cli --domain quickstart subscribe http_watch orders_http
Then send a record. The vhost is matched against the request’s Host header (lowercased, port
stripped), so tell curl which host you mean:
curl -i -X POST http://127.0.0.1:8080/orders \
-H 'Host: orders.example.com' \
-H 'Content-Type: application/json' \
-d '{"order_id":"o-4001","customer":"acme","status":"new","amount":700,"quantity":7}'
The server answers 202 Accepted and the record appears in the subscription. Requests to an
unknown host or path get 404, and anything other than POST gets 405.
One thing 202 does not mean: that the payload decoded. Dispatch is fire-and-forget — a body
that fails the codec goes to the route’s ON MESSAGE ERROR policy, not back to the HTTP client.
Verify through the subscription, not the status code.
Next: reshape foreign JSON at the boundary in JAQ Transformations.
JAQ Transformations
So far every payload matched the wire schema field for field. Real feeds rarely do. When a partner sends orders as a nested envelope, a JAQ codec reshapes the payload at the boundary instead of declaring a wire schema: the codec parses the native format and runs a jq-style program whose outputs must each be a JSON object matching the internal schema. The concise JAQ Reference links the complete, version-matched upstream manual.
Suppose the partner posts this envelope:
{"order":{"id":"o-5001","customer":"acme","state":"new"},"totals":{"amount":1200,"quantity":4}}
A JAQ Codec
A JAQ codec replaces the wire schema entirely — it goes straight from the format to the internal schema, with the program inline:
BEGIN;
CREATE CODEC partner_order_codec
FROM JSON
TO SCHEMA order_record
WITH JAQ TRANSFORMATIONS ON INGESTION $jaq$
{
order_id: .order.id,
customer: .order.customer,
status: .order.state,
amount: .totals.amount,
quantity: .totals.quantity
}
$jaq$;
The explicit ON INGESTION direction means this codec decodes but cannot encode. A bidirectional
codec adds ON EMITTING '<program>' after the ingestion program
(JAQ Transformations). FROM accepts JSON, YAML,
TOML, XML, and CBOR.
Two rules to remember: any field the program can produce as null must be OPTIONAL in the
internal schema, and surplus keys in the program’s output are silently dropped.
Wire It To An Endpoint
Reuse the vhost from HTTP Ingestion with a second path:
CREATE RELAY partner_orders SCHEMA order_record UNBRANCHED;
CREATE ENDPOINT partner_ingress ON edge PATH '/partner-orders' TYPE HTTP;
CREATE HTTP ENDPOINT INGESTOR partner_order_source
FROM partner_ingress MODE NO ACK
ON QUIESCE BUFFER MAX SIZE 1MiB DECODE USING partner_order_codec
TO partner_orders
INHERIT ALL
UNBRANCHED
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
COMMIT;
Send The Envelope
nervix-cli --domain quickstart subscribe partner_watch partner_orders
curl -i -X POST http://127.0.0.1:8080/partner-orders \
-H 'Host: orders.example.com' \
-H 'Content-Type: application/json' \
-d '{"order":{"id":"o-5001","customer":"acme","state":"new"},"totals":{"amount":1200,"quantity":4}}'
The subscription shows the flattened order_record — the envelope never enters the graph.
Unfold A Batch
A partner that sends several orders in one body needs no second endpoint. Every object the program yields becomes its own message, so a program that iterates an array unfolds one POST into one record per element:
CREATE CODEC partner_batch_codec
FROM JSON
TO SCHEMA order_record
WITH JAQ TRANSFORMATIONS ON INGESTION $jaq$
.orders[] | {
order_id: .order.id,
customer: .order.customer,
status: .order.state,
amount: .totals.amount,
quantity: .totals.quantity
}
$jaq$;
An ingestor decoding with partner_batch_codec turns a body of {"orders":[...]} into one
order_record per element, in array order. The body is still accepted or rejected as a whole: if
one element does not fit the schema, no record from that body enters the graph.
Unfolding Payloads covers acknowledgement, limits, and
diagnostics.
Next: binary payloads in Protobuf Codecs.
Protobuf Codecs
Protobuf payloads are decoded from real .proto definitions. The definitions are uploaded as a
versioned resource; the codec compiles them, decodes each payload into
the message’s JSON view, and hands that to a JAQ program — the same bridge as the previous step.
The Proto File
Create proto/order_event.proto in your working directory, with field names matching
order_record:
syntax = "proto3";
package quickstart;
message OrderEvent {
string order_id = 1;
string customer = 2;
string status = 3;
int64 amount = 4;
int64 quantity = 5;
}
Upload It As A Resource
UPLOAD RESOURCE is a client-side statement — the CLI reads the directory from your machine and
streams it to the cluster — so it cannot sit inside a transaction, and its path resolves relative
to where nervix-cli runs:
BEGIN;
CREATE RESOURCE order_proto;
COMMIT;
UPLOAD RESOURCE order_proto VERSION './proto';
The client reports uploaded resource version 1. Uploads are immutable and versioned; see
Versioning.
The Codec And Its Endpoint
The clause order is rigid: resource and its mandatory version, CONFIG (mandatory —
file/include select and root the .proto files inside the resource), the fully-qualified
MESSAGE name, then the target schema and JAQ program. VERSION 1 names the upload above;
VERSION LATEST would bind the highest completed version when the statement is applied and store
that number. The message’s JSON view keeps snake_case proto field names and numeric 64-bit
integers, so a field-for-field message needs only the identity program '.':
BEGIN;
CREATE CODEC order_protobuf_codec
FROM PROTOBUF
USING RESOURCE order_proto VERSION 1
CONFIG {'file' = 'order_event.proto', 'include' = '.'}
MESSAGE 'quickstart.OrderEvent'
TO SCHEMA order_record
WITH JAQ TRANSFORMATIONS ON INGESTION '.';
CREATE RELAY proto_orders SCHEMA order_record UNBRANCHED;
CREATE ENDPOINT proto_ingress ON edge PATH '/proto-orders' TYPE HTTP;
CREATE HTTP ENDPOINT INGESTOR proto_order_source
FROM proto_ingress MODE NO ACK
ON QUIESCE BUFFER MAX SIZE 1MiB DECODE USING order_protobuf_codec
TO proto_orders
INHERIT ALL
UNBRANCHED
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
COMMIT;
Because it declares only ON INGESTION, this codec is decode-only; encoding to protobuf requires
an ON EMITTING program. A DATETIME field would ride in a proto string plus
ENCODE <field> AS RFC3339 on the codec (Codecs).
Send A Binary Order
Watch the relay:
nervix-cli --domain quickstart subscribe proto_watch proto_orders
protoc --encode turns protobuf text format into wire bytes, which curl posts as the raw body:
protoc --encode=quickstart.OrderEvent proto/order_event.proto <<'EOF' > order.bin
order_id: "o-6001"
customer: "acme"
status: "new"
amount: 4200
quantity: 6
EOF
curl -i -X POST http://127.0.0.1:8080/proto-orders \
-H 'Host: orders.example.com' \
-H 'Content-Type: application/octet-stream' \
--data-binary @order.bin
The decoded record appears in the subscription — typed, with no JSON anywhere on the wire.
Next: extend the expression language itself in User-Defined Functions.
User-Defined Functions
The CASE expression in Conditional Routing works, but the
banding logic is trapped inside one junction. A UDF packages it as a named function every node can
call. UDFs are written in Roto, compiled to native code, and operate
on whole Arrow columns at a time; the source is inline in the statement — there is no separate
upload step. See Declaration.
Declare The Function
BEGIN;
CREATE UDF amount_band
WITH ROTO_0_13
ARGS (amount I64)
RETURNS STRING
CODE $roto$
fn amount_band(amount: I64Column) -> StringColumn {
when_s(amount.gt_s(9999), "vip")
.when_s(amount.gt_s(999), "high")
.otherwise_s("routine")
}
$roto$;
The declared signature must exactly match the Roto entry function — same name, matching column
types (an I64 argument is an I64Column), no implicit casts. $roto$ ... $roto$ is a dollar-quoted string, so
the body spans lines verbatim. The available column operations are cataloged in
Roto Column Operations; UDFs are deterministic unless declared
VOLATILE (Nulls, Errors, And Volatility).
Call It
Calls always use the udf:: qualifier and compose with builtins and route filters
(Calling UDFs):
CREATE RELAY banded_orders SCHEMA order_tiered UNBRANCHED;
CREATE JUNCTION band_orders
FROM orders
UNBRANCHED
TO banded_orders
INHERIT ALL
SET tier = udf::amount_band(input.amount)
WHERE udf::amount_band(input.amount) != "routine"
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG;
COMMIT;
This reuses the order_tiered schema from Conditional Routing — the same enrichment, now
expressed once and callable from any junction, filter, or route in the domain.
Watch It Classify
nervix-cli --domain quickstart subscribe band_watch banded_orders
Produce orders with amounts 500, 2500, and 25000 on the Kafka topic (the junction reads the
Kafka-fed orders relay). The 500 order is filtered out by the route WHERE; the others arrive
with "tier":"high" and "tier":"vip".
Lifecycle notes: changing the body means DROP UDF amount_band; and
recreating it, and the drop is rejected while any node still references the function
(Introspection And Lifecycle). For where UDFs sit between
builtins and WASM processors, see
Choosing An Extension Tier.
Next: aggregate the stream in Window Aggregates.
Window Aggregates
Individual orders are flowing; now summarize them. A
window processor collects records into sliding windows and
constructs aggregate output. Declared BRANCHED BY by_customer, it windows each customer
independently — building directly on Branched Processing.
The Summary Model
Window routes are set-only: every output field is an aggregate expression, so the output relay needs its own summary schema (the order schema cannot be reused unless every field is aggregated):
BEGIN;
CREATE SCHEMA order_window (
customer STRING,
order_count I64,
total_amount I64,
max_amount I64,
avg_amount F64
);
CREATE RELAY order_windows SCHEMA order_window BRANCHED BY by_customer;
The Window Processor
Windows use WIDTH and STEP instead of FLUSH. Each bound can count messages, elapse a
duration, or combine both — a combined bound closes the window when either condition is met:
CREATE WINDOW PROCESSOR customer_order_window
FROM orders_by_customer
WIDTH 3 MESSAGES 30s DURATION
STEP 3 MESSAGES 30s DURATION
BRANCHED BY by_customer
TO order_windows
SET customer = FIRST(input.customer),
order_count = COUNT(input.order_id),
total_amount = SUM(input.amount),
max_amount = MAX(input.amount),
avg_amount = AVG(input.amount)
ON MESSAGE ERROR LOG;
COMMIT;
- The aggregate functions are
COUNT,COUNT_IF,SUM,AVG,MIN,MAX,FIRST,LAST,ARG_MIN,ARG_MAX,BOOL_AND,BOOL_OR,VAR_POP,VAR_SAMP,STDDEV_POP,STDDEV_SAMP,COVAR_POP,COVAR_SAMP,CORR, andPERCENTILE_LINEAR_HISTOGRAM, and the approximateAPPROX_COUNT_DISTINCT,APPROX_QUANTILE, andAPPROX_TOP_K. Aggregate calls also participate in larger scalar expressions. See Window Aggregates for their types, null handling, numerical behavior, and the bounded state the approximate ones need. - A null argument contributes nothing to an aggregate. An aggregate that can be null, such as
VAR_SAMPor any aggregate over anOPTIONALfield, needs anOPTIONALoutput field or aCOALESCE. input.<field>is valid only inside aggregate arguments; a barecustomer = input.customeris rejected. Aggregates cannot be nested.WIDTHequal toSTEPmakes the windows tumbling (no overlap); a smallerSTEPslides them.STEPmay never exceedWIDTH.- Every required field of
order_windowmust be assigned, and everySETtarget must exist in the schema.
Watch The Summaries
nervix-cli --domain quickstart subscribe window_watch order_windows
Produce three acme orders. When the third arrives, one summary record for the acme branch
appears — order_count of 3, with the totals. Interleave globex orders and you will see that
each customer’s window fills separately: the branch keeps window state per customer, exactly like
the deduplicator’s history. If a window stays partially filled, the 30s duration bound closes it.
Duration bounds start from record timestamp watermarks and become due when a later watermark or
the bound domain clock reaches the target. A paced domain’s TIME RATE changes the real wait for a
partially filled window while preserving the records’ external event timestamps. The output keeps
the window’s minimum low watermark and records the emission snapshot as its high watermark. See
Ingestion Timestamps.
Next: produce records without any input at all in Generators.
Generators
Every node so far reacts to input. A generator is the opposite: it has no input relay and produces records on a timer, reading a snapshot of materialized relay state on every tick. The classic use is a per-key heartbeat or periodic re-publication of the latest known value.
Materialize The Relay
Materialized state keeps the latest full record per branch instance. It is a relay property, and
an existing relay can adopt it with ALTER RELAY
(Altering Relays):
BEGIN;
ALTER RELAY orders_by_customer SET MATERIALIZED STATE LAST BY TIMESTAMP;
LAST BY TIMESTAMP keeps the newest record per customer according to record timestamp
watermarks — ingestion time works out of the box in an unpaced domain
(Ingestion Timestamps).
The Generator
A generator declares exactly one materialized relay and reads it through the
relay_state.<relay>.<field> namespace. Its routes are set-only and, unlike windows, do
require a flush policy. Branch identity is node-wide: the generator, its materialized relay, and
its output relay must share the exact branch.
CREATE SCHEMA order_heartbeat (
customer STRING,
last_order_id STRING,
last_amount I64
);
CREATE RELAY order_heartbeats SCHEMA order_heartbeat BRANCHED BY by_customer;
CREATE GENERATOR order_heartbeat_source
USING MATERIALIZED STATE orders_by_customer
EACH 5s
BRANCHED BY by_customer
TO order_heartbeats
SET customer = relay_state.orders_by_customer.customer,
last_order_id = relay_state.orders_by_customer.order_id,
last_amount = relay_state.orders_by_customer.amount
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG;
COMMIT;
When a customer’s branch first materializes, that branch gets its own generator task; every 5s
tick reads an immutable snapshot of the customer’s latest order and constructs one heartbeat.
Branch eviction (the branch TTL) stops the task and drops its state. In an unpaced domain the
cadence follows wall clock time; paced domains tick on the domain clock
(Domains And Time).
The first generator occurrence is immediate when the generator starts. Later occurrences stay on
the original EACH schedule. Slow work coalesces missed ticks into one generation at the newest
due occurrence, then continues at the first future boundary without a catch-up burst.
Watch It Tick
nervix-cli --domain quickstart subscribe heartbeat_watch order_heartbeats
Nothing appears yet — a branched generator is silent until a branch exists. Produce one acme
order and heartbeats start arriving every five seconds, repeating the latest acme order.
Produce a globex order and a second, independent heartbeat stream joins in. Send a newer acme
order and the acme heartbeat switches to it on the next tick.
One lifecycle caveat: START clears materialized state for the domain, so after a restart the
generators stay silent until new records materialize branches again
(Materialized State).
Next: join two streams in Correlators.
Correlators
Orders are one stream; payments are another. A correlator joins two
streams on a predicate, holding unmatched records in a bounded buffer until their partner arrives
or a deadline passes. There is no ambiguous default input — a correlator has explicit LEFT and
RIGHT sides, and every expression reads through the left. and right. scopes.
Branch identity is node-wide here too: the left relays, right relays, output relays, and timeout
targets must all share the correlator’s exact branch. This one is fully unbranched, joining the
Kafka-fed orders relay with a new payments feed.
The Payments Feed
BEGIN;
CREATE SCHEMA payment_record (
order_id STRING,
payment_id STRING,
amount I64
);
CREATE WIRE JSON SCHEMA payment_wire MODE STRICT (
order_id string,
payment_id string,
amount integer
);
CREATE CODEC payment_codec
FROM WIRE JSON SCHEMA payment_wire
TO SCHEMA payment_record;
CREATE RELAY payments SCHEMA payment_record UNBRANCHED;
CREATE ENDPOINT payment_ingress ON edge PATH '/payments' TYPE HTTP;
CREATE HTTP ENDPOINT INGESTOR payment_source
FROM payment_ingress MODE NO ACK
ON QUIESCE BUFFER MAX SIZE 1MiB DECODE USING payment_codec
TO payments
INHERIT ALL
UNBRANCHED
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
The Correlator
Matched pairs become paid_orders records. Orders that see no payment within MAX TIME are
forwarded to unpaid_orders by the left side’s timeout action; unmatched payments are dropped by
the right side’s:
CREATE SCHEMA paid_order (
order_id STRING,
customer STRING,
amount I64,
payment_id STRING
);
CREATE RELAY paid_orders SCHEMA paid_order UNBRANCHED;
CREATE RELAY unpaid_orders SCHEMA order_record UNBRANCHED;
CREATE CORRELATOR correlate_payments
LEFT FROM orders
RIGHT FROM payments
CORRELATE WHERE left.order_id = right.order_id
MATCH EARLIEST
MAX TIME 30s
ON CORRELATION TIMEOUT SEND TO unpaid_orders, DROP
UNBRANCHED
TO paid_orders
SET order_id = left.order_id,
customer = left.customer,
amount = left.amount,
payment_id = right.payment_id
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG;
COMMIT;
Reading the clauses:
CORRELATE WHEREis a full boolean expression — equality is typical, but function calls andANDchains work (left.order_id = right.order_id AND left.amount = right.amount).MATCH EARLIESTpairs an arriving record with the oldest pending candidate on the other side (LATESTtakes the newest). Matching is one-to-one and consuming: the chosen pair leaves the buffers, and each record correlates at most once.MAX TIMEbounds how long an unmatched record waits — and therefore the correlator’s memory (Capacity Planning For Branched Graphs).ON CORRELATION TIMEOUT <left-action>, <right-action>takes exactly two actions —DROPorSEND TO <relay>. A timeout forwards the original record unchanged, so the target relay’s schema must be compatible with that side’s schema —unpaid_ordersreusesorder_record.- Routes are set-only:
INHERITis rejected, and every required field ofpaid_ordermust be assigned fromleft.,right., oroutput.values (Filters And Construction). In anON MESSAGE ERROR SEND TOhandler, a correlator exposesleftandrightinstead ofinput(Message Errors).
Pay An Order
Watch both outcomes:
nervix-cli --domain quickstart subscribe paid_watch paid_orders
nervix-cli --domain quickstart subscribe unpaid_watch unpaid_orders
Produce an order on the Kafka topic, then pay it over HTTP:
{"order_id":"o-7001","customer":"acme","status":"new","amount":3000,"quantity":1}
curl -i -X POST http://127.0.0.1:8080/payments \
-H 'Host: orders.example.com' \
-H 'Content-Type: application/json' \
-d '{"order_id":"o-7001","payment_id":"p-1","amount":3000}'
The joined record appears on paid_orders. Now produce order o-7002 and pay nothing: thirty
seconds later the original order surfaces on unpaid_orders — the timeout path in action.
Next: change the running graph without tearing it down in Altering A Running Graph.
Altering A Running Graph
Nothing built so far required tearing anything down — every step added nodes. ALTER statements
change what already exists: junction logic, relay properties, schemas, ingestor sources, emitter
sinks, stateful processor behavior, reingestor wiring, and generator cadence or routes. Nervix
classifies each validated change and pauses as little as possible — the statement result reports
the executed level
(ALTER Lock And Quiesce Classification):
DYNAMIC— hot-applied; nothing pauses (relay capacity, junction filters and route logic, deduplicator or reordererMAX TIME, emitter flush policy)ENTITY_PAUSE— only the affected node or relay is gated and drained (topology changes, deduplication keys, reorderer ordering, ingestor, reingestor, generator, and emitter reconfiguration, relay materialized state — theALTER RELAYin Generators ran at this level)DOMAIN_PAUSE— domain ingestion stops, in-flight work drains, the new graph is installed atomically, and flow resumes (schema and branching changes)
Before altering anything, SHOW CREATE <kind> <name>; prints the current canonical definition.
Change Routing Logic — Dynamic
Lower the high-value threshold from 1000 to 500. Route changes go through
ALTER JUNCTION; REPLACE ROUTE requires the complete
route body, not a diff:
ALTER JUNCTION route_orders
REPLACE ROUTE TO high_value_orders
INHERIT ALL
SET tier = CASE
WHEN input.amount >= 10000 THEN "vip"
ELSE "high"
END
WHERE output.amount >= 500
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG;
The result reports quiesce level: DYNAMIC — the domain never paused, buffered work stayed in
place, and the very next order is evaluated against the new threshold. Produce a 700-amount
order and watch it reach high_value_out, which the old threshold would have dropped.
Relay capacity is equally uneventful (Altering Relays, capacity semantics under Capacity):
ALTER RELAY orders SET CAPACITY 8;
Evolve A Schema — Domain Pause
Payments gain a method. A codec requires its wire schema to exactly match the internal schema,
so both must change in one transaction — the registry validates the complete candidate graph
before anything applies (Altering Schemas):
BEGIN;
ALTER WIRE JSON SCHEMA payment_wire
ADD FIELD method string OPTIONAL;
ALTER SCHEMA payment_record
ADD FIELD method STRING OPTIONAL;
COMMIT;
This reports quiesce level: DOMAIN_PAUSE: ingestion stops, buffered output is force-flushed (no
in-flight records are lost), the graph is swapped, and flow resumes — there is no user-facing
PAUSE statement, and a drain timeout rolls the whole batch back
(Automatic Model-Alteration Quiescing).
Add the field as OPTIONAL: old payloads without it keep decoding, and the field finalizes as a
typed null. Verify by POSTing a payment with "method":"card" to /payments and watching the
payments relay.
Two rules worth knowing before you lean on this:
- One alteration at a time per domain — a concurrent mutation is rejected with
already has a model alteration in progress, not queued. DROPplusCREATEof the same entity in one batch is compared as one modification — you cannot sneak a schema change past domain quiescing by recreating the relay.
Ingestors and emitters have their own operation sets —
Altering Ingestors (an ingestor’s ALTER repeats its connector
and entity, such as ALTER KAFKA TOPIC INGESTOR kafka_orders, and every ingestor change is
entity-pause) and
Altering Emitters (flush changes are dynamic; sink, client, and
codec changes are entity-pause; source-list membership changes are domain-pause). Processor
operations are covered under
Altering Deduplicators,
Altering Reorderers,
Altering Reingestors, and
Altering Generators.
Next: run the graph on a logical clock in Paced Domains.
Paced Domains
The quickstart domain is unpaced: records are admitted as they arrive and every cadence follows
wall clock time. A paced domain instead maintains a domain clock — records are admitted by
their event timestamps, and every time-driven behavior (generator cadence, branch TTL, duration
windows) follows the logical clock. Start the clock at a historical point with an accelerated
TIME RATE and the same graph replays the past faster than real time. See
Paced Domains.
This page builds a small replay domain next to quickstart.
A Second Domain
CREATE PACED DOMAIN quickstart_replay WITH PERIOD 200ms SKEW 100000h;
USE quickstart_replay;
PERIODis the logical spacing between domain ticks.SKEWis the admission window around each tick. It is deliberately huge here: the tutorial posts records with fixed historical timestamps, and they must fall inside a window anchored to the clock. Live feeds whose timestamps track the clock use small skews like1s.
A Timestamped Stream
Paced ingestion needs an event time, so this schema finally carries a DATETIME — which is also
the promised demonstration of the codec’s datetime encoding: the wire field is a string, and
ENCODE ... AS RFC3339 bridges it (Codecs).
BEGIN;
CREATE SCHEMA order_event (
order_id STRING,
amount I64,
occurred_at DATETIME
);
CREATE WIRE JSON SCHEMA order_event_wire MODE STRICT (
order_id string,
amount integer,
occurred_at string
);
CREATE CODEC order_event_codec
FROM WIRE JSON SCHEMA order_event_wire
TO SCHEMA order_event
ENCODE occurred_at AS RFC3339;
CREATE RELAY replay_orders
SCHEMA order_event UNBRANCHED
WITH MATERIALIZED STATE LAST BY TIMESTAMP;
CREATE VHOST replay_edge replay.example.com;
CREATE ENDPOINT replay_ingress ON replay_edge PATH '/events' TYPE HTTP;
CREATE HTTP ENDPOINT INGESTOR replay_source
FROM replay_ingress MODE NO ACK
ON QUIESCE BUFFER MAX SIZE 1MiB DECODE USING order_event_codec
TIMESTAMP AT occurred_at
TO replay_orders
INHERIT ALL
UNBRANCHED
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
TIMESTAMP AT occurred_at is not optional style here: in a paced domain every ingestor must
declare a timestamp source (TIMESTAMP NOW or TIMESTAMP AT <field>), or ingestion fails with
requires ingestor ... to declare TIMESTAMP NOW or TIMESTAMP AT <field>
(Ingestion Timestamps).
To make the clock visible, add a generator whose cadence is declared in logical seconds:
CREATE SCHEMA replay_heartbeat (
order_id STRING,
amount I64
);
CREATE RELAY replay_heartbeats SCHEMA replay_heartbeat UNBRANCHED;
CREATE GENERATOR replay_ticker
USING MATERIALIZED STATE replay_orders
EACH 10s
UNBRANCHED
TO replay_heartbeats
SET order_id = relay_state.replay_orders.order_id,
amount = relay_state.replay_orders.amount
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG;
COMMIT;
Start The Clock
A stopped paced domain has no ticks, so nothing is admitted — try POSTing now and the request fails. Then start the clock at a historical instant, running ten times faster than wall clock (Start And Stop):
START AT '2026-01-01T00:00:00Z' TIME RATE 10.0;
START; alone would resume from persisted state, and START AT NOW re-anchors to the current
wall clock; TIME RATE defaults to 1.0.
Replay The Past
nervix-cli --domain quickstart_replay subscribe replay_watch replay_heartbeats
curl -i -X POST http://127.0.0.1:8080/events \
-H 'Host: replay.example.com' \
-H 'Content-Type: application/json' \
-d '{"order_id":"o-8001","amount":1500,"occurred_at":"2026-01-01T00:00:05Z"}'
The record is admitted against its January event time, and heartbeats begin — declared
EACH 10s, they arrive roughly every second of wall clock, because ten logical seconds pass
per wall second at TIME RATE 10.0. Branch TTLs and duration windows in this domain would follow
the same logical clock, which is what makes accelerated replay reproducible; deterministic UDFs
preserve that reproducibility too (Domains And Time, UDF determinism under
Nulls, Errors, And Volatility).
A record whose timestamp falls outside every reached logical tick window is rejected at
ingestion — that is SKEW doing its job. And as everywhere, START clears materialized relay
state, so a fresh replay starts from an empty snapshot surface.
Where To Go Next
The tutorial graph now ingests from Kafka, HTTP, JSON envelopes, and protobuf; routes conditionally; partitions and deduplicates per customer; dead-letters failures; classifies with a custom function; aggregates windows; emits heartbeats from materialized state; joins payments to orders; evolves live; and replays history on a logical clock. The manual covers what remains:
- WASM processors run your own compiled transform logic
- Lookups and materialized dependencies enrich in-flight records from reference data
- Sessions provide the read-only subscriptions used throughout this tutorial
- Capacity Planning For Branched Graphs sizes branched graphs
- Metrics And Observability instruments everything you just built
- Examples collects complete runnable graphs, including the
examples/directory in the repository
Installation
Nervix does not yet publish stable binary or container-image releases. Choose the installation method for your environment:
- Cargo Install From GitHub builds and installs
nervix-serverandnervix-clifor local or non-Kubernetes use. - Docker runs a three-node cluster with Docker commands or Docker Compose.
- Kubernetes Operator installs the operator with Helm and manages
persistent Nervix clusters through
NervixClusterresources.
Pin a reviewed Git commit or container-image digest when you need a reproducible installation.
Cargo Install From GitHub
Install the Nervix server, the interactive CLI, and the NSPL formatter directly from the Nervix GitHub repository:
cargo install --locked \
--git https://github.com/nervix-io/nervix.git \
nervix-server nervix-cli nervix-nspl-format
This builds the binaries from source and installs them into Cargo’s binary directory, which is
$HOME/.cargo/bin by default. Ensure that directory is on your PATH.
The build requires Rust 1.99 or newer and the native build tools required by Nervix’s dependencies. The initial build can take several minutes.
Confirm that the commands are available:
nervix-server --help
nervix-cli --help
nervix-nspl-format --help
Pin A Git Revision
The unpinned command installs the current default branch. For a reproducible installation, select a reviewed commit:
cargo install --locked \
--git https://github.com/nervix-io/nervix.git \
--rev <commit-sha> \
nervix-server nervix-cli nervix-nspl-format
Run the same command with a different revision to upgrade. Add --force if Cargo reports that the
selected packages are already installed.
To remove both binaries:
cargo uninstall nervix-server nervix-cli nervix-nspl-format
Docker
Nervix publishes a rolling multi-architecture Debian image to
ghcr.io/nervix-io/nervix:debian-latest. The image contains nervix-server, nervix-cli, and
nervix-nspl-format. The examples on this page create a three-node cluster for local evaluation.
For a reproducible or production deployment, replace the rolling tag with a reviewed timestamped tag or image digest. Use TLS, a secret manager, and a client-reachable advertise hostname instead of the local-development defaults shown here.
CUDA Images
Build a local CUDA image from a repository checkout with just docker-build-cuda; the build
requirements are in Developing Nervix. The image
uses the cuda target in Dockerfile.debian; the CPU image uses the cpu target. Both copy
the same Nervix binaries from one shared builder stage. The CUDA image
contains ONNX Runtime 1.30.0’s CUDA 13 provider, CUDA 13.2.2, and cuDNN 9.25.1. It supports Linux
AMD64 and pins the Debian base to debian:trixie-20260918-slim and its image digest. CUDA and
cuDNN occupy one layer directly above that base. The next layer updates and upgrades Debian
packages while holding CUDA and cuDNN at their pinned versions. Nervix’s layers follow and
reuse that CUDA layer across application builds.
To expose an NVIDIA GPU, install a host driver supporting CUDA 13.2 and the NVIDIA Container
Toolkit, set NERVIX_IMAGE=nervix:cuda, and add --gpus all to the docker run commands below.
The container toolkit supplies the host driver; CUDA and cuDNN are supplied by the image.
Run Three Nodes With Docker
Choose the image and initial password. The password environment variable is expanded by the shell and passed only to the bootstrap node, which creates the default user with it. The other nodes need no password: they read that user from the cluster, and a node that stops gracefully asks the leader to drain it under its node certificate.
export NERVIX_IMAGE=ghcr.io/nervix-io/nervix:debian-latest
export NERVIX_INIT_DEFAULT_USER_PASSWORD='replace-with-a-password'
docker network create nervix
docker pull "$NERVIX_IMAGE"
Provision a CA and one certificate/key pair per node under ./tls before starting the containers:
tls/ca.pem
tls/node-1.pem
tls/node-1-key.pem
tls/node-2.pem
tls/node-2-key.pem
tls/node-3.pem
tls/node-3-key.pem
Each node certificate must permit TLS server and client authentication. It must contain its Docker
hostname (nervix-1, nervix-2, or nervix-3) as a DNS SAN and exactly one identity URI matching
its configured cluster and node, such as nervix://cluster/nervix-docker/node/node-1. Sign all
three certificates with the CA in ca.pem.
Start the bootstrap node:
docker run --detach \
--name nervix-1 \
--hostname nervix-1 \
--network nervix \
--restart unless-stopped \
--stop-timeout 60 \
--volume nervix-node-1-data:/var/lib/nervix \
--volume "$PWD/tls/ca.pem:/etc/nervix/interconnect/ca.pem:ro" \
--volume "$PWD/tls/node-1.pem:/etc/nervix/interconnect/node.pem:ro" \
--volume "$PWD/tls/node-1-key.pem:/etc/nervix/interconnect/node-key.pem:ro" \
--publish 47391:47391 \
--publish 47421:47420 \
--publish 9091:9090 \
--env NERVIX_ADDR=0.0.0.0:47391 \
--env NERVIX_GRPC_ADVERTISE_ADDR=127.0.0.1:47391 \
--env NERVIX_WEB_CONSOLE_LISTEN_ADDR=0.0.0.0:47420 \
--env NERVIX_WEB_CONSOLE_ADVERTISE_ADDR=127.0.0.1:47421 \
--env NERVIX_OBSERVABILITY_LISTEN_ADDR=0.0.0.0:9090 \
--env NERVIX_CLUSTER_ID=nervix-docker \
--env NERVIX_NODE_ID=node-1 \
--env NERVIX_INTERCONNECT_LISTEN_ADDR=0.0.0.0:47395 \
--env NERVIX_INTERCONNECT_ADVERTISE_ADDR=nervix-1:47395 \
--env NERVIX_INTERCONNECT_TLS_CA=/etc/nervix/interconnect/ca.pem \
--env NERVIX_INTERCONNECT_TLS_CERT=/etc/nervix/interconnect/node.pem \
--env NERVIX_INTERCONNECT_TLS_KEY=/etc/nervix/interconnect/node-key.pem \
--env NERVIX_REPLICA_COUNT=3 \
--env NERVIX_ALLOW_BOOTSTRAP=true \
--env NERVIX_INIT_DEFAULT_USER_PASSWORD \
"$NERVIX_IMAGE"
Start the other two nodes. Both discover the cluster through nervix-1 on the private Docker
network:
Keep each node’s named data volume across restarts. Once the three nodes have joined, their Raft
membership retains the advertised interconnect names. If nervix-1 stops while the other nodes
continue, it uses those names to find a survivor when it restarts; it does not need a second
configured bootstrap host. The joining nodes still use NERVIX_CLUSTER_BOOTSTRAP_HOST for their
first contact with a fresh cluster. Once they have recovered Raft peer endpoints from their volumes,
they can restart while that bootstrap host does not resolve and rejoin through another peer whose
name resolves. Keep the other members’ advertised names resolvable to the restarting node.
docker run --detach \
--name nervix-2 \
--hostname nervix-2 \
--network nervix \
--restart unless-stopped \
--stop-timeout 60 \
--volume nervix-node-2-data:/var/lib/nervix \
--volume "$PWD/tls/ca.pem:/etc/nervix/interconnect/ca.pem:ro" \
--volume "$PWD/tls/node-2.pem:/etc/nervix/interconnect/node.pem:ro" \
--volume "$PWD/tls/node-2-key.pem:/etc/nervix/interconnect/node-key.pem:ro" \
--publish 47392:47391 \
--publish 47422:47420 \
--publish 9092:9090 \
--env NERVIX_ADDR=0.0.0.0:47391 \
--env NERVIX_GRPC_ADVERTISE_ADDR=127.0.0.1:47392 \
--env NERVIX_WEB_CONSOLE_LISTEN_ADDR=0.0.0.0:47420 \
--env NERVIX_WEB_CONSOLE_ADVERTISE_ADDR=127.0.0.1:47422 \
--env NERVIX_OBSERVABILITY_LISTEN_ADDR=0.0.0.0:9090 \
--env NERVIX_CLUSTER_ID=nervix-docker \
--env NERVIX_NODE_ID=node-2 \
--env NERVIX_INTERCONNECT_LISTEN_ADDR=0.0.0.0:47395 \
--env NERVIX_INTERCONNECT_ADVERTISE_ADDR=nervix-2:47395 \
--env NERVIX_INTERCONNECT_TLS_CA=/etc/nervix/interconnect/ca.pem \
--env NERVIX_INTERCONNECT_TLS_CERT=/etc/nervix/interconnect/node.pem \
--env NERVIX_INTERCONNECT_TLS_KEY=/etc/nervix/interconnect/node-key.pem \
--env NERVIX_REPLICA_COUNT=3 \
--env NERVIX_CLUSTER_BOOTSTRAP_HOST=nervix-1:47395 \
"$NERVIX_IMAGE"
docker run --detach \
--name nervix-3 \
--hostname nervix-3 \
--network nervix \
--restart unless-stopped \
--stop-timeout 60 \
--volume nervix-node-3-data:/var/lib/nervix \
--volume "$PWD/tls/ca.pem:/etc/nervix/interconnect/ca.pem:ro" \
--volume "$PWD/tls/node-3.pem:/etc/nervix/interconnect/node.pem:ro" \
--volume "$PWD/tls/node-3-key.pem:/etc/nervix/interconnect/node-key.pem:ro" \
--publish 47393:47391 \
--publish 47423:47420 \
--publish 9093:9090 \
--env NERVIX_ADDR=0.0.0.0:47391 \
--env NERVIX_GRPC_ADVERTISE_ADDR=127.0.0.1:47393 \
--env NERVIX_WEB_CONSOLE_LISTEN_ADDR=0.0.0.0:47420 \
--env NERVIX_WEB_CONSOLE_ADVERTISE_ADDR=127.0.0.1:47423 \
--env NERVIX_OBSERVABILITY_LISTEN_ADDR=0.0.0.0:9090 \
--env NERVIX_CLUSTER_ID=nervix-docker \
--env NERVIX_NODE_ID=node-3 \
--env NERVIX_INTERCONNECT_LISTEN_ADDR=0.0.0.0:47395 \
--env NERVIX_INTERCONNECT_ADVERTISE_ADDR=nervix-3:47395 \
--env NERVIX_INTERCONNECT_TLS_CA=/etc/nervix/interconnect/ca.pem \
--env NERVIX_INTERCONNECT_TLS_CERT=/etc/nervix/interconnect/node.pem \
--env NERVIX_INTERCONNECT_TLS_KEY=/etc/nervix/interconnect/node-key.pem \
--env NERVIX_REPLICA_COUNT=3 \
--env NERVIX_CLUSTER_BOOTSTRAP_HOST=nervix-1:47395 \
"$NERVIX_IMAGE"
The host ports are:
| Node | gRPC | Web console | Health and metrics |
|---|---|---|---|
node-1 | 47391 | 47421 | 9091 |
node-2 | 47392 | 47422 | 9092 |
node-3 | 47393 | 47423 | 9093 |
Connect an installed CLI to http://127.0.0.1:47391, or open the
web console at http://127.0.0.1:47421/console/, and run:
SHOW CLUSTER STATUS;
The cluster status should show one local node, two live peer nodes, and all three nodes in the Raft membership. The CLI follows leader redirects through the three published gRPC ports.
Each node advertises its container name, such as nervix-1:47395, and finds its peers by those
names through Docker’s embedded DNS, which Docker names in the container’s /etc/resolv.conf. A
node resolves peer names, and the hosts its connectors reach, with its own asynchronous resolver
from that file and /etc/hosts, read once at startup; Name Resolution
describes the options that point it at other files or name servers, and what it does not support.
docker stop sends each node SIGTERM, which starts graceful shutdown, and kills a container only
if its node is still running when the stop timeout ends. A node ends its own shutdown within its
shutdown timeout, 50 seconds by default, which covers the 30-second default drain timeout and the
services that stop after it. The --stop-timeout 60 above keeps Docker waiting longer than that,
so a node that cannot finish shutting down exits with status 1 on its own rather than being
killed; with Docker’s default of ten seconds, a node could be killed partway through its drain.
Shutdown And Recovery explains what a node loses when its
grace period ends first.
Stop the nodes, then remove the containers and private network:
docker stop nervix-1 nervix-2 nervix-3
docker rm nervix-1 nervix-2 nervix-3
docker network rm nervix
The named volumes remain so the cluster can be recreated with its state intact. Removing
nervix-node-1-data, nervix-node-2-data, and nervix-node-3-data permanently deletes that
state.
Run Three Nodes With Docker Compose
Save the following as docker-compose.yml:
x-nervix-common: &nervix-common
image: ${NERVIX_IMAGE:-ghcr.io/nervix-io/nervix:debian-latest}
restart: unless-stopped
stop_grace_period: 60s
environment: &nervix-environment
NERVIX_ADDR: 0.0.0.0:47391
NERVIX_WEB_CONSOLE_LISTEN_ADDR: 0.0.0.0:47420
NERVIX_OBSERVABILITY_LISTEN_ADDR: 0.0.0.0:9090
NERVIX_CLUSTER_ID: nervix-docker
NERVIX_INTERCONNECT_LISTEN_ADDR: 0.0.0.0:47395
NERVIX_INTERCONNECT_TLS_CA: /etc/nervix/interconnect/ca.pem
NERVIX_INTERCONNECT_TLS_CERT: /etc/nervix/interconnect/node.pem
NERVIX_INTERCONNECT_TLS_KEY: /etc/nervix/interconnect/node-key.pem
NERVIX_REPLICA_COUNT: "3"
networks:
- nervix
services:
nervix-1:
<<: *nervix-common
hostname: nervix-1
environment:
<<: *nervix-environment
NERVIX_NODE_ID: node-1
NERVIX_GRPC_ADVERTISE_ADDR: 127.0.0.1:47391
NERVIX_WEB_CONSOLE_ADVERTISE_ADDR: 127.0.0.1:47421
NERVIX_INTERCONNECT_ADVERTISE_ADDR: nervix-1:47395
NERVIX_ALLOW_BOOTSTRAP: "true"
NERVIX_INIT_DEFAULT_USER_PASSWORD: ${NERVIX_INIT_DEFAULT_USER_PASSWORD:?set NERVIX_INIT_DEFAULT_USER_PASSWORD}
ports:
- "47391:47391"
- "47421:47420"
- "9091:9090"
volumes:
- nervix-node-1-data:/var/lib/nervix
- ./tls/ca.pem:/etc/nervix/interconnect/ca.pem:ro
- ./tls/node-1.pem:/etc/nervix/interconnect/node.pem:ro
- ./tls/node-1-key.pem:/etc/nervix/interconnect/node-key.pem:ro
nervix-2:
<<: *nervix-common
hostname: nervix-2
depends_on:
- nervix-1
environment:
<<: *nervix-environment
NERVIX_NODE_ID: node-2
NERVIX_GRPC_ADVERTISE_ADDR: 127.0.0.1:47392
NERVIX_WEB_CONSOLE_ADVERTISE_ADDR: 127.0.0.1:47422
NERVIX_INTERCONNECT_ADVERTISE_ADDR: nervix-2:47395
NERVIX_CLUSTER_BOOTSTRAP_HOST: nervix-1:47395
ports:
- "47392:47391"
- "47422:47420"
- "9092:9090"
volumes:
- nervix-node-2-data:/var/lib/nervix
- ./tls/ca.pem:/etc/nervix/interconnect/ca.pem:ro
- ./tls/node-2.pem:/etc/nervix/interconnect/node.pem:ro
- ./tls/node-2-key.pem:/etc/nervix/interconnect/node-key.pem:ro
nervix-3:
<<: *nervix-common
hostname: nervix-3
depends_on:
- nervix-1
environment:
<<: *nervix-environment
NERVIX_NODE_ID: node-3
NERVIX_GRPC_ADVERTISE_ADDR: 127.0.0.1:47393
NERVIX_WEB_CONSOLE_ADVERTISE_ADDR: 127.0.0.1:47423
NERVIX_INTERCONNECT_ADVERTISE_ADDR: nervix-3:47395
NERVIX_CLUSTER_BOOTSTRAP_HOST: nervix-1:47395
ports:
- "47393:47391"
- "47423:47420"
- "9093:9090"
volumes:
- nervix-node-3-data:/var/lib/nervix
- ./tls/ca.pem:/etc/nervix/interconnect/ca.pem:ro
- ./tls/node-3.pem:/etc/nervix/interconnect/node.pem:ro
- ./tls/node-3-key.pem:/etc/nervix/interconnect/node-key.pem:ro
networks:
nervix:
volumes:
nervix-node-1-data:
nervix-node-2-data:
nervix-node-3-data:
Set the required initial password and start the cluster from the directory containing that file:
export NERVIX_INIT_DEFAULT_USER_PASSWORD='replace-with-a-password'
docker compose up --detach
Set NERVIX_IMAGE before docker compose up to use a pinned tag or digest instead of
debian-latest.
Inspect the containers and follow their logs:
docker compose ps
docker compose logs --follow
Use the same host ports in the table above. Stop the cluster while preserving its volumes:
docker compose down
docker compose down sends each node SIGTERM and waits up to the file’s stop_grace_period of 60
seconds, longer than each node’s 50-second shutdown timeout, for graceful shutdown to finish before
it kills a container. To permanently delete all three nodes’ persisted state, add --volumes.
Kubernetes Operator
The Nervix Kubernetes Operator watches
namespaced NervixCluster resources across a Kubernetes cluster. For each resource, it creates the
services, StatefulSet, and one persistent volume claim per Nervix node. It also coordinates
one-time initialization of the default user before scaling the StatefulSet to the requested
replica count.
Prerequisites
Before installing the operator, ensure that:
kubectland Helm 3 can reach the Kubernetes cluster.- The cluster has a default
StorageClassthat can provision persistent volumes. - Your Kubernetes identity can create custom resource definitions and cluster-wide RBAC resources.
- Cluster nodes can pull images from
ghcr.io.
Install The Operator With Helm
The Helm chart is currently distributed in the operator repository rather than as a versioned chart release. Clone the repository and install its bundled chart:
git clone --depth 1 https://github.com/nervix-io/nervix-k8s-operator.git
helm upgrade --install nervix-k8s-operator \
./nervix-k8s-operator/charts/nervix-k8s-operator \
--namespace nervix-system \
--create-namespace
Wait for the operator to become available:
kubectl --namespace nervix-system rollout status \
deployment/nervix-k8s-operator
The chart installs the NervixCluster custom resource definition and deploys one operator that can
manage Nervix clusters in any namespace.
Create The Initial User Secret
Create a namespace and a Secret containing the initial password for the default user. Use a
password file that contains the exact password without a trailing newline, and do not commit that
file:
kubectl create namespace nervix
kubectl --namespace nervix create secret generic nervix-initial-password \
--from-file=password=/secure/path/to/password
The operator exposes this credential only to the first bootstrap pod. After authentication proves that Nervix committed the user, the operator restarts that pod without the credential and then scales the cluster to the requested replica count.
Create A Nervix Cluster
Save the following resource as nervix-cluster.yaml:
apiVersion: nervix.io/v1alpha1
kind: NervixCluster
metadata:
name: nervix
namespace: nervix
spec:
image: ghcr.io/nervix-io/nervix:debian-latest
replicas: 3
clusterId: nervix-kube
initialDefaultUserPasswordSecretRef:
name: nervix-initial-password
key: password
storage: 5Gi
Apply it and watch the cluster become ready:
kubectl apply --filename nervix-cluster.yaml
kubectl --namespace nervix get nervixcluster nervix --watch
The READY count should eventually match REPLICAS. Inspect the objects owned by the operator with:
kubectl --namespace nervix get pods,services,persistentvolumeclaims
One operator can manage multiple Nervix clusters. Create each cluster as a separate
NervixCluster, usually in a separate namespace or with a distinct name in the same namespace.
Enable Local Access
For development clusters that need direct access from outside Kubernetes, add this to the
NervixCluster spec:
localAccess:
enabled: true
Apply the updated resource:
kubectl apply --filename nervix-cluster.yaml
The default NodePorts are 31390 for the gRPC entry point, 31420 for the web console, and 31090
for health and metrics. The operator also creates per-node NodePorts so clients redirected to the
leader can reach it.
Connect the CLI to the gRPC entry point:
nervix-cli --server http://<node-address>:31390
The web console is available at
http://<node-address>:31420/console/. Omit localAccess for an in-cluster-only deployment.
Shutdown And Grace Periods
Deleting a pod, draining a Kubernetes node, or rolling the StatefulSet sends SIGTERM, which starts
graceful shutdown. A Nervix node ends its own shutdown within its shutdown timeout, 50 seconds by
default, which covers the 30-second default drain timeout and the services that stop after it. The
operator sets terminationGracePeriodSeconds: 60 so the kubelet waits longer than that, and a node
that cannot finish exits on its own rather than being killed partway through its drain. If you raise
NERVIX_SHUTDOWN_TIMEOUT, raise the grace period with it.
Note that /readyz reports whether the node currently knows a Raft leader; it does not turn
negative when a node begins terminating. A draining node is therefore not removed from Service
endpoints by readiness alone. It stops accepting new client connections itself in the first
shutdown phase, and it keeps serving already-admitted work until its drain finishes.
A terminating node moves its scheduled work to a live peer when one is available, and otherwise completes everything it has admitted in place. Rolling one pod at a time keeps a peer available to receive that work, so ownership moves instead of waiting for the pod to come back. See Shutdown And Recovery for the phases, the drain guarantees, and what each ending preserves.
Production Considerations
Both the chart and the example currently default to rolling latest image tags. Pin reviewed
operator and Nervix image tags or digests before using this setup in production. Also choose
storage, resource requests, NodePort exposure, and Secret management appropriate for the cluster.
Client Tools
Nervix ships two interactive clients, and both speak the same language to the same place: they open a session against the cluster’s session API and submit NSPL to the leader. Almost every statement in this book can be typed into either one, and neither is required — a Nervix cluster is administered entirely through NSPL, whatever submits it.
Choosing A Client
| Client | Where it runs | Use it for |
|---|---|---|
| Command Line Client | your terminal, installed separately | interactive work, scripting, shell completions, streaming a relay to stdout, node administration |
| Web Console | your browser, served by every node | reading a running graph, exploring an unfamiliar domain, guided subscriptions, uploading resources, taking and restoring backups |
| Rust Client Library | your own program | embedding Nervix control and subscriptions in a Rust application |
One further tool is not a client at all: nervix-nspl-format formats
saved .nspl files and checks that they are formatted. It never opens a session.
The console is the better tool for understanding a graph you did not write, because it draws one. The command line client is the better tool for repeating yourself, because it can be scripted.
Only a few things differ. Uploading a resource version, and restoring a backup archive, name a local
path in the command line client and use a file picker in the console; a backup’s archive is written
to a local file by the command line client and saved as a browser download by the console, and only
the command line client reads an archive with DESCRIBE BACKUP; the command line client follows a
domain clock from its domain-clock subcommand or with ATTACH DOMAIN CLOCK, while the console
follows the clock of the domain it has selected and shows it in its clock panel; the console
additionally offers guided actions — clicking a graph item or an entity — that simply type NSPL for
you.
What Every Client Shares
- The same statement surface. Domains, schemas, codecs, relays, clients, runtime nodes, and emitters are all declared in NSPL (NSPL Overview).
- Session and transaction statements.
USE,LIST DOMAINS,UPLOAD RESOURCE,CREATE SUBSCRIPTION,DELETE SUBSCRIPTION,ATTACH DOMAIN CLOCK, andDETACH DOMAIN CLOCKare session/client operations.BEGIN,COMMIT, andREVERToperate on a leader-owned, replicated transaction. - Server-driven completion. Suggestions come from the cluster, not from a local copy of the grammar, so they include the identifiers that actually exist in the active domain. Inside a transaction they also include the identifiers its queued statements define.
- HTTP Basic authentication against a registry user.
- Automatic leader redirects. A session opened against a follower is redirected to the current leader; you do not need to know which node leads.
- Automatic transaction attach. Both interactive clients retain the transaction id and attach it before replaying a command after reconnect or leader failover, and do not repeat an operation whose replicated progress already advanced.
Sessions, subscriptions, sampling, backpressure, and domain clock attachment are described in Sessions.
To get a cluster running first, see Installation and Running Nervix.
Command Line Client
nervix-cli is the interactive NSPL client. It is installed alongside nervix-server
(Cargo Install From GitHub) and is also present in the container image
(Docker).
Connecting
With no arguments the client connects to http://127.0.0.1:47391 as user default and prompts for
the password:
nervix-cli
nervix-cli connected to http://127.0.0.1:47391
Type 'exit' to quit. Trailing ';' is optional.
[events] notifications are printed above the prompt
nervix[default]>
Point it at another cluster with --server, which takes a full URL. An https:// URL enables TLS:
nervix-cli --server https://nervix.example.com:47390 --tls required --tls-ca-cert ./ca.pem
Options
| Option | Environment variable | Default | Meaning |
|---|---|---|---|
--server <URL> | http://127.0.0.1:47391 | session gRPC endpoint; the scheme selects TLS | |
--tls <preferred|required> | preferred | required refuses to connect over a non-https URL | |
--tls-ca-cert <PATH> | PEM certificate authority used to verify the server | ||
--dns-resolver-config <PATH> | NERVIX_DNS_RESOLVER_CONFIG | /etc/resolv.conf | resolver configuration for native hostname connections |
--dns-hosts-file <PATH> | NERVIX_DNS_HOSTS_FILE | /etc/hosts | hosts file consulted before DNS |
--dns-name-server <ADDRESS:PORT> | NERVIX_DNS_NAME_SERVERS | from resolver configuration | replacement name server; repeatable or comma-separated |
--domain <NAME> | default | domain the session starts in | |
--username <NAME> | NERVIX_USERNAME | default | registry user |
--password <PASSWORD> | NERVIX_PASSWORD | prompted interactively when unset | |
--command <NSPL> | run statements once and exit |
There is no general CLI configuration file and no --version flag. --server, --domain, and the
TLS options have no environment-variable equivalents. The DNS options select the resolver used by
the native session; the system resolver and hosts files are the defaults, and
Name Resolution describes how that resolver answers. The password can come
from NERVIX_PASSWORD so it need not appear in shell history.
The table above is the short version. nervix-cli Reference is printed by the binary itself while this book is built, so it is the authoritative list of every option and subcommand for this release.
Modes
The client runs in exactly one of three modes, in this order of precedence:
- A subcommand, such as
subscribe,domain-clock,backup,restore, ordrain-node. --command, which submits NSPL, prints the result, and exits.- The interactive REPL, when neither of the above is given.
There is no file-execution mode. To run a saved script, pass it through --command; to format
one, use nervix-nspl-format:
nervix-cli --domain quickstart --command "$(cat pipeline.nspl)"
The Interactive REPL
Prompt And Multi-Line Input
The prompt shows the active domain, and continuation lines are marked with dots:
nervix[quickstart]> CREATE SCHEMA order_record (
....[quickstart]> order_id STRING,
....[quickstart]> amount I64
....[quickstart]> );
Input accumulates until the buffered statements parse or the line ends with ;, so a trailing
semicolon is optional on a complete statement and a partial one simply keeps buffering. While a
transaction is open the prompt says so:
nervix[quickstart tx]>
After COMMIT is accepted, an in-progress commit is shown as
nervix[quickstart committing]>. The prompt is driven by replicated transaction status rather
than a local boolean.
Completion
Tab completes. Suggestions are computed by the server for the exact cursor position, so they
cover grammar keywords and the identifiers that actually exist: models in the active domain,
resource names and versions, session subscription names, and domain names. Inside a quoted path
that names a file or directory on this machine, such as UPLOAD RESOURCE ... VERSION '<path>',
BACKUP ... TO '<path>', DESCRIBE BACKUP '<path>', and RESTORE ... FROM '<path>', the client
completes local filesystem paths instead, expanding ~ to your home directory.
The replacement covers the current word even when the cursor is inside it, and keeps the text after that word. The CLI reads successive bounded pages when the server has more matches. A missing or stale domain or transaction context does not produce candidates from a different configuration.
Scripts can request the same candidates with --suggest 'DROP SCHEMA ord' --cursor 15.
The cursor is a UTF-8 byte offset; omitting it selects the end of the input. The CLI prints JSON
containing a status and each candidate’s display value, kind, and exact text edit.
While a transaction is open, completion describes the configuration that transaction is building:
models and resources its queued statements create are suggested before COMMIT, and a model whose
DROP is queued stops being suggested until a later statement recreates it. Only the session bound
to the transaction sees them; every other session is offered committed configuration alone. A
queued resource has no versions to suggest, because UPLOAD RESOURCE is not transaction content.
History
Submitted lines are stored in .nervix_client_history, relative to the directory the client was
started in, capped at 200 entries. ArrowUp and ArrowDown walk it.
Diagnostics
Server-reported errors are rendered as annotated reports against the text you submitted, with the offending span highlighted in place rather than described by offset.
Asynchronous Output
Subscription deliveries, server notifications, and state changes and ticks of an attached clock arrive independently of the prompt and are printed above it:
[events] subscription [watch] from [orders]: {"order_id":"o-1001","amount":1500}
[events] server ERROR: emitter 'redis_orders' publish failed
[events] topology INFO: raft transition: node-2 became leader
[events] domain clock [simulation]: generation 2, stopped
[events] domain clock [simulation] tick: generation 3, id 12, boundary 2030-01-01T00:00:01.100Z, authority UTC 2026-09-27T00:00:00Z, node logical 2030-01-01T00:00:01.120Z
A domain clock line follows each newer state or accepted tick after the attach reply. A state or
tick that changes again before it is delivered or read is replaced by the newest one, so a slow
client can skip states and tick ids. Between prompts the REPL holds at most 128 event lines, each
cut at 8 KiB with … [line truncated]; lines that arrive while it is full are dropped, and
[events] notice: <n> event lines were omitted because terminal output could not keep up reports
how many. When the server ends an attachment, or the session holding it is interrupted and the clock
is attached again on the next session, the line reads [events] domain clock [<domain>] notice: ...
with the reason.
The REPL prints these lines when it draws its next prompt: after the statement that is running finishes, or when you press Enter on an empty line.
Output keeps printing across reconnects. When the session is lost, the next statement you run opens a new one; the client also opens one at once while a subscription or a followed clock waits to be restored. Server notices then resume with the new session, rows arrive from every subscription restored on it or opened after it, and a followed clock prints its state again once it is attached there. When the client cannot open a session within its 120-second retry deadline, a line names what is waiting and the whole chain of why, and the client keeps trying:
[events] notice: subscription events could not resume yet: failed to connect to server: transport error: tcp connect error: tcp connect error: Connection refused (os error 111); the client keeps trying
After a reconnect the CLI opens every subscription and attaches every clock again. When the new session refuses one, a notice line reports the server’s message and when the next attempt follows:
[events] subscription [watch] notice: opening the subscription again failed: stream 'orders' does not exist in domain 'quickstart'; the next attempt follows in 2s
[events] domain clock [simulation] notice: attaching the clock again failed: session-scoped and client-local statements cannot be queued in a transaction; the next attempt follows in 2s
DELETE SUBSCRIPTION of a subscription no open session holds, because its session ended or the new
session refused to open it again, completes at once and frees the name.
When the server ends a subscription because its relay was redefined or removed, the subscription’s last line reads:
[events] subscription [watch] notice: the subscription ended: session subscription 'watch' ended because relay 'orders' in domain 'quickstart' no longer exists
The CLI never opens an ended subscription again, including after a reconnect. CREATE SUBSCRIPTION
under the same name opens it again under the relay’s current definition, and DELETE SUBSCRIPTION
of it completes at once and frees the name.
If server notices arrive faster than the CLI reads them, the client drops the ones it held and
[events] notice: server notices were dropped because they arrived faster than they were read
marks the gap; the notices after it keep printing.
Leaving
exit, quit, Ctrl-D, or Ctrl-C.
Session And Transaction Statements
Some statements affect the client session or replicated transaction state rather than directly applying one model change:
| Statement | Effect |
|---|---|
USE <domain> | switch the session’s active domain |
LIST DOMAINS | list domains with pace and status |
BEGIN / COMMIT / REVERT | open, apply, or discard a replicated transaction on the leader |
DESCRIBE TRANSACTION ['<id>'] [OPERATION <n>] [FORMAT TEXT | JSON] | read the attached transaction, or one named by id, without changing it |
UPLOAD RESOURCE <name> VERSION '<dir>' | stream a local directory as a new version of that resource in the active domain |
BACKUP CLUSTER TO '<file>' / BACKUP DOMAIN [<name>] TO '<file>' | back up configuration, users and resources into a local archive file |
DESCRIBE BACKUP '<file>' [FORMAT TEXT | JSON] | read and verify a local archive without contacting a server |
RESTORE CLUSTER FROM '<file>' / RESTORE DOMAIN <name> [AS <new_name>] FROM '<file>' | recreate users, domains, resource versions and models from a local archive file |
CREATE SUBSCRIPTION / DELETE SUBSCRIPTION | start and stop a read-only relay subscription |
ATTACH DOMAIN CLOCK / DETACH DOMAIN CLOCK | start and stop following the active domain’s clock |
USE, LIST DOMAINS, UPLOAD RESOURCE, BACKUP, DESCRIBE BACKUP, RESTORE, and the domain
clock statements must be submitted on their own, and never inside a transaction. ATTACH DOMAIN CLOCK prints the clock the serving node
has installed, then each change above the prompt as described in
Asynchronous Output:
nervix[simulation]> ATTACH DOMAIN CLOCK;
attached to the clock of domain 'simulation': generation 1, paced: period 1s, skew 100ms, logical origin 2030-01-01T00:00:00Z, UTC anchor 2026-09-27T09:30:00.125Z, time rate 2
A second attach is refused because the session already follows that clock, and a detach without an
attachment is refused because it does not. USE does not detach a clock, so the REPL can follow the
clocks of several domains at once, and the CLI attaches the clocks it follows again after a
reconnect. See Domain Clock Attachment. Read-only statements,
subscriptions, CREATE DOMAIN, CREATE USER, and node administration are also rejected while
queueing transaction content. DESCRIBE TRANSACTION is the exception: while a transaction is open
it reads that transaction’s impact report without queueing anything or consuming an operation
number, so the next queued statement keeps the number it would have had. It must be submitted on its
own. With an id it reads another transaction of the same user and leaves the prompt, the active
domain, and the attached transaction as they were:
nervix-cli --domain production --command \
"DESCRIBE TRANSACTION '01a0ca64-7062-7302-ac1e-43138ccc2067' OPERATION 2 FORMAT JSON;"
For a standalone --command inspection with FORMAT JSON, stdout contains exactly one JSON
document serialized from the typed report, without terminal decoration or unrelated events.
Failures print one JSON object with error.code and error.message on stdout and exit nonzero;
a configuration, connection or request failure’s message is the whole chain of causes, such as
invalid server URL: relative URL without a base. Diagnostic details and unrelated events go to
stderr. FORMAT TEXT uses the normal readable
terminal output. Inspection output is complete rather than paginated or truncated, so redirect a
large text or JSON report to a file or downstream process when terminal output is impractical.
After a stale-preview COMMIT refusal, run DESCRIBE TRANSACTION for the
attached transaction before retrying the commit so the client fences it to the newly reviewed
planning basis.
See Transaction Quiescence And Impact Inspection for the report’s
planned and actual scopes, historical topology, and diagnostic meanings.
BEGIN requires an existing active domain and binds the transaction to it; attaching a transaction
switches the active domain to the transaction’s domain. An upload targets the active domain,
renders live progress, and finishes after every current live node incarnation has verified and
installed the assigned version:
upload resource 'order_model' finished: 4.2 MiB sent
uploaded resource version 1
See Resources for what a resource version contains.
Use --command for an atomic resource rebinding just like any other server statement:
nervix-cli --domain production --command \
"REBIND RESOURCE order_model TO VERSION LATEST FOR INFERENCER score_orders;"
The command prints the resolved target, changed and selected usage counts, quiesce level, and a
sorted line for every selected model. In a transaction, the queued response is a provisional plan;
COMMIT resolves LATEST again before applying the atomic model step.
Backing Up
BACKUP in the REPL or through --command runs the backup and downloads its archive into the
named file on this machine. The backup subcommand does the same with arguments instead of NSPL,
and exits with a nonzero status whenever the archive was not delivered:
nervix-cli backup cluster --output cluster.nvxb
nervix-cli --domain payments backup domain --output - --without-resources > payments.nvxb
nervix-cli backup domain payments --output payments.nvxb --format json
--output - writes the archive to standard output and the report to standard error. A standard
output that would discard the archive, the null device or one that was closed when the CLI started,
fails with WRITE_FAILED before the backup is taken. The archive is downloaded and verified in a
staging directory first; when standard output then fails, for example because its reader closed
the pipe, the CLI keeps the verified archive there and reports WRITE_FAILED with the backup’s
execution reference and the kept file, which is the only copy left.
Backup And Restore describes how to recover
it. --format json prints the report, or the failure, as one JSON document. DESCRIBE BACKUP reads a local archive without connecting to a server, whether it is typed in the REPL or
passed to --command:
nervix-cli --command "DESCRIBE BACKUP 'cluster.nvxb' FORMAT JSON;"
The description lists every runtime state section of each domain under runtime_state. A
deduplicator line names its keyspace’s branch fingerprint, key count, groups, revision and schema
fingerprint; a window processor line names its branch fingerprint, retained rows, groups, branch
incarnation, next admission sequence, pending delayed histogram removals, revision, schema
fingerprint and window model digest. JSON reports the same values with kind deduplicator or
window_processor, together with the length and digest of every section. Neither form prints
branch-key values or deduplicator keys.
See Backup And Restore for what an archive holds and how long the server retains it.
Restoring
RESTORE in the REPL or through --command streams the archive the statement names from this
machine to the leader, and prints the restore’s report when it ends. The restore subcommand does
the same with arguments instead of NSPL, shows how much of the archive was sent while it streams,
and exits with a nonzero status whenever the restore did not complete:
nervix-cli restore cluster --input cluster.nvxb --on-existing-user skip
nervix-cli restore domain payments --as payments_copy --input cluster.nvxb
nervix-cli restore domain payments --input payments.nvxb --dry-run --format json
nervix-cli restore domain payments --input payments.nvxb --resume --format json
--resume starts the complete restored domain at its archived generation and mapping, preserving
materialized rows, deduplicator keys and windows. The default leaves it stopped; a subsequent
normal START advances its generation and clears materialized state, while deduplicator keys and
windows continue. A window whose model or branch incarnation changed starts empty, and the report
lists it among its warnings. Text and JSON reports include each domain’s status and
start generation, including the planned lifecycle of a dry run.
--dry-run verifies the archive and plans the restore without changing anything, and --format json prints the report, or the failure, as one JSON document. The Rust client sends the archive
again under the same execution reference after a redirect or a lost connection, so a restore
interrupted by a leader change is resumed rather than repeated. See
Backup And Restore for what a restore recreates and the order of
its steps.
Streaming A Relay
The subscribe subcommand opens a read-only session subscription and prints events until
interrupted:
nervix-cli --domain quickstart subscribe watch orders
nervix-cli --domain quickstart subscribe sampled orders \
--dropping \
--batch-sample-rate 0.1 \
--where 'input.amount >= 1000'
| Flag | Meaning |
|---|---|
--dropping | drop deliveries when the session transport queue is full |
--blocking | block instead of dropping; this is the default, so the flag is only ever explicit |
--batch-sample-rate <0.0-1.0> | sampling of each row the predicate selected |
--where <expression> | NSPL predicate over delivered records, read exactly as the WHERE clause of a typed CREATE SUBSCRIPTION and validated locally before the subscription opens |
--dropping and --blocking are mutually exclusive. Subscription semantics, sampling, and
backpressure are covered in Sessions.
The subcommand prints server notices beside the rows, and keeps printing both across reconnects: the client opens the subscription again on its next session, reports the gap as an interruption notice, and resumes the notices of the new session, as described in Asynchronous Output. A subscription the server ended is not opened again: the subcommand prints its end and keeps printing server notices until interrupted.
Following A Domain Clock
The domain-clock subcommand attaches to the selected domain’s clock and prints its state and
progress on stdout until interrupted:
nervix-cli --domain simulation domain-clock
The first line is the attach reply. Every later clock state, tick, interruption, or attachment end uses the same one-line format as the REPL’s Asynchronous Output:
attached to the clock of domain 'simulation': generation 1, paced: period 1s, skew 100ms, logical origin 2030-01-01T00:00:00Z, UTC anchor 2026-09-27T09:30:00.125Z, time rate 2
[events] domain clock [simulation] tick: generation 1, id 1, boundary 2030-01-01T00:00:00Z, authority UTC 2026-09-27T09:30:00.125Z, node logical 2030-01-01T00:00:00Z
[events] domain clock [simulation]: generation 1, stopped
[events] domain clock [simulation]: generation 2, paced: period 1s, skew 100ms, logical origin 2026-09-27T09:31:00Z, UTC anchor 2026-09-27T09:31:00Z, time rate 1
The domain and generation identify each state. A paced state includes its period, skew, logical origin, UTC anchor, and rate. A tick includes its id, logical boundary, the authority’s UTC observation, and the serving node’s logical reading.
Stdout carries these lines and nothing else, and their format is stable, so scripts and external observers can parse them. Every line has one of these forms:
| Line | Printed |
|---|---|
attached to the clock of domain '<domain>': <clock> | first, as the attach reply |
[events] domain clock [<domain>]: <clock> | when the serving node installs another clock, and when a new session attaches the clock again |
[events] domain clock [<domain>] tick: generation <n>, id <n>, boundary <time>, authority UTC <time>, node logical <time> | when the serving node accepts a newer tick of a paced clock |
[events] domain clock [<domain>] notice: the session was interrupted; the clock is attached again on the next session | when the session holding the attachment ends |
[events] domain clock [<domain>] notice: attaching the clock again failed: <message>; the next attempt follows in <wait> | when a new session refuses to attach the clock again |
[events] domain clock [<domain>] notice: the attachment ended because <reason> | when the server ends the attachment, as the last line |
<clock> is generation <n>, followed by stopped, uninstalled, unpaced, or
paced: period <duration>, skew <duration>, logical origin <time>, UTC anchor <time>, time rate <rate>.
Within a line:
- In the attach reply, state, and tick lines, fields are separated by
,and appear in the order shown. Each field but the state is its name, one space, and its value, and a name can contain spaces, aslogical origin,UTC anchor,time rate,authority UTCandnode logicaldo. The state isstopped,uninstalled,unpaced, orpaced:followed by one space and the period field. <n>is an unsigned decimal integer. The generation counts the domain’sSTARTs, and tick ids number the ticks of a generation from one: a tick’s boundary is the logical origin plus the id minus one periods.<time>is an RFC 3339 instant in UTC with aZoffset and no fractional digits, or three, six, or nine of them, as the instant needs.<duration>has unit suffixes, such as500ms,1s, or1s 500ms, and can contain spaces, so read a field by its name rather than by splitting at spaces.<rate>is a decimal number, such as2or0.25.- In a notice line,
<message>is display text that can itself contain,and;, and<wait>is a whole number of seconds such as1sor30s.
Within one session, the tick ids of a generation increase and can skip, because the authority coalesces missed periods and a slow reader receives the newest tick rather than a backlog. When the session is lost, for example because its node restarts, the interruption line reports the gap, and the client opens a new session within its retry deadline and attaches the clock again. The output then shows the clock the new session reports, and its ticks continue from the newest one that session’s node holds. Ticks accepted in between are not replayed, and the first tick after the interruption can repeat the last id printed before it, or precede it when another node serves the new session. A node that is still starting answers the attach once it has installed the cluster’s committed domains, so a restart never ends the attachment. When the new session refuses the attach for another reason, a notice line reports it and when the client tries again on that session.
The server ends an attachment when the domain no longer exists on the serving node, and a new
session that no longer has the domain ends it the same way. <reason> then reads
the domain no longer exists on the serving node. The end line is the last clock line and the
command exits with status 0, as it does after Ctrl-C detaches the clock or finds it already
detached. A failed connection, a refused attach, including a missing domain, and a refused detach
print the typed reason on stderr and exit nonzero, as does a lost session the client cannot replace
within its retry deadline.
Cluster Node Administration
Each of these submits one statement and exits:
| Command | Statement | Purpose |
|---|---|---|
nervix-cli cordon-node <id> | CORDON NODE | stop scheduling new work onto a node |
nervix-cli uncordon-node <id> | UNCORDON NODE | allow scheduling again |
nervix-cli drain-node <id> | DRAIN NODE | move scheduled graph nodes away and keep the node cordoned |
nervix-cli remove-node <id> | DROP NODE | remove the node from cluster membership |
Drain and replication behaviour is described in Replication And Drain Behavior.
drain-node cordons the target before moving work. It visits domains and their hard placement
groups or independent runtime nodes in canonical order, drains one unit at a time, and leaves the
node cordoned. A successful result starts with the aggregate and effective quiesce level, followed
by one line per move:
drained node 'node-2' (moved 2 of 2 scheduled graph node(s))
quiesce level: ENTITY_PAUSE
- kind=ingestor name=orders from=node-2 to=node-3 replicas=node-2 promoted_replica=yes
- kind=emitter name=warehouse from=node-2 to=node-1 replicas=none promoted_replica=no
Each move line identifies the runtime-node kind and name, former and destination owners, resulting
replicas, and whether the destination was promoted from a replica. A node with no scheduled graph
work reports moved 0 of 0 and quiesce level: DYNAMIC.
If one unit cannot drain or activate, the command returns an error containing the same header plus
a failed: line for that unit. Independent units continue moving, so moved can be smaller than
of. The node stays cordoned, successful moves remain committed, and running drain-node again
retries the units it still owns.
Shell Completions
nervix-cli completions bash > /etc/bash_completion.d/nervix-cli
bash, elvish, fish, powershell, and zsh are supported. This subcommand does not contact
the server, so it works before a cluster exists.
Leader Redirects
Persistent statements are applied by the leader. The CLI gives each one a stable execution
reference and retains it until the terminal result. If the session lands on a follower, the client
follows the redirect and reconnects on its own, repeating the statement under the same reference,
and waits out an election the same way; ordinary commands are bounded by the client’s 120-second
retry deadline. BACKUP uses the global --backup-wait-timeout instead, ten minutes by default,
for the entire command across all domain cuts and reconnections. This applies to NSPL in
--command and the REPL as well as the backup subcommand. Its per-domain TIMEOUT does not
determine that total wait. A statement the leader never answered in its wait is reported with its execution
reference as not known yet. A redirect that reaches the terminal as a statement’s own result is
printed as:
topology: not-a-leader, retry on leader 'node-2' at http://10.0.0.12:47391/
After an uncertain backup, backup --execution-reference REFERENCE recovers the admitted command
and downloads its archive with the original selected domain, scope, resources and capture
options. The output file or stdout destination may change. The JSON failure report exposes
error.execution_reference; Backup And Restore
describes recovery and retention limits.
Transaction controls follow the same redirect beginning with BEGIN. The CLI retains the returned
transaction id and attaches it on the new connection before resuming any queued statement or
commit. Each append keeps its execution reference and expected queue position. A pending commit
waits for the exact retained terminal result recorded under its own reference. An open
transaction therefore survives an unclean connection loss or leader failover; a clean CLI exit
reverts it. See Replicated NSPL Transactions and
Client Session Protocol.
nervix-cli Reference
Every option, subcommand, default, and environment variable nervix-cli accepts, printed by the
binary itself while this book was built. It therefore describes exactly the release you are reading
about. For how to use the client, see Command Line Client.
nervix-cli
Interactive Nervix client
Usage: nervix-cli [OPTIONS] [COMMAND]
Commands:
completions Generate shell completion scripts
subscribe Subscribe to one relay and print events until interrupted
domain-clock Follow the selected domain's clock until interrupted
remove-node Remove a node from the cluster membership
cordon-node Prevent the scheduler from placing new tasks on a node
uncordon-node Allow the scheduler to place new tasks on a node
drain-node Move scheduled graph nodes away from a node and keep it cordoned
backup Back up configuration, users and resources into an archive file
restore Restore configuration, users and resources from an archive file
help Print this message or the help of the given subcommand(s)
Options:
--server <SERVER>
Session gRPC endpoint; an https:// URL connects over TLS [default: http://127.0.0.1:47391]
--tls <TLS>
Whether TLS is required; `required` refuses a server URL that is not https [default: preferred] [possible values: preferred, required]
--tls-ca-cert <TLS_CA_CERT>
PEM certificate authority used to verify the server certificate
--dns-resolver-config <DNS_RESOLVER_CONFIG>
Resolv.conf-format file used by native session hostname resolution [env: NERVIX_DNS_RESOLVER_CONFIG=] [default: /etc/resolv.conf]
--dns-hosts-file <DNS_HOSTS_FILE>
Hosts-format file consulted before DNS [env: NERVIX_DNS_HOSTS_FILE=] [default: /etc/hosts]
--dns-name-server <DNS_NAME_SERVERS>
Name server addresses with ports, replacing those in the resolver configuration [env: NERVIX_DNS_NAME_SERVERS=]
--domain <DOMAIN>
Domain the session starts in [default: default]
--username <USERNAME>
Registry user to authenticate as [env: NERVIX_USERNAME=] [default: default]
--password <PASSWORD>
Password for the registry user; prompted for interactively when unset [env: NERVIX_PASSWORD=]
--backup-wait-timeout <BACKUP_WAIT_TIMEOUT>
Overall BACKUP command wait across all domain cuts, redirects and reconnects (default 10m)
--command <COMMAND>
Run NSPL statements once and exit instead of starting the interactive REPL
--suggest <SUGGEST>
Print completion candidates for NSPL input as JSON and exit
--cursor <CURSOR>
UTF-8 byte cursor in --suggest input; defaults to the end
-h, --help
Print help
nervix-cli completions
Generate shell completion scripts
Usage: nervix-cli completions [OPTIONS] <SHELL>
Arguments:
<SHELL> Target shell [possible values: bash, elvish, fish, powershell, zsh]
Options:
--backup-wait-timeout <BACKUP_WAIT_TIMEOUT>
Overall BACKUP command wait across all domain cuts, redirects and reconnects (default 10m)
-h, --help
Print help
nervix-cli subscribe
Subscribe to one relay and print events until interrupted
Usage: nervix-cli subscribe [OPTIONS] <NAME> <RELAY>
Arguments:
<NAME> Session-local subscription name
<RELAY> Relay name to subscribe to
Options:
--dropping
Drop delivered events when the session transport queue is full
--blocking
Block delivered events when the session transport queue is full
--batch-sample-rate <BATCH_SAMPLE_RATE>
Optional per-arrival batch sample rate from 0.0 through 1.0
--where <WHERE_CLAUSE>
Optional NSPL predicate applied to delivered records
--backup-wait-timeout <BACKUP_WAIT_TIMEOUT>
Overall BACKUP command wait across all domain cuts, redirects and reconnects (default 10m)
-h, --help
Print help
nervix-cli domain-clock
Follow the selected domain's clock until interrupted
Usage: nervix-cli domain-clock [OPTIONS]
Options:
--backup-wait-timeout <BACKUP_WAIT_TIMEOUT>
Overall BACKUP command wait across all domain cuts, redirects and reconnects (default 10m)
-h, --help
Print help
nervix-cli remove-node
Remove a node from the cluster membership
Usage: nervix-cli remove-node [OPTIONS] <NODE_ID>
Arguments:
<NODE_ID> Node id to remove
Options:
--backup-wait-timeout <BACKUP_WAIT_TIMEOUT>
Overall BACKUP command wait across all domain cuts, redirects and reconnects (default 10m)
-h, --help
Print help
nervix-cli cordon-node
Prevent the scheduler from placing new tasks on a node
Usage: nervix-cli cordon-node [OPTIONS] <NODE_ID>
Arguments:
<NODE_ID> Node id to cordon
Options:
--backup-wait-timeout <BACKUP_WAIT_TIMEOUT>
Overall BACKUP command wait across all domain cuts, redirects and reconnects (default 10m)
-h, --help
Print help
nervix-cli uncordon-node
Allow the scheduler to place new tasks on a node
Usage: nervix-cli uncordon-node [OPTIONS] <NODE_ID>
Arguments:
<NODE_ID> Node id to uncordon
Options:
--backup-wait-timeout <BACKUP_WAIT_TIMEOUT>
Overall BACKUP command wait across all domain cuts, redirects and reconnects (default 10m)
-h, --help
Print help
nervix-cli drain-node
Move scheduled graph nodes away from a node and keep it cordoned
Usage: nervix-cli drain-node [OPTIONS] <NODE_ID>
Arguments:
<NODE_ID> Node id to drain
Options:
--backup-wait-timeout <BACKUP_WAIT_TIMEOUT>
Overall BACKUP command wait across all domain cuts, redirects and reconnects (default 10m)
-h, --help
Print help
nervix-cli backup
Back up configuration, users and resources into an archive file
Usage: nervix-cli backup [OPTIONS] --output <OUTPUT> <SCOPE> [NAME]
Arguments:
<SCOPE>
What the archive covers: `cluster` for every domain and user, `domain` for one domain
Possible values:
- cluster: Every domain and every user
- domain: One domain
[NAME]
The domain a `domain` backup covers; the session's `--domain` when omitted
Options:
-o, --output <OUTPUT>
Where the archive is written; `-` writes it to standard output
--without-resources
Record every resource version and its digests without the version's bytes
--without-state
Capture configuration only, without pausing domains
--without-pause
Capture the latest published state without pausing domains
--timeout <TIMEOUT>
Maximum time for each running domain's quiesced capture
--execution-reference <EXECUTION_REFERENCE>
Recover the same backup with its original domain and capture options
--format <FORMAT>
How the backup's report is printed
[default: text]
[possible values: text, json]
--backup-wait-timeout <BACKUP_WAIT_TIMEOUT>
Overall BACKUP command wait across all domain cuts, redirects and reconnects (default 10m)
-h, --help
Print help (see a summary with '-h')
nervix-cli restore
Restore configuration, users and resources from an archive file
Usage: nervix-cli restore [OPTIONS] --input <INPUT> <SCOPE> [NAME]
Arguments:
<SCOPE>
What to restore: `cluster` for every domain and user of a cluster archive, `domain` for one domain of an archive
Possible values:
- cluster: Every domain and user of a cluster archive
- domain: One domain of an archive
[NAME]
The archived domain a `domain` restore recreates
Options:
-i, --input <INPUT>
The archive file to restore from
--as <TARGET>
Restore the domain under this name instead of its archived one
--on-existing-user <ON_EXISTING_USER>
What a cluster restore does with an archived user the cluster already has; `fail` when omitted
Possible values:
- fail: Refuse the restore before it changes anything
- skip: Keep the existing user and its password
- replace: Give the existing user the archived password hash
--resume
Resume the archived start and clock mapping after installing all state
--dry-run
Verify the archive and plan the restore, changing nothing
--without-state
Restore configuration without runtime state
--without-source-offsets
Restore runtime state but leave source offsets empty
--format <FORMAT>
How the restore's report is printed
[default: text]
[possible values: text, json]
--backup-wait-timeout <BACKUP_WAIT_TIMEOUT>
Overall BACKUP command wait across all domain cuts, redirects and reconnects (default 10m)
-h, --help
Print help (see a summary with '-h')
Web Console
Every Nervix node serves a browser console. It combines a live drawing of the running execution graph with an NSPL command line, so the same session can be used to read a graph and to change it.

Opening The Console
The console is served by the node itself; nothing extra is deployed. It listens on 0.0.0.0:47420
by default and is served under /console/, with / and /console redirecting there:
http://127.0.0.1:47420/console/
Change the address with --web-console-listen-addr or NERVIX_WEB_CONSOLE_LISTEN_ADDR. The
published ports differ by installation method: see Docker and
Kubernetes Operator.
Any node works. Opening the console on a follower is supported — the console follows the leader redirect, reconnects there, and reports which node it reached:
connected to leader 'node-2'
Signing In
Without credentials in the URL the console shows a login form for a registry user. Two other forms are accepted, which is what automation and bookmarks use:
- an
Authorization: Basic <base64(user:password)>request header - an
?auth=<base64(user:password)>query parameter
The query parameter puts credentials into browser history and server access logs, so prefer the login form or the header for anything but a local cluster.
Layout
The console is one screen with three regions:
- the sidebar on the left: the domain selector, live throughput for the selected domain, and the domain’s entities grouped by kind — schemas, wire schemas, codecs, resources, clients, vhosts, and endpoints
- the execution graph in the upper right
- the REPL below it, which also hosts any relay subscriptions you open
The top bar carries the websocket connection state, the global Create menu, the Backups button, the domain lifecycle button, the selected clock state, and the theme picker. The clock panel below the domain selector shows the selected domain’s clock in detail.
Sidebar entries are counted per kind and each group collapses. Selecting an endpoint runs its
DESCRIBE in the REPL; selecting a resource does the same and opens its version dialog.
The Cluster footer stays independent of the selected domain: it reports the number of running
domains, non-relay graph nodes, and relays across the current cluster graph.
Following the selected domain clock
Selecting a domain attaches the console session to its clock once. The console selects the first
listed domain on its own when none is selected or the selected one disappears, and selecting
another domain, from the domain menu or with a typed USE, detaches the previous clock first. The
console’s session is served by the leader, so after a connection interruption or a leader change it
attaches the selected clock again on the new session; the server releases the earlier attachment
when its session ends. A console that holds a transaction attaches it first on a new connection,
and while the session holds it the server refuses the clock attachment, as it refuses the
automatic detach. The panel shows the attached clock’s typed state and START generation. For a
paced clock it also shows logical now, rate, period, skew, the logical origin and UTC anchor, and
the latest accepted tick id and logical boundary. Logical now is projected from the committed
mapping at the browser’s UTC, refreshed four times a second; a browser whose clock differs from the
cluster’s can show a different projection. The tick is the serving node’s accepted observation.
Stopped, uninstalled, and unpaced states have no projected logical time. A refused attachment
shows its reason and is not retried until the selection or connection changes.
The panel and the top bar’s CLOCK state name the attachment’s phase: NO DOMAIN without a
selection, ATTACHING until the attach reply, STOPPED, UNINSTALLED, UNPACED or PACED while
it follows the clock, INTERRUPTED while the connection is lost, which shows no projection,
REFUSED with the server’s reason, DETACHED after a typed detach, and ENDED with its reason
once the server ends the attachment, which the console does not attach again until the selection
or connection changes.
Creating Entities From Forms
The top bar’s Create menu opens keyboard-accessible forms for domains, users, resource
catalogs, schemas, codecs, signaling protocols, clients, VHOSTs, endpoints, hash maps, Roto UDFs,
relays, branches, ingestors, junctions, reingestors, and subscriptions. The resource group in the
sidebar also provides a contextual create action. A form keeps its unfinished draft when it closes,
restores focus to the action that opened it, reports validation and server failures inline, and
shows the canonical NSPL statement before submission. An expression typed into a form, such as a
filter, a WHERE predicate, an assigned value or an invocation argument, reads exactly as it does
in a typed statement: its string literals are verbatim, so a
backslash is an ordinary character.
The domain form supports paced and unpaced clocks, period and skew for a paced clock, placement
policy, and IF NOT EXISTS. User and resource forms support their corresponding names and the
same creation modifier.
Pace and placement are typed choices supplied by the session server. They are searchable and paged independently, and the placement lookup carries the selected pace as a typed dependency. Loading and no matches have separate neutral messages. A missing prerequisite, such as a captured domain or a selected relay for its field list, shows a neutral hint naming what to choose. A stale choice page shows a neutral message and Retry, which requests a fresh first page. Lookup, session-channel, and unreadable-reply failures remain alerts. These states apply to the domain, branch, relay, and subscription forms. An edit, dialog close, or replacement session makes an older reply ineligible to change the form.
A resource draft captures the selected domain the first time it opens. If the console later selects another domain, reopening the retained draft keeps its captured scope and offers Use current domain as an explicit change. Domain and user creation are cluster scoped. Passwords are sent in the canonical command but appear as eight asterisks in both the preview and REPL; the cleartext value is never added to terminal history.
Submitting uses the same durable command path as the REPL. The form therefore keeps the command’s execution reference through redirects and reconnects, uses the attached transaction’s expected position, and reports Editing, Submitting, Queued until reconnect, Queued in transaction, Completed, or Failed from the correlated outcome. A queued transaction operation is not presented as externally complete. A successful standalone resource create opens the existing resource version dialog, where upload identity and completion continue to be owned by the upload workflow.
The Create menu also opens forms for internal schemas, JSON/CBOR/AVRO wire schemas, and named
branches. Schema fields are added in declaration order and can be moved or removed. An internal
field selects its exact scalar type, then may wrap it in any sequence of variable vectors and
fixed arrays; each fixed array needs a positive length. Optional and sensitive flags are separate
controls. Wire fields select the types of their chosen format and an explicit STRICT or LOOSE
mode. A wire schema’s format is part of its identity, so JSON, CBOR, and AVRO definitions may
share a name. The preview and submitted command come from the completed semantic Model’s
canonical renderer, just as they do for the earlier forms.
A branch form selects an internal key schema from a server-backed, searchable, paged list, then
requires a TTL. It may set a positive maximum instance count with LRU eviction. The schema list
uses the draft’s captured domain and the session’s attached transaction prefix, so a schema staged
earlier in the same transaction is selectable. Changing the captured domain keeps a previous
selection visible but marks it invalid until it is selected again. An invalid branch key schema,
including one containing BYTES, is rejected by registry validation and shown inline with the
branch and field named. These domain-owned drafts keep their captured scope when reopened; Use
current domain changes it explicitly.
A relay form selects the relay’s internal schema from the same kind of list, then states its
branching explicitly: UNBRANCHED, or BRANCHED BY with a branch chosen from a searchable,
paged list of the captured domain’s branches. A new draft has chosen neither, and it cannot be
submitted until one is chosen, so a relay is never unbranched by omission. Capacity starts from the
default relay buffer and must be a positive count. Materialized state is NONE or LAST BY TIMESTAMP. Both lists read the attached transaction prefix, so a schema or branch staged earlier in
the transaction is selectable, and a relay created while a transaction is attached is queued in it
like the other forms’ creates. Changing the captured domain keeps the selected schema and branch
visible but invalid until each is selected again. The preview is the canonical CREATE RELAY
statement, which always states its capacity.
A codec form selects one current wire format: a declared JSON, CBOR, or AVRO wire schema; the fixed SYSLOG format; a JAQ-native JSON, YAML, TOML, XML, or CBOR format; or Protobuf. The internal schema is an exact typed selection. Declared wire-schema choices are limited to the selected format, even when wire schemas of different formats share a name. JAQ-native and Protobuf codecs choose ingestion (decode), emitting (encode), or both, and may add an emitting-batch program after an emitting program. These are program editors: their contents remain jaq source in the current codec Model, including quotes and whitespace. RFC3339 field encoding rules can be added in order where the format supports them. The preview renders the complete codec Model as canonical NSPL.
The Protobuf controls select an existing resource and one of its completed versions, including
LATEST, then accept an optional .proto file and include root, additional compiler
configuration entries, message type, and optional batch message type. The resource list includes
catalogs staged earlier in the attached transaction; the version list offers completed uploads
only. LATEST stays unresolved in the draft and submitted command, and is pinned when the server
applies the statement. These controls never create a resource or upload a file as a side effect;
provision and upload it explicitly through the resource workflow. Changing the resource or captured
domain leaves an earlier version visible but requires selecting it again. A rejected program or
configuration reports failure inline and keeps the draft editable.
A signaling protocol form chooses JSON, YAML, TOML, XML, CBOR, RAW, or Protobuf. Protobuf uses the same resource, version, file, include, and compiler configuration controls and requires separate send and wait message types. The ordered handshake editor adds SEND and WAIT steps, moves or removes steps, and keeps each step’s jaq programs in written order. WAIT steps can have multiple matchers, failure matchers, an optional capture when there is exactly one matcher, and ACCEPT DATA. The form also offers connection-wide ACCEPT DATA, failure matchers, and the handshake timeout. Its canonical preview and command preserve the order and raw program text. Both forms use the existing durable command dispatcher and retain rejected drafts, transaction positions, and reconnect handling.
A client form offers every current transport as a typed selection. Connector CONFIG remains an
ordered key/value editor whose keys and values are passed to the connector unchanged; the form
does not provision external services, topics, buckets, tables, or other objects. Redis, Postgres,
MySQL, and MongoDB clients require explicit minimum and maximum pool sizes. A client may mount an
existing resource and a completed version. A WebSocket client may select a configured signaling
protocol. Switching transports clears transport-specific configuration and signaling choices;
changing the resource or captured domain invalidates its version selection. LATEST remains a
request until the server applies the statement, when the stored binding becomes an exact version.
Configuration rows can be marked secret, and common credential keys and URLs with passwords are
treated as secret automatically. Secret inputs use password controls. The preview and the command
line history mask those values, while the transmitted command keeps the actual values.
A VHOST form accepts ordered hostnames and may bind TLS to an existing resource and completed version. A TLS VHOST installs its certificate in the HTTPS listener of every live node at command completion, including nodes that do not lead or run the domain’s graph. The certificate bundle must already be uploaded; missing or invalid material follows the existing command failure path and leaves the draft editable. An endpoint form selects an existing VHOST, requires a path, and chooses HTTP or WEBSOCKETS. A WebSocket endpoint may also select a signaling protocol. VHOST and signaling lists are typed, searchable, paged, and scoped to the captured domain, including the attached transaction prefix. An upstream selection or domain change refreshes dependent choices; stale selections must be chosen again before submission. A completed VHOST or endpoint is usable immediately by later commands through the same session.
A hash map form selects a resource, a completed version or LATEST, a codec, and a key field from
that codec’s output schema. It also requires the file path within the resource. Codec and key
choices read the captured domain and the attached transaction prefix; changing the codec makes an
earlier key selection invalid until selected again. A changed resource invalidates its version,
and changing the captured domain invalidates all references while leaving them visible for
correction. LATEST remains unresolved in the draft and canonical command; the server pins it
when the statement applies. The form shows Completed only after the resource file has decoded
and the lookup index is ready. A malformed file or unavailable version reports failure inline and
keeps the entered path and selections for inspection. A successful hash map can be queried with
LOOKUP or used by LOOKUP_HASH_MAP immediately.
A Roto UDF form keeps arguments in declaration order, with exact scalar, vector, and fixed-array
types and separate optional flags. The result has the same type controls. The current language is
ROTO_0_13, with an explicit volatility control. The source editor preserves its complete text,
including quotes, whitespace, the named function body, and Roto test blocks; canonical NSPL
chooses a safe dollar-quote delimiter around it. The server checks the declared signature exactly
and runs the Roto tests before Completed appears. Compilation, type, or test failure keeps the
signature and source editable. A completed function is available to udf:: calls in later
commands under the same session and domain.
An ingestor form offers every connector and entity an ingestor reads through a transport:
HTTP REQUEST, HTTP ENDPOINT, KAFKA TOPIC, PULSAR TOPIC, MQTT TOPIC, NATS SUBJECT,
RABBITMQ QUEUE, REDIS PUBSUB, PROMETHEUS QUERY, ZEROMQ PULL, SQS QUEUE,
WEBSOCKETS CONNECTION, WEBSOCKETS ENDPOINT, and SYSLOG LISTENER. Its client or endpoint is a
typed choice limited to that pair: clients of its connector, or endpoints of the TYPE its head
names. The preview writes the statement under that pair’s head. The source’s own fields,
delivery mode, quiesce mode, and required durations or sizes appear as explicit controls. A topic,
queue, subject, channel, consumer group, queue group or subscription keeps its text exactly as
typed, case and spaces included, and the form refuses a name its entity’s rule refuses. The
decoding codec list contains only codecs that support decoding. Timestamp behavior must be chosen
explicitly: source timestamp, NOW, or AT a typed field from the codec’s output schema.
Each ingestor route explicitly chooses an unbranched or named branch and a relay with that exact
branch declaration. Routes can be added, removed, and moved in order. They edit inheritance,
ordered output assignments and function invocations, an optional WHERE expression, branch key
assignments, an explicit flush policy, and a route-local message error policy. An ingestor error
route selects an unbranched relay and its own field assignments. The node-wide general error
policy is also required. The draft retains incomplete values and shows the canonical NSPL preview
only when every required choice and expression is valid. It submits through the usual durable
command path, including attached transactions and reconnect recovery.
A junction form chooses its exact unbranched or named branch before its input relays. A reingestor
chooses inputs first and gives each output route an independent branch action: preserve the input
branch, become unbranched, or construct a named branch from ordered key assignments. Additional
inputs are offered only when their declared schema and branch match the first input exactly.
Both forms keep input predicates, optional collection bounds, materialized dependencies, and
output routes in visible order. A materialized dependency resolves against the incoming branch and
requires REQUIRED SKIP, REQUIRED WAIT, or DEFAULT with ordered assignments. Each transforming
route has ordered inheritance, output assignments, and invocations, an optional predicate, an
explicit flush policy, and a route-local message error policy. Server-backed choices restrict
output and error relays to the required branch and offer only materialized relays for state. A
changed domain or dependent reference keeps the draft visible and requires the affected choice to
be selected again. The preview and durable submission use the completed semantic Model; registry
validation reports any remaining schema, branch, sensitivity, or graph error inline.
The Execution Graph

The graph is redrawn from snapshots the leader pushes twice a second. It shows external clients, ingestors, processors, relays, and emitters, connected in the direction records flow.
Records always travel from left to right. Items sit in columns ordered by how far a record has travelled, and every item appears to the right of everything that feeds it. Relays get columns of their own between the processing columns, drawn as labelled capsules with a bar per buffer quantile and the p50/p90/p99 depths on hover, so a relay reads as the port its producers converge on and its consumers fan out from. A pipeline with no branching or fan-out is drawn as a single straight line.
Edges run horizontally and vertically with rounded corners, through gutters kept clear of the items, so no edge ever passes under a node, a capsule, or a label. Each edge leaves and arrives at its own attachment point, so a node with several outputs fans out visibly rather than from one spot. Two relations between the same pair of items stay two edges: a node that both reads a relay’s records and looks up its materialized state draws one edge of each style, side by side. Edge style carries meaning:
- solid — records flowing
- dashed, error tint — a route’s
ON MESSAGE ERRORdestination - dashed, warning tint — a correlator’s
ON TIMEOUTdestination; a correlator’s two inputs are labelledLEFTandRIGHT - dotted, hollow arrow — a materialized-state dependency, including the relay a generator reads
with
USING MATERIALIZED STATE. State is looked up rather than delivered, so these edges carry no rate
A graph that feeds back on itself — a reingestor writing a relay that reaches it again — draws that one edge as a marked return path above the items it spans, so backwards travel is never mistaken for forward flow. Disconnected parts of a graph are stacked as separate bands.
When traffic is flowing, each edge carries its own rate, with the full messages, bytes, and batches per second on hover, and a pulse travelling along the edge. Where a node declares several routes to the same relay they are drawn as one edge, and hovering reports how many routes it stands for.
Branch groups are drawn as a tinted region with a stacked outline around the part of the graph that
runs per branch, containing exactly the items that run per branch and nothing else. Regions of
different branches never touch, even where they share a column. The region is
headed with the branch name, its key fields, and how many branch instances are currently live; the
outline thickens with that count. The count comes from the per-branch statistics of the graph
snapshot, which name each concrete branch by the lowercase hexadecimal fingerprint of its key, so a
snapshot never carries key values a branch schema may declare SENSITIVE. The ingestors and
reingestors that construct the branch, and the emitters and reingestors that collapse it, sit on
the region’s border rather than inside it. Clicking the header opens the branch’s key schema and
its live instances.
The toolbar controls framing. Search highlights matching items, dims the rest, and brings the
matches into view; the zoom buttons step in tens and Ctrl/Cmd with the scroll wheel is
continuous, both between 25% and 300%; FIT frames the whole graph, which is also the view you
start with; the fullscreen button expands the graph over the whole window. Dragging pans. The
graph moves only when the topology changes, and never because traffic changed.
The header carries the domain’s lifecycle state — RUNNING, PAUSED, or STOPPED — and the state
of the feed: LIVE while snapshots arrive, STALE when they stop, and OFFLINE when the session
is disconnected. A stopped domain still draws its whole graph, with no rates and no pulses.
A domain with no installed graph shows NO ACTIVE DATAFLOW GRAPH instead.
Item Actions

Clicking any node or relay opens its action menu. Each action types the corresponding statement into the REPL and runs it, so the console never does anything you could not have typed:
- DESCRIBE — the runtime view of the item, where the item has a
DESCRIBEform - SHOW CREATE — the NSPL that declares it
- SUBSCRIBE — relays only; opens the subscription form described below for that relay, whose
tab runs the same
CREATE SUBSCRIPTIONthe REPL accepts
Node Health
Nodes are drawn with their runtime status. A node whose external connection has failed is marked in error, and an ingestor or emitter waiting to reconnect shows a countdown to its next attempt. Hovering a node reveals the underlying reason. Metrics for alerting belong in the observability endpoint rather than here — see Metrics And Observability.
Subscribing To A Relay

The Create menu’s subscription form opens a read-only session subscription as a tab beside the
REPL. A relay’s SUBSCRIBE action opens the same form as a new draft for that relay in the
graph’s domain, while the Create menu reopens the retained draft. A draft starts under a
generated name such as web_console_subscription_2, which can be edited; names are unique among the
console’s tabs.
The relay is selected from a searchable, paged list of the captured domain’s relays. The selected
relay’s fields are listed in declaration order with their exact types, OPTIONAL, and SENSITIVE;
selecting one inserts a typed input.<field> reference into the filter. The filter is the WHERE
predicate over the relay’s record. While it is edited, the form shows its canonical reading, and it
refuses to submit text that is not an expression. Checking the fields, scopes, and types the
predicate uses belongs to the server, which reports a failure such as a string compared with an
integer field when the subscription is created. Delivery is BLOCKING or DROPPING, and batch
sampling takes a rate from 0 through 1 that each selected row passes with that probability. The
preview is the canonical CREATE SUBSCRIPTION statement the tab sends.
Submitting opens the tab through the same subscription lifecycle as a CREATE SUBSCRIPTION typed in
the REPL. The tab waits until the session can serve it, opens and becomes active once the server
announces the schema of its rows, and streams records into it. The form reports Completed once
the tab is open. It reports Failed with the server’s reason, such as a relay that no longer
exists, a filter the server cannot compile, or an open transaction, and then no tab remains and the
draft stays editable. A name an open tab already uses fails before anything is sent. After a
reconnect or leader change, the console restores each open tab once under its name; the form does
not submit it again.
The console restores its tabs before it attaches the session’s transaction again, because a session that holds a transaction refuses subscriptions, so an open transaction does not keep a tab from coming back. A tab stays restoring until the server opens its subscription again. A restoration the server refuses leaves the tab interrupted with the reason, and the console tries it again every second while no transaction is attached; closing an interrupted tab stops that.
When a relay is redefined or removed, the server ends the subscriptions that read it. Such a tab turns ended: it keeps the rows it showed together with the reason, receives nothing more, and is not restored after a reconnect, which would not change why it ended. Its ↻ button resubscribes it under the same name with the statement that first opened it. The tab shows resubscribing until the server answers, then becomes active again with the relay’s current schema. A refusal, such as a relay that no longer exists, leaves the tab ended with the reason. An ended tab keeps its name until it is closed, so resubscribe it or close it before opening another subscription with that name.
Closing the tab ends the subscription; closing an ended tab only removes it, because the server already ended its subscription. Subscriptions are read-only views: they cannot construct, inherit, or produce side effects. See Sessions.
The NSPL REPL

The REPL accepts the same NSPL as the command line client, including the client-local statements
USE, LIST DOMAINS, BEGIN, COMMIT, REVERT, and the subscription statements. A typed
CREATE SUBSCRIPTION opens a tab in the selected domain exactly as the subscription form does, and
DELETE SUBSCRIPTION <name> closes the open tab of that name. The prompt shows the active domain,
and marks an open transaction the same way the terminal client does:
nervix[quickstart]>
nervix[quickstart tx]>
nervix[quickstart committing]>
The console sends a statement whether or not a domain is selected, and the server decides whether
it needs one: SHOW CLUSTER STATUS or CREATE DOMAIN runs with no domain selected, while a
statement that acts on a domain fails with no active domain selected.
A typed BACKUP runs through the Backups dialog, which opens to show
its progress and downloads its archive. A typed RESTORE opens the dialog’s restore form with the
statement’s options, because the browser cannot read the path the statement names. BACKUP,
RESTORE, and DESCRIBE BACKUP must each be sent on their own, and DESCRIBE BACKUP, which reads
an archive file on the client’s machine, runs only in nervix-cli.
The REPL renders attachment outcomes, domain-clock state and tick frames, and attachment ends.
Tick entries include the id, boundary, authority UTC observation, and serving node’s logical
reading. Every tick adds a line, so a fast clock can push earlier output out of the REPL’s latest
256 lines. ATTACH DOMAIN CLOCK and DETACH DOMAIN CLOCK typed into the REPL use the same
session-local requests as the automatic attachment, and the console refuses them itself while a
transaction is active. A typed second attach is refused as already attached. A typed detach
releases the clock and leaves the panel detached until a typed ATTACH DOMAIN CLOCK, another
domain selection, or a new connection restores following.
BEGIN requires a selected domain that already exists and binds the transaction to it. The
console follows the transaction’s domain, so attaching switches the domain selector to it.
DESCRIBE TRANSACTION prints the open transaction’s impact report between queued statements, and
with an id it reads another transaction of the same user while the prompt, domain selector, and
attached transaction stay as they were. See
NSPL Overview for its forms and output.
Every persistent command keeps one execution reference while the browser waits, reconnects, or
follows leadership. The transaction id and status are replicated. If the WebSocket closes
unexpectedly or leadership changes, the console reconnects and attaches that id before resuming a
pending command. Each append is matched by its reference and expected position, and an outstanding
commit remains pending through COMMITTING until its exact terminal result. A
finished or failed attach does not settle pending commands: the console sends each under its
original reference and position to recover its own outcome, or a failure if it was never admitted.
The Create form then leaves Queued until reconnect with a completed or failed result. A
second session can attach the same owner’s transaction and take it over; the displaced console
then gets an explicit takeover error. A clean console session close reverts an open transaction,
while an accepted commit continues on the leader without the browser. See
Replicated NSPL Transactions and
Client Session Protocol.
Tab cycles through completions offered by the server for the current cursor position. ArrowUp
and ArrowDown walk the session’s command history, Ctrl/Cmd with Enter submits, and clear
empties the scrollback.
Accepting a candidate replaces its exact source range, including the rest of a word after the cursor, while preserving surrounding text. The list shows one bounded page at a time; use MORE SUGGESTIONS to fetch the next page. The console shows a message when the selected domain or transaction context is missing or stale, or when the candidate lookup fails. An empty list with no message means there are no matches.
Bounded Buffers
The console holds a bounded amount of everything it keeps, however long it runs or however fast its session delivers, and says where it left something out:
- The REPL and every subscription tab each keep their latest 256 lines, up to 256 KiB of text, and a line longer than that is cut. The first line then says that earlier lines were omitted.
- The command history behind
ArrowUpandArrowDownkeeps the latest 256 commands, up to 256 KiB. Walking back to the oldest command kept says that earlier commands were omitted, and a command larger than the whole history says that it cannot be recalled. - While the console connects or waits to reconnect, at most 64 requests of its controls wait for the session, carrying at most 4 MiB of statements, completion input, and search text. Once connected, at most 256 requests, carrying at most 16 MiB, are held or awaiting their reply. Commands, subscription changes, and inspections wait in the order they were issued while 64 requests, the most the server admits for one session, are in flight, and go out as earlier replies arrive; a completion, choice lookup, or domain selection goes out at once and is reported if the server finds the session full. A request past either bound is not sent, and the control that issued it shows why where its outcome would have appeared: the REPL, a form, a tab, the resource dialog, or the inspector. A completion request that is not sent reports a failed lookup rather than an empty list.
- The execution graph and sidebar keep only the latest snapshot of the selected domain. Selecting another domain replaces it with that domain’s snapshot, which the server sends at once.
- A backup’s download moves each 256 KiB chunk of the archive into the browser’s own blob storage as it arrives, and the page holds one chunk at a time. A restore reads its archive file one slice at a time, at most 4 MiB while it digests the file and 256 KiB while it streams it, and keeps at most 1 MiB queued on its connection.
Inspecting a transaction

Select Transaction · Inspect in the top bar while a transaction is active. The inspector reads
the typed impact report without attaching another transaction or changing the session domain.
Use Discover transactions to list retained transactions in the REPL, then enter a transaction
ID to inspect one. A DESCRIBE TRANSACTION command also opens its typed result in the inspector;
the REPL still prints the server’s text or JSON rendering.
The outline groups accepted operations by their execution steps. Selecting an operation shows its own planned contribution; selecting its step shows the effective pause and effects of the atomic step. Whole transaction shows the union of the steps. Switch between Before, Changes, and After without moving graph items, or between Planned and Actual to review recorded progress. Search frames matching names and kinds; FIT, zoom, and drag control the viewport. Select a node or relation for its before/after presence, roles, and contributing operation numbers. The Relations list offers labeled buttons for keyboard selection of graph edges, including parallel data and materialized-state paths. Role marks and the resource card shape supplement the graph colors.
The summary names the transaction, domain, state, accepted and applied counts, completeness,
freshness, aggregate quiesce level, planning basis, relocations, rebuilds, and state resets.
An incomplete report shows its diagnostics. A stale preview must be refreshed before retrying
COMMIT. The console sends the inspected whole-transaction preview identity with COMMIT, even
when an operation is selected. Inspecting another transaction leaves the attached transaction’s
commit basis intact. Retained reports keep their own graph geometry when the live execution graph
changes.
The inspector receives the same complete typed report as the Rust client and CLI. It does not silently omit nodes, edges, operations, or steps from a large retained report; search, focus, and the outline change what is visible in the viewport without narrowing the result that was read. The Transaction Quiescence And Impact Inspection chapter defines the operation, step, and whole-transaction facts behind these views.
Uploading Resources

The sidebar shows each resource’s highest completed version, such as v2, or catalog while no
upload has completed. Selecting a resource opens its version list, read from the typed description
that DESCRIBE RESOURCE returns beside the text the REPL prints: every version with each file and
directory under its exact path, and under each version the models bound to it, listed by kind and
name, or none.
Files or a whole directory can be uploaded from the browser as a new version of that resource in
the selected domain. The successful upload result arrives after every current live node has
verified and installed the version. An upload moves no binding: models keep the version they pin
until REBIND RESOURCE moves them. Version contents and
how nodes consume them are covered in Resources.
Backing Up And Restoring
The top bar’s Backups button opens a dialog with two forms, Back up and Restore. They
run the same BACKUP and RESTORE statements as nervix-cli, on the console’s own session: they
follow its leader redirect, and a statement typed in the REPL runs through the same dialog. Each
form is bound to the statement’s options and shows the canonical NSPL it sends before you submit
it. Backup And Restore describes what the statements capture and restore.
Taking A Backup
The Back up form backs up the cluster or one domain, the selected domain by default, into the
archive file you name. It captures runtime state quiesced, optionally with a drain timeout, live
(WITHOUT PAUSE), or not at all (WITHOUT STATE), and can leave resource bytes out (WITHOUT RESOURCES). Back up and download sends the BACKUP as a command. Once it completes, the
dialog lists the summary the backup reported: the archive’s size and BLAKE3 digest, when it was
captured and until when it can be downloaded, the users, and each domain with its cut, revision,
and sections. The REPL prints the outcome as it prints any command’s.
The console then downloads the archive from the leader that assembled it, and checks it as it arrives. It accepts the archive only once every byte arrived and the size and BLAKE3 digest match the summary, and only then hands it to the browser as a download, saved under the last component of the file the statement names; the browser chooses the directory. A partial or altered archive never reaches the browser’s downloads. The first complete download releases the archive on the leader.
A download that fails in transport, stalls, or ends early starts again from the first byte while the leader retains the archive, and Download again retries one that stopped for such a reason. A download the leader refuses, because the archive expired, a download already collected it, or the node that assembled it restarted, ends with that reason; the backup itself completed, and running it again assembles a new archive.
The console records each backup it sends in the browser tab’s session storage until the archive is downloaded. Reloading the page while the backup runs or downloads sends the same backup again under its execution reference: the leader answers with the backup’s recorded outcome instead of running it twice, and the console downloads the archive again from its first byte. The node collects an archive once it has queued the archive’s last bytes for a download, so a reload that comes after that moment finds the archive collected, and the backup must run again. Closing the tab or signing in as another user forgets the record. So does a reload that finds a record the console cannot read back as exactly what it wrote, such as one edited outside the console; the reload then resumes nothing. An archive stays downloadable only while it is retained, by default for 15 minutes from the moment the console sent the backup.
The browser saves the archive with the permissions of its downloads directory. An archive holds password hashes, client secrets, and TLS private keys, so keep a downloaded archive where only its owner can read it.
Restoring An Archive
The Restore form restores one domain of an archive, under its archived name or a new one, or a whole cluster archive with its policy for users the cluster already has. It can resume the restored domains at their archived lifecycle, and leave out source offsets or all runtime state.
Choose the archive file, set the options, and select Dry run. The console reads the file once to
measure its size and BLAKE3 digest, then streams it to the leader with the statement’s DRY RUN
form, showing how many bytes it has read and sent. The dry run reports what the restore would
create: each domain under the name it would have, with its status, start version, models, and
resource versions, and every step it would apply. The planned model run of each domain is drawn
with the transaction inspector’s view.
Restore streams the archive again and runs the same statement without DRY RUN. It stays
disabled until a dry run of the current options and file has planned the restore. The result lists
each step as applied, failed, or not attempted: a restore that fails at a step keeps the steps
before it, and the dialog names the step. A refusal shows the leader’s reason, such as a domain
that already exists or an archive that is not a valid backup archive, and changes nothing.
A restore that skips archived state says so. The result lists the restore’s warnings above its
report, as nervix-cli prints them: the state the restored schedule cannot take, such as a window
whose model changed, with the reason. The REPL prints the same warnings after the restore’s
message.
A restore runs outside transactions, so the form refuses while the session holds one. Every attempt sends the same execution reference, statement, and archive: a lost connection or an unknown outcome streams the archive again, from its first byte, until the leader answers. If no answer arrives within about ten minutes, the dialog reports the restore’s outcome as unknown and names its execution reference.
The console never renders what an archive holds beyond the summary a backup reports and the report a restore returns.
Domain Lifecycle
The sidebar’s domain selector switches the console between domains; the top bar shows the selected
domain’s state and runs START; or STOP; for it. Both statements appear in the REPL as if you
had typed them. See Start And Stop.
Themes
Four themes are available from the top bar: Dark navy (the default), Pure dark, D0ZNPP, and Light.
NSPL Formatter
nervix-nspl-format puts NSPL files into their canonical shape. It is an offline tool: it never
opens a session, contacts a cluster, or executes anything it reads. It parses a file, renders every
statement canonically, writes the result back, and can instead report which files would change.
The shape it writes is the same one SHOW CREATE prints, so text copied between a file and a
session is already formatted.
Installing
The formatter is installed alongside the other tools; see Cargo Install From GitHub.
Using It
nervix-nspl-format pipeline.nspl # format one file in place
nervix-nspl-format . # format every NSPL file beneath here
nervix-nspl-format --check . # report instead of rewriting
nervix-nspl-format --stdout pipeline.nspl # write to standard output
cat pipeline.nspl | nervix-nspl-format - # read standard input
| Argument | Behavior |
|---|---|
<PATH>... | NSPL files or directories. - reads standard input and writes standard output. |
--check | Print the path of every file that is not formatted, and change nothing. |
--stdout | Write the formatted result to standard output instead of the file. |
--check and --stdout cannot be combined, and - cannot be combined with other paths.
Searching Directories
A path naming a directory is searched recursively for .nspl files. The search honors your
.gitignore and skips hidden directories, so it never descends into build output or vendored
trees, and it does not follow symbolic links. Files are formatted in a stable order, so --check
output is reproducible.
A path naming a file is formatted whatever its extension — naming a file is an explicit request.
Only the recursive search filters by extension, which is why a template such as graph.nspl.upon
is never picked up by nervix-nspl-format ..
There is no built-in diff. To see what would change:
nervix-nspl-format --stdout pipeline.nspl | diff -u pipeline.nspl -
Exit Codes
| Code | Meaning |
|---|---|
| 0 | Every file was handled, or under --check every file was already formatted. |
| 1 | --check only: at least one file is not formatted. |
| 2 | The arguments were not usable. |
| 3 | A file could not be lexed or parsed. The lex or parse error is printed with the offending span, at its line in the file. |
| 4 | A file could not be read or written, or was not UTF-8. |
| 5 | A file could not be rendered, or its formatted output did not reparse to the same statements. This is a defect in the formatter; please report it. |
A file that fails never blocks the others: every failure is reported and every other file is still formatted. A file that fails is left exactly as it was.
What It Guarantees
- Meaning is preserved. Reparsing the output yields exactly the statements that went in. The formatter checks this itself before writing, and refuses to write if it ever fails.
- Formatting is idempotent. Formatting an already formatted file changes nothing, byte for byte, and the file is not rewritten at all — so modification times do not churn.
- Comments survive. A comment between two statements stays between them, and a comment above a statement stays above it. A file header and a comment trailing the last statement are kept.
- Writes are atomic. The result is written to a temporary file in the same directory and renamed over the target, so an interrupted run never leaves a partly written file.
What It Changes
Formatting a hand-written file for the first time normalizes more than layout:
- Keywords are uppercased, including type names, and identifiers are lowercased — NSPL lowercases identifiers when it parses them, so this is the name the cluster already uses.
- String literals prefer single quotes; a value containing an apostrophe uses double quotes, and a value containing a newline or both quote styles is dollar-quoted, with a delimiter the value cannot close early, not even with its last characters.
- Line endings become
\n, except inside a string literal, whose line breaks are part of its value and are kept exactly as written. Trailing whitespace is dropped from comments. - An MQTT topic stays bare only when it is a lower-case identifier; any other topic, including one with an upper-case letter, is quoted, because MQTT topics are case-sensitive.
- A
START ATtimestamp is written in UTC with an explicit+00:00offset. A leap second, which RFC 3339 writes as:60, is written as the instant it reads as: one second past the second before it. - Values that were left implicit are written out, such as a relay’s
CAPACITYand an ingestor’sINSTANCES. - Redundant parentheses are dropped from expressions, keeping only those that change grouping.
Durations and byte sizes keep the spelling you wrote: 250ms stays 250ms, and 1MiB stays
1MiB. A domain’s PERIOD and SKEW, a generator’s EACH and a polling ingestor’s EVERY are
held as nanoseconds, so they are written as one whole number of the largest unit that divides them
exactly: 1500ms stays 1500ms, and 60s becomes 1m.
Comments Inside A Statement
A comment between two statements has one obvious place to go. A comment inside a statement does not, so the formatter does not guess: a statement containing a comment in its body is written back exactly as you wrote it, and the statements around it are still formatted.
That statement keeps its original layout until the comment is moved out of it. This is deliberate — the alternative is relocating a comment away from the clause it explains.
NSPL Agent Skill
Nervix publishes a portable Agent Skills package that teaches compatible
AI coding agents how to design, explain, review, and troubleshoot Nervix configurations written in
NSPL. The maintained source is
nervix-io/nervix/.agents/skills/nspl.
The skill contains guidance and references, not a Nervix client or server. Review its contents before installing it, just as you would review any third-party instructions supplied to an agent. It is distributed under the repository’s FCL-1.0-ALv2 license.
The skill discovers public language documentation through the versioned
llms.txt index. That endpoint selects the latest published
documentation version, whose relative Markdown links remain pinned to the same immutable build.
The index intentionally covers the public NSPL configuration surface rather than Nervix internals
or local-development instructions.
You do not need to clone the Nervix repository. The installer downloads the skill directly from the public GitHub repository, and the installed skill uses the public Nervix documentation.
Quick Start
1. Preview And Install The Skill
The skill becomes installable directly from the public repository as soon as it is merged into the default branch. A separate package upload, repository clone, or GitHub release is not required.
GitHub CLI 2.90.0 or newer provides the preview gh skill commands. If gh skill is unavailable,
install or update GitHub CLI before
continuing. Then inspect the complete skill before installing it:
gh skill preview nervix-io/nervix nspl --allow-hidden-dirs
Install it for one agent at user scope so it is available in every project. This Codex example can
use claude-code, kimi-cli, or another value listed by gh skill install --help instead:
gh skill install nervix-io/nervix nspl \
--allow-hidden-dirs \
--agent codex \
--scope user
The --allow-hidden-dirs option is required because the maintained source lives under
.agents/skills/nspl. GitHub CLI records the repository source with the installation so it can be
updated later.
Cross-Agent Installer
The skills CLI is an alternative that can install into
multiple supported agents at once, including agents not currently supported by gh skill. It
requires Node.js and can run directly through npx.
Install the NSPL skill for your user account so it is available in every project:
npx skills add nervix-io/nervix --skill nspl --global
The installer detects supported coding agents and asks which ones should receive the skill. It can install one canonical copy and create the agent-specific links needed by Claude Code, Codex, Cursor, Kimi Code CLI, GitHub Copilot, Gemini CLI, OpenCode, Grok Build, and other supported agents.
For a non-interactive installation into every agent supported by the installer:
npx skills add nervix-io/nervix --skill nspl --global --agent '*' --yes
Use --agent '*' only when all of those integrations are intentional. For a smaller installation,
repeat --agent with the desired identifiers:
npx skills add nervix-io/nervix \
--skill nspl \
--global \
--agent claude-code \
--agent codex \
--agent kimi-code-cli \
--agent grok
Both installers download only the skill content needed by the selected agents. Neither command clones Nervix into the current directory.
2. Start A New Agent Session
Open the selected coding agent in your own project, configuration repository, or any other working directory. Start a new session after installation so the agent discovers the newly installed skill.
3. Ask The Agent To Use NSPL
The most portable invocation is to name the skill in the request:
Use the nspl skill to design a Nervix configuration that reads JSON orders from Kafka,
deduplicates them by order_id for 10 minutes, and publishes valid orders to another Kafka topic.
Use placeholders for broker addresses and credentials.
Agents may also expose installed skills through a skill picker or command. For example, use
$nspl or /nspl when the selected agent supports that form:
$nspl Review this Nervix configuration for invalid types, missing flush policies, and branch
isolation problems: <paste NSPL here>
The agent can also select the skill automatically from an ordinary request about authoring,
explaining, reviewing, or troubleshooting a Nervix configuration. Naming nspl explicitly is
useful when you want to guarantee that the installed guidance is loaded.
Describe What You Need
Give the agent as much of this deployment contract as you know:
- the input system, external topic, queue, stream, table, or other entity, and a sample payload;
- the required internal fields and exact types, when known;
- whether records are unbranched or isolated by fields such as tenant, device, or customer;
- filtering, transformation, deduplication, windows, correlation, inference, lookup, or custom WASM processing;
- operator-trusted Roto UDF requirements, including representative cases for
testblocks; - the output system and desired payload shape;
- delivery, ordering, batching, flush, error-handling, TLS, and credential requirements; and
- whether you want a new graph, an explanation, a review, or help diagnosing an error.
Use this copyable template when starting from a new graph:
Use the nspl skill to produce a complete Nervix configuration.
Input system and external entity:
Sample input payload:
Required processing:
Branch key, or unbranched:
Output system and external entity:
Delivery, flush, and error requirements:
Deployment-specific values that should remain placeholders:
Return assumptions, external prerequisites, ordered NSPL command phases, and verification
commands. Do not invent undocumented syntax.
If details are missing, the skill directs the agent to ask only questions that materially change the graph or to use conspicuous placeholders and state its assumptions.
Use The Result
A generated configuration should contain:
- assumptions and external prerequisites;
- separate client-local and transactional server command phases;
- complete NSPL with deployment placeholders called out; and
- relevant
SHOW,DESCRIBE, lookup, or subscription commands for verification.
An agent-generated Roto UDF should also contain test blocks for its contract. Nervix runs those
tests during CREATE UDF; any rejecting test prevents the UDF from being persisted. See
User-Defined Functions. For replay-sensitive functions, tests should also
protect the deterministic-by-default and VOLATILE contract.
The skill does not connect to or configure a running Nervix deployment by itself. Review the result, replace placeholders, provision required external entities such as Kafka topics, and then submit the NSPL through your normal Nervix client workflow. See the NSPL Overview for the current language documentation.
Install For One Project
With GitHub CLI, change the scope from user to project:
gh skill install nervix-io/nervix nspl \
--allow-hidden-dirs \
--agent codex \
--scope project
With the cross-agent installer, omit --global:
npx skills add nervix-io/nervix --skill nspl
Run the command from the target project’s root. Review the generated agent directories before committing them because project-scoped installations become part of that project rather than the Nervix repository.
Verify The Installation
List skills installed by GitHub CLI:
gh skill list
List globally installed skills:
npx skills list --global
For a project-scoped installation, run npx skills list from the project root. Confirm that nspl
appears for the expected agents.
Update The Skill
Update an installation managed by GitHub CLI:
gh skill update nspl
Update a global installation to the latest version from the default branch of
nervix-io/nervix:
npx skills update nspl --global
Update a project-scoped installation from that project’s root:
npx skills update nspl --project
Start a new agent session after updating when an existing session has already loaded the previous skill contents. Update the skill whenever upgrading Nervix or when the public NSPL surface changes.
For stricter reproducibility, install a reviewed commit instead of following repository updates:
gh skill install nervix-io/nervix nspl \
--allow-hidden-dirs \
--agent codex \
--scope user \
--pin <commit-sha>
Pinned installations do not update until they are reinstalled with a different commit.
Trust And Safety
Keeping the skill in the Nervix repository makes its changes visible in the same version history and code review as NSPL and its documentation. It does not make installed skills inherently trusted: a skill supplies instructions to an agent, and future updates can change those instructions.
- Preview the skill before installing or updating it.
- Review changes to
SKILL.mdand every bundled reference as carefully as code changes. - Pin a reviewed commit when reproducibility matters more than automatic updates.
- Never add credentials or deployment secrets to a skill directory.
- Remember that an agent follows a skill with the permissions already available to that agent; the skill itself grants no additional access.
The current NSPL skill contains instructions, public-documentation references, and display metadata. It contains no executable scripts.
Manual Installation
If Node.js or the skills CLI is unavailable, download the complete
nspl skill directory and copy
it into a skill location supported by the target agent. Keep SKILL.md, references/, and any
future bundled resources together.
Common project-level locations include:
.agents/skills/nspl/for Codex, Kimi Code CLI, GitHub Copilot, Cursor, Gemini CLI, Antigravity, and OpenCode.claude/skills/nspl/for Claude Code.grok/skills/nspl/for Grok Build
For a manual update, replace the entire installed nspl directory with the latest directory from
the repository. Do not update only SKILL.md, because its bundled references are part of the same
versioned skill.
Manual
This section is the user-facing guide to Nervix. If you are new to Nervix, the Quickstart builds a first graph step by step; this section is the full reference behind it.
Nervix is experimental software in active development. It is intended for evaluation, local testing, and design exploration. It is not suitable for real production workloads.
Use it for:
- understanding product boundaries and non-goals
- learning the NSPL surface
- understanding the currently supported transport integrations, including Kafka and Pulsar
- defining schemas, resources, relays, ingestors, processors, and emitters
- planning branch capacity from the documented cost structure and current signals
- working with sessions and the Rust client
- following end-to-end examples
To write custom WASM processor guests in Rust, continue to the Rust WASM Guest SDK. If you want implementation details, runtime internals, or control-plane/data-plane architecture, continue to Architecture And Internals.
What It Is Not
Nervix is not an exactly-once transactional engine.
With ACK-based ingestion modes, Nervix behaves as an at-least-once system. Without ACK tracking, it behaves as at-most-once. That is an intentional design choice: Nervix prioritizes throughput and low latency over transactional persistence on every action.
Operationally, Nervix is much closer to a high-performance in-memory relaying runtime with snapshot-style replicated state than to a transactional database or durable event log.
Important consequences:
- Raft-backed strong consistency applies to the control plane, not to the data plane.
- Records moving through ingestors, processors, and emitters do not cross an exactly-once transactional boundary.
- ACKs control retry and replay behavior. They do not provide exactly-once execution and are not persisted.
- Runtime state persistence and replication are practical recovery mechanisms for selected execution node state, not atomic commit boundaries across the whole graph.
- Nervix does not provide arbitrary-key queries against live runtime state from expressions. Materialized state is readable only inside its own branch. Cross-key access is out of scope because serving arbitrary keyed reads is database behavior. Nervix’s database-like surfaces, including materialized streams and resource-backed lookups, are structurally amortized instead. See Choosing An Enrichment Mechanism.
The data plane operates in two broad modes:
- Pure in-memory stream processing. Records traveling through relays and runtime nodes stay in memory while they are being routed and processed. ACK guards, ACK tokens, and ACK maps are part of this hot path and are not persisted.
- Snapshot-style replicated runtime state.
Selected runtime state such as Kafka domain offsets, deduplicator state, and materialized relay state is snapshotted to persistent storage and replicated to followers. WASM guest state is the one kind a source acknowledgement waits for: an
ATTACHEDWASM processor checkpoints its guest state to stable storage and to every assigned replica before it acknowledges the inputs the checkpoint covers. That checkpoint still does not make the path exactly once; see Recovery, Replay And Duplicates.
The execution graph itself is different: NSPL models and domain schedules are control-plane state and are persisted with strong consistency guarantees before the data plane runs them.
Each runtime graph node is scheduled onto one cluster node and may have replicas. Replicas maintain eventually consistent copies of replicated runtime state and are designed to catch up through the replication channel.
NSPL Overview
NSPL is the language used to define the Nervix graph.
Language Stability
NSPL has reached alpha. Its period of rapid, broad experimentation is over, and large backward-incompatible changes are no longer expected. The language is more stable, but alpha is not a promise of backward compatibility: NSPL will continue to evolve, and focused breaking changes may still be made when they materially improve the language model or correctness.
AI coding agents can use the portable NSPL Agent Skill to design, explain, review, and troubleshoot Nervix configurations. The guide explains installation without cloning this repository, skill invocation, useful request details, expected output, and updates.
For application-owned producers and consumers, Client Ingestors And Emitters connects the native endpoint grammar to a complete graph and its execution contracts.
The current top-level surface includes:
- domain lifecycle statements
- user statements
- create/alter/drop model statements
- resource lifecycle statements
- session subscription statements
- explicit transaction controls:
BEGIN,COMMIT, andREVERT - describe and show commands
Core create statements:
CREATE [IF NOT EXISTS] USER <name> WITH PASSWORD '<password>';
CREATE [IF NOT EXISTS] SCHEMA <name> (<field> <type> [OPTIONAL], ...);
CREATE [IF NOT EXISTS] WIRE JSON SCHEMA <name> MODE STRICT|LOOSE (<field> <json_type> [OPTIONAL], ...);
CREATE [IF NOT EXISTS] WIRE CBOR SCHEMA <name> MODE STRICT|LOOSE (<field> <cbor_type> [OPTIONAL], ...);
CREATE [IF NOT EXISTS] WIRE AVRO SCHEMA <name> MODE STRICT|LOOSE (<field> <avro_type> [OPTIONAL], ...);
CREATE [IF NOT EXISTS] CODEC <name>
FROM WIRE JSON SCHEMA <wire_schema>
TO SCHEMA <schema>
[ENCODE <field> AS RFC3339, ...];
CREATE [IF NOT EXISTS] CODEC <name>
FROM WIRE CBOR SCHEMA <wire_schema>
TO SCHEMA <schema>
[ENCODE <field> AS RFC3339, ...];
CREATE [IF NOT EXISTS] CODEC <name>
FROM WIRE AVRO SCHEMA <wire_schema>
TO SCHEMA <schema>
[ENCODE <field> AS RFC3339, ...];
CREATE [IF NOT EXISTS] CODEC <name>
FROM JSON|YAML|TOML|XML|CBOR
TO SCHEMA <schema>
WITH JAQ TRANSFORMATIONS
[ON INGESTION '<program>']
[ON EMITTING '<program>' [ON EMITTING BATCH '<program>']];
CREATE [IF NOT EXISTS] CODEC <name>
FROM PROTOBUF
USING RESOURCE <resource> VERSION <n> | LATEST
CONFIG {'file' = '<path.proto>', 'include' = '.'}
MESSAGE '<package.Message>'
[BATCH MESSAGE '<package.BatchMessage>']
TO SCHEMA <schema>
WITH JAQ TRANSFORMATIONS
[ON INGESTION '<program>']
[ON EMITTING '<program>' [ON EMITTING BATCH '<program>']];
CREATE [IF NOT EXISTS] CODEC <name>
FROM SYSLOG
TO SCHEMA <schema>;
CREATE [IF NOT EXISTS] RELAY <name> SCHEMA <schema> [CAPACITY <n>]
[WITH MATERIALIZED STATE LAST BY TIMESTAMP];
CREATE [IF NOT EXISTS] PLACEMENT <name>
FROM <runtime_node> [, <runtime_node> ...]
TO <runtime_node> [, <runtime_node> ...]
REQUIRE COLOCATION|PREFER COLOCATION|NEUTRAL|SUGGEST SEPARATION
[RANK <n>];
JAQ-backed codecs require at least one direction. When both are present, ON INGESTION precedes
ON EMITTING.
Core alter statements:
ALTER DOMAIN SET PLACEMENT
REQUIRE COLOCATION|PREFER COLOCATION|NEUTRAL|SUGGEST SEPARATION;
ALTER RELAY <name>
SET CAPACITY <n> |
SET SCHEMA <schema> |
SET BRANCHED BY <branch> |
SET UNBRANCHED |
SET MATERIALIZED STATE LAST BY TIMESTAMP |
DROP MATERIALIZED STATE
[, ...];
ALTER JUNCTION <name>
ADD FROM <relay> [WHERE <expr>] |
DROP FROM <relay> |
ALTER FROM <relay> SET WHERE <expr> |
ALTER FROM <relay> DROP WHERE |
SET COLLECT FOR <duration> [MAX BATCH SIZE <bytes>] |
DROP COLLECT |
SET FILTER WHERE <expr> |
DROP FILTER WHERE |
SET ATTACHED | SET DETACHED |
SET BRANCHED BY <branch> | SET UNBRANCHED |
ADD|DROP|ALTER MATERIALIZED STATE ... |
ADD|DROP|REPLACE ROUTE ...
[, ...];
ALTER DEDUPLICATOR <name>
SET DEDUPLICATE ON <expr> [, <expr> ...] |
SET MAX TIME <duration> |
ADD|DROP|ALTER FROM ... |
SET|DROP COLLECT ... |
SET|DROP FILTER WHERE ... |
SET ATTACHED | SET DETACHED |
SET BRANCHED BY <branch> | SET UNBRANCHED |
ADD|DROP|ALTER MATERIALIZED STATE ... |
ADD|DROP|REPLACE ROUTE ...
[, ...];
ALTER REORDERER <name>
SET BY <expr> [, <expr> ...] |
SET MAX TIME <duration> |
ADD|DROP|ALTER FROM ... |
SET|DROP COLLECT ... |
SET|DROP FILTER WHERE ... |
SET ATTACHED | SET DETACHED |
SET BRANCHED BY <branch> | SET UNBRANCHED |
ADD|DROP|ALTER MATERIALIZED STATE ... |
ADD|DROP|REPLACE ROUTE ...
[, ...];
ALTER EMITTER <name>
ADD FROM <relay> [WHERE <expr>] |
DROP FROM <relay> |
ALTER FROM <relay> SET WHERE <expr> |
ALTER FROM <relay> DROP WHERE |
SET TO <full sink clause including MODE> |
SET MODE <transport-specific publishing mode body> |
SET CLIENT <client> |
SET ENCODE USING <codec> | DROP ENCODE |
SET COLLECT FOR <duration> [MAX BATCH SIZE <bytes>] | DROP COLLECT |
SET ATTACHED | SET DETACHED |
SET BATCH MAX MESSAGES <n> MAX SIZE <bytes> | DROP BATCH |
SET FLUSH EACH <duration> MAX BATCH SIZE <bytes> | SET FLUSH IMMEDIATE |
SET COMMIT EACH <duration> MAX SIZE <bytes>
[, ...];
ALTER <CONNECTOR> <ENTITY> INGESTOR <name>
SET FROM <body> |
SET QUIESCE <quiesce body> |
SET DECODE USING <codec> |
SET TIMESTAMP NOW | SET TIMESTAMP AT <field> | DROP TIMESTAMP |
SET FILTER WHERE <expr> | DROP FILTER WHERE |
ADD|DROP|REPLACE ROUTE ... |
SET GENERAL ERROR IGNORE|LOG
[, ...];
ALTER REINGESTOR <name>
ADD|DROP|ALTER FROM ... |
SET|DROP COLLECT ... |
SET|DROP FILTER WHERE ... |
SET ATTACHED | SET DETACHED |
ADD|DROP|ALTER MATERIALIZED STATE ... |
ADD|DROP|REPLACE ROUTE TO <relay> <construction> BRANCHED BY <branch>|UNBRANCHED ...
[, ...];
ALTER GENERATOR <name>
SET MATERIALIZED STATE <relay> |
SET EACH <duration> |
SET BRANCHED BY <branch> | SET UNBRANCHED |
ADD|DROP|REPLACE ROUTE ...
[, ...];
ALTER PLACEMENT <placement>
SET POLICY REQUIRE COLOCATION|PREFER COLOCATION|NEUTRAL|SUGGEST SEPARATION |
SET RANK <n> |
DROP RANK |
SET FROM <runtime_node> [, ...] TO <runtime_node> [, ...] |
RENAME TO <name>
[, ...];
ALTER SCHEMA <name>
ADD FIELD <field> <type> [OPTIONAL] [SENSITIVE],
DROP FIELD <field>,
RENAME FIELD <field> TO <field>,
ALTER FIELD <field> SET TYPE <type>,
ALTER FIELD <field> SET|DROP OPTIONAL,
ALTER FIELD <field> SET|DROP SENSITIVE;
ALTER WIRE JSON|CBOR|AVRO SCHEMA <name>
MODE STRICT|LOOSE,
ADD FIELD <field> <wire_type> [OPTIONAL],
DROP FIELD <field>,
RENAME FIELD <field> TO <field>,
ALTER FIELD <field> SET TYPE <wire_type>,
ALTER FIELD <field> SET|DROP OPTIONAL;
Operations in one ALTER RELAY, ALTER JUNCTION, ALTER DEDUPLICATOR, ALTER REORDERER,
ALTER EMITTER, ALTER <CONNECTOR> <ENTITY> INGESTOR, ALTER REINGESTOR, ALTER GENERATOR, or
ALTER PLACEMENT execute in written order. An ingestor’s ALTER repeats the connector and entity
the ingestor reads, such as ALTER KAFKA TOPIC INGESTOR, and never changes them. See
Streams And State for relay operations,
Processors for the full processor operation shapes, and
Emitters and
Ingestors for boundary-node operations. See
Schemas And Codecs for ordered schema changes and atomic
migrations, and Placement Policies for placement rank, path coverage, and
enforcement.
All CREATE statements may optionally insert IF NOT EXISTS immediately after CREATE.
When IF NOT EXISTS is present and the named entity already exists, the command succeeds as a no-op instead of failing. Command responses also mark that condition explicitly with already_existed = true.
The rest of the graph is built with:
CREATE DOMAIN,CREATE UNPACED DOMAIN,CREATE PACED DOMAINCREATE USERCREATE CLIENTCREATE VHOSTCREATE ENDPOINTCREATE <CONNECTOR> <ENTITY> INGESTOR, such asCREATE KAFKA TOPIC INGESTORorCREATE CLIENT PRODUCER INGESTOR; see IngestorsCREATE GENERATORCREATE INFERENCERCREATE JUNCTIONCREATE DEDUPLICATORCREATE REINGESTORCREATE EMITTERCREATE PLACEMENTCREATE HASH MAP
CREATE DOMAIN <name> is the short spelling for CREATE UNPACED DOMAIN <name>.
Domain creation may declare a placement default, and named placement rules may overlay paths in
the active domain. Omission means NEUTRAL, which preserves ordinary scheduler heuristics. See
Placement Policies for the four policy levels and lifecycle commands.
Multiple NSPL statements in one request must be wrapped in an explicit
transaction. BEGIN creates a replicated transaction and returns its id,
COMMIT executes the queued statements, and REVERT drops them without
applying them. A transaction survives an unclean disconnect or leader failover;
the CLI and web console re-attach before sending another command. Sending
multiple statements without BEGIN is rejected.
CREATE DOMAIN production;
USE production;
BEGIN;
CREATE SCHEMA notification (user_id I64);
COMMIT;
BEGIN binds the transaction to the selected domain, which must already exist,
and every queued statement must select that same domain. BEGIN inside an
active transaction is an error. COMMIT and REVERT also require an active
transaction. Queueable content is limited to that domain’s model mutations,
including REBIND RESOURCE, domain configuration and lifecycle, and CREATE RESOURCE. CREATE DOMAIN,
CREATE USER, most read-only statements, subscriptions, resource uploads, and node
administration are not valid inside a transaction and must be sent separately.
SHOW TRANSACTIONS; and DESCRIBE TRANSACTION run separately beside an attached
transaction without entering its queue or changing its binding. Use SHOW TRANSACTIONS;
to inspect live transactions and retained outcomes. See
Control Plane for attach,
failover, commit-step, expiry, and limit semantics.
DESCRIBE TRANSACTION reads one transaction’s impact report without changing
that transaction:
Transaction Quiescence And Impact Inspection explains the planned and actual scopes, affected topology, and retained outcomes reported by this statement.
DESCRIBE TRANSACTION [ '<id>' ] [ OPERATION <n> ] [ FORMAT TEXT | JSON ];
Without an id it reads the transaction attached to the session. A quoted id, as
BEGIN and SHOW TRANSACTIONS print it, reads that transaction instead; it must
belong to the same user, and reading it never attaches it or selects its domain.
OPERATION <n> names an accepted operation, numbered from 1 in the order the
statements were queued, and leads the report with that operation and the
execution step it belongs to without narrowing the rest. TEXT is the default;
FORMAT JSON prints the same report as one JSON document.
Both formats contain the complete report. Large affected graphs and repeated execution steps are
not paginated or shortened; plan client response memory and output handling for the transaction’s
accepted operation count and affected topology.
In the Rust client, reading the attached transaction at its current queue position refreshes the
preview supplied with a later COMMIT. An inspection of another id leaves that preview and the
session binding untouched. After a stale-preview refusal, inspect the attached transaction again
before retrying the commit.
The statement is the one read-only statement allowed while a transaction is open. It is answered before queueing, so it never becomes transaction content and never consumes an operation number, but it must be sent on its own rather than inside a multi-statement request:
// The transaction attached to this session.
DESCRIBE TRANSACTION;
// Its second queued operation first.
DESCRIBE TRANSACTION OPERATION 2;
// A transaction by id, as one JSON document.
DESCRIBE TRANSACTION '01a0ca64-7062-7302-ac1e-43138ccc2067' FORMAT JSON;
For two queued schemas, the text report reads:
transaction: 01a0ca64-7062-7302-ac1e-43138ccc2067
domain: production
state: OPEN
operations: 2 accepted, 0 applied, 2 pending
report: COMPLETE
planning basis: 10b2c6ecf022ec0f2d9149fe98039cc065533d326e5dba7ea74660f64b893ef8
quiesce level: DYNAMIC
pause: NO_PAUSE
operation 1: CREATE_CONFIGURATION kind=schema name=notification
execution step: 1-2
reason: CONFIGURATION kind=schema name=notification aspect=ENTITY_CREATED
operation 2: CREATE_CONFIGURATION kind=schema name=delivery
execution step: 1-2
reason: CONFIGURATION kind=schema name=delivery aspect=ENTITY_CREATED
execution step 1-2: planned DYNAMIC, actual DYNAMIC, outcome UNATTEMPTED
pause: NO_PAUSE
effect: configuration CREATED kind=schema name=delivery operations=2
effect: configuration CREATED kind=schema name=notification operations=1
effect: topology before nodes=0 edges=0 after nodes=2 edges=0
A paused subgraph lists each paused node and admission gate under its pause:
line with the operations that require it, and a finished transaction reports the
APPLIED or FAILED outcome each execution step recorded, for as long as its
outcome is retained. An inspection that cannot be answered says why: no
transaction is attached, the id is unknown or its retention has expired, the
transaction belongs to another user, the operation number is past the operations
it accepted, or no coherent report can be read for it, as for a transaction that
ended before planning any operation. See
Control Plane for what each
transaction state reports.
Ingestors, relay-consuming processors, and generated-output processors use optional node-level
arrival filters and route-local construction. Relay-consuming processors may also attach a
source-level filter to FROM:
FROM <relay> [WHERE <expr>], ...
[COLLECT FOR <duration> [MAX BATCH SIZE <bytes>]]
[FILTER WHERE <expr>]
TO <relay>
[INHERIT ...]
[SET <field> = <expr>, ...]
[WHERE <expr>]
FLUSH ...
ON MESSAGE ERROR ...
[TO <relay> ...]
COLLECT FOR is optional on graph nodes that consume relay input through FROM. It is unavailable
to ingestors, which read external sources rather than relays. When omitted, each incoming Arrow
batch proceeds directly to node execution. When present, Nervix maintains an input batch
independently for each source relay and concrete branch, then executes the node when the duration
expires or the optional size boundary is reached. Correlators configure the clause independently
after each LEFT FROM and RIGHT FROM relay list. Emitters place it after their complete
FROM <relay> [WHERE ...] [, ...] list. All emitter inputs declare the same payload schema, but
they may belong to differently named branches; collection remains independent for each source
relay and concrete branch. Generators do not read a FROM relay and therefore have no input
collection clause. The duration is measured on the domain clock and starts when the source and
branch collector changes from empty to non-empty. A paced domain’s TIME RATE scales that wait.
FROM ... WHERE runs first. FILTER WHERE runs next, before the node accepts rows into its state,
buffer, inferencer, or guest. Every route then creates a new empty output, performs its own ordered
construction, finalizes the declared schema, and evaluates its route WHERE. Required fields must
be initialized; omitted optional fields become typed nulls. There is no implicit identity
transformation and no global SET or INHERIT.
Input collection assembles relay batches before that execution sequence. Its time and size include
rows that a later source or node filter may reject. COLLECT FOR controls input delivery to the
node; route-local FLUSH controls output delivery from the node. The two policies are independent.
Transforming routes use one working message from input through finalization.
Transforming routes—ingestors after decoding, reingestors, junctions, deduplicators, reorderers,
and codec emitters—may use INHERIT. Generators, windows, inferencers, WASM processors,
correlators, and direct emitters are set-only and reject INHERIT. Generated inferencer and WASM
state is an immutable read source shared independently by every route; it is not an automatically
initialized output and it is not exposed as input or message.
This surface is available on:
CREATE <CONNECTOR> <ENTITY> INGESTORCREATE INFERENCERCREATE JUNCTIONCREATE DEDUPLICATORCREATE REINGESTORCREATE WASM PROCESSORCREATE WINDOW PROCESSORCREATE EMITTER
Every TO destination on a flush-based node requires FLUSH EACH <duration> MAX BATCH SIZE <bytes> or FLUSH IMMEDIATE; there are no hidden defaults. Each concrete output branch owns its
buffer and deadline. FLUSH EACH measures its duration on the domain clock, beginning when data
first enters an empty buffer. Window processors use WIDTH and STEP, and WASM processors use
guest-owned output cadence instead of FLUSH.
This is the authoritative FLUSH IMMEDIATE timing rule.
During normal processing, FLUSH IMMEDIATE starts a system-owned physical 100 µs minimum batching
timeout when data first enters an empty route buffer. Domain pacing never scales this monotonic
wait. The route flushes when that timeout expires, allowing nearby arrivals to remain in one Arrow
batch instead of collapsing to one batch per message.
FLUSH IMMEDIATE has no size boundary; shutdown and error handling may still force pending data
out.
Treat each required FLUSH clause as workload-specific operational tuning. FLUSH IMMEDIATE
minimizes time spent waiting beyond the system-owned batching window, while FLUSH EACH <duration> MAX BATCH SIZE <bytes> emits when either configured boundary is reached. Choose both values for
the route’s traffic, downstream behavior, and branch cardinality. Values in examples are
illustrative, not recommended defaults. Two measured facts decide what a FLUSH clause can buy.
MAX BATCH SIZE only clamps a batch; it never grows one. The batch a route emits is roughly its
arrival rate multiplied by the FLUSH EACH interval, cut off at the byte cap, so raising a cap
that never fires changes nothing and the only way to grow batches is a longer interval, paid in
latency. Upstream of every route, ingestors build their first Arrow batches from source groups that
close at 1,024 messages, or earlier after a 5 ms idle gap, independent of any FLUSH policy; a
payload a JAQ-backed codec unfolds is never split, so its group may close above 1,024. Larger
batches downstream come only from route buffering across an interval. Read actual batch sizes
from the nervix_messages_per_batch histogram instead of
inferring them.
Larger batches do not make program execution faster beyond about a thousand rows. Execution is
columnar, so its fixed per-batch cost is amortized quickly. The table shows per-row throughput of
the VM batch-size sweep (crates/nervix-vm/benches/vm.rs, criterion, one development machine,
September 2026) relative to the 1,024-row value for each program shape: at 64 rows it is 12–60%
of that value and climbs steeply; above 1,024 rows most shapes are flat within measurement noise,
float arithmetic dips, and only the cheapest shape, nullable casts, keeps gaining, because batches
above 1,024 rows execute on the node’s data workers and their per-batch admission and hand-off
is no longer amortized by more work. Absolute rates are machine-specific; the shape is not.
| program shape | 64 | 256 | 1,024 | 4,096 | 16,384 | 65,536 |
|---|---|---|---|---|---|---|
| integer arithmetic + filter | 21% | 53% | 100% | 98% | 97% | 100% |
| integer comparison | 19% | 52% | 100% | 96% | 98% | 108% |
| float arithmetic | 28% | 64% | 100% | 84% | 71% | 77% |
| nullable casts | 12% | 41% | 100% | 70% | 133% | 137% |
| string kernels | 24% | 61% | 100% | 82% | 105% | 106% |
| text transforms | 47% | 82% | 100% | 89% | 97% | 105% |
| list builtins | 60% | 87% | 100% | 82% | 93% | 99% |
Larger batches genuinely pay only at boundaries that do work per batch rather than per record:
- OTEL emitters send one export request per batch, so batch size sets request count and the compression ratio.
- Iceberg emitters write one staging file per flush and one commit per
COMMIT EACH ... MAX SIZEboundary, so batch and commit sizes set file counts and Parquet file sizes. - Inferencers whose tensor shapes declare a batch dimension run one model invocation per batch.
- WASM processors serialize one Arrow IPC payload per batch, and cluster interconnect sends one frame per batch, with a fixed 8 MiB frame limit.
Broker emitters — Kafka, Pulsar, NATS, RabbitMQ, MQTT, Redis, ZeroMQ, Sentry, and syslog — encode
and publish one record at a time whatever the batch size; wire batching there belongs to the
client, such as Kafka’s linger.ms and batch.size. SQS BATCH groups at most ten records per
request, and database sinks split every flush into statements of at most the
batching clause’s MAX MESSAGES records. On such routes a larger
MAX BATCH SIZE or a longer interval buys only fewer flush
cycles, at the cost of latency, memory, and coarser failure and retry granularity: prefer the
shortest interval the sink tolerates and let the byte cap protect memory.
MAX BATCH SIZE measures the logical Arrow data in the current batch slice: value buffers plus
the offsets and validity data needed to represent those values. It does not count unused buffer
capacity or Rust and Arrow object overhead.
SET assignments execute left to right and repeated targets are valid. A later assignment may read
an earlier value through the bare field or output.<field>. INHERIT ALL, INHERIT ALL EXCEPT ..., and explicit INHERIT field, ... copy compatible same-named input fields. UNSET is not part
of NSPL.
Supported expression surface:
- literals:
I64integers,F64floats,BOOL, andSTRING - identifiers: field references from the current row
- arithmetic:
+,-,*,/,% - comparisons:
=,!=,>,<,>=,<= - null-safe equality:
IS DISTINCT FROM,IS NOT DISTINCT FROM - membership and ranges:
value [NOT] IN (constant, ...)andvalue [NOT] BETWEEN low AND high - boolean logic:
AND,OR,NOT - conditionals:
IF condition THEN value ELSE value END, searchedCASE WHEN condition THEN value ... [ELSE value] END, and simpleCASE operand WHEN match THEN value ... [ELSE value] END - parentheses for nesting and precedence control
- explicit conversions only:
expr AS TYPE, which fails a message whose value does not convert, andTRY_CAST(expr AS TYPE), which yields a typed null for it instead - JSON extraction from
STRINGvalues holding JSON:JSON_VALUE(doc, '$.path' AS TYPE), which fails a message whose document or value does not read asTYPE,TRY_JSON_VALUE(doc, '$.path' AS TYPE), which yields a typed null for it instead, andJSON_EXISTS(doc, '$.path');TYPEmay be a scalar,VEC<...>, orARRAY<..., n>type
Expression Functions owns the semantics of every form above, including conditional typing, the reserved words, and the rules for where each function may run.
Expression types match the full Nervix internal schema type set:
- integers:
U8,I8,U16,I16,U32,I32,U64,I64 - floating point:
F32,F64 - other scalars:
BOOL,STRING,BYTES,DATETIME - lists:
ARRAY<...>andVEC<...>
AS and TRY_CAST(expr AS TYPE) convert between scalar types and accept long and short spellings,
such as AS UINT8 or AS U8; see Conversions for every
spelling and the values each conversion rejects.
Supported built-ins include string, null-handling, numeric, regex, and contextual functions such as:
- string transforms:
lower,upper,trim,length,concat - null handling:
coalesce,is_null,nullif - numeric and predicates:
abs,contains,starts_with,ends_with - extrema:
greatest,least,clamp - datetime:
date_part,date_trunc,date_bin,date_add,date_diff,to_unix,from_unix,format_datetime,parse_datetime, with calendar units and explicit time zones - IP addresses and networks:
ip_from_string,ip_to_string,ip_family,ip_trunc,ip_in_network,ip_unmap,is_ip_address, over 4- or 16-octetBYTESaddresses - URLs:
url_scheme,url_host,url_port,url_path,url_query,url_fragment,url_query_value,url_query_values,url_decode,is_url - contextual functions:
now,uuid_v4,uuid_v7
See Expression Functions for the full current function list, signatures, and aliases.
User-defined calls always use udf::<name>(...). The explicit namespace means adding a builtin can
never shadow a UDF or change existing user code. See
Choosing An Extension Tier.
General expression rules:
- builtin calls may be nested or chained, for example
lower(trim(raw)) - arithmetic and predicate expressions may also be nested with parentheses
- there is no implicit cast insertion; type mismatches must be resolved with an explicit
AS ...orTRY_CAST(... AS ...) - relay names are graph references, never expression qualifiers
- language scopes are
message,input,output,branch,left,right,relay_state.<relay>,metadata,partial_output, anderror; availability depends on context - transforming construction reads the working message;
output.fieldreads only an already initialized output field - generated routes allow bare reads from immutable generated state until the same-named output is
initialized;
messageandinputare unavailable branch.fieldmust be explicit and is unavailable in successful emitter expressions- supported ingestors read headers with
read_header(name)andread_headers(name); Kafka exposes typedmetadata.topic,metadata.partition, andmetadata.offset, while Syslog exposes optionalmetadata.peer_addr - supported codec emitters stage ordered
write_header(name, value)calls inINVOKE
Example:
CREATE BRANCH by_tenant
SCHEMA tenant_branch TTL 5m;
CREATE HTTP ENDPOINT INGESTOR notifications_in
FROM ingress MODE NO ACK
ON QUIESCE BUFFER MAX SIZE 1MiB DECODE USING notification_codec
FILTER WHERE input.active
TO notifications
INHERIT ALL EXCEPT raw
SET amount = message.amount + 1,
normalized = lower(input.raw)
BRANCHED BY by_tenant SET tenant = message.tenant
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
Another example showing nested conditions and chained calls:
CREATE EMITTER outbound
FROM notifications
TO KAFKA kafka_main TOPIC notifications_out
MODE ACK PARALLEL MAX 1000 ACK TIMEOUT 30s
RETRY POLICY BACKOFF 250ms MAX 30s
ENCODE USING notification_codec
INHERIT ALL EXCEPT raw
SET normalized = lower(trim(input.raw)), magnitude = abs(input.amount)
WHERE (output.active AND output.amount > 5) OR contains(lower(trim(input.raw)), 'urgent')
INVOKE write_header('tenant', input.tenant)
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
The leader parses and validates these structured expressions immediately when the statement is applied. Models never store raw executable NSPL, and runtime execution never reparses expressions.
Generators use a narrower surface:
CREATE GENERATOR synth_notifications
USING MATERIALIZED STATE notifications
EACH 100ms
BRANCHED BY by_tenant
TO generated_notifications
SET user_id = relay_state.notifications.user_id,
amount = relay_state.notifications.amount
FLUSH EACH 1s MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG;
Generator-specific rules:
- only
SETis allowed - exactly one materialized relay is declared and is accessed as
relay_state.<relay>.<field> - every route sees the same immutable state snapshot for one tick
FLUSH EACH <duration> MAX BATCH SIZE <bytes>orFLUSH IMMEDIATEis mandatory and controls buffered emission- paced domains evaluate both generator cadence and flush cadence against the bound logical clock; unpaced domains receive actual UTC through the same domain capability, with no missing-clock fallback
Runtime Node Error Policies
The Errors And Diagnostics chapter explains how failures are classified before these policies receive them.
Every TO route on an ingestor or relay-consuming processor must declare its message error policy after that route’s construction clauses:
ON MESSAGE ERROR IGNORE | LOG | SEND TO error_stream
SET error_reference = error.reference,
error_code = error.code,
source_id = input.id,
attempted_total = partial_output.total
An ingestor additionally declares its node-level general policy after the source configuration:
ON GENERAL ERROR IGNORE | LOG
Emitters attach ON MESSAGE ERROR to their single external route and retain ON GENERAL ERROR at
node level. WASM processors keep ON GLOBAL ERROR at node level for guest failures that are not
tied to a message.
MESSAGE errors carry a stable UUIDv7 reference, code, operation, optional operation index, sorted
affected field paths, timestamp, and a non-sensitive message. Error construction can read the
eligible original input, the exact materialized-state snapshot, an all-optional partial_output,
and the structured error scope. The error route preserves the branch in which the failure
occurred. On flush-based nodes, SEND TO records are buffered independently for the owning TO
route and concrete branch, then emitted when that route’s FLUSH interval or maximum batch size
fires. They are not dispatched immediately. Error-record construction failures are logged and
no-acked without recursively invoking the same policy. General and global errors remain node-wide
and do not inherit a route-local FLUSH policy.
Client definitions are key-value based and may optionally mount a resource for file-backed settings such as TLS material:
CREATE [IF NOT EXISTS] CLIENT <name>
TYPE <client_type>
[MOUNT <resource> VERSION <n>|LATEST]
CONFIG {
'<key>' = '<value>'
};
WebSocket clients and endpoints may also reference a signaling protocol:
CREATE [IF NOT EXISTS] SIGNALING PROTOCOL <name>
FORMAT JSON | YAML | TOML | XML | CBOR | RAW
| PROTOBUF USING RESOURCE <resource> VERSION <n> | LATEST
CONFIG { '<key>' = '<value>' }
SEND MESSAGE '<message_type>' WAIT MESSAGE '<message_type>'
ON CONNECT
( SEND JAQ '<program>'[, '<program>'...]
| WAIT JAQ '<matcher>'[, '<matcher>'...]
| WAIT JAQ '<matcher>' [CAPTURE '<program>'] [ACCEPT DATA] )+
[FAIL JAQ '<matcher>'[, '<matcher>'...]]
TIMEOUT <duration>;
CREATE [IF NOT EXISTS] CLIENT <name>
TYPE WEBSOCKETS WITH SIGNALING PROTOCOL <name>
CONFIG {
'endpoint' = 'wss://example.com/ws'
};
Each SEND JAQ program must produce exactly one value, which is serialized in the declared format.
Each WAIT JAQ matcher is satisfied by any output that is neither null nor false, so it can
assert the fields that matter and ignore the connection ids and timestamps real services add.
FAIL JAQ matchers abort the handshake immediately with the matched value as the reason.
Steps run strictly in written order: a step completes before the next starts, so a request can
depend on an earlier reply. A WAIT step completes when every matcher it lists is satisfied, in any
arrival order. CAPTURE records values from the matched frame, which every later program reads
through $state.
ACCEPT DATA says where payload starts flowing to the relay — on ON CONNECT for the first frame,
or on the WAIT step whose completion proves the peer is streaming. Frames arriving before that are
dropped rather than buffered. A FAIL JAQ guard on a step aborts during that step; the optional one
before ON CONNECT applies throughout.
Current built-in client transport kinds include:
KAFKAPULSARHTTPSENTRYOTELPROMETHEUSRABBITMQREDISMQTTNATSZEROMQSYSLOGSQSWEBSOCKETSS3GCSAZURE_BLOBCLICKHOUSEPOSTGRESMYSQLMONGODBICEBERG_REST
Resource management commands:
CREATE [IF NOT EXISTS] RESOURCE <name>;
UPLOAD RESOURCE <name> VERSION '<local_directory>';
DESCRIBE RESOURCE <name>;
DESCRIBE RESOURCE <name> VERSION <n>;
REBIND RESOURCE <name> TO VERSION <n> | LATEST
[FOR <kind> <name> [, <kind> <name> ...]];
TLS-capable VHOSTs:
CREATE [IF NOT EXISTS] VHOST <name> <hostname>, ...
[WITH TLS <resource> VERSION <n> | LATEST];
Every resource binding names its version: WITH TLS on a VHOST, USING RESOURCE on a protobuf
codec, protobuf signaling protocol, inferencer, or WASM processor, FROM RESOURCE on a hash map,
and MOUNT on a client. VERSION <n> binds that completed version. VERSION LATEST binds the
highest completed version when the statement is applied, and the stored model keeps that number,
so a later upload never moves the binding. See Versioning.
REBIND RESOURCE atomically moves all current usages, or an exact kind-qualified FOR selection,
to one completed version. The resource itself is unchanged. Every selected replacement validates
before any model is written; a no-op selection succeeds without a write. Resource descriptions
list all bound usages, while a version-qualified description lists only usages pinned there.
Backup and restore commands, which read and write archive files on the client’s machine:
BACKUP CLUSTER TO '<file>' [WITHOUT RESOURCES] [WITHOUT STATE | WITHOUT PAUSE | TIMEOUT <duration>];
BACKUP DOMAIN [<name>] TO '<file>' [WITHOUT RESOURCES] [WITHOUT STATE | WITHOUT PAUSE | TIMEOUT <duration>];
DESCRIBE BACKUP '<file>' [FORMAT TEXT | JSON];
RESTORE CLUSTER FROM '<file>' [RESUME] [ON EXISTING USER FAIL | SKIP | REPLACE] [DRY RUN] [WITHOUT STATE | WITHOUT SOURCE OFFSETS];
RESTORE DOMAIN <name> [AS <new_name>] FROM '<file>' [RESUME] [DRY RUN] [WITHOUT STATE | WITHOUT SOURCE OFFSETS];
BACKUP is admitted and run by the leader like other persistent commands, and the client
downloads the archive it assembles. DESCRIBE BACKUP runs in nervix-cli without a server.
RESTORE streams the archive to the leader, which verifies and plans the whole restore before it
changes anything, and then recreates the users, the domains stopped, their resource versions under
their archived numbers, their models, and their compatible runtime checkpoints, including
materialized relay rows. RESUME makes a complete restored domain running at its archived start
generation and clock mapping. A normal START advances that generation and clears materialized
state. A normal backup
quiesces each running domain for the state cut. See Backup And Restore.
Session-only commands:
CREATE SUBSCRIPTION <name> TO <relay> [BLOCKING|DROPPING] [BATCH SAMPLE RATE <rate>] [WHERE ...];
DELETE SUBSCRIPTION <name>;
DESCRIBE RELAY <relay> WHERE (...);
DESCRIBE INGESTOR <ingestor>;
DESCRIBE JUNCTION <junction>;
DESCRIBE DEDUPLICATOR <deduplicator>;
DESCRIBE REORDERER <reorderer>;
DESCRIBE WINDOW PROCESSOR <window_processor>;
DESCRIBE HASH MAP <hash_map>;
DESCRIBE PLACEMENT <placement>;
DESCRIBE RELOCATION <selection> ONTO NODE <node_id>
FOLLOW PREFERENCES | IGNORE PREFERENCES
[FOR <kind> <name> FOLLOW PREFERENCES | IGNORE PREFERENCES ...];
DESCRIBE DOMAIN;
LOOKUP <hash_map> KEY '<key>';
ATTACH DOMAIN CLOCK;
DETACH DOMAIN CLOCK;
Show commands:
SHOW CREATE <kind> <name>;
SHOW CREATE BRANCH by_tenant;
SHOW PLACEMENTS;
SHOW RELAY <name> MATERIALIZED STATE;
SHOW CLUSTER STATUS;
DROP NODE <node_id>;
CORDON NODE <node_id>;
UNCORDON NODE <node_id>;
DRAIN NODE <node_id>;
RELOCATE <selection> ONTO NODE <node_id>
FOLLOW PREFERENCES | IGNORE PREFERENCES
[FOR <kind> <name> FOLLOW PREFERENCES | IGNORE PREFERENCES ...];
A relocation <selection> is a kind-qualified list, <kind> <name>[, ...], or a directed corridor,
FROM <kind> <name>[, ...] TO <kind> <name>[, ...].
For a wire schema, <kind> is the exact multi-word kind: WIRE JSON SCHEMA, WIRE CBOR SCHEMA,
or WIRE AVRO SCHEMA. The same exact kind is required by DROP.
General notes:
- keywords are case-insensitive
- autocomplete is derived from the parser surface
- transport/client configs are generally preserved as pass-through string key/value pairs
- native schema fields may use the
SENSITIVEmodifier; session subscription output masks those values as<masked>, while emitters may send sensitive values to their configured external sink CREATE SUBSCRIPTIONandDELETE SUBSCRIPTIONare not persisted in the registryATTACH DOMAIN CLOCKandDETACH DOMAIN CLOCKfollow and stop following the active domain’s clock for the session alone and are not persisted either; see Domain Clock Attachment- session subscription names are unique within a connected session; one session may subscribe to relays from multiple domains, and
DELETE SUBSCRIPTIONuses the name rather than repeating subscription parameters - every
RELAYis scheduled with one owner; only that owner instantiates its buffer, concrete branch presence, fan-out, subscriptions, and metrics - relay
CAPACITYbounds the single owner buffer cluster-wide; each producer node and remote consumer node adds one fixed in-flight dispatch slot, independent of branch count - materialized state adds scheduler-selected state replicas to the relay rather than a separate runtime-node kind; ordinary relays report no replicas
- ingestors and reingestors construct branch identities, and a relay owner applies branch TTL and
MAX INSTANCESacross all producers for that relay DESCRIBE INGESTORexposes runtime-facing ingestor state, including memory-backpressure state and committed KafkaOFFSET BY DOMAINpartition assignment
The Working Message
A transforming route operates on one working message. It begins as the route’s input view. The
route constructs a new output shape: INHERIT carries fields into that shape, ordered SET
assignments rewrite it step by step, and finalization fixes it as the route output.
message.<field> always reads the working message as it currently stands.
Three scopes pin reads to points on that timeline:
input.<field>pins the original record. It never changes.message.<field>reads the live current value.output.<field>reads only a value that this route has already explicitly initialized.
The precise resolution rule is:
During transforming construction, message.field and a bare RHS field read the working output and
fall back to the exact-compatible input.field only while the output field is uninitialized.
After finalization, route WHERE sees the finalized output and performs no fallback.
Set-only output-construction routes have no message or input.
One Record, Step By Step
Assume the input record contains x = 2 and untouched = 9. Both fields are required I64.
This route constructs an output with x, copied_untouched, and other required fields:
INHERIT x
SET x = message.x + 3,
copied_untouched = message.untouched,
x = message.x * 2
The working message changes in written order:
| Point | Initialized output | Read | Result |
|---|---|---|---|
Before INHERIT | nothing | message.x | 2 from the exact-compatible input view |
After INHERIT x | x = 2 | message.x | 2 from the initialized output |
After the first SET | x = 5 | message.x | 5 |
While setting copied_untouched | x = 5 | message.untouched | 9 from input; untouched was never inherited or set |
While repeating the x target | x = 5 | message.x | 5, so the final x is 10 |
| After finalization | x = 10, copied_untouched = 9 | route WHERE | only the finalized output; no input fallback |
This is not an implicit identity transformation. Enrichment is a basic operation of Nervix, and
the working message is the native consequence of native inheritance: a route can carry input
values forward while progressively replacing selected values. Use input and output when every
read should be pinned to the original record or to explicit output initialization.
Scope Boundaries And Edge Cases
Exact compatibility means the same Nervix type and the same nullability. If the input and output
both declare a field name but their types or nullability differ, an uninitialized output field does
not make the incompatible input field available through message or a bare read. Use an explicit,
valid conversion from input.<field> where the language supports one.
Set-only output-construction routes begin with an empty output, reject INHERIT, and expose neither
message nor input. They must initialize every required output field. Omitted optional fields
finalize as typed nulls. Direct-emitter VALUES is a separate source-row mapping surface:
input, message, and bare fields read the source row, while output is unavailable.
Correlators have no default input scope. They expose left and right, reject bare RHS field
reads, and do not expose input or message.
Generated inferencer and WASM routes are set-only. Bare reads resolve against immutable generated state until a same-named output field has been initialized. Generated state is independently visible to every route and never initializes route output automatically.
Error routes expose the original eligible input, or left and right for correlators; the exact
captured relay_state snapshot; structured error; and the all-optional partial_output view.
Their ordered assignments may read values initialized by earlier error-record assignments. They do
not turn a partial output into a transforming working message.
See Runtime Nodes for route families and VM Functions for the implementation boundary.
Expression Functions
This chapter is the reference for NSPL expressions: the operators, conversions, builtin functions, and window aggregates they can use, with each one’s exact types, null handling, errors, and limits. Expressions appear on ingestors, processors, routes, and emitters, most often in route construction:
[INHERIT ...]
[SET <field> = <expr>, ...]
[WHERE <expr>]
[INVOKE write_header(<name-expr>, <value-expr>), ...]
SET assignments and INVOKE calls execute left to right. A transforming route begins empty and
may initialize fields with INHERIT and SET; a set-only route begins empty and supports only
SET. Route WHERE runs after output finalization. INVOKE and write_header are emitter-only;
side-effect functions are invalid inside ordinary expressions. See
The Working Message for the field scopes a route reads and how they resolve,
and Where Expressions Run for every other place an expression appears.
General rules:
- function names are case-insensitive, and an alias such as
ceilingorsubstringbehaves as, and is reported under the name of, the function it stands for - there is no implicit cast insertion;
expr AS TYPEandTRY_CAST(expr AS TYPE)convert explicitly, as Conversions describes, andJSON_VALUEreads a value of a declared type from JSON text, as JSON Documents describes - argument and result types are validated when the statement is applied, except for the few expressions Where Expressions Run lists as checked later
- sensitive values retain their sensitivity through expression evaluation; an output field that is
not sensitive can hold one only through an explicit
leak_sensitive(...), as Function Properties describes now()returns the execution-local domain time asDATETIME, anduuid_v7()uses that same time when building the UUID
Domain-owned Roto UDFs use the same call syntax and exact typing rules. Builtins take
the unqualified call surface. UDFs use the explicit udf:: namespace.
The Catalog at the end of this chapter lists every operator, builtin, and aggregate with its signature, result, and the section that describes it.
Choosing An Extension Tier
Use the smallest extension tier that fits the operation:
| Tier | Trust required | Statefulness | Unit of work | Execution | Failure containment |
|---|---|---|---|---|---|
| Builtins | None beyond Nervix itself | Stateless | One expression call | Async-safe runtime execution | Per-row error channel |
| Roto UDFs | Operator-trusted native code | Stateless | Vectorized column function with 1–8 arguments | The node’s extension workers | Per-row errors, whole-batch errors, and a post-return watchdog |
| WASM processors | Isolation suitable for third-party code | Branch-local guest state | Batch-and-route processor with guest-owned emission | One guest instance per branch | Message errors, global errors, and guest traps |
Tenant-supplied or potentially non-terminating logic belongs on the WASM processor path. Roto UDF creation is operator-trusted administration. See User-Defined Functions for the native execution contract and Module Sharing And Branch Memory for the WASM isolation and memory boundary.
Where Expressions Run
Every operator and builtin on this page is available wherever an expression is, except for the functions that depend on their context. This table lists those, by the context an expression sits in:
| Context | Window aggregates | read_header, read_headers | LOOKUP_HASH_MAP | now(), uuid_v4(), uuid_v7() |
|---|---|---|---|---|
Ingestor FILTER WHERE and route INHERIT, SET, and WHERE | No | On a header-capable source | Yes | Yes |
BRANCHED BY ... SET on ingestors and reingestors | No | No | Yes | Yes |
Processor FROM ... WHERE and FILTER WHERE, and the routes of junctions, deduplicators, reorderers, reingestors, inferencers, and WASM processors | No | No | Yes | Yes |
Window route SET | Yes, and only here | No | No | Yes |
Window route WHERE | No | No | Yes | Yes |
Generator routes, correlator routes, and CORRELATE WHERE | No | No | No | Yes |
DEDUPLICATE ON, reorderer BY, and inferencer INPUTS | No | No | No | Yes |
Emitter FROM ... WHERE, route SET, WHERE, and INVOKE, HTTP METHOD and PATH, and SQS FIFO GROUP | No | No | Yes | Yes |
Emitter VALUES and OpenTelemetry ATTRIBUTES | No | No | No | Yes |
ON MESSAGE ERROR SEND TO ... SET | No | For a failure on a header-capable ingestor | No | Yes |
Materialized-state DEFAULT | No | No | No | No |
Session subscription WHERE | No | No | No | Yes |
write_headeris a statement of an emitter route’sINVOKEclause, never part of an expression; see Header Functions.- A
udf::call is available in every context. A materialized-stateDEFAULTaccepts only a UDF that is notVOLATILE, and reads no field at all: it is built from constants. - Outside a window route,
count,sum,first,last,min, andmaxare the array and vector functions, and every other aggregate name is an unknown function, such asunknown function 'avg' with arity 1. - Set-only routes, which are the routes of windows, generators, correlators, inferencers, and WASM
processors, accept no
INHERITand noINVOKE.
Reorderer BY expressions and emitter VALUES and ATTRIBUTES mappings are compiled when their
domain’s execution is built rather than when the statement is applied, and a subscription’s
WHERE when the subscription is created, so a type error in one of them is reported then.
Evaluation Model
Builtins are evaluated over Arrow columns: one call computes its result for every message in a
batch together. A message’s result never depends on the other messages in the batch, so batching
does not change any value. The one limit a batch’s messages share is the bytes one STRING or
BYTES column holds; see Result Size. How a function traverses its column, whether
through an Arrow compute kernel, one pass over the column’s value buffer, or a loop over its rows,
is internal to the function and does not change its results.
Several builtins run on libraries that choose SIMD instructions at run time from the ones the
node’s CPU offers: JSON extraction finds a document’s structure that way, base64 and hexadecimal
encoding and decoding process their octets that way, sha256 uses the SHA instructions of x86-64
CPUs that have them, and substring searches such as contains_any and regular expressions scan
text that way. xxh3_64 uses the SIMD instructions of the CPU target the binary is built for.
Integer + and -, and * over every integer type but I64 and U64, run on Nervix’s own
kernels, which also choose vector instructions at run time from the ones the node’s CPU offers.
The other checked numeric kernels and passes over value buffers are written as loops the compiler
can turn into vector instructions for the CPU target the binary is built for, and Nervix makes no
claim about which of them it does. None of these choices changes a result. The one exception is the
transcendental functions, whose last places come from the platform’s C math library; see
Numeric Functions.
The compiler applies three optimizations that preserve results in the same way:
- A deterministic call that cannot fail and whose arguments are all literals may be computed once
instead of for every batch. It produces exactly the value that evaluating it for each message
would, so
upper('grüßen')andupper(input.text)agree wheninput.textholdsgrüßen. - Identical deterministic expressions that cannot fail may be computed once and shared within one
assignment or clause. Calls that return a new value for every message, such as
uuid_v4(), and calls that can report a per-message error are evaluated at each occurrence, so each occurrence reports its own error. - A literal, and any expression whose arguments are all literals or
now(), is carried through a batch as one value rather than as a column of copies. A function reads it as one value where it can and expands it to a column only where a message-by-message operation or an output field needs one, so the result is the same either way. When such an expression fails, such as1 / 0, every message that evaluates it reports the error.
Every operand of an operator or a function is evaluated for every message the expression
evaluates, so AND, OR, coalesce, and the other operators never skip an operand. Only a
conditional expression evaluates an operand for some messages and not
others.
VM Functions describes the implementation behind this model: how an expression is compiled and executed over a batch, and which kernels each function family uses.
Function Properties
Every builtin follows these rules unless its own description says otherwise:
| Property | Contract |
|---|---|
| Types | Arguments are never converted implicitly. A function that accepts several types, such as abs over every numeric type, takes each of them as it is. |
| Nulls | A null argument produces a null result. coalesce, nullif, concat, concat_ws, is_null, greatest, and least define their own null handling, and a function can also yield null for present arguments where its description says so, such as regexp_substr without a match, nth past the end of a list, or url_host of a URL without a host. |
| Optional results | A result is optional when any argument is. is_null, IS [NOT] DISTINCT FROM, now, uuid_v4, uuid_v7, and read_headers are never optional; coalesce, greatest, and least are optional only when every argument is; and TRY_CAST, JSON_VALUE, TRY_JSON_VALUE, nullif, LOOKUP_HASH_MAP, read_header, url_host, url_port, url_query, url_fragment, url_query_value, and the list functions first, last, nth, sum, min, max, and mean are always optional. An optional result initializes a required field only through an expression that is never null, such as coalesce(<result>, <default>); assigning it directly is rejected when the statement is applied with SET field '<field>' may be null but the output field is required. |
| Sensitivity | A result is sensitive when any argument is sensitive, including results such as length(...), is_null(...), and count(...) that do not contain the argument’s value. A conditional is sensitive when its operand, any condition, or any result is. Literals and calls without arguments are not sensitive. Only leak_sensitive(...) removes sensitivity. |
| Volatility | Every builtin is deterministic except now(), which returns one value for an execution, and uuid_v4() and uuid_v7(), which return a new value for every message. |
| Errors | A function that can fail reports a per-message error and yields null for that message. Inside a conditional, only the selected arm can report one. Errors describes how an error reaches the message’s error policy. |
Sensitivity is checked where a value is stored, never where it is only tested. Assigning a
sensitive value to an output field that is not sensitive is rejected with SET field '<field>' would store sensitive data in a non-sensitive output field; use leak_sensitive(...) to explicitly remove sensitivity, while a WHERE clause, a deduplication or ordering key, or a subscription
filter may read sensitive fields freely. INHERIT <field> LEAK SENSITIVE, written in an explicit
field list, copies a field as leak_sensitive(input.<field>) does. Emitters apply their own rules to
the values they send; see Emitters.
Errors
A function that cannot compute its result for a message reports an error of one of four kinds:
| Kind | Meaning | Examples |
|---|---|---|
division_by_zero | An integer division or remainder by zero | integer division by zero, integer remainder by zero |
overflow | A result its type or its column cannot hold | integer addition overflowed, date_add result is outside the DATETIME range, repeat result exceeds the text one STRING column holds |
cast_failed | A value that does not read as the requested type | cannot cast value to Int64, base64_decode input is not valid encoded bytes, JSON_VALUE document is not valid JSON |
invalid_argument | An argument outside the function’s domain, or a result that is not finite | floating-point operation produced a non-finite result, invalid regular expression: ..., vector lengths differ: left has 3, right has 2 |
The sections below name the kind and message of every failure. A failed message yields null for the failed operation, and the message then takes its context’s error handling:
- A failure in route construction (
INHERIT,SET, routeWHERE, andINVOKE), in an ingestor’sFILTER WHEREorBRANCHED BY ... SET, in a generator route, and in an emitter’sFROM ... WHERE, route, orVALUEShands the message to the route’sON MESSAGE ERRORpolicy; an ingestorFILTER WHEREfailure goes to the policy of every route.SETruns before routeWHERE, so aSETfailure is reported even for a message thatWHEREwould drop. A failed branchSETreports thesetoperation and the messagebranch SET failed with, the kind, the reason and the operation’s range, and the error route reads the route’s finalized output aspartial_output. - A failure in a processor’s
FROM ... WHEREorFILTER WHERE, or in the argument of a window aggregate, is logged, and the message is not acknowledged. - A failure in a session subscription’s
WHEREskips the record, and the session is told how many records the filter skipped. - Deduplication keys, reorderer
BYexpressions,CORRELATE WHEREconditions, inferencerINPUTSmappings, and a reingestor’sBRANCHED BY ... SEThave no message-error route. A reingestor whose branch construction fails for one message fails every message of that batch: the failure is logged, and none of them is acknowledged. Keep expressions that can fail out of them, or guard them with a conditional,TRY_CAST, orTRY_JSON_VALUE.
In an ON MESSAGE ERROR SEND TO ... SET, a function failure has the error scope below. Every
failure an expression reports has the code evaluation; the kind appears in the message, which
names the node, the program, the kind, and the reason, such as junction 'enrich' FILTER-MAP side error division_by_zero: integer division by zero at 12..18. The range at the end locates the
failed operation within the route’s program. When several operations fail for one message, the
record reports the first.
| Field | Type | Holds |
|---|---|---|
error.reference | STRING | A stable UUIDv7 that identifies this failure |
error.code | STRING | evaluation for every expression failure |
error.message | STRING | The message above. It never contains a value of the message, except as the regular expressions section describes |
error.operation | STRING | The clause that failed, such as set, inherit, route_where, filter_where, source_where, values, or invoke |
error.operation_index | optional U32 | The zero-based position of the failed assignment, counting INHERIT fields first, or null for a WHERE |
error.fields | VEC<STRING> | The assigned field and every field the failed expression reads |
error.occurred_at | DATETIME | The execution’s domain time |
Some failures belong to a batch rather than to a message: a UDF trap, an invalid UDF result, or a
UDF that exceeds its watchdog, a failed key expression of LOOKUP_HASH_MAP, and a limit of a
column that a whole batch exceeds, such as the one format_datetime checks. Such
a failure fails every message of the batch: a processor reports an internal error and leaves the
batch unacknowledged, and an emitter applies its ON GENERAL ERROR policy.
A statement whose expression does not type-check is rejected when it is applied, with a message
such as function 'trim' requires Utf8 input, found Int64. These messages name types by their Arrow
names:
| NSPL type | Name in messages |
|---|---|
U8 through I64 | UInt8, Int8, UInt16, Int16, UInt32, Int32, UInt64, Int64 |
F32, F64 | Float32, Float64 |
BOOL | Boolean |
STRING | Utf8 |
BYTES | Binary |
DATETIME | Datetime, or Timestamp(Nanosecond, Some("+00:00")) |
ARRAY, VEC | FixedSizeList(...), List(...), or Generic |
Literals
| Literal | Type | Form |
|---|---|---|
| Integer | I64 | Decimal digits, such as 42. A value above the largest I64 is rejected. There is no negative literal: -5 applies unary minus to 5 |
| Float | F64 | Decimal digits with a fraction, written as one word, such as 2.5. An exponent, a leading ., a trailing ., and a space beside the ., as in 2 .5, are not float literals; write '1e5' AS F64 |
| String | STRING | 'text', "text", or dollar-quoted $$text$$ and $tag$text$tag$, where the tag is letters, digits, and underscores. A quoted string cannot hold its own quote or a line break; a dollar-quoted string can hold anything but its closing delimiter, line breaks included. No escape sequence is interpreted, so 'a\nb' is four characters |
| Boolean | BOOL | TRUE, FALSE, in any letter case |
| Null | the type its destination supplies | NULL, only as the whole value of an assignment to an optional field or as a result of a conditional whose other results give it a type |
A literal reads the same wherever its expression is written: in a statement, in a web console form,
and in nervix-cli subscribe --where.
There is no DATETIME, BYTES, NaN, or infinity literal. Convert a string instead:
'2024-02-29T12:00:00Z' AS DATETIME, hex_decode('00ff'), 'NaN' AS F64, or 'inf' AS F64. A
NULL anywhere else is rejected with NULL requires a declared optional assignment target; a typed
null comes from a conditional without ELSE or from TRY_CAST.
Integer literals are I64 and float literals F64, and operands are never converted, so a
literal combined with a value of another width is written with a cast:
input.priority > 5 AS I32, bitwise_and(input.flags, 255 AS U16), or input.ratio * 0.5 AS F32.
Arguments that take a count of any integer type, such as the digits of round and the counts of
the string and shift functions, accept an I64 literal as it is.
Operator Precedence
Operators bind in this order, tightest first:
| Level | Operators | Grouping |
|---|---|---|
| 1 | Literals, fields, calls, [...], IF ... END, CASE ... END, TRY_CAST(...), JSON_VALUE(...), TRY_JSON_VALUE(...), JSON_EXISTS(...), and parentheses | |
| 2 | <expr> AS <type> | Repeats from the left: x AS I64 AS STRING |
| 3 | Unary - and NOT | Repeat, the innermost applying first: - -x is -(-x) |
| 4 | *, /, % | From the left |
| 5 | +, - | From the left |
| 6 | =, !=, <, <=, >, >=, IS [NOT] DISTINCT FROM, [NOT] IN (...), [NOT] BETWEEN ... AND ... | From the left |
| 7 | AND | From the left |
| 8 | OR | From the left |
Three consequences are easy to miss:
NOTbinds tighter than comparisons, soNOT input.count > 5negatesinput.countand is rejected withoperator Not is not valid for Int64. WriteNOT (input.count > 5)orinput.count <= 5.ASbinds tighter than unary minus, so-128 AS I8casts128, which fails. Write(-128) AS I8.- An
ASapplies only to the operand it follows, soinput.low + input.high AS STRINGconvertsinput.highalone, andinput.low + 1 AS I32adds1 AS I32.
Comparisons group from the left like the arithmetic operators, so a = b = c compares a = b with
c, and a < b < c is rejected because a < b is a BOOL. A trailing comma is accepted in the
arguments of a call, the elements of an IN set, and the elements of [...].
Reserved Words
These words are reserved in expressions: WHERE, SET, INHERIT, ALL, EXCEPT, LEAK,
SENSITIVE, INVOKE, AS, TRY_CAST, JSON_VALUE, TRY_JSON_VALUE, JSON_EXISTS, AND, OR,
NOT, TRUE, FALSE, NULL, IF, CASE, WHEN, THEN, ELSE, END, IN, BETWEEN, IS,
DISTINCT, FROM, and UDF. Wherever a keyword could stand, a reserved word reads as that
keyword, so a bare field, a call, or a SET, DEFAULT or INHERIT field named with one is written
between backticks: SET `end` = input.end. After a field scope such as input. and after
udf::, no keyword can stand, so the name is written as it is: input.end, left.from and
udf::case(input.value) read without backticks.
Every other word, including a statement keyword such as to, on, by, or max, is an ordinary
name wherever an expression writes one: SET to = input.to writes a field named to.
Inside an expression, a name between backticks may hold any character a name may, such as -, ~,
., or a leading digit, which a bare word cannot: input.`user-id`. Canonical NSPL, as
SHOW CREATE and nervix-nspl-format write it, puts a name between backticks exactly where the
name would not read back otherwise. It also quotes a bare field named like an ALTER operation
keyword, ADD, DROP, ALTER, REPLACE, or RENAME, because inside an ALTER statement a comma
before such a word begins the next operation.
Expressions Inside Statements
A statement reads an expression it embeds for as long as the expression can go on, and the
statement’s next clause begins where it cannot, so an expression needs no parentheses to end. After
WHERE input.total > 10, a TO, MAX TIME, or correlator RIGHT FROM continues no expression,
so it begins the next clause. A word written where an operand, a call, or a field belongs is part of
the expression, whatever keyword it spells: in DEDUPLICATE ON max(input.readings), max MAX TIME 10m
the first max is a call, the second a field, and the third begins MAX TIME, and a correlator’s
left input can test right(left.name, 2) = right before its RIGHT FROM.
When the expression goes on with a token and then cannot finish, as an IN ( that is never closed
cannot, the statement is rejected where the expression fails rather than read as a shorter
expression. Inside an ALTER statement, a comma followed by a bare operation keyword begins the next
operation, while a comma inside a call’s parentheses never does, so
SET FILTER WHERE concat(input.kind, replace) holds one expression.
A statement reads the expression it embeds from its own words, with the same lexer and grammar that
read an expression in a web console form, in nervix-cli subscribe --where, and through the client
library. Keywords, literals, comments, and spacing therefore mean the same wherever an expression is
written: text one of them accepts reads as the same expression in all of them, and text one of them
rejects all of them reject. A rejected expression is reported at the word of the statement where it
went wrong. When the expression itself cannot finish, the message is the one a web console form shows
for the same text; when a complete expression is followed by a word no clause of the statement
expects, the statement names the clauses it expected there.
Logical Operators
AND, OR, and NOT take BOOL operands and return BOOL. They follow three-valued logic, in
which null means an unknown truth value:
a | b | a AND b | a OR b |
|---|---|---|---|
TRUE | null | null | TRUE |
FALSE | null | FALSE | null |
| null | null | null | null |
NOT of null is null. A WHERE selects only the messages whose condition is TRUE, so a null
condition drops the message. The result is optional when either operand is, even where the other
operand decides it.
Both operands are evaluated for every message, whatever the first one holds. A failure in the
second operand therefore fails the message even where the first operand already decides the
result: input.divisor != 0 AND input.total / input.divisor > 10 reports division_by_zero for a
message whose divisor is 0. Guard such an operand with a conditional, which evaluates it only for
the messages that select it:
WHERE CASE WHEN input.divisor = 0 THEN FALSE ELSE input.total / input.divisor > 10 END
An operand of another type is rejected: operator And is not valid for Int64, or binary operator And requires matching operand types, found Int64 and Boolean for operands of two types.
Conditional Expressions
NSPL provides three self-delimited conditional forms:
IF <bool-condition> THEN <result> ELSE <result> END
CASE
WHEN <bool-condition> THEN <result>
[WHEN <bool-condition> THEN <result> ...]
[ELSE <result>]
END
CASE <operand>
WHEN <match-value> THEN <result>
[WHEN <match-value> THEN <result> ...]
[ELSE <result>]
END
Every form uses first-match-wins ordering. A null condition is not a match, and a null operand never
equals a simple-CASE match value. Conditions are BOOL, and a simple-CASE operand and its match
values have one exact type; the operand is computed once, before any arm. All non-null results must
have the same exact type, which may be any scalar type but not ARRAY or VEC; implicit casts are
not inserted. An omitted ELSE is a typed null and therefore requires an optional destination. An
IF always includes ELSE. A statement that breaks these rules is rejected with a message such as
CASE WHEN condition must evaluate to Boolean, found Int64, CASE results must have one exact type, found Int64 and Utf8, or CASE result type List(...) is not supported by conditional selection,
which IF reports too.
Conditional values are computed in the columnar batch engine, and every arm is evaluated only for
the messages that select it: a condition is evaluated for the messages no earlier arm answered, and
a result for the messages its condition selected. A function in an arm therefore never reports an
error for a message that selects another arm, so an error in an unselected arm never reaches the
message’s error handling, and a CASE guard shields a function from the messages it
cannot handle.
Context-injected operations such as window aggregates, header reads, and UDFs are invoked for the
selected messages only, and not at all in a batch where no message selects their arm; a whole-batch
failure of such an invocation still fails the batch it was invoked for.
The words IF, CASE, WHEN, THEN, ELSE, and END are reserved.
Comparison And Equality
=, !=, <, <=, >, and >= require both operands to have the same exact type. = and !=
compare any scalar type, BYTES included, which compares octet by octet. The ordering comparisons
<, <=, >, and >= accept numeric, STRING, and DATETIME operands. A null operand makes the
comparison null. No comparison is defined for ARRAY and VEC values: compare their elements with
the array and vector functions instead.
STRINGvalues compare by Unicode code point, which is also the order of their UTF-8 bytes. Comparison is case-sensitive and never depends on a locale.- Floating-point comparisons follow IEEE 754. NaN is unequal to every value, including another
NaN, so
=is false,!=is true, and every ordering comparison with NaN is false.0.0and-0.0are equal. nullif(a, b), a simpleCASE <operand> WHEN <value>,IS [NOT] DISTINCT FROM, andINdecide equality exactly as=does.
a IS NOT DISTINCT FROM b is equality under which two nulls are equal: it is true when both
operands are null or both are present and a = b, and false otherwise. a IS DISTINCT FROM b is
its negation, a null-safe !=. Neither is ever null, so a required BOOL field can hold either,
and input.region IS DISTINCT FROM input.home_region is true where exactly one region is null,
where input.region != input.home_region is null. Both operands must have the same exact scalar
type. Present floats compare as = does, so NaN is distinct from every value, another NaN included,
and 0.0 is not distinct from -0.0.
IN, BETWEEN, and IS [NOT] DISTINCT FROM bind like the comparison operators and group from the
left with them, so a = b IN (TRUE) tests whether a = b is in the set. Unary NOT binds more
tightly than any comparison, so NOT x IN (1, 2) negates x before testing it; write
x NOT IN (1, 2) or NOT (x IN (1, 2)) instead. See Operator Precedence.
There is no IS NULL operator: test for null with is_null(x) and NOT is_null(x).
Membership And Ranges
<value> IN (<element>, ...) tests whether a value equals an element of a written set, and
<value> NOT IN (<element>, ...) is its negation. <value> BETWEEN <low> AND <high> tests whether a
value lies in an inclusive range, and <value> NOT BETWEEN <low> AND <high> is its negation.
input.status IN ('open', 'held')
input.priority NOT IN (1 AS I32, 2 AS I32)
input.weight BETWEEN 1.0 AND 50.0
input.observed_at NOT BETWEEN input.window_start AND input.window_end
Sets
A set lists its elements in parentheses, separated by commas. Every element is a constant: a
literal, optionally negated with - or NOT and cast with AS or TRY_CAST, such as
-1 AS I32 or '2026-01-01T00:00:00Z' AS DATETIME. Each element is evaluated once, when the
statement is applied, exactly as the same expression evaluates for a message, so an element that
cannot be evaluated, such as 300 AS U8, rejects the statement, and so does a TRY_CAST that
cannot convert its literal, whose typed null is no valid element. So does an element that reads a
field or calls a function; to test a value against other fields, compare it with = and combine
the comparisons with OR.
The operand may have any numeric type, BOOL, STRING, or DATETIME, and every element must have
exactly the operand’s type. Integer literals are I64 and float literals F64, so a set tested
against an I32 or F32 value casts each element: input.priority IN (1 AS I32, 2 AS I32).
A value is an element of a set when it equals one of its elements under =:
NULLis not a valid element, because no value equals it; test for null withis_null(...)orIS NOT DISTINCT FROM.- An empty set is valid. No value is an element of it, a null one included, so
x IN ()is false andx NOT IN ()true for every message. - Otherwise a null operand makes both
INandNOT INnull, so aWHEREselects a message with a null operand for neither. - Writing an element twice changes nothing.
- NaN equals no value, so a NaN element matches nothing and a NaN operand is an element of no set,
while
0.0and-0.0are the same element.
A set is prepared once for its program rather than for every batch or message. A small set of
numbers, BOOL, or DATETIME values is tested by comparing the value with each of its elements,
while a larger set, and every set of STRING values, is looked up by key, so a set of thousands of
elements still costs each message one lookup.
Ranges
x BETWEEN low AND high is x >= low AND x <= high with x computed once, so an operand that fails
reports its error once. The operand and both bounds must have one exact type: any numeric type,
STRING, or DATETIME, the types < orders. Both bounds are inclusive, and a range whose low bound
is above its high bound holds no value; the bounds are never swapped.
Nulls follow AND: a null operand makes the result null, and a null bound makes it null unless the
comparison with the other bound is false, which makes it false. NaN lies in no range, so BETWEEN
is false for a NaN operand or bound and NOT BETWEEN is true. NOT BETWEEN negates BETWEEN; it is
not x < low OR x > high, which is false for a NaN operand.
The AND that closes a low bound belongs to the range, so x BETWEEN 1 AND 5 AND y is
(x BETWEEN 1 AND 5) AND y. A bound that is itself a comparison needs parentheses.
Arithmetic
+, -, *, /, and % require both operands to have the same exact numeric type, and the result
has that type. Unary - applies to signed integer and floating-point operands; an unsigned operand
is rejected with operator Neg is not valid for UInt8. A null operand makes the result null.
Integer literals are I64, so input.count + 1 over a U32 field is rejected with binary operator Add requires matching operand types, found UInt32 and Int64; write input.count + 1 AS U32. + does not join text, which concat does, and DATETIME values have no
arithmetic operators: move and measure them with date_add and date_diff,
or convert them to I64 nanoseconds explicitly.
Arithmetic is checked. An operation whose result its type cannot hold reports a per-message error and yields null for that message, instead of wrapping, saturating, or producing NaN or an infinity. Only that message fails: every other message in the batch is computed as usual, and a message’s result never depends on the other messages in its batch.
- Integer
+,-, and*report anoverflowerror when the exact result does not fit the operand type, so-overU8,U16,U32, orU64operands fails when the right operand is larger. - Integer
/truncates toward zero. A zero divisor reports adivision_by_zeroerror, and dividing the minimum value of a signed type by-1reports anoverflowerror. - Integer
%returns the remainder with the sign of the dividend. A zero divisor reports adivision_by_zeroerror. Every other remainder exists, so the remainder of the minimum value of a signed type by-1is0. - Unary
-on the minimum value of a signed integer type reports anoverflowerror. F32andF64arithmetic is IEEE 754 arithmetic at the operand’s own width, and%returns the remainder with the sign of the dividend. A result that is NaN or an infinity reports aninvalid_argumenterror, which includes every division by zero and every operation on NaN. The rule applies to the result, so an infinite operand fails only where the result is not finite:1.0 / xis0.0for an infinitex. Unary-on a float never fails: it changes the sign of every value, including zero, NaN, and the infinities.
The error’s message names the failure: integer addition overflowed, integer subtraction overflowed, integer multiplication overflowed, integer division overflowed, or integer negation overflowed for an overflow; integer division by zero or integer remainder by zero
for a division_by_zero; and floating-point operation produced a non-finite result for an
invalid_argument.
Conversions
A value never changes its type implicitly. Two explicit forms convert it to another scalar type. They accept the same types and convert every value the same way, and differ only in what a value that does not convert does:
| Form | Result | A value that does not convert |
|---|---|---|
<expr> AS <type> | <type>, optional exactly when <expr> is | Reports a per-message cast_failed error, such as cannot cast value to Int64, and yields null for that message, which takes the error handling of its context |
TRY_CAST(<expr> AS <type>) | Optional <type> | Yields a typed null, and the message continues without an error |
<type> is a scalar type written with the same spellings in both forms, in any letter case: U8
or UINT8, I8 or INT8, U16 or UINT16, I16 or INT16, U32 or UINT32, I32 or
INT32, U64 or UINT64, I64 or INT64, F32 or FLOAT32, F64 or FLOAT64, BOOL or
BOOLEAN, STRING or UTF8, BYTES, and DATETIME. Any other name is rejected with
unsupported type '<name>'. Conversions are defined between scalar values only: an ARRAY or
VEC type cannot be written as a target, and no conversion is defined for an ARRAY or VEC
operand. A BYTES value converts only to BYTES, which returns it unchanged, and no other type
converts to BYTES; a statement that asks for either is rejected with BYTES conversions require bytes_from_utf8, bytes_to_utf8, base64 or hex functions, which name the functions that convert
explicitly. A null operand is not a failure: it converts to a typed null in both forms, so
input.amount AS I64 over a message without an amount yields null and reports nothing. Test the
operand to tell a missing value from one that did not convert:
SET amount = TRY_CAST(input.amount AS I64),
amount_state = CASE
WHEN is_null(input.amount) THEN 'missing'
WHEN is_null(TRY_CAST(input.amount AS I64)) THEN 'malformed'
ELSE 'converted'
END
The result of TRY_CAST is optional even for a conversion that cannot fail, so it initializes a
required field only through an expression that is never null, such as
coalesce(TRY_CAST(input.amount AS I64), 0), and a statement that assigns it to a required field
directly is rejected when it is applied. It keeps the sensitivity of its operand, as every
conversion does.
TRY_CAST suppresses only the failure of the conversion it performs. Every other failure of its
operand still fails the message with its own error:
TRY_CAST(100 / input.divisor AS STRING)reportsdivision_by_zerofor a zero divisor.TRY_CAST(input.raw AS I64 AS STRING)convertsinput.raw AS I64, which reportscast_failedfor text that is not an integer.- A UDF called in the operand reports its own per-message errors, and a failure of the whole batch still fails the batch.
A conversion of a TRY_CAST result is an ordinary one: TRY_CAST(input.raw AS I64) AS U8 fails a
message whose number does not fit U8, and yields null without an error for one whose text is not
an integer. Inside a conditional, a TRY_CAST converts only the messages that select its arm, as
every operation does.
The operand of TRY_CAST is the whole expression before its final AS, so
TRY_CAST(input.low + input.high AS STRING) converts the sum, while in
input.low + input.high AS STRING the AS applies to input.high alone.
A conversion between scalar types other than BYTES succeeds for every value, except for the
values the table below lists. Converting a value to its own type returns it unchanged.
- Every value other than
BYTESconverts toSTRING. An integer is written in decimal, and aBOOLastrueorfalse. A float is written in decimal without an exponent, with the fewest digits that read back as the same value:1.0is written1,1e21as1and 21 zeros, NaN asNaN, the infinities asinfand-inf, and negative zero as-0. ADATETIMEis written in RFC 3339 with a+00:00offset and a fraction of three, six, or nine digits when its fraction is not zero, such as2024-02-29T12:00:00+00:00and2024-02-29T12:00:00.500+00:00. - A number converts to
BOOLasfalsefor zero andtruefor every other value, including NaN, and aBOOLconverts to a number as0or1. - A number converts to
F32orF64as the nearest value of that type, so anF64beyond theF32range becomes an infinity. - A
DATETIMEconverts toI64as its count of nanoseconds since1970-01-01T00:00:00Z, and toF32orF64as the nearest value to that count. An integer converts to theDATETIMEthat many nanoseconds after that instant, and a float to theDATETIMEits count of nanoseconds names, cut toward zero, so1.5 AS DATETIMEis one nanosecond after the epoch.
| From | To | Fails for |
|---|---|---|
| An integer type | Another integer type | A value outside the target type’s range |
U64 | DATETIME | A value above the largest I64 |
F32, F64 | An integer type | NaN, an infinity, and a value whose integer part, which it is rounded to toward zero, is outside the target type’s range |
F32, F64 | DATETIME | NaN, an infinity, and a value outside the DATETIME range when read as nanoseconds since 1970-01-01T00:00:00Z |
STRING | An integer type | Text other than an optional + or - followed by decimal digits, with no surrounding whitespace, and a number outside the target type’s range |
STRING | F32, F64 | Text other than a decimal number with an optional sign, fraction, and exponent, such as 1e3, .5, or +1.5, or NaN, inf, or infinity in any letter case and with an optional sign. A number beyond the type’s range reads as an infinity |
STRING | BOOL | Text other than t, tr, tru, true, y, ye, yes, on, or 1, which read as true, and f, fa, fal, fals, false, n, no, of, off, or 0, which read as false, in any letter case and with any surrounding whitespace |
STRING | DATETIME | Text that is not an RFC 3339 date and time with a UTC offset, such as 2024-02-29T12:00:00Z or 2024-02-29 13:00:00+01:00, where a space may take the place of the T, and an instant outside the DATETIME range. Read other forms with parse_datetime |
DATETIME | An integer type other than I64 | A count of nanoseconds since 1970-01-01T00:00:00Z outside the target type’s range |
BOOL | DATETIME | Every value |
DATETIME | BOOL | Every value |
JSON Documents
A STRING value holding a JSON document is read into typed values explicitly. Three forms read it,
each naming a path to one value in the document:
| Form | Result | A document or value that cannot be read |
|---|---|---|
JSON_VALUE(<document>, '<path>' AS <type>) | Optional <type> | Reports a per-message error that names the defect and yields null for that message, which takes the error handling of its context |
TRY_JSON_VALUE(<document>, '<path>' AS <type>) | Optional <type> | Yields a typed null, and the message continues without an error |
JSON_EXISTS(<document>, '<path>') | BOOL, optional exactly when <document> is | Reports a per-message error, as JSON_VALUE does |
<document> is any STRING expression; any other type rejects the statement when it is applied,
with an error such as JSON_VALUE document must be STRING, found Int64. A null document yields null
in all three forms and reports nothing. <path> is a string literal in single or double quotes,
parsed and checked when the statement is parsed, so a malformed path rejects the statement; see
Paths. JSON_VALUE, TRY_JSON_VALUE, and JSON_EXISTS are reserved.
SET customer_id = JSON_VALUE(input.payload, '$.customer.id' AS I64),
tags = JSON_VALUE(input.payload, '$.tags' AS VEC<STRING>),
first_quantity = TRY_JSON_VALUE(input.payload, '$.items[0].qty' AS U32),
has_note = JSON_EXISTS(input.payload, '$.note')
A value read from a document keeps the document’s sensitivity, as every function result does.
Paths
A path starts with $, which names the whole document, followed by any number of steps, at most 64:
| Step | Leads to |
|---|---|
.name | The member of an object named name, where name is ASCII letters, digits, and underscores not starting with a digit |
["name"] | The member of an object with any name, written as a JSON string in double quotes with its escapes, such as ["unit price"] or ["café"] |
[n] | The element of an array at the zero-based index n, from 0 to 4294967295, written without leading zeros |
A path holds no whitespace, and has no wildcards, slices, filters, recursive descent, negative
indexes, or single-quoted names. Member names are compared exactly, letter case included, with no
Unicode normalization. Where an object repeats a member name, JSON_VALUE reads the last of them,
and JSON_EXISTS finds any of them. A path finds no value when an object lacks the member, an array
is shorter than the index, a member step meets an array, an index step meets an object, or a step
leads into a value that is neither: $.a.b finds nothing where a is a number, a string, JSON
null, or an array, and $[0] finds nothing in an object.
A malformed path rejects the statement with invalid JSON path '<path>': followed by the defect,
whose byte positions count from zero:
a path starts with '$'expected '.' or '[' at byte <n>expected a member name of letters, digits and underscores not starting with a digit at byte <n>; write any other name as ["name"]expected an array index or a double-quoted member name at byte <n>array index at byte <n> is not an integer from 0 to 4294967295 without leading zerosmember name at byte <n> is not a valid JSON stringexpected ']' at byte <n>a path takes at most 64 steps
A message error writes its path in one form, with .name where a name allows it and ["name"]
otherwise, so $["plain"] appears as $.plain.
Declared Types
<type> is a scalar type written with the same spellings as in Conversions, or a
VEC<...> or ARRAY<..., n> of declared types written as a schema field declares them, nested to
any depth, such as VEC<VEC<I32>> or ARRAY<F32, 2, 3>, where ARRAY<F32, 2, 3> is two arrays of
three and every fixed length is from 1 to 2,147,483,647. JSON has no date or byte type, so
DATETIME and BYTES, including as elements, reject the statement with an error such as
JSON_VALUE cannot read DATETIME, which no JSON value is; read the text as STRING and convert it explicitly; read the text as a STRING and convert it with parse_datetime or
base64_decode. A value is never converted between kinds: a number is
not read as text, and text is not read as a number.
| Declared type | Reads | Fails for |
|---|---|---|
BOOL | true and false | Every other value |
STRING | A JSON string, with its escapes decoded | Every other value, numbers included |
| An integer type | A JSON number with no fractional part, so 2, 2.0, and 2e0 read as 2 | A number with a fractional part, a number outside the type’s range, and every value that is not a number |
F64 | A JSON number, as the nearest F64 | Every value that is not a number |
F32 | A JSON number, as the nearest F32 | A number beyond the F32 range, and every value that is not a number |
VEC<type> | A JSON array of any length, each element read as type | Every value that is not an array, and an array holding an element that type does not read |
ARRAY<type, n> | A JSON array of exactly n elements, each read as type | Every value that is not an array of n elements, and an array holding an element that type does not read |
VEC and ARRAY elements are never null, so an array holding JSON null fails as any other element
of the wrong kind does. A number written as digits with an optional -, and no fraction or
exponent, is read exactly across the whole 64-bit range. Every other number is read as the nearest
F64 first, and an integer type accepts it when that F64 is an integer in its range:
9007199254740993.0 reads as 9007199254740992, and 1.0000000000000001 reads as 1. An integer
written as digits beyond the 64-bit range reads as the nearest F64 too, so it is out of range for
every integer type, except that the negative integers down to -9223372036854776832 read as the
F64 -2^63, which is the smallest I64. A number whose magnitude is beyond the F64 range, such
as 1e400, makes the document unreadable. -0 reads as 0 for every type, and -0.0 reads as 0
for an integer type and as -0.0 for F64.
Missing Values, JSON Null, And Failures
What each form yields depends on what the path finds:
| The document | JSON_VALUE | TRY_JSON_VALUE | JSON_EXISTS |
|---|---|---|---|
| is not valid JSON | Error cast_failed: JSON_VALUE document is not valid JSON | Null | Error cast_failed: JSON_EXISTS document is not valid JSON |
| has no value at the path | Null | Null | FALSE |
| holds JSON null at the path | Null | Null | TRUE |
| holds a value of another kind | Error cast_failed, such as JSON_VALUE found a JSON string at $.count where I64 is declared | Null | TRUE |
| holds a number the type cannot hold | Error cast_failed, such as JSON_VALUE number at $.level does not fit U8 | Null | TRUE |
holds an array of the wrong length for an ARRAY | Error cast_failed, such as JSON_VALUE array at $.point has 3 elements where 2 are declared | Null | TRUE |
A number with a fraction where an integer is declared is a value of another kind, reported as
JSON_VALUE found a fractional JSON number at $.count where I64 is declared. A defect inside a
collection names the collection with in and the innermost declared element type, such as
JSON_VALUE found JSON null in $.tags where I64 elements are declared or JSON_VALUE array in $.p has 1 elements where 2 are declared. An error names the path and the declared type but never a
value from the document, so it can be reported for a sensitive document.
JSON_VALUE and TRY_JSON_VALUE yield null both where the path finds nothing and where it finds
JSON null; JSON_EXISTS tells the two apart. Test the forms together to classify a document without
failing the message:
SET amount = TRY_JSON_VALUE(input.payload, '$.amount' AS F64),
amount_state = CASE
WHEN is_null(input.payload) THEN 'no document'
WHEN NOT is_null(TRY_JSON_VALUE(input.payload, '$.amount' AS F64)) THEN 'read'
WHEN NOT is_null(TRY_JSON_VALUE(input.payload, '$.amount' AS STRING)) THEN 'text'
ELSE 'missing, null, or unreadable'
END
To tell a malformed document from a missing value, route the message through JSON_EXISTS or
JSON_VALUE and read the error in ON MESSAGE ERROR, where the error names the defect.
As with TRY_CAST, the result of TRY_JSON_VALUE and JSON_VALUE is optional, so it initializes a
required field only through an expression that is never null, such as
coalesce(TRY_JSON_VALUE(input.payload, '$.qty' AS U32), 0 AS U32), and a statement that assigns
it to a required field directly is rejected when it is applied. TRY_JSON_VALUE suppresses only the
failures of reading its document: a failure of its document expression still fails the message.
Document Limits
A document is read only when it holds at most 16,777,216 bytes and nests objects and arrays at most
128 deep. A larger or deeper document fails JSON_VALUE and JSON_EXISTS with an
invalid_argument error, such as JSON_VALUE document exceeds 16777216 bytes or JSON_VALUE document nests deeper than 128 levels, and yields null from TRY_JSON_VALUE. A result whose text
or elements do not fit one column, as with repeat, reports an overflow error such
as JSON_VALUE result exceeds what one VEC<STRING> column holds, from TRY_JSON_VALUE too, as
TRY_JSON_VALUE result exceeds ..., since it is a limit of the batch rather than a defect of the
document. JSON_EXISTS builds no text and never reports it.
Reading a document is bounded by these limits. Like every builtin, an extraction runs to completion for the batch it is evaluating, so stopping a node or a processor takes effect between batches.
Reading A Document Once
Every extraction a route makes from the same document field reads one parse of each document,
whether the extractions are in one SET assignment or several, in WHERE, or a mix of
JSON_VALUE, TRY_JSON_VALUE, and JSON_EXISTS, so reading ten fields costs one parse rather than
ten. A document computed by an expression, such as coalesce(input.payload, '{}'), shares one
parse among the extractions of one assignment, and is parsed again for each assignment that reads
it. A document is parsed with SIMD instructions that find its structure, chosen at run time from
the ones the node’s CPU offers, and each path is then followed and its value written straight into
a typed column of the declared type; no document is held as an untyped value. A document field that
an earlier SET assignment rewrites is a new column, whose documents are parsed again.
Inside a conditional arm, an extraction parses only the documents of the messages that select the arm, and an extraction in the arm does not share its parse with one outside it. A document written as a literal is parsed once for each batch and shared by every message of it, so a malformed literal document fails every message that evaluates it.
Null Handling
| Function | Returns | Notes |
|---|---|---|
coalesce(a, b, ...) | same type as inputs | Returns the first non-null argument, or a typed null when every argument is null. Takes one or more arguments of one exact type, which may be any type, ARRAY, VEC, and BYTES included |
is_null(x) | BOOL | True when the input is null. Takes one argument of any type. Never null |
nullif(a, b) | same type as inputs | Returns a typed null when a = b, and a otherwise, including when b is null. Both arguments must have the same type |
coalesce is optional only when every argument is, so a required last argument makes its result
required: coalesce(input.nickname, input.name) initializes a required field when name is
required. It evaluates every argument for every message, including the arguments after the first
present one, so a failing later argument fails the message; guard such an argument with a
conditional. There is no IS NULL operator and no is_not_null function: write is_null(x) and
NOT is_null(x). A bare NULL is not an argument of any of these functions; see
Literals.
Extrema
| Function | Returns | Notes |
|---|---|---|
greatest(a, ...) | same type as inputs | The largest present argument, or a typed null when every argument is null |
least(a, ...) | same type as inputs | The smallest present argument, or a typed null when every argument is null |
clamp(value, low, high) | same type as inputs | low where value < low, high where value > high, and value otherwise |
greatest and least take one or more arguments of one exact scalar type: any numeric type,
BOOL, STRING, DATETIME, or BYTES. They skip null arguments, so their result is required when
any argument is. They order values the way the window MIN and MAX aggregates do: BOOL orders
false before true, STRING orders by Unicode code point, BYTES orders octet by octet, and
floating-point values order NaN above every other value and treat both zeros as equal. Among equal
values the earliest argument is returned, so greatest(-0.0, 0.0) is -0.0 and
greatest(0.0, -0.0) is 0.0. Every argument is evaluated, whichever one is returned.
clamp takes three arguments of one exact type: any numeric type, STRING, or DATETIME. It
compares exactly as < and > do, so it returns a NaN value unchanged and keeps -0.0 inside a
range that starts at 0.0. A null argument produces a null result. A message whose low bound is
above its high bound, or whose floating-point bound is NaN, reports an invalid_argument error,
clamp lower bound is above its upper bound or clamp bound is NaN, and yields null, so give a
route whose bounds can cross an ON MESSAGE ERROR policy. A message with a null argument is never
failed.
Context And Identity
| Function | Returns | Notes |
|---|---|---|
leak_sensitive(value) | same type as input | Removes sensitivity from a value of any type, including every element of an ARRAY or VEC |
now() | DATETIME | The execution-local domain time. Never null |
uuid_v4() | STRING | A random UUID, new for every message |
uuid_v7() | STRING | A time-ordered UUID built from the execution-local domain time, new for every message. Reports an overflow error when that time is before the Unix epoch |
leak_sensitive takes exactly one argument and returns it unchanged, optional exactly when the
argument is; it only changes what the statement may do with the value. now(), uuid_v4(), and
uuid_v7() take no arguments and are never sensitive.
now() is one snapshot of the domain clock for each unit of work, shared by every expression of
that unit: an ingested group of messages, a batch a processor accepts, and the timer, flush, or
wait release that resumes work. It is logical time in a paced domain, which can be before 1970, and
actual UTC in an unpaced one. See Domain Clock for how snapshots are taken.
Both UUID functions return the canonical 36-character lowercase form, such as
0192f2b5-6c1d-7e3a-9f4b-2a7c1e5d8b90, and every occurrence of a call in an expression returns its
own value. A version 7 UUID encodes its time as milliseconds since 1970-01-01T00:00:00Z and fills
the remaining 74 bits at random, so UUIDs of different milliseconds order by time, while UUIDs of
one millisecond, such as those of one batch, have no order among themselves. In a paced domain
whose logical time is earlier than the epoch, such as one started with
START AT '1969-07-20T20:17:00Z', uuid_v7() reports uuid_v7 execution time is before the Unix epoch for every message that evaluates it until domain time reaches the epoch. It never encodes
another instant instead.
Header Functions
| Function | Returns | Notes |
|---|---|---|
read_header(name) | optional STRING | Ingestor-only. Returns the first value, or NULL when absent |
read_headers(name) | VEC<STRING> | Ingestor-only. Returns all values in order, or an empty vector when absent. Never null |
write_header(name, value) | nothing | Emitter-only side effect. Valid only as a top-level call in the final INVOKE block |
Header names may be dynamic STRING expressions, and a name matches exactly, letter case included,
the names the connector recorded. A null name reads as absent. Header reads are available on
HTTP ENDPOINT, WEBSOCKETS ENDPOINT, HTTP REQUEST, KAFKA TOPIC, NATS SUBJECT,
PULSAR TOPIC, RABBITMQ QUEUE, and SQS QUEUE ingestors, in their FILTER WHERE and route
construction, and in an error route’s construction for a failure on such an ingestor. Header writes
are available on HTTP, Kafka, NATS, Pulsar, RabbitMQ, and SQS emitters. Anywhere else a statement
is rejected when it is validated, with a message such as function 'read_header' is only available to ingestors whose connector supports headers, MQTT TOPIC ingestors do not support read_header or read_headers, or MQTT emitters do not support write_header.
write_header arguments must be statically non-null STRING expressions, and a sensitive argument
requires leak_sensitive(...). They are evaluated after payload construction and route filtering.
Calls are staged in source order in a route-local envelope; if any call fails, no payload or partial
header envelope is published. See Emitters for each sink’s header rules.
Lookups And UDF Calls
| Call | Returns | Notes |
|---|---|---|
LOOKUP_HASH_MAP('<hash_map>', <key>, '<field>') | the field’s type, always optional | The named field of the record whose key equals <key> in a hash map, or null when the key is missing or null or the field is null |
udf::<name>(<arg>, ...) | the UDF’s declared result type | A domain-owned Roto UDF with 1–8 exactly typed arguments |
The hash-map name and the field are string literals, checked when the statement is applied; a key expression that fails for any message fails its whole batch. Identical lookups of one program are answered once. Where Expressions Run lists the contexts that accept a lookup, and Lookups describes hash maps and their resource versions.
A UDF call is optional when the UDF declares an OPTIONAL result or when a required parameter
receives an optional argument, is sensitive when any argument is, and is deterministic unless the
UDF is declared VOLATILE. It never runs for a message whose required argument is null or whose
earlier operation already failed, and yields null for it. See
Nulls, errors, and volatility for its per-message and
whole-batch errors.
String Functions
String functions count and select characters, meaning Unicode scalar values, rather than bytes or
grapheme clusters. A combining mark counts as a separate character. Positions count from 1. Every
text argument is exactly STRING: a BYTES value is rejected, so no string function measures,
joins, or searches octets. Convert between the two with the
bytes functions.
| Function | Returns | Notes |
|---|---|---|
lower(text) | STRING | Lowercases with Unicode full case mapping |
upper(text) | STRING | Uppercases with Unicode full case mapping |
trim(text) | STRING | Removes leading and trailing Unicode whitespace |
btrim(text) | STRING | Same as trim |
ltrim(text) | STRING | Removes leading Unicode whitespace |
rtrim(text) | STRING | Removes trailing Unicode whitespace |
length(text) | I64 | Character count |
char_length(text) | I64 | Same as length |
bit_length(text) | I64 | Eight times the UTF-8 byte length |
octet_length(text) | I64 | UTF-8 byte length, read from the Arrow string offsets |
ascii(text) | I64 | Unicode code point of the first character, or 0 for an empty string |
initcap(text) | STRING | Uppercases the first character of each run of letters and digits and lowercases the rest |
left(text, count) | STRING | The first count characters. A negative count removes that many characters from the end |
right(text, count) | STRING | The last count characters. A negative count removes that many characters from the start |
substr(text, start) | STRING | Characters from position start to the end |
substr(text, start, length) | STRING | At most length characters from position start |
substring(text, start) | STRING | Alias for substr |
substring(text, start, length) | STRING | Alias for substr |
concat(a, b, ...) | STRING | Joins one or more STRING arguments in order. A null argument contributes nothing, so no message’s result is null. See the note on its type below |
concat_ws(separator, a, ...) | STRING | Joins the non-null values after separator, keeping empty strings, with separator between them. A null separator makes the result null |
repeat(text, count) | STRING | The text repeated count times, or an empty string when count is at most 0 |
replace(text, from, to) | STRING | Replaces every occurrence of from, matched as plain text from left to right without overlaps. An empty from inserts to before every character and at the end |
reverse(text) | STRING | Reverses the characters |
lpad(text, length, fill) | STRING | Pads on the left with repetitions of fill to length characters |
rpad(text, length, fill) | STRING | Pads on the right with repetitions of fill to length characters |
split_part(text, delimiter, index) | STRING | The part at position index |
split(text, delimiter) | VEC<STRING> | All parts in order, including empty parts at the ends or between adjacent delimiters |
join(parts, separator) | STRING | Joins an ARRAY<STRING> or VEC<STRING>; null elements contribute nothing |
normalize_nfc(text) | STRING | Unicode canonical composition (NFC); does not change case or apply compatibility folding |
strpos(text, needle) | I64 | Position of the first occurrence of needle, 1 for an empty needle, or 0 when it does not occur |
translate(text, from_chars, to_chars) | STRING | Replaces each character found in from_chars with the character at the same position in to_chars, and removes a character that has no counterpart. A character that from_chars repeats takes its first position, and characters of to_chars past the length of from_chars are ignored |
to_hex(value) | STRING | Lowercase hexadecimal digits without a prefix. Integer input only; a negative value is written as its two’s complement at the input’s width, so an integer literal, which is I64, is written at 64 bits |
md5(text) | STRING | The 32 lowercase hexadecimal digits of the MD5 digest of the text’s UTF-8 bytes |
lower, upper, and initcap use Unicode case mappings, which never depend on the node’s locale.
A mapping can change a value’s length: upper('Grüßen') is GRÜSSEN. A literal, a field, and a
computed value holding the same text always convert to the same result. initcap starts a run at
every letter or digit that follows a character that is neither, and maps each character on its
own: a run that starts with ß starts with SS, no character takes a titlecase form, and a run
may start with a digit, so initcap('3rd') is 3rd.
count, start, length, and index may be any integer type and are never narrowed to another:
an unsigned count above the I64 range reaches past the end of any text, exactly as the largest
I64 count does. A floating-point count is rejected.
substr treats a start at or before 1 as the first character and counts length from there. A
start past the end, or a negative length, returns an empty string.
lpad and rpad shorten text longer than length to its first length characters, return an
empty string when length is at most 0, and return shorter text unchanged when fill is empty.
split_part returns an empty string when index is at most 0 or past the last part; it never
counts from the end. With an empty delimiter, the whole text is part 1.
split uses the same empty-delimiter rule: it returns one part containing the whole text. An
empty input also produces one empty part. join on an empty list produces an empty string;
concat_ws with no non-null values after the separator does the same. These functions preserve
the written order.
concat never yields null for a message, but a call that has an optional argument has an optional
result, so it initializes a required field only through an expression that is never null, such as
concat(coalesce(input.first, ''), ' ', coalesce(input.last, '')). concat_ws follows the same
rule.
Result Size
The values one call produces for a batch share one STRING column, which holds at most
2,147,483,647 bytes of text. A function whose result can be longer than its arguments computes how
long each result is before it builds it, and a result that does not fit in what its column has left
reports an overflow error and yields null instead of being built:
| Function | Error |
|---|---|
repeat, lpad, rpad, concat_ws, join, normalize_nfc | <function> result exceeds the text one STRING column holds, such as repeat result exceeds the text one STRING column holds |
split | split result exceeds 65,536 parts for a result of more than 65,536 parts, and split result exceeds the text one STRING column holds |
base64_encode, hex_encode | <function> result exceeds the bytes one column holds |
Inside a conditional arm the column holds only the results of the messages that select the arm, so a
message that selects another arm uses none of its text. A call whose arguments are all literals
computes one value that every message in the batch holds, so that value must fit once for each of
them: repeat('ab', 600000000) fits a batch of one message but reports the error on every message
of a batch of two. JSON extraction, URL and IP address
functions, and format_datetime, bound their results in the same column limit.
concat, replace, regexp_replace, translate, lower, upper, and initcap build their
column without this check, so no message reports an overflow error for them. Keep the text they
produce for one batch within the column limit: for example, a replace with an empty from
multiplies the length of its text.
String Predicates
Plain substring matching is exact and case-sensitive, and compares UTF-8 bytes, so it never normalizes text.
| Function | Returns | Notes |
|---|---|---|
contains(text, needle) | BOOL | True when needle occurs in text |
starts_with(text, prefix) | BOOL | True when text begins with prefix |
ends_with(text, suffix) | BOOL | True when text ends with suffix |
contains_any(text, patterns) | BOOL | True when any non-null string in an ARRAY<STRING> or VEC<STRING> occurs in text; an empty set is false and an empty pattern matches every non-null text |
like(text, pattern) | BOOL | SQL LIKE: % matches zero or more Unicode characters and _ matches one, newlines included; a backslash makes the character after it literal |
ilike(text, pattern) | BOOL | The same wildcard rules with Unicode loose case-insensitive matching |
like and ilike match the entire text. A trailing backslash is a literal backslash.
ilike follows Arrow’s Unicode loose matching: it ignores case without expanding characters,
so ß does not equal SS. For full Unicode case mapping, apply lower or upper explicitly;
neither matching function normalizes text. Each LIKE pattern is limited to 4,096 bytes of UTF-8
text: a longer pattern reports an invalid_argument error, LIKE pattern exceeds 4 KiB, on every
message that evaluates it, whatever its text holds.
contains_any compares exact case-sensitive text, including combining marks. A set written in the
call as array(...), vec(...), or [...] whose elements are all non-null STRING constants is
prepared once with the program. Every other set is read for each message, and the distinct sets of
one batch are prepared once each, keeping at most 64 of them for the batch and replacing the one
prepared first when more are read. A set has at most 128 non-null patterns and 65,536 bytes of
combined pattern text; a larger set reports an invalid_argument error, contains_any pattern set exceeds 128 patterns or 64 KiB, on each message with a non-null text and set that uses it, even
when the set is a constant.
Regular Expressions
Regular-expression functions take STRING arguments and use Rust regex syntax. A pattern that does
not compile reports a per-message invalid_argument error whose message is invalid regular expression: followed by the parser’s description of the defect.
A pattern written as a literal, or an expression that compiles to a constant, such as a CASE with
constant conditions or lower, upper, trim, or coalesce over literals, is compiled once when
the node is activated and reused by every batch. It is still not a configuration error: a constant
pattern that does not compile reports its per-message error only for the messages that evaluate it,
so an invalid pattern in a conditional arm no message selects reports nothing. Any other pattern,
including one built with concat, is compiled when a message first uses it and kept in a cache of
the 64 most recently compiled patterns per call, which evicts the pattern compiled longest ago and
keeps the outcome of a pattern that did not compile. A compiled pattern is limited to 10 MiB; a
pattern that compiles past that limit reports a per-message error like any other invalid pattern. A
message whose text, replacement, or group is null yields null without compiling its pattern, so an
invalid pattern reports nothing for it.
| Function | Returns | Notes |
|---|---|---|
regexp_like(text, pattern) | BOOL | True when the pattern matches anywhere in the text |
regexp_replace(text, pattern, replacement) | STRING | Replaces every match. $1 and ${name} in replacement insert a capture group, and $$ inserts $. A group that did not participate inserts nothing |
regexp_substr(text, pattern) | optional STRING | The first match, or null when the pattern does not match |
regexp_extract(text, pattern, group) | optional STRING | Numbered capture from the first leftmost match; group 0 is the complete match, and an absent group or match produces null |
regexp_extract accepts any integer type for group. A negative index produces null. Regex syntax
uses Unicode classes by default, while POSIX classes such as [[:alpha:]] are ASCII only. Matching
is leftmost-first and supports no backreferences or look-around; the engine keeps linear-time
search guarantees rather than enabling backtracking features. In a replacement, a group name runs
as far as letters, digits, and underscores continue, so $1a names a group 1a; write ${1}a to
follow group 1 with a.
regexp_substr and regexp_extract are null for a message the pattern does not match even when
every argument is required, so assign them to an OPTIONAL field or give them a value with
coalesce.
The description of an invalid pattern quotes the pattern. A pattern read from a field is part of the message, so the error message of such a pattern carries its text: validate patterns before they reach a route whose error records leave the node, or write patterns as literals.
Bytes, Encodings And Hashes
BYTES values hold arbitrary octets, including zero and non UTF-8 bytes. These functions accept
exact types, propagate nulls, and preserve sensitivity. An encoded or hashed sensitive input stays
sensitive; emitting it still requires explicit leakage. A cast never converts between BYTES and
STRING: input.raw AS BYTES is rejected with BYTES conversions require bytes_from_utf8, bytes_to_utf8, base64 or hex functions.
| Function | Returns | Notes |
|---|---|---|
bytes_from_utf8(text) | BYTES | The exact UTF-8 bytes of a STRING, with no terminator or normalization |
bytes_to_utf8(bytes) | STRING | Decodes UTF-8; invalid sequences fail that message |
base64_encode(bytes) | STRING | RFC 4648 standard alphabet with required = padding |
base64_decode(text) | BYTES | Accepts canonical padded standard base64; the URL-safe alphabet, missing or extra padding, nonzero trailing bits, and whitespace fail that message |
hex_encode(bytes) | STRING | Two lowercase hexadecimal digits per byte, without a prefix |
hex_decode(text) | BYTES | Accepts hexadecimal pairs in either letter case, mixed in one value; odd length or any other character fails that message |
sha256(bytes) | BYTES | The 32 raw SHA-256 digest bytes |
xxh3_64(bytes) | U64 | Stable XXH3 64-bit hash with seed zero |
An empty input encodes and decodes to an empty output. md5 reads STRING text and returns
hexadecimal text, while sha256 reads BYTES and returns raw bytes: hash text with
sha256(bytes_from_utf8(input.text)) and write the digest with hex_encode.
xxh3_64 hashes the input octets in their given order and returns the algorithm’s unsigned 64-bit
number. The value is independent of host byte order and stays the same across nodes and restarts.
It is a noncryptographic hash, so use sha256 where collision resistance matters.
A decoding failure reports a cast_failed error: bytes_to_utf8 input is not valid UTF-8,
base64_decode input is not valid encoded bytes, or hex_decode input is not valid encoded bytes.
Like every message error, it names the failed function without exposing its input.
IP Addresses And Networks
An IP address is a BYTES value in network byte order: four octets for an IPv4 address and sixteen
for an IPv6 address, so its length is its family. ip_from_string and is_ip_address read an
address from text, ip_in_network reads its network from text, and every other address function
reads the octets as one 32-bit or 128-bit number. Parse an address once into a BYTES field and
mask and test that field, so each further test costs no second parse: ip_from_string can fail, so
two calls of it are two parses even in one expression. A function over an address that is not a
constant reports its own errors and is evaluated at each occurrence.
| Function | Returns | Notes |
|---|---|---|
ip_from_string(text) | BYTES | The address text writes: 4 octets for IPv4 and 16 for IPv6 |
ip_to_string(address) | STRING | The address in its canonical text form |
ip_family(address) | I64 | 4 for an IPv4 address and 6 for an IPv6 address |
ip_trunc(address, prefix_length) | BYTES | The address with every bit after its first prefix_length bits cleared: the first address of the network of that length that holds it. prefix_length may be any integer type |
ip_in_network(address, network) | BOOL | True when address lies in network, a STRING in CIDR notation such as '10.0.0.0/8' |
ip_unmap(address) | BYTES | The IPv4 address an IPv4-mapped IPv6 address carries, and every other address unchanged |
is_ip_address(text) | BOOL | True when ip_from_string reads an address from text. Never fails |
ip_from_string reads an IPv4 address as four decimal numbers from 0 to 255 separated by dots,
each without leading zeros, so 010.0.0.1 is rejected rather than read as octal. It reads an IPv6
address in the text forms of RFC 4291: eight groups of one to four hexadecimal digits in either
letter case, :: in place of one run of zero groups, and optionally the last two groups written as
an IPv4 address, as in ::ffff:192.0.2.1. The text is the address alone: surrounding whitespace,
brackets, a port, a prefix length, and an IPv6 zone index such as %eth0 are rejected. No function
resolves a name, so ip_from_string('localhost') fails like any other text that is not an address.
ip_to_string writes an IPv4 address in dotted decimal and an IPv6 address in the canonical form of
RFC 5952: lowercase hexadecimal groups without leading zeros, with the longest run of two or more
zero groups, or the first of two equally long runs, written as ::. An IPv4-mapped address is
written with its IPv4 address in dotted decimal.
ip_to_string(ip_from_string('2001:DB8:0:0:0:0:0:1')) is 2001:db8::1, and
ip_to_string(ip_from_string('::FFFF:C000:201')) is ::ffff:192.0.2.1. A BYTES address renders
as base64 in subscriptions and JSON payloads, so write it with ip_to_string to show it as text.
A network is written as an address, /, and a prefix length in decimal without leading zeros: 0
to 32 for an IPv4 network and 0 to 128 for an IPv6 network. The address may set no bit after
the prefix, so '10.0.0.1/8' is rejected rather than read as '10.0.0.0/8'. '0.0.0.0/0' holds
every IPv4 address and '::/0' every IPv6 address. A network written as a literal, or an expression
that compiles to a constant as a constant pattern does, is read once when the
statement is applied, and one that does not read rejects the statement with a message that names its
defect, such as function 'ip_in_network' network '10.0.0.1/8' has host bits set past its prefix length. Any other network, including one built with concat, is read for each message, a message
whose network is the text last read reuses that reading, and a network that does not read fails
its message.
Each address has exactly one family, and no function converts between families unless it says so:
ip_in_networkis false for an address of the other family than its network, so no IPv6 address lies in an IPv4 network.ip_from_string('::ffff:10.0.0.1')is a 16-octet IPv6 address, the IPv4-mapped form of10.0.0.1that dual-stack sockets report for IPv4 clients. It does not lie in'10.0.0.0/8', andip_unmapof it does. Testip_in_network(ip_unmap(address), '10.0.0.0/8')to treat such clients as IPv4.ip_unmapconverts only IPv4-mapped addresses, the::ffff:0:0/96range. An IPv4-compatible address such as::a09:807, and every other IPv6 or IPv4 address, is returned unchanged.- A prefix length counts bits at the address’s own width, so
ip_trunc(address, 24)keeps the first 24 of the 32 bits of an IPv4 address and the first 24 of the 128 bits of an IPv6 address. Where both families occur, choose the length by family:ip_trunc(address, IF ip_family(address) = 4 THEN 24 ELSE 48 END).
A null argument produces a null result. Otherwise a function fails only the messages it cannot answer:
| Function | Fails when | Error |
|---|---|---|
ip_from_string | text is not an address in the forms above | cast_failed: ip_from_string input is not an IPv4 or IPv6 address |
ip_to_string, ip_family, ip_trunc, ip_in_network, ip_unmap | address is neither 4 nor 16 octets long | invalid_argument, such as ip_family input is not a 4 or 16 byte IP address |
ip_trunc | prefix_length is negative or longer than the address | invalid_argument, such as ip_trunc prefix length must be 0 to 32 for an IPv4 address |
ip_in_network | A network that is not a constant does not read | invalid_argument: ip_in_network network followed by the defect: is not written as address/prefix, address is not an IPv4 or IPv6 address, prefix length is not a decimal number without leading zeros, prefix length must be 0 to 32 for an IPv4 network, or has host bits set past its prefix length |
ip_from_string, ip_trunc, ip_to_string | The result does not fit in what its column has left, as in Result Size | overflow, such as ip_from_string result exceeds the bytes one column holds or ip_to_string result exceeds the text one STRING column holds |
ip_trunc checks its prefix_length first, so a null prefix length yields null whatever the
address holds. An address function over a literal, such as ip_from_string('192.0.2.300'), is
computed once for each batch, so a literal that does not read fails every message that evaluates
it rather than the statement. A statement that passes an argument of another type is rejected, such
as function 'ip_in_network' requires BYTES input, found Utf8.
To route malformed text explicitly instead of failing the message, test it first:
CASE WHEN is_ip_address(input.client) THEN ip_from_string(input.client) END reads only the
messages that hold an address and is null for the others. Address functions keep the sensitivity
of their arguments, so whether a sensitive address lies in a network is sensitive.
SET client = ip_from_string(input.client_ip),
client_subnet = ip_to_string(ip_trunc(output.client, IF ip_family(output.client) = 4 THEN 24 ELSE 48 END)),
internal = ip_in_network(ip_unmap(output.client), '10.0.0.0/8')
WHERE NOT output.internal
URLs
URL functions read their STRING argument as an absolute URL under the WHATWG URL Standard, the
rules web browsers apply, through the Rust url crate. They compute only from the text: none
fetches a URL, resolves a host, or consults the public suffix list.
| Function | Returns | Notes |
|---|---|---|
url_scheme(url) | STRING | The scheme, lowercased, without its : |
url_host(url) | optional STRING | The host, or null when the URL has none |
url_port(url) | optional I64 | The port, the scheme’s default port when the URL names none, or null when it has neither |
url_path(url) | STRING | The path, percent-encoded |
url_query(url) | optional STRING | The query without its ?, percent-encoded, or null when the URL has no ? |
url_fragment(url) | optional STRING | The fragment without its #, percent-encoded, or null when the URL has no # |
url_query_value(url, name) | optional STRING | The decoded value of the first query parameter named name, or null when there is none |
url_query_values(url, name) | VEC<STRING> | The decoded values of every query parameter named name, in query order, or an empty vector when there is none |
url_decode(text) | STRING | text with every percent escape decoded |
is_url(text) | BOOL | True when the other URL functions read text as a URL. Never fails |
A URL is read and normalized as the URL Standard specifies:
- Leading and trailing spaces and control characters are removed, and so are tabs and newlines anywhere in the text.
- The scheme is lowercased. For the schemes
http,https,ws,wss,ftp, andfile, the host is lowercased and an internationalized domain name is written in its ASCII form, so the host ofhttp://Bücher.example/isxn--bcher-kva.example. An IPv4 host is written in dotted decimal, including the forms browsers accept, so the host ofhttp://0x7f.1/is127.0.0.1. - An IPv6 host is written as the URL Standard writes it, lowercase, compressed, and never in dotted
decimal, and
url_hostreturns it without its brackets: the host ofhttp://[::FFFF:192.0.2.1]/is::ffff:c000:201, whichip_from_stringreads. .and..path segments are resolved, and a space or any other character a path cannot hold is percent-encoded:url_path('https://example.com/a/./b/../c d')is/a/c%20d. The path of a URL of those six schemes starts with/.- A port equal to the scheme’s default is removed, so
url_portanswers80forhttp://example.com:80/as forhttp://example.com/. The defaults are80forhttpandws,443forhttpsandwss, and21forftp. A URL of any other scheme that names no port has a null port. - Other schemes have no default port and may have no host: the path of
mailto:ops@example.comisops@example.com, and its host is null. Their hosts are kept as written, letter case included, without the ASCII form of an internationalized name.
A component the URL lacks is null: the host of a mailto: or urn: URL and of a file: URL with
an empty host, the query of a URL without ?, and the fragment of a URL without #. A URL that ends
in ? or # has an empty query or fragment rather than a null one.
The query is read as application/x-www-form-urlencoded parameters. It is split at every &, an
empty parameter is skipped, and each parameter is split at its first = into a name and a value; a
parameter without = has an empty value. Names and values are percent-decoded with + read as a
space, so the query q=hello+world%21&tag=x&tag=y%26z has the value hello world! for q and the
values x and y&z for tag. A parameter matches when its decoded name equals name exactly,
letter case included. A parameter whose name does not decode matches no name. url_query_value
reads only the first matching parameter, so only its value must decode, while url_query_values
fails the message when any matching value does not decode.
url_decode replaces each % followed by two hexadecimal digits of either case with the octet they
name, and keeps every other character as it is, + included, so url_decode(url_path(input.url))
is the readable path. Rather than guess, it fails a message whose text has a % not followed by two
hexadecimal digits, or whose decoded octets are not UTF-8.
A null argument produces a null result. Otherwise a function fails only the messages it cannot answer:
| Function | Fails when | Error |
|---|---|---|
Every URL function except url_decode and is_url | url is not an absolute URL | cast_failed, naming the function and the URL Standard’s reason, such as url_host input is not an absolute URL: relative URL without a base; other reasons include empty host, invalid port number, invalid IPv4 address, invalid IPv6 address, invalid domain character, and invalid international domain name, which a space in the host of an http URL reports |
url_query_value, url_query_values | The value of a matching parameter is not percent-encoded UTF-8 | cast_failed, such as url_query_value input is not valid percent-encoded UTF-8 |
url_decode | text is not percent-encoded UTF-8 | cast_failed: url_decode input is not valid percent-encoded UTF-8 |
Every URL function except url_port and is_url | The result does not fit in what its column has left | overflow, such as url_path result exceeds the text one STRING column holds or url_query_values result exceeds what one VEC<STRING> column holds |
A relative reference, such as the request target /search?q=x of an HTTP request line, is not an
absolute URL. Prefix it with a base explicitly:
url_query_value(concat('http://localhost', input.target), 'q'). To route text that may not be a
URL, test it first: CASE WHEN is_url(input.referrer) THEN url_host(input.referrer) END is null for
the messages whose referrer is not a URL.
Each call reads its own URL, so url_host(input.url) and url_path(input.url) each parse it, while
a URL that every message shares, such as a literal, is read once per batch, and url_query_value
and url_query_values reuse one reading while consecutive messages hold the same URL. is_url
over a constant is answered when the statement is applied. Percent-encoding and ASCII domain names
can make a component longer than the text it came from, and like the results of repeat, a
component that does not fit in what its STRING column has left reports an overflow error. URL
functions keep the sensitivity of their arguments, so the host of a sensitive URL is sensitive.
SET scheme = url_scheme(input.referrer),
referrer_host = url_host(input.referrer),
campaign = url_query_value(input.referrer, 'utm_campaign'),
tags = url_query_values(input.referrer, 'tag'),
landing_path = url_decode(url_path(input.referrer))
Numeric Functions
Numeric functions accept every integer and floating-point type unless a description below narrows
it. A function that returns F64 reads an integer argument as the nearest F64, which is exact for
every type up to 32 bits and rounds I64 and U64 values beyond 2^53, and reads an F32 argument
exactly, so its result follows the F32’s stored value. atan2, log(base, x), and pow take two
arguments that may have different numeric types, and always return F64: pow(-8, 3) is the F64
-512.0. A floating-point result that is not finite, such as sqrt(-1.0), ln(0.0), or
exp(1000.0), reports a per-message invalid_argument error and yields null instead of producing
NaN or an infinity. The error names the function by its canonical name, such as pow produced a non-finite result for a call written power(...).
| Function | Returns | Notes |
|---|---|---|
abs(x) | same numeric type as input | The absolute value. An unsigned value is returned unchanged, and abs(-0.0) is 0.0. The minimum value of a signed integer type has none and reports an overflow |
acos(x) | F64 | Arc cosine in radians |
asin(x) | F64 | Arc sine in radians |
atan(x) | F64 | Arc tangent in radians |
atan2(y, x) | F64 | The angle in radians, from -π to π, of the point (x, y). The signs of both arguments select the quadrant, so atan2(0.0, -1.0) is π. y comes first, and the two arguments may have different numeric types |
ceil(x) | same numeric type as input | Rounds up. Integer input is returned unchanged |
ceiling(x) | same numeric type as input | Alias for ceil |
cos(x) | F64 | Cosine of an angle in radians |
degrees(x) | F64 | Converts an angle from radians to degrees |
exp(x) | F64 | e raised to x |
floor(x) | same numeric type as input | Rounds down. Integer input is returned unchanged |
ln(x) | F64 | Natural logarithm |
log(x) | F64 | Base-10 logarithm |
log(base, x) | F64 | Logarithm with explicit base |
log2(x) | F64 | Base-2 logarithm |
pow(x, y) | F64 | x raised to y |
power(x, y) | F64 | Alias for pow |
radians(x) | F64 | Converts an angle from degrees to radians |
round(x) | same numeric type as input | Rounds to the nearest integer, with halves rounded away from zero. Integer input is returned unchanged |
round(x, digits) | same numeric type as x | Rounds to digits decimal places, or for a negative digits to a multiple of 10^-digits. See Precision Rounding |
sign(x) | same numeric type as input | -1 for a negative value, 1 for a positive one, and 0 for zero, so an unsigned value has the sign 0 or 1. A float zero keeps its sign, an infinity has the sign -1.0 or 1.0, and NaN has no sign and reports an error |
sin(x) | F64 | Sine of an angle in radians |
sqrt(x) | F64 | Square root |
tan(x) | F64 | Tangent of an angle in radians |
trunc(x) | same numeric type as input | Rounds toward zero. Integer input is returned unchanged |
abs, ceil, floor, round, sign, sqrt, and trunc are exact: each returns the correctly
rounded result, which is the same on every node. Rounding keeps the sign of a zero result, so
ceil(-0.4), round(-0.4), and trunc(-0.5) are -0.0, and sqrt(-0.0) is -0.0 without an
error. radians and degrees multiply by the F64 nearest to π/180 and to 180/π, so their
result is the same on every node and within two units in the last place of the exact conversion.
acos, asin, atan, atan2, cos, exp, ln, log(x), log2, pow, sin, and tan
evaluate in IEEE 754 double precision through the C math library of the platform a node runs on,
which is glibc for the published Linux builds. Their precision is that library’s: Nervix states no
bound of its own, and a node with another C library, another version of it, or a CPU for which the
library selects another implementation can differ in the last places. log(base, x) computes
ln(x) / ln(base), so it carries the rounding of two logarithms and a division, which exceeds two
units in the last place for some arguments.
A function reports an error exactly where its result is not a finite value of its type. For finite arguments, that is:
| Function | Arguments that report an error |
|---|---|
abs, ceil, floor, round(x), sign, trunc | None. abs over the minimum value of a signed integer type reports an overflow error |
round(x, digits) | A negative digits whose rounded multiple exceeds the range of x’s type. A float reports an invalid_argument error and an integer an overflow error |
sqrt(x) | x < 0 |
ln(x), log(x), log2(x) | x <= 0 |
log(base, x) | x <= 0, base < 0, and base = 1. A base of 0.0 or -0.0 gives 0.0 or -0.0, since ln(0) is an infinity |
acos(x), asin(x) | x < -1 and x > 1 |
exp(x) | x above about 709.78, where the result overflows. A result too small to represent is 0.0 |
degrees(x) | x beyond about ±3.14e306, where the result overflows |
pow(x, y) | A result that overflows, x < 0 with a y that is not an integer, and x = 0 with y < 0 |
atan(x), atan2(y, x), cos(x), radians(x), sin(x), tan(x) | None |
A NaN or infinite argument reports an error unless a finite result is defined for it, as IEEE 754
defines, for example, for atan of an infinity, atan2 of any arguments that are not NaN, exp of
negative infinity, log(base, x) with an infinite base and a finite positive x, and these
pow cases: pow(1, y) is 1 for every y, NaN included; pow(x, 0) and pow(x, -0.0) are
1; pow(-1, ±inf) is 1; pow(x, -inf) is 0 where |x| > 1 and pow(x, inf) is 0 where
|x| < 1; and pow(±inf, y) is a zero for a negative y. sign of an infinity is also defined.
abs, ceil, floor, round, and trunc report an invalid_argument error for a NaN or
infinite argument, such as floating-point absolute value produced a non-finite result or
round produced a non-finite result.
Precision Rounding
round(x, digits) takes any integer type for digits, independently of the numeric type of x,
and returns x’s type. A positive digits counts decimal places after the point, 0 rounds to the
nearest integer as round(x) does, and a negative digits rounds to a multiple of a power of ten:
round(1250, -2) is 1300. Halves round away from zero at every position, so round(-0.125, 2) is
-0.13. Every count is accepted. When 10^-digits is finer than the smallest step between values
of x’s type near x, the result is x itself, and when x is less than half of 10^-digits in
magnitude, the result is zero.
A float is rounded exactly. The result is the value of x’s type nearest to the stored value of x
rounded to digits decimal places, so it is the same on every node. The stored value of a decimal
literal is the nearest binary float, which can lie on either side of a decimal tie:
round(0.125, 2)is0.13, because0.125is stored exactly and is a tie.round(2.675, 2)is2.67, because theF64nearest to2.675is slightly below it.- An
F32rounds its own stored value: theF32nearest to0.15is slightly above0.15, soround(x, 1)of thatF32is0.2.
A float result that is zero keeps the sign of x, as round(-0.004, 2) is -0.0. Rounding to a
multiple of a power of ten can exceed the largest finite value, as round(1.7976931348623157e308, -308) does, and reports an invalid_argument error.
An integer has no decimal places, so a digits of 0 or more returns it unchanged. A negative
digits rounds it to a multiple of 10^-digits, and a multiple that does not fit its type reports
an overflow error: round(x, -1) over the I32 value 2147483647 would be 2147483650.
Floating-Point Classification
Classification functions take an F32 or F64 argument. An integer argument is rejected when the
statement is applied, since no integer is NaN or infinite. A null argument produces a null result,
and no value makes them fail.
| Function | Returns | Notes |
|---|---|---|
is_nan(x) | BOOL | True for NaN, whatever its sign or payload |
is_finite(x) | BOOL | True for every value that is neither NaN nor an infinity, including both zeros |
is_infinite(x) | BOOL | True for positive and negative infinity |
A decoded float or a cast from STRING can hold NaN or an infinity, so a route can test for them
before calling a function that would report an error: IF is_finite(input.reading) THEN sqrt(abs(input.reading)) ELSE 0.0 END.
Bitwise Functions
Bitwise functions take integer arguments and read each value as its two’s complement bits at the
width of its type, so bitwise_not over the U8 value 0 is 255 and over the I8 value 0 is
-1. A null argument produces a null result.
| Function | Returns | Notes |
|---|---|---|
bitwise_and(a, b) | same integer type as inputs | Both arguments must have the same integer type |
bitwise_or(a, b) | same integer type as inputs | Both arguments must have the same integer type |
bitwise_xor(a, b) | same integer type as inputs | Both arguments must have the same integer type |
bitwise_not(a) | same integer type as input | Inverts every bit |
bit_count(a) | I64 | The number of set bits. A negative value counts its sign bits, so bit_count of the I64 value -1 is 64 |
shift_left(value, count) | same integer type as value | value multiplied by 2^count |
shift_right(value, count) | same integer type as value | value divided by 2^count, rounded toward negative infinity |
bitwise_and, bitwise_or, bitwise_xor, bitwise_not, and bit_count never fail.
A shift takes count from any integer type, independently of the type of value, and a negative
count reports an invalid_argument error. Shifts are checked like arithmetic:
shift_leftreports anoverflowerror when the product does not fit the type ofvalue, including a signed value whose sign would change:shift_leftof theI8value64by1fails, and of theI8value-1by7is-128. Acountat or beyond the type’s width moves every bit out, so it returns0for a zerovalueand fails for any other.shift_rightnever overflows. A signed value keeps its sign, soshift_right(-5, 1)is-3, and acountat or beyond the type’s width returns-1for a negativevalueand0for any other.
Datetime Functions
A DATETIME is a UTC instant with nanosecond precision. Its range is the signed Unix-nanosecond
range, 1677-09-21T00:12:43.145224192Z through 2262-04-11T23:47:16.854775807Z. Datetime functions
compute exactly from the values they are given and never read a clock, so a call gives the same
result for the same arguments whenever and on whichever node it runs. The current time enters an
expression as a value only through now(), which returns the execution-local domain time:
date_trunc('day', now(), 'Europe/Berlin') follows a paced domain’s logical clock at any
TIME RATE, while the work a call does, and how long it takes, never depends on the domain’s pace.
| Function | Returns | Notes |
|---|---|---|
date_part(part, value[, zone]) | I64 | One part of value’s local date and time in zone, named by part from the table of date parts below |
date_trunc(unit, value[, zone]) | DATETIME | The first instant of the local unit that holds value in zone. See Calendar Arithmetic |
date_bin(unit, width, value, origin) | DATETIME | The start of the bin that holds value, for bins width units wide that start at origin and at every whole number of widths before and after it |
date_add(unit, amount, value[, zone]) | DATETIME | value moved by amount units in zone, backward when amount is negative. amount may be any integer type |
date_diff(unit, start, end[, zone]) | I64 | The whole units from start to end in zone, rounded toward zero. Negative when end is before start |
to_unix(unit, value) | I64 | The whole units from 1970-01-01T00:00:00Z to value, rounded down |
from_unix(unit, count) | DATETIME | The instant count units after 1970-01-01T00:00:00Z, or before it when count is negative. count may be any integer type |
format_datetime(format, value[, zone]) | STRING | value’s local date and time in zone, written in format. See Datetime Formats |
parse_datetime(format, text[, zone[, disambiguation]]) | DATETIME | The instant text names when read in format. See Datetime Formats |
unit, part, width, zone, format, and disambiguation are literals written in the call, not
expressions, and they are checked when the statement is applied: a zone is resolved and a format is
compiled once then, never for each message. unit, part, zone, and disambiguation are
STRING literals whose names are case-insensitive, and format is a STRING literal. width is a
positive integer literal, and width units must not exceed 9,223,372,036,854,775,807 nanoseconds,
about 292 years. A name must match exactly apart from letter case, so plurals and abbreviations such
as days or ms are unknown. A call with an unknown name, a non-literal argument, an invalid
format, or a width that is not positive is rejected, and its message names what the call accepts,
such as function 'date_bin' does not accept time unit 'month'; expected one of nanosecond, microsecond, millisecond, second, minute, hour, day, week or function 'date_part' requires its date part to be a STRING literal. A call that names no zone reads in UTC; see
Time Zones for the zones a call can name.
The units nanosecond through week have a fixed length. A DATETIME has no leap seconds, so
every UTC day is exactly 86,400 seconds long. month, quarter, and year are calendar units,
whose length depends on the month they count from. date_trunc, date_add, and date_diff accept
every unit; date_bin, to_unix, and from_unix accept only units of fixed length.
| Unit | Length |
|---|---|
nanosecond | 1 nanosecond |
microsecond | 1,000 nanoseconds |
millisecond | 1,000 microseconds |
second | 1,000 milliseconds |
minute | 60 seconds |
hour | 60 minutes |
day | 24 hours, or one local calendar day in an IANA time zone |
week | 7 days, or seven local calendar days in an IANA time zone |
month | One calendar month |
quarter | Three calendar months, starting in January, April, July, and October |
year | Twelve calendar months, starting in January |
A date part reads value’s local date and time in zone:
| Part | Range | Meaning |
|---|---|---|
year | 1677–2262 | The proleptic Gregorian year |
quarter | 1–4 | The quarter of the year |
month | 1–12 | The month |
day | 1–31 | The day of the month |
hour | 0–23 | The hour of the day |
minute | 0–59 | The minute of the hour |
second | 0–59 | The whole seconds of the minute |
millisecond | 0–999 | The whole milliseconds past the second |
microsecond | 0–999,999 | The whole microseconds past the second |
nanosecond | 0–999,999,999 | The nanoseconds past the second |
day_of_week | 0–6 | The day of the week, from Sunday as 0 |
day_of_year | 1–366 | The day of the year |
iso_year | 1677–2262 | The ISO 8601 week-numbering year that iso_week belongs to |
iso_week | 1–53 | The ISO 8601 week, whose week 1 holds the year’s first Thursday |
iso_day_of_week | 1–7 | The ISO 8601 day of the week, from Monday as 1 |
Results are exact to the nanosecond, and each function rounds in one fixed direction:
date_trunc,date_bin, andto_unixround toward negative infinity, including before the Unix epoch and before an origin, so a value belongs to the unit or bin that starts at or before it.date_trunc('day', ...)of1969-12-31T23:59:59.999999999Zis1969-12-31T00:00:00Z, andto_unix('millisecond', ...)of the same value is-1.- In UTC, a day, and every shorter unit, starts a whole number of units after the Unix epoch. A week
starts on Monday in
date_trunc, whileto_unix('week', ...)counts whole weeks from the epoch itself, which was a Thursday. date_binaccepts anoriginbefore or aftervalue. A value exactly on a bin boundary starts its own bin.date_diffrounds toward zero. For units that count elapsed time, exchangingstartandendonly changes the sign of the result, anddate_diff('second', start, end)is0when the two values are less than a second apart in either direction.
A null argument produces a null result. date_part, to_unix, and format_datetime never fail a
message; see Writing Values for the one batch-wide limit of format_datetime.
Another function reports a per-message error and yields null exactly where it cannot produce a
result:
| Function | Fails when |
|---|---|
date_trunc, date_bin | The unit or bin that holds value starts before the DATETIME range, with an overflow error |
date_add | The moved instant is outside the DATETIME range, with an overflow error. Only the result is checked, so an amount whose units alone are longer than the range fails only when the moved instant is outside it |
from_unix | The instant is outside the DATETIME range, with an overflow error |
date_diff | The whole units do not fit I64, which only a count of nanoseconds between values more than about 292 years apart can reach, with an overflow error |
parse_datetime | text does not name an instant in format, with a cast_failed error; its local time does not exist or is ambiguous in zone, with an invalid_argument error; or the instant is outside the DATETIME range, with an overflow error. See Reading Text |
The error’s message names the function, such as date_add result is outside the DATETIME range,
date_diff result does not fit I64, or
parse_datetime local time is ambiguous in America/New_York.
SET hour = date_part('hour', input.occurred_at),
local_hour = date_part('hour', input.occurred_at, 'America/New_York'),
quarter_hour = date_bin('minute', 15, input.occurred_at, from_unix('second', 0)),
local_day = date_trunc('day', input.occurred_at, 'Europe/Berlin'),
renewal = date_add('month', 1, input.subscribed_at),
deadline = date_add('millisecond', input.timeout_ms, input.occurred_at),
age_seconds = date_diff('second', input.occurred_at, now()),
received_at = from_unix('millisecond', input.epoch_ms),
label = format_datetime('%a %d %b %Y %H:%M %Z', input.occurred_at, 'Asia/Tokyo'),
logged_at = parse_datetime('%d/%b/%Y:%H:%M:%S %z', input.log_time)
Calendar Arithmetic
date_trunc, date_add, and date_diff work on the local calendar of their zone. In UTC and at a
fixed offset every day is 24 hours long. Under the rules of an IANA time zone a local day can be
shorter or longer, because daylight saving time and other transitions skip or repeat local times. An
hour or any shorter unit is always elapsed time: date_add('hour', 1, ...) is one hour later in
every zone.
date_trunc returns the first instant of the local unit that holds value: the earliest instant
from which the zone’s clock showed times inside that unit without interruption until value. A day
starts at local midnight, a week on Monday, a month on its first day, a quarter on the first day of
January, April, July, or October, and a year on January 1.
date_trunc('day', ...)of2024-03-10T11:00:00ZinAmerica/New_Yorkis2024-03-10T05:00:00Z, midnight in New York, even though that day is only 23 hours long.- A unit whose local start a transition skips starts when the skipped span ends.
America/Sao_Pauloskipped midnight on 2018-11-04, so that day starts at2018-11-04T03:00:00Z, local01:00. - A local time a transition repeats stays in one unit. New York showed
01:00through02:00twice on 2024-11-03, anddate_trunc('hour', ...)of both2024-11-03T05:30:00Zand2024-11-03T06:30:00Zis2024-11-03T05:00:00Z, where the repeated hour began. - In an IANA zone every unit follows the zone’s clock, including local mean time before standard
time: New York ran 4:56:02 behind UTC until 1883-11-18T17:00:00Z, when its clocks went back from
12:03:58 local mean time to 12:00 Eastern Standard Time, so
date_trunc('day', ...)of1883-11-18T17:01:00Zthere is1883-11-18T04:56:02Z.
date_add moves value in local time:
- An
hourand every shorter unit move by elapsed time. - A
dayor aweekmoves by 24 hours or 7 days in UTC and at a fixed offset. In an IANA zone it moves the local date and keeps the local time of day, sodate_add('day', 1, ...)of2024-03-09T12:00:00ZinAmerica/New_Yorkis2024-03-10T11:00:00Z, 23 hours later. - A
month,quarter, oryearmoves the local month and keeps the day of the month and the local time of day, unless the new month is shorter, in which case the result is on its last day:date_add('month', 1, ...)of2024-01-31T23:30:00Zis2024-02-29T23:30:00Z, anddate_add('year', 1, ...)of2024-02-29T12:00:00Zis2025-02-28T12:00:00Z. Each call moves once fromvalue, so adding one month twice to January 31, 2024 lands on March 29, while adding two months at once lands on March 31. - When the moved local time falls in a span the zone skips, the result moves forward by the length
of that span, and when the zone shows the moved local time twice, the result is the earlier
instant. Adding a
dayto2024-03-09T07:30:00Zin New York lands on the skipped02:30, so the result is2024-03-10T07:30:00Z, local03:30. - An
amountof0returnsvalueitself.
date_diff counts elapsed time for an hour and every shorter unit, and for a day or a week in
UTC or at a fixed offset. It counts whole calendar units for a day or a week in an IANA zone and
for a month, quarter, or year in every zone. A calendar count measures from start:
- The count runs to
end’s local date, moved towardstartby as few days as it takes forstart’s local time of day on that date to not lie pastend.date_diff('day', ...)from2024-03-09T12:00:00Zto2024-03-10T11:00:00ZinAmerica/New_Yorkis1, because the start’s local time of day,07:00, on 2024-03-10 in New York is11:00in UTC, and it is0in UTC, where the two instants are 23 hours apart. - Days count the dates between. Weeks are whole sevens of those days.
- Months count from
start’s local date, and a month is complete once the date has reachedstart’s day of the month.date_diff('month', ...)from2024-01-31T00:00:00Zto2024-02-29T00:00:00Zis0, even thoughdate_add('month', 1, ...)of the start is the end. Quarters and years are whole threes and twelves of those months.
Because a calendar count measures from start, exchanging start and end can change its
magnitude as well as its sign. Two instants on the same local date are 0 days apart.
Time Zones
A zone is one of:
'UTC'.- An IANA time zone name, such as
'Europe/Berlin','America/New_York', or'Asia/Kolkata', matched without regard to letter case. - A fixed UTC offset written
'+HH:MM'or'-HH:MM', from'-23:59'to'+23:59'.'-00:00'is the same zone as'+00:00', which reads as UTC does but is written+00:00by%Zand%Q.
Anything else, including an abbreviation such as 'CEST', 'PST', or 'EDT' that is not also an
IANA name, the host’s local zone, or an offset written another way, is rejected when the statement
is applied, with function '<name>' does not accept time zone '<zone>'; expected an IANA time zone name, UTC, or a UTC offset such as '+05:30'. Some names that look like abbreviations are IANA
names, and are accepted with the rules the database gives them: 'EST', 'MST', 'HST', 'CET',
'EET', 'WET', 'MET', 'GMT', 'UCT', 'Universal', and 'Zulu', as well as the
'Etc/GMT+5' family, whose sign is the reverse of the offset’s. A zone only decides how an instant
reads as a local date and time: every DATETIME a function returns is a UTC instant.
Time zone rules come from the IANA Time Zone Database bundled into Nervix, release 2026c in this
version. Nervix never reads the time zone database, the time zone, or the locale of the host it runs
on, so every node of a cluster computes the same local times. A Nervix upgrade can bundle a newer
release, and a zone whose rules the release changed then reads differently in every computation
after the upgrade, including computations over instants in the past.
An IANA zone keeps the name the database gives it: %Q writes America/New_York for
'america/new_york', and US/Eastern for the alias 'US/Eastern'. A fixed offset is written in
its +HH:MM form by both %Q and %Z, and UTC as UTC.
Datetime Formats
format_datetime writes a local date and time in a format, and parse_datetime reads one. A format
is a STRING literal in which % starts a directive and every other character, including
whitespace, is literal text written as it is and read only where it appears exactly. Names are
English and never depend on a locale.
| Directive | Writes and reads | Longest |
|---|---|---|
%Y | The year, in four digits | 4 |
%m | The month, 01–12 | 2 |
%b, %B | The month’s name, abbreviated as Jan or in full as January | 3, 9 |
%d | The day of the month, 01–31 | 2 |
%e | The day of the month padded with a space, 1–31 | 2 |
%j | The day of the year, 001–366 | 3 |
%a, %A | The day of the week’s name, abbreviated as Mon or in full as Monday | 3, 9 |
%u | The ISO 8601 day of the week, from Monday as 1 to Sunday as 7 | 1 |
%w | The day of the week, from Sunday as 0 to Saturday as 6 | 1 |
%G | The ISO 8601 week-numbering year, in four digits | 4 |
%V | The ISO 8601 week, 01–53 | 2 |
%H | The hour of the 24-hour clock, 00–23 | 2 |
%I | The hour of the 12-hour clock, 01–12 | 2 |
%p | AM before noon and PM from noon | 2 |
%M | The minute, 00–59 | 2 |
%S | The second, 00–59 | 2 |
%f | The fraction of the second in nine digits | 9 |
%1f–%9f | The fraction of the second in exactly that many digits, truncated | 1–9 |
%.f | Nothing for a whole second, and otherwise . and the fraction without trailing zeros | 10 |
%z | The UTC offset as +hhmm, followed by ss when the offset has seconds | 7 |
%:z | The UTC offset as +hh:mm, followed by :ss when the offset has seconds | 9 |
%::z | The UTC offset as +hh:mm:ss | 9 |
%s | The whole seconds since 1970-01-01T00:00:00Z, rounded down | 20 |
%Z | The abbreviation the zone shows, such as EST; written only | The zone’s longest |
%Q | The zone’s name, such as Europe/Berlin; written only | The name’s length |
%F | %Y-%m-%d | 10 |
%T | %H:%M:%S | 8 |
%R | %H:%M | 5 |
%%, %n, %t | A %, a newline, and a tab | 1 |
The - flag writes a number without padding: %-m, %-d, %-j, %-V, %-H, %-I, %-M, and
%-S. Any other directive, including a two-digit year, a locale’s date or time such as %c, a
fraction written %.3f rather than %3f, and a flag on a directive that does not take it, such as
%-e, is rejected when the statement is applied, and the message names the directive and the byte
of the format it starts at, such as unknown directive '%q' at byte 3. A format that ends inside a
directive, such as a trailing % or %-, is rejected with incomplete directive at byte <n>. Offsets from local mean time
can have seconds, as New York’s -04:56:02 before 1883 does. %s counts whole seconds rounded
down, so %s%.f writes 1969-12-31T23:59:59.5Z as -1.5: one second before the epoch plus half a
second.
Every value a format describes must fit in 256 bytes, counting every directive at its longest in the call’s zone. A format that could describe a longer value is rejected when the statement is applied, so each value of a formatted column holds at most 256 bytes.
Writing Values
format_datetime writes value’s local date and time in zone, and writes a null value as null.
It never fails a message. format_datetime('%FT%T%:z', ...) of 2024-11-03T06:30:00Z in
America/New_York is 2024-11-03T01:30:00-05:00, and an empty format writes an empty string.
A call sizes its column for every message it formats at the format’s longest value before it writes
any, so a batch whose messages could together exceed the 2,147,483,647 bytes one STRING column
holds fails as a whole with format_datetime values for <n> messages of up to <m> bytes each could exceed the text one STRING column holds. At the 256-byte maximum that takes more than 8,388,607
formatted messages in one batch.
Reading Text
parse_datetime reads text from its first byte to its last and never guesses or normalizes what
it reads:
- Literal text must appear exactly, and a directive reads exactly its field:
%mreads two digits, and%ereads a space and a digit, or two digits. A-flag reads as many digits as the field’s width allows, with or without a leading zero.%.freads nothing, or.and one to nine digits; a.without a digit after it does not match.%sreads an optional-, never a+, and one to nineteen digits.%Yand%Gread any four digits, and a year outside theDATETIMErange fails as that range does. - Month and weekday names, and
AMandPM, are read without regard to letter case.%z,%:z, and%::zread the forms they write, and also read an uppercaseZas UTC. Each part of an offset is at most23:59:59. - Every field must lie in its range: a 13th month, hour
24, and second60are rejected, since aDATETIMEhas no leap seconds. The date must exist, so2023-02-29is rejected rather than read as March 1, and a day of the week read with a calendar or ordinal date must be that date’s day.
The first field that fails is reported, checking the time of day before the date: a text such as
2023-02-29 24:00:00 reports its hour. A day of the year 366 in a common year and an ISO week
53 in a year of 52 weeks are dates that do not exist.
A readable format reads each field at most once and names exactly one instant. An empty format writes an empty string but reads no instant:
- A date, as
%Ywith a month and a day of the month,%Ywith%j,%Gwith%Vand a day of the week, or%swith nothing but a fraction of a second.%Zand%Qcannot be read. - Optionally a time of day, as
%H, or%Iwith%p.%Mrequires an hour,%Srequires%M, and a fraction of a second requires%S. A time of day the format does not read is midnight. - Optionally a UTC offset. A format that reads
%z,%:z, or%::zplaces its local date and time at that offset, and one that reads%snames its instant directly: both take nozone. Every other format reads a local date and time and requires azoneto place it.
disambiguation decides the instant of a local date and time that zone skips or repeats:
| Disambiguation | Skipped local time | Repeated local time |
|---|---|---|
reject | Fails with an invalid_argument error | Fails with an invalid_argument error |
earlier | The earlier of the two instants it could mean, read at the offset after the transition | The earlier instant |
later | The later instant, read at the offset before the transition | The later instant |
compatible | The later instant, as date_add moves local times | The earlier instant, as date_add moves local times |
A call without disambiguation rejects. In America/New_York, 2024-03-10 02:30:00 does not exist
and 2024-11-03 01:30:00 happens twice: earlier reads them as 2024-03-10T06:30:00Z and
2024-11-03T05:30:00Z, and later as 2024-03-10T07:30:00Z and 2024-11-03T06:30:00Z.
A null text produces a null result. Every other failure fails only its message:
| Error | Message |
|---|---|
cast_failed | parse_datetime input does not match its format at byte 4: expected '-', naming the literal text or directive that did not match |
cast_failed | parse_datetime input continues past its format at byte 19 |
cast_failed | parse_datetime hour is out of range, naming the field |
cast_failed | parse_datetime date does not exist |
cast_failed | parse_datetime day of the week does not match the date |
invalid_argument | parse_datetime local time does not exist in America/New_York |
invalid_argument | parse_datetime local time is ambiguous in America/New_York |
overflow | parse_datetime result is outside the DATETIME range |
A message never quotes the text it could not read. It names byte positions, the format’s own literal text and directives, field names, and the zone.
Array And Vector Functions
[a, b, ...] and array(a, b, ...) construct an ARRAY whose fixed width is the number of
arguments. vec(a, b, ...) constructs a VEC; vec() constructs an empty vector when it is the
whole value assigned to a declared VEC field, which supplies its element type. Constructor
elements must have exactly the same declared type, which may be any type, BYTES, ARRAY, and
VEC included, so a literal element beside a narrower field is cast: [input.low, 0 AS I32]. If
any element is null, the constructed container is null; schema elements themselves remain required.
A fixed ARRAY has at least one element, so [] and array() are rejected.
The functions below take ARRAY or VEC values described in
Schemas And Codecs, and a function of two lists accepts an
ARRAY and a VEC, or fixed arrays of different widths, together. The elements of a
multidimensional ARRAY are its outermost items. A null container produces a null result,
including for binary functions when either container is null.
| Function | Elements | Returns | Notes |
|---|---|---|---|
count(list) | any | I64 | Number of elements |
sum(list) | numeric | optional element type | Sum of the elements. An empty list returns null |
first(list) | numeric, BOOL, STRING, DATETIME | optional element type | The first element, or null for an empty list |
last(list) | numeric, BOOL, STRING, DATETIME | optional element type | The last element, or null for an empty list |
nth(list, index) | numeric, BOOL, STRING, DATETIME | optional element type | The element at index, counting from 0, or null when index is negative or past the end. index may be any integer type |
contains(list, element) | any scalar | BOOL | Whether element, of exactly the element type, occurs in the list. Empty lists return false; a null element returns null |
overlap(left, right) | any scalar | BOOL | Whether two lists of the same exact element type share an element. An empty list returns false |
slice(list, start, length) | any | VEC<element> | Selects up to length elements from the zero-based start; negative bounds act as zero and bounds past the end are clipped. Null bounds return null |
concat(list, ...) | any | ARRAY or VEC | Concatenates lists with one exact element type. All fixed arrays produce a fixed array whose width is their sum; any vector input produces a vector |
min(list), max(list) | numeric, BOOL, STRING, DATETIME | optional element type | Least or greatest element. Empty lists return null |
mean(list) | numeric | optional F64 | Arithmetic mean. An empty list returns null; integer inputs round to F64 for the calculation |
dot(left, right) | numeric | element type | Dot product of two lists of one element type. Empty lists return zero. Integer multiplication and accumulation are checked at the element type |
distance(left, right) | numeric | F64 | Euclidean distance of two lists of one element type. Empty lists return zero; integer inputs round to F64 for the calculation |
nth counts from 0, unlike string positions: nth(input.values, 0) is the same element as
first(input.values). A function whose elements the table narrows rejects any other element type
when the statement is validated, such as a list whose elements are themselves ARRAY or VEC
values, with function 'first' requires ARRAY or VEC elements of a scalar type. first, last,
nth, min, and max do not take BYTES elements.
min and max order floating-point elements by the IEEE 754 total order, in which -0.0 is below
0.0, a NaN with the sign bit set is below every other value, and any other NaN is above every
other value. This differs from greatest and least and from the window MIN and
MAX aggregates, which treat both zeros as equal and order every NaN above every other value.
Among equal elements, the first is returned.
sum follows the arithmetic operators: an integer sum that overflows its type reports integer sum overflowed with the kind overflow, and a floating-point sum that is not finite reports
floating-point sum produced a non-finite result with the kind invalid_argument. dot checks its
products and their sum at the element type, so a dot product of two U8 lists fails once it
reaches 256, with integer dot product overflowed. dot and distance require equal lengths in
every message; a mismatch reports invalid_argument, vector lengths differ: left has 3, right has 2, for that message. A non-finite mean, dot, or distance reports invalid_argument, such as
mean produced a non-finite result; mean sums its elements in F64 before it divides, so a sum
beyond the F64 range fails even where the mean itself would be finite. contains and overlap
use the scalar equality contract: NaN equals no element, and positive and negative zero are equal.
These functions compare exact element types; they never cast or stringify list elements.
In a window route, count, sum, first, last, min, and max are always
window aggregates, like every other aggregate name. They take a per-row expression over input and
aggregate it across the rows the window retained, so COUNT(input.values) aggregates retained rows
rather than counting the elements of one list. A window route therefore cannot use these six list
functions, even inside an aggregate’s argument, where SUM(count(input.values)) is rejected as a
nested aggregate. Everywhere else these names are the list functions above.
Window Aggregates
A window processor route computes its output from aggregates over
the rows its window retains when it emits. Aggregates appear directly in the route’s SET, and
every route uses at least one. An aggregate reads per-row arguments from input, and input is
available only inside aggregate arguments, which read nothing else. Aggregate calls may take part in
larger scalar expressions, such as arithmetic, a conditional, a datetime function, or a [...] over
aggregates, combined with constants, but aggregates cannot be nested. Route WHERE reads the
finalized output. A statement that breaks these rules is rejected when it is applied, with a
message such as input.latency is available only inside a window aggregate argument, aggregate functions must not be nested inside aggregate arguments, CORR expects 2 argument(s), found 1, or
window output 'summary' must contain at least one aggregate function.
Aggregate names are case-insensitive, and argument and result types are exact and checked when the
processor is applied. No aggregate argument may be BYTES or contain BYTES, which is rejected
with ... cannot retain BYTES in aggregate demand <n> argument <m>.
| Function | Arguments | Returns | Typed null when |
|---|---|---|---|
COUNT(value) | any | I64 | never; counts every retained row |
COUNT_IF(condition) | BOOL | I64 | never; counts rows whose condition is true |
BOOL_AND(condition) | BOOL | BOOL | no row has a condition |
BOOL_OR(condition) | BOOL | BOOL | no row has a condition |
SUM(value) | numeric | the argument’s type | no row has a value |
AVG(value) | numeric | F64 | no row has a value |
MIN(value), MAX(value) | numeric, BOOL, STRING, or DATETIME | the argument’s type | no row has a value |
FIRST(value), LAST(value) | any | the argument’s type | no row has a value |
ARG_MIN(value, key), ARG_MAX(value, key) | any value; a numeric, BOOL, STRING, or DATETIME key | the value’s type | no row has both a value and a key |
VAR_POP(value), STDDEV_POP(value) | numeric | F64 | no row has a value |
VAR_SAMP(value), STDDEV_SAMP(value) | numeric | F64 | fewer than two rows have a value |
COVAR_POP(first, second) | numeric, numeric | F64 | no row has both values |
COVAR_SAMP(first, second) | numeric, numeric | F64 | fewer than two rows have both values |
CORR(first, second) | numeric, numeric | F64 | fewer than two rows have both values, or either variable is constant across them |
PERCENTILE_LINEAR_HISTOGRAM(value, percentile, buckets, min, max, delay) | numeric value, then constants | F64 | the histogram counts no value |
APPROX_COUNT_DISTINCT(value, precision) | numeric, BOOL, STRING, or DATETIME; constant precision 4–16 | I64 | never; zero when no row contributes |
APPROX_QUANTILE(value, percentile, capacity) | numeric; constant percentile 0–100 and capacity 32–4096 | F64 | no row has a value |
APPROX_TOP_K(value, k, capacity) | numeric, BOOL, STRING, or DATETIME; constants 1 <= k <= capacity <= 4096 | VEC<value type> | never; empty when no row contributes |
The approximate sketches describe the last three.
Nulls. A row contributes to an aggregate only when every argument that aggregate reads is
present, so a null argument contributes nothing. COUNT is the exception: it counts every retained
row whatever its argument holds, so SUM(input.amount) / COUNT(input.amount) is not the mean of an
optional field; use AVG. A window emits only while it retains at least one row, so an aggregate
whose arguments are all required always has a value, except the sample statistics and CORR, which
can be undefined in any window. COUNT, COUNT_IF, APPROX_COUNT_DISTINCT, and APPROX_TOP_K are
never optional; VAR_SAMP, STDDEV_SAMP, COVAR_SAMP, and CORR are always optional; every other
aggregate is optional exactly when an argument it reads is. Assign an aggregate that can be null to
an OPTIONAL field or give it a value with COALESCE; assigning it to a required field is rejected
when the processor is applied.
Order and ties. MIN, MAX, ARG_MIN, and ARG_MAX return the earliest admitted row among
rows with equal keys. FIRST and LAST order rows by their ingestion low watermark, then by
admission. BOOL orders false before true, STRING orders by bytes, and floating-point keys
order NaN above every other value and treat both zeros as equal.
Population and sample. VAR_POP, STDDEV_POP, and COVAR_POP divide by the number of
contributing rows n; VAR_SAMP, STDDEV_SAMP, and COVAR_SAMP divide by n - 1. Standard
deviations are the square roots of the matching variances. CORR is the Pearson correlation, kept
within [-1, 1].
Numerical behavior. COUNT, COUNT_IF, BOOL_AND, BOOL_OR, and SUM over integers are
exact: an integer SUM is accumulated without overflow and checked against its argument’s type
only when the window emits. SUM over floating-point values carries the rounding error of every
addition beside the running total, and an F32 SUM accumulates in F64 and rounds to F32 when
it emits. AVG, the variances, standard deviations, covariances, and CORR convert each argument
to the nearest F64 and keep centered moments, so a variance is never the difference of two large
sums of squares. Admission computes a compensated mean and then centered second moments over each
typed run; one merge combines that run with the retained aggregate. Floating-point results can
vary slightly with run and SIMD lane grouping because addition and merging round in a different
order. Integer sums and counts are independent of grouping. Stepping a window never subtracts a
floating-point value from a statistic: the statistic of the rows that remain is rebuilt from the
rows themselves, so even a large value that left the window leaves no rounding behind.
Errors. A row is refused at admission, and never changes the window, when an argument
expression fails for it, when a floating-point argument of SUM, AVG, a variance, standard
deviation, covariance, CORR, PERCENTILE_LINEAR_HISTOGRAM, or a sketch is NaN or infinite, or
when it would exceed the window’s MAX STATE SIZE or pane limit.
A refused row is logged and not acknowledged, whatever the route’s ON MESSAGE ERROR policy says;
a non-finite argument is reported by the first function, in alphabetical order, of the structure
that reads it, such as AVG requires finite floating-point arguments. At emission, a SUM that
does not fit its type, such as SUM of the window does not fit Float32, or a statistic that
overflows F64 fails the window: every retained row’s acknowledgement fails, the window is
cleared, and routes the window emitted before the failing one keep their output.
Sensitivity. An aggregate is sensitive when an argument reads a sensitive field, including
COUNT, COUNT_IF, APPROX_COUNT_DISTINCT, and APPROX_TOP_K, whose results do not contain the
field’s values. Wrap the aggregate, or its argument, in leak_sensitive(...) to assign it to a
field that is not sensitive.
Shared structures. Aggregates of one route that can be answered from one structure over the same
arguments share it: AVG, the variances, and the standard deviations of one argument share a
moments structure; the covariances and CORR of one argument pair, in the same order, share
co_moments; COUNT_IF, BOOL_AND, and BOOL_OR share a truth_counter; ARG_MIN and ARG_MAX
share arg_extremes; MIN and MAX share extremes; FIRST and LAST share a sequence;
APPROX_QUANTILE calls with one argument and capacity share a quantile_sketch; and
PERCENTILE_LINEAR_HISTOGRAM calls with one argument, bucket count, bounds, and delay share a
linear_histogram. DESCRIBE WINDOW PROCESSOR lists every structure with the functions it serves
and the arguments it reads.
Linear Histogram Percentiles
PERCENTILE_LINEAR_HISTOGRAM(value, percentile, buckets, min, max, delay) counts value in
buckets buckets of equal width from min to max, and returns the midpoint of the bucket that
holds the value of rank round(percentile / 100 * (n - 1)) among the n counted values, clamped to
[min, max]. It does not interpolate within a bucket, so its precision is the bucket width,
(max - min) / buckets. A value at or below min counts in the first bucket and one at or above
max in the last, rather than being dropped.
percentile is an integer or float literal from 0 to 100, buckets a positive integer literal,
and min and max numeric literals with min below max. A negative number is written with
unary minus, which is not a literal, so min and max are zero or above. delay is a duration
literal such as '2s': a row that stepping removes from the window stays counted until the
watermark or the domain clock passes its removal time plus delay, and '0s' removes it at once.
A delay that names no duration, or whose spans could add up to 18446744073709551615 seconds, is
rejected when the window processor is created, with
invalid PERCENTILE_LINEAR_HISTOGRAM delay duration '<delay>': <reason>. Each branch holds eight
bytes per bucket for each histogram, which MAX STATE SIZE does not count.
Approximate Sketches
APPROX_COUNT_DISTINCT, APPROX_QUANTILE, and APPROX_TOP_K answer from bounded sketches instead
of the retained values. A route that uses one needs its window processor to declare MAX STATE SIZE,
duration WIDTH and STEP, and, when it is branched, a branch with MAX INSTANCES ... EVICT LRU,
as Window processor describes. The sketches ignore nulls and
refuse non-finite floating-point values as described under Errors above.
| Sketch | Algorithm | Accuracy | Reserved per pane |
|---|---|---|---|
APPROX_COUNT_DISTINCT(value, precision) | HyperLogLog with 2^precision registers over a stable, type-tagged BLAKE3 hash of each value | Relative standard error of about 1.04 / sqrt(2^precision): 26% at precision 4, 3.25% at 10, and 0.41% at 16 | 2^precision + 128 bytes |
APPROX_QUANTILE(value, percentile, capacity) | t-digest with at most capacity centroids, smaller near the tails | Exact, interpolating linearly at rank percentile / 100 * (n - 1), while at most capacity values contribute; beyond that, an interpolated estimate whose accuracy depends on the distribution and capacity, with no distribution-independent bound | 32 * capacity + 128 bytes |
APPROX_TOP_K(value, k, capacity) | Misra-Gries frequency candidates, at most capacity keys | Every value that occurs more than n / (capacity + 1) times among the n contributing rows remains a candidate; values near the frequency cutoff may differ from an exact top-k | 160 * capacity + 128 bytes |
APPROX_TOP_K returns up to k values ordered by estimated frequency, then by the bytes of their
type-tagged key to break ties, so ties among numbers do not follow numeric order. Each value it
returns is the one the earliest retained row with that key holds. Its counts are internal.
APPROX_COUNT_DISTINCT hashes -0.0 and 0.0 as one value and a STRING by its UTF-8 bytes.
Memory. A sketch window keeps one sketch for each pane, a span of time whose length is the
greatest common divisor of WIDTH and STEP, aligned to the Unix epoch, and merges the panes still
in the window when it emits. A window with duration WIDTH w and pane length p spans at most
w / p + 2 panes. For each route, the processor is rejected when it is applied unless
1024 + (the route's reservations summed) * (w / p + 3) bytes fit its MAX STATE SIZE, with
window processor '<name>' ... requires <bytes> bytes for <panes> sketch panes, above MAX STATE SIZE <limit> bytes. At run time each branch charges the reservation of every sketch in every active pane,
and of the merged sketch it builds at emission, beside the retained rows and their argument columns,
and a row that would exceed MAX STATE SIZE is refused. The configured worst case across branches
is therefore bounded by MAX STATE SIZE * MAX INSTANCES; an unbranched window has one state.
Expiry. Panes are merged only from rows still in the active window and rebuilt after stepping, so a row that left the window never contributes to a later result. Mergeability does not make a sketch retractable: nothing is subtracted from one. When a branch’s ownership moves or a node recovers, the sketches are rebuilt from the retained input columns that the branch’s snapshots carry.
Determinism. A sketch is deterministic for the same ordered inputs, pane layout, and configuration, so every node computes the same estimate from the same rows.
Measured accuracy. In the VM Functions benchmark report, APPROX_COUNT_DISTINCT over full
windows of distinct keys measured a mean absolute relative error of 1.74% for 500 keys and 1.90% for
100 keys at precision 10, whose standard error is 3.25%, and 0.26% for 500 keys at precision 16,
whose standard error is 0.41%, over 31 windows each. The quantile and top-k sketches have no
measured accuracy beyond the exactness above.
Limits
Every limit an expression can reach is fixed by Nervix rather than configured, except the window state size a window processor declares:
| Limit | Bound | Reached by |
|---|---|---|
Text or octets one STRING or BYTES column holds | 2,147,483,647 bytes for the results of one call in one batch | The functions of Result Size, which fail each message that does not fit with overflow, and format_datetime, which fails the batch |
Parts of one split result | 65,536 | split, with overflow |
| LIKE pattern | 4,096 bytes | like and ilike, with invalid_argument |
contains_any set | 128 non-null patterns and 65,536 bytes of pattern text; 64 distinct sets prepared per batch | contains_any, with invalid_argument |
| Compiled regular expression | 10 MiB; 64 compiled patterns cached per call | Regular-expression functions, with invalid_argument |
| JSON document | 16,777,216 bytes and 128 levels of nesting | JSON_VALUE and JSON_EXISTS, with invalid_argument; TRY_JSON_VALUE yields null |
| JSON path | 64 steps; array index up to 4,294,967,295 | Rejected when the statement is parsed |
DATETIME | 1677-09-21T00:12:43.145224192Z through 2262-04-11T23:47:16.854775807Z, in nanoseconds | Datetime functions and conversions, with overflow |
| Datetime format | 256 bytes per value, counting every directive at its longest | Rejected when the statement is applied |
date_bin width | 9,223,372,036,854,775,807 nanoseconds, about 292 years | Rejected when the statement is applied |
| Window sketches | Precision 4–16; capacity 32–4096; k at most the capacity | Rejected when the processor is applied |
| Window sketch state | MAX STATE SIZE per branch, and at most MAX INSTANCES branches | Rows refused at admission; see Approximate Sketches |
The one bound on how long a builtin runs is the size of its input: every builtin finishes the batch it is evaluating, bounded by the limits above, so stopping a node or a processor takes effect between batches. A UDF has its own watchdog; see User-Defined Functions.
Measured Performance
Execution is columnar, so a batch pays a fixed cost once and each message pays for its own work. Two measured facts, from the VM batch-size sweep and from the VM function workloads below, shape what an expression costs:
-
Per-message cost falls steeply up to about a thousand messages per batch and then flattens. Batches above 1,024 messages execute on the node’s data workers, whose admission and hand-off cost more than a small batch does to execute.
-
An expression costs what its functions do for the messages that evaluate them. Inside a conditional arm, some operations run only for the messages that select the arm, so their cost follows the share of messages it selects:
- operations that can fail for a message and cost more than the batch’s narrowing does, such as pattern matching, text parsing, calendar arithmetic, and transcendental functions
- UDFs
- JSON extraction
Every other operation runs over the whole batch, and the arm keeps only its own messages’ results.
The table shows the median time to execute a complete compiled program over one batch, measured with the VM function workloads on one 32-thread x86-64 development machine in September 2026, with other builds running on the same machine. The absolute rates are specific to that machine and are not guarantees; the ratios between rows are what carries over.
| Workload, 1,024 messages unless noted | Median per batch | Messages per second |
|---|---|---|
| Checked integer arithmetic, no failures | 8.09 µs | 127 million |
| Checked integer arithmetic, sparse failures | 15.48 µs | 66 million |
| Checked integer arithmetic, dense failures | 51.07 µs | 20 million |
| Arithmetic, 1 message | 3.44 µs | 0.29 million |
| Arithmetic, 8 messages | 3.38 µs | 2.4 million |
| Arithmetic, 1,025 messages, on the data workers | 14.40 µs | 71 million |
| Arithmetic over a sliced batch | 10.39 µs | 99 million |
List function over ragged VEC values | 384.74 µs | 2.7 million |
contains_any with a per-message set, 32-byte ASCII text | 117.76 µs | 8.7 million |
contains_any with a per-message set, 1,024-byte UTF-8 text | 143.61 µs | 7.1 million |
repeat producing 1, 8, and 64 copies | 19.59, 25.76, and 40.18 µs | 52, 40, and 25 million |
Four JSON_VALUE extractions from one document field | 548.58 µs | 1.9 million |
Four JSON_VALUE extractions from four document fields | 1,861.2 µs | 0.55 million |
| Regular expression in a conditional arm that selects no, 1%, and 50% of messages | 2.45, 5.25, and 30.05 µs | 417, 195, and 34 million |
What the measurements show:
- A message that fails costs several times what a successful one does: dense failures made checked
arithmetic six times slower. Guard expected failures with a conditional,
TRY_CAST, orTRY_JSON_VALUErather than relying on the error path. - Extractions from one document share its parse, so four fields read from one document cost less than a third of four fields read from four documents.
- Variable-length work such as ragged lists, text search, and JSON runs at millions of messages per second rather than the hundred million of fixed-width arithmetic.
The workloads, commands, allocation observations, and hardware are recorded in the VM functions measurement report.
Catalog
Every operator, builtin, and aggregate, with the section that describes its types, nulls, and
errors. T is the argument’s type, and a list is an ARRAY or a VEC.
| Name | Returns | Section |
|---|---|---|
+, -, *, /, %, unary - | numeric T | Arithmetic |
AND, OR, NOT | BOOL | Logical Operators |
=, !=, <, <=, >, >= | BOOL | Comparison And Equality |
IS [NOT] DISTINCT FROM | BOOL, never null | Comparison And Equality |
[NOT] IN (...) | BOOL | Sets |
[NOT] BETWEEN ... AND ... | BOOL | Ranges |
IF ... END, CASE ... END | the result type | Conditional Expressions |
<expr> AS <type>, TRY_CAST(<expr> AS <type>) | <type> | Conversions |
JSON_VALUE, TRY_JSON_VALUE | optional declared type | JSON Documents |
JSON_EXISTS | BOOL | JSON Documents |
coalesce, nullif | T | Null Handling |
is_null | BOOL, never null | Null Handling |
greatest, least, clamp | T | Extrema |
leak_sensitive | T | Context And Identity |
now | DATETIME | Context And Identity |
uuid_v4, uuid_v7 | STRING | Context And Identity |
read_header | optional STRING | Header Functions |
read_headers | VEC<STRING> | Header Functions |
write_header | nothing; INVOKE only | Header Functions |
LOOKUP_HASH_MAP | optional field type | Lookups And UDF Calls |
udf::<name> | the UDF’s result type | Lookups And UDF Calls |
lower, upper, initcap, trim, btrim, ltrim, rtrim, reverse, normalize_nfc | STRING | String Functions |
left, right, substr, substring, repeat, lpad, rpad, replace, translate, split_part | STRING | String Functions |
concat over text, concat_ws, join | STRING | String Functions |
split | VEC<STRING> | String Functions |
length, char_length, bit_length, octet_length, ascii, strpos | I64 | String Functions |
to_hex, md5 | STRING | String Functions |
contains over text, starts_with, ends_with, contains_any, like, ilike | BOOL | String Predicates |
regexp_like | BOOL | Regular Expressions |
regexp_replace | STRING | Regular Expressions |
regexp_substr, regexp_extract | optional STRING | Regular Expressions |
bytes_from_utf8, base64_decode, hex_decode, sha256 | BYTES | Bytes, Encodings And Hashes |
bytes_to_utf8, base64_encode, hex_encode | STRING | Bytes, Encodings And Hashes |
xxh3_64 | U64 | Bytes, Encodings And Hashes |
ip_from_string, ip_trunc, ip_unmap | BYTES | IP Addresses And Networks |
ip_to_string | STRING | IP Addresses And Networks |
ip_family | I64 | IP Addresses And Networks |
ip_in_network, is_ip_address | BOOL | IP Addresses And Networks |
url_scheme, url_path, url_decode | STRING | URLs |
url_host, url_query, url_fragment, url_query_value | optional STRING | URLs |
url_port | optional I64 | URLs |
url_query_values | VEC<STRING> | URLs |
is_url | BOOL | URLs |
abs, ceil, ceiling, floor, round, trunc, sign | numeric T | Numeric Functions |
acos, asin, atan, atan2, cos, sin, tan, degrees, radians, exp, ln, log, log2, pow, power, sqrt | F64 | Numeric Functions |
round(x, digits) | numeric T | Precision Rounding |
is_nan, is_finite, is_infinite | BOOL | Floating-Point Classification |
bitwise_and, bitwise_or, bitwise_xor, bitwise_not, shift_left, shift_right | integer T | Bitwise Functions |
bit_count | I64 | Bitwise Functions |
date_part, date_diff, to_unix | I64 | Datetime Functions |
date_trunc, date_bin, date_add, from_unix, parse_datetime | DATETIME | Datetime Functions |
format_datetime | STRING | Datetime Formats |
[...], array | ARRAY | Array And Vector Functions |
vec, slice | VEC | Array And Vector Functions |
concat over lists | ARRAY or VEC | Array And Vector Functions |
count over a list | I64 | Array And Vector Functions |
sum, first, last, nth, min, max over a list | optional element type | Array And Vector Functions |
contains over a list, overlap | BOOL | Array And Vector Functions |
mean | optional F64 | Array And Vector Functions |
dot | element type | Array And Vector Functions |
distance | F64 | Array And Vector Functions |
COUNT, COUNT_IF, APPROX_COUNT_DISTINCT | I64 | Window Aggregates |
BOOL_AND, BOOL_OR | BOOL | Window Aggregates |
SUM, MIN, MAX, FIRST, LAST | the argument’s type | Window Aggregates |
ARG_MIN, ARG_MAX | the value’s type | Window Aggregates |
AVG, VAR_POP, VAR_SAMP, STDDEV_POP, STDDEV_SAMP, COVAR_POP, COVAR_SAMP, CORR | F64 | Window Aggregates |
PERCENTILE_LINEAR_HISTOGRAM | F64 | Linear Histogram Percentiles |
APPROX_QUANTILE | F64 | Approximate Sketches |
APPROX_TOP_K | VEC of the value’s type | Approximate Sketches |
Example
This graph runs without any external system: a node’s own HTTP endpoint receives access records, a junction describes each one with the address, URL, JSON, datetime, conditional, identity, and hash functions, and a window processor summarizes latency with exact and approximate aggregates. Create the domain and select it:
CREATE UNPACED DOMAIN functions_example;
USE functions_example;
Then declare the graph:
BEGIN;
CREATE SCHEMA access_event (
client STRING,
url STRING,
payload STRING,
logged STRING,
latency_ms I64
);
CREATE WIRE JSON SCHEMA access_wire MODE STRICT (
client string,
url string,
payload string,
logged string,
latency_ms integer
);
CREATE CODEC access_codec
FROM WIRE JSON SCHEMA access_wire
TO SCHEMA access_event;
CREATE SCHEMA access_summary (
client_address BYTES,
client_subnet STRING,
internal BOOL,
host STRING OPTIONAL,
campaign STRING OPTIONAL,
customer_id I64 OPTIONAL,
tags VEC<STRING> OPTIONAL,
logged_at DATETIME,
local_hour I64,
latency_band STRING,
event_id STRING,
url_digest STRING
);
CREATE SCHEMA latency_window (
requests I64,
mean_latency F64,
p90_latency F64,
distinct_clients I64
);
CREATE RELAY access_events SCHEMA access_event UNBRANCHED;
CREATE RELAY access_summaries SCHEMA access_summary UNBRANCHED;
CREATE RELAY latency_windows SCHEMA latency_window UNBRANCHED;
CREATE VHOST functions_edge access.example.com;
CREATE ENDPOINT access_ingress
ON functions_edge
PATH '/access'
TYPE HTTP;
CREATE HTTP ENDPOINT INGESTOR access_intake
FROM access_ingress MODE NO ACK
ON QUIESCE BUFFER MAX SIZE 1MiB DECODE USING access_codec
TO access_events
INHERIT ALL
UNBRANCHED
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
CREATE JUNCTION describe_access
FROM access_events
UNBRANCHED
TO access_summaries
SET client_address = ip_from_string(input.client),
client_subnet = ip_to_string(ip_trunc(output.client_address, IF ip_family(output.client_address) = 4 THEN 24 ELSE 48 END)),
internal = ip_in_network(ip_unmap(output.client_address), '10.0.0.0/8'),
host = url_host(input.url),
campaign = url_query_value(input.url, 'utm_campaign'),
customer_id = TRY_JSON_VALUE(input.payload, '$.customer.id' AS I64),
tags = JSON_VALUE(input.payload, '$.tags' AS VEC<STRING>),
logged_at = parse_datetime('%d/%b/%Y:%H:%M:%S %z', input.logged),
local_hour = date_part('hour', output.logged_at, 'Europe/Berlin'),
latency_band = CASE
WHEN input.latency_ms < 100 THEN 'fast'
WHEN input.latency_ms < 1000 THEN 'slow'
ELSE 'stalled'
END,
event_id = uuid_v7(),
url_digest = hex_encode(sha256(bytes_from_utf8(lower(trim(input.url)))))
WHERE NOT output.internal OR output.latency_band != 'fast'
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG;
CREATE WINDOW PROCESSOR access_latency
FROM access_events
WIDTH 10s DURATION
STEP 10s DURATION
MAX STATE SIZE 1MiB
UNBRANCHED
TO latency_windows
SET requests = COUNT(input.client),
mean_latency = AVG(input.latency_ms),
p90_latency = APPROX_QUANTILE(input.latency_ms, 90, 64),
distinct_clients = APPROX_COUNT_DISTINCT(input.client, 12)
ON MESSAGE ERROR LOG;
COMMIT;
describe_access parses the client address once into the BYTES field client_address and
reads it back through output.client_address, so client_subnet and internal cost no second
parse. TRY_JSON_VALUE yields a typed null for a customer id that is not an integer, and every
extraction shares one parse of each payload. local_hour reads the local hour in Berlin from the
logged_at value an earlier assignment computed. The route drops fast internal requests.
Start the domain, subscribe to both outputs, each in its own terminal, and post three records:
START;
nervix-cli --domain functions_example subscribe access_watch access_summaries
nervix-cli --domain functions_example subscribe latency_watch latency_windows
curl -X POST http://127.0.0.1:8080/access -H 'Host: access.example.com' \
-H 'Content-Type: application/json' \
-d '{"client":"203.0.113.9","url":"https://Shop.Example.com/landing?utm_campaign=spring&tag=a","payload":"{\"customer\":{\"id\":42},\"tags\":[\"new\",\"vip\"]}","logged":"25/Sep/2026:14:03:07 +0000","latency_ms":85}'
curl -X POST http://127.0.0.1:8080/access -H 'Host: access.example.com' \
-H 'Content-Type: application/json' \
-d '{"client":"10.1.2.3","url":"https://shop.example.com/cart","payload":"{\"customer\":{\"id\":\"c-7\"},\"tags\":[]}","logged":"25/Sep/2026:14:03:08 +0000","latency_ms":1450}'
curl -X POST http://127.0.0.1:8080/access -H 'Host: access.example.com' \
-H 'Content-Type: application/json' \
-d '{"client":"10.1.2.4","url":"https://shop.example.com/","payload":"{\"tags\":[\"x\"]}","logged":"25/Sep/2026:14:03:09 +0000","latency_ms":40}'
access_summaries receives the first two records, and the third, a fast internal request, is
filtered out. The subscription renders BYTES as base64 and omits a null optional field, and
event_id differs on every run:
{"campaign":"spring","client_address":"ywBxCQ==","client_subnet":"203.0.113.0","customer_id":42,"event_id":"01a0d977-422d-7530-a7df-1f7cc335ffd8","host":"shop.example.com","internal":false,"latency_band":"fast","local_hour":16,"logged_at":"2026-09-25T14:03:07+00:00","tags":["new","vip"],"url_digest":"e3573155b66ec8947e80bc6a6cf1d8dc62108261d9d8c0b64906259bd7f47302"}
{"client_address":"CgECAw==","client_subnet":"10.1.2.0","event_id":"01a0d977-423f-7d17-820b-d2be58c6d798","host":"shop.example.com","internal":true,"latency_band":"stalled","local_hour":16,"logged_at":"2026-09-25T14:03:08+00:00","tags":[],"url_digest":"aebca4f73e8e9c15a3679803b396f37c61a468bf9e24a60a95fe1d6d1078131c"}
When the ten-second window closes, latency_windows receives one summary of all three requests.
Three latencies are within the quantile sketch’s capacity of 64, so p90_latency is exact: rank
0.9 * 2 interpolates between 85 and 1450.
{"distinct_clients":3,"mean_latency":525.0,"p90_latency":1177.0,"requests":3}
Domains And Time
Every runtime graph in Nervix runs inside a domain.
This chapter describes the public language and runtime behavior. See Domain Clock for the internal clock architecture.
Nervix currently supports:
CREATE PACED DOMAIN <id> WITH PERIOD <duration> SKEW <duration> [PLACEMENT <policy>];CREATE UNPACED DOMAIN <id> [PLACEMENT <policy>];CREATE DOMAIN <id> [PLACEMENT <policy>];
CREATE DOMAIN <id> is the short spelling for CREATE UNPACED DOMAIN <id>.
Domain creation is never transaction content. A transaction is bound to one already-existing
domain, so CREATE DOMAIN runs on its own before BEGIN; queueing it is rejected. See
Replicated NSPL Transactions.
Paced Domains
Paced domains maintain a domain clock.
PERIOD must be positive and no larger than 18446744073709551615ns. SKEW may be zero but must
fit in that same 64-bit nanosecond duration range. Invalid durations are rejected before the domain
is stored. Duration text anywhere in NSPL whose spans could add up to 18446744073709551615 seconds,
however they are written, is rejected: a duration literal when the statement is parsed, and a
duration string argument, such as the delay of PERCENTILE_LINEAR_HISTOGRAM, when the statement is
validated.
While the domain is running:
- Nervix produces domain ticks
- paced ingestors admit records whose effective timestamp falls within
SKEWof an eligible logical tick center
Centers are origin + n × PERIOD, where origin is the committed logical start and n is
nonnegative. The current domain mapping determines the reached frontier. Only the newest 256
positions through that frontier are eligible; position zero is eligible immediately on start.
SKEW is an inclusive logical-time distance: an event exactly at either edge is accepted.
Overlapping windows admit their union, while gaps between windows reject events.
Admission reconstructs these positions from the installed mapping. Delayed, missing, or coalesced
tick notifications do not change eligibility. A hypothetical future center is never eligible:
an event can extend past the last reached center only by its declared SKEW. An event before
the origin can be admitted within the origin’s tolerance while that position remains eligible.
TIME RATE changes when the frontier advances in real time; it does not scale source timestamps,
PERIOD, or SKEW.
Paced time is also important for expiration:
- branch TTL uses domain logical time in paced domains, including the relay owner’s cluster-wide branch-presence decision
- materialized-state cleanup follows the same logical-time rule
- deduplication expiry, reorder and correlation retention, window completion, and guest-requested WASM timeouts are logical deadlines on the same clock
- branch activity is the domain time at which a branch’s last input was accepted, so an idle relay owner or processor never spends part of a branch’s TTL waiting for that input
The maintenance scan that releases expired branches and the snapshot wakeups that persist branch-local state run on the physical clock, and so does the WASM executor’s own safety timeout. Their predicates still compare logical deadlines, so a scan never expires a branch early: its interval is release latency measured after the logical boundary, reported separately from the TTL itself. See TTL.
Deterministic Roto UDFs preserve reproducibility when paced input is replayed at an accelerated
time rate. See the deterministic-by-default and VOLATILE contract,
including common-subexpression reuse and the rule that user code is never constant-folded.
Unpaced Domains
Unpaced domains do not produce ticks.
Ingestors in an unpaced domain admit records as they arrive, and branch TTL uses wall clock time.
Placement Default
Every domain has a fallback placement policy. It applies to directly connected runtime-node pairs
that no named placement rule claims. Because relays are scheduled runtime nodes, a
producer-to-relay edge and a relay-to-consumer edge each receive the default independently. The
clause is optional on every domain-creation form, and omission means NEUTRAL so ordinary
scheduler heuristics remain active:
CREATE PACED DOMAIN production
WITH PERIOD 1s SKEW 100ms
PLACEMENT REQUIRE COLOCATION;
CREATE UNPACED DOMAIN archive
PLACEMENT SUGGEST SEPARATION;
Change the default for the session’s active domain with the nameless domain alteration:
ALTER DOMAIN SET PLACEMENT PREFER COLOCATION;
A newly effective REQUIRE COLOCATION default can relocate runtime nodes during graph activation.
Soft policies influence only future placement decisions and do not migrate existing assignments.
See Placement Policies for named overlays, path-gating, rank resolution, and
enforcement.
Start And Stop
Domain lifecycle commands apply to the active domain:
START;START AT NOW [TIME RATE <float>];START AT <rfc3339_timestamp> [TIME RATE <float>];STOP;
Important runtime consequences:
START;resumes from persisted domain-owned runtime state when a source supports itSTART AT NOWreinitializes paced time and domain-owned source offsets from current wall clockSTOPpreserves persisted runtime stateSTARTclears materialized relay state for the active domain before new execution proceeds
Successful lifecycle commands are completion boundaries. START returns after every current live
node has prepared the same revision and every configured listener and assigned source has completed
startup. STOP returns after intake and runtime work have stopped remotely, including when it is the
last running domain. A dependent connection, observation, or publish can follow immediately through
any live node.
The lifecycle state, active paced-clock anchor, and one clock authority are replicated. The
authority identifies a concrete incarnation of a named cluster node and carries a revision that
advances whenever ownership changes or is revoked. After leader failover, reconciliation uses that
committed state; a completed transactional START therefore remains effective without
re-executing its commit step, and leadership transfer alone does not establish a new anchor or
authority.
Every live node installs that committed mapping before it builds executable work for the domain.
The installed capability is bound to the domain name and START generation. A joining node uses
the existing logical origin, physical UTC anchor, and rate; it does not establish a new anchor from
its join time. An unpaced domain receives actual UTC through the same domain-bound capability. A
missing domain, stopped clock, uninstalled paced mapping, or task bound to an earlier generation is
a lifecycle error and never selects wall time as a paced fallback. Reads on one node do not move
backward within a generation.
Nervix assumes every cluster host maintains UTC closely enough for the deployment’s event-time
contract. Operators must run and monitor a clock-synchronization service on every node. The
committed mapping prevents authority changes and joins from inventing a new logical origin, and
each node clamps its own reads so they do not move backward within a generation. It does not make
simultaneous reads on different hosts identical or establish a distributed total order. Host UTC
offset affects a newly established START AT NOW, unpaced observations, and each node’s projection
of a paced mapping; TIME RATE multiplies that projection error. SKEW is event-admission
tolerance around logical tick centers, not an allowance for unsynchronized hosts.
The authority begins producing once its own node installs the mapping and fence of the consensus
revision that assigns it. Each live peer receives progress once it has installed that fence. A
node installs a reassigned fence ahead of its graph installation, so neither the authority nor a
peer waits for its own or another node’s graph installation or activation. Join, restart, owner
loss, and membership change can select a new authority without changing the clock mapping. When
the authority’s node becomes unavailable, the consensus leader commits a replacement and retries a
proposal its new leadership could not yet accept; if that node also led consensus, the remaining
voters elect a leader first. A progress report is accepted only when its START generation,
authority revision, node incarnation, and authenticated peer match the committed authority.
Duplicate, delayed, reordered, or superseded progress is ignored and cannot create a domain. STOP
revokes the authority in the same replicated lifecycle transition; a later START commits a new
generation and mapping. Automatic ALTER quiescing keeps the authority and clock running.
Progress crosses the interconnection as a typed HTTP/2 management request in the reserved progress subquota. For each domain and each remote node that installed its clock, the authority retains one replaceable pending report and permits one delivery attempt at a time. A newer frontier replaces an older pending frontier while connection capacity or a response is delayed. The interconnection bounds all in-flight progress requests independently of the stream and memory reservations for health, cancellation, admission, and terminal outcomes. A joining or reconnected node receives the newest retained frontier once it advertises that it installed the required domain clocks.
Each progress attempt has a two-second physical deadline. A failed attempt retries the newest frontier after a 200-millisecond physical backoff, and authority shutdown cancels both the request and that backoff. The response only confirms that the authenticated receiver evaluated the report; the committed generation and authority fence decide whether it is current. Progress never replaces the committed mapping or moves a node’s logical clock. Its wire value contains typed logical and UTC timestamps and never contains a process-local monotonic instant.
An explicit START AT timestamp must fit exactly in signed Unix nanoseconds. The inclusive range
is 1677-09-21T00:12:43.145224192Z through 2262-04-11T23:47:16.854775807Z; valid RFC 3339 values
immediately outside those endpoints are rejected by the command. Nervix converts accepted text to
a timestamp at the language boundary and carries that timestamp through persistence and runtime
state without reparsing it. An offset is converted to UTC, fractional digits past the ninth are
truncated, and a leap second, which RFC 3339 writes as :60, reads as the instant one second past
the second before it, because Unix nanoseconds have no leap seconds.
TIME RATE accepts every positive finite f64, including scientific notation such as 5e-324
and 1.7976931348623157e308. Zero, negative values, infinities, and NaN are rejected. The committed
start mapping remains the sole clock anchor for that run. Tick delivery records progress but never
re-anchors logical time, so a delayed tick cannot move time backwards.
Clock projection rounds fractional logical nanoseconds down. Converting a logical target to a physical wait rounds fractional physical nanoseconds up, which prevents a deadline from firing early. Timestamp, duration, boundary, and tick-id overflow are errors. If tick production wakes after several periods have passed, it emits one tick at the latest due logical boundary and next targets the first future boundary.
Domain cadence and data-lifecycle policy create logical deadlines from a bound domain clock. Connection, retry, cancellation, drain, and other operational policy create physical deadlines on the process monotonic clock. The two deadline kinds cannot be interchanged. A logical wait revalidates its domain and generation after every wake and returns both the logical instant that became due and a fresh execution-time snapshot. Cancellation is a separate typed outcome.
HTTP EVERY polling and generator EACH scheduling have an immediate first occurrence;
Prometheus EVERY polling first becomes due after one interval. Later occurrences remain anchored
to that initial logical schedule. If work takes long enough to miss several occurrences, Nervix
runs once for the newest due instant and advances directly to the first future boundary. It never
bursts through the missed intervals. Prometheus sends the due instant as its query evaluation time,
then takes a fresh domain execution snapshot when the response is decoded and evaluated. Skipped
polls and generators during quiescing do not stop or re-anchor the cadence.
COLLECT FOR, FLUSH EACH, and the Iceberg COMMIT EACH are domain-logical durations, in
emitters exactly as in every other flush-based node. A paced domain’s TIME RATE therefore
accelerates or slows them, and a stopped clock cannot silently turn any of them into a wall-clock
wait. Each source relay and concrete branch owns its collection deadline; each concrete output
branch owns its route deadline. A deadline starts only when data enters an empty buffer and is
cleared when that buffer is released by time, size, force flush, shutdown, or error handling. For
COMMIT EACH that buffer is the Iceberg emitter’s staged files: its deadline starts when a flush
stages rows while none are staged, not at the domain time those rows’ batch was accepted at, and a
retry or drain commits them without reading the clock.
FLUSH IMMEDIATE instead uses a physical 100 µs minimum on the process monotonic clock, regardless
of domain rate. The ingestor source-group bound of 1,024 messages and 5 ms idle close are also
physical and run before route-local flushing. Connector retry and backoff timers remain separate
physical controls; none of these operational waits is projected through the domain clock.
An emitter’s publish cadence and its publish attempts are separate state. Retry backoff, the
acknowledgement keepalive that holds an upstream MODE ACK source open while a sink is
unavailable, the sink acknowledgement timeout, the stop and drain deadlines, and an HTTP
Retry-After a server returns are all physical: a paced domain never shortens or lengthens the
real wait an external system asked for. A failed attempt keeps the emitter’s pending batches, their
acknowledgements, and its unchanged cadence deadline; while a retry is scheduled the emitter waits
for the retry instead of the cadence, and a forced flush or drain still releases the buffer. The
maximum batch size releases a buffer that has reached it as soon as no retry is pending.
Following A Domain Clock
A client session can follow the active domain’s clock instead of inferring it from the domain list:
ATTACH DOMAIN CLOCK;
DETACH DOMAIN CLOCK;
The attach reply, and a frame after later changes of the installed clock, carry the START
generation and the clock as the serving node has it installed: stopped, uninstalled, unpaced, or
paced with its PERIOD, SKEW, logical origin, UTC anchor, and TIME RATE. Changes made before a
frame is sent arrive together as the newest clock. A paced clock is the committed mapping this
chapter describes, so a client projects the domain’s logical time for its own UTC observation with
the same arithmetic every node uses, including rounding down, and reconstructs the admission window
from the origin, PERIOD, and SKEW. A driver of a paced simulation can therefore choose
TIMESTAMP AT values the ingestor admits, such as the newest reached center, and compute how long
to wait until a logical instant; the Paced Simulation Drivers are two
runnable such drivers. The projection uses the client host’s UTC, so the synchronization requirement
above extends to such a client: its offset from the cluster’s hosts, multiplied by TIME RATE,
shifts the logical instants and admission centers it projects, and the offset itself moves the end
of every physical wait it computes. The session also receives each newest accepted tick as a
replaceable frame with its id, logical boundary, authority UTC observation, and serving node’s
logical reading. A client can pace from those accepted boundaries even if its own UTC is offset; the
serving node’s reading gives it a cluster-side anchor.
Both statements are session-local and are refused while a transaction is open, and a session follows
each domain clock at most once. See Domain Clock Attachment
for the delivery contract and Rust Client Library for
the helper that performs this arithmetic, which the shared C binding exposes to its hosts through
nx_session_domain_clock.
Execution-Time Snapshots
Each accepted unit of domain work reads its domain clock once and uses that execution-time snapshot
for every expression in the unit. This applies to ingestion, processor input and output programs,
branching, deduplication, ordering, correlation and window expressions, inferencer mappings,
emitter filters, construction, VALUES and SQS FIFO-group expressions, and subscription filters.
Buffered emitter batches and retries retain the snapshot assigned when the batch was accepted.
Processor flushes, scheduled callbacks, and other later executions receive a new snapshot when that
work begins. A message released from REQUIRED WAIT also begins again with a fresh snapshot.
A message error records the snapshot of the operation that failed, and its error-route SET
program uses that same instant. Generator output and WASM rows without a source token receive that
instant as both ingestion watermarks. WASM rows with a source token retain the source metadata.
Guest initialization, batch processing, timeouts, flushing, and state lifecycle calls each receive
the snapshot of their owning execution. Roto and WASM hosts have no context-free time entry point:
their now() or domain-time import returns that supplied snapshot and never reads UTC on its own.
Generated timestamps at external boundaries follow their declared clock class. An omitted Sentry
event timestamp uses the emitter batch’s domain execution time, while an explicit timestamp is
preserved. OTEL VALUES use domain execution time, while observed_time_unix_nano records actual
UTC at export. HTTP-date Retry-After values are interpreted against actual UTC and converted to a
physical monotonic wait.
Automatic Model-Alteration Quiescing
Nervix derives a quiesce level from the complete validated model diff. ALTER SCHEMA,
ALTER WIRE ... SCHEMA, relay schema changes, and relay branching changes use an internal paused
lifecycle state when their domain is running. Dynamic changes such as relay capacity do not pause
the domain. There is no NSPL PAUSE or RESUME statement.
Before changing the live graph, the leader validates the complete candidate graph without writing it while holding the domain’s exclusive ALTER lock. For an entity-pause alteration, Nervix gates the affected relays or stops the affected ingestor instances on every live node, then waits only for their rings and target-node work to drain. Unrelated graph paths continue to run. For a domain-pause alteration, Nervix instead stops domain ingestion and generators on every node, keeps the processing graph and domain clock alive, force-flushes ingestor route, processor, and emitter output, and waits for ingestors, generators, ACK roots, and emitter buffers to drain. Both waits are condition-based and bounded to 60 seconds by default.
After a successful drain, Nervix atomically installs the model batch, replaces the schedule while
ingestion is still withheld, and resumes the domain on the new graph. A timeout or cutover failure
leaves the mutation unapplied, restores the old graph, and automatically resumes it with a clear
outstanding-work error. An ALTER on a stopped domain only validates and persists the new schedule.
Pure CREATE and DROP batches keep the immediate schedule-rebuild behavior. An all-no-op batch
writes and publishes nothing and never pauses.
For an HTTP emitter, this means a pending request must finish at the origin selected when it was
admitted before a new method, path, body mode, or client takes effect. A failed drain keeps that
emitter definition active. An operator facing a persistently unavailable origin may restore it,
or STOP, change the configuration while stopped, and START. Stopping leaves no prepared HTTP
request or retry state to carry over; an acknowledged source may redeliver unresolved attached
work, including work the destination applied before its successful response was lost.
Pause is not a restart: domain clock state, start version, broker offsets, branch identity, and eligible handoff residue are preserved across the quiesce cycle.
Ingestion Timestamps
RESTORE ... RESUME retains the archived start generation, latest start point and paced clock
mapping after installing all compatible runtime state. That mapping projects the downtime, so
TIMESTAMP AT admission uses the restored clock’s reached windows. Unpaced domains resume without
a mapping. A default restore stays stopped. A later normal START advances the generation and
clears materialized relay rows while preserving compatible WASM state, source offsets and branch
lifecycle. To start from the archived frontier with a new mapping, use a default restore and
explicit START AT '<frontier>' TIME RATE <rate>. DESCRIBE DOMAIN exposes the generation, start
point and mapping; LIST DOMAINS exposes pace, status and generation. See
Backup And Restore.
Every ingested record receives internal ingestion metadata, including mandatory low and high watermarks with nanosecond precision.
Timestamp sources:
TIMESTAMP NOWselects the installed domain’s logical time when the ingest group is delivered. Buffered intake released after automatic quiescing receives that delivery time. In an unpaced domain, the same clock capability supplies actual UTC. In a paced domain that delivery time is tested against the admission window like any event time: it lies less than onePERIODafter the newest reached tick center, so aSKEWsmaller thanPERIODrejects the records delivered between two windows, and aSKEWof at leastPERIODadmits every delivery.TIMESTAMP AT <field>preserves the decodedDATETIMEfield as the event time. Connector timestamps selected when this clause is omitted in an unpaced domain, and broker metadata, retain their external values. None of these values is multiplied byTIME RATE.
In paced domains, ingestors must declare a timestamp source explicitly. In unpaced domains, timestamp metadata is still recorded, but it is not used to gate admission.
Both ingestion watermarks initially equal the selected event time and travel with the record through relays and across nodes. Timestamp selection and admission share one delivery snapshot. An unknown, stopped, or uninstalled domain clock produces a lifecycle error; a paused domain withholds delivery until its ingestion path resumes.
Window processors also use this metadata. Duration windows evaluate input event time from the record low watermark. Emitted aggregate records receive a low watermark equal to the minimum input low watermark in the emitted window and a high watermark equal to the current domain time at emission.
Schemas And Codecs
Nervix separates internal runtime schema from wire schema.
Internal Schemas
An internal schema describes the typed runtime record:
CREATE IF NOT EXISTS SCHEMA notification (
user_id U32,
created_at DATETIME,
payload STRING OPTIONAL,
cpu_last_64 ARRAY<F32, 64>,
image ARRAY<F32, 3, 224, 224>,
detections VEC<ARRAY<F32, 6>> OPTIONAL
);
Schemas must declare at least one field.
A schema may name a field with any word, keywords included. An expression writes a field named like
a reserved word between backticks where it stands bare,
and as it is after a scope such as input..
These types are the values Nervix stores in runtime records and uses for subscription matching and processor logic. BYTES may be a deduplication or reordering key, ordered lexicographically by octet, but branch key schemas cannot contain BYTES, including nested ARRAY or VEC elements.
BYTES holds arbitrary octets in an Arrow Binary column. It is distinct from STRING. Use
bytes_from_utf8 and bytes_to_utf8 for explicit text conversion, or hex_decode and
base64_decode for encoded text. A CAST never converts between BYTES and text.
Lookup schemas, materialized-state dependencies, generator source relays, and window aggregate
arguments cannot contain BYTES; their declarations fail validation. LOOKUP_HASH_MAP key
expressions also cannot produce BYTES.
ARRAY<T, D1, ..., Dn> is a fixed rectangular array. Each dimension maps to one
nested Arrow FixedSizeList level, so ARRAY<F32, 2, 3> maps to
FixedSizeList<FixedSizeList<Float32, 3>, 2> and remains a 2-by-3 value.
VEC<T> is a variable-length sequence and maps to Arrow List<T>. The element
type is recursive, so fixed and variable axes can be mixed, for example
VEC<ARRAY<F32, 6>> and ARRAY<VEC<STRING>, 4>.
ARRAY and VEC are distinct and are never implicitly converted. Every fixed
axis must have a positive length no greater than 2147483647, the largest length
an Arrow FixedSizeList carries, fixed arrays must contain exactly that many
elements at runtime, and dense multidimensional values must retain their nested
shape. JSON and CBOR represent both with nested JSON-style arrays. AVRO uses
nested array schemas with item types inferred recursively from the internal
schema.
Append OPTIONAL to either an internal schema field or a wire schema field when the value may be absent. Optional fields are omitted from runtime records and emitted JSON payloads when no value is present.
Wire Schemas
Wire schemas describe the serialized format on the transport side.
Declared wire schemas are either STRICT or LOOSE. Strict wire schemas reject payload fields
that are not declared by the wire schema. Loose wire schemas accept extra payload fields and drop
them before decoding into the internal schema.
JSON wire schema:
CREATE IF NOT EXISTS WIRE JSON SCHEMA notification_wire MODE STRICT (
user_id integer,
created_at string,
payload string OPTIONAL
);
CBOR wire schema:
CREATE IF NOT EXISTS WIRE CBOR SCHEMA notification_wire MODE LOOSE (
user_id integer,
created_at string,
payload string OPTIONAL
);
AVRO wire schema:
CREATE IF NOT EXISTS WIRE AVRO SCHEMA notification_wire MODE STRICT (
user_id LONG,
created_at STRING,
payload STRING OPTIONAL
);
JSON, CBOR, and AVRO wire schemas must declare at least one field.
JSON and CBOR also accept exact numeric and datetime wire types (U8 through I64, F32,
F64, and DATETIME) alongside the generic JSON types. The exact variants retain their type in
the canonical definition and in completion suggestions. A codec binds an exact wire type only to
the internal type of the same name: a U8 wire field to a U8 field, and a DATETIME wire field,
whose value is RFC 3339 text, to a DATETIME field without an ENCODE ... AS RFC3339 rule.
The web console’s Create menu provides structured editors for internal schemas and each of
these three declared wire formats. Internal collection controls build nested ARRAY and VEC
types from an exact scalar type. Field order, OPTIONAL, SENSITIVE, wire mode, and exact format
are preserved in the completed Model and its canonical command. See Web Console.
SYSLOG is different: it is a predefined singleton wire schema whose shape comes from the syslog
protocol. It has no user-defined name, CREATE WIRE declaration, mode, field list, ALTER, or
DROP lifecycle. A codec references it directly:
CREATE CODEC syslog_codec FROM SYSLOG TO SCHEMA syslog_event;
Its fixed field contract is documented in Syslog.
Altering Schemas
Internal and declared wire schemas can be changed without dropping their whole dependent graph first. Operations in one statement run from left to right, and each operation sees the result of the previous one.
Internal schema operations are:
ADD FIELD <field> <type> [OPTIONAL] [SENSITIVE]DROP FIELD <field>RENAME FIELD <field> TO <field>ALTER FIELD <field> SET TYPE <type>ALTER FIELD <field> SET OPTIONALandALTER FIELD <field> DROP OPTIONALALTER FIELD <field> SET SENSITIVEandALTER FIELD <field> DROP SENSITIVE
For example:
ALTER SCHEMA notification
ADD FIELD note STRING OPTIONAL,
RENAME FIELD created_at TO received_at,
ALTER FIELD payload SET SENSITIVE;
DROP SENSITIVE is the explicit way to downgrade a field. Nervix still rebuilds and validates the
whole candidate graph, including every downstream leakage rule. A schema must retain at least one
field, added and renamed names must be unique, and the target of every drop, rename, or field alter
must exist.
Wire schema operations are:
ADD FIELD <field> <wire_type> [OPTIONAL]DROP FIELD <field>RENAME FIELD <field> TO <field>ALTER FIELD <field> SET TYPE <wire_type>ALTER FIELD <field> SET OPTIONALandALTER FIELD <field> DROP OPTIONAL
JSON, CBOR, and AVRO wire schemas are separate entity kinds. They may use the same name without colliding, and the exact format is required for every ALTER:
ALTER WIRE JSON SCHEMA notification_wire
ADD FIELD note string OPTIONAL;
Mode changes use that same exact entity kind:
ALTER WIRE JSON SCHEMA notification_wire MODE LOOSE;
Use one explicit transaction when a type or shape change requires coordinated updates. Model
mutations for one domain—CREATE, schema ALTER, relay ALTER, and DROP—are validated against
one candidate graph and committed atomically. The following replacement changes the wire and
internal types together and recreates their codec without exposing an intermediate invalid graph:
BEGIN;
ALTER WIRE JSON SCHEMA notification_wire
ALTER FIELD user_id SET TYPE number;
ALTER SCHEMA notification
ALTER FIELD user_id SET TYPE F64;
DROP CODEC notification_codec;
CREATE CODEC notification_codec
FROM WIRE JSON SCHEMA notification_wire
TO SCHEMA notification;
COMMIT;
If any operation or dependent model fails validation, none of the mutations are persisted.
SHOW CREATE SCHEMA and exact wire forms such as
SHOW CREATE WIRE JSON SCHEMA notification_wire render the resulting canonical definitions.
Dropping a wire schema is exact-format too, for example
DROP WIRE JSON SCHEMA notification_wire.
On a running domain, a schema ALTER is applied through an automatic quiesce cycle: Nervix validates first, stops new ingestion and generators, force-flushes buffered output, drains in-flight work, installs the new graph, and resumes. This internal state is not a user-facing lifecycle command. A drain timeout rejects the batch and resumes the old graph. On a stopped domain, Nervix validates and persists the change without a quiesce cycle.
Runtime state whose record layout derives from an altered schema is recreated. Independent stateful nodes retain their state, and so does state that depends on no schema at all: domain-owned Kafka offsets and the counters behind a node’s metrics survive a schema change of their own node. Persisted schema-bound state is keyed by the fingerprint of the schemas it was written under, so a stale layout is never restored as the new type. Relay subscriptions closed by the rebuild report that they must be recreated against the current schema.
Codecs
A codec maps one transport payload format to one internal schema.
CREATE IF NOT EXISTS CODEC notification_codec
FROM WIRE JSON SCHEMA notification_wire
TO SCHEMA notification;
Schemaful codecs are type-strict. A JSON string wire field does not implicitly decode
into an internal DATETIME field. Declare the wire conversion explicitly:
CREATE IF NOT EXISTS CODEC notification_codec
FROM WIRE JSON SCHEMA notification_wire
TO SCHEMA notification
ENCODE created_at AS RFC3339;
created_at is the internal schema field name. The matching wire field must be a
string, and the internal field must be DATETIME.
For an internal BYTES field, declare BYTES in the JSON or CBOR wire schema. Its wire value is
a canonical RFC 4648 standard base64 string with padding; malformed or unpadded text is a decode
error. AVRO BYTES fields use Avro’s native byte sequence. A wire STRING field cannot bind an
internal BYTES field. Nested ARRAY and VEC byte elements use the same representation.
Live WIRE JSON ingestion copies each connector-owned payload into mutable scratch retained by
the open ingest group, then parses it with simd-json using parser buffers retained beside that
scratch. The decoder walks simd-json’s borrowed values directly into the batch’s typed Arrow
builders. It resolves schema field hashes and Arrow data types when the codec and batch are built,
parses each datetime once, and streams base64 output into the binary builder. It does not construct
a serde JSON tree or a row map. Strict and loose field behavior, optional nulls, exact integer
ranges, nested sequence shapes, invalid UTF-8 and malformed escape rejection remain the public
wire-schema contract.
On emission, a compiled WIRE JSON codec writes each object in schema field order directly from
its typed Arrow columns. Field names are escaped when the codec is compiled. A runtime-selected
SIMD classifier marks quotes, backslashes and control bytes in each string column once per batch;
clean UTF-8 strings are copied without an escape pass, while marked strings use JSON escaping.
Optional null fields are omitted, required null fields fail encoding, datetimes use their declared
wire rule, and bytes use padded base64. Nested arrays retain their declared shape. The encoder
writes into the caller’s output buffer, including a bounded buffer when MAX SIZE applies.
JAQ-native codecs parse a transport payload in a jaq-supported format and run explicitly directed JAQ transformations. An ingestion transformation turns every value the payload holds into zero or more JSON objects, and each object is decoded into the internal schema as one message (Unfolding Payloads):
CREATE IF NOT EXISTS CODEC notification_cbor
FROM CBOR
TO SCHEMA notification
WITH JAQ TRANSFORMATIONS ON INGESTION '.';
CREATE IF NOT EXISTS CODEC notification_xml
FROM XML
TO SCHEMA notification
WITH JAQ TRANSFORMATIONS
ON INGESTION '{user_id: (.c[] | select(.t == "user_id").c[0] | tonumber)}';
Protobuf codecs compile .proto files from an uploaded resource, decode or encode the selected message with prost-reflect, and use JAQ to translate between the protobuf JSON view and the internal schema:
CREATE IF NOT EXISTS CODEC notification_proto
FROM PROTOBUF
USING RESOURCE proto_bundle VERSION 1
CONFIG {'file' = 'notification.proto', 'include' = '.'}
MESSAGE 'nervix.test.Notification'
TO SCHEMA notification
WITH JAQ TRANSFORMATIONS ON INGESTION '{user_id: .user_id, payload: .payload}';
The resource contains the .proto files. USING RESOURCE requires VERSION <n> or
VERSION LATEST, and the codec compiles its descriptors from the one version it stores.
CONFIG declares compile parameters; file/files select source files and include/includes select import roots, all relative to the resource root. If no file is listed, all .proto files in the resource are compiled.
A protobuf codec that a batching emitter encodes through names the message
a batch is published as, after MESSAGE:
CREATE CODEC notification_proto
FROM PROTOBUF
USING RESOURCE proto_bundle VERSION 1
CONFIG {'file' = 'notification.proto', 'include' = '.'}
MESSAGE 'nervix.test.Notification'
BATCH MESSAGE 'nervix.test.NotificationBatch'
TO SCHEMA notification
WITH JAQ TRANSFORMATIONS ON EMITTING '{user_id: .user_id, action: .action}';
BATCH MESSAGE names a message in the same compiled descriptors, and the domain build fails when it
does not exist. Without an ON EMITTING BATCH transformation it must declare exactly one field,
repeated <MESSAGE>, which holds the members. With one, it may have any shape the
transformation’s output is a valid instance of.
Current schemaful codec wire formats are:
JSON, with an explicit JSON wire schemaCBOR, with an explicit CBOR wire schemaAVRO, with an explicit AVRO wire schema
Current predefined fixed-contract wire format:
SYSLOG, a singleton wire schema referenced directly by a codec
Current JAQ-native codec formats are:
JSONYAMLTOMLXMLCBOR
Current protobuf codec format:
PROTOBUF, with resource-backed.protofiles, inline compile config, and message name
Value Fidelity
A schemaful codec decodes every row it encodes back to the same values: every integer width to its
extremes, booleans, any Unicode text, bytes, datetimes to the nanosecond, nested ARRAY and VEC
values, and nulls of optional fields. The formats themselves set the bounds of that promise:
- JSON has no number for a NaN or an infinity.
WIRE JSONwrites a non-finite float asnull, which reads back as a null of an optional field and refuses a required field or a list element.WIRE CBORwrites the float itself but reads every value through the same JSON model, so it reads a non-finite float as a null. - A JSON or CBOR number read into an
F32field becomes the nearestF32, and the shortest decimal a writer prints for anF32reads back as that sameF32, also where the number lies exactly halfway between two of them once read as a 64-bit float. A number beyond the type’s range rounds to an infinity. Every finite float the codec writes reads back to its exact bits. WIRE AVROkeeps every float bit pattern. It writes an unsigned element of anARRAYorVECas an Avrolong, so an element above 9223372036854775807 fails to encode.- A datetime travels as RFC 3339 text in UTC; text written with another offset reads as the same instant.
- A
WIRE AVROpayload is one raw Avro datum, without an object container or a schema registry header, and aWIRE CBORpayload is one CBOR data item.
The JAQ-native and protobuf codecs are transformations rather than encodings of a row: a program
decides the shape in each direction. A number a program passes on unchanged keeps its value: a
64-bit integer exactly, and a finite float to its bits through JSON, YAML, TOML, CBOR and
the double and float fields of a protobuf message, because every decimal is read as the float
nearest to it. The formats set two bounds of their own:
TOMLholds signed 64-bit integers. A larger unsigned value is written as it stands and refused when it is read.- A protobuf message does not carry a field that holds its default value, so its JSON view omits
such a field: a zero of either sign,
false, empty text or an empty list. An ingestion program that needs one restores it, as(.level // 0)does; a negative zero comes back positive.
SYSLOG writes its timestamp to the microsecond in UTC; see Syslog.
JAQ Transformations
JAQ-backed codecs must declare a JAQ transform. The concise
JAQ Reference links the full upstream manual for the exact jaq-core release
Nervix embeds and summarizes the Nervix-specific boundary:
CREATE IF NOT EXISTS CODEC notification_codec
FROM JSON
TO SCHEMA notification
WITH JAQ TRANSFORMATIONS
ON INGESTION '.payload'
ON EMITTING '{payload: .}';
Semantics:
- no-wire codecs must use
FROM JSON|YAML|TOML|XML|CBOR ... WITH JAQ ... - protobuf codecs must use
FROM PROTOBUF USING RESOURCE ... VERSION <n> | LATEST CONFIG {...} MESSAGE ... [BATCH MESSAGE ...] WITH JAQ ... - codecs using declared wire schemas must use
FROM WIRE JSON|CBOR|AVRO SCHEMA ...and do not carry JAQ transforms - codecs using the predefined SYSLOG wire schema must use
FROM SYSLOG TO SCHEMA ...and do not carry JAQ transformations or field encoding rules WITH JAQ TRANSFORMATIONSrequiresON INGESTION,ON EMITTING, or both in that order, andON EMITTINGmay be followed byON EMITTING BATCH(Batch Transformations)ON INGESTIONruns on every value the parsed native or protobuf payload holds and may yield zero or more JSON objects, each of which becomes one message compatible with the internal schema (Unfolding Payloads)ON EMITTINGruns after the runtime record has been converted into JSON and must yield exactly one native-format or protobuf-message value
JAQ-backed encode/decode runs on the node’s extension workers, so an expensive or non-terminating transform never stalls an async ingestor or emitter task and never holds the workers that relay bodies are encoded and decoded on. A node whose extension workers cannot take a payload now refuses it without judging it: an ingested payload fails its dispatch rather than its decode, and an emitter keeps the rows and retries them. A payload no sender can present again is not refused when its source can wait: a payload an ingestor’s quiesce buffer retained waits in the buffer, and a paced poll, or a payload a source that survives a held loop handed over without an acknowledgement, waits until the extension workers take it (When The Extension Workers Are Full).
JAQ JSON output intentionally preserves object member declaration order. The serde JSON
preserve_order feature exists for that public JAQ behavior; schemaful WIRE JSON ingestion does
not depend on serde JSON object storage.
Batch Transformations
A JAQ-backed codec — JAQ-native or protobuf — may declare a third transformation for emitters that declare a batching clause:
CREATE IF NOT EXISTS CODEC notification_envelope
FROM JSON
TO SCHEMA notification
WITH JAQ TRANSFORMATIONS
ON EMITTING '{id: .user_id, action: .action}'
ON EMITTING BATCH '{schema_version: 2, count: length, records: .}';
ON EMITTING BATCH is written only after ON EMITTING, because its input is built from that
transformation’s outputs: one array holding the member values of a batch, in publication order. It
must yield exactly one value, valid in the codec’s format; for a protobuf codec, a valid instance of
the declared BATCH MESSAGE. The program compiles with the codec, so a program that cannot compile
fails the domain build. A batching Sentry emitter requires its codec to declare this
transformation. ON EMITTING keeps its meaning exactly: it runs per record and yields one value per
record.
The transformation replaces the format’s batch container, so an array mapping, an envelope and a larger value are all ordinary uses:
ON EMITTING BATCH '.'
ON EMITTING BATCH 'map({id: .user_id})'
ON EMITTING BATCH '{count: length, records: .}'
Its output is written by the codec’s format writer and measured against the emitter’s MAX SIZE
like any container. Whatever it reports as a count, Nervix still counts, acknowledges and limits the
source records that went in. It sees only the member values, never the source row, materialized
state or headers, so it cannot reach a sensitive value that construction did not already leak.
Batch Containers
An emitter that declares a batching clause publishes several records
as one payload. Each record contributes one member value, exactly the value it would have been
published as alone: the wire object for WIRE JSON, WIRE CBOR and WIRE AVRO codecs, the
ON EMITTING output for JAQ-native and protobuf codecs, and the complete RFC 5424 message for
SYSLOG. Without an ON EMITTING BATCH transformation the members are written in the format’s own
container, by the same writer, with the same whitespace and number formatting, that writes one
record:
| Format | Container | Three members |
|---|---|---|
WIRE JSON | Array | [{"seq":1},{"seq":2},{"seq":3}] |
JAQ-native JSON | Array | [{"seq": 1}, {"seq": 2}, {"seq": 3}] |
WIRE CBOR, JAQ-native CBOR | Definite-length array | Major type 4 with the member count, then the members |
YAML | One document holding a sequence | [{seq: 1}, {seq: 2}, {seq: 3}] |
WIRE AVRO | One datum of type array whose items are the codec’s record schema | One block: the member count, the members, then the terminating zero |
TOML | One document with a single key, batch | [[batch]] sections, one per member |
XML | One root element, batch, whose children are the members | <batch><event seq="1"/><event seq="2"/><event seq="3"/></batch> |
PROTOBUF | One instance of the codec’s BATCH MESSAGE | The members in its single repeated <MESSAGE> field |
SYSLOG | One RFC 5424 message | The members’ common header, the first member’s timestamp, and a MSG that is the JSON array of the members’ own messages |
TOML and XML have no top-level sequence, so their containers use the single key and the single root
element; an XML member must therefore be an element. A SYSLOG frame can say its header once only
for members that share it, so records whose PRI, HOSTNAME, APP-NAME, PROCID, MSGID or
STRUCTURED-DATA differ are published in separate frames, while each member keeps its own timestamp
inside the array:
<134>1 2026-09-24T10:15:00Z app-01 checkout - - - ["<134>1 2026-09-24T10:15:00Z app-01 checkout - - - order accepted","<134>1 2026-09-24T10:15:01Z app-01 checkout - - - order shipped"]
A consumer reads a container back with the same codec: a JAQ-native codec whose ON INGESTION
unfolds the array, such as '.[]', yields the members as separate messages within the
unfolding limit, and a protobuf codec whose MESSAGE is the batch message can
unfold its repeated field, such as '.events[]'.
Unfolding Payloads
A JAQ-backed codec decodes a payload as a stream. The payload is parsed into the values its format
holds, ON INGESTION runs on each value in payload order, and every object the program yields
becomes one message, in the order the program yields it:
| Format | Values in one payload |
|---|---|
JSON | Whitespace-separated JSON values: one document, newline-delimited JSON, or concatenated JSON |
CBOR | Consecutive CBOR data items |
YAML | The documents of a YAML stream |
XML | The root element; a declaration, document type, comment, or processing instruction outside it is not a value |
TOML | Exactly one document |
PROTOBUF | Exactly one message of the declared type |
An empty JSON, CBOR, YAML, or XML payload holds no values and decodes into no messages. jq stream
semantics apply unchanged: .[] unfolds an array into one message per element, a comma yields
several messages, and select or empty yields no message for the values it rejects.
CREATE IF NOT EXISTS CODEC order_lines_codec
FROM JSON
TO SCHEMA order_line
WITH JAQ TRANSFORMATIONS ON INGESTION '.lines[] | select(.quantity > 0)';
Every output must be a JSON object that fits the internal schema: surplus keys are dropped, an
absent or null value is accepted only for an OPTIONAL field, and every value must match its
field’s type exactly. Messages carry no position of their own; a program that needs one computes
it, for example with range(length) as $i | .[$i] + {index: $i}.
A payload is decoded as a whole or rejected as a whole. A malformed value, a program evaluation
error, an output that is not an object or does not fit the schema, or more than 65,536 messages
rejects the entire payload, including the messages produced before the failure. The rejection is a
decode failure of the payload, not a route message error: it is reported as a runtime error event
and logged, MODE NO ACK continues with the next payload, and acknowledged modes retry or
redeliver it as they do any payload that fails to decode. The diagnostic names the codec, the
cause, and the zero-based position of the input value, plus the output’s position when an output
is at fault. It never quotes payload values, so a mismatched field is described by its JSON kind
and a program evaluation error omits the evaluator’s message, which is logged at trace level
instead.
The messages of one payload join one source ingest group together, so they share its
domain execution snapshot, and every one of them sees the payload’s source metadata and headers.
Each message is filtered, constructed, branched, routed, and error-handled on its own, and ingestor
metrics count messages. Source acknowledgement stays per payload: a payload is acknowledged once
every message it unfolded into has been acknowledged, a negative acknowledgement of any of them
negatively acknowledges the whole payload so that its already acknowledged messages are delivered
again with it, and a payload that unfolds into no messages is acknowledged as soon as it decodes.
ACK PARALLEL MAX <n> admits n payloads however many messages each unfolds into.
Why The Split Matters
The schema split lets Nervix:
- keep runtime typing independent from transport shape
- support multiple wire formats
- normalize awkward inbound JSON without changing the internal data model
- reshape outbound payloads during emission without changing the internal record layout
JAQ Reference
Nervix embeds jaq-core 3.1.1. For the complete language and standard-library catalogue, read
the human-readable upstream JAQ manual. The exact embedded version corresponds
to the JAQ v3.1.1 release. This page only summarizes the Nervix boundary.
Nervix Contract
- An ingestion transform runs once for every value a parsed native-format or protobuf payload
holds, and receives that value as
.. - Every object an ingestion transform yields becomes one message compatible with the internal schema. It may yield zero, one, or many objects, and one payload unfolds into at most 65,536 messages.
- An emitting transform receives one record as
.and must yield exactly one value accepted by the selected native format or protobuf message. - Nervix compiles filters with JAQ’s core, standard-library, JSON, and format functions. Standalone JAQ command-line options and file loading are not part of the codec contract.
See JAQ transformations for directionality, supported formats, exact output rules, and NSPL syntax, and Unfolding Payloads for how one payload becomes several messages.
Common Filters
| Filter | Purpose |
|---|---|
. | Preserve the current value |
.field, .nested.field | Read object fields |
{field: .source} | Construct an object |
.items | map(.value) | Transform every array element |
.items[] | select(.enabled) | Select matching elements |
tonumber, tostring | Explicitly convert JAQ values |
// | Supply an alternative value |
Common
Common guides document features that cross several NSPL surfaces and are easiest to use when their pieces are explained together. They complement the entity-focused manual chapters; they do not introduce another model category.
- Syslog groups the predefined singleton syslog wire schema, codecs that use it, socket clients, ingestor and emitter transport behavior, TLS, framing, failure semantics, and limits.
- Database Client Connection Pools groups the required
POOL SIZE MIN <n> MAX <n>clause shared by Postgres, MySQL, MongoDB, and Redis clients: accepted values, how the bounds relate to connectorCONFIG, inspection, and how a bound is changed.
Additional cross-cutting guides will live in this section.
Syslog
Nervix separates the syslog message format from syslog network transport:
- the predefined singleton
SYSLOGwire schema represents the fixed syslog message format - a codec that uses that wire schema decodes RFC 3164 and RFC 5424 messages and encodes RFC 5424 messages
- a
TYPE SYSLOGclient declares a UDP, TCP, or TLS socket endpoint - a
SYSLOG LISTENERingestor receives transport frames and passes each frame to its declared codec - a
TO SYSLOGemitter sends codec output with the selected syslog transport framing
The pieces are intentionally independent. A KAFKA TOPIC ingestor can decode a syslog payload
with a codec that uses the SYSLOG wire schema, and a SYSLOG LISTENER ingestor can pass its
frames to a JSON codec.
Pairing the format and transport pieces creates a conventional syslog receiver or forwarder.
SYSLOG is a reserved NSPL keyword.
Predefined wire schema and codec
Reference the predefined wire schema directly from a codec:
CREATE CODEC syslog_codec
FROM SYSLOG
TO SCHEMA syslog_event;
SYSLOG itself is the reference to the singleton wire schema. It has no user-defined name or
separate CREATE, SHOW CREATE, ALTER, or DROP lifecycle. A codec using it does not accept
WITH JAQ TRANSFORMATIONS or ENCODE <field> AS RFC3339 rules.
Fixed field contract
The target schema may declare any subset of these fields. Every declared field must use the exact name, type, and optionality shown here.
| Field | Required schema shape | Decode source | RFC 5424 encoding |
|---|---|---|---|
facility | U8 | PRI divided by 8 | Required for encoding; must be at most 23 |
severity | U8 | PRI modulo 8 | Required for encoding; must be at most 7 |
timestamp | DATETIME OPTIONAL | RFC 5424 timestamp or RFC 3164 timestamp | RFC 3339 UTC, or - when null or undeclared |
hostname | STRING OPTIONAL | HOSTNAME | Value, or - when null or undeclared |
app_name | STRING OPTIONAL | RFC 5424 APP-NAME or RFC 3164 TAG | Value, or - when null or undeclared |
proc_id | STRING OPTIONAL | PROCID | Value, or - when null or undeclared |
msg_id | STRING OPTIONAL | MSGID | Value, or - when null or undeclared |
structured_data | STRING OPTIONAL | Exact RFC 5424 STRUCTURED-DATA text | Validated and emitted verbatim, or - when null or undeclared |
message | STRING | MSG, including an empty message | Required for encoding |
A field outside this contract, or a field with different type or optionality, makes CREATE CODEC
fail and identifies the offending field and expected shape. Fields may be marked SENSITIVE in
the target schema; normal sensitivity propagation and explicit external-leakage rules still apply.
Fields omitted from the schema are parsed and discarded during decoding. During encoding, omitted
optional header fields and structured data become the RFC 5424 NILVALUE -.
An example schema that supports both decoding and encoding is:
CREATE SCHEMA syslog_event (
facility U8,
severity U8,
timestamp DATETIME OPTIONAL,
hostname STRING OPTIONAL,
app_name STRING OPTIONAL,
proc_id STRING OPTIONAL,
msg_id STRING OPTIONAL,
structured_data STRING OPTIONAL,
message STRING
);
CREATE CODEC syslog_codec
FROM SYSLOG
TO SCHEMA syslog_event;
Decoding
A codec using the SYSLOG wire schema detects RFC 5424 or RFC 3164 for each message. The choice
is not configurable.
- the payload must be non-empty UTF-8 after trailing carriage returns, line feeds, and NUL bytes are removed
- an absent or unparseable PRI uses priority 13: facility 1 and severity 5
- an RFC 3164 timestamp uses the receiving node’s current year and is interpreted as UTC
- RFC 5424 NILVALUE fields decode as typed nulls
structured_dataretains the exact bracketed and escaped RFC 5424 text; RFC 3164 produces nullmessagemay be an empty string
A decode failure skips that frame, records the normal decode diagnostic, and continues with the next frame.
Encoding
Encoding through the SYSLOG wire schema always produces RFC 5424 version 1 without a byte-order
mark. A codec is encode-capable only when its target schema declares facility, severity, and
message. Graph validation rejects an emitter using such a codec when any field is absent and
lists the missing fields.
Timestamps are rendered in UTC. Nanosecond values are truncated to RFC 5424’s maximum six fractional digits, and trailing fractional zeroes are omitted.
Record-specific encoding failures follow the emitter’s ON MESSAGE ERROR policy. These include:
- a facility above 23 or severity above 7
hostname,app_name,proc_id, ormsg_idcontaining characters outside printable US-ASCII or exceeding its RFC 5424 length limitstructured_datathat is neither-nor a well-formed sequence of structured-data elements
Client
A syslog client describes one direction-dependent socket endpoint:
CREATE CLIENT <name>
TYPE SYSLOG [MOUNT <resource> VERSION <n>|LATEST]
CONFIG {
'protocol' = 'udp'|'tcp'|'tls',
'addr' = '<host:port>',
...
};
One client may be referenced by both ingestors and emitters. An ingestor binds addr; an emitter
connects or sends to it.
An emitter resolves the host in addr through the node’s asynchronous resolver each time it
opens a sender. Literal IPv4 and IPv6 addresses are used directly; hosts-file names and DNS
answers follow the node’s configured resolver and TTL policy. UDP tries answers in order, binding
each socket to the answer’s IP family, and sends to the first address whose setup succeeds. TCP
and TLS try the answers in order, giving each an equal share of the time left in one 30-second
connection budget. DNS, address attempts and the TLS
handshake all spend that same budget. TLS verifies the original host from addr, whichever address
accepted the connection. A new sender after a failure resolves again, so an expired answer can be
replaced without restarting the node.
| Config key | Required | Meaning |
|---|---|---|
protocol | Yes | udp, tcp, or tls |
addr | Yes | host:port; bind address for ingestion and destination for emission |
max_message_size | No | Ingested-message byte cap; defaults to 131072 |
framing | No | TCP emitter framing: octet-counting by default or non-transparent |
tls_cert_file | Directional | TLS ingestor server certificate; optional emitter client certificate |
tls_key_file | Directional | Private key paired with tls_cert_file |
tls_ca_file | No | Ingestor client-certificate CA, or emitter server trust root |
TLS file paths use normal client resource mounts. For example:
CREATE CLIENT syslog_tls
TYPE SYSLOG MOUNT syslog_identity VERSION 1
CONFIG {
'protocol' = 'tls',
'addr' = '0.0.0.0:6514',
'tls_cert_file' = '{{ syslog_identity }}/server.crt',
'tls_key_file' = '{{ syslog_identity }}/server.key',
'tls_ca_file' = '{{ syslog_identity }}/clients-ca.crt'
};
For a TLS ingestor, tls_cert_file and tls_key_file are both required. Supplying
tls_ca_file additionally enables mutual TLS and requires every client to present a certificate
that chains to that CA. Without it, the server does not request client certificates.
For a TLS emitter, the certificate and key are optional, but must appear together when used for
client authentication. tls_ca_file adds a server trust root; platform roots are used when it is
absent. TLS verifies the server name derived from addr.
Configuration fails with a key-specific error for an unknown protocol, malformed address,
framing on UDP, non-transparent framing on TLS, a missing TLS server identity, or only one half
of an emitter client identity. TLS keys are invalid with UDP or plain TCP.
Startup diagnostics retain the failed TLS file, certificate, or key cause beneath the Syslog
configuration error, with the setting or path that failed. They do not include TLS file contents.
Ingestor
A SYSLOG LISTENER ingestor reads through a TYPE SYSLOG client. It has one delivery mode,
NO ACK, and no INSTANCES clause:
CREATE [IF NOT EXISTS] SYSLOG LISTENER INGESTOR <name>
FROM <client>
MODE NO ACK
ON QUIESCE SUSPEND
| BUFFER MAX SIZE <bytes> ON OVERFLOW DROP OLDEST|DROP NEWEST
| DROP
DECODE USING <codec>
Every live Nervix node runs the listener on the client’s configured addr. The listener is not
leader-owned and has no placement owner: leadership changes do not move or restart it. A restarted
node starts its listener again after restoring the active graph, and a node joining the cluster
starts one after receiving that graph. In Kubernetes, a Service can therefore target the syslog
port on every Nervix pod. Routes, branch construction, flush policy, and error policy follow the
normal ingestor contract.
Every received message exposes its remote socket address as optional STRING
metadata.peer_addr. For UDP this is the datagram source; for TCP and TLS it is the connection
peer. Transport metadata does not propagate through a relay unless a route assigns it to a
schema-backed field:
TO received_syslog
INHERIT ALL
SET peer_addr = metadata.peer_addr
UNBRANCHED
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG
UDP
UDP follows RFC 5426. One datagram is one frame. A datagram larger than max_message_size is
dropped and logged at debug level.
TCP
TCP follows RFC 6587. The listener accepts any number of concurrent connections and detects framing for every frame:
- a frame beginning with a digit uses octet counting
- any other frame is terminated by LF; a CR immediately before the LF is removed
- both framing forms may be interleaved on one connection
An octet count must be a positive decimal number with at most ten digits. A malformed or oversized
octet-counted frame, or an oversized non-transparent frame, closes that connection because the
stream cannot be resynchronized safely. Other connections and the listener continue unaffected.
Per-connection frame memory is bounded by max_message_size, and completed frames enter a bounded
intake queue.
A frame may arrive split across any number of reads, including inside its octet count or between its CR and LF. The listener keeps the part it has and completes the frame from the following reads; the frame, and any framing failure, is the same as when one read carries it whole.
TLS
TLS follows RFC 5425: the TCP listener behavior runs over a TLS session and frames use octet
counting. A failed handshake closes only that connection. The client-authentication behavior is
controlled by tls_ca_file as described above.
Quiesce and failures
SUSPEND stops reading. UDP datagrams may accumulate in and overflow the kernel receive buffer;
TCP and TLS apply transport backpressure and retain transport-buffered data for resume. BUFFER
and DROP continue reading and apply the normal bounded local quiesce behavior.
Syslog transport is at-most-once at intake. The listener sends no application acknowledgment, and datagrams dropped or stream data lost before intake are not recoverable.
Bind and listener-level socket failures appear as that node’s ingestor transient error and retry with the standard reconnect backoff. A failure on one node does not stop listeners on other nodes. Recovery clears the error and resets the backoff. A malformed message body is a codec decode failure and skips only that frame. For a malformed stream frame, the connection closes and its debug diagnostic retains the framing cause without quoting the frame’s payload.
Emitter
The syslog sink requires a codec and supports only NO_ACK:
TO SYSLOG <client>
MODE NO_ACK RETRY POLICY BACKOFF <duration> MAX <duration>
ENCODE USING <codec>
Header invocations are not supported. Every emitter route still requires its normal flush policy, message error policy, and general error policy. Branch identity collapses only after successful external emission.
UDP
Each encoded record is one datagram. An encoded payload above 65,507 bytes is a message error.
With an emitter BATCH clause and a SYSLOG codec, the codec writes one RFC 5424 message per
batch. Its MSG is a JSON array of the members’ complete RFC 5424 messages, and the outer header
contains the fields every member shares. Each complete batch frame is one datagram and is checked
against the same UDP limit.
TCP
The emitter keeps one persistent connection. octet-counting writes the decimal byte length, one
space, and the payload. It rejects a payload whose count needs more than ten digits before writing.
non-transparent writes the payload followed by LF; an encoded payload
that already contains LF is a message error in this mode.
TLS
The emitter uses octet-counted framing over one verified TLS connection. RFC 5425 does not permit non-transparent TLS framing. The same ten-digit count limit applies.
For every transport, success means the local socket accepted and flushed the complete frame. No remote delivery acknowledgment exists. Connection establishment and write failures are infrastructure errors: the current batch is retained, the connection is rebuilt, and delivery is retried on the declared backoff. Records accepted before a later failure in the same batch may be delivered more than once.
A missing name, silent DNS server, or unreachable answer is a connection failure. Resolving an address does not report record delivery. Shutdown or cancelled startup drops pending DNS and connection work; the physical connection budget also bounds an attempt if no cancellation occurs.
Observability and limits
Syslog ingestors and emitters use the standard DESCRIBE INGESTOR and DESCRIBE EMITTER runtime
state, transient-error, reconnect-backoff, edge-metric, and emitter-metric surfaces. SHOW CREATE
renders canonical codec, client, ingestor, and emitter declarations. Normal ALTER quiesce
classification applies. Lifecycle transitions are info-level events; per-frame and per-connection
details are debug or trace and do not include payload values.
| Limit | Value |
|---|---|
| Ingested message | max_message_size, default 131,072 bytes |
| UDP datagram intake | 65,535 bytes, further limited by max_message_size |
| UDP payload emission | 65,507 bytes |
| Octet-counting prefix | At most 10 digits |
| Concurrent TCP/TLS connections | Unbounded count; bounded per-connection frame memory and bounded intake queue |
Database Client Connection Pools
Database clients declare how many connections Nervix may open for them. The pool-capable client
types are POSTGRES, MYSQL, MONGODB, and REDIS. Every client of those types states both a
minimum and a maximum connection count as part of its definition.
HTTP clients are outside this contract, including ClickHouse, HTTP polling, Prometheus, Sentry, OTEL, SQS, Iceberg REST, and the object-store transports. Broker clients, WebSockets, Syslog, and ZeroMQ keep their transport-specific connection policies. The pool clauses are available only for the four types listed above.
Declaring the bounds
The clause is required, with its two bounds in this order, immediately after the client type and before an optional resource mount:
CREATE [IF NOT EXISTS] CLIENT <name>
TYPE POSTGRES|MYSQL|MONGODB|REDIS
POOL SIZE MIN <minimum> MAX <maximum>
[MOUNT <resource> VERSION <n>|LATEST]
CONFIG { <connector configuration> };
CREATE CLIENT postgres_main
TYPE POSTGRES
POOL SIZE MIN 2 MAX 8
CONFIG {
'addr' = 'postgresql://USER:PASSWORD@HOST:5432/DATABASE?sslmode=verify-full'
};
CREATE CLIENT redis_main
TYPE REDIS
POOL SIZE MIN 1 MAX 4
MOUNT dev_tls VERSION 1
CONFIG {
'addr' = 'rediss://USER:PASSWORD@HOST:6379/0',
'tls_ca_file' = '{{ dev_tls }}/ca.pem'
};
MIN is the number of established, usable connections the pool maintains while it has local
users. It counts idle and checked-out connections together; it does not reserve that many idle
connections in addition to active ones. MAX is the ceiling on connections Nervix manages for the
client, counting connections being established or retired as well as those in use.
A minimum of zero allows the pool to become empty while it is idle. Equal positive bounds request a fixed maintained size. A maximum of 1 is an ordinary bound, not a special case.
One pool per client, per domain, per node
A pool belongs to one named client in one domain on one physical Nervix node. Every local emitter of that client borrows from the same pool, so adding emitters does not add capacity and the bounds describe the node’s connections rather than each emitter’s. Two separately named clients have separate budgets even when their addresses match, and clients in different domains do too.
A pool is materialized only when the node has a local user that needs pooled operations. Registering a client, or replicating its definition to a node with no local user, opens no connections. Removing one user preserves the pool for the others; removing the last one closes it and releases its resource mounts.
Placement therefore decides how many pools exist. A client with POOL SIZE MIN 2 MAX 8 and local
users on three nodes maintains six connections cluster-wide and may reach 24. The bounds are not a
cluster-wide database quota, and capacity planning must also account for separately named clients
and other applications using the same database.
Borrowing and waiting
A borrower holds a connection only while it performs an external operation or its necessary metadata work. For Postgres and MySQL the unit is a bounded insert or its metadata lookup, and for Redis it is one publish and its response; a flush containing several inserts releases capacity between them so other emitters can make progress. An emitter waiting out a flush interval or a retry backoff holds no connection at all.
When capacity is exhausted the operation waits, and that wait applies backpressure through the
emitter’s existing bounded input and flush buffers. A full pool is a waiting state rather than a
failure: DESCRIBE EMITTER <name>; reports
status: WAITING
detail: waiting for a connection from client 'postgres_main' for 3s
An acquisition timeout, an inability to connect, or the loss of a connection is an infrastructure failure instead, and follows the emitter’s existing retry policy and general-error reporting.
Postgres has a 30-second acquisition deadline covering pool wait, connection establishment, authentication and validation, independent of how long an accepted query then runs. MySQL and Redis have no separate pool-wait deadline. MongoDB retains its native server-selection and operation timeouts.
What each bound counts
| Client type | Scope of POOL SIZE MIN M MAX N on one Nervix node |
|---|---|
POSTGRES | Maintain M usable connections and allow at most N across all local users of the named client. |
MYSQL | Maintain M usable connections and allow at most N across all local users of the named client. |
REDIS | Maintain M usable command connections and allow at most N across all local publishers. Each Redis Pub/Sub ingestor owns one dedicated subscription connection that is never drawn from, returned to, or charged against the command pool. |
MONGODB | Maintain M and allow at most N application connections independently for each ready MongoDB server, shared by all local users. Monitoring connections are driver-owned and counted separately. |
A Redis client used only by ingestors still declares both bounds but materializes no command pool
until a local publisher needs one. Consequently a client with POOL SIZE MIN 1 MAX 4, one local
publisher and two local Pub/Sub ingestors maintains one command connection and may use up to six
Redis connections in total: four command connections and two dedicated subscription connections.
Accepted values
Both values are unquoted decimal integers written directly in the statement.
| Bound | Accepted range |
|---|---|
MIN | 0 through 4,294,967,295 |
MAX | 1 through 4,294,967,295 |
The minimum must not exceed the maximum. The clause appears exactly once and carries each bound
once, and neither count takes a unit, an expression, a template, an unlimited value, or an implicit
default. A statement that omits the clause or a bound, writes the bounds in the other order,
repeats one, places the clause after MOUNT, or gives a count outside its range is rejected where
it is read, before any client exists. The diagnostic points at the rejected text and never prints a
credential or a complete connection address.
Bounds and connector configuration
The bounds are structured client configuration, independent of the connector’s string-valued
CONFIG. Endpoint, authentication, database, TLS, and supported session options continue to belong
to CONFIG. Connection-pool sizing is declared only through the POOL SIZE clause. Native
connector options for connection establishment, idle lifetime, and operation timeouts stay in
CONFIG and do not restate either bound.
Both counts are strongly persisted with the rest of the client definition, and
SHOW CREATE CLIENT <name>; reports them in the declared order:
CREATE CLIENT postgres_main
TYPE POSTGRES
POOL SIZE MIN 2 MAX 8
CONFIG {
'addr' = 'postgresql://USER:PASSWORD@HOST:5432/DATABASE?sslmode=verify-full'
};
Changing a bound
There is no ALTER CLIENT. Change either bound the same way as any other part of a client
definition: DROP CLIENT and CREATE CLIENT in one transaction. A client that runtime entities
reference is replaced together with them, under the existing transactional quiesce contract for
those entities.
Resources
RESOURCE is Nervix’s versioned file-distribution primitive.
A resource is an uploaded directory tree that is stored and replicated across the cluster. It is used for binary or multi-file assets that runtime nodes need locally, such as:
- ONNX models
- protobuf descriptor trees
- CSV lookup tables
- TLS certificate bundles
This chapter describes the NSPL statements and their observable behavior. See Resource Versions And Bindings for the architecture behind them: how a version is installed and completed on every node, what each binding loads and when, and how a rebinding is validated, classified, and applied.
Domain Ownership
A resource belongs to the domain it was created in, like every other entity. CREATE RESOURCE, UPLOAD RESOURCE, and DESCRIBE RESOURCE all act on the session’s selected domain, and a model resolves a resource name only against versions completed in its own domain.
The same resource name in two domains is two independent resources: they have separate version sequences, separate stored content, and separate replica state. Referring to a name that was never created in the selected domain fails with resource '<name>' does not exist, even when another domain has it.
Lifecycle
Resources are managed in two phases:
CREATE [IF NOT EXISTS] RESOURCE <name>;
UPLOAD RESOURCE <name> VERSION '<local_directory>';
DESCRIBE RESOURCE <name>;
DESCRIBE RESOURCE <name> VERSION <n>;
REBIND RESOURCE <name> TO VERSION <n>|LATEST
[FOR <kind> <name> [, <kind> <name> ...]];
CREATE RESOURCE registers the logical resource name.
With IF NOT EXISTS, creating an already-registered resource becomes a successful no-op instead of an error.
UPLOAD RESOURCE uploads a local directory recursively and returns the assigned numeric version
after every current live node incarnation has verified and atomically installed the same archive
digest. A command that references the version can run immediately through any live node after the
upload succeeds.
Clients give each administrative upload an identity and retain it across redirects and transport retries. Reusing an identity for the same user, domain, and resource returns its existing assigned version and terminal outcome. Reusing an identity with different archive bytes fails with a digest conflict. Once the client has supplied the complete declared archive and the cluster admits it, installation continues if that request disconnects. An interrupted partial body has not admitted a complete upload and can be sent again with the same identity.
DESCRIBE RESOURCE <name> reports latest as the highest completed version, or (none) when no
upload has completed. Its versions list and version details include every version whose metadata
has been published, including a version whose upload is still applying or finished with failure.
This keeps incomplete upload diagnostics visible without making that version eligible for use.
Its usages section lists every model currently bound to the resource and its pinned version.
A session client also receives the same description typed, so it never reads these facts back out
of the printed text.
DESCRIBE RESOURCE <name> VERSION <n> shows the detailed state for one version, including:
- content checksums
- total file count and size
- each node’s replica state, process incarnation, and installation diagnostics
- the models pinned to that specific version
Descriptions are observations. They are useful for diagnosis and recovery visibility, but a caller does not poll them to finish an upload.
Versioning
Versions are monotonically increasing integers assigned by the cluster leader per domain and
resource name. A resource binding may select only a completed version, and every binding names the
version it selects with a mandatory VERSION <n> or VERSION LATEST clause. An explicit
VERSION <n> fails while that upload is applying and remains unavailable if the upload fails.
VERSION LATEST selects the highest completed version, so a newer applying or failed version does
not displace the last usable one. A resource with no completed version cannot be bound.
LATEST is resolved when the statement is applied, and the stored model keeps the resolved number.
A standalone statement resolves it immediately. A statement queued in a transaction resolves it
provisionally when it is admitted, so the queued prefix can be validated, and again when COMMIT
applies it; a version that completes in between is the one COMMIT binds. The command result names
the version each LATEST resolved to, SHOW CREATE renders the stored number, and no later upload
moves an existing binding.
latest in DESCRIBE RESOURCE is the version VERSION LATEST would select at that moment.
Rebinding Existing Models
REBIND RESOURCE moves existing bindings without changing the resource catalog:
REBIND RESOURCE fraud_model TO VERSION LATEST;
REBIND RESOURCE fraud_model TO VERSION 3
FOR INFERENCER score_model, HASH MAP scores_by_id, CLIENT model_store;
Without FOR, every usage in the selected domain is included. With FOR, every member is
kind-qualified and must exist and already bind that resource. The supported kinds are VHOST,
CODEC, SIGNALING PROTOCOL, INFERENCER, WASM PROCESSOR, HASH MAP, and CLIENT. Duplicate
members are rejected, and written member order has no semantic effect.
Nervix resolves the target from one captured planning snapshot, rebuilds every selected model,
runs the same model and external-file validation used by CREATE, and commits the resulting model
replacements as one mutation batch. If one selected model fails validation, none of the bindings
move. A stopped domain still stores the replacements without a runtime pause. An already-pinned
selection succeeds at DYNAMIC, reports every selected usage as unchanged, and writes no model.
LATEST follows the same timing as other model bindings: immediate for a standalone command,
provisional during transaction admission, and resolved again when COMMIT plans the queued
statement. The result reports the number of changed and selected usages, the effective quiesce
level, and one sorted from/to line per selected model.
rebound 2 of 3 usage(s) of resource 'fraud_model' to version 3 (latest)
quiesce level: DOMAIN_PAUSE
- kind=client name=model_store from=3 to=3 unchanged
- kind=inferencer name=score_model from=1 to=3
- kind=hash_map name=scores_by_id from=2 to=3
Rotate a resource by uploading the replacement first and then moving its usages. The upload must complete everywhere before the rebind can select it:
UPLOAD RESOURCE fraud_model VERSION './models/2026-09-17';
REBIND RESOURCE fraud_model TO VERSION LATEST;
The rebinding runs at the level of the usages it moves. Rotating a VHOST certificate this way is
DYNAMIC: every node’s HTTPS listener installs the new bundle before the command succeeds,
established connections keep their session, new connections present the new certificate, and
ingestion does not pause. The refresh also applies while the domain is stopped. An inferencer or
WASM processor usage pauses only that entity, and a protobuf codec, signaling protocol, hash map, or
client mount usage pauses the domain. A WASM processor moved to another version starts every branch
without guest state. If any node’s listener cannot install the new certificate, the rebinding
fails; when none of its usages paused, as when it moves only VHOSTs, every usage keeps its previous
version.
A rebinding that leaves a WASM processor on the version it already binds moves nothing and so resets nothing. Nervix compiles each candidate module before the batch is published, so a module it cannot compile rejects the whole rebinding with every previous binding and its saved guest state still in place:
invalid WASM PROCESSOR 'counting_guest' in domain '<domain>': wasm processor 'counting_guest' module compilation failed
Upload Format
The client builds a deterministic tar archive of the directory’s subdirectories and regular files, skipping symbolic links and other special files, declares its exact size, and sends it in bounded chunks. Every node verifies the archive against its digest and the per-version limits below before it atomically installs the version, so a failed install leaves no partial version behind. An upload never changes the version an existing binding uses. See Resource Versions And Bindings for installation, transfer between nodes, and completion.
The per-version limits are configured with NERVIX_RESOURCE_MAX_ARCHIVE_BYTES, NERVIX_RESOURCE_MAX_EXTRACTED_BYTES, and NERVIX_RESOURCE_MAX_FILE_COUNT. Their defaults are 4 GiB, 16 GiB, and 1,000,000 files. Every node enforces them for each version it installs.
TLS Bundles
The first runtime integration for resources is VHOST TLS. A VHOST presents the bundle version it
binds on the HTTPS listener of every node, and REBIND RESOURCE moves it to another version
without pausing the domain.
A TLS resource bundle must contain these files at its root:
tls.crttls.keyca.crt
This matches the common cert-manager layout.
Client Config Mounts
Client configs can mount a resource version into a temporary directory at runtime and then refer to files inside that mount from normal key-value settings.
Declare the mount directly on the client:
MOUNT <resource_name> VERSION <n>|LATEST
Every mount includes a version. A number binds that exact completed resource version. With
VERSION LATEST, Nervix resolves the highest completed version when the client definition is
applied and stores the resulting number. Later uploads and restarts therefore keep using the same
files, and SHOW CREATE CLIENT renders the stored number.
Inside other client config values, Nervix renders values with a lightweight Jinja-like template language. The mounted resource path is exposed as a template variable named after the mounted resource:
{{ <resource_name> }}
Example:
CREATE IF NOT EXISTS CLIENT kafka_tls
TYPE KAFKA
MOUNT dev_tls VERSION 1
CONFIG {
'bootstrap.servers' = '127.0.0.1:9094',
'security.protocol' = 'ssl',
'ssl.ca.location' = '{{ dev_tls }}/ca.pem'
};
Nervix creates one temporary mount root per instantiated client and keeps it alive for the lifetime of the ingestor or emitter using that client.
TLS Client Config Pattern
The general pattern for TLS-enabled external clients is:
- upload a resource containing the PEM files you want to use
- mount that resource on the client with
MOUNT <resource_name> VERSION <n>|LATEST - reference mounted paths from the client config template
Example:
CREATE IF NOT EXISTS RESOURCE dev_tls;
UPLOAD RESOURCE dev_tls VERSION './tls/dev';
CREATE IF NOT EXISTS CLIENT redis_tls
TYPE REDIS
POOL SIZE MIN 1 MAX 4
MOUNT dev_tls VERSION 1
CONFIG {
'addr' = 'rediss://127.0.0.1:6380/',
'tls_ca_file' = '{{ dev_tls }}/ca.pem'
};
Common Nervix-managed TLS config keys:
tls_ca_file: PEM-encoded CA bundle used to trust the remote servertls_cert_file: PEM-encoded client certificate for mTLStls_key_file: PEM-encoded client private key for mTLS
When supported by the client type, tls_cert_file and tls_key_file must be supplied together.
Client types that currently support TLS-oriented configuration through mounted resources:
- Kafka
- HTTP
- Sentry
- OTEL
- Prometheus
- WebSockets
- MQTT
- NATS
- Pulsar
- RabbitMQ
- Redis
- SQS
- Syslog
ZEROMQ remains plain pass-through transport configuration and does not currently expose a Nervix-specific TLS helper surface.
Pulsar currently supports mounted tls_ca_file server trust configuration, but not mounted client certificate authentication.
Ingestors
Ingestors are the entry points into the runtime graph.
A typical ingestor:
CREATE BRANCH by_user
SCHEMA user_branch TTL 5m;
CREATE IF NOT EXISTS KAFKA TOPIC INGESTOR kafka_notifications
FROM kafka_main
TOPIC 'notifications'
OFFSET BY CONSUMER GROUP 'nervix_consumer'
INSTANCES 1
MODE ACK SEQUENTIAL ACK TIMEOUT 30s RETRY POLICY BACKOFF 200ms MAX 5s
ON QUIESCE SUSPEND DECODE USING notification_codec
TIMESTAMP NOW
TO notifications
INHERIT ALL
BRANCHED BY by_user
SET user_id = message.user_id
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
An ingestor statement names the connector and the entity it reads in its head, as one phrase
before INGESTOR: KAFKA TOPIC consumes a Kafka topic, HTTP ENDPOINT serves a Nervix HTTP
endpoint, and CLIENT PRODUCER admits batches that applications submit. The body after FROM
names no connector and holds only that pair’s clauses; see
Supported Ingestor Types. Completion offers each head as one item,
such as KAFKA TOPIC INGESTOR. ALTER repeats the stored ingestor’s connector and entity, so an
alteration never changes what an ingestor reads; to read another connector or entity, drop the
ingestor and create it again. DROP INGESTOR, DESCRIBE INGESTOR, SHOW CREATE INGESTOR,
SHOW INGESTORS, and relocation address an ingestor by its name alone.
CREATE [IF NOT EXISTS] <CONNECTOR> <ENTITY> INGESTOR <name>
FROM <body>
[DECODE USING <codec>]
[TIMESTAMP NOW | TIMESTAMP AT <field>]
[FILTER WHERE <expression>]
TO <relay> <route body>
[TO <relay> <route body> ...]
ON GENERAL ERROR IGNORE | LOG;
DECODE USING is required under every head except CLIENT PRODUCER, which decodes nothing and
does not accept it. A client the body names must be a client of the head’s connector, and an
endpoint must have the head’s TYPE; completion after FROM offers only those, and any other
reference is refused when the statement is applied, with a message that names both kinds, such as
requires a KAFKA client, but client 'cache' is a REDIS client. SHOW CREATE INGESTOR renders the
complete statement under its head. DESCRIBE INGESTOR prints the pair and the body on its from:
line, such as from: KAFKA TOPIC kafka_main TOPIC 'Orders' OFFSET BY CONSUMER GROUP 'Readers' MODE NO ACK ON QUIESCE SUSPEND, and SHOW INGESTORS prints the pair alone, such as from=KAFKA TOPIC.
Every ingestor defines:
- the destination relay or relays
- the codec used for decoding, or for a client ingestor the schema its batches carry
- a route-local outgoing branch declaration
- a flush policy for every destination relay
- a message error policy for every destination relay
- the timestamp source
- the connector and entity it reads, and the source body that follows
FROM - the delivery mode
- the source-specific quiesce mode and every variable it requires
- optional node-level
FILTER WHEREand route-localINHERIT/SET/WHERE
FLUSH EACH <duration> MAX BATCH SIZE <bytes> or FLUSH IMMEDIATE is required after every TO <relay>. The NSPL Overview defines the system-owned 100 µs minimum batching
window for FLUSH IMMEDIATE. It has no size boundary. Every route also requires its own
ON MESSAGE ERROR <policy>. Each route buffers and handles message-specific construction failures
independently. ON MESSAGE ERROR SEND TO uses that same route’s interval and maximum batch-size
boundaries for error-record delivery. ON GENERAL ERROR remains node-level because it handles
source or transport failures that are not tied to one message or output route.
MAX BATCH SIZE counts logical Arrow value, offset, and validity bytes. It excludes unused Arrow
buffer capacity and object overhead. In delivery modes, MAX <n> is only an in-flight window for
ACK PARALLEL; MODE NO ACK never accepts it.
At runtime, the ingestor:
- decodes inbound payloads into runtime records; a JAQ-backed codec may unfold one payload into zero or more records
- collects decoded records into a bounded source ingest group that closes once it holds 1,024 or
more messages, or earlier when the source goes quiet for 5 ms; the records of one payload always
join one group together, so a payload that unfolds past the remaining room closes its group
above 1,024. This bound is independent of every route
FLUSHpolicy and caps the first Arrow batch built from external input at 1,023 messages plus one payload’s records - executes
FILTER WHEREonce against that whole group - executes each route’s ordered construction and
WHEREprogram once against the surviving group - resolves the concrete branch group from the referenced
CREATE BRANCH - accumulates decoded rows independently for every matching destination and branch group
- writes each route’s buffered rows when its configured interval or size boundary fires, when the
documented
FLUSH IMMEDIATEsystem timeout expires, or when the domain is force-flushed by a model alteration, a domain pause, or a planned ownership handoff
Branch execution receives these completed Arrow batches and does not buffer them behind another flush policy.
Source acknowledgement is per payload. An acknowledged delivery mode acknowledges a payload once
every record decoded from it has been acknowledged on every route it reached, and a negative
acknowledgement of any of those records negatively acknowledges the payload.
ACK PARALLEL MAX <n> windows count payloads. A client ingestor’s payload is one submitted batch.
Timestamp selection and admission use one domain execution snapshot when the source group is
delivered. TIMESTAMP NOW records that snapshot. TIMESTAMP AT <field> and connector-owned event
timestamps preserve the external instant exactly; TIME RATE never scales them. The internal low
and high watermarks initially equal the selected event time, so source event time remains distinct
from delivery and observation time throughout the graph. The 5 ms source-idle close and the
FLUSH IMMEDIATE 100 µs minimum are physical monotonic waits. FLUSH EACH and paced admission use
domain-logical time. See Domains And Time.
For a decoded group, timestamp selection and paced admission operate on Arrow columns. The group
keeps admitted rows together; each rejected or missing event timestamp follows the ingestor route’s
ON MESSAGE ERROR policy with code validation and operation admit, preserving that row’s ACK
and source metadata. A batch containing both kinds can therefore deliver its accepted rows while
its rejected rows reach the configured error route.
Delivery Modes
Every source body declares its delivery mode with MODE, polled and listener sources included:
NO ACKis one composed phrase. Nothing waits on an acknowledgement, and it takes no variables.ACK SEQUENTIAL ACK TIMEOUT <duration> RETRY POLICY BACKOFF <duration> MAX <duration>lets one payload at a time await its acknowledgement.ACK PARALLEL MAX <n> BATCH TIMEOUT <duration> ACK TIMEOUT <duration> RETRY POLICY BACKOFF <duration> MAX <duration>lets at mostnpayloads await their acknowledgement;BATCH TIMEOUTis the longest wait for a partial batch. ACLIENT PRODUCERingestor writesACK PARALLEL MAX <n> ACK TIMEOUT ...withoutBATCH TIMEOUT.
Each entity accepts only the modes it can honor, and completion offers only those:
| Entity | MODE forms |
|---|---|
KAFKA TOPIC, PULSAR TOPIC, MQTT TOPIC | NO ACK, ACK SEQUENTIAL ..., ACK PARALLEL ... |
RABBITMQ QUEUE, SQS QUEUE | ACK SEQUENTIAL ... |
CLIENT PRODUCER | ACK SEQUENTIAL ..., ACK PARALLEL ... without BATCH TIMEOUT |
HTTP REQUEST, HTTP ENDPOINT, WEBSOCKETS ENDPOINT, WEBSOCKETS CONNECTION, REDIS PUBSUB, NATS SUBJECT, ZEROMQ PULL, SYSLOG LISTENER, PROMETHEUS QUERY | NO ACK |
In an ACK mode an ingestor acknowledges a message on its transport only once every route and
error delivery the message unfolded into has completed, and it reports that progress only once
the transport confirmed the acknowledgement as far as the entity can: an SQS QUEUE waits for the
broker’s answer to its deletion, a RABBITMQ QUEUE for its acknowledgement to be written to the
connection, and a KAFKA TOPIC, PULSAR TOPIC or MQTT TOPIC only for its client to queue the
acknowledgement. An acknowledgement the transport fails to confirm is a source error that
DESCRIBE INGESTOR shows as the transient error until a later acknowledgement is confirmed, and the
messages are delivered again. A node that stops gracefully waits for its ingestors’ outstanding
confirmations before it closes them; see
Shutdown And Recovery.
Altering Ingestors
ALTER names the connector and entity of the stored ingestor in its head and applies one or more
comma-separated operations in written order:
ALTER <CONNECTOR> <ENTITY> INGESTOR <ingestor>
SET FROM <complete body of the head's connector and entity>
| SET QUIESCE <quiesce body>
| SET DECODE USING <codec>
| SET TIMESTAMP NOW
| SET TIMESTAMP AT <field>
| DROP TIMESTAMP
| SET FILTER WHERE <expression>
| DROP FILTER WHERE
| ADD ROUTE TO <relay> <full ingestor route body>
| DROP ROUTE TO <relay>
| REPLACE ROUTE TO <relay> <full ingestor route body>
| SET GENERAL ERROR IGNORE
| SET GENERAL ERROR LOG
[, ...];
A head whose connector and entity differ from those the stored ingestor reads is refused, for
example with the stored ingestor reads HTTP ENDPOINT, so ALTER KAFKA TOPIC INGESTOR cannot alter it. An alteration therefore never changes what an ingestor reads; drop the ingestor and create it
again under the other head instead.
SET FROM accepts the complete body that follows FROM in a CREATE statement with the same
head, and only that pair’s body. Change a client or endpoint, a Kafka topic or offset mode,
INSTANCES, the delivery mode, or source-specific subscription settings by supplying that complete
body, including its required ON QUIESCE clause. SET QUIESCE changes only the quiesce mode and
its variables. It parses every quiesce body, and the stored source refuses one it does not honor,
for example with HTTP ENDPOINT ingestors do not support ON QUIESCE SUSPEND; there is no
DROP QUIESCE operation. SET DECODE USING exists under every head except CLIENT PRODUCER,
which decodes nothing. ADD ROUTE appends a route, while REPLACE ROUTE preserves the matched
route’s position. Duplicate routes to the same relay remain legal, so DROP ROUTE and
REPLACE ROUTE reject an ambiguous target. An ingestor must retain at least one route.
Every ingestor alteration uses ENTITY_PAUSE. Nervix quiesces only the affected ingestor instances
on all live nodes and waits for their already accepted in-flight work to drain before commit. That
work includes route output still held behind a FLUSH EACH interval: the hold force-flushes those
branch buffers, so a slow or stopped domain clock cannot keep accepted rows out of the drain.
Schedule application starts the desired source configuration on its assigned nodes; unrelated graph
paths keep flowing. The quiesce mode in effect when the hold begins governs that hold; a mode changed
by the alteration applies to later pauses. A drain or cutover failure leaves the candidate unapplied
and releases the old source. The transient hold also expires automatically, so loss of the
coordinating leader cannot leave an ingestor quiesced indefinitely.
Quiesce Modes
Every source body ends with a required ON QUIESCE <body>, which DECODE USING follows under every
transport head. The quiesce level says which runtime work pauses for an alteration; an
ingestor’s quiesce mode says what its external source experiences while that ingestor is paused.
There is no default.
The shared mode vocabulary is:
SUSPENDstops taking payloads. Data waits in the external source where that source retains it; the source’s retention, queue, or session limits continue to run. ZeroMQ instead keeps its socket open and stops reading, leaving transport flow control and the peer’s high-water marks to govern the backlog. Polling ingestors skip polls.SUSPENDhas no variables.BUFFER MAX SIZE <bytes> ON OVERFLOW DROP OLDEST|DROP NEWESTkeeps a push connection active and retains raw payloads plus their ingest context in a per-instance volatile buffer.MAX SIZEmust be positive. A payload larger than the bound is dropped, and a full buffer applies the declared side of the overflow policy. Every discard is counted.DROPkeeps the source connection and protocol maintenance active but discards and counts every payload received during the quiesce.REJECT RETRY AFTER <duration>is available only toHTTP ENDPOINTandWEBSOCKETS ENDPOINT. HTTP requests and new WebSocket upgrades receive 503; HTTP includesRetry-After, rounded up to whole seconds. Established WebSockets close with code 1013 (Try Again Later).- Endpoint buffering, for
HTTP ENDPOINTandWEBSOCKETS ENDPOINT, isBUFFER MAX SIZE <bytes>withoutON OVERFLOW. Requests that fit receive 202 and are held. A full buffer, or a body that cannot fit, is rejected with HTTP 503 or a WebSocket 1013 close; an acknowledged body is never discarded by buffer policy.
A source accepts only the modes it can honor, and completion of its body offers only those.
SET QUIESCE parses every body and refuses one the stored source does not honor:
| Entity | Allowed ON QUIESCE bodies |
|---|---|
KAFKA TOPIC, PULSAR TOPIC, RABBITMQ QUEUE, SQS QUEUE | SUSPEND |
MQTT TOPIC | SUSPEND, BUFFER ... ON OVERFLOW ..., DROP; SUSPEND requires SESSION PERSISTENT QOS 1 |
NATS SUBJECT, REDIS PUBSUB, WEBSOCKETS CONNECTION | BUFFER ... ON OVERFLOW ..., DROP |
ZEROMQ PULL, SYSLOG LISTENER | SUSPEND, BUFFER ... ON OVERFLOW ..., DROP |
HTTP REQUEST, PROMETHEUS QUERY | SUSPEND, BUFFER ... ON OVERFLOW ... |
HTTP ENDPOINT, WEBSOCKETS ENDPOINT | REJECT RETRY AFTER ..., BUFFER MAX SIZE ... |
CLIENT PRODUCER | SUSPEND |
The mode is consulted for resumable model-alteration ENTITY_PAUSE holds, DOMAIN_PAUSE batches,
and memory-pressure shedding. STOP and DROP INGESTOR terminate the source session. Unexpected
owner loss also terminates it immediately; volatile quiesce buffers and in-memory ACK state do not
survive, so recovery depends on the external source’s retention and redelivery contract.
Node drain, graceful-shutdown drain, placement relocation, and explicit relocation with
RELOCATE use a planned ownership handoff.
That hold deliberately ignores ON QUIESCE: polling and new endpoint admission stop, an already
admitted payload continues through its routes, and connected-source policy does not buffer or drop
new work on behalf of the move. Nervix waits for the moved ingestor’s admitted ACK roots to resolve
before it commits the new owner. Kafka offsets, broker acknowledgements, queue deletes, and the
corresponding downstream attached work therefore complete on the former owner before its source
session stops. The destination starts only after the schedule revision commits.
Only moved ingestors take this hold. Every other ingestor keeps its session open and continues ingesting, including ingestors on the former and destination cluster nodes. A failed fence or drain releases the former source and leaves the schedule unchanged.
Graceful shutdown then stops intake the same way on every ingestor of the terminating node,
including endpoint ingestors, which serve on every node, and ingestors whose scheduled owner did not
move. It also ignores ON QUIESCE. Already admitted payloads continue through their routes, route
buffers are force-flushed whatever their FLUSH EACH cadence, and Nervix waits for their ACK roots
to resolve before the source session stops, so offsets, acknowledgements, and deletes complete for
work that reached its sinks. A root still unresolved when the drain timeout passes is negatively
acknowledged, and the source’s redelivery contract applies. A raw quiesce buffer is outside runtime
graph work, so a shutdown discards what it retained instead of replaying it. See
Planned Ownership Handoffs And Failover
and Shutdown And Recovery for the per-source acknowledgement and
redelivery contract at shutdown.
Ordinary model-alteration quiesce still does not wait for downstream ACK chains. Already admitted
route batches continue downstream, and a source item not yet acknowledged, committed, or deleted is
eligible for redelivery on resume. A connected mode’s raw quiesce buffer is outside runtime graph
work. Ordinary relay backpressure is also independent of ON QUIESCE.
Buffered payloads drain in arrival order per instance before that instance admits new live intake.
There is no total order across instances. Decode, timestamp selection, filters, construction,
routing, and domain pacing use the configuration in effect at delivery, so a codec installed by an
alteration decodes payloads buffered during its hold. TIMESTAMP NOW therefore records delivery
time. Bounds are per instance, and an endpoint has an independent buffer on every serving node.
A payload stays in its buffer, counted in the buffered records and bytes and within MAX SIZE,
until its messages enter their ingest group, and overflow never discards it while it drains. Its
sender was already answered or its source has moved past it, so when its codec’s ON INGESTION
transformation finds the node’s extension workers full, the payload waits for them rather than
being refused, ahead of work that asks after it, and nothing behind it drains first. A new quiesce
or a shutdown ends that wait and leaves the payload at the front of the buffer. A payload its codec
rejects is reported and dropped, as any failure after it was retained is.
Memory pressure never adds buffered bytes. During memory-pressure quiesce, a BUFFER push source
drops and counts new payloads, polling sources skip polls, and endpoints reject. Payloads already
buffered by an overlapping alteration remain retained. DROP continues to discard and SUSPEND
continues to hold at the source.
DESCRIBE INGESTOR renders quiesce:, quiesce state: none|entity hold|ownership handoff|domain pause|memory pressure, status: quiesced for every active hold, and the four quiesce counters.
It also renders nervix_ingestor_transport_redeliveries_total, the acknowledgements and rejections
the node discarded after a RabbitMQ, SQS or MQTT source reconnected, because the broker redelivers
what the lost connection did not acknowledge; see
Connector Crates And The Connector Contract. SHOW CREATE round-trips the
full clause.
When The Extension Workers Are Full
A codec with an ON INGESTION transformation unfolds
every payload on the node’s extension workers. When every worker is busy and the class’s wait queue
is full, what happens to a payload depends on whether its sender can present it again:
- An acknowledged delivery mode rejects the batch, and its source delivers it again on its
RETRY POLICY. - An endpoint ingestor answers an HTTP body 503 without
Retry-Afterand closes an established WebSocket with 1013, so the sender sends it again. - A payload that
BUFFERretained waits for a place when its buffer drains; see Quiesce Modes. - A paced source has already moved past its poll, so the poll waits for a place. Waiting delays the next poll, and the domain cadence coalesces the occurrences it missed into one poll at the newest due instant.
- A source read in
MODE NO ACKcannot present a payload again either. The payload waits for a place when holding the source’s loop keeps its transport connected with bounded memory. Otherwise it is refused: the ingestor reports the refusal as an error and counts the payload innervix_ingestor_unfolding_refused_total.
| Entity | A payload the extension workers cannot take now | Why |
|---|---|---|
HTTP REQUEST, PROMETHEUS QUERY | Waits | Holding the loop only delays the next poll. |
NATS SUBJECT | Waits | The client keeps its connection and reads the subscription while the loop waits, holding up to 65,536 messages before it drops the newest as a slow consumer. |
ZEROMQ PULL | Waits | Unread messages stay in the socket’s kernel buffers, and then the pushing peers wait. Nothing is dropped. |
SYSLOG LISTENER | Waits | The listener stays bound. The kernel drops UDP datagrams beyond the socket’s receive buffer, and TCP and TLS senders wait once the listener’s 64-frame queue is full. |
MQTT TOPIC | Refused and counted | The client sends its keep-alive only while the loop reads, so the broker closes the connection once one and a half keep-alive intervals pass without a packet from it, and what is published until the source reconnects is lost. |
REDIS PUBSUB | Refused and counted | The client reads every published message into an unbounded queue of its own, so a held loop would grow the node’s memory without limit. |
WEBSOCKETS CONNECTION | Refused and counted | The client answers the server’s pings only while the loop reads, so a server that pings would close the connection and lose what it sent until the source reconnects. |
KAFKA TOPIC in MODE NO ACK | Refused and counted | A consumer-group member that stops polling for max.poll.interval.ms leaves its group, and an instance reading domain offsets would keep its partitions assigned through an ownership handoff. |
PULSAR TOPIC in MODE NO ACK | Refused and counted | A consumer whose receive queue fills cannot complete its close, so a later suspension or stop would wait on it. The refused message stays unacknowledged until the consumer reconnects, when the broker delivers it again. |
A wait never delays a stop or a new quiesce. Stopping the ingestor ends the wait and drops the
payload with the rest of the ingestor’s in-flight input. A new quiesce decides on the payload again,
as on input that arrives while quiesced: BUFFER retains it, DROP drops it and counts it in
nervix_ingestor_quiesce_dropped_total, and a suspension, under which a payload the source already
handed over still dispatches, lets it wait on until the workers have room. While a payload waits,
the ones behind it stay with the transport, in order.
Branch Semantics
Ingestors are where external mixed flows enter branch-isolated processing. Every route independently constructs a concrete branch or becomes unbranched. The branch declaration owns the key schema, TTL, and optional LRU eviction policy:
CREATE BRANCH <branch> SCHEMA <schema> TTL <duration>declares the branch key schema and lifetimeBRANCHED BY <branch> SET field = expression, ...constructs the route’s key after output finalization and route filteringMAX INSTANCES <n> EVICT LRUmay be added to cap active concrete branch instances and evict the least recently used branch when capacity is reachedUNBRANCHEDproduces an absent branch key; it is not encoded as an empty string or synthetic root identifier- the named branch defines both the branch identity and its key shape; downstream relays and branch-preserving processors must reference that same branch name
- decoded rows are appended to matching destination relays inside that group’s branch
- downstream normal processors keep the same group until a
REINGESTORorEMITTERboundary
Per-group behavior such as downstream deduplication, reordering, and window aggregation stays scoped to that branch.
Batching follows that same rule:
- an ingestor buffers independently per concrete branch group
UNBRANCHEDproduces one root branch and one batcher
Client-backed ingestors can use resource-mounted client config values for TLS material and other file-based settings. See Resources.
Value Construction and Filters
Ingestors may declare an optional arrival filter and per-route construction clauses:
CREATE BRANCH by_tenant
SCHEMA tenant_branch TTL 5m;
CREATE IF NOT EXISTS HTTP ENDPOINT INGESTOR notifications_in
FROM ingress MODE NO ACK
ON QUIESCE BUFFER MAX SIZE 1MiB DECODE USING notification_codec
FILTER WHERE input.active
TO notifications
INHERIT ALL EXCEPT raw
SET amount = message.amount + 1,
normalized = lower(input.raw)
WHERE output.tenant = 'acme'
BRANCHED BY by_tenant SET tenant = message.tenant
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
FILTER WHERE runs after codec decoding and before route processing. Each TO route starts with an
empty output, applies INHERIT and ordered SET, finalizes the output, evaluates its WHERE, and
then constructs its own branch key.
Supported blocks:
INHERIT ALL,INHERIT ALL EXCEPT ..., or explicitINHERIT field, ...copies compatible same-named decoded fieldsSET <field> = <expr>, ...initializes route output fields in orderWHERE <expr>: drops rows whose predicate is false or null
General notes:
SETis a single clause with comma-separated assignments- assignments execute left to right; repeated destination fields are allowed and later expressions read the latest preceding value
- later assignments may read earlier output values; repeated targets are valid
message.fieldreads the working message;input.fieldalways reads the original decoded row andoutput.fieldrequires prior initialization- route
WHEREsees finalized output through bare fields,message, oroutput, without fallback - relay-qualified fields and
UNSETare invalid - all node expressions use explicit typing; there is no implicit cast insertion
- nested predicates and nested/chained builtin calls are supported
- leader-side validation requires every required output and branch field to be initialized
- public Models store structured expressions; the runtime never reparses NSPL
- source-specific transport metadata may be exposed through
metadata.<field>when the ingestor supports it;KAFKA TOPICprovidesmetadata.topic,metadata.partition, andmetadata.offset, whileSYSLOG LISTENERprovidesmetadata.peer_addr - supported sources expose transport headers through
read_header(name)andread_headers(name); header names may be anySTRINGexpression, and these functions may be used in both top-levelFILTER WHEREand per-route expressions
Useful built-ins include string, null-handling, numeric, regex, and contextual functions such as lower, coalesce, abs, regexp_like, now, and uuid_v7.
See Expression Functions for the full function reference.
Common expression patterns include:
- nested conditions such as
(active AND amount > 5) OR NOT flagged - chained calls such as
lower(trim(raw)) - arithmetic expressions such as
(amount + fee) / divisor - explicit casts such as
raw AS INT64, and tolerant conversions such asTRY_CAST(raw AS INT64), which yield a typed null instead of failing the message - typed reads of JSON text held in a
STRINGfield, such asJSON_VALUE(raw, '$.order.id' AS INT64); see JSON Documents
The expression type surface matches the full Nervix internal schema type set:
U8,I8,U16,I16,U32,I32,U64,I64F32,F64BOOL,STRING,DATETIME
DATETIME is the internal logical timestamp type. JSON or AVRO string wire values require ENCODE <field> AS RFC3339 on the codec before they can decode into the named DATETIME field.
Header Context
Some ingestors receive additional source data alongside the decoded payload body. They expose it through two functions:
read_header(name)returns the first value as an optionalSTRING, orNULLwhen the header is absentread_headers(name)returns every value in transport order as requiredLIST<STRING>, or an empty list when the header is absent
The name argument can be any expression that returns STRING. Both functions are available in top-level FILTER WHERE and in per-route SET / WHERE expressions for these sources:
HTTP ENDPOINTandHTTP REQUESTexpose HTTP headers with UTF-8 valuesWEBSOCKETS ENDPOINTexposes UTF-8 headers from the opening HTTP upgrade requestKAFKA TOPICexposes Kafka record headersNATS SUBJECTexposes message headersPULSAR TOPICexposes message propertiesRABBITMQ QUEUEexposes AMQP message headers, converting typed AMQP values to stringsSQS QUEUEexposes message attributes, converting string, number, and binary values to strings
The same captured source envelope remains available while a supported ingestor constructs an
ON MESSAGE ERROR SEND TO record, so its error-route SET may also call read_header and
read_headers.
Header values are non-sensitive strings and do not propagate through relays unless explicitly assigned to schema-backed fields:
SET route = read_header(lower(input.route_header))
WHERE read_header("tenant") = output.tenant
MQTT TOPIC, REDIS PUBSUB, PROMETHEUS QUERY, SYSLOG LISTENER, ZEROMQ PULL,
WEBSOCKETS CONNECTION, and CLIENT PRODUCER ingestors do not support header reads. Leader-side
validation rejects these functions for a source without header support, with a message that names
the source’s phrase, such as MQTT TOPIC ingestors do not support read_header or read_headers.
TLS Client Configuration
For outbound ingestor clients, TLS is configured on the CLIENT, not on the INGESTOR.
General pattern:
CREATE [IF NOT EXISTS] CLIENT <name>
TYPE <kind>
MOUNT <tls_resource> VERSION <n>|LATEST
CONFIG {
...
'tls_ca_file' = '{{ tls_resource }}/ca.pem'
};
Transport-specific schemes and keys:
KAFKA: pass-through to librdkafka. Typically set'security.protocol' = 'ssl','ssl.ca.location' = '{{ tls_resource }}/ca.pem', and if needed'ssl.certificate.location'plus'ssl.key.location'.HTTP: use anhttps://...endpoint. Nervix honorstls_ca_file,tls_cert_file,tls_key_file, and optionaltimeout_ms.PROMETHEUS: use anhttps://...addr. Nervix honorstls_ca_file,tls_cert_file,tls_key_file, and optionaltimeout_ms.WEBSOCKETS: use awss://...endpoint. Nervix honorstls_ca_file,tls_cert_file,tls_key_file.MQTT: usemqtts://...inaddr. Nervix requirestls_ca_filefor server trust and also supportstls_cert_fileplustls_key_filefor mTLS.NATS: usetls://...inaddr. Nervix honorstls_ca_file,tls_cert_file,tls_key_file.PULSAR: usepulsar+ssl://...inaddr. Nervix honorstls_ca_fileand optionaltls_allow_insecure_connectionplustls_hostname_verification_enabled. Pulsar client certificate authentication is not currently exposed.RABBITMQ: useamqps://...inaddr. Nervix honorstls_ca_file.REDIS: userediss://...inaddr. Nervix honorstls_ca_file,tls_cert_file,tls_key_file.SQS: use anhttps://...endpoint. Nervix honorstls_ca_file. This is primarily useful for SQS-compatible local/test endpoints.SYSLOG: select'protocol' = 'tls'. An ingestor requirestls_cert_fileandtls_key_file; optionaltls_ca_fileenables required client-certificate verification.
HTTP REQUEST and PROMETHEUS QUERY ingestors resolve endpoint names through the node’s
configured DNS resolver.
A client timeout_ms covers name resolution, connection establishment, TLS, and the response.
The endpoint name remains the HTTP authority and HTTPS certificate name after resolution.
RabbitMQ resolves the host of its addr the same way and verifies an amqps broker certificate
against that host; see RabbitMQ.
Redis Pub/Sub also resolves its addr hostname through the node resolver for each dedicated
subscription connection; see Redis Pub/Sub.
MQTT resolves the host of its addr through the node resolver for every connection and verifies
an mqtts broker certificate against that host; see MQTT.
Example Kafka TLS client:
CREATE IF NOT EXISTS CLIENT kafka_tls
TYPE KAFKA
MOUNT dev_tls VERSION 1
CONFIG {
'bootstrap.servers' = '127.0.0.1:9094',
'security.protocol' = 'ssl',
'ssl.ca.location' = '{{ dev_tls }}/ca.pem'
};
Example HTTP TLS client:
CREATE IF NOT EXISTS CLIENT http_tls
TYPE HTTP
MOUNT dev_tls VERSION 1
CONFIG {
'endpoint' = 'https://127.0.0.1:18443/http/notifications',
'method' = 'GET',
'timeout_ms' = 5000,
'tls_ca_file' = '{{ dev_tls }}/ca.pem'
};
Client Ingestors
The Client Ingestors And Emitters architecture chapter follows a complete native graph through admission, application ACK, credit, forwarding and recovery.
A client ingestor, declared under the CLIENT PRODUCER head, admits typed batches that applications
submit through a Nervix session instead of reading an external system. There is no
CREATE CLIENT, codec, DECODE USING, transport header, or NO ACK mode: a submitted batch
already carries the ingestor’s input schema, and every batch is acknowledged.
CREATE SCHEMA order_in (
region STRING, order_id STRING, amount I64, card STRING SENSITIVE
);
CREATE SCHEMA region_key (region STRING);
CREATE BRANCH by_region SCHEMA region_key TTL 5m;
CREATE CLIENT PRODUCER INGESTOR orders_in
FROM SCHEMA order_in
MODE ACK PARALLEL MAX 4 ACK TIMEOUT 30s RETRY POLICY BACKOFF 100ms MAX 1s
ON QUIESCE SUSPEND
TIMESTAMP NOW
TO orders_by_region
INHERIT region, order_id, card
SET amount_cents = input.amount * 100
BRANCHED BY by_region SET region = message.region
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
CREATE [IF NOT EXISTS] CLIENT PRODUCER INGESTOR <name>
FROM SCHEMA <schema>
MODE ACK SEQUENTIAL | ACK PARALLEL MAX <n>
ACK TIMEOUT <duration>
RETRY POLICY BACKOFF <duration> MAX <duration>
ON QUIESCE SUSPEND
Every clause is required and there are no defaults:
SCHEMAnames the input schema. A batch must carry exactly its fields, in order, with their types and optionality, and a producer must declare exactly those fields, including which areSENSITIVE, before it may submit anything.ACK SEQUENTIALadmits one batch at a time;ACK PARALLEL MAX <n>lets at mostnadmitted batches await their acknowledgement. The window belongs to the ingestor’s one execution and every producer shares it, so opening more producers never widens it. Producers with queued batches take the window in turn.ACK TIMEOUTbounds how long an admitted batch may make no acknowledgement progress. When it passes, the batch is reported as failed withack_timeout; its admitted work is not cancelled and may still complete. Downstream work that reports it is alive keeps the batch from timing out.RETRY POLICY BACKOFF <duration> MAX <duration>is the physical backoff a producer applies before sending again a batch that was refused only temporarily. It starts atBACKOFFand doubles up toMAX, which must not be shorter thanBACKOFF. Producers are told it when they open.ON QUIESCE SUSPENDis the only quiesce mode. Admission stops for the hold: producers are told that admission is suspended, queued batches that were not admitted yet are refused assuspended, and the producer keeps them to send again once admission reopens.
Timestamp selection, FILTER WHERE, route construction, WHERE, branch construction, route
FLUSH, ON MESSAGE ERROR, and ON GENERAL ERROR behave as for every ingestor; message and
input read the submitted row. Client batches carry no transport headers or metadata, so
read_header, read_headers, and metadata.* are unavailable. The ingestor executes on its one
scheduled cluster node. A producer is opened on a session that any live node may serve, and that
node forwards the producer’s batches to the ingestor’s node; a console session, like every console
session, is served only by the leader.
ALTER CLIENT PRODUCER INGESTOR ... SET FROM SCHEMA ... replaces the complete source body, and
SET QUIESCE SUSPEND restates the only quiesce mode. SHOW CREATE INGESTOR renders the
CLIENT PRODUCER head and the whole FROM SCHEMA body.
Submitted Batches
A batch is one canonical Arrow IPC stream: the schema message, exactly one record batch, and the end-of-stream marker. It is uncompressed and has no dictionary or extension encodings, and its Arrow schema is exactly the input schema in Nervix’s Arrow representation of each type, with no field metadata. A batch carries at most 65,536 rows and at most the bytes one submission may carry, which is the smaller of the producer’s granted bytes and what one session frame holds. The node that executes the ingestor validates the whole batch, its structure, bounds, and every column, before any row is admitted; a batch that fails is refused whole and has no effect on the graph. Nothing is coerced, cast, or partially admitted.
Outcomes
One batch is one source acknowledgement unit. It has exactly one terminal outcome:
| Outcome | Meaning | Cause |
|---|---|---|
| not admitted | No row entered the graph. | invalid batch: <defect>, suspended, busy, draining, producer ended, credit exceeded |
| completed | Every route and acknowledging sink its rows reached confirmed them under the graph’s rules. | — |
| processing failed | The batch was admitted and its acknowledgement failed. Some of its effects may have happened. | ack_timeout, rejected |
| unknown outcome | The batch may have been admitted and processed, but no terminal result can be established. | interrupted, owner_lost; a client whose session ended adds session_lost |
Transport receipt is never an outcome. A batch completes under the normal acknowledgement rules: a
row a filter drops is resolved; a detached boundary resolves where it detaches; and an
acknowledging sink resolves its rows at its own success boundary, such as the complete response
headers of an HTTP emitter. The defects of an invalid batch are malformed, unexpected message,
compressed, schema mismatch, not one batch, too many rows, too large, and invalid data.
A row that fails on a route of an admitted batch is handled by that route’s ON MESSAGE ERROR
policy exactly as for any other ingestor: IGNORE acknowledges it, LOG negatively acknowledges
it, so its batch fails processing as rejected, and SEND TO acknowledges it once its error record
is published to the error relay. The other rows of the batch are processed either way.
Only suspended and busy are temporary: sending the same batch again on the same producer may
succeed, and the Rust client does so on the declared backoff. Every other outcome is final for that
attempt. Nervix never replays a batch that failed or whose outcome is unknown; replaying it is the
application’s decision, and it may duplicate the batch’s effects. The application therefore keeps
its replayable source data until a batch completes.
Producers
A producer is opened on a session with an explicit domain and ingestor, the fields it expects, and
the credit it asks for: how many batches and how many bytes it may have outstanding. The open is
answered with the input schema, the domain’s START generation, the identity of the endpoint
contract, the attachment, the policy above, the granted credit, and whether admission is open now.
A later USE does not move a producer. An open is refused, with nothing left attached, when the
domain does not exist or is stopped, the ingestor does not exist, reads a transport, or is not
running on its scheduled node, the expected fields differ, the credit is larger than one producer
may ask for, the session or node has no room left, or the session holds a transaction.
| Limit | Value |
|---|---|
| Producers per session | 32 |
| Outstanding bytes per session | 32 MiB |
| Outstanding bytes per node, including batches retained for forwarded producers | 128 MiB |
| Batches one producer may ask to have outstanding | 1,024 |
| Rows per batch | 65,536 |
A batch holds its share of the credit from the moment it is sent until its outcome is observed. A
batch sent beyond the credit is refused as credit exceeded without reaching the graph, and the
producer is ended as a protocol violation; the batches it already submitted still receive their
outcomes.
A producer stays attached while the ingestor’s endpoint contract holds. The contract is the
ingestor as producers see it — its input schema, mode, timestamp, filter, routes, error policies,
and the branch declarations its routes construct — without the routes’ FLUSH cadence. The server
ends a producer, as the last event about it, with one of these reasons:
| Reason | When |
|---|---|
endpoint changed | An alteration changed the endpoint contract, or made the ingestor read a transport. |
endpoint removed | The ingestor or its domain was dropped. |
domain stopped | The domain stopped, or a new START replaced the generation the producer attached under. |
relocated | A planned ownership handoff moved the ingestor to another node. |
shutting down | The serving node, or the node that executes the ingestor, is shutting down. |
owner lost | The node that executes the ingestor, or the connection to it, was lost. |
protocol violated | The producer sent a batch beyond its credit. |
An ended producer’s queued batches are refused as producer ended, and its admitted batches whose
acknowledgement is unresolved have an unknown outcome. That holds when the node that executes the
ingestor dies or stops answering, too: the node that forwards a producer’s batches to it clears each
batch before it may be admitted, so after the loss it refuses every batch it never cleared as
producer ended, and only the cleared ones have an unknown outcome with cause owner_lost. A new
producer can be opened as soon as the ingestor runs again. An alteration that keeps the contract, such as one that changes only a route’s
FLUSH, suspends admission for its hold and reopens it afterwards with every producer attached. A
planned ownership handoff stops intake for good on the former owner: batches that arrive are
refused as draining, admitted ones complete there, and its producers end as relocated once the
new owner is committed.
Observing Client Ingestors
SHOW INGESTORS lists every ingestor of the domain with the connector and entity it reads, its
codec or schema, the node that executes it, and its state. A client ingestor adds whether it admits
batches, its attached producers, the batches and bytes they have outstanding, and the batches
holding a slot of its window:
orders_in from=CLIENT PRODUCER schema=order_in owner=node-2 status=running admission=open producers=2 outstanding_batches=3 outstanding_bytes=2412 admitted_batches=2
DESCRIBE INGESTOR renders from: with the head’s phrase and the complete body, such as
from: CLIENT PRODUCER SCHEMA order_in MODE ACK PARALLEL MAX 4 ACK TIMEOUT 30s RETRY POLICY BACKOFF 100ms MAX 1s ON QUIESCE SUSPEND, and schema:, mode:, owner:, admission:, producers:,
forwarded producers:, outstanding batches:, outstanding bytes:, and admitted batches:. The
node that executes the ingestor also exports the same counts and every answered batch as metrics;
see Metrics And Observability.
External Names
An ingestor names the external entity it reads with a string literal: a topic, consumer group,
subscription, queue, channel, subject, queue group, or MQTT topic filter. Nervix keeps the name
exactly as written, letter case included, and sends it to the external system as written. Each
entity validates its names when CREATE or ALTER applies, by the external system’s own rule
where it has one:
| Entity | Name | Rule |
|---|---|---|
KAFKA TOPIC | TOPIC | 1 to 249 ASCII letters, digits, ., _, and -, and neither . nor ..: the broker’s own rule |
KAFKA TOPIC | CONSUMER GROUP | 1 to 255 bytes without control characters |
PULSAR TOPIC | TOPIC | A local name without /, which the client places in its namespace (public/default unless the client configures one), or a fully qualified persistent://tenant/namespace/topic or non-persistent://tenant/namespace/topic. At most 1,024 bytes, without whitespace, control characters, or an empty level |
PULSAR TOPIC | SUBSCRIPTION | 1 to 255 bytes without control characters |
RABBITMQ QUEUE | QUEUE | 1 to 255 bytes: the AMQP 0-9-1 limit |
SQS QUEUE | QUEUE | 1 to 80 ASCII letters, digits, -, and _, and a FIFO queue’s name ends in .fifo: SQS’s own rule |
REDIS PUBSUB | CHANNEL | 1 to 1,024 bytes of any text; a * is the character itself, and pattern subscriptions are not offered |
NATS SUBJECT | SUBJECT | At most 1,024 bytes of .-separated tokens, none empty, without whitespace or control characters; * stands for one token and > for the rest, each only as a whole token, and > only last |
NATS SUBJECT | QUEUE GROUP | 1 to 255 bytes without whitespace or control characters |
MQTT TOPIC | TOPIC | A topic filter of 1 to 65,535 bytes without NUL; + stands for one level and # for the rest, each only as a whole level, and # only last. Levels are joined by / and may be empty |
A reference to a Nervix model, such as a client, endpoint, schema, codec, or relay, stays a bare identifier.
Supported Ingestor Types
Each subsection shows a head and the body that follows its FROM; SET FROM takes the same body
under the same head. DECODE USING <codec>, the timestamp, the filter, the routes, and
ON GENERAL ERROR follow the body as in every ingestor statement.
| Head | Reads | Section |
|---|---|---|
HTTP REQUEST | Responses to the requests a TYPE HTTP client sends on a cadence | HTTP REQUEST |
HTTP ENDPOINT | Requests to a TYPE HTTP endpoint | Endpoints |
WEBSOCKETS ENDPOINT | Frames on connections to a TYPE WEBSOCKETS endpoint | Endpoints |
WEBSOCKETS CONNECTION | Frames on the outbound connection of a TYPE WEBSOCKETS client | WEBSOCKETS CONNECTION |
KAFKA TOPIC | A topic, through a TYPE KAFKA client | Kafka |
PULSAR TOPIC | A topic, through a TYPE PULSAR client | Pulsar |
RABBITMQ QUEUE | A queue, through a TYPE RABBITMQ client | RabbitMQ |
REDIS PUBSUB | A channel, through a TYPE REDIS client | Redis Pub/Sub |
MQTT TOPIC | A topic filter, through a TYPE MQTT client | MQTT |
NATS SUBJECT | A subject, through a TYPE NATS client | NATS |
ZEROMQ PULL | The PULL socket of a TYPE ZEROMQ client | ZeroMQ |
SYSLOG LISTENER | The listener a TYPE SYSLOG client declares | Syslog |
SQS QUEUE | A queue, through a TYPE SQS client | SQS |
PROMETHEUS QUERY | A PromQL query, through a TYPE PROMETHEUS client | Prometheus |
CLIENT PRODUCER | Batches applications submit through their sessions | Client Ingestors |
HTTP REQUEST
CREATE [IF NOT EXISTS] HTTP REQUEST INGESTOR <name>
FROM <client> EVERY <duration>
MODE NO ACK
ON QUIESCE SUSPEND
| BUFFER MAX SIZE <bytes> ON OVERFLOW DROP OLDEST|DROP NEWEST
- polls a configured HTTP endpoint periodically
- issues the first request immediately, then follows an anchored domain-clock cadence; slow responses coalesce missed occurrences into one request at the newest due instant
204 No Contentis treated as no messageSUSPENDskips polls and therefore creates a sampling gapBUFFERkeeps polling on the declared cadence and drains retained poll payloads in order- a poll whose
ON INGESTIONunfolding finds the node’s extension workers full waits for them; see When The Extension Workers Are Full
Kafka
CREATE [IF NOT EXISTS] KAFKA TOPIC INGESTOR <name>
FROM <client>
TOPIC '<topic>'
OFFSET BY CONSUMER GROUP '<group>'|DOMAIN
[INSTANCES <count>]
MODE NO ACK
| ACK SEQUENTIAL ACK TIMEOUT <duration> RETRY POLICY BACKOFF <duration> MAX <duration>
| ACK PARALLEL MAX <n> BATCH TIMEOUT <duration> ACK TIMEOUT <duration>
RETRY POLICY BACKOFF <duration> MAX <duration>
ON QUIESCE SUSPEND
Kafka is the richest ingestion surface today.
Offset modes:
CONSUMER GROUP '<group>': Kafka manages offsetsDOMAIN: Nervix stores the next offset in replicated runtime state and commits partition-to-instance assignment in the Raft-backed domain schedule
Kafka client configuration is passed through to librdkafka. Nervix does not override
auto.offset.reset; set it explicitly, for example to earliest, when a new consumer group must
read records that may already exist.
In an ACK mode, Nervix commits a Kafka position only after its downstream acknowledgement completes. If downstream work rejects a record, the ingestor seeks back to it. A consumer-group rebalance can move the record’s partition to another group member while its batch is in flight. The ingestor then does not seek: whichever member is assigned the partition next, this ingestor included, resumes it from the committed offset, which never passes a rejected record. If Kafka cannot seek back on a partition the ingestor still holds, the ingestor stops polling, refreshes its assignment, and retries that seek. It cannot commit a later offset while the rejected position remains unresolved. Replays after a crash or assignment change may repeat records whose output was already written.
Under OFFSET BY CONSUMER GROUP, MODE NO ACK is the consumer’s auto-commit: the consumer
commits the positions it has read on its own schedule, without waiting for downstream
acknowledgement, so a failure can lose records whose positions were committed before their
downstream work completed.
OFFSET BY DOMAIN is at-least-once. A commit records the partition’s next offset in memory. Nervix persists the offsets on the runtime state snapshot interval and whenever a node stops executing the domain, and when the ingestor has state replicas, a commit completes only after every replica has acknowledged it. Crash recovery may therefore restart from a slightly stale persisted offset snapshot. The leader watches Kafka partition topology and commits any rebalance through the strongly consistent domain schedule, which is persisted through the control-plane Raft/Fjall path. Executing ingestors consume only the committed partition assignment.
A commit or start-point reset waits for its replica acknowledgements within the native checkpoint bulk operation’s thirty-second budget. A missing acknowledgement fails the commit at that deadline; the source applies its existing rejection and retry policy. Large native checkpoints use bounded disk staging and transfer chunks, so checkpoint length is independent of the Bulk memory ceiling.
Offset recovery details:
- persisted per-partition offsets are clamped to the partition’s currently available Kafka watermark range on reassignment
- if a partition appears later and has no stored domain offset yet, unpaced domains start from the normal default behavior, while paced domains seek from the domain’s current logical time
Quiescing leaves a consumer group, or unassigns domain-owned partitions, and resume continues from
committed offsets. Topic time and size retention continue during the pause. For consumer-group
offsets that age out, the client’s pass-through auto.offset.reset setting decides whether resume
uses the earliest retained record, skips to new records, or raises a general source error. Domain
offsets clamp to the live watermark range and resume at the earliest retained record, exposing a
gap. Other members of a user-named group may consume and commit while Nervix is absent. Quiescing
does not change the auto-commit loss windows of MODE NO ACK.
Pulsar
CREATE [IF NOT EXISTS] PULSAR TOPIC INGESTOR <name>
FROM <client>
TOPIC '<topic>'
SUBSCRIPTION '<subscription>'
[INSTANCES <count>]
MODE NO ACK
| ACK SEQUENTIAL ACK TIMEOUT <duration> RETRY POLICY BACKOFF <duration> MAX <duration>
| ACK PARALLEL MAX <n> BATCH TIMEOUT <duration> ACK TIMEOUT <duration>
RETRY POLICY BACKOFF <duration> MAX <duration>
ON QUIESCE SUSPEND
Pulsar ingestors use Nervix-managed shared subscriptions. The subscription name is still required by Pulsar, but subscription type is not exposed in NSPL. Client config currently supports:
'addr': broker address such as'pulsar://127.0.0.1:6650'- optional
'namespace': defaults short topic names topersistent://public/default/<topic>; fully qualified topic names are accepted as-is - optional
'tls_ca_file': PEM-encoded CA bundle forpulsar+ssl://...connections - optional
'tls_allow_insecure_connection':trueorfalse; defaults tofalse - optional
'tls_hostname_verification_enabled':trueorfalse; defaults totrue
Pulsar TLS currently supports server trust configuration only. Nervix does not yet expose Pulsar client certificate authentication.
Suspension disconnects the consumer. The durable subscription retains its backlog and redelivers unacknowledged in-flight messages. Broker message TTL, backlog quota policy, and inactive-subscription expiry remain in force. If the subscription expires, resume creates it again at the broker’s new subscription initial position and the suspension window is lost.
RabbitMQ
CREATE [IF NOT EXISTS] RABBITMQ QUEUE INGESTOR <name>
FROM <client>
QUEUE '<queue>'
[INSTANCES <count>]
MODE ACK SEQUENTIAL ACK TIMEOUT <duration> RETRY POLICY BACKOFF <duration> MAX <duration>
ON QUIESCE SUSPEND
Each instance consumes with a prefetch window of one delivery, which it acknowledges once the
records decoded from it have been acknowledged through the graph. A delivery whose acknowledgement
fails, or whose payload cannot be decoded, is negatively acknowledged with requeue, so RabbitMQ
delivers it again and the ingestor retries it on its declared RETRY POLICY cadence.
Suspension cancels the consumer and RabbitMQ requeues unacknowledged in-flight deliveries. Other consumers on the same queue continue. Queue length, message TTL, overflow, and auto-expiry policies remain in force. If auto-expiry deletes the queue, resume reports a source error until an operator re-provisions it; Nervix never creates it.
A connection that closes while a delivery is still unacknowledged, because the broker restarted,
closed it, or the network dropped it, also makes RabbitMQ requeue that delivery. The acknowledgement
or the requeue the ingestor still owes it can only be sent on the connection it arrived on, so the
instance reconnects on its RETRY POLICY cadence, discards what it owed, counts it in
nervix_ingestor_transport_redeliveries_total, which DESCRIBE INGESTOR also prints, and consumes
again. RabbitMQ then delivers the message again: a delivery that failed in the graph is retried,
and one the graph had already acknowledged is ingested a second time, so delivery across a
reconnect is at least once.
Each instance resolves the host of the client’s addr through the node’s configured DNS resolver
every time it connects, so a changed DNS answer takes effect on the next connection, and tries the
addresses it receives in order. A literal IPv4 address, or an IPv6 address in brackets, is
connected to as written. Resolution, the TCP connection and, for amqps, the TLS handshake have 30
seconds together; the broker certificate must name the host addr names. A connection that fails,
including a name that does not resolve, is a source error that DESCRIBE INGESTOR shows as the
transient error, and the instance tries again on its RETRY POLICY cadence without acknowledging
anything.
Redis Pub/Sub
CREATE [IF NOT EXISTS] REDIS PUBSUB INGESTOR <name>
FROM <client>
CHANNEL '<channel>'
MODE NO ACK
ON QUIESCE BUFFER MAX SIZE <bytes> ON OVERFLOW DROP OLDEST|DROP NEWEST
| DROP
Redis Pub/Sub has no retained backlog, so it cannot suspend honestly. Both modes keep reading and
keep the subscriber healthy; payloads are either retained locally within the declared bound or
discarded and counted. A TYPE REDIS client declares connection-pool bounds even when only
ingestors reference it; see Database Client Connection Pools.
Each subscription owns a separate connection. It resolves the addr hostname through the node’s
asynchronous DNS resolver on initial subscribe and every resume after a disconnect; it does not
consume a pooled command connection. For rediss://, TLS verifies the original hostname and
uses the configured CA and optional client identity. DNS and connection failures follow the
source’s existing retry cadence; Redis Pub/Sub does not replay messages missed while disconnected.
MQTT
CREATE [IF NOT EXISTS] MQTT TOPIC INGESTOR <name>
FROM <client>
TOPIC '<topic-filter>'
[INSTANCES <count>]
[SESSION CLEAN|PERSISTENT]
[QOS 0|1]
MODE NO ACK
| ACK SEQUENTIAL ACK TIMEOUT <duration> RETRY POLICY BACKOFF <duration> MAX <duration>
| ACK PARALLEL MAX <n> BATCH TIMEOUT <duration> ACK TIMEOUT <duration>
RETRY POLICY BACKOFF <duration> MAX <duration>
ON QUIESCE SUSPEND
| BUFFER MAX SIZE <bytes> ON OVERFLOW DROP OLDEST|DROP NEWEST
| DROP
The topic filter is a string literal; see External Names for the levels and wildcards it may hold.
Delivery constraints:
NO ACKdefaults toSESSION CLEAN QOS 0; explicitSESSIONandQOSmay be supplied beforeMODENO ACKhas no in-flight ACK window, so it never acceptsMAX <n>ACKmodes requireSESSION PERSISTENT QOS 1ACK PARALLEL MAX <n>is the in-flight ACK window andBATCH TIMEOUTis the maximum partial-batch waitINSTANCES <count>controls Nervix consumer parallelism; MQTT delivery always uses Nervix-managed shared subscription groups so instances do not duplicate messages
SUSPEND is valid only with SESSION PERSISTENT QOS 1. It disconnects while asking the broker to
retain the session; broker offline-queue bounds and session expiry limit what can return. If the
session expires, resume establishes a fresh session and the interim is lost. BUFFER and DROP
remain connected under any valid session declaration. In ACK modes, Nervix acknowledges a payload
when it is buffered or deliberately dropped, trading broker redelivery for the declared connected
behavior.
Each instance resolves the host of its client’s addr through the node’s asynchronous DNS
resolver whenever it connects: on its first subscription and on every resume after a lost
connection. It dials the answers in order within the client’s five-second connect timeout, which
also covers TLS and the MQTT handshake. For mqtts, TLS verifies the configured host name. A name
that does not resolve is a connection failure, never a message rejection: the instance resumes
again on its declared RETRY POLICY, or on the host’s reconnect cadence in MODE NO ACK. See
DNS for MQTT.
NATS
CREATE [IF NOT EXISTS] NATS SUBJECT INGESTOR <name>
FROM <client>
SUBJECT '<subject>'
QUEUE GROUP '<queue_group>'
INSTANCES <count>
MODE NO ACK
ON QUIESCE BUFFER MAX SIZE <bytes> ON OVERFLOW DROP OLDEST|DROP NEWEST
| DROP
NATS ingestors use Core NATS queue subscriptions. QUEUE GROUP and INSTANCES
are mandatory; use INSTANCES 1 for a single queue member.
Core NATS retains no backlog, so SUSPEND is unavailable. A quiesced instance remains a member of
its queue group and keeps receiving its share; that share is buffered or discarded rather than
shifted to other members.
ZeroMQ
CREATE [IF NOT EXISTS] ZEROMQ PULL INGESTOR <name>
FROM <client>
MODE NO ACK
ON QUIESCE SUSPEND
| BUFFER MAX SIZE <bytes> ON OVERFLOW DROP OLDEST|DROP NEWEST
| DROP
The source is a PULL socket. SUSPEND keeps it open but unread, so transport buffers fill and the
peer’s own high-water-mark policy determines whether sends queue, block, or drop. Buffered transport
data is read on resume. BUFFER and DROP keep reading locally.
Syslog
CREATE [IF NOT EXISTS] SYSLOG LISTENER INGESTOR <name>
FROM <client>
MODE NO ACK
ON QUIESCE SUSPEND
| BUFFER MAX SIZE <bytes> ON OVERFLOW DROP OLDEST|DROP NEWEST
| DROP
The client binds a UDP, TCP, or TLS listener. UDP treats one datagram as one message; TCP detects
RFC 6587 octet-counted or LF-terminated framing per frame; TLS uses RFC 5425 octet counting.
Messages expose optional metadata.peer_addr. The source takes no INSTANCES clause and has no
application acknowledgment. Every live cluster node runs the listener; it is independent of the
leader and restarts or joins with its owning node. See Syslog for the client keys,
cluster lifecycle, framing, TLS, limits, and failure semantics.
An invalid client setting or unreadable TLS file fails listener startup with the Syslog key or
file and its underlying cause. A frame or connection failure keeps its cause in the source
diagnostic. These failures do not acknowledge a message; Syslog has no application ACK.
SQS
CREATE [IF NOT EXISTS] SQS QUEUE INGESTOR <name>
FROM <client>
QUEUE '<queue>'
[INSTANCES <count>]
MODE ACK SEQUENTIAL ACK TIMEOUT <duration> RETRY POLICY BACKOFF <duration> MAX <duration>
ON QUIESCE SUSPEND
Suspension stops polling. Messages remain only for the queue’s configured retention period. An already received message remains invisible until its visibility timeout and may then be redelivered as a duplicate; the ingestor resumes by polling past anything the service expired.
Starting the ingestor sends no request to the service. Each source instance looks its queue up
before its first poll and keeps the queue’s URL from then on. The source resolves the host of its
client’s endpoint through the node’s asynchronous resolver each time it opens a connection, and
tries the answers in order. Every request is still signed for the configured host, and over HTTPS
the service certificate must name that host. Without tls_ca_file the client trusts the platform’s
native roots and follows the HTTP_PROXY, HTTPS_PROXY, ALL_PROXY and NO_PROXY environment
variables; with it, the client trusts that CA alone and connects directly. A missing name, a silent
name server or an unreachable answer fails the queue lookup, a poll, or a deletion. The AWS SDK’s
standard retry mode makes up to three attempts at each such request. A failed lookup, poll or
deletion is then a transient source failure that DESCRIBE INGESTOR shows and the source retries
on its RETRY POLICY, so an ingestor started while the service is unreachable consumes once the
service answers. A queue that does not exist fails the lookup with its name, such as
SQS queue 'orders' does not exist, and is retried the same way; Nervix never creates it. A message
is deleted only once it is acknowledged, so a lookup failure never removes one from the queue.
Prometheus
CREATE [IF NOT EXISTS] PROMETHEUS QUERY INGESTOR <name>
FROM <client>
QUERY '<promql>'
EVERY <duration>
MODE NO ACK
ON QUIESCE SUSPEND
| BUFFER MAX SIZE <bytes> ON OVERFLOW DROP OLDEST|DROP NEWEST
Prometheus samples are flattened into JSON before codec decoding.
The first query becomes due after one EVERY interval. Nervix supplies each anchored due instant
as Prometheus’s evaluation time; a slow query coalesces missed occurrences and returned samples are
evaluated with a fresh domain execution snapshot.
SUSPEND skips scrapes and leaves a sampling gap. BUFFER keeps querying at the declared cadence
and delivers retained results in order after resume. A scrape whose ON INGESTION unfolding finds
the node’s extension workers full waits for them; see
When The Extension Workers Are Full.
HTTP ENDPOINT And WEBSOCKETS ENDPOINT
CREATE [IF NOT EXISTS] HTTP ENDPOINT INGESTOR <name>
FROM <endpoint> MODE NO ACK
ON QUIESCE REJECT RETRY AFTER <duration>
| BUFFER MAX SIZE <bytes>
CREATE [IF NOT EXISTS] WEBSOCKETS ENDPOINT INGESTOR <name>
FROM <endpoint> MODE NO ACK
ON QUIESCE REJECT RETRY AFTER <duration>
| BUFFER MAX SIZE <bytes>
This is how Nervix receives inbound HTTP requests and WebSocket connections on its own server-side
endpoints. An HTTP ENDPOINT ingestor reads an endpoint declared TYPE HTTP, and a
WEBSOCKETS ENDPOINT ingestor reads one declared TYPE WEBSOCKETS; completion after FROM offers
only endpoints of that type. An endpoint of the other type is refused when the statement is
applied, with a message such as requires a TYPE HTTP endpoint, but endpoint 'stream' is TYPE WEBSOCKETS. Both heads take the same body.
Quiesce is enforced independently on every serving node. With REJECT, HTTP requests and new
WebSocket upgrades receive 503, while established WebSockets close with 1013. With BUFFER, bodies
that fit receive 202 and drain after resume; a full buffer rejects further intake without discarding
anything already acknowledged. A stopped, absent, or otherwise unable ingestor always rejects and
never returns a silent 202. If several ingestors share a route, the request is accepted when at least
one accepts it. It is rejected only when all reject, and Retry-After is included only when every
rejecting ingestor declares REJECT.
A body whose codec runs an ON INGESTION transformation is unfolded on the node’s extension
workers. When they cannot take it now, the body is rejected the same way: HTTP answers 503 without
Retry-After, and an established WebSocket closes with 1013. The body was not decoded, so its
sender may send it again; a body its codec rejects is still accepted and reported as a decode
failure. A body BUFFER retained was already answered 202, so when it drains after resume and the
extension workers cannot take it, it waits for them instead. It stays in the buffer, counted in the
ingestor’s buffered records and bytes, until they unfold it, and the bodies behind it follow in
order. A body its codec rejects as it drains is reported as an ingestor error and not delivered,
like any other retained body that fails after its 202.
A WebSocket connection retains the route and signaling protocol selected at upgrade. Stopping or replacing its endpoint source ends that intake lifetime, including signaling data intake. During payload ingestion after signaling, the next refused payload closes the connection with 1013, even if the source has already restarted. Open a new connection to use the replacement source. A request already admitted before source ending may finish. Stopping a domain withdraws only that domain’s routes and intakes; another domain serving the same host and path continues receiving requests. A route whose configuration has been withdrawn returns HTTP 404.
Server-side endpoints are hosted under a VHOST. A plain VHOST serves HTTP and WS on the HTTP listener. A TLS-enabled VHOST serves HTTPS and WSS on the separate HTTPS listener.
TLS is configured on the VHOST itself:
CREATE IF NOT EXISTS VHOST edge api.example.com, ws.example.com
WITH TLS tls_bundle VERSION 3;
WITH TLS requires a version. VERSION LATEST binds the highest completed version of the bundle
when the statement is applied:
CREATE IF NOT EXISTS VHOST edge api.example.com, ws.example.com
WITH TLS tls_bundle VERSION LATEST;
Either way the VHOST stores one concrete version and keeps presenting that certificate after later
uploads; SHOW CREATE VHOST renders the number it bound. Rotate the certificate by uploading a new
version and moving the VHOST to it with REBIND RESOURCE, which refreshes the HTTPS listener on
every node without pausing ingestion. See Resources.
The referenced resource bundle must contain:
tls.crttls.keyca.crt
WEBSOCKETS CONNECTION
CREATE [IF NOT EXISTS] WEBSOCKETS CONNECTION INGESTOR <name>
FROM <client> MODE NO ACK
ON QUIESCE BUFFER MAX SIZE <bytes> ON OVERFLOW DROP OLDEST|DROP NEWEST
| DROP
This opens an outbound WebSocket connection through a TYPE WEBSOCKETS client and decodes text or
binary frames.
For ws and wss, each initial connection and resume resolves the URL host through the node’s
asynchronous resolver. It tries the returned addresses in order within one 30-second budget for
DNS, TCP, TLS and the opening upgrade. The upgrade still uses the configured URL for the HTTP Host
header, TLS server name, path and query; custom TLS roots and client identity remain in effect.
An expired DNS answer is refreshed on a new attempt, while a healthy connection stays open.
Resolution and connection failures are transient source failures handled by the host’s existing
retry lifecycle. Cancelling or stopping the source drops a pending connection attempt.
Both quiesce modes continue polling the connection so keepalives and reconnect behavior remain live.
A stop-reading mode is unavailable because it would starve protocol maintenance and become a
disconnect.
A WEBSOCKETS CONNECTION ingestor is a single-owner ingestor. It keeps its existing primary and
replica assignment through schedule recomputation while every assigned cluster node remains live,
just like Kafka, MQTT, and the other client sources. Creating or changing another runtime node, a
cluster node joining or being uncordoned, and a soft placement-policy change do not move its
outbound session. Failover, draining its owner, an explicit RELOCATE, or a newly effective
REQUIRE COLOCATION group can move it; a real move closes the former owner’s session and opens a
new session on the new owner.
HTTP ENDPOINT, WEBSOCKETS ENDPOINT, and SYSLOG LISTENER ingestors are different because
their listeners execute on every live cluster node.
Outbound WebSocket clients can declare WITH SIGNALING PROTOCOL <name> after
TYPE WEBSOCKETS. Server-side WebSocket endpoints can declare the same clause
after TYPE WEBSOCKETS.
A signaling protocol declares a wire format and expresses the whole handshake as JAQ programs:
CREATE SIGNALING PROTOCOL bybit_subscribe
FORMAT JSON
ON CONNECT
SEND JAQ '{op: "subscribe", args: ["publicTrade.BTCUSDT"]}'
WAIT JAQ '.op == "subscribe" and .success == true'
FAIL JAQ 'select(.success == false) | .ret_msg'
ACCEPT DATA
TIMEOUT 5s;
Steps run strictly in the order they are written: a step completes before the
next one starts, which is what lets a request depend on an earlier reply. A
SEND step writes its frames; a WAIT step blocks until every matcher it lists
is satisfied, in any arrival order.
Each SEND JAQ program must produce exactly one value, sent as a frame in the
declared format. JSON, YAML, TOML, XML, and RAW travel as text frames;
CBOR and PROTOBUF travel as binary frames. A RAW program must produce a
string, which is sent verbatim — that is how plain-text handshakes are
expressed.
Every incoming frame is decoded with the same format into exactly one value,
which for XML is the frame’s root element, and offered to the current step. A
textual format also reads payloads delivered as binary frames, since peers
commonly frame text that way; a binary format reads binary frames only.
Failure matchers run first, so a rejection is never swallowed by a lenient
acknowledgement matcher: a FAIL JAQ guard on the step is checked before the
protocol-wide one, and the first to produce a value that is neither null nor
false aborts the handshake carrying that value as the reason. Otherwise the
step’s outstanding matchers are tried and the first satisfied one is consumed.
Because matchers assert only the fields they name, acknowledgements carrying
connection ids, timestamps, or echoed parameters still match.
A matcher that errors on a frame of a different shape counts as a non-match rather than a connection failure. On timeout, the error names the matchers the current step was still waiting on. Invalid signaling programs fail validation with the protocol, clause, step, and compiler cause. Handshake send, capture, encoding, and transport failures retain their causes in the source diagnostic. A failed handshake delivers no frame to the relay.
Data Arriving During The Handshake
Peers commonly start streaming before they finish acknowledging. ACCEPT DATA
says where payload starts flowing to the relay:
ON CONNECT ACCEPT DATA -- from the first frame
WAIT JAQ '<matcher>' [FAIL JAQ '<matcher>'] [CAPTURE '<program>'] ACCEPT DATA
On ON CONNECT the relay is open before anything is negotiated. On a WAIT
step it opens when that step completes, which is the usual case: completing the
step is what proves the peer is streaming. Because a step may list several
matchers, a client can subscribe to two streams at once and open the relay when
the first subscription is confirmed while the second is still outstanding.
Frames that arrive before the relay opens are not payload the graph asked for,
so they are dropped rather than held. Nothing accumulates in memory, and nothing
reaches a relay from a connection that was never established. Without
ACCEPT DATA anywhere, payload starts flowing once the handshake finishes.
Sequencing And Captured State
CAPTURE records values from the frame that satisfied its matcher, and every
program can read what has been captured so far through the $state variable:
CREATE SIGNALING PROTOCOL exchange_login
FORMAT JSON
ON CONNECT
SEND JAQ '{op: "auth", key: "..."}'
WAIT JAQ '.op == "auth" and .success' CAPTURE '{token: .data.token}'
SEND JAQ '{op: "subscribe", token: $state.token, id: 1}',
'{op: "subscribe", token: $state.token, id: 2}'
WAIT JAQ '.id == 1' ACCEPT DATA
WAIT JAQ '.id == 2'
TIMEOUT 5s;
$state starts as an empty object; each CAPTURE must produce an object, whose
entries are merged in with later values winning. A capture that fails or yields
a non-object fails the handshake, because its matcher already accepted the frame.
CAPTURE attaches to a single-matcher WAIT only, since it describes the one
frame that matched.
A handshake may wait before it sends anything, which is how a server-initiated challenge is answered — wait for the challenge, capture it, then reply:
CREATE SIGNALING PROTOCOL challenge_response
FORMAT JSON
ON CONNECT
WAIT JAQ '.challenge' CAPTURE '{nonce: .challenge}'
SEND JAQ '{op: "answer", nonce: $state.nonce}'
WAIT JAQ '.accepted'
TIMEOUT 5s;
The TIMEOUT is one budget for the whole handshake, and captured state lives
only for its duration — it is never logged, and does not reach ingestion.
PROTOBUF signaling declares its resource, the version it compiles, and one message type per
direction:
CREATE SIGNALING PROTOCOL protobuf_subscribe
FORMAT PROTOBUF USING RESOURCE proto_bundle VERSION 1
CONFIG {'file' = 'signaling.proto', 'include' = '.'}
SEND MESSAGE 'nervix.test.Subscribe'
WAIT MESSAGE 'nervix.test.Ack'
ON CONNECT
SEND JAQ '{id: 1}'
WAIT JAQ '.id == 1'
TIMEOUT 5s;
VERSION is mandatory. VERSION LATEST binds the highest completed version when the statement is
applied, and the protocol stores that number. SHOW CREATE SIGNALING PROTOCOL renders it.
Protobuf decoding is permissive: unknown fields are kept and missing fields take
their proto3 defaults, so nearly any binary frame decodes as the WAIT MESSAGE
type. Write matchers that test meaningful field values rather than relying on a
decode failure to reject a frame.
Instancing
INSTANCES <count> is accepted by the entities that can safely scale through competing consumers
or parallel pollers on one node:
KAFKA TOPICPULSAR TOPICMQTT TOPICRABBITMQ QUEUESQS QUEUENATS SUBJECT
NATS SUBJECT requires the clause. For the others, if omitted, the default is INSTANCES 1.
Processors
Processors consume relay records and create one or more route-local outputs. Relay names identify graph edges; expressions use language-defined scopes instead of relay-qualified fields.
Shared input and branch contracts
Ordinary multi-source processors require every FROM relay to reference the same declared schema
Model. Merely having structurally equal schemas is not enough. The relays must also use the same
exact named branch, or all be unbranched.
Relay inputs may optionally collect incoming Arrow batches before node execution:
FROM <relay> [WHERE <expr>], ...
COLLECT FOR <duration> [MAX BATCH SIZE <bytes>]
Without COLLECT FOR, each relay batch is executed immediately and Nervix creates no additional
input buffer. With it, the duration starts when data enters an empty input collector. The node
executes the accumulated batch when that timer expires or the optional maximum size is reached.
Collection is independent for each source relay and concrete branch, and occurs before
source-specific WHERE, node-wide FILTER WHERE, and node-specific execution. The duration is a
domain-logical wait that begins only when an empty collector receives data.
This clause is available on junctions, deduplicators, reorderers, window processors, inferencers,
WASM processors, and reingestors. Correlators configure it independently after each complete
LEFT FROM or RIGHT FROM relay list. Ingestors cannot use it because they do not consume relays;
generators are scheduled from materialized state and have no FROM relay list.
Each accepted input batch receives one domain execution snapshot. Source predicates, the node
filter, construction, keys, aggregate expressions, route predicates, message-error construction,
and any Roto or WASM call made for that execution all see that same instant. A batch released from
COLLECT FOR starts a new execution with a fresh snapshot; the collection deadline itself remains
bound to the source relay, concrete branch, domain, and START generation.
Branch-preserving processors declare one node-wide contract:
BRANCHED BY <branch>
or:
UNBRANCHED
Every input and output relay must match it exactly. State, scheduling, buffers, and materialized views are instantiated independently for every concrete branch. Only ingestors and reingestors construct branch keys.
Filters and construction
Transforming routes use the working-message model. The scopes below describe fixed points in that construction timeline.
Processing order is:
- A source-specific
FROM ... WHEREpredicate. - Node-wide
FILTER WHERE, when the node supports it. - Node-specific work such as deduplication, inference, or ordering.
- Independent route construction and route
WHEREevaluation.
A transforming route starts empty and may use INHERIT and ordered SET:
TO projected_notifications
INHERIT tenant, user_id, amount
SET amount = amount + 1,
amount = amount * 2,
normalized = lower(trim(input.raw))
WHERE output.amount > 10
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG
INHERIT ALL, INHERIT ALL EXCEPT ..., and explicit field lists require exact type and nullability
matches. Sensitive values may be promoted but not downgraded. Explicit inheritance leakage is
written INHERIT password LEAK SENSITIVE.
Assignments run left to right. output.field reads only an already initialized output field.
message.field and a bare RHS field read the
working message. Route WHERE reads the finalized route output.
Set-only routes reject INHERIT. All required fields must be assigned; omitted optional fields
finalize as typed nulls. Generated inferencer and WASM values are immutable read sources and are
visible independently to every route. They never initialize route outputs automatically.
Every flush-based processor route declares FLUSH EACH <duration> MAX BATCH SIZE <bytes> or
FLUSH IMMEDIATE. The NSPL Overview defines the system-owned 100 µs minimum
batching window and its forced-flush exceptions. FLUSH EACH is domain-logical, while the Immediate
minimum is physical; both start on the empty-to-buffered transition and remain independent for
each concrete branch. A route using ON MESSAGE ERROR SEND TO buffers its error records
independently and emits them on that route’s same interval or maximum batch-size boundary.
Materialized relay state
For mechanism selection, including differently keyed data, see Choosing An Enrichment Mechanism.
Normal processors declare ordered node-wide dependencies after their branch declaration:
USING MATERIALIZED STATE profiles REQUIRED WAIT
USING MATERIALIZED STATE rules REQUIRED SKIP
USING MATERIALIZED STATE preferences DEFAULT {
theme = "system",
alerts = true
}
State is read as relay_state.<relay>.<field>. Each relay must be materialized, in the same domain,
and exactly branch-compatible. Duplicate dependencies are invalid.
Dependencies execute in written order. Real state binds immediately; DEFAULT binds a typed
constant record; REQUIRED SKIP suppresses the input successfully; and REQUIRED WAIT retains the
message in memory, keeps its acknowledgement open, and applies backpressure. When state arrives,
resolution restarts at the first declaration. Whole-branch eviction drops both state and suspended
work.
REQUIRED SKIP and REQUIRED WAIT gate a node’s input. Dependencies resolve once per batch, and
every output route of that batch reads the same resolved values, including the constants bound by
DEFAULT. Routes never observe a partially resolved or per-route view of state.
Defaults must initialize every required field. Omitted optional fields become typed nulls. Default expressions cannot contain field reads, side effects, or nondeterministic calls.
Junction
Junctions perform transforming fan-out:
CREATE JUNCTION route_notifications
FROM notifications WHERE input.active
FILTER WHERE input.amount > 0
BRANCHED BY by_tenant
USING MATERIALIZED STATE profiles REQUIRED SKIP
TO accepted
INHERIT ALL
WHERE relay_state.profiles.enabled
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG
TO audit
INHERIT ALL
FLUSH EACH 1s MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG;
DESCRIBE JUNCTION <junction> reports the stored inputs, branch and attachment contracts, route
summaries, scheduled owner and replicas, and local incoming and outgoing edge metrics when those
metrics exist.
Altering Junctions
ALTER JUNCTION accepts comma-separated operations and applies them in written order. Input,
materialized-dependency, and route order are preserved:
ALTER JUNCTION route_notifications
ADD FROM priority_notifications WHERE input.active,
SET COLLECT FOR 25ms MAX BATCH SIZE 1MiB,
SET FILTER WHERE input.amount >= 10,
ALTER MATERIALIZED STATE profiles SET REQUIRED WAIT,
REPLACE ROUTE TO accepted
INHERIT ALL
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG,
SET DETACHED;
Input operations are ADD FROM, DROP FROM, and ALTER FROM ... SET|DROP WHERE. A junction must
retain at least one input. DROP FROM also removes that input’s WHERE. Collection, node filter,
attachment mode, and branch selection each have SET forms; collection and filtering also have
DROP forms.
Materialized dependencies support ADD MATERIALIZED STATE <relay> <policy>,
DROP MATERIALIZED STATE <relay>, and
ALTER MATERIALIZED STATE <relay> SET <policy>. Adding appends; altering keeps the existing order
position; duplicate dependencies are invalid.
Routes support ADD ROUTE TO <relay> <full route body>, DROP ROUTE TO <relay>, and
REPLACE ROUTE TO <relay> <full route body>. Adding appends and replacing keeps the route’s index.
Multiple routes may target the same relay, so drop and replace require that their target identify
exactly one route. A junction must retain at least one route.
Filter, per-input WHERE, construction, flush, collect, and same-target message-error policy
changes are classified dynamic and hot-applied from the published schedule. Existing input
collectors, buffered route output, pending materialized-state work, subscriptions, and branch-local
processor state remain in place. Before publication, the runtime binds a complete typed plan for
each changed processor against its installed schemas, branch contract, lookups and UDFs. Existing
branches adopt that plan by typed revision identity, and branches that appear later start from the
same plan. Unchanged processors retain the exact prepared programs they already hold. A flush-policy
update also forces an immediate convergence pass so buffered output is evaluated against the new
policy without waiting for another input.
Input/route topology, attachment, branching, dependencies, and changed error-route targets are classified entity pause. Nervix gates their source relays across the cluster, drains affected relay owner buffers, dispatch slots, and node work, and swaps only the altered junction task. Pending materialized-state work and branch presence residue are handed to the replacement before it resumes. Other nodes in the domain continue to run; sibling consumers of a gated relay can experience bounded backpressure until the gate is released.
Deduplicator
Deduplication expressions are structured and evaluated in source order:
CREATE DEDUPLICATOR unique_notifications
FROM notifications
FILTER WHERE input.active
DEDUPLICATE ON input.tenant, input.event_id
MAX TIME 10m
BRANCHED BY by_tenant
TO unique_events
INHERIT ALL
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG;
Deduplication state is branch-local. Duplicate details are logged at debug or trace, never at
info.
Altering Deduplicators
ALTER DEDUPLICATOR applies comma-separated operations in written order:
ALTER DEDUPLICATOR unique_notifications
SET DEDUPLICATE ON input.tenant, input.external_id,
SET MAX TIME 30m,
SET FILTER WHERE input.active,
REPLACE ROUTE TO unique_events
INHERIT ALL
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG;
The deduplicator-specific operations are SET DEDUPLICATE ON <expr>, ... and
SET MAX TIME <duration>. It also supports the junction-style input, collection, filter,
attachment, branching, materialized-state, and route operations described above. A processor must
retain at least one input and one route. Duplicate materialized dependencies are rejected, and
drop/replace route operations require a unique target when multiple routes use the same relay.
Changing only MAX TIME, filters, per-input WHERE, collection, route construction/flush, or a
same-target message-error policy is dynamic. Changing the deduplication expressions is an
entity-pause operation: Nervix gates and drains the input relays, stops the old task, purges its
branch-local and persisted deduplication keyspace, then starts the replacement. Input/route
topology, attachment, branching, dependencies, and changed error-route targets also use entity
pause.
A backup archives each branch’s keyspace with the time every key was
first seen. A restore installs those keys, so a duplicate of an archived message is suppressed and
each key expires MAX TIME after its archived first sighting.
Reorderer
CREATE REORDERER ordered_notifications
FROM notifications
BY input.occurred_at, input.sequence
MAX TIME 30s
BRANCHED BY by_tenant
TO ordered_events
INHERIT ALL
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG;
Ordering buffers and maximum-time release are independent per concrete branch.
Altering Reorderers
ALTER REORDERER uses the same ordered common processor operations, plus SET BY and
SET MAX TIME:
ALTER REORDERER ordered_notifications
SET BY input.priority, input.occurred_at, input.sequence,
SET MAX TIME 10s,
SET COLLECT FOR 25ms MAX BATCH SIZE 1MiB,
SET ATTACHED;
SET BY <expr>, ... replaces the complete ordering expression list. SET MAX TIME <duration>
changes only the maximum holding time. The shared input, filter, route, materialized-state,
attachment, and branching operations have the same validation and ordering semantics as
deduplicators and junctions.
MAX TIME and the shared expression/configuration-only aspects are dynamic. Changing BY uses
entity pause: the old ordering buffers are force-flushed while the input relays are gated, then the
node task is replaced with the new ordering program. Structural shared operations also use entity
pause.
Window processor
Windows are set-only. Aggregates appear directly in route SET; there is no AGGREGATE clause.
input.field is valid only inside aggregate arguments, and aggregates cannot be nested:
CREATE WINDOW PROCESSOR latency_windows
FROM latencies
FILTER WHERE input.latency >= 0
WIDTH 5m DURATION
STEP 1m DURATION
MAX STATE SIZE 1MiB
BRANCHED BY by_tenant
TO latency_summary
SET count = COUNT(input.latency),
count_plus_one = COUNT(input.latency) + 1,
minimum = MIN(input.latency),
maximum = MAX(input.latency),
tenant = FIRST(input.tenant)
WHERE output.count > 0
ON MESSAGE ERROR LOG;
Aggregate calls may participate in larger scalar expressions and combine with constants. Route
WHERE reads the finalized output and cannot read live input rows. Windows use WIDTH and STEP,
never FLUSH. Window Aggregates owns every aggregate’s
types, null handling, numerical behavior, errors, and sensitivity, and
Approximate Sketches owns the accuracy, memory, and
expiry of the sketch aggregates.
MAX STATE SIZE <bytes> caps the live state of each concrete branch. It charges retained Arrow
input and argument allocations, row handles, branch keys, and the reserved capacity of every
sketch in every active pane. A row that would exceed the cap, or a row whose aggregate arguments
fail, is refused before it changes the window: the refusal is logged and the row is not
acknowledged. Sketches require this clause, duration WIDTH and STEP, and a bounded
MAX INSTANCES ... EVICT LRU branch when branched. Thus the configured worst-case live sketch state
across branches is bounded by MAX STATE SIZE * MAX INSTANCES; unbranched windows have one state.
A sketch’s pane size is the greatest common divisor of its width and step, aligned to the Unix
epoch. Each pane includes its starting timestamp and excludes the next pane’s starting timestamp.
Stepping removes records strictly before the step cutoff; records exactly at that cutoff remain.
Panes are merged only from rows still in the active window and are rebuilt after stepping.
Published branch snapshots share the retained Arrow input and argument columns, seal them in bounded
sections, and rebuild the same sketches from those columns when ownership moves, a node recovers or
a backup is restored. A window whose retained rows exceed one 8 MiB
snapshot section persists through bounded pieces on quota-owned disk rather than in memory, so the
window a node persists is bounded by its own WIDTH and MAX STATE SIZE and by the node’s snapshot
staging quota rather than by the bulk memory budget. Histogram delayed removals travel in bounded
typed sections beside the columns. A restored window continues only under the archived window model
and branch lifetime; otherwise it starts empty and the restore reports a warning.
Admission reads each consecutive run of retained rows as typed Arrow value slices with their validity bits. Counts and integer sums are exact across run boundaries. Floating-point sums and statistics merge each run’s compensated or centered result into the branch-local window, so their last rounded digits can depend on the run and lane grouping. Stepping refolds surviving runs; restoring a snapshot re-admits its retained rows.
In a window route, COUNT, SUM, FIRST, LAST, MIN, and MAX always name window aggregates.
The array and vector functions with the same
names apply to one ARRAY or VEC value everywhere else.
Window aggregate functions
The aggregates a window route can compute are COUNT, COUNT_IF, BOOL_AND, BOOL_OR, SUM,
AVG, MIN, MAX, FIRST, LAST, ARG_MIN, ARG_MAX, VAR_POP, VAR_SAMP, STDDEV_POP,
STDDEV_SAMP, COVAR_POP, COVAR_SAMP, CORR, PERCENTILE_LINEAR_HISTOGRAM,
APPROX_COUNT_DISTINCT, APPROX_QUANTILE, and APPROX_TOP_K. See
Window Aggregates for each one’s arguments, result, and
behavior. DESCRIBE WINDOW PROCESSOR lists the shared structures that answer them.
A duration width begins at the first retained record’s low watermark and becomes due when an input
watermark or the bound domain clock reaches that logical target. A paced TIME RATE therefore
changes how soon a partially filled window becomes due in real time without changing its source
event timestamps. Each concrete branch owns independent entries, aggregate state, and deadlines.
The emitted record keeps the minimum input low watermark and uses the emission execution snapshot
as its high watermark.
Inferencer
Commands that bind an ONNX model inspect its tensor metadata on the node’s bulk workers. Each inspection uses its admitted worker for ONNX initialization and optimization and has a thirty-second bound covering the worker queue, model loading, metadata extraction and session release. A full bulk queue refuses the inspection with its execution cause; releasing that capacity permits the same valid inferencer to be created. Tensor names, element types and dimensions are checked before the model is committed.
Inferencers keep the explicit tensor mapping surface. INPUTS expressions may read input; route
construction cannot. Routes read immutable generated model fields, declared materialized state,
and the branch:
CREATE INFERENCER score_events
FROM features
USING RESOURCE scoring VERSION 1
FILE "score.onnx"
INPUTS {
"features" DENSE TENSOR<F32>[2] = input.features
}
OUTPUT SCHEMA {
"score" DENSE TENSOR<F32>[1]
}
BRANCHED BY by_tenant
TO scores
SET tenant = branch.tenant,
score = score
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG;
Every required route-output field is explicit. The source input is not implicitly inherited.
USING RESOURCE requires VERSION <n> or VERSION LATEST. The inferencer loads the model file
from the one version it stores, so a later upload of the resource does not change the scoring
model; see Versioning.
WASM processor
WASM routes are also set-only and execute only when the guest returns actual output data:
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
WHERE output.normalized != ""
ON MESSAGE ERROR LOG
ON REJECTED STATE PRESERVE
ON GLOBAL ERROR LOG;
USING RESOURCE requires VERSION <n> or VERSION LATEST, and the processor compiles the module
from the one version it stores. SHOW CREATE WASM PROCESSOR renders that version and
DESCRIBE WASM PROCESSOR reports it.
Generated guest state is immutable across routes. WASM processors do not declare FLUSH; guest
output and guest-requested timeouts own emission cadence. MAX FUEL and MAX MEMORY are both
required, in that order immediately after FILE. Fuel bounds one logical guest operation, while
memory bounds the branch instance’s Wasmtime linear memory. See
WASM Processor Guests for exact accounting and
failure behavior.
Guest initialization, input processing, requested-timeout callbacks, quiesce flushes, and state saves and restores each receive the snapshot selected for that operation. The guest cannot ask the engine for wall time or execute without an explicit snapshot.
ON REJECTED STATE decides what happens when a recreated guest refuses the snapshot Nervix hands
it. It is optional and defaults to PRESERVE, which keeps the refused snapshot and reports the
refusal; RESET opts the processor in to replacing that branch’s state lifetime once. Only the
guest’s own verdict on the saved bytes reaches this policy, so no module, limit, storage or
replication failure can erase computation state through it. See
Recovering A Rejected Snapshot.
An ATTACHED WASM processor, the default, acknowledges an input only after the guest-state
checkpoint that covers it is on the stable storage of the branch’s owner and of every replica the
schedule assigns the processor; relay fan-out acknowledges the input of a DETACHED one upstream.
A checkpoint that cannot get there negatively acknowledges what it covers and recreates the branch’s
guest from its last completed checkpoint. DESCRIBE WASM PROCESSOR reads the committed binding,
default state generation, latest coordinated reset and retained rejected-state recoveries together
with the owner’s current checkpoint stages and revisions. Each reported branch uses a fixed-size
opaque fingerprint rather than a branch value. Required and confirmed replica counts are shown for
checkpoints captured on the current owner; a restored checkpoint whose earlier boundary is unknown
reports those counts as unknown. Counts cover every active branch, while detailed branch and
recovery entries are bounded. DESCRIBE WASM PROCESSOR <name> FORMAT JSON serializes the same
typed state inspection used by the text output; the command also returns it as a typed client
outcome in either format. See
Checkpoints And Acknowledgements and
WASM State And Recovery.
Reset guest state from NSPL
RESET WASM PROCESSOR <processor> STATE IN DOMAIN <domain> FOR <scope>; replaces the selected
guest-state lifetime of an existing, running processor. The scope is mandatory:
RESET WASM PROCESSOR normalize_events STATE IN DOMAIN sales FOR UNBRANCHED;
RESET WASM PROCESSOR normalize_events STATE IN DOMAIN sales FOR ALL BRANCHES;
RESET WASM PROCESSOR normalize_events STATE IN DOMAIN sales FOR BRANCH VALUES { tenant = 'acme' };
Use FOR UNBRANCHED only for an unbranched processor. Use FOR ALL BRANCHES or exact
FOR BRANCH VALUES fields only for a branched processor. Values must match the declared branch
schema exactly. A selected branch must already be active; the command does not create one.
The selected branch loses its previous guest computation state and starts from the guest’s initial
state. Sibling branches keep their state and continue processing.
The normal client and CLI command path returns success only after the new generation is durable and its replacement execution is usable. Repeating the same command execution reference returns the retained outcome without creating another generation. A fresh command is a new reset. See Command Completion for retry validity and expired references.
Correlator
Correlators use explicit sides and have no default input scope:
CREATE CORRELATOR correlate_orders
LEFT FROM orders WHERE left.active
COLLECT FOR 10ms
RIGHT FROM payments WHERE right.approved
COLLECT FOR 10ms MAX BATCH SIZE 1MiB
CORRELATE WHERE left.order_id = right.order_id
MATCH EARLIEST
MAX TIME 5m
ON CORRELATION TIMEOUT DROP, DROP
BRANCHED BY by_tenant
TO paid_orders
SET order_id = left.order_id,
amount = right.amount,
label = concat("paid:", output.amount AS STRING)
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG;
Relays on each side share one declared schema; left and right schemas may differ. Correlators reject
FILTER WHERE, INHERIT, bare RHS field reads, input, and a separate OUTPUT block.
Correlations occur only within one concrete branch.
Reingestor
Reingestors are branch-boundary transforming nodes. Each route preserves the incoming exact branch, constructs another branch, or becomes unbranched:
CREATE REINGESTOR repartition_events
FROM events
USING MATERIALIZED STATE profiles REQUIRED WAIT
TO by_user_events
INHERIT ALL
BRANCHED BY by_user
SET tenant = message.tenant,
user_id = message.user_id
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG;
When the outgoing branch name equals the incoming branch, no branch SET is allowed and the key is
preserved. A message passes its input’s FROM ... WHERE and then the reingestor’s FILTER WHERE
before any route constructs it. State lookup always uses the incoming branch, never a partially
constructed outgoing key. The reingestor resolves the outgoing branch before buffering the route,
so each concrete outgoing branch has an independent flush interval and size boundary. Downstream
branch execution receives the completed Arrow batch and does not apply a second flush policy.
Altering Reingestors
ALTER REINGESTOR applies the shared input, collection, filter, attachment, materialized-state,
and route operations in written order:
ALTER REINGESTOR repartition_events
ADD FROM priority_events WHERE input.active,
SET COLLECT FOR 25ms MAX BATCH SIZE 1MiB,
SET FILTER WHERE input.amount > 0,
SET DETACHED,
REPLACE ROUTE TO by_user_events
INHERIT ALL
BRANCHED BY by_user
SET tenant = message.tenant,
user_id = message.user_id
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG;
The input, collection, filter, attachment, and materialized-state forms are the same as for
junctions. Route add/replace bodies include the reingestor’s required per-route BRANCHED BY ...
or UNBRANCHED construction. Reingestors do not have a node-wide branching operation.
Every reingestor change uses entity pause because inputs, route construction, attachment, and dependency changes affect its relay consumers or branch-entrypoint wiring. Nervix gates both old and desired input relays, force-flushes collected input, drains node work, stops only the affected reingestor tasks, rebuilds their branch entrypoints, and reconnects their relay consumers. Other domain nodes continue to run.
Generator
Generators run from a materialized relay on a domain-clock cadence. Their routes are set-only:
CREATE GENERATOR synth_notifications
USING MATERIALIZED STATE notifications
EACH 100ms
UNBRANCHED
TO generated_notifications
SET user_id = relay_state.notifications.user_id,
amount = relay_state.notifications.amount
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG;
The first occurrence is immediate. Later occurrences remain anchored to the declared EACH
schedule. If reading or generating spans multiple periods, Nervix generates once for the newest
due occurrence and continues at the first future boundary. Route expressions use a fresh domain
execution snapshot taken after the materialized-state read.
Altering Generators
ALTER GENERATOR supports SET MATERIALIZED STATE <relay>, SET EACH <duration>,
SET BRANCHED BY <branch>, SET UNBRANCHED, and the ordered ADD ROUTE, DROP ROUTE, and
REPLACE ROUTE operations:
ALTER GENERATOR synth_notifications
SET EACH 250ms,
REPLACE ROUTE TO generated_notifications
SET user_id = relay_state.notifications.user_id,
amount = relay_state.notifications.amount
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG;
Generator route bodies remain set-only and must contain at least one SET assignment. Drop and
replace require a unique target when duplicate target relays exist, and the generator must retain
at least one route.
Every generator change uses entity pause. Nervix gates its old and desired materialized source relays, lets the old timed task force-flush pending route output, waits until that task reports quiescent, then replaces only that generator task from the published schedule. The gate has a deadline expiry backstop, so a failed control-plane operation cannot leave generation wedged.
Message errors
Errors And Diagnostics owns the typed failure and structured message-error model; this section defines the route behavior and available expressions.
ON MESSAGE ERROR terminates each route. SEND TO constructs an error relay record with ordered
SET assignments:
ON MESSAGE ERROR SEND TO processing_errors
SET error_reference = error.reference,
error_code = error.code,
operation = error.operation,
source_id = input.id,
attempted_total = partial_output.total
error is structured; partial_output is an all-optional view of the failed route output. Eligible
handlers may also read the original input (or correlator left and right) and the exact
relay_state snapshot. Error routes preserve the branch in which the failed operation executed and
never construct a new key. Error-route assignments run through the same typed expression VM as
ordinary SET, so deterministic scalar functions, casts, unary expressions, binary expressions,
and ordered reads of earlier error-record assignments are supported. Window aggregates and
side-effect functions are not available in error construction.
The error relay must declare the same exact named branch as the source route, or be unbranched when the source is unbranched. A rejected binding identifies the node, source route, error relay, and both branch declarations.
WASM Processor Guests
WASM processors are native WebAssembly modules loaded by Wasmtime. They do not use WASI. A guest module exports a small C ABI, owns one reusable linear-memory buffer, and exchanges Arrow IPC record batches with Nervix through that buffer.
Rust guests use the Rust WASM Guest SDK rather than implementing this ABI by hand. This chapter is the authoritative wire contract that the SDK implements and that guests in other languages implement directly.
WASM State And Recovery is the architecture reference for how Nervix makes guest state durable, fences its lifetimes, replaces them, and recovers them after a failure.
See Choosing An Extension Tier when deciding between builtins, operator-trusted Roto UDFs, and a WASM processor.
The runtime creates one guest instance per concrete branch. Guest state must therefore be branch-local. Do not aggregate across branch keys inside the guest.
Module Sharing And Branch Memory
A node compiles the module file a WASM processor pins once, and keeps the compiled module for as long as the schedule assigns the processor to that node, as its owner or as a replica. The owner instantiates every branch from it, and a replica that forced recovery promotes restores the branches’ guests from it, so taking over a processor never waits for its module to compile. Each branch instance still has its own Wasmtime store, linear memory, guest state, and timeout handles. Compiled code is shared. Mutable guest memory is not.
Capacity therefore grows with the linear-memory pages dirtied by each live branch, not with a full copy of compiled machine code per branch; each node the processor is assigned to holds one copy. See Capacity Planning For Branched Graphs.
WASM processor output flush is guest-controlled. CREATE WASM PROCESSOR does not accept FLUSH EACH or FLUSH IMMEDIATE; Nervix routes batches returned from nervix_process_batch, batches returned later from nervix_on_timeout callbacks requested by the guest, and batches released by nervix_flush when the host quiesces the branch, through the processor’s declared TO clauses.
Execution Limits
Every declaration requires both limits immediately after FILE, in this order:
FILE "processor.wasm"
MAX FUEL 1000000000
MAX MEMORY 64MiB
MAX FUEL is the Wasmtime instruction-fuel budget for one logical guest operation. Nervix resets
the store to that budget before initialization, each input batch, each timeout callback, a
quiesce flush, and each state save or load. All guest ABI calls made as part of that
operation share the budget: for example, input-buffer allocation, nervix_process_batch, global
error inspection, and every subsequent nervix_read_emit call are one batch-processing budget.
Module instantiation also receives one budget. Fuel measures guest WebAssembly execution, not
wall-clock time or host-side work.
MAX MEMORY is the Wasmtime linear-memory ceiling for each branch store. It applies to the total
initial size of all module memories and every later memory.grow; choose a value large enough for
the module’s initial pages and reusable ABI buffer. It does not include shared compiled code,
host-side Arrow or FlatBuffer data, or Wasmtime store bookkeeping.
Fuel or memory exhaustion is handled by the processor’s node-wide ON GLOBAL ERROR policy. Nervix
discards that concrete branch’s trapped guest instance; later work instantiates a replacement from
the branch’s committed checkpoint. Other branch instances are
independent and continue running. There is no separate WASM wall-clock timeout clause.
Before every guest operation, Nervix supplies one explicit domain execution snapshot to the WASM
host. Initialization, input processing, a timeout callback, quiesce flush, and state save or load
each receive the snapshot of their owning execution. Every call to
nervix_domain_time_nanos() during that operation returns exactly that value. The host exposes no
context-free guest invocation and the module cannot choose or read host wall time. Wasmtime epoch
yielding and fuel enforcement remain physical execution safeguards and do not change guest-visible
domain time.
Contract Summary
The guest imports host functions from the env module:
nervix_domain_time_nanos() -> i64
nervix_timeout_after_nanos(delay_nanos: i64) -> i64
nervix_request_state_reset() -> i32
The guest must export:
nervix_buffer_ptr() -> i32
nervix_buffer_len() -> i32
nervix_buffer_capacity() -> i32
nervix_alloc(size: i32) -> i32
nervix_init(ptr: i32, size: i32) -> i32
nervix_current_domain_time_nanos() -> i64
nervix_process_batch(ptr: i32, size: i32) -> i32
nervix_on_timeout(handle: i64) -> i32
nervix_flush() -> i32
nervix_read_emit() -> i32
nervix_dump_state() -> i32
nervix_load_state(ptr: i32, size: i32) -> i32
nervix_reset_state() -> i32
Return 0 from fallible functions on success. Return a negative code on guest rejection. Nervix treats negative codes as runtime errors and applies the processor error policy. nervix_dump_state returns the size of the snapshot it wrote, or a negative code when it cannot serialize its state; Nervix then keeps the state saved last.
Nervix resolves nervix_reset_state when it instantiates a branch but never calls it: every new
state lifetime starts in a newly instantiated guest that is initialized without a restore. A guest
must still export it.
nervix_load_state has two reserved codes that classify the saved state itself:
| Code | Verdict |
|---|---|
-7 | The saved bytes are not a snapshot envelope the guest can decode. |
-8 | The guest decoded the snapshot envelope and refuses the application state it carries. |
Only these two codes are a rejection of the saved state. A trap, an exhausted MAX FUEL or
MAX MEMORY, or any other negative code while restoring is a failure of the restore itself and says
nothing about the saved bytes. When an instance the branch’s owner creates fails to restore, Nervix
keeps the saved state, including after a rejection, and reports each outcome as a distinct
failure stage.
nervix_request_state_reset is answered by the host rather than by the guest: 0 when Nervix
takes the request and -9 when the guest operation in progress cannot carry one. See
Guest-requested reset for what an accepted request does.
Buffer Ownership
The guest owns one reusable byte buffer in linear memory.
- Host estimates the required FlatBuffer capacity and calls
nervix_alloc(capacity). - Guest ensures its buffer can hold
capacitybytes and returns its current pointer. The buffer may move on every call. - Host builds the FlatBuffer directly in that guest-memory range.
- Because FlatBuffers builds backwards, the completed message may occupy a suffix of the
allocation. Host calls
nervix_process_batch(ptr, size)with that exact range. - Guest writes pending output or state back into the same buffer.
- Host calls
nervix_read_emit()ornervix_dump_state()and reads the returned byte length fromnervix_buffer_ptr().
If the capacity estimate is too small, the host finishes the message in temporary spill storage,
releases its linear-memory borrow, calls nervix_alloc again with a larger capacity, and copies the
finished message once. Reallocation is never attempted while the host holds a guest-memory slice.
The host enforces a maximum guest buffer size. A guest must still validate the supplied pointer and
size against its current buffer and reject impossible ranges.
Init Payload
nervix_init receives the BranchInit variant of the size-prefixed FlatBuffers Message union.
output_schemas contains one schema per declared TO relay. A guest output envelope must name one
of those relays and provide one destination-aligned column descriptor per field before Nervix
applies the route-level SET and WHERE clauses. WASM routes are set-only: they reject INHERIT
and UNSET, and construction does not expose message or input. Guest-generated columns form an
immutable base independently visible to every route. The authoritative cross-language schema is
crates/nervix-wasm-protocol/schema/nervix_wasm.fbs.
{
"domain_name": text,
"domain_type": text,
"branch_key": bytes | null,
"input_schema": {
"name": text,
"fields": [
{ "name": text, "ty": WasmProcessorType, "optional": bool }
]
},
"output_schemas": [
{
"name": text,
"fields": [
{ "name": text, "ty": WasmProcessorType, "optional": bool }
]
}
]
}
domain_type is currently a descriptive host string. Guests should not branch on
it unless they own a strict compatibility rule for the exact Nervix version they
are targeting.
branch_key is the concrete branch key of this instance as its canonical JSON
text, for example {"tenant":"alpha"}. It is absent for the explicit
unbranched instance of an unbranched processor; an unbranched execution is
never represented by an empty key.
ProcessorTypeKind is a FlatBuffers enum with these scalar variants:
"U8" | "I8" | "U16" | "I16" | "U32" | "I32" | "U64" | "I64"
| "Bool" | "String" | "Bytes" | "Datetime" | "F32" | "F64"
Container variants use the nested ProcessorType.element table; arrays also
set array_len:
ProcessorType { kind: Array, element: ProcessorType, array_len: u32 }
ProcessorType { kind: Vec, element: ProcessorType }
Datetime values are Arrow Timestamp(Nanosecond) values at the wire boundary.
Bytes values are Arrow Binary arrays and retain arbitrary octets without UTF-8 conversion.
Treat nanosecond integers as a boundary format and convert them to your guest’s
typed timestamp representation immediately after decoding.
Treat this as configuration for the branch instance. Store what you need in guest state; reject it only when the processor cannot run correctly.
Batch Envelope
Every input, output, init, or bundled guest-state payload is one size-prefixed
FlatBuffer. Its root is the Message union and its file identifier is NVWY.
The ABI size and internal size prefix must agree exactly. Arrow IPC payloads
are FlatBuffers byte vectors. Generated Rust and Go accessors return slices
into the FlatBuffer, avoiding a deserialization copy. Crossing WebAssembly
linear memory still requires writing the source bytes once. On the normal host-to-guest path,
FlatBuffers writes them directly into their final guest-memory representation instead of creating
and then copying a complete host-side FlatBuffer. After a guest emit, the host keeps the generated
Arrow vector as a shared slice of that retained FlatBuffer.
Input envelopes have this shape:
{
"kind": "input",
"arrow_ipc_batch": bytes,
"acks": AckSidecar
}
Output envelopes have this shape:
{
"kind": "output",
"generated_arrow_ipc_batch": bytes,
"outputs": [
{
"output_relay": text,
"columns": [
{ "kind": "input", "column_index": u32 },
{ "kind": "generated", "column_index": u32 },
{ "kind": "uninitialized" }
],
"acks": AckSidecar
}
]
}
Each routed output’s columns entries correspond positionally to its
destination fields. An input column references the declared processor input
schema by index; source and destination types and nullability must match
exactly, although their names may differ. A generated column references the
common generated Arrow batch by index. Index namespaces are determined by the
variant. An uninitialized descriptor is encoded as FlatBuffers
ColumnSource.Uninitialized with canonical column_index = 0. It has no input
or generated-pool index; its type and nullability come from the positionally
aligned destination field, and its row count comes from acks.rows.len().
generated_arrow_ipc_batch is either an empty byte string or exactly one Arrow
IPC stream containing one schema and one uncompressed record batch. The stream
ends with the end-of-stream marker, or where its record batch message ends. Its
schema may declare only the Arrow types Nervix carries: integers of 8 to 64
bits, 32- and 64-bit floats, booleans, UTF-8 text, binary, nanosecond
timestamps, and lists and fixed-size lists of those, none dictionary-encoded.
Every message must open with Arrow’s continuation marker, 0xFFFFFFFF, and
declare metadata and body lengths that lie inside the stream. Its record batch
must declare exactly the field nodes and buffers those fields take, every
buffer inside the message body, a validity bitmap long enough for the nulls it
counts and offsets buffers that hold whole offsets. Nervix checks all of this
before it reads the batch or allocates anything from a declared length, and a
stream that fails the check is invalid generated Arrow IPC. The empty byte
string is the only valid empty generated pool; do not encode a zero-column
Arrow stream.
When present, generated schema field names must be empty. Nervix compares every
other field property with each referencing destination field, including data
type, nullability, timestamp units and timezones, nested types, fixed lengths,
and semantic field metadata. Destination schemas remain authoritative for field
names.
Generated columns are immutable and reusable. Several routes, or several
fields in one route, may reference the same generated index. Nervix decodes the
common batch once and clones the same ArrayRef; it does not copy, decode, or
serialize that column again. Every generated pool column must be referenced at
least once.
Rows are positional within one output group. For routed row R, a generated
reference reads generated array row R, while an input reference reads the
host input row selected by that routed row’s source_token. Every route that
references the generated pool must therefore have the pool’s row count and use
the same generated row order. Routes needing different generated counts,
ordering, guest-side filtering, or duplication must be queued as separate
output envelopes and returned by separate positive nervix_read_emit() calls.
Generated indexes never cross an envelope boundary. Route-level SET and
WHERE processing occurs after this materialization and does not prevent
sharing.
Uninitialized columns pass through route processing as explicit VM state. Any
expression read materializes a typed all-NULL column before applying ordinary
NULL semantics, so coalesce(uninitialized, value) yields value and
is_null(uninitialized) yields true. At the node boundary, an uninitialized
optional field becomes typed NULLs, while an uninitialized required field is a
schema error. The marker is not part of a Nervix relay schema and never crosses
a relay, IPC, persistence, interconnect, or node boundary. An uninitialized
descriptor does not by itself require source_token.
The ACK sidecar is:
{
"rows": [
{ "tokens": [u64, ...], "source_token": u64 | null }
],
"acked": [
{ "tokens": [u64, ...] }
],
"nacked": [
{ "tokens": [u64, ...], "reason": text }
],
"message_errors": [
{ "tokens": [u64, ...], "reason": text }
]
}
For every host input row, Nervix issues one token and sets both tokens and
source_token to that token. Preserve the complete row sidecar when filtering
or enriching. source_token is an optional FlatBuffers scalar and is absent
only for a generated row that has no input source.
If an output envelope contains any input column, every output row must have a
non-null, live source_token, and that token must occur in the row’s tokens.
It selects the retained host input row used for every referenced column in that
output row. It also selects the original record exposed through route
expressions such as input.field. A source token does not add an ACK use.
If the guest drops an input row, put that row’s token set in acked. To fail it
directly without invoking the processor message error policy, put it in
nacked with a reason.
Use message_errors for per-message guest errors that must be handled through ON MESSAGE ERROR (IGNORE, LOG, or SEND TO). Global errors are not part of the ACK sidecar because they are guest/node state, not message lineage.
Guests may expose this optional global-error channel:
nervix_global_error_ptr() -> i32
nervix_global_error_len() -> i32
nervix_clear_global_error() -> i32
If any of these exports exists, all three must exist. After host calls into the guest (nervix_init, nervix_process_batch, nervix_on_timeout, nervix_flush, and emit reads), and after a negative result from nervix_dump_state, nervix_load_state, or an emit read, it checks nervix_global_error_len(). A positive length means nervix_global_error_ptr() points at UTF-8 error bytes. The host reads the bytes, calls nervix_clear_global_error(), and reports them as the reason for the failure. Failures of initialization, restore, input processing, timeout callbacks, quiesce flush, and output validation apply ON GLOBAL ERROR; a failure to save, persist, or replicate guest state is always reported. Wasmtime call failures and traps are also handled as global processor errors.
The guest decides lineage; the host performs the actual ACK/NACK operation. Tokens are host-local hot-path capabilities. They are valid only while the current host instance is alive, and they are never persisted.
Each routed output retains its own sidecar because routes may carry different row lineage or terminal decisions. The host counts carried token uses across all routed outputs in the callback, so fan-out completes the original input ACK only after every downstream delivery completes. A terminal decision may occur only once across the validated callback.
The sidecars must be internally consistent:
rows.len()is the routed output row count and must equal the generated pool row count when that route references a generated column.- an uninitialized descriptor uses
rows.len()directly and must use canonicalcolumn_index = 0. - every token in
rows,acked,nacked, andmessage_errorsmust come from the current host-provided input sidecar. - a token may be carried into output rows, or terminally acked/nacked, but not both.
- a token may have at most one terminal decision across
acked,nacked, andmessage_errors. - a non-null
source_tokenmust be live and carried in its output row.
It is valid to carry the same input token into more than one emitted row, routed output, or output group. The host keeps attached child guards and resolves the original guard only after all derived deliveries complete. All output groups from one callback are validated together; no output is dispatched and no terminal decision is applied if any routed output is invalid.
For input references, Nervix retains the original input Arrow batch while its
tokens are live. Identity selections reuse the source ArrayRef, contiguous
selections use a buffer-sharing slice, and filtered, reordered, duplicated, or
cross-batch selections use host-side Arrow kernels. The guest never has to
serialize unchanged field values back to the host.
All table and vector fields are required, including empty vectors. Unknown fields are ignored for FlatBuffers schema evolution; unknown union or enum variants, missing required fields, a wrong identifier or size prefix, trailing bytes, invalid column counts, bad source tokens, malformed or trailing Arrow IPC, and exact-schema mismatches are global processor errors. Short headers are rejected before identifier access, including a complete size prefix whose declared length matches a buffer too short to hold the root and identifier. Empty output groups, out-of-range or unreferenced generated columns, and generated row-layout mismatches are also rejected. Nonzero uninitialized column indexes are rejected. CBOR and per-output generated-column envelopes are not supported. There is no format negotiation, legacy decoder, or fallback path. Rebuild every guest for this FlatBuffers contract.
State
Use branch-local guest state for the durable computation state a recreated instance needs to continue: counters, aggregates, open windows, or whatever else the guest derives from the input it has already accepted.
Nervix saves guest state through nervix_dump_state at the end of every guest callback, as the
checkpoint described below, and when it checkpoints a branch for an ownership handoff. It restores
the committed checkpoint through nervix_load_state, after nervix_init, when the branch instance
is recreated.
A save that fails — a negative nervix_dump_state code, a trap, or an exhausted limit — is reported
as a state snapshot failure and fails the checkpoint. Nervix never replaces the committed
checkpoint with the result of a failed save, so a recreated instance restores the last checkpoint
that completed. A save is bounded by the host’s 64 MiB guest buffer limit; a larger state fails the
save the same way.
Guest state holds computation state only. Everything an instance uses to execute belongs to that instance and is never saved:
- input the guest still buffers, together with its ACK tokens, row sidecars, and input-column references;
- output groups the guest has not emitted yet;
- timeout handles, which the host issues per instance;
- error state the guest latched after a failed callback.
A recreated instance therefore starts without buffered input, pending output, error state, or
pending timeouts. A guest releases buffered input from nervix_flush before a handoff, and a guest
whose restored state needs a timer requests it again from its next input or timeout callback.
Keep nervix_load_state strict about what it restores. Save what a restore needs to validate the
state, such as the branch configuration it was taken under, and reject state that does not match
the instance it is handed to. The Rust SDK’s snapshot envelope permits a restored domain name to
change if the domain type, branch key, and input and output schemas match. Guests with their own
snapshot format decide whether a domain name change is valid for their application state.
A concrete branch evicted for its TTL or instance limit stops without a quiesce flush, but its
checkpoint stays: when the same branch key appears again, its new instance restores the last
checkpoint that branch committed.
A zero-length nervix_dump_state result saves no state at all: the next instance is initialized
without a nervix_load_state call. A guest whose computation state can be empty therefore wraps it
in an envelope, as both example guests do with GuestSnapshot, so that empty computation state is
still restored as state.
A restore that fails leaves the saved state in place. The next input for that branch instantiates
the guest again and hands it the same saved state, so a guest that rejects its state keeps being
reported with the same saved state revision instead of silently starting fresh. Rejecting the
state with -7 or -8 is the only outcome that classifies the saved bytes as unusable; keep
nervix_load_state strict and use those codes only for a verdict on the state. A processor can opt
in to discarding a lifetime its guest rejected; see
Recovering A Rejected Snapshot.
State Generations
Every branch’s guest state belongs to a generation, one lifetime of that state that the committed schedule names. A save belongs to the generation that was current when the guest saved it, and Nervix persists, replicates, serves, restores, and recovers saved state only in the generation the committed schedule names for its branch. A snapshot of an earlier generation never becomes current again, whatever revision it carries: a former owner that restarts with older saves, a replica that missed a transition while it was offline, and a handoff prepared before the transition are all fenced out.
A new WASM processor starts every branch in its first generation, and a planned ownership handoff
keeps the generation. When an owner is lost and its state is recovered without it, the forced
recovery starts a new generation for every branch of the processor in the same schedule
publication that names the new owner. The checkpoint the recovery selects for a branch continues in
that generation; a branch with no surviving checkpoint of the generation being replaced starts
fresh, and SHOW CLUSTER STATUS reports it as a wasm_processor reset. A recovery whose
preparation fails, including one whose new owner cannot restore a staged checkpoint and one that
runs past its five-second budget, starts every branch fresh the same way, whatever
ON REJECTED STATE declares; see Forced Recovery. Applying the
same committed schedule again, after a restart or a rebuild, publishes nothing new.
A generation also belongs to the module binding it was published for. A model change that binds
another resource, version, or module file, such as a REBIND RESOURCE, starts a new generation for
every branch in the schedule publication that commits it, so the new module starts every branch
without guest state. Changing only the execution limits, the global error policy, or the
rejected-state policy leaves the binding alone and keeps every branch in the generation it has. The
node compiles the candidate module before the batch is published, so a module it cannot compile
rejects the whole change while the previous binding and its saved state are still the current ones.
See Resource Versions And Bindings.
Coordinated reset
The control plane can replace the state lifetime of the explicit unbranched instance, one concrete branch, or every branch of a WASM processor, and a guest can ask for the lifetime of its own branch to be replaced through the same operation. One-branch reset leaves every sibling generation and task intact. All-branches reset advances the processor’s shared generation and also covers branches that currently exist only as stored checkpoints, so a later appearance cannot restore their old state.
The owner first stops selected live instances at their callback boundary, initializes fresh guests
without calling nervix_load_state, and captures each fresh nervix_dump_state result. No durable
identity has changed yet; an initialization failure restores the stopped instances. The control
plane then publishes the new generation as Publishing. From that point, the old checkpoints are
unreachable and the operation cannot roll back. The owner durably publishes the selected branch
lifecycle, writes the first checkpoint of each fresh guest, and waits for the assigned replicas
before the schedule becomes Ready and input resumes.
Accepted input drains before the stop boundary, and its buffered output and acknowledgements are finalized once. Work still suspended on materialized state is discarded and negatively acknowledged. Timeout handles and remaining guest-local capabilities are not copied into the fresh lifetime. A pre-publication failure reports an ordinary reset failure with the old lifetime still usable. A post-publication failure reports a committed but unusable reset; retrying the same stable request reference resumes its initial checkpoints without advancing the generation again. Restart, owner failover, and an offline replica follow the same published generation, and no stale former owner can make an older checkpoint current.
A checkpoint of an instance whose generation or ownership has already moved on is refused before
anything is saved, persisted or replicated, and is reported as a state authority check failure.
Checkpoints are written to the node’s state store on its storage workers, never on the worker that
runs the guest.
ACK tokens are separate from guest state. They are host-local hot-path runtime capabilities and are not persisted or replicated. If ACK state is lost with a processor owner, the upstream ingestor reacts according to its delivery mode and retry policy.
Guest-requested reset
A guest asks for the state lifetime of the branch it runs in to be replaced by calling
nervix_request_state_reset() from a callback. The host performs the replacement after the
callback returns and never calls back into the guest to do it. The request selects nothing: it is
scoped to the calling branch and to the generation that branch is running in, so a guest can never
name another processor, domain, branch, or generation. Every accepted request is routed through the
same coordinated reset described above, so it has the same durability, replica, fencing and
recovery guarantees as an operator’s. DESCRIBE WASM PROCESSOR reports it with the reason
GUEST, and its FORMAT JSON inspection carries its request reference, which begins with
wasm-guest-reset..
Only the callbacks that end with a checkpoint own uncommitted effects a reset can discard, so only those admit a request: an input batch, a timeout callback, and a quiesce flush. Every other operation, including initialization, saving, and restoring, refuses the request and schedules nothing.
An accepted request is terminal for the callback that made it:
- Output that callback emitted is discarded and never dispatched.
- Every input the branch holds, including input the guest still buffers, is negatively acknowledged, so a source with acknowledgements redelivers it into the new lifetime.
- No checkpoint follows, because the state that callback would save is the state the reset discards.
- The instance is dropped with its pending timeouts, and the branch accepts nothing more until the new lifetime is durable. It then resumes through a fresh instance restored from that lifetime’s initial checkpoint, which the failed callback can never observe.
- Effects earlier callbacks published stand, because their checkpoints completed; their acknowledgements settle exactly once.
A callback that asks and then fails is still asking, and asking repeatedly — several times in one callback, or from several callbacks of one lifetime — replaces that lifetime exactly once and initializes one fresh guest. Acceptance says only that the host took the request; it is never proof that a new lifetime became durable. A reset that fails is reported as a runtime error naming the processor and domain, the branch keeps the lifetime the reset could not replace, and the guest asks again from its next callback.
Clearing the guest’s own fields is an ordinary application-state mutation that the next checkpoint saves, and needs none of this.
Recovering A Rejected Snapshot
ON REJECTED STATE is the processor’s answer to a guest that refuses the snapshot it is handed. It
is written between the last TO clause and ON GLOBAL ERROR, it is optional, and it has two
values:
PRESERVE, the default. The refused snapshot stays exactly where it is. Every later instance of the branch is handed the same bytes and reports the same failure, so the state is still there when an operator decides what to do with it — including running a coordinated reset by hand.RESET. Nervix replaces that branch’s state lifetime once, through the same coordinated reset, and the branch resumes on the fresh lifetime it publishes.
Only the guest’s own verdict — -7 or -8 from nervix_load_state — reaches this policy. A
module that does not compile, an initialization the guest refuses, an exhausted MAX FUEL or
MAX MEMORY, a trap while restoring, and every storage, replication and state-authority failure
leave the saved bytes as usable as they were, so the policy never discards state for any of them.
Losing the branch’s owner is the one path that can recreate state without a verdict; see
Forced Recovery.
One refused lifetime is worth exactly one recovery attempt. Nervix records that the attempt was spent before it resets anything, so the budget survives whatever happens next:
- The reset publishes a fresh lifetime and the branch resumes on it. The branch has moved on, and a guest that later refuses that lifetime is a new failure with an attempt of its own.
- The reset fails before it publishes. The previous lifetime — refused bytes and all — is still there, and the spent attempt means the branch reports its refusal from then on instead of resetting again on every arriving record.
- The reset is committed but its fresh lifetime is not usable. Admission for the branch stays fenced under the published generation, exactly as it does for an operator-requested reset that ends the same way.
The record is control-plane state, so a process restart, a leader change and an owner change all
read the same spent attempt. Nothing about the refused guest’s memory is kept: the record names the
branch by its opaque fingerprint, the generation that was refused, which of the two verdicts the
guest gave, and what the attempt achieved. DESCRIBE WASM PROCESSOR reports it as a
rejected state recovery line, beside the rejected state policy the processor declares.
Checkpoints And Acknowledgements
Every guest callback that runs — an input batch, a timeout callback, and a quiesce flush that emits output — ends with a checkpoint of the guest instance it leaves behind, and Nervix acknowledges an input as successful only after the checkpoint that covers it has completed. A branch handles one callback at a time, in this order:
- Nervix runs the callback with its execution snapshot and reads every output group the guest emits.
- It validates the callback’s output as a whole. Output that fails validation dispatches nothing,
and every input the branch holds goes through
ON GLOBAL ERRORinstead. - It dispatches the output rows to their relays, negatively acknowledges the
nackedinputs, and routesmessage_errorsthrough the route’sON MESSAGE ERRORpolicy. It holds back the success of every other input the callback decided: inputs carried into output rows,ackedinputs, handled message errors, and inputs thatON GLOBAL ERROR IGNOREaccepts after a failed callback. Output reaches downstream nodes, and can reach sinks, while its checkpoint is still on its way; only the acknowledgement waits. Until the checkpoint ends, Nervix reports the inputs it holds back alive to their source, so a source’sACK TIMEOUTshorter than the checkpoint does not deliver them again. - It saves the guest with
nervix_dump_state. The saved state is captured and stamped with the branch’s next revision. - It writes that revision to the node’s stable storage and waits until the storage is synchronized. The state is then locally durable. Checkpoints taken by different branches at the same time share one synchronization.
- When the committed schedule assigns the processor replicas, every replica fetches the revision,
writes it to its own stable storage, synchronizes it, and only then acknowledges it. Nervix
waits until every assigned replica has, and the state is then replica-confirmed. A processor
with no assigned replica, which is always the case with a replica count of
0, completes its checkpoints on local storage alone. - It commits the checkpoint, which becomes the state a recreated instance restores, and releases the held acknowledgements. An input’s acknowledgement then succeeds as soon as every delivery the callback made for it has succeeded as well.
- Only then does the branch run its next callback.
Only an ATTACHED processor, the default, holds its source’s acknowledgements this way. A
DETACHED processor receives its input after relay fan-out has acknowledged it upstream, so no
source acknowledgement waits for its checkpoints, although its guest state is checkpointed the same
way.
The replicas a checkpoint waits for are the ones the committed schedule assigns when it is captured,
and SHOW CLUSTER STATUS lists them. While it waits, the checkpoint follows the schedule: a replica
the schedule replaces is replaced in the wait as well. It never completes with fewer replicas than it
was captured for, and never after the branch’s state generation or owner has moved on. A later
checkpoint uses the replicas assigned when it is captured.
The whole checkpoint, from the save to the last replica’s acknowledgement, must complete within ten seconds. Branches are independent: one branch waiting for its checkpoint delays no other branch’s callbacks.
When A Checkpoint Fails
A checkpoint fails when the guest cannot save (state snapshot), when this node cannot write or
synchronize it (local state persistence), when an assigned replica does not hold it within the
deadline or the schedule assigns fewer replicas than it was captured for (state replication), and
when the branch’s state generation or ownership has moved on (state authority check). Each
failure is reported as a runtime error at its stage, whatever the
processor’s ON GLOBAL ERROR policy, and then:
- Every input the callback decided, and every input the guest still buffers, is negatively acknowledged. A source with acknowledgements redelivers them.
- The committed checkpoint stays where it was.
- The guest instance is discarded with the state nothing committed, together with its buffered input, pending output and timeouts. The branch never continues from that state: its next input instantiates the guest again from the committed checkpoint.
DESCRIBE WASM PROCESSOR reads the scheduled binding and state lifetime beside a read-only sample
of the owner’s current checkpoints. The reset line reports its PUBLISHING or READY phase, reason,
scope, and selected generation. PUBLISHING remains fenced even if its initiating command has
returned an error; READY means the new initial checkpoint reached its durability boundary and
the schedule admitted the new lifetime. Reset readiness reports RESETTING while the selected
checkpoint is unfinished, AWAITING_USABLE_EXECUTION when a selected single-branch checkpoint is
confirmed but its scope is still fenced, and READY when the ready schedule is published. An
all-branches reset remains RESETTING until ready publication because a sample of currently
materialized branches cannot establish that every selected branch completed. Rejected-state recovery
lines retain the original guest
rejection class and the attempt’s outcome, including a failed fresh initialization.
For each current branch, the description gives its generation, committed and latest revision,
checkpoint stage, and required and confirmed replica counts. CAPTURED has not reached local
storage, LOCALLY_DURABLE reached it but has not completed the replica boundary, and
REPLICA_CONFIRMED completed its boundary. A restored checkpoint whose earlier replica boundary
cannot be reconstructed reports replica counts as unknown rather than claiming confirmation.
FAILED leaves the previous committed revision current until another checkpoint succeeds. When
the failure occurred before capture, the latest revision and replica counts are absent because no
new checkpoint exists. Branch
identities are fixed-size opaque fingerprints; guest bytes and branch field values never enter the
inspection. Counts cover all current branches, while detailed branch and recovery entries have a
fixed report limit. FORMAT JSON serializes the same typed inspection; it is also returned to
clients beside either rendering. JSON is the state inspection itself, so an unscheduled processor
reports null rather than inventing runtime progress.
A failed synchronization of a node’s stable storage is not retried. The operating system may have dropped the writes it could not flush, and the database refuses to synchronize again, so every later checkpoint on that node fails until the node restarts.
Recovery, Replay And Duplicates
A checkpoint is not a transaction that spans the guest, its output and external sinks. It guarantees exactly one thing: once a source has received a successful acknowledgement for an input, the guest state that reflects the input is on the stable storage of the branch’s owner and of every replica the checkpoint was confirmed by. What that means differs for guest state, for the source, and for sinks.
- Guest state. A restarted node restores each branch from the newest checkpoint on its own storage. After an owner is lost, forced recovery continues each branch from the newest surviving checkpoint of its generation when the new owner prepares the recovery, and with replicas, a replica holds every checkpoint whose acknowledgements were released. Without replicas, only the owner’s own storage holds a branch’s checkpoints, so recovering without that node resets the branch.
- Source replay. An input whose acknowledgement is withheld — its checkpoint failed, its node stopped first, or the guest still buffers it — is redelivered by a source with acknowledgements and lost by one without. The state the branch continues from can already reflect a redelivered input: a guest counts buffered input as it accepts it, a checkpoint that failed at its replicas stays on the owner’s storage, and a replica can hold a checkpoint whose acknowledgement it sent too late. A guest therefore applies a redelivered input again unless it recognizes the input itself.
- External delivery. Output is dispatched before its checkpoint completes, so a sink can emit output whose input is later negatively acknowledged and redelivered, and the redelivered input produces that output again. Delivery through a WASM processor is at least once; see ACK Semantics And Effective Delivery.
Where the checkpoint of the last callback had got to when its owner stopped decides which state the branch continues from. In every window the callback’s output may already have been dispatched, and its inputs were not acknowledged, so a source with acknowledgements redelivers them:
| The owner stopped | The branch continues from | A redelivered input |
|---|---|---|
| After dispatching output, before the guest saved | The previous checkpoint | Is applied once more to state that never reflected it, and can emit its output again |
| After the save was captured, before it reached stable storage | The previous checkpoint | Is applied once more to state that never reflected it, and can emit its output again |
| After it reached the owner’s stable storage, before every replica confirmed it | On restart, this checkpoint; after owner loss, whichever of the two checkpoints the promoted replica holds | Is applied again to state that may already reflect it |
| After every replica confirmed it, before the acknowledgements were released | This checkpoint, on restart and after owner loss | Is applied again to state that already reflects it |
A source that keeps redelivering an input while no owner accepts it can deliver it to the recovered branch more than once, so the number of times a redelivered input is applied is not fixed. A source without acknowledgements delivers nothing again, and the input is lost in the first two windows. Failure At Each Boundary extends this account to the control plane’s authority boundaries.
A branch holds back the acknowledgements of one callback at a time, for no longer than the checkpoint deadline. Stopping a node ends a branch still waiting for its checkpoint after the branch’s stop grace, and whatever it held is negatively acknowledged when the node’s drain ends.
Timeouts
A guest can call nervix_timeout_after_nanos(delay) while processing. The request is anchored at
that operation’s supplied domain snapshot, and delay is a logical duration. The host returns a
monotonically increasing handle for that branch instance. When the same bound domain and START
generation reach the requested instant, the host calls:
nervix_on_timeout(handle)
After any successful timeout callback, the host repeatedly calls
nervix_read_emit() and forwards every emitted output group. Shared generated
columns work identically in timeout output. Input-column references remain
valid while their source token is live.
Timeout handles belong to the branch instance that requested them. A recreated instance starts without pending timeouts and never receives a handle issued to an earlier instance. A guest-requested reset drops the instance that asked together with every timeout it had requested, so no old handle survives into the new lifetime.
Quiesce Flush
When Nervix quiesces a branch — to hand it to a replacement node during an ENTITY_PAUSE
alteration or an ownership move, to pause its domain, to stop it for a coordinated reset, or to shut
it down — it calls:
nervix_flush()
Emit every buffered output group during this call; the host drains them with nervix_read_emit()
exactly as it does after a timeout, and a flush that emitted output is completed by a
checkpoint like any other callback. A flush that emits nothing
decides no input, so no checkpoint follows it. A guest that keeps input past nervix_flush leaves
it unacknowledged until the branch resumes, so a guest that buffers between calls must release that
buffer here instead of waiting for more input or for its next timeout. A guest that never buffers
can return success without emitting.
This is what lets a WASM processor participate in ENTITY_PAUSE like every other stateful node:
the host pauses intake at the relay gate, asks the guest to flush, checkpoints it, and restores that
checkpoint into the replacement instance.
Rust SDK
The nervix-wasm-sdk crate owns the complete exported ABI surface — the
reusable buffer, envelope encoding and decoding, the emit queue, the
global-error channel, panic conversion, error-state latching, and
GuestSnapshot plumbing — and exposes the typed Processor trait instead.
See Rust WASM Guest SDK for installation and usage, and
examples/wasm-processors/rust-guest for the complete reference guest.
Go Sketch
The prototype Go guest is in examples/wasm-processors/go-guest and is built with TinyGo:
//go:wasmimport env nervix_domain_time_nanos
func hostDomainTimeNanos() int64
//go:wasmimport env nervix_timeout_after_nanos
func hostTimeoutAfterNanos(delayNanos int64) int64
var buffer []byte
var pendingEmit [][]byte
//export nervix_alloc
func nervixAlloc(size int32) int32 {
if size < 0 {
return -1
}
if cap(buffer) < int(size) {
buffer = make([]byte, int(size))
} else {
buffer = buffer[:int(size)]
clear(buffer)
}
return int32(uintptr(unsafe.Pointer(&buffer[0])))
}
//export nervix_process_batch
func nervixProcessBatch(ptr int32, size int32) int32 {
input, code := readBufferRange(ptr, size)
if code != 0 {
return code
}
envelope, code := decodeEnvelope(input)
if code != 0 {
return code
}
_ = hostTimeoutAfterNanos(1_000_000_000)
pendingEmit, code = buildOutputEnvelopes(envelope)
return code
}
//export nervix_read_emit
func nervixReadEmit() int32 {
if len(pendingEmit) == 0 {
return 0
}
next := pendingEmit[0]
pendingEmit = pendingEmit[1:]
buffer = append(buffer[:0], next...)
return int32(len(buffer))
}
The Go guest uses bindings generated from nervix_wasm.fbs. FlatBuffers byte
vectors are exposed as slices backed by the input buffer, so parse Arrow IPC
directly from those slices while the ABI buffer remains alive. Guest-side
domain structs should stay explicit before they are passed to the generated
builders:
type ackSidecar struct {
Rows []outputRow
Acked []ackTokenSet
Nacked []nackSet
MessageErrors []messageErrorSet
}
type outputRow struct {
Tokens []uint64
SourceToken *uint64
}
type ackTokenSet struct {
Tokens []uint64
}
type nackSet struct {
Tokens []uint64
Reason string
}
Use the generated MessagePayload union and enum values rather than string
tags. In particular, an input reference to column zero must set the generated
column index to 0. The same filter contract applies in Go: preserve the
complete row sidecar for rows you emit, add dropped rows to Acked, and add
rejected rows to Nacked with a reason.
Build with TinyGo’s non-WASI wasm-unknown target; standard Go only produces
js/wasm and wasip1/wasm modules. TinyGo compiles against the standard library
of the Go toolchain that go selects, so select the toolchain pinned in the
guest’s go.mod explicitly; automatic selection keeps a newer host Go that
TinyGo may not support:
cd examples/wasm-processors/go-guest
GOTOOLCHAIN="$(sed -n 's/^toolchain //p' go.mod)" tinygo build \
-target=wasm-unknown \
-scheduler=none \
-opt=z \
-panic=trap \
-no-debug \
-o nervix_wasm_processor_go_guest.wasm \
.
Common Mistakes
- Do not use WASI imports. The host does not provide WASI.
- Do not keep global mixed-branch state. Each module instance is branch-local.
- Do not invent ACK tokens. Only carry, ack, or nack tokens that arrived in the input sidecar.
- Do not omit
source_tokenwhen preserving an input-derived row. - Do not serialize a generated column separately for every route; place it once in the common generated batch and reference its index.
- Do not give common generated schema fields destination names; their names must be empty.
- Do not emit an encoded zero-column Arrow stream; use an empty byte string for an empty pool.
- Do not leave generated pool columns unreferenced or reuse a pool for routes with different generated row layouts.
- Do not rebuild unchanged input fields in the guest; emit input-column references.
- Do not ack/nack a token and also carry it into an emitted row.
- Do not silently accept an init payload whose schema does not match what the guest implements.
- Do not persist guest state in a custom host-facing format unless
load_statecan reject bad bytes cleanly. - Do not save buffered input, ACK tokens, pending output, timeout handles, or latched error state in guest state; save only durable computation state.
- Do not call host ACK/NACK directly. The guest only reports lineage and decisions in the sidecar.
Failure Diagnostics
Nervix reports a module that fails to compile, and every failure of a branch instance’s guest operations and saved state, in one shape:
wasm processor '<processor>' <stage> failed (<branch>, resource '<resource>' version <version> file '<file>'[, export '<export>'][, saved state revision <revision>]): <cause>
<branch> is branch followed by the lowercase hexadecimal fingerprint of the concrete branch’s
key, or unbranched. The fingerprint is the one DESCRIBE WASM PROCESSOR prints beside the
branch’s checkpoint, and a diagnostic never contains the key’s field values, which the branch
schema may declare SENSITIVE. <export> names the
guest export whose call failed, or the export that runs the failed operation. <revision> appears
when the failure involves saved state: the revision being restored or the revision being persisted.
<cause> is the complete chain of failures below the stage, ending with the guest’s own reason when
it put one on the global-error channel. A module that fails to compile is reported without a branch
or export. Diagnostics never contain guest payloads or saved state bytes, and a diagnostic raised on
another node reaches the session unchanged.
| Stage | What failed |
|---|---|
module compilation | Resolving, reading, or compiling the pinned module file. |
instantiation | Creating the branch store, instantiating the module, or resolving its exports. |
initialization | nervix_init. The saved state revision appears when the instance was about to restore state. |
snapshot envelope decoding | nervix_load_state rejected the saved bytes with -7. |
application state restoration | nervix_load_state rejected the saved application state with -8. |
state restore | nervix_load_state failed without a verdict: a trap, an exhausted limit, or another negative code. |
batch processing | nervix_process_batch and the emit reads that follow it. |
timeout callback | nervix_on_timeout and the emit reads that follow it. |
quiesce flush | nervix_flush and the emit reads that follow it. |
output emission | An emitted envelope the host cannot decode, or output that fails validation. |
state snapshot | nervix_dump_state. Nervix keeps the state saved last. |
local state persistence | Writing the checkpoint to the node’s stable storage and synchronizing it. |
state replication | Waiting for every assigned replica to hold the checkpoint on its stable storage, or the schedule assigning fewer replicas than the checkpoint was captured for. |
state authority check | The state’s authority refused it: a replica or peer that is not the state’s authority, or this node after the branch’s state generation or ownership moved on. |
For example, the branch keyed by tenant alpha whose guest rejects its saved counters after an
instance was recreated is reported as:
wasm processor 'sessionizer' application state restoration failed (branch 40238306d5ebf339fe640ae723e4705c5bb1743e39d0ec207ed8de19fead54b1, resource 'sessionizer' version 3 file 'sessionizer.wasm', export 'nervix_load_state', saved state revision 12): wasm guest rejected the application state in its saved snapshot: counters header is truncated
Troubleshooting
module compilation failed-
The module is not valid
wasm32-unknown-unknown, imports something outside theenvfunctions listed above, or is not actually a WASM module. missing required export-
The guest did not export one of the required
nervix_*functions with the expected C ABI signature. guest buffer size ... exceeds configured limit-
The host refused to write or read a buffer larger than the configured safety limit. Split the output, reduce the input batch size, or raise
MAX MEMORYif the processor’s linear-memory ceiling is the constraining limit. wasm guest exhausted MAX FUEL ...-
One logical guest operation consumed its complete Wasmtime fuel budget. Raise
MAX FUELfor expected work or remove an unbounded/overly expensive guest loop. wasm guest exceeded MAX MEMORY ...-
The branch guest tried to instantiate or grow linear memory beyond
MAX MEMORY. Reduce guest allocation or raise the declared ceiling. generated column ... has ... rows, but the routed output has ... rows-
A generated pool and one referencing route disagree. Emit exactly one
rowsentry per generated Arrow value, or move the incompatible route into a separate output group. missing source token for output row ...-
An output uses an input-column reference, but the row does not select a live input row. Preserve the host-provided
source_tokenand keep it intokens. wasm output referenced unknown ack token ...-
The guest emitted or completed a token that did not come from the host input sidecar for the current live instance.
snapshot envelope decoding failedorapplication state restoration failed-
The guest rejected the saved state it was asked to restore. Under the default
ON REJECTED STATE PRESERVE, Nervix keeps that state and reports the rejection again, with the same saved state revision, each time the branch is instantiated. Keepload_statestrict; rejecting the state is preferred to running with partially decoded state. DeclareON REJECTED STATE RESETto have the branch’s lifetime replaced once instead; see Recovering A Rejected Snapshot. rejected-state recovery failed-
A processor that declares
ON REJECTED STATE RESETcould not replace the rejected lifetime, and the one attempt that lifetime was worth is now spent. The branch keeps reporting its rejection until an operator acts.DESCRIBE WASM PROCESSORreports the attempt as arejected state recoveryline naming the generation, the verdict and the outcome. state restore failed-
Restoring the saved state failed without a verdict on it, for example because the guest trapped or exhausted
MAX FUEL. Fix the failure; the saved state is unchanged and is restored again on the next attempt.
Emitters
Emitters publish relay records to external systems or to application consumers through a client session.
A typical emitter:
CREATE IF NOT EXISTS EMITTER kafka_notifications
FROM notifications
COLLECT FOR 10ms MAX BATCH SIZE 1MiB
TO KAFKA kafka_main TOPIC notifications_out
MODE ACK PARALLEL MAX 1000 ACK TIMEOUT 30s
RETRY POLICY BACKOFF 250ms MAX 30s
ENCODE USING notification_codec
INHERIT ALL
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
An emitter defines:
- one or more source relays that declare the same payload schema
- an optional input collection policy
- the codec used for encoding
- the transport-specific sink
- the sink’s explicit publishing mode, confirmation window and bound where applicable, and retry pacing
- an optional batching clause bounding how many records one external write carries and its encoded size, required for database sinks
- the flush policy used to collect a batch before publishing
- whether the branch is
ATTACHEDorDETACHED - route-local codec construction or a direct
VALUESmapping - optional ordered header invocations on supported codec sinks
- optional ordered materialized-state dependencies
Branch Semantics
An emitter is the terminal consumer for its source relays. The FROM list uses the same
source-local predicate form as other relay-consuming nodes:
CREATE EMITTER combined_notifications
FROM primary_notifications WHERE input.source = 'primary',
replayed_notifications WHERE input.source = 'replay'
TO KAFKA kafka_main TOPIC notifications_out
MODE NO_ACK RETRY POLICY BACKOFF 250ms MAX 30s
ENCODE USING notification_codec
INHERIT ALL
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
Every listed relay must declare the exact same schema name. Unlike ordinary multi-input processors, emitter inputs may be unbranched or use differently named branches. Each source keeps its own branch identity until its records cross the successful external boundary.
That means:
- the emitter consumes from all concrete branches of every source relay
- each optional source
WHEREis evaluated only for that relay - the current branch remains available internally for compatible materialized-state lookup
branch.fieldis unavailable to successful emitter expressions- branch identity collapses only after successful external publication
A node-wide materialized-state dependency and an ON MESSAGE ERROR SEND TO route must be
exact-branch compatible with every source. Consequently, an emitter whose inputs use differently
named branches cannot configure one branch-bound dependency or error relay across those inputs.
All emitters declare FLUSH EACH <duration> MAX BATCH SIZE <bytes> or FLUSH IMMEDIATE, and
DESCRIBE EMITTER reports the declared policy on its flush: line. FLUSH
means Nervix collects an in-memory Arrow batch before handing it to the external sink. FLUSH EACH
is a domain-logical duration, so a paced domain’s TIME RATE accelerates or slows it; retry
backoff, acknowledgement keepalive, and sink acknowledgement timeouts stay on the physical clock.
See Domains And Time for the full clock split. The
NSPL Overview defines the FLUSH IMMEDIATE 100 µs minimum batching window.
For most emitters the collected batch is encoded and published on the flush boundary. Iceberg
additionally requires COMMIT EACH <duration> MAX SIZE <bytes> as part of its sink clause: flush
writes local Arrow IPC staging files, and commit appends the staged data to object storage. ON MESSAGE ERROR SEND TO
buffers failed-message error records separately and delivers them using the emitter’s same FLUSH
interval or maximum batch-size boundary. Which sinks gain from larger flush batches, and which
publish per record regardless of batch size, is covered by the
flush tuning guidance.
An emitter may place COLLECT FOR <duration> [MAX BATCH SIZE <bytes>] immediately after the
complete FROM <relay> [WHERE ...] [, ...] list. This input policy runs before emitter filtering,
construction, encoding, and the required output FLUSH policy. Omission means no additional input
collection: each incoming relay batch enters emitter execution directly. When configured,
collection is independent for each source relay and concrete branch and releases on the timer or
optional size boundary. Equal keys from differently named branches are never collected together.
Branch identity still collapses only after successful publication.
Emitter filtering, construction, headers, direct VALUES, encoding, and generated integration
timestamps use the batch’s accepted domain execution snapshot. A failed publish keeps that
snapshot with the pending batch across physical retry backoff; retry does not reevaluate
expressions at a later domain time. Explicit source event timestamps remain unchanged. Fields
whose public contract is observation time, such as OTEL observed_time_unix_nano, read actual UTC
only at the external export boundary.
Publishing modes
Every emitter sink requires MODE <body> before its body selection, immediately before
ENCODE USING when the sink uses a codec. There is no implicit mode, confirmation window, ACK
timeout, or retry cadence. SHOW CREATE EMITTER and DESCRIBE EMITTER render the complete mode.
The shared variables are:
ACK SEQUENTIALpublishes and confirms one record before sending the next.ACK PARALLEL MAX <n>permits at mostnrecords to await confirmation, wherenis at least one. Nervix fills the window, waits for the oldest confirmation when it is full, and completes a flush only after every record in that flush has been confirmed.ACK TIMEOUT <duration>bounds one asynchronous broker confirmation. Expiry is ambiguous, not a record rejection: Nervix retries the still-unconfirmed records as an infrastructure failure. The broker may have accepted a timed-out record, so confirming modes are at least once and can duplicate on this path.RETRY POLICY BACKOFF <duration> MAX <duration>is required by every mode. Infrastructure retry delays begin atBACKOFF, double on each attempt, and cap atMAX. A server-requested delay, such as an HTTP rate-limit interval, can extend an individual delay; one the node’s monotonic clock cannot schedule extends nothing. Retries continue with backpressure until the external system recovers or an operator repairs its provisioning.
Request/response sinks—SQS, Sentry, OTEL, HTTP, the databases, and Iceberg—do not take
ACK TIMEOUT; their client request timeout bounds the response. SQS, Sentry, OTEL, and ClickHouse
clients expose that bound as the optional timeout_ms CONFIG key, and a client an HTTP emitter
uses must declare it. For every sink, Nervix accounts for records individually
wherever the transport exposes individual results: delivered records acknowledge upstream,
definitively invalid records follow ON MESSAGE ERROR, and a retry resends only records that are
neither delivered nor rejected. An ambiguous or infrastructure-wide failure is never used to
discard a record.
| Sink | Mode forms | Publish success boundary |
|---|---|---|
| Kafka | NO_ACK; ACK SEQUENTIAL; ACK PARALLEL MAX <n> | Local producer-queue acceptance for NO_ACK; one delivery report per record for ACK |
| Pulsar | NO_ACK; ACK SEQUENTIAL; ACK PARALLEL MAX <n> | Producer acceptance for NO_ACK; one broker receipt per record for ACK |
| RabbitMQ | NO_ACK; ACK SEQUENTIAL; ACK PARALLEL MAX <n> | The channel’s answer to one round trip after each write for NO_ACK; publisher confirm for ACK |
| MQTT | QOS 0; QOS 1 ACK ...; QOS 2 ACK ... | Client acceptance, PUBACK, or completion of the QoS 2 handshake respectively |
| NATS | NO_ACK; JETSTREAM ACK SEQUENTIAL; JETSTREAM ACK PARALLEL MAX <n> | Core-NATS connection flush for NO_ACK; JetStream PubAck otherwise |
| Redis Pub/Sub | NO_ACK | Server acceptance of PUBLISH; the subscriber count is not a delivery guarantee |
| ZeroMQ | NO_ACK | Socket acceptance |
| SQS | SINGLE; BATCH | Successful per-record or per-entry service response |
| Sentry | ACK | Successful one-event envelope response |
| OTEL | ACK | Successful OTLP Export response; partial_success is acknowledged with a warning |
| ClickHouse, Postgres, MySQL, MongoDB | ACK | Successful insert/write result |
| Iceberg | ACK | Successful catalog commit |
| HTTP | ACK | Complete successful response headers |
| Client | ACK SEQUENTIAL; ACK PARALLEL MAX <n> | A current consumer attempt’s explicit application ACK |
Client emitters
The Client Ingestors And Emitters architecture chapter connects construction, competing consumers, application ACK, buffering and lifecycle in a complete graph.
TO CLIENT SCHEMA <output_schema> constructs native Arrow output for applications. It has no
connector CLIENT object, codec, header operations, or direct VALUES form. The emitter still
uses its ordinary FROM predicates, optional collection, materialized dependencies, ordered
INHERIT and SET, route WHERE, error policies, placement, and ATTACHED or DETACHED
boundary. A client sink requires MODE ACK SEQUENTIAL or MODE ACK PARALLEL MAX <n>, an explicit
ACK TIMEOUT and RETRY POLICY, BATCH MAX MESSAGES <1..65536> MAX SIZE <bytes>, and its
ordinary required FLUSH policy. SHOW CREATE EMITTER preserves this full contract.
The declared output schema is exact. Construction starts empty; only explicitly inherited or set
fields are exported. A sensitive input cannot be copied into output without explicit
leak_sensitive(...). Branch fields do not become expression values. Every prepared Arrow IPC
stream contains rows from one source relay and one concrete branch, within both declared row and
encoded byte limits. One row larger than the limit follows ON MESSAGE ERROR; it does not weaken
the batch bound. A delivery carries an opaque branch fingerprint, not the branch key’s values.
Consumers of one emitter compete for its output. ACK SEQUENTIAL permits one outstanding batch
per source relay and concrete branch; ACK PARALLEL MAX <n> permits at most n across all
workers of that source and branch. The application may ack, retry, or reject a live attempt.
Only ack confirms the batch. A retry keeps the original IPC bytes, member positions, identity,
and execution snapshot and waits on physical backoff before a fresh attempt. A timeout or lost
consumer revokes its ACK reference before reassignment. A later ACK for that reference is stale;
repeating a confirmed ACK is idempotent while its bounded result is retained. reject applies the
route’s message error policy to every batch member with a bounded, non-sensitive reason. An
ATTACHED emitter keeps its source acknowledgement waiting for the application ACK; a DETACHED
emitter keeps its existing earlier source boundary. There is no durable consumer cursor or
delivery history: an owner loss can require upstream replay, and a lost ACK can duplicate an
application effect.
DESCRIBE EMITTER reports active consumers, forwarded consumers and their granted credit,
retained batches and IPC bytes, the forwarded subset of that retained work, assigned batches still
awaiting application processing, retries, application ACKs, and application rejections. Retained
work and forwarding counts describe the executing node’s current owner generation; outcome
counters remain on that node across emitter restarts.
HTTP request configuration
The HTTP Emitter Architecture chapter follows the validated request through host preparation, the connector, retry, lifecycle and qualification evidence.
An HTTP emitter uses an existing TYPE HTTP client and declares the request method, path and body
selection in this order:
TO HTTP <client>
METHOD <string_expression>
PATH <string_expression>
MODE ACK RETRY POLICY BACKOFF <duration> MAX <duration>
(ENCODE USING <codec> | WITHOUT BODY)
METHOD and PATH are structured expressions. They may read qualified source fields such as
input.method and input.path; parentheses, arrays and string literals keep clause words inside
the expression. MODE ACK has no acknowledgement window or ACK TIMEOUT, and NO_ACK is not
available. The ordinary route-local FLUSH clause is required.
The referenced client must be a TYPE HTTP client in the same domain. Its endpoint is an
http:// or https:// origin with a host and optional port, and no credentials, non-root path,
query or fragment. It requires a positive, schedulable timeout_ms; the client’s polling method
setting does not supply the emitter’s METHOD. HTTPS retains certificate and hostname verification.
The optional tls_cert_file and tls_key_file settings must be supplied together, and mounted TLS
files use the client’s pinned resource version. Validation does not probe the destination.
Method, path and write_header name and value expressions must each be exact, non-null STRING
values. Request expressions read the original input and, with a codec, finalized output and
message. Without a body, message is the source and output is unavailable. Sensitive values
in any request field or body require explicit leakage. Branch fields and source-envelope header
reads are unavailable. A literal invalid method, target or header rejects configuration; the same
rules are checked per record for computed values before publication.
Methods are ASCII HTTP tokens of at most 64 bytes. CONNECT and TRACE are unavailable, and GET
and HEAD require WITHOUT BODY; both rules apply to every ASCII case variant, while any other
method is sent with exactly the spelling it evaluated to. PATH begins with exactly one / and is
parsed against the client origin. It cannot include a fragment, backslash, invalid percent escape,
whitespace or control character; normalization must keep it on that origin and must not produce a
leading //. The normalized target is limited to 8 KiB. write_header accepts valid HTTP field
names and UTF-8 values without control characters or leading/trailing whitespace in a nonempty
value. Transport-owned headers cannot be written. After case-insensitive replacement, at most 128
application headers and 32 KiB of name/value bytes are allowed.
ENCODE USING permits the ordinary transforming construction clauses. WITHOUT BODY selects an
absent request body and permits WHERE and INVOKE but no INHERIT, SET or VALUES. HTTP
emitters publish one request per eligible source record, so they do not accept the optional
BATCH clause. SHOW CREATE EMITTER preserves both request expressions and the explicit body
selection.
For example, the first emitter below sends each record of outgoing with its own method, path and
tenant header and a JSON body of two of its fields, and the second deletes without a body.
api.example.com stands for an endpoint the operator has already provisioned.
CREATE CLIENT api TYPE HTTP CONFIG {
'endpoint' = 'https://api.example.com',
'timeout_ms' = 5000
};
CREATE ATTACHED EMITTER deliver_event
FROM outgoing
TO HTTP api
METHOD input.request_method
PATH input.request_path
MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
ENCODE USING event_body_codec
INHERIT event_id, payload
INVOKE write_header('Content-Type', 'application/json'),
write_header('X-Tenant', input.tenant),
write_header('Idempotency-Key', input.event_id)
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
CREATE EMITTER delete_event
FROM outgoing WHERE input.request_method = 'DELETE'
TO HTTP api
METHOD 'DELETE'
PATH input.request_path
MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
WITHOUT BODY
INVOKE write_header('Idempotency-Key', input.event_id)
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
HTTP requests
An HTTP emitter sends one request for each eligible record. For each batch it admits, it resolves
its materialized dependencies once and uses one execution snapshot, and every expression below
reads that snapshot. Each record then passes, in order: its source WHERE; construction and
finalization of its codec record; route WHERE; METHOD; PATH; and each write_header
invocation as it is written. A record that route WHERE filters evaluates no request field and
sends nothing, even when one of its request fields would fail.
The first request field that fails rejects its record through ON MESSAGE ERROR before any part of
its request is sent. A failed expression keeps its evaluation code; a value that is not a valid
request field has the code validation. A method or path failure has the operation publish and
names the request field, method or path, beside the fields its expression reads. A header write
failure has the operation invoke and the zero-based position of its invocation. A record whose
body the codec cannot encode when a flush releases it is rejected the same way with the operation
encode. The message never quotes a method, target or header value. The error handler of every
such rejection, and of any later rejection of an admitted request, reads the original input and the
materialized state its batch was admitted with; with a codec it also reads the attempted record as
partial_output.
The method keeps its spelling. The request goes to exactly the normalized target: encoded
separators such as %2F, repeated query parameters, a literal +, and the empty query of a
trailing ? stay as they are. Header names compare without ASCII case and a later write replaces an
earlier value; an empty string is sent as an empty value. An invalid or reserved write rejects its
record even when a later write would replace it, and the 128-header and 32 KiB bounds apply after
every replacement. The body is exactly the bytes the codec produced, with no wrapper, array,
newline, form encoding or compression added; Nervix adds no Content-Type, and a declared
Content-Encoding does not transform the bytes. WITHOUT BODY sends zero content bytes.
A prepared request, with its method, target, headers and body, is kept until the endpoint answers
for it, so every retry resends it byte for byte: neither the request expressions nor the codec run
again, including volatile calls such as uuid_v4(). Retained bodies occupy node memory, which
memory pressure accounts for, until their requests complete.
One active emitter execution has at most one request awaiting final response headers, across all source relays and branches it serves. It sends the requests a flush releases in publication order. Independent executions have no total order, and an endpoint can apply a request after Nervix loses its response, so this order does not settle ambiguous remote effects.
The HTTP sink speaks HTTP/1.1. It creates a connection for each attempt and closes it after final
headers, so an unread or stalled body cannot be reused as the next response. DNS resolution,
connection acquisition, TLS negotiation, the complete request send, interim responses and
complete final headers share the client’s physical timeout_ms; queueing behind an earlier
request and host retry backoff do not consume the next attempt’s timeout. HTTPS validates trust and
the destination hostname and uses the client’s pinned CA and optional client certificate mounts.
Starting the sink reads local configuration and sends no probe.
The transport writes Host, Connection: close, and Content-Length when a body exists. It adds
Accept: */* only when the application did not write Accept. It adds no Accept-Encoding or
Content-Type. The application may write Accept, Content-Type, Authorization, and Cookie
with write_header. Response cookies are
not retained; an authentication challenge sends no additional request. There is no redirect or
library retry: every repeat is a separate emitter attempt under its declared policy.
Each interim and final response header block may contain at most 128 fields and 64 KiB of field
name and value bytes. Malformed headers or final framing and any exceeded bound fail the attempt,
even when the final status line says 200. Complete valid final 200–299 headers deliver the
record, including 202 and 204; a stalled or failed body after those headers does not reverse
delivery. The sink never waits for a body or uses bodies and trailers as graph data.
408, 425, 429, and 500–599 retain the current request and all later work for host retry.
401, 403, and 407 do the same and report an authentication or authorization infrastructure
failure. DNS, connection, TLS, send, timeout, malformed response and loss before complete final
headers are also infrastructure failures. Other 300–499 statuses and 101 reject only their
record through ON MESSAGE ERROR; later records proceed after that policy completes. Redirects
are never followed, 304 is not delivery, and 409 never implies an earlier delivery. Rejection
diagnostics include the numeric status but no evaluated URL, header value, request body or response
body.
HTTP retries and acknowledgements
A flush sends its requests in order and stops at the first one whose outcome is unresolved. That
request and every request prepared after it stay retained, and records admitted while they wait
queue behind them. A delivered request completes exactly its own record, and a rejected one resolves
its record through ON MESSAGE ERROR; neither is sent again, even when its flush held other records
that are retried. The retry resends the unresolved requests exactly as they were first sent, ahead
of any request prepared later.
Retries wait on the declared physical backoff: the first waits BACKOFF, each later one twice the
previous wait up to MAX, and a flush that completes resets the wait to BACKOFF. There is no
attempt limit; retrying continues until the request resolves or the emitter stops. A retryable
status (408, 425, 429, 500–599, 401, 403 or 407) whose final headers carry exactly
one valid Retry-After field asks for a delay of its own: whole seconds, or an HTTP date in
IMF-fixdate, RFC 850 or asctime form, compared with actual UTC when the response arrives. A date
already past asks for no delay. The next attempt waits for the longer of that delay and the
backoff, even beyond MAX, and the backoff sequence itself continues unchanged. A Retry-After
that is missing, repeated in more than one field, malformed, such as fractional seconds, or
unrepresentable, such as a delay ending after the year 2262, asks for nothing. Retry-After never
turns a delivery or a rejection into a retry.
The request timeout, the backoff and a Retry-After delay are physical. TIME RATE scales the
emitter’s COLLECT FOR and FLUSH EACH cadences and the domain time its expressions read, but
never these waits, and FLUSH IMMEDIATE keeps its physical batching window.
An ATTACHED emitter keeps the upstream acknowledgement of every unresolved request alive until
the request resolves: a delivery acknowledges it, and a rejection leaves it to the emitter’s
message error policy. A DETACHED emitter acknowledges upstream at relay fan-out, yet still
retries, keeps later work waiting behind an unresolved request, and routes rejections. Prepared
requests and retry state live in memory only.
An endpoint can apply a request whose outcome Nervix never learns: the response is lost, the
connection fails, or the attempt times out after the endpoint acted. The retry then sends the same
request again, so the endpoint can receive it twice. Nervix generates no idempotency key and does
not interpret an endpoint’s deduplication protocol. An endpoint that must recognize duplicates
should receive a stable key taken from the record, such as
write_header('Idempotency-Key', input.event_id). A value generated during evaluation, such as
uuid_v4(), stays the same across these retries but not across an upstream redelivery.
ALTER EMITTER ... SET TO HTTP restates the complete method, path, mode and body selection.
SET CLIENT changes the referenced client, SET MODE changes the retry policy, and SET ENCODE USING selects a codec body. DROP ENCODE is invalid for HTTP; use a complete SET TO HTTP ... WITHOUT BODY replacement. The retained construction and error scopes must be valid for the new
selection.
Changing the HTTP sink, method, path, client reference, body selection, or publishing mode uses
ENTITY_PAUSE. The emitter drains every admitted request with the client and request fields that
prepared it before the replacement starts. If that drain fails, the change fails and the old
definition remains active; an unanswered request is never sent to the proposed destination.
Changing only FLUSH is DYNAMIC, while adding or removing a source relay uses DOMAIN_PAUSE.
Changing the CLIENT definition itself follows the domain-pause configuration lifecycle.
ALTER retains the emitter’s INHERIT, SET, INVOKE, and error clauses, so a body-mode change
must remain valid with all of them. To change construction or header invocations, use DROP EMITTER and CREATE EMITTER for the same name in one transaction. The complete candidate is
validated before the transaction commits and has the same effective drain obligation as an
ALTER replacement. A destination that remains unavailable can prevent that drain. The operator
can repair it, or STOP the domain, change the emitter while stopped, and START again. Stopping
discards prepared requests and retry state; attached work depends on its source’s redelivery
contract, and an endpoint that applied a request before losing its response may see a duplicate.
While confirmations or infrastructure retries are pending, the emitter stops consuming from its
relays and keeps upstream ACK leases alive. FLUSH still controls when and how much work enters a
flush; MODE controls when each record in that flush counts as published. ATTACHED and
DETACHED are orthogonal: a detached emitter acknowledges upstream immediately but still performs
its declared confirmations and retries for error visibility and backpressure.
HTTP inspection and metrics
SHOW CREATE EMITTER and canonical formatting render the method and path expressions, the header
invocations and the construction in canonical NSPL, with the explicit body selection. A configured
expression is rendered as NSPL under the ordinary sensitivity rules, so an explicitly leaked field
appears as its leak_sensitive(...) call and never as a value. DESCRIBE EMITTER
reports the request contract of the deliver_event example above in these lines:
codec: event_body_codec
body: codec
sink: HTTP client=api method=input.request_method path=input.request_path
batch: none
flush: FLUSH EACH 100ms MAX BATCH SIZE 1MiB
publishing mode: ACK RETRY POLICY BACKOFF 250ms MAX 30s
body reads codec for ENCODE USING, and without body, with codec: none, for WITHOUT BODY.
Header invocations appear in SHOW CREATE EMITTER rather than in DESCRIBE.
While a request is pending after a failed attempt — waiting out its backoff or Retry-After,
or sent again and not yet answered — DESCRIBE EMITTER reports that failure as its
transient error, with the reconnect backoff the retry waited and the reconnect wait still
left, and the node reports it as a runtime event. It clears once the pending request is delivered.
The failure names its cause and, for a response, its status, such as
HTTP endpoint answered with retryable status 503,
HTTP endpoint answered with authentication or authorization status 401,
HTTP request timed out before complete final response headers, or
HTTP TLS handshake failed: invalid peer certificate: UnknownIssuer. A DNS, connection, TLS, send
or response-header failure keeps the cause beneath it, such as the resolver’s
resolving 'api.example.com' failed: the name does not exist. ON GENERAL ERROR has no say over a
response: a retryable failure stays pending and keeps attached acknowledgements alive, and a
refusal follows ON MESSAGE ERROR, even with ON GENERAL ERROR IGNORE.
A record’s message error names the operation that rejected it and where applicable the request
field or invocation: publish with the field method or path, invoke with the zero-based
position of the header write, encode for a body the codec cannot produce, and, for a refused
request, the code external with the operation publish and a message carrying the numeric status,
such as HTTP endpoint answered with status 404. Message errors, emitter status, runtime events,
logs and metric labels never carry an evaluated target, a header value, a credential, a request body
or a response body.
The emitter’s sent counters count each record once, when its request is delivered, however many
attempts that took, and never a record the endpoint refused. messages_total counts delivered
records, not attempts. bytes_total counts a codec body’s record with the ordinary emitter payload
accounting, the logical Arrow data of the finalized record, while a request without a body adds
no payload bytes. A request’s method, target and headers and the response add nothing to either
counter, and the counters carry only the ordinary graph labels, never a request value.
Batching
An emitter may declare two hard limits for every batch it publishes, written after the complete sink clause and its route construction, and before the flush policy:
BATCH MAX MESSAGES <n> MAX SIZE <bytes>
MAX MESSAGES is the most source records one batch carries, from 1 to 65,536. MAX SIZE is the
most bytes its encoded payload may occupy: a positive whole number followed by B, KB, KiB,
MB, MiB, GB, GiB, TB or TiB. Neither limit has a default, neither may be omitted, and
neither is derived from the other or from FLUSH ... MAX BATCH SIZE, which measures Arrow memory
rather than encoded bytes.
CREATE EMITTER kafka_notifications
FROM notifications
TO KAFKA kafka_main TOPIC notifications_out
MODE ACK PARALLEL MAX 100 ACK TIMEOUT 30s RETRY POLICY BACKOFF 250ms MAX 30s
ENCODE USING notification_codec
INHERIT ALL
BATCH MAX MESSAGES 500 MAX SIZE 1MiB
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
| Sinks | Clause |
|---|---|
| Kafka, Pulsar, RabbitMQ, Redis, MQTT, NATS, ZeroMQ, SQS, Sentry, Syslog, OTEL, Iceberg | Optional |
| ClickHouse, Postgres, MySQL, MongoDB | Required: a database write always carries several rows |
| HTTP | Unavailable: each request contains one source record |
A statement is rejected, naming the offending value, when:
MAX MESSAGESis zero or above 65,536;MAX SIZEis zero, fractional, written without a unit, or past the 64-bit byte range;MAX SIZEis above 256 KiB on an SQS emitter, the largest SQS message Nervix sends;- the clause is absent on a ClickHouse, Postgres, MySQL or MongoDB emitter;
- the clause is present on a Sentry emitter whose codec declares no
ON EMITTING BATCHtransformation, because one Sentry envelope carries at most one event and only that transformation can say where the members go; - the clause is present on an emitter whose protobuf codec declares no
BATCH MESSAGE, because protobuf has no self-delimiting sequence. Without anON EMITTING BATCHtransformation, the batch message must declare exactly one field,repeated <MESSAGE>, which the domain build checks against the compiled descriptors.
The codec forms are described in Schemas and codecs. These checks cover the whole candidate graph, so replacing a codec that a batching emitter uses is validated against that emitter too.
SHOW CREATE EMITTER renders the clause between the sink clause and FLUSH, with the size in the
unit it was written in. DESCRIBE EMITTER reports it on the line after sink:, as
batch: MAX MESSAGES 500 MAX SIZE 1MiB or batch: none.
The complete contract for batch payloads — packing, containers per wire format, exact size measurement and failure attribution for every sink — is defined in Optional emitter batching. The database sinks bound every insert or bulk write by both limits, as Database writes describes. An OTEL emitter with the clause bounds each export request; without it, OTEL keeps one request per pending Arrow batch. Iceberg keeps its data-file and commit boundaries.
Batch payloads
A Kafka, Pulsar, RabbitMQ, Redis, MQTT, NATS, ZeroMQ, SQS, Sentry or Syslog emitter that declares
the clause publishes batch payloads instead of one payload per record. Each payload is one message,
one event or one frame whose value is the codec’s
batch container: an array for JSON, CBOR, YAML and Avro, a
single batch key for TOML, a single batch root element for XML, the codec’s BATCH MESSAGE for
protobuf and one RFC 5424 frame for SYSLOG, or the single value an ON EMITTING BATCH
transformation built instead.
The emitter walks the Arrow carriers released by one flush in arrival order, and each carrier’s
eligible rows in source order. A payload can span carriers only while their source relay, exact
named and concrete branch, key, ordered written headers, ordering group and, with a SYSLOG codec,
syslog header other than the timestamp agree. A different value seals the open payload before the
next row is considered; no row is skipped over or reordered. A carrier retains its own execution
snapshot and row membership for errors and acknowledgements. A payload with one member keeps the
container shape, such as a one-element array, and a carrier with no eligible row publishes nothing.
Batching adds no timer: FLUSH alone decides when records leave, and a partial payload is published
exactly like a full one.
MAX SIZE is the exact length of the payload: escaping, UTF-8, base64, field names, separators,
length prefixes and the container’s own brackets all count, and a batch transformation is measured
by the bytes of its output. Nervix never estimates the size. It encodes the candidate into a buffer
that refuses to grow past the limit and abandons the encoding at the first byte that would not fit,
so an oversize payload is never built in full and never reaches the destination. A payload of
exactly MAX SIZE bytes is published. A candidate whose encoding reached the limit is halved: its
first half is re-encoded under the same limit and the rest returns to the front of the queue. Each
halving is encoded again, because a batch transformation may write more bytes for fewer members, so
a candidate of n members takes at most ⌈log2(n)⌉ + 1 encodings. The bound covers the payload
only: keys, headers and the framing a transport adds around the payload are outside it.
Schemaful JSON rows use the same bounded writer as other codecs. Their columnar encoder stops at the first write over the limit; its string classifier and direct column writes do not change exact byte measurement, halving, or the per-record error policy.
A record is rejected alone, through ON MESSAGE ERROR with operation encode, when its member value
cannot be produced — its ON EMITTING transformation fails, or, without a batch transformation, its
value is not one the format can write — and when a payload of it alone still exceeds MAX SIZE.
The second case has code validation and a message naming the codec, its encoding and the limit,
such as emitter 'bounded_events' codec 'event_codec' JSON payload exceeds MAX SIZE 32B. The records
around it are packed as usual.
A batch transformation that yields no output, more than one output, fails to evaluate, or yields a
value the format cannot write fails the whole candidate. Every member follows ON MESSAGE ERROR
with operation encode, the same error reference and a message naming the emitter, the codec, the
cause and the member count, such as
emitter 'kafka_notifications' codec 'notification_envelope' ON EMITTING BATCH produced no output for a batch of 3 messages. The code is evaluation, or validation for a value the format cannot
write. The message never quotes a payload value, so it does not repeat the program’s own error text,
and Nervix does not subdivide such a batch to look for a member to blame; use a smaller
MAX MESSAGES, or no batching, where per-record attribution matters.
One payload is one publish. Its confirmation delivers every member, and a destination’s rejection
of it rejects every member with one shared error reference. ACK PARALLEL MAX <n> therefore counts
payloads, not records. nervix_messages_total keeps counting source records.
A payload whose outcome the emitter did not learn — the destination failed, stopped answering, or
its confirmation timed out — is retained exactly as it was written. The retry, or a force flush or
drain before it, writes the same bytes with the same members again, so a duplicate a retry produces
is the payload the destination may already hold, never a regrouped one. Payloads the destination
confirmed or rejected in the failed attempt are not written again, and records that arrive while a
payload is retained go into later payloads. The members of a retained payload keep their upstream
acknowledgements alive until it resolves; a DETACHED emitter still acknowledges upstream at relay
fan-out and still retries the payload. Where the destination answers for every record itself, as a
MongoDB bulk write does per document, a retry carries only the records it left unresolved.
Member values and containers are working values that exist only while the released carriers are
encoded; the records themselves stay in Arrow batches. The packer holds at most MAX MESSAGES
prepared members in one candidate, even when the members come from successive carriers. For a
codec with jaq transformations, member preparation, batch transformations and every re-encoding
run in the same job on the node’s extension workers that already runs ON EMITTING, so a slow
program never stalls the emitter task. A node whose extension workers cannot take the job now
refuses it, and the emitter keeps its rows and retries them as it retries a failed publish. Candidate work is bounded by MAX MESSAGES and the
encodings above; MAX SIZE bounds what is written.
Broker and message emitters
Kafka, Pulsar, RabbitMQ, Redis Pub/Sub, MQTT, NATS, ZeroMQ and SQS publish each batch payload as
one message through the connector’s own driver: one Kafka record value, one Pulsar message
payload, one AMQP message body, one PUBLISH to a Redis channel, one MQTT PUBLISH packet, one
NATS message on the subject, one single-frame ZeroMQ message or one SQS message body. The message
carries once what its members share. The Kafka record key and the Pulsar partition key are the
members’ concrete branch key; the written headers become Kafka and NATS headers, Pulsar
properties, AMQP headers or SQS message attributes; and an SQS FIFO message carries the members’
message group. Without the clause every record stays its own message, carried the same way.
Batching keeps every publishing mode’s completion point. NO_ACK and QOS 0 complete when the
producer, channel, client or socket accepts the batch message; the confirming modes wait for one
delivery report, broker receipt, publisher confirm, PUBACK, completed QoS 2 handshake or
JetStream PubAck for it. ACK TIMEOUT bounds that one wait, and a batch message whose outcome
stays unknown is retained for the retry described above.
SQS MODE BATCH remains a request shape and is independent of the clause. One SendMessageBatch
request carries up to ten batch messages as separate entries within the 256 KiB request limit, and
never two messages of one FIFO message group. The service answers for each entry, and an entry’s
answer applies to every member of the batch message it carries. Entries are never merged into one
message, and one entry never carries more than one batch message.
MAX SIZE bounds the payload, while the destination bounds the whole message it receives. Where
the connector learns the destination’s limit, it checks every message against it before writing,
counting what it writes around the payload:
| Sink | Limit checked before a message is written | Counted around the payload |
|---|---|---|
| Kafka | The producer’s message.max.bytes client setting | The record key, headers and record overhead |
| MQTT | The Maximum Packet Size the broker declared in its latest CONNACK, and the largest packet the protocol can express | The fixed header, topic, packet identifier and property length |
| NATS | The max_payload the server announced | The message headers |
| Pulsar | The maxMessageSize the broker announced when the producer’s connection opened | The message metadata: the properties, the partition key and the producer’s own fields |
| SQS | 256 KiB | Message attribute names, types and values, and the FIFO message group |
A batch message that does not fit is never written. Every member follows ON MESSAGE ERROR with
code external, operation publish and one shared reference, and the message names the size and
the limit, such as mqtt rejected record: MQTT PUBLISH packet of 1200090 bytes exceeds the broker's maximum packet size of 1048576 bytes. The messages around it are still written. Declare a
MAX SIZE that leaves room for the metadata to keep batches from reaching the limit.
A Pulsar broker announces its maxMessageSize on every connection, so a producer that reconnects
to another broker checks against the new one’s. A Pulsar topic can also set a smaller
maxMessageSize policy of its own. Only the broker applies it, measuring the metadata and payload
with ten bytes of framing, so such a message is written and refused afterwards. In MODE ACK the
refusal rejects every member the same way, with the broker’s reason, such as
pulsar rejected record: the Pulsar broker does not allow the message: Exceed maximum message size. In MODE NO_ACK the message was already delivered when the producer accepted it, so the
refusal is not observed.
The remaining limits are not visible to the client. RabbitMQ’s max_message_size is a broker
setting that AMQP never tells a client, and the broker compares it with the message body alone. A
batch message whose body is larger reaches the broker, which refuses it by closing the channel it
arrived on and names the limit, and Nervix rejects the message on that answer, in every publishing
mode: every member follows ON MESSAGE ERROR with code external, operation publish and one
shared reference, and the message names the size and the limit, such as rabbitmq rejected record: message body of 1200090 bytes exceeds the broker's max_message_size of 1048576 bytes. The broker
discards the messages written after the refused one with the channel, and Nervix writes them again
on a new channel. A message written ahead of it that the broker had not confirmed yet may have
reached its queue, as it can on a quorum queue, so the write then fails as an infrastructure
failure, and its retry carries every message but the rejected one and those already confirmed.
Headers do not count, so a MAX SIZE no larger than max_message_size keeps every batch message
within it.
Redis rejects a value above its proto-max-bulk-len itself, and that rejection follows
ON MESSAGE ERROR like the ones above. ZeroMQ fixes no limit; a receiving socket configured with a
maximum message size drops a larger message after the sending socket has accepted it.
Database writes
ClickHouse, Postgres, MySQL and MongoDB always write several rows at once, so their emitters
require the clause. A flush hands the sink every run of rows from successive Arrow carriers of one
source relay and concrete branch, in the order the emitter would have published them, and the sink
writes the run as inserts or bulk writes of at most MAX MESSAGES rows. Every source record stays
one row or one document: an array or VEC value is one column value, never a set of rows, and no
destination schema changes.
| Sink | One write | MAX SIZE measures |
|---|---|---|
| ClickHouse | One INSERT INTO <table> FORMAT JSONEachRow request | The request body, one JSON line and newline per row, before the client compresses it |
| Postgres | One INSERT ... SELECT ... FROM unnest(...) statement binding one text array per mapped column | The statement text and every bound array as the extended-query protocol encodes it: a 20-byte array header, then a four-byte length and the value’s text for each row |
| MySQL | One multi-row INSERT ... VALUES (...), (...) statement | The statement text and every bound value as the binary protocol encodes it |
| MongoDB | One unordered insert_many, or one unordered bulk write of upserts under ON CONFLICT | Each inserted document as the driver writes it, with the _id it adds to a document that has none, or each upsert’s filter and update documents |
Everything around the measured payload is framing outside MAX SIZE: HTTP headers and the
statement in the ClickHouse URL, each Postgres parameter’s own length word and the protocol
messages, the MySQL packet headers and the parameter types and null bitmap of its execute packet,
and the command and operation fields around MongoDB documents, the fields the driver adds to every
command, and the wire-protocol header. The measured size is exact, and a write of exactly
MAX SIZE bytes is sent. A candidate that measures more is halved, and its first half is measured
again while the rest returns to the front of the next candidate. A row whose own write still
measures more follows ON MESSAGE ERROR with code validation, operation encode, and a message
naming the limit, such as Postgres insert of one row measures 1219 bytes, above MAX SIZE 600B.
The rows around it are written.
The destination’s own limits bound every write too. A MySQL statement binds at most 65,535
placeholders, so an insert carries at most as many rows as fit them: 13,107 rows of five mapped
columns, whatever MAX MESSAGES allows. Postgres reads no protocol message above 1,073,741,822
bytes, and a write’s measured size is never smaller than the message that carries its arrays, so a
Postgres write is kept within that size too; a row whose own insert exceeds it follows
ON MESSAGE ERROR as an external publish error. A MongoDB document holds at most 16 MiB, so a
row whose document exceeds it follows ON MESSAGE ERROR as an external publish error naming
16777216 bytes before any write, and the rows around it are written. MongoDB takes 100,000 writes
in one command, more than MAX MESSAGES allows, and its driver divides a write larger than the
maxMessageSizeBytes the server reports into several commands whose documents it still answers
for one by one.
A write that fails for a reason specific to its rows — a constraint, a value its column cannot
hold, a packet the server refuses as too large — is written again one row at a time, so the rows
the destination accepts land and only the rows it refuses follow ON MESSAGE ERROR, each with the
destination’s own reason. This includes a Postgres ON CONFLICT DO UPDATE whose insert carries one
key twice, which Postgres refuses for the whole statement: row by row, the later row updates the
one before it, exactly as the rows would one insert at a time. MySQL applies the rows of one insert
in order,
so a key repeated in one write is updated by its later row under DO UPDATE and keeps its first
row under DO NOTHING. A MongoDB write is unordered: one server applies its upserts in the order
the write lists them, with the same result, while a sharded deployment may apply them in parallel.
MongoDB names every document it rejects, so its healthy documents are delivered by the write itself
without a row-by-row pass.
A write whose outcome is unknown — the connection failed or the destination stopped answering — is retried with its rows unresolved. The rows before it stay delivered, so the retry packs the same rows into the same writes again, and a write the destination already took is taken twice; use a conflict policy where duplicates matter. Where MongoDB answered for some documents of a write, the retry carries only the documents it left unresolved.
Each sink writes a BYTES value in its native binary form: the octets themselves in a ClickHouse
String, the bytea hex format Postgres reads back as the same octets, the octets bound to a MySQL
binary or blob column, and generic BSON binary data in MongoDB. An ARRAY or VEC value is a JSON
array in ClickHouse’s JSONEachRow and in the JSON text Postgres and MySQL bind for it, and a BSON
array in MongoDB; inside that JSON text a BYTES element is the padded base64 text every Nervix JSON
value carries octets as.
Altering emitters
ALTER EMITTER applies one or more comma-separated operations in written order:
ALTER EMITTER <emitter>
ADD FROM <relay> [WHERE <expr>]
| DROP FROM <relay>
| ALTER FROM <relay> SET WHERE <expr>
| ALTER FROM <relay> DROP WHERE
| SET TO <full sink clause>
| SET MODE <transport-specific mode body>
| SET CLIENT <client>
| SET ENCODE USING <codec>
| DROP ENCODE
| SET COLLECT FOR <duration> [MAX BATCH SIZE <bytes>]
| DROP COLLECT
| SET ATTACHED
| SET DETACHED
| SET BATCH MAX MESSAGES <n> MAX SIZE <bytes>
| DROP BATCH
| SET FLUSH EACH <duration> MAX BATCH SIZE <bytes>
| SET FLUSH IMMEDIATE
| SET COMMIT EACH <duration> MAX SIZE <bytes>
[, ...];
SET TO accepts the same complete transport-specific sink body that follows TO in CREATE EMITTER, including its required MODE, SQS FIFO group, and Iceberg commit policy. The existing
construction, batching clause and output flush policy remain in place, so changing to a database
sink requires the emitter to declare a batching clause, in the same statement if necessary. SET MODE changes only
the current sink’s publishing mode and rejects a body that the sink does not support. SET CLIENT
changes only the client of the current sink kind. SET COMMIT is valid only for Iceberg. DROP ENCODE fails if the emitter has no codec configured. SET BATCH adds or replaces the batching
clause. DROP BATCH fails when the emitter has no clause and when its sink requires one.
ADD FROM rejects an already configured relay. DROP FROM cannot remove the final input.
ALTER FROM ... SET WHERE adds or replaces that source’s predicate; ALTER FROM ... DROP WHERE
fails when the source has no predicate.
Changing only FLUSH is a DYNAMIC update. The live emitter keeps its pending Arrow batches,
installs the new cadence, and receives a force-flush kick, so buffered output is neither discarded
nor re-encoded. Source-predicate, sink, publishing-mode, client, codec, collection, batching, and
attachment changes use
ENTITY_PAUSE: Nervix gates all of the emitter’s source relays, drains collected input and pending
sink output, replaces that emitter task, and releases the gates. Changing source membership uses
DOMAIN_PAUSE because it changes graph topology. Other relays continue flowing during an entity
pause; sibling consumers of a gated source may see bounded backpressure until the gate is released.
The complete candidate graph is validated before any change is committed.
Codec-emitter construction
Codec emitters are transforming routes. They begin with an empty codec-schema payload and use explicit inheritance and ordered assignment:
CREATE IF NOT EXISTS EMITTER kafka_notifications
FROM notifications
TO KAFKA kafka_main TOPIC notifications_out
MODE ACK PARALLEL MAX 1000 ACK TIMEOUT 30s
RETRY POLICY BACKOFF 250ms MAX 30s
ENCODE USING notification_codec
INHERIT ALL EXCEPT raw, secret
SET secret = leak_sensitive(input.secret),
normalized = lower(input.raw)
WHERE output.active
INVOKE write_header("tenant", input.tenant),
write_header("route", output.normalized)
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
message.field reads the working message, input.field always reads the
source relay row, and output.field requires prior initialization. Relay-qualified fields are
invalid. There is no implicit identity transformation and no UNSET; use INHERIT ALL EXCEPT.
External sensitivity is strict. Every sensitive payload value requires leak_sensitive(...) or an
explicit INHERIT field LEAK SENSITIVE, even when the codec target field is also sensitive.
Direct-emitter values
Database, object-store, and OTEL direct emitters construct external name-keyed mappings:
VALUES {
"tenant" = input.tenant,
"normalized" = lower(input.action),
"secret" = leak_sensitive(input.secret)
}
WHERE input.active
Entries are independent and do not create variables. Order does not affect evaluation, duplicate
external keys are invalid, output is unavailable, and sensitive values require explicit leakage.
Bare fields, message.field, and input.field read the source row. Direct emitters reject
INHERIT and all current direct sinks reject INVOKE.
Header invocations
write_header is a side-effect function. It accepts statically non-null STRING name and value
expressions and is valid only as a top-level INVOKE call. Sensitive values require
leak_sensitive. Calls execute left to right after payload finalization and route filtering. Header
mutations are staged in a temporary route-local envelope; invocation failure prevents payload and
partial-envelope publication.
Header output is supported only on codec emitters for Kafka, NATS, Pulsar, RabbitMQ, and SQS. Kafka and NATS preserve ordered repeated values. Pulsar, RabbitMQ, and SQS use last-write-wins behavior. Redis, MQTT, Syslog, ZeroMQ, Sentry, OTEL, direct database sinks, and Iceberg reject header writes.
Emitter expressions use the same typed surface as other runtime nodes:
- arithmetic:
+,-,*,/,% - comparisons and boolean logic:
=,!=,>,<,>=,<=,AND,OR,NOT - explicit conversions:
expr AS TYPE, andTRY_CAST(expr AS TYPE), which yields a typed null instead of failing the message - JSON extraction:
JSON_VALUE(doc, '$.path' AS TYPE),TRY_JSON_VALUE(doc, '$.path' AS TYPE), andJSON_EXISTS(doc, '$.path') - built-ins: string, null-handling, numeric, regex, and contextual functions such as
lower,coalesce,abs,regexp_substr,now, anduuid_v4
See Expression Functions for the full function reference.
That expression surface applies to the full Nervix internal schema type set:
U8,I8,U16,I16,U32,I32,U64,I64F32,F64BOOL,STRING,DATETIME
Nested conditions and chained calls such as contains(lower(trim(input.raw)), 'warn') are supported
before encoding.
Client-backed emitters can use resource-mounted client config values for TLS material and other file-based settings. See Resources.
TLS Client Configuration
Emitter TLS is configured on the referenced CLIENT exactly the same way as ingestor TLS.
Common pattern:
CREATE [IF NOT EXISTS] CLIENT <name>
TYPE <kind>
MOUNT <tls_resource> VERSION <n>|LATEST
CONFIG {
...
'tls_ca_file' = '{{ tls_resource }}/ca.pem'
};
Transport-specific expectations:
KAFKA: pass-through to librdkafka. Typically set'security.protocol' = 'ssl','ssl.ca.location' = '{{ tls_resource }}/ca.pem', and optional'ssl.certificate.location'plus'ssl.key.location'.RABBITMQ: useamqps://...inaddr; Nervix honorstls_ca_file.REDIS: userediss://...inaddr; Nervix honorstls_ca_file,tls_cert_file,tls_key_file.MQTT: usemqtts://...inaddr; Nervix requirestls_ca_fileand supportstls_cert_fileplustls_key_file.NATS: usetls://...inaddr; Nervix honorstls_ca_file,tls_cert_file,tls_key_file.PULSAR: usepulsar+ssl://...inaddr; Nervix honorstls_ca_fileand optionaltls_allow_insecure_connectionplustls_hostname_verification_enabled. Pulsar client certificate authentication is not currently exposed.SQS: use anhttps://...endpoint; Nervix honorstls_ca_fileand optionaltimeout_ms.SENTRY: the referencedTYPE SENTRYclient carries anhttps://...dsn; Nervix honors the client’stls_ca_file,tls_cert_file, andtls_key_file.OTEL: use anhttps://...endpoint; Nervix honorstls_ca_file,tls_cert_file, andtls_key_filefor both OTLP/gRPC and OTLP/HTTP-protobuf.CLICKHOUSE: use anhttps://...addr; Nervix honorstls_ca_fileand optionaltimeout_ms.POSTGRES: usesslmode=verify-fullin theaddrURL; Nervix honorstls_ca_file,tls_cert_file, andtls_key_file.sslmode=disableis the only other accepted policy.MYSQL: includerequire_ssl=trueinaddr; Nervix honorstls_ca_file.SYSLOG: select'protocol' = 'tls'. Optionaltls_ca_fileadds a server trust root; optionaltls_cert_fileandtls_key_fileconfigure client authentication and must appear together.
Example Kafka TLS emitter client:
CREATE IF NOT EXISTS CLIENT kafka_tls
TYPE KAFKA
MOUNT dev_tls VERSION 1
CONFIG {
'bootstrap.servers' = '127.0.0.1:9094',
'security.protocol' = 'ssl',
'ssl.ca.location' = '{{ dev_tls }}/ca.pem'
};
Supported Emitter Sinks
Kafka
TO KAFKA <client> TOPIC <topic>
MODE NO_ACK RETRY POLICY BACKOFF <duration> MAX <duration>
| ACK (SEQUENTIAL | PARALLEL MAX <n>) ACK TIMEOUT <duration>
RETRY POLICY BACKOFF <duration> MAX <duration>
ACK waits for every record’s delivery report. Kafka’s acks, idempotence, batching, linger, and
compression remain pass-through client configuration; Nervix’s ACK TIMEOUT independently bounds
the report wait. NO_ACK is fire-and-forget after local admission: Nervix acknowledges the
emitter’s ATTACHED ACK share when librdkafka accepts the record into its producer queue and does
not wait for a delivery report. A later broker error, producer timeout, crash, or process exit can
therefore lose an accepted record. A full local queue is an infrastructure condition paced by the
declared retry policy with backpressure. Graceful shutdown drains queued records within the node
drain bound in both modes. A definitive record-specific rejection, such as an oversized message,
follows ON MESSAGE ERROR.
Pulsar
TO PULSAR <client> TOPIC <topic>
MODE NO_ACK RETRY POLICY BACKOFF <duration> MAX <duration>
| ACK (SEQUENTIAL | PARALLEL MAX <n>) ACK TIMEOUT <duration>
RETRY POLICY BACKOFF <duration> MAX <duration>
ACK waits for each broker receipt. NO_ACK acknowledges producer acceptance and does not expose
later broker errors; its throughput advantage may be smaller than Kafka’s because Pulsar already
pipelines producer work.
A record whose message is larger than the maxMessageSize the broker announced for the producer’s
connection, counting its metadata and properties, follows ON MESSAGE ERROR in either mode: the
producer refuses it before writing it, because the broker would close the connection and fail
every message in flight on it. With ACK, a message the broker receives and refuses with
NotAllowedError, such as one above the topic’s own maxMessageSize policy, follows
ON MESSAGE ERROR too, while any other broker error is retried.
Pulsar emitters use the same client config surface as Pulsar ingestors:
'addr': broker address such as'pulsar://127.0.0.1:6650'- optional
'namespace': defaults short topic names topersistent://public/default/<topic>; fully qualified topic names are accepted as-is - optional
'tls_ca_file': PEM-encoded CA bundle forpulsar+ssl://...connections - optional
'tls_allow_insecure_connection':trueorfalse; defaults tofalse - optional
'tls_hostname_verification_enabled':trueorfalse; defaults totrue
Pulsar TLS currently supports server trust configuration only. Nervix does not yet expose Pulsar client certificate authentication.
RabbitMQ
TO RABBITMQ <client> QUEUE <queue>
MODE NO_ACK RETRY POLICY BACKOFF <duration> MAX <duration>
| ACK (SEQUENTIAL | PARALLEL MAX <n>) ACK TIMEOUT <duration>
RETRY POLICY BACKOFF <duration> MAX <duration>
ACK enables publisher confirms and waits for the confirm of each message. A broker nack is an
infrastructure failure and is retried with backpressure. In NO_ACK the broker confirms nothing,
so once a write’s messages are on the channel, the emitter asks the channel for one round trip,
which the broker answers only after it has taken every message written before it, and that answer
acknowledges them. A NO_ACK write therefore waits for one round trip to the broker however many
messages it carries, and a channel or connection lost before the answer leaves the write’s messages
to the retry.
The broker’s max_message_size, 16 MiB by default in RabbitMQ 4.x, bounds each message body;
headers do not count. The broker closes the channel a larger body arrives on, and the emitter
rejects that message through ON MESSAGE ERROR in either mode, instead of retrying it or
acknowledging it, then keeps publishing on a new channel of the same connection; see
Broker and message emitters.
The emitter resolves the host of the client’s addr through the node’s configured DNS resolver
when it opens and whenever it reopens after a failed publish, so a changed DNS answer takes effect
on the next connection, and tries the addresses it receives in order. A literal IPv4 address, or
an IPv6 address in brackets, is connected to as written. Resolution, the TCP connection and, for
amqps, the TLS handshake have 30 seconds together; the broker certificate must name the host
addr names. A connection that fails, including a name that does not resolve, leaves the emitter
unavailable with the failure as its transient error; it confirms nothing, so its input stays
unacknowledged, and it reopens on its RETRY POLICY backoff.
Redis Pub/Sub
TO REDIS PUBSUB <client> CHANNEL <channel>
MODE NO_ACK RETRY POLICY BACKOFF <duration> MAX <duration>
Redis Pub/Sub has no subscriber delivery acknowledgment. The awaited PUBLISH response confirms
server acceptance only. A record-specific server rejection follows ON MESSAGE ERROR; connection
failures retry the undelivered work. A TYPE REDIS client declares its connection-pool bounds; see
Database Client Connection Pools.
Each physical pooled command connection resolves the addr hostname through the node’s
asynchronous DNS resolver when it opens. A replacement connection can use a changed DNS answer;
an established connection stays open until Redis or the network closes it. For rediss://, TLS
still verifies the configured hostname and uses its configured CA and optional client identity.
MQTT
TO MQTT <client> TOPIC <topic>
MODE QOS 0 RETRY POLICY BACKOFF <duration> MAX <duration>
| QOS (1 | 2) ACK (SEQUENTIAL | PARALLEL MAX <n>) ACK TIMEOUT <duration>
RETRY POLICY BACKOFF <duration> MAX <duration>
QoS 0 acknowledges client acceptance. QoS 1 waits for PUBACK; QoS 2 waits for the complete
exactly-once handshake. QoS 1 and 2 use a persistent session and the emitter client’s stable
identity so in-flight messages survive reconnects. Reconnect pacing follows the declared retry
policy. When an answer misses its ACK TIMEOUT, or the broker or client fails it for a reason that
is not the record’s own, the emitter keeps every answer the broker already gave and retries only the
records without one; DESCRIBE EMITTER shows why, such as
the broker did not answer within ACK TIMEOUT 10s. A record the emitter stopped waiting for stays
with the client, which sends it again after a reconnect that resumes the session, beside the
emitter’s own retry, so the broker can receive it twice. A definitive record rejection, such as an
invalid topic or payload-format rejection, follows ON MESSAGE ERROR. So does a record whose
PUBLISH packet would exceed the Maximum Packet Size the broker declared when the client connected,
or the largest packet MQTT can express: the emitter rejects it before handing it to the client,
which would otherwise lose its connection on a packet the broker refuses to receive. The client
resolves the host of its addr through the node’s asynchronous DNS resolver every time it connects,
first and after every lost connection, and dials the answers in order within its five-second connect
timeout. For mqtts, TLS verifies the configured host name. While the name does not resolve, the
client reconnects on the declared retry policy, DESCRIBE EMITTER shows the lookup failure, and a
QoS 1 or 2 record stays unacknowledged until the broker acknowledges it.
NATS
TO NATS <client> SUBJECT <subject>
MODE NO_ACK RETRY POLICY BACKOFF <duration> MAX <duration>
| JETSTREAM ACK (SEQUENTIAL | PARALLEL MAX <n>) ACK TIMEOUT <duration>
RETRY POLICY BACKOFF <duration> MAX <duration>
NO_ACK publishes through Core NATS and acknowledges after the connection flush. JETSTREAM
waits for one PubAck per record. The stream must already exist and capture the subject; a missing
stream is an infrastructure error that remains under backpressure until an operator provisions it.
ZeroMQ
TO ZEROMQ <client>
MODE NO_ACK RETRY POLICY BACKOFF <duration> MAX <duration>
ZeroMQ has no delivery acknowledgment; success is socket acceptance. Transient socket failures are paced by the declared retry policy.
Syslog
TO SYSLOG <client>
MODE NO_ACK RETRY POLICY BACKOFF <duration> MAX <duration>
ENCODE USING <codec>
The client sends UDP datagrams, persistent RFC 6587 TCP frames, or RFC 5425 TLS frames. TCP uses octet-counting by default and may select non-transparent framing; TLS always uses octet counting. Success is local socket acceptance and flush, not remote delivery confirmation. The sink requires a codec and rejects header writes. See Syslog for client configuration, framing, message errors, retry behavior, and limits.
With BATCH, a SYSLOG codec encodes one RFC 5424 frame whose MSG is a JSON array of the
members’ complete RFC 5424 messages. Members must share every header field except timestamp; the
outer frame uses the first member’s timestamp. The same frame travels as one UDP datagram, one
octet-counted TCP/TLS frame, or one non-transparent TCP frame when it contains no LF.
SQS
TO SQS <client> QUEUE <queue> [FIFO GROUP (FROM BRANCH | <string_expression>)]
MODE (SINGLE | BATCH) RETRY POLICY BACKOFF <duration> MAX <duration>
SINGLE issues one request per message. BATCH groups messages within SQS’s fixed limit of ten
entries and 256 KiB per request; both modes issue requests sequentially and acknowledge service
responses. Per-entry transient failures retry only those entries, while invalid entries and a
message larger than 256 KiB, counting its attributes and FIFO message group, follow
ON MESSAGE ERROR individually. Nervix checks what SQS refuses before sending: a body that is not
UTF-8, a body or attribute value holding a character SQS forbids, more than ten attributes, an
attribute name SQS does not allow, an empty attribute value, and an invalid FIFO message group. SQS
accepts the characters of the XML 1.0 Char production: tab, line feed, carriage return, U+0020
to U+D7FF, U+E000 to U+FFFD and U+10000 to U+10FFFF. Every other C0 control and the noncharacters
U+FFFE and U+FFFF are forbidden, while the other noncharacters fall inside those ranges and are
sent.
Set the SQS client’s optional timeout_ms CONFIG key to bound both the complete service operation
and its single SDK attempt. Nervix disables the AWS SDK’s internal retries, so a timeout returns to
the emitter and the mode’s declared RETRY POLICY owns all retry pacing.
The client resolves the host of its endpoint through the node’s asynchronous resolver each time it
opens a connection, and tries the answers in order; a literal IPv4 or IPv6 address is dialled as
written. Every request is still signed for the configured host, and over HTTPS the service
certificate must name that host, whichever address accepted the connection. The lookup counts
against the SDK’s 3.1-second connect timeout and against timeout_ms. Without tls_ca_file the
client trusts the platform’s native roots and follows the HTTP_PROXY, HTTPS_PROXY, ALL_PROXY
and NO_PROXY environment variables; with it, the client trusts that CA alone and connects
directly. A missing name, a silent name server or an unreachable answer fails the queue lookup or
the send, which the emitter retries on its RETRY POLICY; nothing is acknowledged until SQS
answers.
For FIFO queues, one batch request contains at most one record from each message group. A partial batch failure therefore cannot deliver a later record from a group ahead of the failed record; other groups may still make progress independently.
A queue name ending in .fifo requires FIFO GROUP, and FIFO GROUP is rejected for a queue
without that suffix. FROM BRANCH requires branched input and uses the record’s branch key as its
message group. Otherwise the expression must have exact STRING type for every record and obey
normal external-sensitivity leakage rules. Nervix relies on content-based deduplication, which the
operator must enable while provisioning the FIFO queue. Sends to a FIFO queue without it fail as
publish errors; Nervix never creates or reconfigures the queue.
FIFO GROUP is the emitter’s ordering group. Nervix evaluates it against each record’s input
row after any FROM ... WHERE filter, and every record the emitter sends carries the group its own
input row produced, whatever the emitter’s WHERE and construction keep or build. A record whose
group expression fails for its row, or that reaches FROM BRANCH without a branch key, is not sent.
It follows ON MESSAGE ERROR as an external error of the publish operation whose message begins
ordering group, and the emitter’s other records are still sent under their own groups. A record
the emitter’s WHERE drops is never sent, so a failure of its group is never reported.
Sentry
Sentry emission uses a TYPE SENTRY client whose required dsn contains the project endpoint and
public key:
CREATE CLIENT sentry_main
TYPE SENTRY
CONFIG {
'dsn' = 'https://<public-key>@sentry.example.com/<project-id>',
'timeout_ms' = 5000
};
CREATE EMITTER sentry_errors
FROM errors
TO SENTRY sentry_main
MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
ENCODE USING sentry_event_codec
INHERIT ALL
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
The codec must produce one top-level JSON object per record. Its fields use the Sentry event
protocol, such as message, level, environment, release, tags, extra, user, and
exception. Nervix preserves the complete object and supplies event_id, timestamp, and
platform when they are omitted. It then creates a Sentry envelope, derives the envelope URL and
authentication header from the DSN, and submits the event. An invalid event object is handled as a
route-local encoding error.
Sentry sends one event per envelope and acknowledges the successful HTTP response. On 429 or
503, Nervix honors Retry-After and X-Sentry-Rate-Limits; the server interval extends the
declared retry delay when it is longer.
With BATCH, ON EMITTING BATCH builds one event from the candidate array, usually placing its
members under extra. The envelope still has exactly one event item. MAX SIZE measures the
transformed event JSON; Nervix also checks the final serialized event, after default fields are
added, against Sentry’s 1 MB decompressed event limit before sending the envelope. An oversized
event follows the emitter’s message error policy. The envelope header, item header, and newline
framing are outside MAX SIZE.
Use a JSON wire codec or a JAQ-native codec with JSON output. Sentry emitters require ENCODE USING, do not accept write_header, and still require explicit leakage for sensitive event
fields. The optional Sentry client keys timeout_ms, tls_ca_file, tls_cert_file, and
tls_key_file have their usual meanings.
Sentry resolves the DSN endpoint through the node’s configured DNS resolver; a timeout_ms
includes that lookup, connection setup, TLS, and the response.
OTEL
OTEL emission is a codec-free direct sink for OTLP logs, traces, and metric data points. It uses a
TYPE OTEL client and supports both OTLP/gRPC and OTLP/HTTP-protobuf. The protocol is always
explicit; there is no hidden default:
CREATE CLIENT otel_main
TYPE OTEL
CONFIG {
'endpoint' = 'http://127.0.0.1:4317',
'protocol' = 'grpc',
'headers' = 'authorization=Bearer <token>',
'compression' = 'gzip',
'timeout_ms' = 5000
};
endpoint and protocol are required. protocol is exactly grpc or http/protobuf. headers
is the OTLP comma-separated key=value form, compression accepts only gzip, and an absent
compression key sends an uncompressed request. timeout_ms is an optional positive request bound.
For http/protobuf, Nervix appends /v1/logs, /v1/traces, or /v1/metrics to the endpoint path.
Mount TLS files and use tls_ca_file, tls_cert_file, and tls_key_file in the same client; the
certificate and key must be supplied together. Both protocols resolve the endpoint’s host through
the node’s configured DNS resolver for every new connection, and keep the endpoint’s host for the
HTTP authority and the TLS server name. A timeout_ms covers the lookup and connection setup as
well as the response, and for grpc it is also the connection timeout. Without it, only a
thirty-second limit bounds the lookup, and neither the connection nor the export has a deadline of
its own. A failed lookup is an export failure that the emitter retries on its RETRY POLICY.
One log record is mapped as follows:
CREATE EMITTER audit_to_otel
FROM audit_events
TO OTEL otel_main LOGS
VALUES {
'time' = input.event_ts,
'severity_text' = input.level,
'severity_number' = input.level_num,
'body' = input.message,
'trace_id' = input.trace_id,
'span_id' = input.span_id
}
ATTRIBUTES {
'user.id' = input.user_id,
'audit.action' = leak_sensitive(input.action)
}
RESOURCE {
'service.name' = 'checkout-pipeline',
'deployment.environment.name' = 'prod'
}
SCOPE 'nervix/audit' VERSION '1.0'
MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
FLUSH EACH 2s MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
The clause order is fixed: signal, required VALUES, optional ATTRIBUTES, optional RESOURCE,
optional SCOPE '<name>' [VERSION '<version>'], then MODE. RESOURCE values must be literals or
literal arrays. ATTRIBUTES may use exact-typed STRING, BOOL, integer-family, F32, F64,
DATETIME, or array values; datetimes become RFC 3339 strings. Null attribute values are omitted.
Normal sensitivity rules apply to every expression, including ATTRIBUTES.
The closed log VALUES set is:
time: requiredDATETIMEbody: requiredSTRINGseverity_text: optionalSTRINGseverity_number: optionalI32in0..=24trace_id: optional nonzero 32-hex-characterSTRINGspan_id: optional nonzero 16-hex-characterSTRING
Trace emitters use TO OTEL <client> TRACES. Their closed VALUES set requires trace_id
(nonzero 32-hex STRING), span_id (nonzero 16-hex STRING), name (STRING), start_time
(DATETIME), and end_time (DATETIME). Optional keys are parent_span_id (nonzero 16-hex
STRING), kind (SERVER, CLIENT, INTERNAL, PRODUCER, or CONSUMER), status_code (OK,
ERROR, or UNSET), and status_message (STRING). Enum strings are case-sensitive.
Each metric emitter defines exactly one metric stream:
TO OTEL otel_main
METRIC 'http.server.request.count' UNIT '1'
DESCRIPTION 'Completed HTTP requests'
SUM MONOTONIC DELTA
VALUES {
'time' = input.window_end,
'start_time' = input.window_start,
'value' = input.request_count
}
ATTRIBUTES { 'http.route' = input.route }
RESOURCE { 'service.name' = 'checkout-pipeline' }
MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
Metric shapes are GAUGE, SUM [MONOTONIC] (DELTA | CUMULATIVE), and HISTOGRAM (DELTA | CUMULATIVE). DESCRIPTION is optional and follows the required UNIT. Gauge and sum points
require time (DATETIME) and value (an exact integer-family, F32, or F64 value). Integer
values use OTLP as_int; floating-point values use as_double. start_time is required for a
delta sum and optional otherwise.
Histogram points require time, integer-family count, integer-array bucket_counts, and F32
or F64 array explicit_bounds. Optional keys are start_time, numeric sum, min, and max;
start_time is required for delta histograms. At runtime, counts must be non-negative and
len(bucket_counts) must equal len(explicit_bounds) + 1. Exponential histograms and summaries
are not supported.
Without BATCH, each pending Arrow batch becomes one Export request containing one resource, one
scope, and all successfully converted records. With BATCH, the connector keeps that resource and
scope in every request, takes successfully converted records in source order, and divides them by
MAX MESSAGES and the exact protobuf size of each Export request. When a candidate exceeds MAX SIZE, it is halved until each request fits, and a record whose request alone exceeds it follows
ON MESSAGE ERROR as a validation error of the encode operation. The byte limit measures the uncompressed protobuf request before optional gzip; HTTP and gRPC
framing and headers are outside it. FLUSH ... MAX BATCH SIZE continues to measure the Arrow batch.
Nervix prepares each Export request once. It stamps log observed_time_unix_nano from the node’s
actual UTC clock when it prepares the request, and keeps the request’s exact protobuf bytes, and
the records they carry, until the receiver answers for it. A request whose outcome Nervix did not
learn — the connection closed before the answer, the request timed out, or the receiver asked for a
retry — is sent again with the same bytes: the same records in the same order, the same resource,
scope and observed timestamps, compressed the same way. The retry follows the requests the receiver
already answered, never sends them again, and never regroups the kept records with records that
arrived later. A receiver therefore sees a duplicate only as a repeat of a request it may already
hold.
Connection failures, timeouts, lost responses, HTTP 429 and 5xx, gRPC RESOURCE_EXHAUSTED, and
the gRPC codes the OTLP specification lists as retryable — CANCELLED, DEADLINE_EXCEEDED,
ABORTED, OUT_OF_RANGE, UNAVAILABLE and DATA_LOSS — retry with backpressure.
Retry-After and gRPC RetryInfo can extend the declared retry delay. Bad IDs, enum strings,
severity values, numeric ranges, or histogram shapes reject only the affected record through ON MESSAGE ERROR. HTTP 400 and gRPC INVALID_ARGUMENT reject every record in that request without
retry, with one shared error reference. Any other HTTP status or gRPC code the receiver answers
with means the endpoint cannot accept the export as configured, and the emitter’s unresolved
records follow ON MESSAGE ERROR. OTLP partial_success cannot be retried safely: Nervix
acknowledges every record and logs a warning, so records rejected by the receiver in that response
are lost.
For a local conversion failure, the rejected record identifies its otel.<key> field and gives a
bounded reason without quoting that field’s value. The conversion cause stays in the connector’s
internal report; the failure does not reject other records in the request.
Nervix does not provision collectors, indexes, tenants, or vendor-side telemetry objects. The OTLP endpoint must already exist; an unreachable endpoint remains an initialization or publish error.
ClickHouse
CREATE EMITTER to_ch
FROM notifications
TO CLICKHOUSE clickhouse_client INSERT TO TABLE my_table
VALUES {
"clickhouse_user_id" = input.user_id,
"clickhouse_now" = NOW(),
"clickhouse_action" = LOWER(input.action)
}
MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
BATCH MAX MESSAGES 500 MAX SIZE 8MiB
FLUSH EACH 10s MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
ClickHouse clients use the HTTP endpoint:
CREATE CLIENT ch
TYPE CLICKHOUSE
CONFIG {
'addr' = 'http://127.0.0.1:8123',
'user' = 'default',
'password' = 'nervix',
'timeout_ms' = 5000
};
Optional config keys are 'user', 'password', 'database', and 'timeout_ms'. The timeout
bounds both sending an insert body and waiting for ClickHouse to finish the insert and return its
result.
For HTTPS endpoints, mount a TLS resource and set 'tls_ca_file' to the mounted CA path.
The client resolves the host in addr through the node’s asynchronous resolver for each new
connection and tries the answers in order; a literal IPv4 or IPv6 address is dialled as written.
Every request keeps addr as its authority, and over HTTPS the server certificate must name that
host, whichever address accepted the connection. The connection, lookup included, is made while the
insert waits for its result, so timeout_ms bounds it too. A missing name, a silent name server or
an unreachable answer fails the insert, which the emitter retries on its RETRY POLICY; nothing is
acknowledged until ClickHouse returns the insert’s result. A pooled connection stays in use when its
host’s answer changes, and the next connection resolves again.
ClickHouse requires the batching clause. A flush is written as inserts of at most
MAX MESSAGES rows whose JSONEachRow body is at most MAX SIZE bytes, as
Database writes describes, and each successful insert is an acknowledgment.
ClickHouse writes each JSONEachRow line directly from the mapped Arrow columns in VALUES
order, using the same typed JSON column writer as schemaful JSON emission. Null mapped values are
written as null, including null list elements. Column names are escaped once for each write, and
string values use their carrier’s escape classification.
F32 columns keep ClickHouse’s JSON number formatting after widening to F64; schemaful JSON
codecs format F32 directly. A BYTES value is written as its own octets, which a String column
stores unchanged, where schemaful JSON codecs write padded base64.
For ClickHouse, Postgres, and MySQL, a failed multi-row insert is classified first as
record-specific or infrastructure-wide. Infrastructure failures retry with backpressure. A
record-specific failure is isolated by re-executing the insert one record at a time so healthy rows
land and only poison rows follow ON MESSAGE ERROR. Isolation can reapply rows from the failed
insert; use the sink’s idempotent write facilities where available. Dividing a flush into inserts
also means one flush is not an atomic database transaction.
For a rejected row, the error names a safe ClickHouse error name, Postgres SQLSTATE, or MySQL
SQLSTATE and code. Destination response text can quote a bound value, so it is not included in
diagnostics. Connection and pool failures retain their causes for diagnosing a retry.
Postgres
CREATE EMITTER to_pg
FROM notifications
TO POSTGRES postgres_client INSERT TO TABLE my_table
VALUES {
"postgres_user_id" = input.user_id,
"postgres_now" = NOW() AS STRING,
"postgres_action" = LOWER(input.action)
}
MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
BATCH MAX MESSAGES 500 MAX SIZE 8MiB
FLUSH EACH 10s MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
Postgres emitters use VALUES expressions and insert batches with INSERT ... SELECT ... FROM unnest(...). The batching clause is required: each insert carries at most
MAX MESSAGES records, and its statement text and bound arrays measure at most MAX SIZE bytes, as
Database writes describes. The insert result acknowledges those records. On the
poison-isolation path, tables without an idempotent ON CONFLICT policy may observe duplicates when
healthy records are re-executed.
Postgres emitters may include an insert conflict policy after VALUES:
ON CONFLICT ("postgres_user_id") DO UPDATE
ON CONFLICT ("postgres_user_id") DO NOTHING
ON CONFLICT DO NOTHING
DO UPDATE updates every mapped VALUES column except the conflict target columns, and requires a conflict target. DO NOTHING may be used with or without a target.
Postgres refuses a DO UPDATE insert that carries one conflict key twice, so such an insert is
written again one record at a time, and the later record updates the earlier one.
Postgres clients declare their connection-pool bounds and connect with a postgres:// or
postgresql:// URL. The URL must select one of two TLS policies: sslmode=disable for an
unencrypted connection, or sslmode=verify-full for TLS with certificate-chain and hostname
verification. There is no opportunistic fallback and no encrypted connection without peer
verification. See Database Client Connection Pools for the accepted
pool counts:
CREATE CLIENT pg
TYPE POSTGRES
POOL SIZE MIN 2 MAX 8
CONFIG {
'addr' = 'postgresql://postgres:nervix@127.0.0.1:5432/postgres?sslmode=disable'
};
For TLS connections, use sslmode=verify-full, mount a TLS resource, and set 'tls_ca_file' to the mounted CA path. 'tls_cert_file' and 'tls_key_file' supply a client identity and must be given together. TLS files require sslmode=verify-full.
MySQL
CREATE EMITTER to_mysql
FROM notifications
TO MYSQL mysql_client INSERT TO TABLE my_table
VALUES {
"mysql_user_id" = input.user_id,
"mysql_now" = NOW() AS STRING,
"mysql_action" = LOWER(input.action)
}
MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
BATCH MAX MESSAGES 500 MAX SIZE 8MiB
FLUSH EACH 10s MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
MySQL emitters use VALUES expressions and insert batches with a multi-row INSERT ... VALUES (?, ...), ... command. The batching clause is required: each insert carries at most
MAX MESSAGES records and no more than fit the 65,535 placeholders one statement binds, and its
statement text and bound values measure at most MAX SIZE bytes, as
Database writes describes. The insert result acknowledges those records.
Conflict clauses are the user’s tool for bounding duplicates when poison isolation re-executes a
failed insert. Declare a MAX SIZE no larger than the server’s max_allowed_packet, 64 MiB by
default on MySQL 8.x, which the client applies to every packet it sends.
MySQL emitters may include an insert conflict policy after VALUES:
ON CONFLICT DO UPDATE
ON CONFLICT DO NOTHING
MySQL and MariaDB resolve conflicts through primary and unique keys already defined on the table, so the NSPL conflict policy does not accept a target list. DO UPDATE uses ON DUPLICATE KEY UPDATE for all mapped VALUES columns. DO NOTHING uses a no-op duplicate-key update.
MySQL clients declare their connection-pool bounds and use a mysql_async connection URL. See Database Client Connection Pools for the accepted counts:
CREATE CLIENT mysql
TYPE MYSQL
POOL SIZE MIN 2 MAX 8
CONFIG {
'addr' = 'mysql://nervix:nervix@127.0.0.1:3306/nervix'
};
For TLS connections, include require_ssl=true, mount a TLS resource, and set 'tls_ca_file' to the mounted CA path.
MongoDB
CREATE EMITTER to_mongodb
FROM notifications
TO MONGODB mongodb_client INSERT TO COLLECTION my_collection
VALUES {
"mongodb_user_id" = input.user_id,
"mongodb_now" = NOW() AS STRING,
"mongodb_action" = LOWER(input.action)
}
MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
BATCH MAX MESSAGES 500 MAX SIZE 8MiB
FLUSH EACH 10s MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
MongoDB emitters use VALUES expressions and bulk writes. The batching clause is
required: each write carries at most MAX MESSAGES documents, which measure at most MAX SIZE
bytes, as Database writes describes. MongoDB reports per-document outcomes, so
healthy documents acknowledge and poison documents follow ON MESSAGE ERROR without a separate
isolation pass. Transient or infrastructure failures retry only the undelivered documents. A
document larger than MongoDB’s 16 MiB document limit is rejected before the write that would carry
it.
Every mapped integer is written as a BSON 64-bit signed integer, and a U64 value above that range
has no BSON integer at all. Such a record is rejected before its document is written: it follows
ON MESSAGE ERROR with a validation error whose affected field is mongodb.<column>, it is never
inserted, and its value is never used as an ON CONFLICT target. The rejection is permanent, so the
record does not retry. Every other record in the same write is unaffected, and a mapped value that
is genuinely NULL is still written as BSON null.
MongoDB emitters may include an insert conflict policy after VALUES:
ON CONFLICT ("mongodb_user_id") DO UPDATE
ON CONFLICT ("mongodb_user_id") DO NOTHING
MongoDB conflict policies require a target list because the emitter must build an explicit upsert filter. Target fields must be mapped in VALUES. DO UPDATE updates every mapped field except the conflict target fields and inserts the full mapped document when no existing document matches. DO NOTHING inserts only when no document matches the target.
Emitters using either MongoDB ON CONFLICT form require MongoDB 8.0 or newer because those modes
execute each write as one client bulk write.
MongoDB clients declare their connection-pool bounds and use a MongoDB connection URL and database name. See Database Client Connection Pools for the accepted counts:
CREATE CLIENT mongodb
TYPE MONGODB
POOL SIZE MIN 2 MAX 8
CONFIG {
'addr' = 'mongodb://root:nervix@127.0.0.1:27017/nervix?authSource=admin',
'database' = 'nervix'
};
For TLS connections, include tls=true, mount a TLS resource, and set 'tls_ca_file' to the mounted CA path.
Iceberg
CREATE CLIENT s3_main
TYPE S3
CONFIG {
'endpoint' = 'http://127.0.0.1:9900',
'region' = 'us-east-1',
'access_key_id' = 'rustfsadmin',
'secret_access_key' = 'rustfsadmin',
'path_style_access' = true
};
CREATE CLIENT iceberg_catalog
TYPE ICEBERG_REST
CONFIG {
'uri' = 'http://127.0.0.1:8181',
'warehouse' = 's3://nervix-iceberg/warehouse'
};
CREATE EMITTER iceberg_notifications
FROM notifications
TO ICEBERG ON S3 s3_main TABLE notifications
VALUES {
'user_id' = input.user_id,
'action' = input.action
}
LOCATION 's3://nervix-iceberg/tables/notifications'
CATALOG iceberg_catalog COMMIT EACH 1m MAX SIZE 512MiB
MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
FLUSH EACH 10s MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
Iceberg emitters use explicit VALUES expressions and do not declare ENCODE USING. The ON S3, ON GCS, or ON AZURE_BLOB backend clause selects the object-store implementation. The referenced blob client supplies the object-store connection for table files. The CATALOG <client> clause references a separate TYPE ICEBERG_REST client that supplies the REST catalog URI and warehouse. The referenced REST catalog namespace and table must already exist; Nervix loads that table and appends data, but does not create catalog namespaces or tables implicitly. The emitter owns the Iceberg table name, mapped output columns, table location, catalog client reference, and flush policy.
The catalog and object-store HTTP clients resolve endpoint names through the node’s configured DNS resolver. The catalog keeps its configured URL and authentication, while OpenDAL uses the resolver for object operations and credential HTTP calls. DNS failures enter the existing sink initialization or commit retry path; they do not advance the commit or ACK boundary.
GCS uses the same emitter shape with a TYPE GCS client and gs:// locations:
CREATE CLIENT gcs_main
TYPE GCS
CONFIG {
'service_path' = 'https://storage.googleapis.com',
'token' = '<oauth2-token>'
};
CREATE CLIENT iceberg_catalog
TYPE ICEBERG_REST
CONFIG {
'uri' = 'https://iceberg-rest.example.com',
'warehouse' = 'gs://nervix-iceberg/warehouse'
};
CREATE EMITTER iceberg_notifications
FROM notifications
TO ICEBERG ON GCS gcs_main TABLE notifications
VALUES {
'user_id' = input.user_id,
'action' = input.action
}
LOCATION 'gs://nervix-iceberg/tables/notifications'
CATALOG iceberg_catalog COMMIT EACH 1m MAX SIZE 512MiB
MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
FLUSH EACH 10s MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
Azure Blob uses TYPE AZURE_BLOB and wasbs:// locations. wasb:// is also accepted for plain-HTTP local endpoints:
CREATE CLIENT azure_main
TYPE AZURE_BLOB
CONFIG {
'account_name' = 'myaccount',
'account_key' = '<account-key>'
};
CREATE CLIENT iceberg_catalog
TYPE ICEBERG_REST
CONFIG {
'uri' = 'https://iceberg-rest.example.com',
'warehouse' = 'wasbs://nervix-iceberg@myaccount.blob.core.windows.net/warehouse'
};
CREATE EMITTER iceberg_notifications
FROM notifications
TO ICEBERG ON AZURE_BLOB azure_main TABLE notifications
VALUES {
'user_id' = input.user_id,
'action' = input.action
}
LOCATION 'wasbs://nervix-iceberg@myaccount.blob.core.windows.net/tables/notifications'
CATALOG iceberg_catalog COMMIT EACH 1m MAX SIZE 512MiB
MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
FLUSH EACH 10s MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
The REST catalog is the authority for namespace and table metadata. Nervix does not write a separate object-store catalog pointer file and does not provision catalog entries from the emitter runtime path.
Iceberg uses two explicit boundaries. FLUSH collects typed in-memory batches and writes them to
local Arrow IPC files under the runtime temporary-file root. COMMIT EACH <duration> MAX SIZE <bytes> reads the staged Arrow IPC batches, concatenates them into one Arrow batch, appends that
batch to the Iceberg table, and commits the catalog update. The commit duration starts when a flush
stages rows while none are staged, and rows later flushes stage join that commit without moving it,
so a row waits at most FLUSH EACH in memory and then at most COMMIT EACH after it is staged.
Staged files that reach MAX SIZE are committed at once. Both durations are domain-logical, so a
paced domain’s TIME RATE moves the flush and commit boundaries together; the maximum batch and
commit sizes, the catalog retry backoff, and a drain remain independent of domain pace. The
temporary-file root defaults to /tmp and can be changed with --temp-dir or NERVIX_TEMP_DIR.
The sink completion point for MODE ACK is the successful catalog commit. Local staging is not an
ACK boundary. Commit conflicts, incompatible table evolution, a dropped table, and unavailable
catalog or object storage are table-level infrastructure failures: Nervix reports them in runtime
and drain status and retries all affected staged records with the declared policy and
backpressure. Record-level expression and construction errors are attributed through ON MESSAGE ERROR before staging. An ambiguous failure after the catalog commit can append the rows again;
Iceberg appends are not idempotent. See ACK Semantics And Effective
Delivery for attachment and fan-out behavior.
Codec Behavior On Emission
ENCODE USING <codec> follows the sink it encodes for, because whether a codec applies is a
property of the sink. Kafka, Pulsar, RabbitMQ, Redis, MQTT, NATS, ZeroMQ, SQS and Sentry publish an
encoded payload and require it. ClickHouse, Postgres, MySQL, and MongoDB map columns with VALUES
and do not take a codec. OTEL maps signal fields and attributes with VALUES and takes no codec.
Iceberg writes typed records and takes none.
JAQ-native codecs can reshape outbound payloads with ON EMITTING before writing the selected
format:
CREATE IF NOT EXISTS CODEC notification_codec
FROM JSON
TO SCHEMA notification
WITH JAQ TRANSFORMATIONS ON EMITTING '{payload: .}';
That lets the emitter publish a different JSON envelope for each outbound row without changing the declared relay schema.
ACK Semantics And Effective Delivery
Nervix composes per-hop ACKs. The effective delivery semantics of a source-to-sink path are the
observable duplicate and loss behavior produced by the source delivery mode, the emitter’s
publishing MODE, its attachment, and the external service. They are not the ACK mechanics of any
one hop.
Publishing MODE selects the sink completion point at which the emitter considers a record
delivered. Attachment determines whether that outcome participates in the upstream ACK chain:
ATTACHED: emitter success or failure at the selected sink completion point stays part of the upstream ACK chain.DETACHED: relay fan-out acknowledges upstream immediately. The emitter still waits for its declared confirmations, applies its retry policy, error-routes record failures, and exerts local backpressure, but that outcome cannot delay, retry, or fail the source ACK.
Confirming broker modes and request/response ACK modes are at least once. A confirmation timeout
or lost response is not proof that the service rejected a record, so retry can duplicate it. The
parallel window limits how many records are exposed to that ambiguity at one time, and Nervix
resends only records not yet confirmed or definitively rejected. With the
batching clause the unit is the batch payload: a retry resends exactly the payloads not
yet confirmed or rejected, with the bytes and members they were first written with. NO_ACK, MQTT QoS 0, Core NATS,
Redis Pub/Sub, and ZeroMQ expose earlier acceptance boundaries and can lose acknowledged records
after a crash or downstream failure. External broker durability and idempotence settings remain
the user’s client and service configuration.
When one source record reaches multiple emitters or multiple attached routes, the upstream ACK completes only after every attached emitter reaches its sink completion point. A failure on any attached path reopens source retry for the record on all paths. A sink that already published successfully may therefore receive the record again because a sibling sink failed. This applies to every sink without idempotent writes. Iceberg is the canonical case: rows can be appended to the table again after a sibling emitter fails.
This sibling-retry case assumes a source mode that retries when an attached ACK fails or is lost. A no-ACK source cannot create that retry duplicate, but it can lose the record instead.
A WASM processor on the path adds one more retry of the same kind. It dispatches a guest callback’s output before the checkpoint of the guest’s state completes and holds back only the source ACK, so a checkpoint that fails negatively acknowledges records whose output a sink may already have published, and the source redelivers them. See Recovery, Replay And Duplicates.
Every DETACHED path has a common loss window: a process can fail after relay fan-out acknowledges
upstream but before the emitter reaches its declared sink completion point. The table below calls
out the additional mode- and transport-specific duplicate and loss conditions.
| Sink | Duplicate conditions (ATTACHED) | Additional loss conditions | Idempotency available in Nervix |
|---|---|---|---|
| Kafka | ACK retry after an ambiguous delivery report or timeout; either mode after a lost upstream ACK or attached sibling failure | NO_ACK can lose a record after local producer-queue admission; broker durability follows Kafka client and topic configuration | None; Kafka producer idempotence is pass-through client configuration |
| Pulsar | ACK retry after an ambiguous broker receipt; either mode after a lost upstream ACK or attached sibling failure | NO_ACK does not expose broker failures after producer acceptance; retention and durability remain broker policy | None |
| NATS | JetStream retry after an ambiguous PubAck; either mode after a lost upstream ACK or attached sibling failure | Core NATS NO_ACK connection flush is not durable stream acknowledgement | None |
| RabbitMQ | Confirming ACK retry after a nack, timeout, or lost confirm; NO_ACK retry after its channel or connection is lost before a write’s round trip completes; either mode after a lost upstream ACK or attached sibling failure | NO_ACK can lose a record after the broker’s channel has taken it; queue durability and message persistence remain broker policy | None |
| SQS | Retry after an ambiguous SendMessage result, lost ACK, or attached sibling failure | Any failure after detached relay acceptance; SQS retains its own at-least-once behavior | None |
| MQTT | QoS 1 or 2 retry after an ambiguous handshake; any mode after a lost upstream ACK or attached sibling failure | QoS 0 can lose a record after client acceptance; later delivery follows the configured broker and session guarantees | None |
| Redis Pub/Sub | Retry after Redis accepts PUBLISH but the Nervix ACK is lost, or after attached sibling failure | Any failure after detached relay acceptance; subscribers that are absent or disconnected miss the message | None |
| ZeroMQ | Retry after socket send acceptance followed by lost ACK or attached sibling failure | Any failure after detached relay acceptance; socket send does not establish durable receiver storage | None |
| Sentry | Retry after an ambiguous HTTP result, lost ACK, or attached sibling failure | Any failure after detached relay acceptance; an accepted event can still be subject to Sentry service policy | None |
| OTEL | Retry after an ambiguous Export result, which resends the same request bytes; lost ACK; or attached sibling failure | Any failure after detached relay acceptance; partial_success acknowledges the whole request, so receiver-rejected records in that response are lost | None |
| HTTP | Retry after a lost response, failed connection, or timeout following an endpoint that applied the request; a lost ACK or attached sibling failure | Any failure after detached relay acceptance; a 2xx delivers on its headers, so an endpoint that later fails its own processing loses the record | None; the endpoint can deduplicate by a stable key the emitter writes with write_header |
| ClickHouse | Retry after an ambiguous insert result, lost ACK, or attached sibling failure | Any failure after detached relay acceptance; a crash after insert but before acknowledgement can also leave an inserted batch that later retries | None |
| Postgres | Retry after an ambiguous transaction result, lost ACK, or attached sibling failure | Any failure after detached relay acceptance; a committed insert can survive a crash before Nervix observes success | ON CONFLICT |
| MySQL | Retry after an ambiguous transaction result, lost ACK, or attached sibling failure | Any failure after detached relay acceptance; a committed insert can survive a crash before Nervix observes success | ON CONFLICT |
| MongoDB | Retry after an ambiguous write result, lost ACK, or attached sibling failure | Any failure after detached relay acceptance; a committed write can survive a crash before Nervix observes success | ON CONFLICT |
| Iceberg | Retry after a commit with a lost ACK or attached sibling failure; appends repeat rows | Crash before catalog commit loses staged work unless the attached source redelivers; detached mode accepts that loss | None; appends are not idempotent |
The publishing-mode table names each transport’s exact completion point.
ATTACHED waits only for that declared point and cannot make an earlier NO_ACK boundary durable.
MQTT QoS 0, Core NATS, Redis Pub/Sub, and ZeroMQ can still lose a message after Nervix observes
client-side acceptance, and RabbitMQ NO_ACK after the broker’s channel has taken it. DETACHED
cannot turn a confirming mode into fire-and-forget inside the emitter; it changes only whether the
result participates upstream.
Emit a stable idempotency key at ingestion, for example with uuid_v7(), and carry it through the
graph. Downstream consumers and queries can use that key to suppress retries within that admitted
record’s fan-out. A source-provided identifier is stronger because it also survives a fresh source
redelivery. Generate the key once; regenerating it on a downstream route defeats the purpose.
For Postgres, MySQL, and MongoDB, use ON CONFLICT against a stable key. This preserves
at-least-once delivery attempts while making the resulting table or collection state
effectively-once for that conflict contract.
Iceberg appends are not idempotent. Deduplicate by the stable key at query time or in downstream
compaction or MERGE work. Nervix does not isolate sibling-sink retries by changing ACK mechanics.
That is an accepted tradeoff.
Accepted Tradeoff: Shared Retry
One input ACK represents all attached descendants. This keeps ACK composition small and preserves backpressure across the graph. It also means one attached failure retries successful siblings. Nervix accepts that coupling instead of maintaining a transactional per-sink commit ledger.
Graph Design
When the same records feed a non-idempotent sink and other emitters, consider DETACHED mode for a
non-critical path or separate relays with separate ACK boundaries per sink so one sink’s failure
does not drive duplicates into another. DETACHED makes that path at-most-once relative to the
upstream ACK: the emitter still confirms and retries according to MODE, but a crash after the
detached ACK can lose its in-memory work without causing source redelivery.
See Data Plane for the relay fan-out mechanics and What It Is Not for the persistence boundary.
Placement Policies
Placement policies control where a domain’s schedulable work executes. Use them to keep a latency-sensitive processing corridor on one machine, prefer locality without forcing it, or bias expensive stages across machines.
Nervix distinguishes two kinds of nodes throughout this chapter:
- A runtime node is a schedulable graph entity such as a relay, ingestor, processor, lookup, generator, or emitter.
- A cluster node is a machine participating in the Nervix cluster.
A named placement rule marks the directed paths between existing runtime nodes. For example, this
rule keeps feature_normalizer, risk_scorer, and every runtime node on a path between them on
one cluster node:
CREATE PLACEMENT scoring_local
FROM feature_normalizer
TO risk_scorer
REQUIRE COLOCATION
RANK 1;
Placement changes assignments, not graph routing. Records still follow the routes declared by the ingestors, processors, and emitters.
Choosing A Policy
The policies occupy one colocation scale; NEUTRAL expresses no opinion on that scale. There is
no hard-separation policy.
| Policy | Scheduling effect | Existing assignments |
|---|---|---|
REQUIRE COLOCATION | Every runtime node within each captured corridor executes on the same cluster node. | A newly effective requirement can relocate runtime nodes. |
PREFER COLOCATION | New placement decisions favor the same cluster node. | The preference does not move existing assignments by itself. |
NEUTRAL | The placement rule expresses no preference, so ordinary scheduler heuristics remain active. | It does not move assignments by itself. |
SUGGEST SEPARATION | New placement decisions favor different cluster nodes. | The suggestion does not move existing assignments by itself. |
Use REQUIRE COLOCATION when a remote hop is unacceptable, especially for a short
latency-critical path or a materialized-state reader. Use PREFER COLOCATION when locality is
valuable but the scheduler should retain freedom to distribute work. Use SUGGEST SEPARATION to
reduce the chance that expensive stages share one machine, not to create an isolation boundary.
For new assignments, an explicit soft policy takes precedence over the scheduler’s built-in
upstream-locality preference. It remains advisory: cluster availability and the scheduler’s other
placement inputs can still produce a different result. NEUTRAL means “no placement opinion”;
it does not randomize placement or disable the scheduler’s normal heuristics.
Setting The Domain Default
Every domain has a fallback placement policy. Declare it when creating the domain:
CREATE [IF NOT EXISTS] UNPACED DOMAIN <domain_name>
[PLACEMENT <policy>];
CREATE [IF NOT EXISTS] PACED DOMAIN <domain_name>
WITH PERIOD <duration> SKEW <duration>
[PLACEMENT <policy>];
CREATE DOMAIN remains the short spelling for CREATE UNPACED DOMAIN and accepts the same
placement clause. Omitting PLACEMENT selects NEUTRAL.
CREATE UNPACED DOMAIN realtime
PLACEMENT PREFER COLOCATION;
The default applies to directly connected runtime nodes when no named placement rule claims that relationship. Relays are runtime nodes, so producer-to-relay and relay-to-consumer are separate hops. The default is deliberately per hop:
REQUIRE COLOCATIONplaces each connected graph component on one cluster node unless a stronger named rule carves out part of it.PREFER COLOCATIONadds a locality preference to each uncovered hop.SUGGEST SEPARATIONadds a spreading preference to each uncovered hop.NEUTRALleaves each uncovered hop to ordinary scheduler behavior.
Change the default for the session’s active domain with the nameless ALTER DOMAIN statement:
ALTER DOMAIN SET PLACEMENT REQUIRE COLOCATION;
Changing a running domain’s default activates a new schedule. A newly effective
REQUIRE COLOCATION default can relocate runtime nodes; changing only a soft default does not
migrate existing assignments.
Defining A Named Corridor
Create a named rule after every referenced member exists in the active domain. Placements are domain-owned and cannot span domains:
CREATE [IF NOT EXISTS] PLACEMENT <placement_name>
FROM <runtime_node> [, <runtime_node> ...]
TO <runtime_node> [, <runtime_node> ...]
REQUIRE COLOCATION | PREFER COLOCATION | NEUTRAL | SUGGEST SEPARATION
[RANK <positive_integer>];
Each FROM member is paired with each TO member. Nervix evaluates those directed endpoint pairs
independently and applies the policy only where a path exists.
The following user-declared runtime-node kinds are placement-eligible:
- ingestors, except cluster-wide server-listener ingestors;
- reingestors and generators;
- junctions, deduplicators, correlators, reorderers, and window processors;
- inferencers and WASM processors;
- emitters and
HASH MAPlookups; and - every relay, whether or not it has materialized state.
A relay name always denotes its relay runtime node. Enabling materialized state adds scheduler-selected state replicas to that relay; it does not add another placement member or runtime-node kind. See Materialized Relay State for dependency behavior.
HTTP ENDPOINT, WEBSOCKETS ENDPOINT, and SYSLOG LISTENER ingestors execute on every cluster
node and therefore cannot be constrained by a placement rule. An ingestor’s head fixes the pair it
reads, so altering an ingestor never changes whether it can be a placement member. Member names are unqualified; a name shared by more than one
eligible entity kind is ambiguous and must be changed before it can be used in a placement.
Duplicate names on one side collapse. Both sides must contain at least one member after duplicate
removal. The same runtime node may appear in FROM and TO, but it contributes coverage only when
the graph contains a directed cycle back to that runtime node.
Understanding Path-Gated Coverage
For each connected FROM/TO pair, the rule captures both endpoints and every runtime node that
can carry traffic from the source to the destination. If the graph contains alternate paths, all
runtime nodes on those paths are part of the corridor. A cycle within a corridor is captured in
full.
For this graph:
feature_normalizer -> enrich_features -> risk_scorer
-> validate_features -> risk_scorer
a rule from feature_normalizer to risk_scorer covers all four runtime nodes. The policy applies
across the runtime nodes captured by that corridor, not only to its two named endpoints.
Placement follows the relationships that can deliver messages or state:
- normal output routes;
- message-error routes;
- correlation-timeout routes;
- generator feeds; and
- materialized-state dependencies, directed from the relay to each reader
that declares
USING MATERIALIZED STATE.
Relays are corridor members. A producer-to-relay or relay-to-consumer relationship is one placement hop, so a domain default applies independently on both sides and a named path captures relay nodes between its endpoints.
A disconnected endpoint pair contributes no coverage. A rule for which every endpoint pair is
disconnected is valid and remains available for inspection with coverage=empty. Nervix does not
force unconnected workloads into a group merely because their names occur in one rule.
Separate connected corridors are also evaluated independently. Runtime nodes captured by two different corridors in one rule are not related to each other unless they occur together in at least one corridor.
Coverage is recalculated whenever the graph activates. Adding or removing a route can therefore
grow or shrink an existing rule without altering the rule itself. Inspect placement coverage after
topology changes, especially when a broad rule uses several FROM or TO members.
Resolving Overlapping Rules
RANK decides which named rule wins where rules overlap. The policy names themselves do not
establish precedence: a stronger-ranked NEUTRAL rule overrides a weaker-ranked
REQUIRE COLOCATION rule on the relationships they both cover.
Precedence is:
- rank 1;
- rank 2, rank 3, and subsequent explicit ranks in ascending numeric order;
- the unranked tier; and
- the domain default.
Every explicitly ranked rule outranks every unranked rule, regardless of the numeric value. All unranked rules share one tier. At the strongest rank affecting a runtime-node relationship, equal-rank rules with the same policy agree. Different policies at that strongest rank conflict, and Nervix rejects the candidate graph activation with the rule names and an affected runtime-node pair. Weaker overridden rules do not conflict with one another.
Effective REQUIRE COLOCATION relationships are transitive. If one requirement joins a to b
and another joins b to c, all three form one colocation group. A stronger soft or neutral rule
can remove specific hard relationships, but a runtime node remains in the group while any other
effective REQUIRE COLOCATION chain still connects it.
For example, first declare a scheduler-managed archive corridor:
CREATE PLACEMENT archive_scheduler_managed
FROM archiver
TO cold_sink
NEUTRAL
RANK 1;
Then make hard colocation the domain fallback:
ALTER DOMAIN SET PLACEMENT REQUIRE COLOCATION;
The named NEUTRAL rule overrides the default within its connected corridor. It does not guarantee
that the archive stages run on different cluster nodes; it returns those relationships to ordinary
scheduler heuristics. Use SUGGEST SEPARATION instead when the carve-out should also express a
soft spreading preference.
Runtime Behavior
Placement coverage, rank resolution, and colocation groups are recalculated as part of graph activation. A conflicting candidate is rejected before it replaces the active graph.
Every single-owner ingestor keeps its existing primary and replica assignment across schedule
recomputation while all assigned cluster nodes are live. This includes outbound WebSocket-client
ingestors. Model changes, cluster-node joins and uncordons, and soft placement changes therefore do
not migrate those ingestors. HTTP ENDPOINT, WEBSOCKETS ENDPOINT, and SYSLOG LISTENER
ingestors are the only ingestors that instead follow live cluster membership, because their server
listeners execute on every cluster node.
When a new REQUIRE COLOCATION relationship becomes effective, Nervix consolidates the complete
colocation group onto one eligible cluster node. Existing assignments are preserved only when
they satisfy the hard requirement. On a running domain, Nervix plans every relocation before
holding the graph, gates all moved groups together, drains their admitted work, and publishes the
placement model and assignments in one schedule update. A timeout leaves both the placement model
and schedule unchanged. The immediate command, or the aggregate COMMIT result for a transaction,
reports quiesce level: ENTITY_PAUSE and the number of planned relocations. A change with no owner
move reports DYNAMIC.
PREFER COLOCATION and SUGGEST SEPARATION affect new placement decisions, including newly
created runtime nodes and targets selected during failover or drain. They never move an existing
assignment merely to improve a preference. NEUTRAL contributes no placement score. Removing a
hard requirement also does not spread runtime nodes that are already on the same cluster node.
Failover and drain relocate a hard colocation group as one unit, so an executing assignment never
violates REQUIRE COLOCATION. A cordoned cluster node is not considered for a new primary or
ordinary replica assignment. During a successful planned move, however, a live former primary is
kept as the first replica when the configured replica count provides a slot. See
Control Plane for the gate, drain,
failure, and activation behavior.
Explicit relocation with RELOCATE is the third planned ownership change: it moves a selected
subgraph onto a cluster node you name, through the same handoff, and leaves every runtime node
outside its unit untouched.
Drain, colocation consolidation, and explicit relocation use a planned handoff and restart only
runtime nodes whose primary owner changed. A runtime node that keeps its assignment continues on every cluster node,
including the former and new owners of a moved neighbor: it keeps buffered work, retained
REQUIRED WAIT messages, branch-local state, and external source sessions. Its neighbors re-point
at the committed revision. Draining a host moves hard groups and independent nodes one at a time in
canonical kind/name order. Failover uses immediate termination because the former owner is already
unavailable.
Placement constrains primary owners. It does not control replica count or replica placement. An ordinary relay has no replicas; a materialized relay’s replicas contain only materialized state. Branches also have no separate placement dimension: every concrete relay branch is owned by its relay owner, and every processor branch executes within that processor’s assignment.
Relocating Runtime Nodes
Placement rules constrain where the scheduler puts work. RELOCATE moves chosen work onto a
cluster node you name, and DESCRIBE RELOCATION shows the plan a statement would execute without
executing it:
RELOCATE <selection>
ONTO NODE <node_id>
FOLLOW PREFERENCES | IGNORE PREFERENCES
[FOR <kind> <name> FOLLOW PREFERENCES | IGNORE PREFERENCES ...];
DESCRIBE RELOCATION <selection>
ONTO NODE <node_id>
FOLLOW PREFERENCES | IGNORE PREFERENCES
[FOR <kind> <name> FOLLOW PREFERENCES | IGNORE PREFERENCES ...];
DESCRIBE RELOCATION accepts exactly the clauses of RELOCATE after the verb, so a plan can be
inspected and then executed by changing one word.
Selecting The Subgraph
A selection is either an explicit list or a directed corridor, using the same path-gated coverage named placement rules use:
RELOCATE JUNCTION risk_scorer ONTO NODE node-2 FOLLOW PREFERENCES;
RELOCATE FROM JUNCTION feature_normalizer TO JUNCTION risk_scorer
ONTO NODE node-2
FOLLOW PREFERENCES
FOR DEDUPLICATOR dedup_txns IGNORE PREFERENCES;
Every member is kind-qualified, because a name is unique only within its kind. The kind uses the
same spelling as SHOW CREATE <kind> <name>:
<kind> | Runtime node |
|---|---|
INGESTOR | An ingestor other than an endpoint or Syslog source. |
REINGESTOR, GENERATOR | The named reingestor or generator. |
JUNCTION, DEDUPLICATOR, CORRELATOR, REORDERER, WINDOW PROCESSOR | The named processor. |
INFERENCER, WASM PROCESSOR | The named processor. |
EMITTER | The named emitter. |
HASH MAP | The named lookup; reported as kind lookup in schedule output. |
RELAY | The named relay, materialized or not. |
Any other kind keyword is a parse error. Names resolve in the session’s active domain, duplicate
members collapse, and an ingestor with several INSTANCES is one runtime node whose instances move
together. A corridor whose every endpoint pair is disconnected is rejected; a partially connected
corridor is valid and the plan reports each pair’s connectivity. The destination must be a Raft
voter that is live and not cordoned.
Building The Unit
The set of hard groups a statement moves is its unit. REQUIRE COLOCATION is not a preference: a
selected runtime node always moves with its whole hard group. Soft preferences are shaped by the
strategy:
FOLLOW PREFERENCESmoves eachPREFER COLOCATIONpartner of the group along and holds eachSUGGEST SEPARATIONpartner out of the unit.IGNORE PREFERENCESmoves the group and lets its preferences shape nothing: its partners are neither captured nor held back by it.
The strategy after ONTO NODE is mandatory and applies to every hard group. A FOR clause names
one runtime node and sets the strategy of that runtime node’s whole hard group, because a group
cannot be moved in pieces and its preferences are one set. Two FOR clauses that disagree within
one hard group are rejected, and a FOR clause naming a runtime node outside the plan is rejected.
Capture is transitive and follows the statement default. A captured group inherits that default, so
FOLLOW PREFERENCES closes the unit over PREFER COLOCATION relationships until a
FOR ... IGNORE PREFERENCES cuts the chain, while IGNORE PREFERENCES with a
FOR ... FOLLOW PREFERENCES opens capture at one group only. Under a PREFER COLOCATION domain
default, a FOLLOW PREFERENCES relocation therefore moves the whole connected component reachable
from the selection. Soft preferences never cause a rejection on their own; the plan reports every
preference the move leaves unsatisfied.
Reading The Plan
Both statements return the same block, and RELOCATE prefixes it with the executed outcome and
appends the hold duration:
relocation onto node 'node-2'
quiesce level: ENTITY_PAUSE
gated relays: normalized_features, scored_events
coverage:
- junction feature_normalizer -> junction risk_scorer connected=yes covered=2
unit:
- kind=junction name=feature_normalizer group=1 strategy=follow reason=selected owner=node-1 moves=yes replicas=node-1 promoted_replica=no
- kind=junction name=risk_scorer group=1 strategy=follow reason=selected owner=node-1 moves=yes replicas=node-1 promoted_replica=no
- kind=deduplicator name=dedup_txns group=2 strategy=ignore reason=preferred owner=node-2 moves=no replicas=node-3
unsatisfied preferences: 1
- suggest separation risk_scorer <-> archive_transform (spread_archive)
coverage: appears for the corridor form only. gated relays: lists the relays the hold gates, or
- for a DYNAMIC plan. group numbers the hard groups in the unit and reason is selected,
required for a hard-group mate, or preferred for a captured partner. owner is the current
owner; a member that moves also reports its new replicas and whether the destination was already a
replica. unsatisfied preferences counts, after the move, every PREFER COLOCATION relationship
touching the unit whose two owners differ and every SUGGEST SEPARATION relationship touching the
unit whose two owners are the same, naming the winning rule or domain default.
kind and name use the schedule spelling shown by SHOW CLUSTER STATUS, so a HASH MAP member
appears as kind=lookup and a RELAY member as kind=relay.
Executing The Move
A relocation in a running domain that moves at least one runtime node is a planned ownership change
and executes as one unit, one hold, one commit: the hold gates exactly the relays the plan lists,
every moved runtime node drains its admitted work, one schedule write assigns the whole unit to the
destination, and the gates open once the destination has activated the revision. The unit is
atomic, because the plan is what you inspected and approved; a unit containing a runtime node that
cannot drain is excluded with a narrower selection or IGNORE PREFERENCES, or the stall is
resolved and the statement retried. See
Control Plane for the shared hold,
drain, and failure behavior.
Successful relocation returns after the destination has activated the exact schedule revision, the source has drained, and the handoff gates have opened. A following command can use the destination immediately. Cordon and uncordon similarly return after the eligibility state is authoritative; drain and node removal return after resulting ownership and schedules are usable on the remaining live set.
The reported quiesce level is ENTITY_PAUSE when the plan moves at least one runtime node in a
running domain, and DYNAMIC when the plan moves nothing or the domain is stopped. A stopped
domain keeps its schedule, so its runtime nodes are relocated without a hold and execute on the
destination when the domain starts. A unit member whose owner is already the destination is
retained in the plan and left untouched; a unit member whose owner is unavailable is rejected until
failover has given it a live owner.
For each moved member the primary assignment becomes the destination, and replicas are recomputed
against the configured replica count: the former owner first when it is a live schedulable voter,
then the member’s existing replicas other than the destination, then the cluster nodes the
scheduler would choose. A relay without materialized state has no replicas regardless of the count.
Kafka OFFSET BY DOMAIN partition-to-instance assignments are preserved verbatim, and the host of
a moved hard group becomes the destination.
RELOCATE is a leader operation that holds the domain’s exclusive alteration lock from planning
through release, so a model change, a placement change, another RELOCATE, or a DRAIN NODE of
the same domain is rejected with the concurrent-alteration diagnostic rather than queued.
DESCRIBE RELOCATION is read-only and is served by any cluster node.
A relocation records no affinity. It is a one-time move, not a pin: a later failover, drain, or
newly effective REQUIRE COLOCATION consolidation may move the runtime node again, and Nervix does
not move it back when its former owner returns. A preference left unsatisfied by a relocation stays
unsatisfied, because Nervix never moves an existing assignment to improve a preference.
Altering And Dropping Rules
ALTER PLACEMENT accepts one or more comma-separated operations:
ALTER PLACEMENT <placement>
SET POLICY <policy>
| SET RANK <positive_integer>
| DROP RANK
| SET FROM <runtime_node> [, ...] TO <runtime_node> [, ...]
| RENAME TO <placement_name>
[, <operation> ...];
DROP PLACEMENT <placement>;
Operations execute in written order, and the complete statement is applied atomically. SET FROM ... TO ... replaces both member lists. This makes it possible to change policy, precedence,
membership, and name in one statement:
ALTER PLACEMENT scoring_local
SET POLICY PREFER COLOCATION,
SET RANK 2,
RENAME TO scoring_preferred;
A placement pins the entities named in its FROM and TO lists even when its current coverage is
empty. Dropping a referenced runtime node is blocked until every pinning placement is altered or
dropped. A change that would make a member ineligible, such as changing an ingestor to an endpoint
or Syslog source, is blocked in the same way. Adding or dropping a relay’s materialized state does
not change its placement identity.
When a topology edit and its placement update depend on one another, put the complete set of model changes in one explicit transaction and order creation before reference. Nervix validates the resulting candidate graph before publishing it. See Control Plane for transaction and quiesce behavior.
Inspecting Placement
Use the placement commands in the active domain:
SHOW CREATE PLACEMENT scoring_local;
SHOW PLACEMENTS;
DESCRIBE PLACEMENT scoring_local;
DESCRIBE DOMAIN;
SHOW CREATE PLACEMENT renders the normalized stored rule as canonical NSPL and omits the
creation-time IF NOT EXISTS modifier. SHOW PLACEMENTS lists each rule’s policy, rank, and one
of these coverage states:
| Coverage | Meaning |
|---|---|
empty | No FROM/TO endpoint pair currently has a directed path. |
effective | All endpoint pairs are connected and all of the rule’s resulting relationships win rank resolution. |
partial | Only some endpoint pairs are connected, or only some resulting relationships remain effective. |
overridden | Connected coverage exists, but stronger rules win every resulting relationship. |
DESCRIBE PLACEMENT provides the stored form, policy, rank, resolved members, connectivity and
covered runtime nodes for each endpoint pair, witness paths for captured intermediates, and the
effective policy and winning or overriding rule for each affected relationship. For effective
hard requirements it also reports the colocation group and its current host cluster node.
DESCRIBE DOMAIN reports the domain default, named-rule count, and every effective colocation
group. Each group includes its runtime-node members, host cluster node, and the effective hard
relationships holding it together. Use those relationships to diagnose why a runtime node remains
in a larger group after a carve-out.
SHOW CLUSTER STATUS provides the complete scheduled-owner view when you need to compare placement
with other runtime nodes outside hard colocation groups.
Troubleshooting
| Symptom | What To Check |
|---|---|
A rule shows coverage=empty. | Confirm the route direction and that a directed message or state path exists from a FROM member to a TO member. Disconnected rules are valid. |
A rule is partial or overridden. | Run DESCRIBE PLACEMENT and inspect the winning rule for each affected relationship. |
| Activation reports a placement conflict. | Two overlapping rules have different policies at the strongest rank for an affected relationship. Change one policy or give one rule a stronger rank. A topology edit can expose a previously latent overlap. |
SUGGEST SEPARATION still leaves stages together. | The policy is advisory and never migrates existing assignments by itself. Re-evaluate it on a new placement decision; do not use it as an isolation control. |
| A soft-policy change did not move anything. | This is expected. Use REQUIRE COLOCATION only when movement is justified by a hard locality requirement. |
| A runtime node remains in a hard group after a carve-out. | Inspect the group’s hard relationships in DESCRIBE DOMAIN; another uncut requirement still connects it to the group. |
| Dropping or reshaping a member is blocked. | Alter or drop every placement named by the diagnostic before changing the member. |
Creation also rejects an unknown or ambiguous member, a non-schedulable entity, a cluster-wide
server-listener ingestor, an empty side, and RANK 0.
Common Patterns
Keep a latency-critical corridor on one cluster node:
CREATE PLACEMENT fraud_scoring_local
FROM normalize_transaction
TO fraud_scorer
REQUIRE COLOCATION
RANK 1;
Keep a materialized-state owner with a reader. Here customer_state is a materialized relay and
fraud_scorer declares USING MATERIALIZED STATE customer_state:
CREATE PLACEMENT state_read_local
FROM customer_state
TO fraud_scorer
REQUIRE COLOCATION
RANK 1;
Bias a heavy archive corridor across cluster nodes without guaranteeing isolation:
CREATE PLACEMENT spread_archive
FROM archive_transform
TO archive_emitter
SUGGEST SEPARATION;
Prefer locality for a corridor while retaining scheduler flexibility:
CREATE PLACEMENT prefer_enrichment_local
FROM decode_events
TO enrich_events
PREFER COLOCATION;
Operational Boundaries
Placement is not a capacity planner or a general load-balancing control. A hard colocation group must fit on one cluster node, and Nervix does not use placement rules to calculate CPU or memory capacity. Keep hard groups as narrow as the latency or state-locality requirement allows, then observe host resource use and inter-node delivery latency.
Placement policies do not provide:
- hard separation or workload isolation;
- grouping for runtime nodes with no directed path between them;
- cluster-node labels, availability zones, or pinning to a named cluster node;
- per-branch placement; or
- replica-count or replica-placement control.
RELOCATE moves work onto a named cluster node once. It is not pinning: nothing keeps the moved
runtime node there, and a later failover, drain, or hard-group consolidation may move it again. It
also does not relocate endpoint or Syslog ingestors, an individual replica, an individual branch, or
a unit spanning domains, and it never applies a unit in part.
Use SUGGEST SEPARATION as a performance hint only. Security, tenant isolation, and failure-domain
requirements need controls outside placement policies.
Capacity Planning For Branched Graphs
Nervix bounds work; the operator sizes it. The runtime provides backpressure for message-batch
processing through bounded relay capacity, and it bounds branch population
through branch TTL and optional MAX INSTANCES <n> EVICT LRU. The capacity-planning contract
also requires observable eviction and resource consumption. Current signals cover process memory,
traffic, latency, relay-buffer occupancy, branch population, eviction counts, and branch-local
relay inspection, but not per-node state size. Remaining signals are stated in
Branch Lifecycle Signals.
Sizing inside those mechanisms is the operator’s responsibility. There is no universal branches-per-node figure. Data rate, flush policy, graph shape, and stateful-node configuration change the answer.
Per-Branch Cost Structure
One live branch instance can hold the following at each graph node that belongs to the branch:
- branch-local task and runtime-node state;
- route buffers until the route’s flush boundary;
- the relay owner’s shared buffer, bounded by
CAPACITYacross all concrete branches; - pending flush timers;
- deduplication entries retained within
MAX TIME; - open window state retained by
WIDTHand advanced bySTEP; - reorderer and correlator buffers retained within
MAX TIME; - for WASM processors, an isolated store and the guest linear-memory pages dirtied by that branch.
FLUSH EACH has a configured byte boundary. FLUSH IMMEDIATE has no size boundary; its pending
depth depends on arrivals during the system-owned 100 µs window. See the
authoritative flush rule and tuning guidance.
WASM code compilation is shared while mutable guest state is not. See Module Sharing And Branch Memory.
UDF-bearing expressions and JAQ transformations run on the node’s extension workers, which nothing else uses. Heavy UDF or JAQ use and the number of concurrent paths that run them are extension sizing inputs. A native UDF that never returns permanently occupies one extension worker, and never a worker another class needs. See the UDF watchdog consequences.
Bounded Execution And Transient Memory
Every variable-size encode, decode, validation, hash, compilation and program execution a node runs off its asynchronous runtime is admitted into one of seven bounded classes, and is charged against a reserved byte budget before it allocates. Each class has its own admission, so work saturating one cannot take the slots another is entitled to.
| Class | Concurrent jobs | Work |
|---|---|---|
| Control | 1 | Control-plane and consensus work: heartbeats, votes, acknowledgements, administrative replies |
| Credentials | 1 | Password hashing and verification for users being created or authenticated |
| Data | available CPUs − 1 | Per-message relay body encoding, decoding and validation, expressions over batches above 1,024 messages, ingest branch preparation and model inference |
| Extension | available CPUs − 1 | Operator-supplied code the node cannot bound: UDF-bearing expressions and JAQ transformations |
| Bulk | available CPUs − 1 | Whole-transfer work: resource archives, large read results, and compiling UDFs, WASM modules, protobuf descriptors and ONNX models |
| Consensus storage | 1, ordered | Consensus storage batches, applied in the order they were admitted |
| Filesystem storage | 2 | Every other synchronous filesystem and database operation, including Iceberg’s staged files |
These counts bound admission, not threads. Jobs run on the process-wide blocking pool, so a class’s count is the number of its jobs that may be on that pool at once. A class is guaranteed its share of admission; it is not guaranteed an idle thread. Apart from the classes, only work that waits rather than computes uses the pool: a Kafka producer’s final drain and partition inspection, which wait on the broker, and the one read of the resolver configuration at startup. Each class also holds at most 1,024 jobs waiting for a worker; a job beyond that is refused, and the work that submitted it reports the refusal as its own failure. The one exception is work the node already accepted and keeps: the unfolding of a payload a quiesce buffer retained waits for a place instead, while its payload stays counted in the buffer. So does the unfolding of a poll a paced source handed over, and of a payload a NATS, ZeroMQ or Syslog source read without an acknowledgement. Holding one of those source loops pushes back into its transport, so a node whose extension workers stay full delays polls and lets those transports buffer or drop at their own bounds; see When The Extension Workers Are Full.
Transient memory is 275 MiB per node, divided into ceilings that cannot borrow from each other: 8 MiB for management, 24 MiB for commands and replication, 192 MiB for relay work, 32 MiB for bulk buffers and 19 MiB for credentials. One relay operation may hold at most 32 MiB of encoded body, 32 MiB of decoded data and 16 MiB of conversion scratch, and the relay budget is sized to hold two such operations at once so one blocked channel cannot exhaust the capacity another needs. The relay budget also carries the acknowledgement watches a relay owner keeps for rows another node delivered with acknowledgements: 1 KiB per row until the owner reports that row’s outcome, and 4 KiB per delivery. A delivery of 8 MiB of 164-byte rows therefore needs about 51 MiB of watches beside the 56 MiB its body and decoding reserve. When rows arrive faster than the owner reports them, their watches fill the budget and the owner holds further deliveries back until reported rows return their share. Sustained remote delivery with acknowledgements is then bounded by how fast acknowledgements return to the sending node, not by the budget’s size, and a source that sends there waits instead of failing. The budgets are fixed defaults and do not follow the node’s own memory. The commands budget also holds the 16 MiB working reservation for one normalized consensus state write; half is the keyed payload ceiling and half covers database journal encoding and descriptors. The credentials budget holds the 19 MiB Argon2id works in for one password, so a node computes one hash at a time and a burst of login attempts waits there without touching any other class. A stored hash whose parameters need more memory than that never verifies.
These budgets cover transient work: data in flight between nodes and the working memory of the jobs the classes admit. They are separate from the process memory a running graph holds, which the memory-pressure watermarks govern. The limits are checked against each other when a node starts: a budget that could not hold the largest operation of its class is reported with that class and size rather than discovered by stalling on the first such operation.
Two multipliers usually dominate:
- live branch count × per-branch buffered depth;
- stateful retention window × per-branch arrival rate.
The second multiplier applies separately to every stateful node. A long deduplication horizon does not pay for a window’s state, and a window width does not bound a correlator.
Sizing Knobs
- Branch
TTLtrades branch reuse against how long idle branch-local tasks, buffers, and state remain live. Shorter TTL releases idle branches sooner. Idle time is measured from a branch’s last accepted input, and a physical maintenance scan releases expired branches, so peak branch population reflects the TTL plus that scan latency. See TTL. MAX INSTANCES <n> EVICT LRUtrades branch coverage against a hard branch-population ceiling. Eviction drops the least recently used branch and its suspended or buffered branch-local work.- Relay
CAPACITYtrades burst absorption against Arrow batches in the single owner buffer. Smaller capacity applies backpressure sooner. In addition to that buffer, each producer cluster node can hold one batch on its dispatch to the owner and the owner can hold one batch on its dispatch to each remote consumer cluster node. Those fixed slots do not resize withCAPACITYand do not multiply with consumers or subscriptions on the same node. - Route
FLUSH EACHinterval andMAX BATCH SIZEbound how long rows wait and how much a route buffers.MAX BATCH SIZEonly clamps a batch and never grows one: the batch a route emits is roughly arrival rate × interval, up to the byte cap. Larger batches reduce per-batch cost only at boundaries that work per batch; see the flush tuning guidance for which sinks those are.FLUSH IMMEDIATEminimizes configured wait but still micro-batches. - Cluster interconnect carries each relay batch as one Arrow IPC body with a fixed 32 MiB limit.
It reserves one unadmitted batch and one terminal outcome per active logical channel. Channels
are concrete-branch local, so waiting work in one branch does not consume another branch’s
ordering slot. Management subquotas independently reserve streams for discovery, liveness,
replaceable domain-clock progress, relay admission, cancellation, and terminal outcomes even when
ordinary management requests are full.
Bulk subquotas independently reserve streams for resource transfer and for runtime and Raft
snapshot transfer. A runtime state snapshot is described before it is fetched, so a node that
already holds the current revision causes no scan and no encoding on the node that owns it.
Consensus replication uses one ordered append stream per follower, bounded at 16 outstanding
batches and 16 MiB, and charges each batch against the shared command budget for as long as it
is unacknowledged. That stream reserves its own replication stream slot, so the ownership
handoff requests sharing the replication pool never wait behind it. A Raft snapshot is sealed
and moved one bounded section at a time, so neither construction nor transfer grows with the size
of the replicated state.
Keep
MAX BATCH SIZEwell below 32 MiB on any route whose consumer may be scheduled on another node. Each node publishes what its pools and budgets are actually holding; see the interconnection metrics for the series to size against. - Stateful
MAX TIME,WIDTH, andSTEPtrade history and aggregation coverage against retained entries, open windows, and buffered rows. - Source
INSTANCEStrades source parallelism against concurrent admission pressure. It does not reduce the cost of any branch instance that becomes live.
See the existing FLUSH tuning guidance instead of treating example values as defaults.
What To Watch
nervix_jemalloc_allocated_bytes,nervix_jemalloc_active_bytes, andnervix_jemalloc_resident_bytesshow process-memory pressure from all workloads on the node.nervix_branch_instancesshows the current concrete branch-key population per domain, branch declaration, and physical node.nervix_branch_evictions_totalshows LRU pressure and TTL churn through itsreasonlabel.- Sustained high
nervix_relay_buffer_lenpercentiles show downstream backpressure at the relay owner buffer. - High
nervix_delivery_latency_secondspercentiles show downstream lag between graph nodes. DESCRIBE RELAY <relay> WHERE (...)confirms whether one concrete branch-local relay exists and reports its buffer metrics when available.DESCRIBE INGESTOR <name>reportsmemory-backpressure: active|inactive.
Prometheus does not currently expose branch-creation counters, deduplication entry counts, or
open-window counts. DESCRIBE also does not provide a branch-population inventory or eviction
history.
For runtime ownership and snapshot boundaries, see Data Plane. For the current metric families and cardinality policy, see Metrics And Observability.
Metrics And Observability
Nervix exposes runtime graph metrics in two forms:
- raw Prometheus metrics at the observability server’s
/metricsendpoint - owner-routed summarized metrics in
DESCRIBEcommand output
The two surfaces use the same semantic labels where they overlap, but they use separate storage.
Prometheus receives raw counters and histogram buckets through a Prometheus registry for external
aggregation. DESCRIBE reads Nervix runtime state and includes derived values such as rates and
recent percentiles. Relay descriptions are routed to the scheduled relay owner; nonowners do not
maintain relay metrics.
Errors And Diagnostics defines the error information that may be reported and its sensitivity and log-level rules. This chapter defines metric surfaces and labels.
Observability Server
The observability listener exposes:
/livez: process liveness/readyz: readiness once a leader is known/metrics: Prometheus text output for graph, interconnection, and allocator metrics
Use the node’s observability address, not the data-plane HTTP listener:
curl http://127.0.0.1:<observability-port>/metrics
Prometheus metrics are intentionally branch-aggregated. They aggregate across concrete relay branches and do not include a branch key label. This keeps Prometheus cardinality bounded when a relay is branched by high-cardinality values such as tenant, user, account, or device id. Branch lifecycle metrics identify the declared branch, not the concrete key.
Metric Labels
Graph metric series use these labels:
domain: owning domaintarget_kind: runtime target kind, such asRELAY,INGESTOR,JUNCTION,DEDUPLICATOR,REINGESTOR,WINDOW_PROCESSOR,EMITTER, orLOOKUPtarget: relay or node namephysical_node_id: Nervix cluster node where the metric was observeddirection:receivedorsentstream: logical relay associated with the observation, or-when no relay appliespeer_kindandpeer: relay peer labels for node-to-relay observations, or-when no peer appliesbranch: named branch declaration on branch lifecycle metricsreason: branch eviction reason, eitherlruorttlle: Prometheus histogram bucket boundary
For a single-input emitter, sent metrics retain that input relay as stream. A multi-input
emitter’s received metrics identify the actual source relay, while its sent metrics aggregate the
shared sink pipeline with stream="-" because one flush may contain work from several sources.
An emitter’s sent metrics count the records its destination delivered, each once, however many
attempts it took: a flush that completes after retries counts its delivered records once, and a
record the destination refused follows ON MESSAGE ERROR and is not sent. When a flush fails for
good, the records its sink delivered before the failure are counted as its other records are routed
to their error policy. A sink that publishes on its own commit, such as Iceberg, counts what each
commit publishes. Sent bytes measure a delivered record’s payload as the emitter’s buffer measures
it; an HTTP request’s method, target and headers are not payload, and a request without a body
carries none. See HTTP inspection and metrics.
DESCRIBE output uses the same concepts but renders physical_node_id as physical_node for readability.
Example Prometheus series:
nervix_messages_total{domain="prod",target_kind="RELAY",target="notifications",physical_node_id="node-1",direction="received",stream="notifications",peer_kind="-",peer="-"} 42
Example DESCRIBE INGESTOR edge metric section:
metrics:
outgoing_edges:
messages_total sent relay=notifications physical_node=node-1 total=42 wall_rate_per_sec=12.5 domain_rate_per_sec=10 wall_rate_ema_1m_per_sec=11.2 wall_rate_ema_15m_per_sec=8.7 domain_rate_ema_1m_per_sec=9.8 domain_rate_ema_15m_per_sec=7.4
Raw Metrics
Nervix records these raw metric families:
nervix_messages_total: total messages received or sentnervix_batches_total: total batches received or sentnervix_bytes_total: total bytes received or sentnervix_messages_per_batch: histogram of message count per batch. Its finite Prometheus bucket boundaries are1,2,5,10,50,100,500,1000,1024,2048,4096,8192,16384,32768, and65536, followed by+Inf. The rolling HDR histograms used byDESCRIBEtrack batch sizes through 65,536 messages.nervix_delivery_latency_seconds: histogram of delivery latency between graph nodesnervix_relay_buffer_len: histogram of runtime relay buffer occupancy in queued batchesnervix_branch_instances: current concrete branch keys with at least one runtime instance on the physical nodenervix_branch_evictions_total: total concrete branches evicted on the physical node, split byreason="lru"orreason="ttl"nervix_ingestor_quiesce_buffered_records: raw payloads currently retained in an ingestor’s per-instance quiesce buffers, including a payload that is draining until its messages enter their ingest groupnervix_ingestor_quiesce_buffered_bytes: raw payload bytes currently retained in those buffersnervix_ingestor_quiesce_dropped_total: payloads deliberately discarded byDROP, buffer overflow, memory-pressure zero-capacity behavior, or an interrupting terminationnervix_ingestor_quiesce_rejected_total: endpoint requests or connections refused while an ingestor cannot accept themnervix_ingestor_unfolding_refused_total: payloads a source handed over without an acknowledgement that an ingestor did not deliver because the node’s extension workers had no room to unfold them, for a source whose transport would not survive a held loop; see When The Extension Workers Are Fullnervix_ingestor_transport_redeliveries_total: pending acknowledgements and rejections an ingestor discarded after its RabbitMQ, SQS or MQTT source reconnected. The broker redelivers what the lost connection did not acknowledge, so each counted position is ingested againnervix_session_subscriptions: open session subscriptions the node delivers from a relay. The node advertises interest in the relay to the cluster exactly while this is above zero, and the series stays at0once the relay’s last subscription on the node closesnervix_session_subscription_dropped_rows_total: rowsDROPPINGsession subscriptions on the node discarded because their session could not take them in timenervix_client_ingestor_producers: producers attached to a client ingestor the node executesnervix_client_ingestor_forwarded_producers: those of them whose sessions another node servesnervix_client_ingestor_outstanding_batches: batches those producers submitted that have no outcome yetnervix_client_ingestor_outstanding_bytes: the Arrow IPC bytes of those batchesnervix_client_ingestor_admitted_batches: batches holding a slot of the ingestor’s acknowledgement windownervix_client_ingestor_submissions_total: batches the ingestor answered, byoutcomeandcausenervix_jemalloc_active_bytes: bytes in active allocator pagesnervix_jemalloc_allocated_bytes: bytes allocated by the processnervix_jemalloc_mapped_bytes: bytes mapped by active allocator extentsnervix_jemalloc_metadata_bytes: bytes dedicated to allocator metadatanervix_jemalloc_resident_bytes: resident data-page bytes mapped by the allocatornervix_jemalloc_retained_bytes: retained virtual-memory mapping bytes
Histograms follow Prometheus conventions and include _bucket, _sum, and _count series. Current bucket boundaries are:
- messages per batch:
1,2,5,10,50,100,500,1000,+Inf - delivery latency seconds:
0.001,0.005,0.01,0.05,0.1,0.5,1,5,30,+Inf - relay buffer length:
1,2,4,8,16,32,64,128,256,512,1024,2048,+Inf
Prometheus receives raw values only. The /metrics endpoint is encoded by the Prometheus client registry, not by Nervix internal summary state. Prometheus should compute external queries, alerts, and dashboards with normal PromQL aggregation.
The four ingestor-quiesce families, nervix_ingestor_unfolding_refused_total and
nervix_ingestor_transport_redeliveries_total use domain, ingestor, and physical_node_id
labels. Buffer families are gauges; dropped, rejected, refused and redelivered families are
monotonic counters. They are process-local: quiesce buffers do not migrate during
termination or failover.
Quiesce, refusal and redelivery children are registered when the ingestor starts, and
DESCRIBE INGESTOR registers the redelivery child at zero on the node that runs the ingestor.
Client-ingestor outcome children are registered when its endpoint starts, and subscription loss
counters when delivery starts. These bounded series may therefore appear at zero before their
first observation. Their labels and counting contracts are unchanged.
The two session-subscription families use domain and relay labels and describe the node that
exports them: the subscriptions its sessions hold and the rows those sessions lost. A client is told
of its own losses directly, as described in Sessions. The server exports no other
session metrics; Client Session Protocol describes
what clients and operators observe instead.
Client Ingestors
The six client-ingestor families describe the node that executes the ingestor and use domain and
ingestor labels. The first five are gauges, rewritten whenever the ingestor’s endpoint changes
them, and they are 0 once the endpoint ends. admitted_batches never exceeds the window the
ingestor declares, however many producers are attached.
nervix_client_ingestor_submissions_total is a counter with two more labels drawn from fixed sets.
outcome is not_admitted, completed, processing_failed, or outcome_unknown. cause is the
refusal of a batch that was not admitted (invalid_batch, suspended, busy, draining,
producer_ended, credit_exceeded), the failure of one that failed (ack_timeout, rejected),
the uncertainty of one whose outcome is unknown (interrupted, owner_lost), or none for a
completed batch. No label carries a payload value, a producer, or an attachment identity. A batch
the serving session refused before it reached the node that executes the ingestor, such as one
beyond its producer’s credit, is answered by the session and not counted here. So is every batch the
node that forwards a producer answers itself once it lost the node that executes the ingestor: a
batch it never cleared for admission as not_admitted with producer_ended, and a cleared one as
outcome_unknown with owner_lost.
Ingestors describes the same counts in SHOW INGESTORS
and DESCRIBE INGESTOR.
Client Emitters
Client emitter metrics use domain and emitter labels on the executing node. The gauges
nervix_client_emitter_consumers, nervix_client_emitter_forwarded_consumers,
nervix_client_emitter_forwarded_credit_bytes, nervix_client_emitter_retained_batches, and
nervix_client_emitter_retained_bytes describe current in-memory attachments and prepared Arrow
IPC awaiting an application decision. nervix_client_emitter_forwarded_retained_batches and
nervix_client_emitter_forwarded_retained_bytes count the subset assigned to consumers served on
another node. nervix_client_emitter_incomplete_batches counts assigned batches whose application
decision is still pending. These gauges return to zero when that endpoint ends. The counters
nervix_client_emitter_retries_total, nervix_client_emitter_acks_total, and
nervix_client_emitter_rejections_total retain results across endpoint restarts on the same node.
Retries include explicit application retry, ACK timeout, and consumer loss; a repeated confirmed
ACK does not increment the ACK counter. DESCRIBE EMITTER reports the same values for the
scheduled owner.
Restoring a client producer or consumer creates a new server attachment. The attachment gauges
fall when the prior session detaches and rise only after the replacement open succeeds; retained
batch and incomplete-attempt gauges remain bounded by the existing endpoint budgets. None uses a
session, producer, consumer, delivery or attempt identity as a metric label. Applications can sample
the Rust handles’ connection() states to distinguish an active, interrupted, restoring or
reopen-required handle. An uncertain producer result and an uncertain consumer settlement remain
typed call outcomes; these gauges do not imply replay or a confirmed ACK.
Delivery Latency
Every processor, reingestor, and emitter input records the delivery latency of each batch it accepts. A row’s latency runs from its ingestion high watermark to the instant the input accepts the batch. Processors and reingestors read that instant from the domain clock, so in a paced domain their latency is measured in domain time; emitters read the wall clock. A row whose high watermark is later than that instant has no latency and adds nothing to the histograms, although it still counts as a received message.
A batch is recorded as a whole. Each row’s ingestion watermarks travel with the batch in Arrow
buffers of Unix-nanosecond timestamps, and one pass over the high watermarks finds the batch’s
latest watermark and folds every row’s latency into buckets. Each latency series then takes the whole batch
in one update: the rolling histograms behind DESCRIBE merge the buckets under one lock with one
wall-clock reading, and the Prometheus histogram takes the batch in one flush.
- The batch’s latest high watermark is its domain timestamp. It stamps the batch’s traffic counters and places its latencies in the domain-clock windows. A batch older than a window’s current step lands in its own step only while that step is still retained; an older step is never reopened.
- The rolling histograms record each latency to the nearest millisecond, halves rounding up, and record anything above 30 seconds as 30 seconds, so a slow tail is never dropped from the percentiles.
- The Prometheus histogram receives every latency in seconds and counts it in the boundaries
listed under Raw Metrics; a latency above 30 seconds counts only in
+Inf.
Interconnection Metrics
Every node also exposes its own interconnection: the authenticated HTTP/2 pools it carries internal
traffic over, the execution admission every variable-size operation passes through, its consensus
retention, and the reactor delay they share. These series describe the exporting node only, so they
carry no node label, and they are aggregated by dimensions whose value sets are fixed: class,
direction, operation, reason, and outcome. None of them carries a branch key, a peer
identity, a domain, or an operation identifier.
class is the traffic class on transport series — management, commands, replication, relay,
or bulk — and the execution class on admission series — management, commands, relay, bulk,
credentials for memory, and control_cpu, credentials_cpu, data_cpu, extension_cpu,
bulk_cpu, consensus_storage, filesystem_storage for workers. operation is the reserved request subquota: shared, append, resource, snapshot,
discovery, liveness, progress, admission, cancellation, or terminal.
Transport pools:
nervix_interconnect_connections: physical connections held, byclassanddirectionnervix_interconnect_streams: HTTP/2 stream slots leased on outbound connections, byclassnervix_interconnect_pending_operations: requests admitted and not yet resolved, bydirectionandoperationnervix_interconnect_connections_established_total: connections established, byclassnervix_interconnect_connection_failures_total: connections that failed to establish or ended, byclassandreason, wherereasonissetup,handshake,capacity,closed, orresolutionfor an attempt whose peer’s advertised host did not resolvenervix_interconnect_stream_resets_total: streams that ended without a result, byclassandreason, wherereasonisdeadline,capacity,peer,shutdown, ormalformednervix_interconnect_quota_failures_total: requests refused because their reserved subquota was full, bydirectionandoperationnervix_interconnect_requests_totalandnervix_interconnect_request_seconds_total: typed requests completed and the round trip they took, byoperation. Divide the seconds by the count for average latency;operation="liveness"is the peer health probe, so that ratio is health RTT, andoperation="progress"is replaceable fenced domain-clock progressnervix_interconnect_bulk_bytes_total: bytes carried by streamed bodies, byclassanddirection. This rises while a transfer is running, so it reads as bulk progress
Relay admission:
nervix_interconnect_relay_channels,nervix_interconnect_relay_attempts,nervix_interconnect_relay_grants: logical channels with unresolved work, attempts whose outcome has not been retired, and transfer grants issued and not yet spent. These aggregate bounded authenticated peer owners. Ending a peer releases its protocol capacity immediately, including when the application still borrows an admitted intake; that intake’s retained metadata stays charged to relay memory until its last borrow ends. A delivery the receiver holds back because its relay memory has no room for the delivery’s acknowledgement watches stays an unresolved attempt here, with its relay memory nearnervix_execution_memory_capacity_bytes, and counts no rejection until it is admittednervix_interconnect_relay_admissions_totalandnervix_interconnect_relay_admission_wait_seconds_total: attempts resolved, byoutcome(admitted,rejected,cancelled), and the time they spent between an accepted reservation and their resolutionnervix_interconnect_unresolved_outcome_age_seconds: how long the oldest unacknowledged relay outcome has been waiting. A rising value means acknowledgements are not coming back
Execution admission:
nervix_execution_memory_capacity_bytesandnervix_execution_memory_reserved_bytes: the transient memory reserved for one class, and what it is currently holdingnervix_execution_memory_reservations_totalandnervix_execution_memory_rejections_total: charges granted and charges refused outrightnervix_execution_workers,nervix_execution_jobs_running,nervix_execution_jobs_pending: how many jobs one class may have on the blocking pool at once, how many it holds, and how many waitnervix_execution_jobs_total,nervix_execution_jobs_completed_total,nervix_execution_job_rejections_total: jobs admitted, jobs that left a worker, and jobs refused because the class already held its whole wait queuenervix_execution_job_queue_seconds_totalandnervix_execution_job_work_seconds_total: time admitted jobs spent waiting for a worker, and time completed jobs spent holding one
Node scheduling:
nervix_node_scheduler_delay_seconds_total,nervix_node_scheduler_delay_peak_seconds, andnervix_node_scheduler_samples_total: how late the node’s reactor has been polling work that was already runnable. A timer asks to be woken every 250 milliseconds; the excess it is woken by is the delay every other task on that worker paid
Consensus retention:
nervix_consensus_log_last_index,nervix_consensus_log_snapshot_index,nervix_consensus_log_purged_index: the highest Raft index the log holds, the highest the current snapshot covers, and the highest already removed. A position that does not exist yet reports-1nervix_consensus_log_retained_bytes: encoded bytes held by the node’s current Raft log entriesnervix_consensus_snapshot_pinned_generations,nervix_consensus_snapshot_pinned_readers,nervix_consensus_snapshot_unreferenced_generations: snapshot generations an outgoing transfer holds against deletion, the readers those pins are held for, and generations waiting to be deleted
These series answer two operational questions directly. A class that is saturated shows a reserved
byte count at its capacity, or running jobs at its worker count with pending jobs behind them,
while the other classes stay free — that is the isolation the pools exist for. A control answer
arriving late while bulk work runs shows up as reactor delay and as request seconds rising for the
shared or liveness operation, not as a connection failure.
DESCRIBE Metrics
DESCRIBE commands include metric summaries for the described target when metrics exist. For a
scheduled target, the request is answered from its owner. Node traffic is grouped under
incoming_edges and outgoing_edges; relay descriptions keep owner-buffer utilization under
relay_buffers rather than mixing relay traffic into the relay node:
DESCRIBE RELAY notifications WHERE (user_id = 42);
DESCRIBE INGESTOR kafka_notifications;
DESCRIBE JUNCTION route_notifications;
DESCRIBE DEDUPLICATOR dedup_txns;
DESCRIBE REINGESTOR repartition_notifications;
DESCRIBE WINDOW PROCESSOR latency_window;
DESCRIBE EMITTER kafka_notifications_out;
DESCRIBE HASH MAP user_profiles;
DESCRIBE DOMAIN;
DESCRIBE DOMAIN summarizes active-domain traffic from per-node metric state. Its
input_output section aggregates ingestor and emitter metrics. Its processed
section aggregates metrics for all runtime nodes in the domain, including
processing nodes.
DESCRIBE EMITTER reports a status: of OK, WAITING, or ERROR, with a detail: line
explaining anything other than ordinary operation. WAITING means the emitter is healthy but holds
no connection because it has asked a shared client’s pool for one and has not been given it yet;
see Database Client Connection Pools. A full pool is a waiting state,
not a failure, so an acquisition timeout or a lost connection is reported as ERROR instead.
DESCRIBE INGESTOR also reports the declared quiesce: policy, current quiesce state:, all
four quiesce values and the node’s nervix_ingestor_transport_redeliveries_total, using their
Prometheus family names. A connected ingestor in any active mode is
reported as status: quiesced, not stopped. Per-payload activity remains at debug or trace log
levels and payload values are never logged.
Counter summaries include:
total: accumulated raw counter valuewall_rate_per_sec: total divided by wall-clock elapsed time since the local series starteddomain_rate_per_sec: total divided by the observed domain-time span, when records carry domain timestampswall_rate_ema_1m_per_secandwall_rate_ema_15m_per_sec: exponentially decayed wall-clock ratesdomain_rate_ema_1m_per_secanddomain_rate_ema_15m_per_sec: exponentially decayed domain-clock rates
Histogram summaries include:
p50_1m,p90_1m,p99_1m: one-minute wall-clock decayed percentilesp50_15m,p90_15m,p99_15m: fifteen-minute wall-clock decayed percentilesdomain_p50_1m,domain_p90_1m,domain_p99_1m: one-minute domain-clock decayed percentilesdomain_p50_15m,domain_p90_15m,domain_p99_15m: fifteen-minute domain-clock decayed percentiles
Histogram DESCRIBE lines do not include raw count / sum values or rates. For messages_per_batch, the raw observation count is the batch count and the raw sum is the message count; those are already reported clearly by batches_total and messages_total. The histogram answers distribution questions such as typical and tail batch size. Percentiles are rendered as decimal estimates interpolated within the configured histogram bucket range rather than as raw bucket boundary labels. The same rule applies to delivery latency: messages_total and batches_total answer throughput questions, while delivery_latency_seconds answers latency distribution questions.
Relay buffer summaries use relay_buffer_len. The percentile values are queued batch slots
observed in the single buffer on the relay owner, and capacity=<n> shows that buffer’s declared
bound. A branched relay can hold interleaved concrete branches in the same owner buffer, so
DESCRIBE RELAY reports observed lengths as percentiles instead of rendering a separate current
depth per branch. The fixed one-batch producer and consumer-node dispatch slots are not additional
relay-buffer metric series.
A - value means the derived value is not available. This is common for domain-clock values when no domain timestamp has been observed or when the observed domain-time span is zero.
Branch Lifecycle Signals
Prometheus exposes branch lifecycle state without a concrete branch-key label:
nervix_branch_instances{domain,branch,physical_node_id}is a gauge of live concrete branch keys on that physical node. A key is counted once even when multiple local graph nodes hold runtime state for it.nervix_branch_evictions_total{domain,branch,physical_node_id,reason}counts each concrete key once when branch eviction begins on that physical node, even if multiple local graph nodes hold the key.reason="lru"is aMAX INSTANCES ... EVICT LRUcapacity eviction;reason="ttl"is idle expiration.
Normal shutdown, schedule replacement, and runtime detachment reduce the live gauge but do not increment the eviction counter. Relay branch presence and its TTL or LRU decisions exist only on the relay owner, so a relay-owner failure loses those live values. A schedule replacement reduces state only for runtime nodes whose assignment changed; a drain, failover, or colocation consolidation leaves unaffected owners continuous. Lifecycle metrics are live process-local Prometheus state and are not persisted or replicated.
Concrete branch-local inspection remains available through DESCRIBE RELAY <relay> WHERE (...).
DESCRIBE does not provide a common branch inventory or eviction history. Prometheus deliberately
has no branch-key label. See
Capacity Planning For Branched Graphs for the cost model.
Wall Clock And Domain Clock
Nervix reports two time bases because they answer different questions:
- wall-clock rates describe actual handled load per second of real process time
- domain-clock rates describe records per second of event/domain time
For unpaced domains or records without usable timestamps, domain-clock values may be unavailable. For paced domains, domain-clock values follow the event timestamps and domain pace rather than the speed of test execution or wall-clock ingestion.
The moving rates are online exponential summaries. The percentile windows keep one bounded HDR histogram per step, 10 seconds for the one-minute windows and one minute for the fifteen-minute windows, rather than every observation. Both keep memory bounded and let metric state be snapshotted and replicated without retaining the observations themselves.
Replication And Drain Behavior
Nervix maintains internal metric state for DESCRIBE, edge statistics, and runtime recovery.
Stateful processor summaries may use their node-owned snapshot path. Relay metrics are different:
the relay owner is their sole authority, and buffer, traffic, concrete-presence, and branch-local
relay summaries are neither persisted nor replicated.
DESCRIBE RELAY and DESCRIBE RELAY ... WHERE (...) are routed to that authority. A planned move
fences the moved subgraph, drains admitted work, and activates the committed revision before the
gate opens. Relay metrics start fresh on the new owner because they do not travel with materialized
state. When a state-replica slot exists, a live former primary becomes the first replica candidate
after the move.
Only moved runtime nodes cross that boundary. Internal DESCRIBE and edge metrics for unaffected
nodes and concrete branches remain continuous during drain, graceful-shutdown drain, and placement
consolidation. There is no shutdown-specific metric family: a draining node is identified by its
phase log records and by terminating: true in its status, as described in
Shutdown And Recovery. A timed-out precommit handoff leaves the schedule and metric authority unchanged.
Unexpected owner loss continues through failover immediately and starts fresh relay metrics on the
new owner.
Prometheus export is a separate, live process-local registry. Traffic metrics ignore branch identity, while lifecycle metrics retain only the bounded declared branch name. Prometheus registry values are not snapshotted into Nervix internal metric state and do not migrate during a planned handoff or failover.
User-Defined Functions
NSPL user-defined functions (UDFs) are domain-owned functions written in Roto and invoked anywhere an ordinary expression function can be used. They execute in process over Arrow columns: one UDF invocation receives every message of a batch that reaches it, or, inside a conditional arm, only the messages that select that arm, and column methods perform vectorized work without serializing rows.
UDF creation is trusted-code administration, like uploading a WASM resource. Roto code is native JIT-compiled code, not a sandbox for third-party programs.
See Choosing An Extension Tier before choosing between builtins, Roto UDFs, and WASM processors.
Roto execution is synchronous. Nervix therefore runs every UDF-bearing expression on the node’s extension workers, including small Arrow batches and UDFs used while constructing branch keys, so native UDF work never occupies an asynchronous runtime worker or the data workers that relay bodies are encoded and decoded on.
The Roto language itself — syntax, types, functions, and test blocks — is covered by the
generated Roto Language Reference, downloaded from upstream at the
exact Roto release Nervix embeds for ROTO_0_13; the language is developed at
NLnetLabs/roto.
The column types and operations available inside a UDF body are the Nervix-provided catalog
described on this page, not part of the Roto standard library.
Declaration
CREATE [IF NOT EXISTS] UDF <name>
WITH ROTO_0_13
ARGS (<argument> <type> [OPTIONAL], ...)
RETURNS <type> [OPTIONAL]
[VOLATILE]
CODE $roto$
fn <name>(...) -> ... {
...
}
$roto$;
A declaration has between one and eight arguments. Argument and return types use the schema type
grammar: all scalar types, VEC<T>, and ARRAY<T, n> are accepted. Code is limited to 64 KiB.
The declared signature must exactly match the named Roto entry function; implicit casts are never
inserted.
Dollar-quoted strings ($$...$$ and $tag$...$tag$) are verbatim and may span lines. They are
valid wherever NSPL accepts a string literal.
For example:
CREATE UDF risk_band
WITH ROTO_0_13
ARGS (score F64)
RETURNS STRING
CODE $roto$
fn risk_band(score: F64Column) -> StringColumn {
when_s(score.gt_s(0.9), "critical")
.when_s(score.gt_s(0.7), "high")
.otherwise_s("normal")
}
$roto$;
The Roto column names for scalar declarations are U8Column, I8Column, U16Column,
I16Column, U32Column, I32Column, U64Column, I64Column, F32Column, F64Column,
BoolColumn, StringColumn, BytesColumn, and DatetimeColumn. A one-level sequence uses
VecU8Column through VecDatetimeColumn, including VecBytesColumn; deeper nesting uses
AnyColumn. A bytes column carries Arrow Binary values directly.
Calling UDFs
Calls use the dedicated, case-insensitive udf:: qualifier and compose with fields, literals,
builtins, and other call sites:
CREATE JUNCTION scoring FROM raw_events
UNBRANCHED
TO scored_events
SET band = udf::risk_band(message.score)
WHERE udf::risk_band(message.score) != 'normal'
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG;
The NSPL compiler validates exact arity and types when the consuming model is created. Use an
explicit AS or TRY_CAST conversion when conversion is intended. An unqualified call never
resolves to a UDF. The separate namespace allows a UDF name to equal a builtin name without
changing either call when a later Nervix version adds builtins. Results retain the maximum
sensitivity of their arguments.
Roto test blocks execute during CREATE UDF. If any test rejects, creation fails with
Roto test block failed and the UDF is not persisted.
The web console’s Create → Roto UDF form edits ordered arguments and the result with the exact schema types, optional flags, volatility, and a source editor that keeps the function and its test blocks verbatim. It renders the completed Model as canonical NSPL and shows Completed only after compilation and all Roto tests succeed. A rejected test leaves the entered declaration and source in the form for correction.
Nulls, errors, and volatility
OPTIONAL controls boundary nullability; it does not change the Roto column type.
- A null row in a non-optional argument strictly produces a null result row without exposing that row to the UDF.
- An optional argument exposes its validity bitmap so the body can use null-aware operations.
- A non-optional result may contain nulls only for strict-propagation rows or rows carrying a side error. Any other null is a batch error.
- Checked column operations and
reject_where_sadd per-row errors to the same channel used by builtins, so the owning route’sON MESSAGE ERRORpolicy applies. - A Roto trap, invalid result type or row count, or watchdog expiry is a whole-batch error.
The watchdog detects an overrun after native code returns; Roto provides no in-process preemption. A non-terminating UDF occupies one extension worker permanently. The watchdog converts a late completion into a whole-batch error, but it cannot reclaim a worker from native code that never returns. A node whose extension workers are all held, and whose wait queue for them is full, refuses further UDF-bearing executions, which the owning processor’s general error policy handles; the node’s relay, control and bulk work keeps its own workers throughout.
Treat trusted authorship, code review, and passing Roto test blocks as admission policy. The
extension class runs one job per available CPU but one, shared with JAQ transformations, and is not
an NSPL setting. Limit concurrent UDF-bearing paths or isolate them onto separate Nervix processes
when one workload could hold every extension worker. See
Capacity Planning For Branched Graphs.
Non-terminating or third-party code must stay on the isolated WASM processor path.
UDFs are deterministic unless declared VOLATILE. Only a volatile UDF can call now(),
rand_f64(), or uuid_v4(). now() uses the execution-local domain clock. Deterministic calls
may be reused within one compiled expression; volatile calls are evaluated at every occurrence.
User code is never executed by constant folding.
Roto column operations
Column methods operate elementwise and preserve the input row count. A base method takes another
column of the identical type; an _s method broadcasts a Roto scalar.
The ROTO_0_13 catalog currently includes:
- integer and floating columns:
add,sub,mul,div, their_sforms,eq,lt,gt, their_sforms, and batchmin/max; - boolean columns:
and,or,not, plus string-columnselect; - string columns:
trim,contains_s,regexp_replace, and the slow-pathget; I64Column.cast_f64()andDatetimeColumn.lt_s(Timestamp);VecStringColumn.contains_s;- string
coalesce,when_s(...).when_s(...).otherwise_s(...), andreject_where_s; ColumnBuilder.bool,push,push_null, andfinishfor explicit per-row fallback code;- volatile
now,rand_f64, anduuid_v4.
Arithmetic overflow, division by zero, and explicit rejection produce per-row errors. Invalid scalar configuration such as a malformed regular expression fails the batch.
Use column operations whenever possible. get and a builder are the explicit slow path for logic
that has no vectorized kernel.
Introspection and lifecycle
SHOW UDFS;
DESCRIBE UDF risk_band;
SHOW CREATE UDF risk_band;
DROP UDF risk_band;
DESCRIBE UDF reports the language tag, signature, volatility, content hash, and referencing
nodes. SHOW CREATE preserves the Roto source bytes and chooses a safe dollar-quote delimiter.
Dropping a UDF is rejected while a stored model references it.
Changing a UDF body means dropping the old UDF and recreating it, and the drop is rejected until
every referencing model has been removed. While a node references a UDF, that node’s UDF semantics
are fixed by the UDF content hash reported by DESCRIBE UDF. A UDF body change quiesces the domain
because its callers are spread across the graph.
Roto source is persisted; native JIT output is rebuilt in memory during domain activation. A server that does not support a stored language tag rejects activation rather than reinterpreting the source.
Nervix does not currently expose per-UDF invocation, latency, or row-error metrics.
Roto Language Reference
Generated at book-build time from the upstream Roto documentation source
docs/source/reference/language_reference.mdat tagv0.12.0of NLnetLabs/roto, © NLnet Labs, BSD-3-Clause, lightly normalized from MyST Markdown. Nervix UDFs embed this exact Roto package release. The column operations available inside a UDF body come from the Nervix catalog in User-Defined Functions, not from the Roto standard library.
This section describes the basic syntax of Roto scripts. This is written in a reference style, mostly intended to be used as a cheat sheet, not as an introduction to the language.
Shebang
The first line of a script is allowed to be a
shebang; if it starts with #!
then it will be ignored.
Comments
Comments in Roto start with // and continue until the end of the line. They
can be inserted anywhere in the script and are ignored.
// this is a comment
Block comments are not supported; to create a multiline comment, prefix
every line with //.
// one comment line
// another comment line
Literals
Roto supports literals for primitive types:
0,1,34,-10,0xFFetc. for integers0.0,10.,10e5,5E-5etc. for floating point numberstrueandfalsefor booleans0.0.0.0,2001:DB8:2CA1:0000:0000:0567:5673:23b5,::, etc. for IP addresses0.0.0.0/10for prefixesAS1234for AS numbers'a'for characters"Hello"for strings
Note: Floating point literals need either a
.,eorEto distinguish them from integer literals.
Identifiers
Identifiers in Roto can consist of any character from Unicode’s
XID_Start character set or an _ followed by characters from
XID_Continue. In practice, this means that identifiers can use ASCII,
numbers, diacritics and other alphabets, with the restriction that they
cannot start with a number. Language keywords are also not valid identifiers.
Identifiers are case-sensitive, so foo is not considered to be equal
to Foo or FOO. Every character in the identifier is significant.
Here are some examples of valid identifiers:
foo_barfoo_barfoo1234StraßeМосква東京
The following strings are not valid identifiers:
12foo(cannot start with a number)foo.bar(cannot contain.)filter(cannot be a keyword)
Additionally, we have the following conventions:
- The names of local variables, modules, functions and function
arguments should use
snake_case, i.e. all lowercase with words separated by_. - The names of types and enum constructors should use
PascalCase, i.e. each word capitalized. The exceptions are the built-in boolean, integer and floating point types. - The names of constants should use
SCREAMING_SNAKE_CASE, i.e. all uppercase with words separated by_. - A leading underscore can be used to signal that a value is unused.
Local Variables
Local variables are declared with a let statement.
fn greater_than_square(x: i32, y: i32) -> bool {
let y_squared = y * y;
x > y_squared
}
Any local variable can be overwritten with an assignment, which is
expressed as = without let:
let x = 0;
x = x + 1;
A let-binding can take an optional type annotation for readability or to
help with type inference. In the example below, 0 has an unknown type
as it can be any integer type, so the type annotation forces it to be
u32.
let x: u32 = 0;
Local variables are dropped (i.e., deleted) at the end of the scope
where they are declared. A new scope is created with {}, including
when that is part of the syntax. For example, the body of an if
expression creates a new scope.
let x = true;
if x {
let y = false;
print(f"{y}"); // ok!
// y is implicitly dropped here
}
print(f"{x}"); // ok!
print(f"{y}"); // this is not possible: y is no longer in scope!
Built-in Types
Primitive Types
There are several core types at Roto which can be expressed as literals:
bool: booleanu8,u16,u32,u64: unsigned integer of 8, 16, 32 and 64 bits, respectivelyi8,i16,i32,i64: signed integer of 8, 16, 32 and 64 bits, respectivelyf32,f64: floating point numbers of 32 and 64 bits, respectivelychar: characterString: stringIpAddr: IP addressPrefix: IP prefixAsn: AS number
There are many more types available that have more to do with BGP. These
are described elsewhere. Note that as for identifiers, Roto types are
case-sensitive; writing the String type as STRING or string won't work.
Unit Type
The unit type is a special type written as () with only one value:
(). It is the type of expressions that do not have meaningful values
to evaluate to. For functions, returning () is equivalent to returning
nothing.
Never Type
The never type ! is an uninhabited type, meaning that it cannot be
constructed. It appears in code paths that are unreachable. For example,
it is the type of a return expression. It can be unified with any
other type.
Booleans
The boolean type in Roto is called bool and it has two possible
values: true and false. Booleans can be manipulated via several operators
such as && (logical and), || (logical or) and ! (logical negation).
Integers
There are several integer types in Roto. This might be familiar to users of languages such as C and Rust, but not for users of Python and similar languages which only have one integer type.
Roto is a compiled language and as such needs to know how many bytes to
use for a given integer. Hence, the number of bits are included in the
type. The prefix u is used for unsigned (i.e. non-negative) numbers
and i for signed integers.
Below is a table of all available integer types.
| Type | Bits | Signed | Minimum | Maximum |
|---|---|---|---|---|
u8 | 8 | No | 0 | 255 |
u16 | 16 | No | 0 | 65,535 |
u32 | 32 | No | 0 | 4,294,967,295 |
u64 | 64 | No | 0 | 18,446,744,073,709,551,615 |
i8 | 8 | Yes | -128 | 127 |
i16 | 16 | Yes | -32768 | 65,535 |
i32 | 32 | Yes | -2147483648 | 4,294,967,295 |
i64 | 64 | Yes | -9,223,372,036,854,775,808 | 9,223,372,036,854,775,807 |
Literals for integers can contain underscores for grouping digits. For example,
1_000_000 is a valid integer that is identical to 1000000. Any number of
underscores at any point in the literal can be used.
Additionally, integer literals can end with the type of the integer, such as
10u8.
Floating Point Numbers
There are two floating point types: f32 and f64, of 32 and 64 bits
length, respectively.
Literals for floating point numbers can contain underscores for grouping digits.
For example, 1_234.567 is a valid integer that is identical to 1234.567. Any
number of underscores at any point in the literal can be used.
Additionally, integer literals can end with the type of the integer, such as
10.2f32.
Characters
A character of a string has type char and represents a Unicode code
point. Character literals consist of a character enclosed in single quotes.
let character_a: char = 'a';
Strings
Strings are enclosed in double quotes like so:
let s = "This is a string!";
Strings can be concatenated with +:
let s = "race" + "car"; // yields the string "racecar"
The String type has some useful built-in methods such as
String.contains. See the documentation for the String
type for more information.
Escape sequences
Strings can contain the following escape sequences:
| Escape sequence | Escaped value | Common name |
|---|---|---|
\0 | U+0000 (NUL) | Nul |
\t | U+0009 (HT) | Tab |
\n | U+000A (LF) | Newline |
\r | U+000D (CR) | Carriage return |
\" | U+0022 (QUOTATION MARK) | Double quote |
\' | U+0027 (APOSTROPHE) | Single quote |
\\ | U+005C (REVERSE SOLIDUS) | Backslash |
In addition, any Unicode character can be represented by its scalar
value. This can be done with \x followed by 2 hexadecimal digits or
with \u{...} where the ... is a hexadecimal number representing a Unicode
code point.
Finally, Roto will ignore any whitespace after a \ followed by a newline, so
long lines can be broken into multiple indented lines without introducing spaces
when they are reassembled.
String Formatting
Roto supports a Python-like syntax for string formatting. Any string literal
prefixed with f will become a format string (or “f-string”), that interpolates
the expressions between { and }. The f-string will insert a call to the
to_string method for displaying the value. Therefore, any type implementing a
to_string method can be put in an f-string, including registered types.
let x = 10;
print(f"x is {x}");
Output:
x is 10
Arbitrary expressions are allowed to appear in format strings, including other strings and format strings.
let x = 10;
print(f"Twice x is {2 * x}");
print(f"x is {if x > 100 {
"big"
} else {
"small"
}}");
Output:
Twice x is 20
x is small
The { and } characters need to be escaped to be used in an f-string
by duplicating them: {{, }}.
print(f"x is {{ x }}");
Output:
x is { x }
Lists
A list is a growable array of items. In Roto, all elements of a list
must be of the same type. Therefore, the type of a list has a type
parameter: List[T]. You can create lists with [ and ] with
expressions separated by commas.
let my_list: List[i32] = [1, 2, 3];
let first: i32? = my_list.get(0);
match first {
Some(first) => print(f"First element was: {first}"),
None => print("No elements!"),
}
Output:
First element was: 1
The + operator can be used to concatenate two lists.
let a = ["one", "two", "three"];
let b = ["four", "five", "six"];
let concatenated = a + b;
See List[T] for all methods available on lists. Lists are passed by
reference, meaning that if we assign a list a to a variable b and we modify
a then b will see the same modifications.
Optional Values
Roto does not feature a value like None, null or nil. Instead, it
has optional values. The type of an optional value is written T?,
which is shorthand for Option[T]. For example, an optional u32 is
u32?, or equivalently, Option[u32].
The Option type is an enum type with 2 constructors: None and
Some. A value of T? is constructed with either Option.None or
Option.Some(t) where t is a value of type T.
Like any variant type it is possible to match on a value of type T?
match x {
Some(x) => x,
None => 0,
}
In addition, there is a ? operator, which will evaluate to the value
of Some or return Option.None. That is, if x is of type T?, then
x? is equivalent to the following match expression:
match x {
Some(x) => x,
None => return Option.None,
}
Results
The Result[T, E] type is used for functions that might fail. It is an enum
with two variants: Ok(T) and Err(E). The Ok variant should be returned
when the function exits successfully and the Err variant when it has failed.
The caller can then match on this value and decide what to do.
Note: More syntactic sugar for dealing with
Resultwill be added in the future.
Custom Types
Anonymous Records
Multiple values can be grouped into records. A record is constructed
with {} and contains key-value pairs.
let foo = { bar: 5, baz: 10 };
These records are statically typed, which means that records with different field names or different field types are separate types. For example, this is a type checking error:
if x {
{ foo: 5, bar: 10 }
} else {
{ foo: 5 } // error!
}
Note that this makes records significantly different from dictionaries in Python and objects in JavaScript, which resemble hash-maps and are far more dynamic.
Fields of records can be accessed with the . operator.
filtermap example_filter_map() {
let x = { foo: 5 };
accept x.foo
}
Fields can also be updated with an assignment.
let x = { foo: 5 };
x.foo = 6;
Named Records
Named records provide a more principled approach to grouping values which will yield more readable type checking errors.
record SomeRecord {
foo: i32,
bar: bool,
}
// ...
let x = SomeRecord { foo: 3, bar: false };
Roto checks that all declared values are provided and are of the same type.
There is an automatic coercion from anonymous records to named records:
fn foo(int: i32) -> SomeRecord {
{ foo: int, bar: false } // implicitly coerced to SomeRecord
}
Enum Types
Another kind of custom type in Roto are enum types. These types
have a set of constructors and values of these types are always
constructed using one of these. Each of the constructors can take
arguments. To inspect enum types, we can match on them.
// A `Number` enum type that has the constructors `Int`, `Float` and `Nan`.
enum Number {
Int(i32),
Float(f32),
Nan,
}
fn use_number() {
let x = Number.Int(10);
let y = Number.Float(21.5);
let z = Number.Nan;
match x {
Int(i) => print(f"int: {i}"),
Float(f) => print(f"float: {f}"),
Nan => print(f"nan!"),
}
}
Enum types can be generic over other types by taking type parameters.
enum Either[L, R] {
Left(L),
Right(R),
}
fn to_string(x: Either[i32, String]) -> String {
match x {
Left(i) => f"{i}",
Right(s) => s,
}
}
Note: Enum types are also used to model optional values. See optional values.
Operators
Arithmetic Operators
The unary - operator will negate a number. It requires that its
operand is a signed integer or a floating point number (i.e. not an
unsigned integer).
There are binary operators for common arithmetic operations, which are implemented for numeric types:
| Operator | Description |
|---|---|
+ | addition |
- | subtraction |
* | multiplication |
/ | division |
% | remainder (integers only) |
These operators follow the conventional PEMDAS rule for precedence. The order is
- Parentheses
- Multiplication, division and remainder
- Addition and subtraction
Parentheses can always be used to force a certain order of operations. For example, this expression:
1 + 2 * 3; // evaluates to 7
is interpreted as
1 + (2 * 3); // evaluates to 7
and not as
(1 + 2) * 3; // evaluates to 9
Comparison Operators
In addition to arithmetic operators, there are operators to compare values. Comparison operators have a lower precedence than arithmetic operators. The script won't compile if the operands have different types.
| Operator | Description |
|---|---|
== | Equals |
!= | Does not equal |
> | Greater than |
>= | Greater than or equal |
< | Less than |
<= | Less than or equal |
Examples:
5 > 10 // evaluates to false
10 > 5 // evaluates to true
5 == 5 // evaluates to true
5 == true // compile error!
1 < x < 10 // compile error!
Logical Operators
Operators to combine boolean values are called logical operators. These are the logical operators in Roto:
| Operator | Description |
|---|---|
&& | Logical and (short-circuiting) |
|| | Logical or (short-circuiting) |
! | Negation |
Now that we have all the rules for precedence, here is an example using all types of operators (arithmetic, comparison and logical):
1 + x * 3 == 5 && y < 10
This is equivalent to:
((1 + (x * 3)) == 5) && (y < 10)
The && and || are short-circuiting, meaning that if the left-hand
operand of && evaluates to false or the left-hand operand of ||
evaluates to true, the right-hand side won’t be evaluated.
Control Flow
If-else
To conditionally execute some code, use an if block. The braces in the
example below are required. The condition does not require parentheses.
The condition must evaluate to a boolean.
if x > 0 {
// if the condition is true
}
An else-clause can optionally follow the if-block. The if-else
construct is an expression and therefore evaluates to a value.
if x > 0 {
// if the condition is true
} else {
// if the condition is false
}
The if-else is an expression, not a statement, which means that it evaluates to a value. This means that it can be used as a replacement for a ternary operator.
let x = if y { 1 } else { 0 };
If-else expressions can be chained without additional braces.
if x > 0 {
print("x is positive!");
} else if x < 0 {
print("x is negative!");
} else {
print("x is zero!");
}
Match
Pattern matching in Roto is supported via match expressions. These
take a value and a set of patterns to check against, with an expression
associated with each of the patterns.
The pattern is separated from the associated expression with ->. The
arms should be separated with commas, unless the expression is a block,
i.e., when it is wrapped in {}.
The current implementation of this feature is very limited: you can only
match against variant types and only match against the constructor,
not against the contents of the constructor. See issue
124 for the status of
these limitations.
let x = Some(10);
match x {
None => print("x is None"),
Some(i) => {
print("x is Some");
print(f"x is {i}");
}
}
Note: In contrast with Rust, Roto currently doesn’t use the full path to the
variantconstructor, but only the name. SoOption.Noneis not allowed as a pattern, butNoneis. This will probably change oncematchexpressions become more general.
While Loops
A while loop takes a condition and a block. It will keep executing the
block until the condition evaluates to false.
let i = 0;
while i < 10 {
i = i + 1;
}
A while loop is an expression of the type (). Like with if,
while does not require parentheses around the condition.
For Loops
A for loop can be used to iterate over the elements in a list. The
variable created by the loop is available only within the loop and
received a copy of the data in the list.
let total = 0;
for x in [1, 2, 3, 4] {
total = total + x;
}
A for loop is an expression of the type (). Like with if, while
does not require parentheses around the condition.
Functions
Functions
Functions can be defined with the fn keyword, followed by the name and
parameters of the function. It is required to specify the types of the
parameters. The return type is specified with ->. A function without a
return type does not return anything.
fn add_one(x: u64) -> u64 {
x + 1
}
This function can then be called like so:
add_one(10);
A function can contain multiple expressions. The last expression is
returned if it is not terminated by a ;. The return can also be made
explicit with the return keyword. This function is equivalent to the
previous example.
fn add_one(x: u64) -> u64 {
return x + 1;
}
The following function uses multiple statements to return 0 if the
input is 0 and subtract 1 otherwise.
fn subtract_one(x: u64) -> u64 {
if x == 0 {
return 0;
}
x - 1
}
This function does not return anything:
fn returns_nothing(x: u64) {
x + 1;
}
The return keyword can still be used in functions that don't return a
value to exit the function early.
Filtermap
A filtermap is a function that filters and transforms some incoming
value.
Filter-maps resemble functions but they don’t return. Instead, they
either accept or reject, which determines what happens to the value.
Generally, an accepted value is stored or fed to some other component
and a reject value is dropped.
filtermap reject_zeros(input: IpAddr) {
if input == 0.0.0.0 {
reject
} else {
accept
}
}
This describes a filter which takes in an IP address and accepts it if
it is not equal to 0.0.0.0.
Like with functions, intermediate results can be stored in variables with let bindings.
filtermap reject_zeros(input: IpAddr) {
let zeros = 0.0.0.0;
if input == zeros {
reject
} else {
accept
}
}
A filtermap can also accept or reject with a value.
filtermap small_enough(x: i32) {
if x < 10 {
accept x
} else {
reject "value was too big!"
}
}
This filtermap is identical to the following function:
fn small_enough(x: i32) -> Verdict[i32, String] {
if x < 10 {
return Verdict.Accept(x)
} else {
return Verdict.Reject("value was too big!")
}
}
On the Rust side, a filtermap is a function that returns a
Verdict<A, R>. The type parameters of a Verdict specify the types of
the values given in the accept and reject cases, respectively.
Tests
Tests are written with the test keyword followed by an identifier and
a block. The name must be unique. Inside the block you can write any
Roto code that you want but it must return a Verdict[(), ()]. If the
test returns with accept, the test passes, it it returns with
reject, the test fails.
Tests look a lot like functions with a few crucial differences: they cannot be called directly and they do not have any arguments. Instead, Roto’s test runner finds the tests and runs them.
fn add(x: u32, y: u32) -> u32 {
x + y
}
test test_add_function {
if add(10, 20) == 30 {
accept
} else {
reject
}
}
If you’re using Roto as a binary or with the generated
CLI, you can run the tests with the test subcommand:
$ roto test
Constants
Constants in Roto are declared with the const keyword. They are evaluated
at the end of compilation, before functions are extracted.
const FOO: u32 = 10;
Unlike let declarations, const declarations always require a type
annotation. By convention, the names of constants are written in all
capital letters with words separated by underscores (also known as
SCREAMING_SNAKE_CASE).
The declarations of constants can be in any order. Context variables is not available during the evaluation of constants. Therefore, it is not possible to use any functions that use context variables. Any other function, both from the runtime and from the script can be used.
If constant A is defined in terms of constant B, then Roto will ensure
that B is evaluated first. Otherwise, the evaluation order is undefined
and you should not rely on any order. If there is any cycle in the dependencies
between constants, then Roto will yield a compile-time error before any
constants are evaluated.
const FOO: u32 = BAR;
const BAR: u32 = FOO;
Context Variables
Context variables are global variables that are passed to each invocation of a script. They are declared by the runtime. There is no way to create context variables in a Roto script.
The use case of context variables is to pass some implicit values to a script that do not need to be passed explicitly to functions in the script.
For example, if the runtime declares a context variable FOO of type u32,
then we can access it by name directly.
fn foo() {
let foo = FOO;
print(f"{x}");
}
Modules
Modules
A Roto script can be split over multiple files. To do this, we have to
create a folder with the name of the script and create a Roto file
directly in it called pkg.roto. This file is the root of our script.
The contents of pkg.roto will form the pkg module. No other files in
the directory can be called pkg.roto.
Files adjacent to pkg.roto are submodules of pkg. For example, a
file called foo.roto will define the module pkg.foo.
A directory next to pkg.roto will also be a submodule if it contains
a file called lib.roto. A file foo/lib.roto is therefore equivalent
to foo.roto and defines the module called pkg.foo. We can do this
recursively, so we can define the module pkg.foo.bar with either a
file called foo/bar.roto or foo/bar/lib.roto and so forth.
The files foo.roto and foo/lib.roto cannot both exist and only
foo/lib.roto can have submodules.
An item such as a function, filtermap or type can be used from other modules in a couple of ways. To access them, we must know the path, which is the period-separated list of identifiers to follow to get to the item, starting with the module name and ending with the name of the item.
For the following examples, we will work with the following files:
pkg.roto
foo.roto
bar/lib.roto
bar/baz.roto
These define the following modules:
pkg
pkg.foo
pkg.bar
pkg.bar.baz
Now assume that foo.roto contains a function called square, this
function can be referenced in any of the other files with the absolute
path pkg.foo.square. For example:
fn add_and_square(x: i32, y: i32) -> i32 {
pkg.foo.square(x + y)
}
We can also use the relative path, which is different for each file. We
can use the super keyword in a path to reference the parent module of
the current module. Multiple super keywords can appear at the start of
a path.
// in pkg.roto
foo.square
// in foo.roto
square
// in bar.roto
super.foo.square
// in bar/baz.roto
super.super.foo.square
There are 3 special identifiers that can only be used at the start of a path and automatically make the path an absolute path:
pkgfor the current packagestdfor the Roto standard librarydepfor dependencies (not implemented yet, but the identifier is reserved)
Imports
Of course, writing out the full path to anything you want to use can
become quite tedious. We can import items from other modules into the
current module with the import keyword. The import keyword is
followed by a path. The item the path references will be available by
name in the current scope.
import foo.square;
fn fourth_power(x: i32) -> i32 {
square(square(x))
}
We can also import entire modules. Imported modules are not available in other modules.
An import does not need to be at the top-level, they can be in any
scope. We can rewrite the previous example as follows.
fn fourth_power(x: i32) -> i32 {
import foo.square;
square(square(x))
}
Now the name square can only be used within the fourth_power
function and not in any other functions we define. But we can define
even more granular imports such as in the following example, where we
use a function foo from either module A or B, depending on a
boolean flag.
fn use_foo(x: i32, choice: bool) -> i32 {
if choice {
import A.foo;
foo(x)
} else {
import B.foo;
foo(x)
}
}
Next Steps
You can learn more about Roto by looking at the documentation for the Nervix Roto column operations.
Upstream Roto License
Copyright (c) 2022, NLnet Labs. All rights reserved.
This software is open source.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
Neither the name of the NLNET LABS nor the names of its contributors may
be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Lookups
Lookups are resource-backed reference data models that can be queried directly or used inside structured NSPL expressions.
Choosing An Enrichment Mechanism
Choose by ownership and update path:
| Need | Mechanism | Cost shape |
|---|---|---|
| Reference data changes when an operator uploads a resource version | HASH MAP with LOOKUP_HASH_MAP | Static in-memory lookup data for the selected resource version |
| Live state is keyed by the current branch | USING MATERIALIZED STATE with relay_state.<relay>.<field> | Branch-local state persisted and snapshot-replicated with the runtime node |
| Live data is keyed differently from the current branch | Re-key through a REINGESTOR into the branch that owns the data, or join outside Nervix in an external store | Reingestion adds a graph stage and creates branch instances under the new key; an external join leaves storage and query cost outside Nervix |
Materialized dependencies require the exact compatible branch. See
Materialized relay state for the linked REQUIRED WAIT,
REQUIRED SKIP, and DEFAULT semantics. Do not use a materialized relay as a cross-branch index.
This boundary is structural. Hash maps amortize a versioned reference dataset. Materialized state amortizes the latest record inside the branch that owns it. Neither mechanism turns runtime state into an arbitrary-key query service.
The current lookup model is a hash map:
CREATE [IF NOT EXISTS] HASH MAP <name>
KEY <field>
FROM RESOURCE <resource> VERSION <n>|LATEST
PATH '<file>'
DECODE USING <codec>;
The hash map loads records from a versioned RESOURCE file in the same domain as the hash map. The
file is decoded through the declared codec, and the KEY <field> value becomes the lookup key.
VERSION is mandatory. A number binds that exact completed version. VERSION LATEST resolves the
highest completed version when the statement is applied and stores the resulting number, so later
uploads and restarts do not move the hash map to another version. SHOW CREATE HASH MAP renders the
stored number. Creation succeeds only after the selected file has decoded and the index is usable
on every current live node, including while the domain is stopped. The next command may run
LOOKUP immediately; malformed input fails creation.
The web console’s Create → Hash map form gathers the name, key field, resource, completed
version or LATEST, file path, and codec as structured controls. The key-field picker lists the
selected codec’s output schema fields with their exact types. The form submits the current Model’s
canonical NSPL and waits for this same loading boundary before showing Completed. A failed
load leaves the entered draft visible for inspection. The transaction can already have committed
the Model before a malformed line fails activation, so a retry under the same name does not replace
that Model.
A resource file is read line by line, and each non-blank line is one payload for the codec. With a schemaful codec, such as a JSON wire schema over JSON Lines, each line is one entry:
CREATE RESOURCE zip_codes;
UPLOAD RESOURCE zip_codes VERSION './lookups/zip_codes';
CREATE SCHEMA zip_code_entry (
zip STRING,
city STRING,
region STRING OPTIONAL
);
CREATE WIRE JSON SCHEMA zip_code_entry_wire MODE STRICT (
zip string,
city string,
region string OPTIONAL
);
CREATE CODEC zip_code_entry_codec
FROM WIRE JSON SCHEMA zip_code_entry_wire
TO SCHEMA zip_code_entry;
CREATE HASH MAP zip_codes_by_zip
KEY zip
FROM RESOURCE zip_codes VERSION 1
PATH 'lookup.jsonl'
DECODE USING zip_code_entry_codec;
A JAQ-backed codec unfolds each line, so one line may contribute zero, one, or several entries, in the order its program yields them. When two entries carry the same key, whether from two lines or from one, the later entry replaces the earlier one. A line that fails to decode fails the hash map’s creation with a diagnostic naming the line.
Direct lookup commands are session/control commands:
DESCRIBE HASH MAP <name>;
LOOKUP <name> KEY '<key>';
DESCRIBE HASH MAP reports the loaded resource version, path, codec, owner/replica placement, key
field, and entry count, which counts distinct keys. LOOKUP returns the matching decoded record
when the key exists, written as JSON. JSON has no number for a NaN or an infinity, so a float field
holding one is written as the string NaN, Infinity or -Infinity; a key holding one matches
the entry keyed by the same name, every NaN alike. These are read-only control-plane commands: they do not execute the graph or
require a running domain clock, so a loaded hash map remains directly queryable while its domain is
stopped.
Expressions can call LOOKUP_HASH_MAP:
LOOKUP_HASH_MAP("<hash_map>", <key_expr>, "<field>")
The function evaluates <key_expr>, looks up that key in the named hash map, and returns the requested field from the matched lookup record. A missing key or missing optional field returns null. Referencing a field that is not present in the hash map schema is a statement-validation error.
Example enrichment:
CREATE BRANCH by_zip
SCHEMA zip_branch TTL 5m;
CREATE DEDUPLICATOR enrich_zip
FROM inbound
DEDUPLICATE ON input.zip
MAX TIME 10m
BRANCHED BY by_zip
TO enriched
INHERIT ALL
SET city = LOOKUP_HASH_MAP("zip_codes_by_zip", input.zip, "city"),
region = LOOKUP_HASH_MAP("zip_codes_by_zip", input.zip, "region")
WHERE NOT is_null(output.city)
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG;
Lookup models are domain-owned. A hash map name must be unique within the active domain, and CREATE IF NOT EXISTS HASH MAP ... follows the same idempotent-create behavior as other model create statements.
Sessions
Nervix supports session-local commands over its session protocol.
These commands are not persisted in the registry:
CREATE SUBSCRIPTION acme_notifications TO notifications WHERE tenant = 'acme';
CREATE SUBSCRIPTION sampled_telemetry TO telemetry DROPPING BATCH SAMPLE RATE 0.1 WHERE input.tenant = 'acme';
DELETE SUBSCRIPTION acme_notifications;
DESCRIBE RELAY notifications WHERE (tenant = 'acme');
ATTACH DOMAIN CLOCK;
DETACH DOMAIN CLOCK;
Following a domain clock is not a subscription; see Domain Clock Attachment. Publishing to a client ingestor uses producers, which are not statements.
The Client Ingestors And Emitters architecture chapter connects these transient handles to their persisted graph endpoints, ACK boundaries and restoration.
This page describes sessions as a user of NSPL sees them. Client Session Protocol explains the protocol that carries them, and the Client Implementation Manual states what a client implementation must do.
Current session behavior:
- subscription creation validates the statement against the relay as the cluster schedule declares it, and attaches only while the serving node declares the relay with that same definition, whether or not it owns the relay and whether or not the domain is running
- subscription names are unique within one connected session and may refer to relays in different domains
DELETE SUBSCRIPTIONresolves only the session-local subscription name, independent of the currently active domain- subscribing to a relay collects records from all active branch groups for that relay
- a branched subscription reports the concrete branch key of every batch of rows it delivers, and the text view repeats it on each row; sensitive branch-key fields are masked using the same rules as sensitive relay fields
- subscriptions are read-only views; only an optional
WHEREpredicate is supported, and a selected record is delivered without construction or transformation - the predicate is an ordinary
BOOLexpression, including membership, range, and null-safe equality tests such asinput.status IN ('open', 'held'); it selects a record only where it is true, so a null predicate, such asINover a null field, does not select it - bare fields,
message.<field>, andinput.<field>all read the subscribed relay record; the compiler rejectsoutput,branch, andrelay_statescopes when the subscription is created - subscription syntax does not accept
INHERIT,SET,VALUES,INVOKE, or other side effects - a subscription with a
WHEREpredicate reads one snapshot of the domain’s clock on the serving node for each batch it evaluates; every predicate expression and volatile UDF call for that batch sees the same instant, and a batch whose domain time cannot be read is skipped and reported - optional
BATCH SAMPLE RATE <rate>samples each selected row, afterWHEREhas been evaluated, with a pseudo-random draw; a rate of1delivers every selected row and0delivers none BLOCKINGdelivery waits for room in the session’s subscription queue, which holds back the relay it reads, whileDROPPINGdiscards rows when that queue is full and reports how many it discarded before its next rows; the queue holds 16 frames shared by every subscription of the session, and a count still outstanding when the subscription ends is not reported- subscription changes belong to the session rather than to a transaction, so
CREATE SUBSCRIPTIONandDELETE SUBSCRIPTIONare refused while the session holds a transaction - subscription events are delivered asynchronously to the connected client session
- the relay owner is the sole subscription fan-out source, so each admitted batch is delivered at most once to a subscription even when producers and consumers run on several cluster nodes
- when the subscription’s cluster node also hosts a runtime consumer, subscription delivery piggybacks on the same owner-to-node Arrow IPC batch instead of adding another serialized copy
- runtime and server errors are also delivered asynchronously
- cluster membership updates are also delivered asynchronously
Sessions are runtime-facing protocol interactions, not part of the persisted namespace model.
A terminal reply may hold up to 64 MiB. A reply above the 4 MiB frame bound is carried as ordered transfer parts that each fit one frame, then validated and reassembled before the client exposes it. This is how a rendered transaction report larger than one frame reaches the Rust client, CLI, and browser without truncation. A reply above the transfer bound is rejected whole; no client receives a successful partial result.
Subscription Lifecycle
A subscription is identified by its name together with the generation its session assigns when it opens it. Every frame about a subscription carries both, and a name reused after deletion opens a new generation, so a frame about an earlier generation is never taken for a later one. A subscription belongs to the session that created it and moves through one lifecycle:
- Creating. The server validates the statement, makes the node’s interest in the relay visible to every live node, and attaches to the relay. A refusal at any step leaves nothing behind: no interest, no attachment, and the name remains free. Reopening after the last subscription closed waits for the renewed interest to become visible; an advertisement from before that closure does not establish readiness.
- Active. The reply that opens the subscription carries the schema of its rows, and no row precedes it. When that reply cannot be delivered, because the request was cancelled, the session ended, or the reply did not fit the session limits, the subscription is abandoned before it delivers anything.
- Ended by the server. When its relay is redefined, so that a field’s name, position, type,
nullability, or sensitivity changes, the relay becomes branched or unbranched, or its branch or
branch key schema changes, the subscription receives
SubscriptionEndedwith reasonRelayChanged. When its relay or the relay’s domain is removed, the reason isRelayRemoved. Either is the last frame about that generation, and no row of a redefined relay reaches a subscription announced under its earlier definition. Stopping and starting a domain, and rebuilds that keep the relay’s definition, keep its subscriptions delivering. An ended subscription can still be deleted by name, and its name can be used again once its end has been sent; because replies travel ahead of queued subscription frames, that end can reach the client after the reply that opens the new generation. - Deleted.
DELETE SUBSCRIPTION, or the end of the session, stops the subscription at once even while its client reads nothing, and discards the frames it still has queued. Nothing about that generation follows the reply that deleted it.
Replies and session events travel ahead of the subscription rows a session has queued, so rows
waiting for a slow client never hold back a command reply or the reply to the unsubscribe that stops
them. Frames already handed to the transport stay in order: a reply follows the rows the transport
took before it. A node advertises
interest in a relay while at least one of its subscriptions holds it; see
Cluster Interconnect and the nervix_session_subscriptions and
nervix_session_subscription_dropped_rows_total series in
Metrics and Observability.
Subscriptions are live views. They do not replay rows, keep durable offsets, or deliver exactly
once. A session is told of the rows it lost where the server knows of them: a DROPPING
subscription reports the rows it discarded, and rows a filter could not evaluate, rows of a batch
whose domain time could not be read, and every selected row of a batch the encoder could not fit
into a frame are reported as skipped. Rows in transit can also be lost without a report while
relay ownership moves between nodes or a node-to-node delivery fails. Row
Subscriptions lists every gap and whether it is
reported.
Typed Row subscription frames carry a BYTES field as raw octets in a BytesCell; clients read
the value as borrowed bytes, including empty and non-UTF-8 sequences. JSON subscription views
render that field as padded standard base64 text. Sensitive byte fields are redacted in both views.
Persistent administrative commands carry a stable execution reference. The cluster binds that reference to the authenticated owner, selected domain, and semantic command, continues admitted work after the session disconnects, and retains one terminal result for the command retry validity, 15 minutes by default. The reference is a UUIDv7 whose creation time bounds how long it may be retried. The CLI, web console, and Rust client reuse the reference through redirects and reconnects. Reuse for changed content fails. While a long command waits, server events, subscription delivery, completion, and transaction inspection continue independently; the session’s later commands wait for it, because a session runs its commands in the order it received them. See Command Completion and Exact Recovery.
Domain Clock Attachment
A session can follow the clock of a domain: its START generation and what the serving node has
installed for it. ATTACH DOMAIN CLOCK; starts following the clock of the active domain and
DETACH DOMAIN CLOCK; stops following it. Clients send them as an AttachDomainClockRequest and a
DetachDomainClockRequest naming the active domain, so each addresses the domain active when it
runs. The Rust client, the CLI and every host of the shared C binding send each statement on its
own: they refuse it beside other statements, and fail it when no domain is active. Selecting
another domain with USE does not change what the session follows, and a session follows several
domain clocks by attaching while each domain is active. The web console instead follows the clock
of the domain it has selected: selecting another domain, from its domain menu or with USE,
detaches the previous clock and attaches the selected one.
An attachment is keyed by its domain alone. It is not a subscription: it reads no relay, has no
name or generation of its own, and a session follows each domain clock at most once. The observed
clock carries the START generation and one state:
- Stopped. The generation exists but executes no domain work. Generation 0 means the domain has never started.
- Uninstalled. A paced generation is running, but the serving node has no assigned clock authority or mapping for it, so it cannot execute paced work.
- Unpaced. The domain reads actual UTC.
- Paced. The committed mapping the domain executes with:
PERIOD,SKEW, the logical origin, the UTC anchor, and the time rate. Timestamps are signed Unix nanoseconds, period and skew are unsigned nanoseconds, and the rate is a double.
An attachment moves through one lifecycle:
- Attached. The reply carries the clock as the serving node has it installed, and no frame of this attachment precedes it. A second attach is refused as already attached, and a domain the serving node does not have is refused as not found. A node that has not installed the cluster’s committed domains since it started, as right after a restart, answers only once it has, so it never refuses a domain the cluster has as not found. That wait ends early only with the session. While it waits, the attach holds the session’s ordered lane, so the session’s later commands and ordered requests wait behind it. When the reply cannot be delivered, because the request was cancelled, the session ended, or the reply did not fit the session limits, the attachment is abandoned before it delivers anything; cancelling a waiting attach therefore attaches nothing, though it does not end the wait. If the installation has not changed and the node already holds a tick of that generation, its newest tick is the first frame after the reply.
- Following. Each later change of the installed clock arrives as a
DomainClockObservedframe carrying the new clock:STOPdelivers stopped, aSTARTdelivers its new generation, paced with its mapping or unpaced, and a paced generation left without an assigned clock authority delivers uninstalled, then its mapping again once an authority is assigned. A frame carries the clock installed when the server built it. Changes made while an earlier frame waits for room on the session arrive together as the newest clock, and a change that leaves the clock as the client last received it sends nothing: moving the authority to another node and the pause that alters a running model are not observed. For a paced installation, each newer accepted tick arrives as aDomainClockTickedframe with its generation, one-based id, logical boundary, the authority’s UTC observation, and the serving node’s own logical reading taken when it built the frame. A tick of a new generation follows that generation’s state frame. Ticks are replaceable on the control lane: each attached domain holds at most one queued tick, updated to the newest while the client is slow, and a changed clock withdraws a queued tick the transport has not taken yet. Tick ids may skip after coalesced periods. An unpaced, stopped or uninstalled clock emits no ticks. - Detached.
DETACH DOMAIN CLOCKstops delivery before its reply, so nothing about the domain follows the reply. A detach without an attachment is refused as not attached. The Rust client, the CLI and every host of the shared C binding never attach a clock again on their own behind a detach of it: a restoration attach they already decided to send reaches the session first, and the detach releases what it attached. - Ended by the server. When the domain no longer exists on the serving node, the session
receives
DomainClockAttachmentEndedwith reasonDomainRemoved, the last frame about that attachment. A request sent after that frame finds the attachment gone: an attach is served as a new attachment, which the serving node refuses as not found unless it holds the domain again, and a detach is refused as not attached. - Session end. The attachment ends with the session, and nothing is sent about it. The Rust client, the CLI, and every host of the shared C binding attach every clock they followed again on their next session, and the web console attaches its selected domain’s clock again when it reconnects. A console that holds a transaction attaches it first on the new connection, so the server refuses that clock attach, and the console shows the refusal until its selection or connection changes. The new attachment delivers the clock as it is then and the newest tick its serving node holds. That tick can repeat one the client already received, or precede it when another node serves the new session. When the new session’s attach is answered while the old session still holds the attachment, as after a redirect to the leader, the Rust client and the shared C binding keep the newest tick of an unchanged clock and report a tick of the new session only when it is newer.
Both statements run in order with the session’s commands. While the session holds a transaction, the server refuses them as it refuses a subscription change, and the Rust client, the CLI, every host of the shared C binding and the web console refuse them before sending, as they refuse every client-local statement. Neither is persisted or becomes transaction content. See Domains And Time for what a client computes from a paced clock, and Domain Clock for how the serving node delivers an attachment.
Producers
A session can publish typed batches to a client ingestor through
producers it opens. A producer is not a statement: clients open, feed, and close it with the
OpenIngestor, SubmitBatch, and CloseIngestor requests, and the Rust client exposes it as
Client::open_ingestor; see Client Library.
- An open names its domain explicitly, so
USEnever retargets a producer, and it is answered on the session’s ordered lane with the ingestor’s input schema, itsSTARTgeneration, the endpoint contract and attachment it bound to, the ingestor’s acknowledgement policy, the credit it was granted, and whether admission is open. A refused open leaves nothing attached. - Producers belong to the session, not to a transaction: an open is refused while the session holds a transaction.
- A session holds at most 32 producers and 32 MiB of producer credit; the serving node holds at most 128 MiB for every producer it serves or retains batches for.
- A submitted batch is handed to its producer without the session’s receive loop waiting on it, so commands, subscriptions, domain clock frames, and every other request keep moving while batches await their graph outcome. Batches are not counted against the session’s in-flight request limit; the producer’s credit bounds them instead.
- Every batch receives exactly one terminal reply: not admitted, completed, processing failed, or of
unknown outcome. A batch cannot be cancelled once sent;
CancelRequestis refused for it, because withdrawing an admitted batch is not possible. - A batch sent beyond the producer’s credit is refused and ends the producer as a protocol violation. Its earlier batches still receive their outcomes.
- The session tells the client when a producer’s admission is suspended or open again, and when the server ends a producer, which is the last frame about it. Closing a producer refuses its queued batches, waits for its admitted ones, and is answered once every batch has its outcome.
- When the session’s node forwards a producer to the node that executes its ingestor and loses that
node, whether it crashed or stopped answering, the producer ends as
owner lost. Before the end, every batch that node could not have admitted is refused asproducer ended, because the session’s node clears each forwarded batch before it may be admitted; only the cleared batches are reported as of unknown outcome. - When the session ends, its producers detach. Admitted batches continue through the graph with nobody left to answer them, so a client reports them as of unknown outcome. The Rust client keeps the producer handle and restores a fresh attachment if the domain generation and endpoint contract still match; it does not resend those uncertain batches.
Emitter consumers
An application opens a consumer of a TO CLIENT emitter by naming its domain, emitter, and the
exact output fields, including order, optionality, and sensitivity. The open reserves room for
one maximum-sized Arrow delivery before reporting success. A session holds at most 32 consumers
and 32 MiB of consumer credit; the serving node reserves at most 128 MiB for this direction,
independently of its producer budget. Thus one session’s producer and consumer ceilings total
64 MiB and a node’s total 256 MiB. No consumer means retained output fills the bounded node
budget and backpressures the graph.
OpenEmitter, ReadEmitterBatch, SettleEmitterBatch, and CloseEmitter are session operations,
not relay subscriptions. A read is concurrent with the session’s ordered control lane; the
receive loop can keep processing producer submissions, ACKs, commands, and clock observations
while that read waits. A returned batch is still unacknowledged. Its current reference must be
confirmed with ACK, retried, or rejected; close and session loss revoke unresolved attempts.
The server attachment belongs to its session exchange. The Rust client keeps the consumer handle
and restores a fresh attachment when the generation and endpoint contract still match. The first
read after a gap reports an interruption. Delivery references from the former attachment cannot
ACK through the new one. The server does not persist a consumer position. A session may attach
through a different node from the emitter; that node forwards output and settlement over the
authenticated interconnect. Client Session Protocol
defines the frames, and Emitters defines their graph ACK boundary.
Suggestions
The session’s SuggestRequest carries the full NSPL input, a UTF-8 byte cursor on a character
boundary, the selected domain, a page size from 1 through 100, and an optional continuation.
The server asks the composed client/server grammar for expectations at that cursor, then resolves
semantic references from one read of the selected domain’s committed configuration with the
authenticated session’s ordered queued transaction prefix applied. The prefix includes staged
model creation, alteration, renaming, and removal. Other sessions see committed configuration.
For ALTER SCHEMA and ALTER WIRE ... SCHEMA, field references are drawn from the named schema
after that prefix has been applied, so a staged field rename or removal changes the available
candidates immediately.
Inside a route expression, completion also offers ordinary VM builtins. For a junction,
input.<field> resolves through the input relay’s declared schema, and a SET field resolves
through the output relay’s declared schema. Qualified udf::<name> calls use the domain’s UDF
models, including the session’s queued changes. In an ingestor construction expression,
read_header and read_headers are offered only when the selected source supports header reads.
In an emitter INVOKE expression, write_header is offered only when the selected sink supports
header writes.
Each suggestion carries a display value, a kind, and a text edit with UTF-8 byte start and end
offsets and replacement text. Clients apply that edit to the original input; they do not infer the
range from the display value. A local path suggestion asks the native CLI to search its own
filesystem and carries the path fragment’s source range. The grammar offers one wherever a
statement names a file or directory on the client’s machine: the directory of UPLOAD RESOURCE,
the destination of BACKUP, and the archive DESCRIBE BACKUP or RESTORE reads. The server sorts
and deduplicates the candidate set before returning a bounded page. A continuation binds the input, cursor, domain,
configuration revision, and candidate set; if any of these changes, the server returns
StaleContext instead of silently paging through a different set.
Ready with no suggestions means there are no matches. MissingContext means a domain required
for a semantic reference is absent or unavailable. StaleContext means a transaction binding or
page basis no longer matches. LookupFailed means the semantic configuration read failed. These
statuses are part of the suggestion reply, so a client can distinguish them without interpreting
an empty candidate list.
Suggestions are read-only session requests. They can complete while a command is still pending; they neither enter the command admission gate nor change the transaction queue.
Backup Downloads
The session service’s DownloadBackup method is a server-streaming gRPC call that sends the
archive of one completed backup. Its single request names the backup’s execution reference. The
call authenticates from its metadata before it reads the request, like every session method, and a
call without valid credentials ends with UNAUTHENTICATED.
A node that retains the archive answers with a start frame carrying the archive’s total size and BLAKE3 digest, then the archive’s bytes in chunks of at most 256 KiB, then a completion frame. It reads the archive only as fast as the client takes frames, at most four frames ahead, so a slow client holds back the reads instead of growing what the node holds for it. Every other answer is a single frame:
| Answer | When |
|---|---|
Refused InvalidRequest | The request does not decode. |
Refused Expired | The retry validity of the execution reference has ended. |
Refused NotOwner | Another user ran the backup. |
| Redirect to the leader | The node does not retain the archive and is not the leader. |
Refused NotRetained | The leader does not retain the archive: a download already collected it, the node that assembled it restarted, or the reference names no backup. |
Refused ReadFailed | The node could not read the archive it retains; the archive stays retained. |
The first download whose completion frame was queued collects the archive, and a later download of it is refused. A download whose client goes away releases only its own hold, so the archive stays retained for the client to download again. The call is not bounded by the request timeout; the Rust client bounds the wait for each frame by it instead, and starts a failed download again from the first byte. Backup And Restore describes backups and their archives.
The web console makes the same call over a WebSocket of its own on the console listener,
/console/backups/download, authenticated like the console session. The connection carries the one
request and the frames that answer it, one frame per binary message, and the node closes it
normally after the last frame. The console checks the archive against the backup’s summary exactly
as the Rust client does before it hands the archive to the browser.
Restore Streams
The session service’s RestoreBackup method is a client-streaming gRPC call that carries one
restore and its archive, and a single reply answers it. The call authenticates from its metadata
before it reads a frame, like every session method, and a call without valid credentials ends with
UNAUTHENTICATED.
The first frame is the restore’s start. It names the request identity of the reply, the restore’s
execution reference, the RESTORE statement as canonical NSPL, and the archive’s exact size and
BLAKE3 digest. Every later frame carries the next bytes of the archive, at most 256 KiB each. The
execution reference, the statement, and the archive’s size and digest together are the restore’s
request identity, so sending them again joins the same restore.
The reply is either the restore’s outcome as a command outcome, or a typed refusal of the stream itself, which leaves nothing changed:
| Answer | When |
|---|---|
Refused InvalidStream | The first frame is not a start, a later frame is not a chunk, or a frame does not decode. |
Refused InvalidStatement | The start does not name exactly one RESTORE statement. |
Refused SizeMismatch | The chunks add up to more, or fewer, bytes than the start declares. |
Refused DigestMismatch | The archive’s bytes do not have the digest the start declares. |
Refused QuotaExceeded | The archive is larger than the leader stages, or its staging area cannot hold the archive now. |
Refused StagingFailed | The leader could not write the archive to its staging area. |
| A redirect outcome | The node is not the leader. |
| An outcome at once | A restore under the reference already finished, which answers with its recorded outcome, or still applies on this node, which answers OutcomeUnknown with the StillApplying cause. |
| The restore’s outcome | The archive arrived, and the restore was refused, failed at a step, or completed. |
A refusal is not recorded: sending the stream again stages the archive again. The leader stages the archive only as fast as the client sends it and refuses, rather than queues, an archive its staging area cannot hold now. The call is not bounded by the request timeout, because an archive may take far longer to send than a command takes to run; the Rust client bounds each frame by it instead, and then the wait for the reply once the last frame was sent. A call that ends before the last frame arrived changes nothing and releases what the leader staged for it. Once the whole archive arrived, the restore goes on without the call. Backup And Restore describes restores.
The web console makes the same call over a WebSocket of its own on the console listener,
/console/backups/restore, authenticated like the console session. A WebSocket client cannot
half-close the stream and still read the reply, so the stream ends with the chunk that completes the
size the start declares, and the node answers on the open connection, then closes it normally. A
connection that closes before that chunk ends the call as a failed gRPC call does.
Structured Choices
ChoiceLookupRequest resolves values for structured client controls without constructing partial
NSPL. It carries a semantic target, typed dependent selections, search text, a page size from 1
through 100, and an optional page cursor. Targets resolve domain pace, placement policy, and a
domain’s internal schemas, each wire-schema format, branches, relays, codecs, VHOSTs, signaling
protocols, relay fields, codec output fields, resource catalogs, and completed resource versions.
Ingestor controls additionally ask for a source reference by connector and entity, a decoding
codec, relays with exact branch declarations, and fields of a selected branch’s key schema. Each
source target requires a domain and returns what its entity reads through: clients of its
connector, or for HTTP ENDPOINT and WEBSOCKETS ENDPOINT the endpoints of that TYPE. The
ingestor codec target requires a domain and filters out codecs without decoding.
An unbranched relay target requires a domain and also supplies ingestor error relay choices, while
a branched relay target requires a domain then a branch model reference. Branch fields require the
same pair. Decoded fields and output fields use the existing codec-field and relay-field targets.
Placement requires exactly one domain-pace dependency. Schema, wire-schema, branch, relay, VHOST,
signaling-protocol, resource, and codec lookups require exactly one domain reference. A
relay-field lookup requires that domain reference followed by a relay model reference, and returns
the fields of the relay’s records in the order its schema declares them; the other configuration
lookups order their models by name. A codec-field lookup requires the domain followed by a codec
model reference and returns the fields of that codec’s output schema in declaration order. A
completed-version lookup requires the domain followed by a resource reference. It offers LATEST
and each completed uploaded version, excluding applying or
failed uploads. Resource catalog choices include resources staged in the session’s attached
transaction. These lookups read the domain’s current Models with the
requesting session’s attached transaction prefix applied, so a model staged earlier in that
transaction appears before commit. An absent or differently typed dependency, a domain that does
not exist, or a relay or codec the configuration no longer has returns MissingContext. A relay
or codec whose output schema the configuration does not declare returns LookupFailed.
Each result separates semantics from presentation. ChoiceValue carries a domain-pace or
placement-policy variant, a typed domain, resource, or model reference, or a reference to a field
of the record the dependencies select, or a requested resource version as an explicit number or
LATEST. ChoicePresentation carries its label, optional detail, and
optional group; a relay or codec field’s detail is its exact type followed by OPTIONAL and
SENSITIVE as its schema declares them. A client selects by the typed value and never derives
behavior from the label. Ready with no values is an ordinary empty match; MissingContext,
StaleContext, and
LookupFailed remain distinct outcomes.
A page cursor binds the target, every dependent value, search text, application revision, and the
ordered typed candidate set including its presentation metadata. Changing any part returns
StaleContext instead of continuing through a different result. Schema, branch, and relay pages
also bind the canonical definition of each matching model, as do VHOST and signaling-protocol
pages. Relay-field pages bind the relay
and its schema, so a change within an attached transaction invalidates a cursor even when names and
field counts stay the same. Wire-schema pages bind their canonical definitions; resource pages
bind names and completed-version counts, and version pages bind the offered version values. Choice
lookups are read-only and can run
concurrently with each other and with commands. The web console additionally correlates
each lookup with its control, draft revision, and session generation, so a late reply cannot
replace the choices for a newer edit or connection.
Transaction Binding
An NSPL transaction is replicated control-plane state, but its binding to a live session is leader-local soft state. A session may bind one transaction, and a transaction may be bound to at most one session. The session protocol can attach by transaction id; the authenticated user must match the transaction owner. A later attach takes over the binding and the displaced session gets an explicit takeover error on its next transaction operation.
A transaction also carries the domain it is bound to, and every statement queued in it must name
that domain. The CLI, web console, and Rust client adopt the transaction’s domain as their selected
domain when they attach it, and refuse USE while a transaction is active.
The CLI, web console, and Rust client retain the transaction id, each append’s execution reference
and expected position, and the commit execution reference. They automatically attach after a
redirect or transport reconnect before resuming a command. A leader that has no binding for the
session’s transaction answers with a distinct detached result rather than an ordinary error, and
the client attaches again and replays the command instead of surfacing it. An unclean transport loss, node loss,
or leadership change therefore leaves an open transaction intact until attach or idle expiry. A
client matches the attached progress to the outstanding append or commit and does not satisfy that
request from an unrelated transaction count. During election convergence, a client also retries a
bounded interval when a peer cannot yet advertise the new leader. A session that its client closes
cleanly, with no request in flight, reverts the open transaction bound to it on the leader; any
other ending only releases the binding. Ending a session never reverts admitted append work or a
transaction whose replicated state is already COMMITTING; the leader finishes it without a
client.
Finished transaction outcomes remain available during tombstone retention. Attach during that
window reports COMMITTED, FAILED, REVERTED, or EXPIRED with the structured final status and
the transaction’s aggregate outcome. After retention, attach reports an unknown transaction id. See
Replicated NSPL Transactions and Transactions Over
The Protocol.
Backup And Restore
A backup writes a whole cluster, or one domain, into one archive file on the machine of the client that ran it. The archive holds each domain’s committed models as NSPL, each domain’s lifecycle and clock, its resource catalog with the original bytes of every resource version, and, for a cluster backup, every user. The archive is a public format: a tar stream whose sections standard tools can list and extract, and whose records every reader validates before it trusts them.
A normal backup pauses each running domain in turn, waits for its intake and acknowledged work to
drain, then captures its committed configuration, WASM guest checkpoints, Kafka source offsets,
branch lifecycle, fresh materialized relay generations, deduplicator keyspaces and the rows every
window retains. It resumes that domain before transferring the captured sections into the
archive. Stopped domains need no pause. WITHOUT PAUSE captures the latest published state while
the domain runs and has crash-consistent rather than quiesced semantics; WITHOUT STATE captures
configuration only. Queued relay batches and acknowledgements remain volatile.
A restore recreates what an archive holds: every domain and user of a cluster archive in a fresh cluster, whatever its nodes are named, or one domain beside the domains a cluster already has, under its archived name or a new one.
Backing Up
BACKUP CLUSTER TO '/var/backups/nervix/cluster.nvxb';
BACKUP DOMAIN payments TO './payments.nvxb';
BACKUP DOMAIN TO './current-domain.nvxb' WITHOUT RESOURCES;
BACKUP DOMAIN payments TO './payments-live.nvxb' WITHOUT PAUSE;
BACKUP CLUSTER TO './cluster-config.nvxb' WITHOUT STATE;
BACKUP CLUSTERcovers every domain and every user.BACKUP DOMAIN <name>covers one domain. Without a name it covers the session’s selected domain. A domain backup holds no users.WITHOUT RESOURCESrecords every resource version with its checksums and sizes, and leaves out the version’s bytes.WITHOUT STATEomits runtime state and takes no domain mutation lease.WITHOUT PAUSEincludes published runtime checkpoints without pausing a running domain. Its state and configuration need not be from one quiesced cut.TIMEOUT <duration>bounds each running domain’s quiesce wait. It is valid with the normal quiesced capture. Domains are captured in sequence and each receives its own budget. Each owner stages its share of a domain’s state within that same budget, however long its state takes to stage; a stopped domain’s owners stage within the budget too, and aWITHOUT PAUSEcapture’s owners within the default one. The command’s total wait includes all cuts, admission and archive assembly.- The path names a file on the client’s machine. A leading
~/refers to the client user’s home directory. In the web console it names the file the browser saves the archive as.
BACKUP runs in nervix-cli, in the Rust and C clients, and in the web console, which saves the
archive as a browser download; see From The Web Console. It must be sent
alone: not in a batch with other statements and not while a transaction is open.
Each domain records the applied configuration revision and Raft log entry of its own capture.
Before reading state, each owner waits up to five seconds to apply the selected log revision,
brings its runtime plan current, and checks the sending leader again. An owner that cannot reach
the revision refuses capture. This also applies to stopped and live captures.
For a quiesced running domain, the leader holds a replicated domain mutation lease, pauses and
drains the domain across live nodes, asks state owners to publish and stage their checkpoints,
and reads the configuration while the cut is held. The leader resumes the domain and releases its
lease before copying staged state into the final archive. Other domains continue independently.
Each owner asks its active ingestor, reingestor, and processor supervisors to checkpoint their
current branch lifecycle, including branches created since the periodic snapshot. Capture retains
the immutable checkpoint published by each entity’s replication handle; a handle awaiting its first
checkpoint contributes no lifecycle state. It seals those checkpoints and the current Kafka offsets
durably before opening one database snapshot that also
contains the already durable WASM guest saves. A stopped domain reads its stored checkpoints
without active task requests.
The cut lists that snapshot’s checkpoints without reading their payloads, and reads only those it
archives, all from the same snapshot however late they are read. A branch lifecycle is read while
the cut is taken only when a WASM, deduplicator or window branch of its entity needs a typed key,
one lifecycle at a time, and only the keys those branches need are kept.
Each selected WASM save stays in that snapshot until its scheduled generation has been checked.
The capture storage job inspects a selected segmented header’s bounded size; staging admission
uses that recorded size without a database read on an async worker.
The owner then opens one save at a time, charges its inline checkpoint conversion to
restore_metadata, and streams its raw bytes into a quota-owned staged section under a fixed
192 KiB bulk working charge. The checkpoint read supplies both the bytes and the descriptor’s
revision. The writer seals the section’s actual length and BLAKE3 digest. Neither another save nor an
encoded guest section is retained whole during the write; a superseded save is never opened.
Each Kafka offset and branch lifecycle checkpoint then becomes its own archive section. It is read
whole into one aligned allocation charged to restore_metadata, its entries convert one at a time,
and its record streams through a 64 KiB bulk buffer into a quota-owned staged file that seals the
section’s exact length and digest. The record’s serializer scratch, one resolver per entry, is
charged to restore_metadata beside the checkpoint. Neither the converted entries nor the encoded
record are held whole, so native metadata larger than the bulk budget is archived within it.
Materialized relays capture shared Arrow row views, typed branch keys, per-row watermarks, revision,
ownership fence and branch generation under their assignment barrier. Capture reads the current
entries at the cut, including updates since the periodic snapshot; it never waits for that interval.
An empty relay contributes an explicit empty generation. A live capture obtains the same complete
relay generation under that barrier, while its domain-wide cut retains live capture semantics.
The lifecycle checkpoint also makes every active deduplicator and window processor branch task
publish its state. For each branch a captured lifecycle holds, capture takes the keyspace its
deduplicator published, every key with the time it was first seen, and the rows its window
retains, with their aggregate argument values, admission sequences and watermarks, the branch
incarnation the window belongs to, and every linear histogram’s pending delayed removals. A branch
with no live publication on its owner, including an inactive window with a sealed publication,
contributes its stored checkpoint. A stopped domain reads stored checkpoints only. An evicted
branch contributes nothing.
Stored keyspace and window selections retain the cut’s immutable database view until their
scheduled owner and schema are checked. Each owner opens one selected checkpoint at a time,
streams it to quota-owned disk in 64 KiB blocks, and admits an immutable mapping after unlinking
its temporary name. The checkpoint revision comes from the same read as its bytes. A running
branch’s live publication supersedes its stored checkpoint before that checkpoint is opened.
Keyspace validation compares borrowed native keys for uniqueness and records their sizes; only
the next archive group is converted into owned key parts. A stored window validates its header,
sequences, identities and delayed removals, then indexes its native column sections. It decodes
one native group at a time to determine the canonical archive boundaries, releases those decoded
columns, and subsequently reads only sections overlapping each input or argument group. The
descriptor and every group’s order, bytes, length and digest use the same archive contracts as
capture of a live publication.
The drain uses the shutdown admitted-work view: active intake and generators, active source ACK
roots, relay and node buffers, relays that admitted a batch while the node read that view, and
emitter buffers or publishes. Parked REQUIRED WAIT messages and the rows a window retains until
it advances are exempt: their acknowledgements wait for input the paused domain does not admit,
and the cut captures them as state. Once every node reports no admitted work, the leader requests a confirming force-flush
generation on every node and waits for its obligations to finish before asking owners to capture.
An unavailable sink keeps its publish and ACK counts outstanding until TIMEOUT expires; the
failed backup reports those counts and resumes the domain.
A leader-tenure change during a cut refuses the archive. The paused-domain recovery path releases
the old coordinator’s lease and resumes the domain under the new leader. A client that retries the
same execution reference may complete a new cut under that leader.
The server renders each domain’s models as NSPL, parses the rendered text back, and refuses the backup unless it yields exactly the committed models. A resource version’s bytes are read from the leader’s resource store and checked against the size the catalog records.
A completed backup reports the archive it wrote:
backed up the cluster: 2 domains, 3 users, 48213504 bytes, digest 9f86d081…; archive written to '/var/backups/nervix/cluster.nvxb'
The client writes the archive into a private file beside the destination, with permissions that
only the owner may read and write (0600), and replaces the destination only once every byte
arrived and matched the archive’s size and BLAKE3 digest. A destination never holds a partial or
foreign archive.
From The Command Line
nervix-cli backup runs one backup and exits:
nervix-cli backup cluster --output cluster.nvxb
nervix-cli backup domain payments --output payments.nvxb --without-resources
nervix-cli backup domain payments --output payments.nvxb --timeout 30s
nervix-cli backup domain payments --output live.nvxb --without-pause
nervix-cli backup cluster --output config.nvxb --without-state
nervix-cli backup domain --output - > current-domain.nvxb
nervix-cli backup cluster --output cluster.nvxb --format json
| Argument | Meaning |
|---|---|
cluster or domain [NAME] | What the archive covers. domain without a name covers --domain. |
--output PATH | Where the archive is written. - writes it to standard output. |
--without-resources | Leaves resource version bytes out of the archive. |
--without-state | Captures configuration only. |
--without-pause | Captures published runtime state without quiescing a running domain. |
--timeout DURATION | Bounds the normal quiesced capture of each running domain. |
--backup-wait-timeout DURATION | Bounds the overall command wait across all cuts, redirects and reconnects. Defaults to 10m; also applies to NSPL through --command and the REPL. |
--execution-reference REFERENCE | Recovers the same backup under its durable reference, with the original selected domain, scope, resources and capture options. The output destination may change. |
--format text or --format json | How the report is printed. Text is the default. |
When the archive goes to standard output, the report goes to standard error, so standard output
carries only the archive. The command exits with a nonzero status whenever the archive was not
delivered. With --format json the report is one JSON document:
{
"execution_reference": "0192d4e4-7b36-7c3e-9f00-5b2d8c3a1e44",
"output": "cluster.nvxb",
"total_bytes": 48213504,
"blake3": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"captured_at": "2026-09-27 12:00:00 UTC",
"resources": "included",
"users": 3,
"domains": [
{ "domain": "payments", "revision": 812, "cut": { "kind": "quiesced", "engaged_at": "2026-09-29 10:00:00 UTC", "released_at": "2026-09-29 10:00:01 UTC", "buffered_records": 0, "buffered_bytes": 0, "dropped_records": 0, "rejected_records": 0 }, "sections": 7, "section_bytes": 48190112 }
]
}
A failure prints {"error": {"code": "...", "message": "..."}} instead. The codes are
INVALID_ARGUMENTS, CONNECTION_FAILED, BACKUP_FAILED for a failure between client and
server, BACKUP_REFUSED for a backup the server refused, WRITE_FAILED for an archive that did
not reach standard output, and CLEANUP_FAILED for an archive that reached standard output while
its staging directory could not be removed.
Delivering To Standard Output
With --output -, the CLI first makes sure standard output can receive the archive. It then
downloads the archive into a staging directory of its own under the system’s temporary directory
(TMPDIR on Unix), as a file only its owner may read and write (0600), and verifies it there
before it copies a byte to standard output. The complete download releases the server’s copy of
the archive, so from then on the staged archive is the only one. The copy goes through a
descriptor of the CLI’s own for standard output, without a buffer, so every write reaches the
operating system and every failed write is reported.
- A standard output that would discard the archive fails with
WRITE_FAILEDbefore anything is staged or sent: the null device, which accepts every byte and keeps none, and a standard output that was closed when the CLI started, where the CLI finds the null device instead. The CLI cannot tell the two apart and refuses both. No backup was taken, so the report names no execution reference; give the CLI a pipe, a file or a terminal and run the backup again. - A staging directory that cannot be created fails with
WRITE_FAILEDbefore the backup is sent. No backup was admitted, so the report names no execution reference. - A delivery that fails after the download fails with
WRITE_FAILED: a reader that closed the pipe, a full disk behind a redirection, or a staged archive that could not be read. A closed pipe does not end the CLI throughSIGPIPE; its failed write is reported like any other. Standard output may hold part of the archive, and the report goes to standard error as always. The CLI keeps the verified archive in its staging directory, and the report names it together with the backup’s execution reference: JSON addserror.execution_referenceanderror.archive, and text ends withrecover backup REFERENCE from its verified archive at 'PATH'; .... - When every byte reached standard output but the staging directory could not be removed, the
command fails with
CLEANUP_FAILEDand names the execution reference and, in JSON, the directory aserror.staging. The archive on standard output is complete; remove the directory, which may still hold a copy of it.
Recover an undelivered archive by delivering the kept file, for example by moving it to a working
destination. It is the archive the backup assembled, with the summary and cuts its execution
reference records, so no new capture is needed. Running the backup again with
--execution-reference returns that recorded outcome, but its download is refused as
NotRetained, because the first complete download collected the archive. Nothing removes a kept
archive: delete it once it is delivered, and treat it as the secret it is.
nervix-cli backup cluster --output - --format json | ssh backup-host 'cat > cluster.nvxb'
# {"error":{"code":"WRITE_FAILED","message":"the backup archive could not be written to standard output: Broken pipe (os error 32)","execution_reference":"0192d4e4-7b36-7c3e-9f00-5b2d8c3a1e44","archive":"/tmp/.tmpQ2x9Lk/backup.nvxb"}}
mv /tmp/.tmpQ2x9Lk/backup.nvxb cluster.nvxb
Waiting And Recovering
The native client uses one bounded overall command wait for BACKUP, ten minutes by default.
--backup-wait-timeout sets it independently of each domain’s --timeout, without counting or
predicting domains. Native wait options accept durations from one millisecond through 24 hours.
Redirects and reconnects share the same deadline and execution reference. Ordinary request and
retry deadlines remain separate; an archive download begins after the command completes and
bounds each frame’s wait with the ordinary request timeout.
When the command wait expires, admission may already have happened. The CLI exits unsuccessfully
and reports the durable reference. A JSON BACKUP_FAILED report includes
error.execution_reference for an uncertain command or failed archive download. Recover promptly
with the same selected --domain, scope, resource inclusion and capture options:
nervix-cli --domain payments backup cluster --output cluster.nvxb --timeout 30s --backup-wait-timeout 4m --format json
nervix-cli --domain payments backup cluster --output recovered.nvxb --timeout 30s --backup-wait-timeout 10m --execution-reference 0192d4e4-7b36-7c3e-9f00-5b2d8c3a1e44
Use the reference returned by the first command. Recovery waits for the applying command or
returns its recorded outcome, then downloads the retained archive. --output - may also be used
for recovery. A WRITE_FAILED report that names a kept archive follows a complete download, which
already collected the server’s copy, so it is recovered from that file instead; see
Delivering To Standard Output. Changing capture inputs
conflicts with the bound request. A fresh reference starts a separate backup. Expiration of the
client wait ends only that waiter; it does not cancel the admitted command or extend the server’s
retry validity or archive retention. A longer wait can therefore recover a terminal outcome whose
archive is already unavailable, especially when the server has a shorter retry validity. Such a
download is reported as a failure with its reference.
Downloading The Archive
The leader assembles the archive in its staging area and retains it under the backup’s execution
reference. The client then downloads it through the session service’s DownloadBackup streaming
call, which the web console reaches through a console WebSocket of its own;
Sessions describes the call.
- The archive is retained until a download receives all of it, or until the retry validity of the backup’s execution reference ends, 15 minutes by default; see Command Completion.
- A download is not bounded by the request timeout, because an archive may take far longer to transfer than a command takes to run. Each frame must arrive within the request timeout instead.
- A download that fails in transport starts again from the first byte. Running the backup again with the same execution reference returns its recorded outcome, and the client downloads the archive again while it is retained.
- The first download that receives the whole archive releases it. A later download of the same backup is refused, and a new backup is needed for another copy.
- Only the user who ran the backup may download its archive.
- The archive stays on the node that assembled it. A download sent to another node that does not hold it is redirected to the leader.
- A node that restarts loses the archives it retained, and their downloads are refused. If leadership moves while a backup runs, the new leader runs the backup again under the same execution reference and retains the archive it assembles.
Inspecting An Archive
DESCRIBE BACKUP './cluster.nvxb';
DESCRIBE BACKUP './cluster.nvxb' FORMAT JSON;
DESCRIBE BACKUP runs in nervix-cli against a file on its own machine and contacts no server. It
reads the whole archive and verifies every section’s length and digest before it prints anything.
The description names the archive format, the Nervix and NSPL releases that wrote it, the cluster,
the capture time, the scope, whether resource bytes are included, and the users of a cluster
archive. For each domain it names the revision and Raft log entry the domain was read at, its
status, cut kind, pace and start count, the size and digest of its models, and each resource version with the
root_checksum and manifest_checksum that DESCRIBE RESOURCE prints for the same version.
It also reports the latest start point, clock mapping and logical frontier, and inventories WASM guest saves by processor and branch fingerprint, Kafka positions by
topic and partition, branch lifecycle records by processor and branch count, and materialized relays
by schema fingerprint, revision, fence, branch generation, row count and bounded Arrow groups.
Deduplicator keyspaces are listed by branch fingerprint with their key count, groups, revision and
schema fingerprint. Window processor state is listed by branch fingerprint with its retained rows,
groups, branch incarnation, next admission sequence, pending delayed histogram removals, revision,
schema fingerprint and window model digest. The inspection does not print branch-key field values
or deduplicator keys.
backup: ./cluster.nvxb
format: 1
producer_version: 0.1.0
language_version: 0.1.0
cluster: nervix-3f1c
captured_at: 2026-09-27 12:00:00 UTC
scope: cluster
resources: included
users: default,operator
domains:
- domain=payments revision=812 raft_term=4 raft_index=812 status=RUNNING pace=unpaced start_version=2
cut: quiesced
models: bytes=4213 blake3=5d41402abc4b2a76b9719d911017c592…
resource_versions:
- resource=proto version=1 state=completed root_checksum=… manifest_checksum=… file_count=3 total_bytes=18204 archive_bytes=24576 created_by_node=node-1 created_at=2026-09-20 08:15:00 UTC archive=included blake3=…
runtime_state:
- deduplicator=unique_payment_filter branch=4f2a… keys=1204 groups=1 revision=37 schema_fingerprint=…
- window_processor=latency_window branch=4f2a… rows=1 groups=1 incarnation=1 next_sequence=6 delayed_removals=4 revision=12 schema_fingerprint=… model=…
An archive that fails verification is refused whole, with the section and the check that failed.
Restoring
RESTORE CLUSTER FROM '/var/backups/nervix/cluster.nvxb' ON EXISTING USER SKIP;
RESTORE DOMAIN payments FROM './payments.nvxb';
RESTORE DOMAIN payments FROM './payments.nvxb' RESUME;
RESTORE DOMAIN payments AS payments_copy FROM '/var/backups/nervix/cluster.nvxb';
RESTORE DOMAIN payments AS payments_copy FROM './payments.nvxb' DRY RUN;
RESTORE DOMAIN payments FROM './payments.nvxb' WITHOUT SOURCE OFFSETS;
RESTORE CLUSTERrecreates every domain and imports every user of a cluster archive. It refuses a domain archive.RESTORE DOMAIN <name>recreates the archived domain<name>of a cluster or domain archive, and imports no users.AS <new_name>restores it under another name, which is how a domain is copied beside its original in the same cluster.ON EXISTING USERdecides what a cluster restore does with an archived user the cluster already has.FAIL, the default, refuses the restore before it changes anything.SKIPkeeps the existing user and its password.REPLACEgives the existing user the archived password hash. Every other archived user is created with its archived password hash, so its original password authenticates.DRY RUNreceives and verifies the archive and plans the whole restore, and changes nothing.RESUMEmakes each restored domain running after its complete state set is published on every assigned owner and replica. Its start generation and latest start point stay exactly as archived. Omit it to leave the restored domain stopped. It precedesON EXISTING USERandDRY RUN.WITHOUT STATErestores the configuration and purges state for the target domain.WITHOUT SOURCE OFFSETSrestores WASM, branch lifecycle, materialized, deduplicator and window state but starts sources without the archived Kafka positions.- A state section whose entity is absent or whose schema fingerprint differs from the published schedule is skipped. The restore succeeds and reports a warning naming the skipped state in its command diagnostics and CLI output; that entity starts without the skipped checkpoint.
- Every other archived branch key must be a key of the branching its restored entity declares: each branch lifecycle entry, the branch each WASM, deduplicator and window descriptor names, and the branch of each materialized record identity. See Archived Branch Keys.
- A window whose restored window processor is not the archived window model, or whose branch incarnation is not the one the restored branch lifecycle holds for that branch, is skipped with a warning naming the reason, and that branch’s window starts empty.
- A verified state record with an unknown kind tag or unsupported record version is also skipped with a warning. The archive reader still rejects malformed records of a supported kind, and always validates section lengths and digests.
- The path names a file on the client’s machine. A leading
~/refers to the client user’s home directory. In the web console it names the archive file the operator chose.
A fresh cluster already has the user its configuration creates when the cluster starts, and a
cluster archive holds that user too, so restoring a cluster archive into a fresh cluster takes
ON EXISTING USER SKIP to keep the fresh cluster’s password, or ON EXISTING USER REPLACE to take
the archived one.
RESTORE runs in nervix-cli, in the Rust and C clients, and in the web console, which streams an
archive file the operator chooses; see From The Web Console. It must be
sent alone: not in a batch with other statements and not while a transaction is open.
What A Restore Recreates
- Users. Each archived user, with its password hash exactly as the archive holds it.
- Domains. Each restored domain is initially created stopped, with its archived pace and placement
default, start generation and latest start point. Complete publication releases its installation
gate. With
RESUME, that same consensus effect changes it to running, without a newSTART.LIST DOMAINS,DESCRIBE DOMAIN, restore reports and dry-run reports expose the status and start generation;DESCRIBE DOMAINalso exposes the start point and installed mapping. - Resources. Each resource the domain declared, and each completed version under its archived
number, with its checksums, file count, sizes, creation time and creating node as
DESCRIBE RESOURCEshowed them in the source cluster. A number the source assigned to an upload that failed or was still installing when the backup read it stays a gap, and the next upload of the resource receives the number the source would have assigned next. See Resource Versions. - Models. Every model of the domain, bound to exactly the resource versions it was bound to in
the source cluster.
SHOW CREATEprints each restored model as it printed it in the source.
A restore stages the archive’s compatible branch lifecycle, WASM guest checkpoints, Kafka
positions, materialized relay checkpoints, deduplicator keyspaces and windows on every newly
assigned owner and replica after its models are scheduled. Each
node validates the complete staged inventory, synchronizes its generation namespace, then
atomically selects it through one durably synchronized active-generation pointer. Nodes without assigned checkpoints publish an empty
set. In-memory state handles are cleared only after that publication. A replicated installation
gate prevents START until every target node has published the complete set; it survives failure,
lease release and node restart. A source uses its restored next offset when it starts, clamped to
the partitions its current source assignment contains and clamped to their live low and high
watermarks. Materialized state binds to the exact restored schema fingerprint and archived start
generation before a branch or generator starts. Its rows, typed keys, watermarks, revision and
branch generation are preserved. A new assignment establishes its own ownership fence.
A deduplicator branch restores every archived key under the restored schema fingerprint, with the
time the key was first seen in the source cluster. Each key is normalized as the restored
DEDUPLICATE ON expressions key the values they produce, so a duplicate of an archived message is
suppressed once the restored domain runs, and the key expires MAX TIME after its archived first
sighting rather than after the restore. A window branch task re-admits the archived rows in their
admission order when it starts, which rebuilds every aggregate structure, including sketches, and
then restores each linear histogram’s pending delayed removals. A window that was partly filled at
the cut therefore closes over rows from both sides of the backup. The window must still be the
archived window model, and its branch must be the lifetime the restored branch lifecycle holds:
otherwise the restore reports a warning and that branch’s window starts empty. Branch keys,
admission sequences, watermarks and aggregate argument values are preserved exactly.
Archived Kafka positions follow acknowledged records, and a window acknowledges a row only when it steps past it. The positions of a domain that reads Kafka records into a window therefore stand before the rows its windows retain at the cut, and a source that resumes from them reads those rows again, which the restored window then admits beside the rows it restored. A restart has the same effect; see Shutdown And Recovery.
For a paced RESUME, the archived wall/logical origin and time rate are installed verbatim. The
mapping projects elapsed downtime; the leader chooses a fresh clock authority for this cluster.
The archived frontier is inspection metadata, rather than a new clock origin. TIMESTAMP AT
admission follows the projected reached windows. A paced archive without its committed mapping
cannot resume and is refused before admission. An unpaced resume needs no clock mapping.
A normal START after a default stopped restore, or after STOP, advances the start generation
and clears materialized state. It preserves compatible WASM state, source positions, branch
lifecycle, deduplicator keys and windows. To establish a new clock at the saved logical frontier instead, leave the domain stopped
and use START AT '<frontier>' TIME RATE <rate> explicitly.
Archived Branch Keys
A restore installs an archived branch key only when it is a key of the branching its restored
entity declares in the schedule the restore publishes. A processor runs every branch under its one
declaration, and a relay’s materialized records carry its declared branch. An ingestor or a
reingestor keeps one branch lifecycle for all of its routes, and each route writes the branch of
the relay it targets, so a key it archived may belong to the branching of any of those relays,
unbranched included. A key belongs to a branching when it is absent exactly where the branching is
unbranched, and otherwise holds exactly the fields of the branch’s key schema, each with a value of
exactly the field’s declared type: the same integer width, a float that is finite, a datetime that
reads as RFC 3339 text, and an ARRAY of its declared length. A key lists its fields in field-name
order, the order the archive reader requires. No value is converted to fit, and a key that does not
belong is never staged, so no installed checkpoint names a branch the restored entity’s branch
routing could not reach.
Planning checks every branch lifecycle entry and the branch of every WASM, deduplicator and window
descriptor whose entity the planned schedule holds under the archived schema fingerprint; a section
the restore skips is not checked. The conversion of a materialized relay checks the branch of each
record identity as it reads it. A dry run runs the same checks, and a restore WITHOUT STATE
installs no archived key and checks none. A key that does not belong refuses the whole restore
before it changes anything. The refusal names the archive section, the lifecycle entry, descriptor
or record identity, numbered from 1, the entity, the branches it runs in and, for a key of another
shape, the field that is missing, undeclared or of another type. It never includes a key value,
which a branch schema may declare SENSITIVE:
restore refused: the branch key of lifecycle entry 1 in archive section 'domains/payments/state/branch_lifecycle/deduplicator/unique_payment_filter/branches.rkyv' does not belong to the branching of deduplicator 'unique_payment_filter' in domain 'payments': it is not a key of branch 'by_tenant': field 'tenant' does not hold a value of its declared type: expected STRING, found I64
Installation checks the same keys against the schedule it installs under before it stages any state, so a restore resumed by a new leader or a retry stages none that does not belong either.
Publishing The State Generation
A node writes restored checkpoint bytes into an installation-specific namespace in chunks of at most 64 KiB. A completed checkpoint has a revision, length and BLAKE3 digest and an authority-bound receipt; incomplete chunks have no completed receipt. Guest saves stream directly from the staged archive on the coordinator and from the sealed upload file on a remote node. Neither path assembles a guest save in a second full-size buffer or reserves a file-read chunk inside an already reserved full checkpoint. Materialized payloads use archive-owned rkyv descriptors and scalar identity records with exact-schema Arrow IPC column sections. Planning, including a dry run, verifies their Arrow schema, row count and unique typed branch identities. Conversion stages bounded native header, identity and column pieces, then concatenates them into a quota-owned file with a 64 KiB buffer. That file uses the same streamed publisher as a guest save, including when one relay’s container exceeds the 32 MiB bulk budget. It never enters encoded metadata installation. Temporary conversion pieces and the completed container can briefly occupy twice the container’s disk space. Deduplicator and window sections follow the same boundary, including a keyspace or a window whose native checkpoint exceeds the 32 MiB bulk budget. Planning, including a dry run, and installation decode every key group, input group and argument group under the exact Arrow schema the restored models give it, one bounded group at a time on the bulk CPU workers. Each decoded group is charged to bulk memory only while it converts. Conversion checks the key and row counts, rejects a key that appears twice, and checks each delayed histogram bucket against the restored histogram’s bucket count.
Every Arrow section of an archive is scanned before Arrow’s reader reads it, whatever digests the archive carries for its own bytes. A section whose stream is not framed within its bytes is refused as misframed, and one that declares a field type Nervix does not carry, or a record batch at odds with its own schema, as undecodable, when planning reads it, a dry run included. Nothing is allocated from a length the section only declares.
A deduplicator conversion holds the keyspace as the runtime holds a restored one: every key
normalized as the restored DEDUPLICATE ON expressions key it, in an expiry map. That is how a key
the archive holds twice is found. The parts and values of the keys follow their Arrow columns, and
are charged to the restore_metadata class as each group is admitted; a conversion that class
cannot hold now is refused rather than kept waiting. The fixed share of every key, its map entry
and its serializer resolver, was admitted with the archive description. The native checkpoint then
streams from the resident keyspace into a quota-owned file through a 64 KiB buffered writer under
the fixed 2 MiB bulk grant, converting one entry at a time. It is the same encoding the runtime
writes for the same keys.
A window conversion seals each archived group’s input rows, and then its argument columns, into quota-owned pieces as soon as each is admitted, so it holds one archived section and its decoded batch at a time and never the whole window. When a group’s row identities fit one 1 MiB identity record, its archived input and argument sections become the checkpoint’s Arrow sections unchanged. Otherwise the group splits into groups that fit, whose columns are projected again. The window header, which counts the rows and the nested containers’ bytes, is written last and placed first; the delayed histogram removals follow in bounded typed sections. The pieces are then concatenated into the checkpoint file with one 64 KiB buffer, and briefly occupy twice the checkpoint’s disk space.
A conversion the node refuses only for room does not fail the restore. A memory class with no room left, a worker queue that is full and a staging quota that cannot hold another piece judged nothing of the archive, so the conversion releases what it holds and converts the refused unit again. For a window the unit is one section: it is read and decoded again into a checkpoint that is exactly as it was before the refused attempt, whose staged pieces were dropped. A window refused while its sealed pieces are finished into one checkpoint is converted again from its first group. For a keyspace the unit is the keyspace from its first group, because the keys already admitted are what held its charge. A unit is converted again every 50 ms for up to 30 seconds after the node first refuses it. A refusal that outlasts that wait ends the conversion and is reported as the node having had no room for it, since what the restore itself holds can be what fills the budget. A request larger than a whole budget, and archived content that does not fit the restored shapes, end it at once.
Backup section openings share Snapshot admission with materialized readers. A capacity-only refusal retries within the opening’s single 30-second deadline; other failures end the fetch. Completed response streams release their transport permits before local verification and decoding.
Publication checks each receipt, checkpoint header and ordered chunk against the complete expected inventory. It synchronizes those writes before committing the small pointer containing the exact installation authority and inventory, then synchronizes that pointer before clearing runtime handles or acknowledging publication. Cancellation or failure on either side of pointer publication leaves the replicated start gate closed. An exact retry repeats pointer durability and cleanup; it cannot select different counts or a different authority under the same generation.
Every read selects the pointer, checkpoint headers and chunks from one database snapshot. Readers that opened a view before publication keep its complete prior set while obsolete keys are deleted. Checkpoint jobs retain the namespace they selected before execution and compare it again under the installation barrier; a queued job for another namespace cannot write into the restored set. Normal checkpoint writes and replica installation use the selected namespace. Generation identity is separate from a WASM branch’s guest-state lifetime and its checkpoint revision.
Successful publication removes obsolete namespace keys and completed or abandoned receipts through bounded deletion batches. A snapshot can retain the data it still reads after that deletion. Within the selected namespace, whether initial or restored, the same maintenance owner removes chunk sets whose selected header is absent, inline, or names another checkpoint revision. Segmented replacement and incomplete streamed revisions therefore use the same liveness check. Ordinary checkpoints, replica installation, handoff, forced recovery, module rebinding and entity or stale-identity purge therefore release unreachable restored payloads without requiring another restore generation. Existing snapshot readers retain complete values through Fjall’s snapshot retention. Every node runs restore checkpoint maintenance at startup and then one second after each completed sweep, independently of leadership. It borrows one applied consensus revision and then takes the same installation barrier as checkpoint writers and publishers. A generation is retained while its exact installation belongs to an applying restore, even when leadership or its mutation lease has changed. A generation ahead of the node’s applied log is retained until catch-up establishes its outcome; a node without an applied log retains every generation. The selected durable publication is always retained. Terminal, expired, missing and superseded attempts become reclaimable once the node has applied their generation. Cleanup covers headers, revision indices, receipts and chunks, including chunks written before a receipt existed. It uses bounded deletion batches and synchronizes completed deletion; cancellation or restart resumes from remaining keys. Cursors seek past initial headers, indices and receipts and selected headers/indices; deletion also seeks past retained applying or future generations. In the selected chunk namespace, one header check selects each placement/revision set, and a cursor skips the whole referenced set. Unreferenced sets use the same bounded deletion batches. Checkpoint writers, purges and pointer publication share the installation barrier, so the selected view stays authoritative during cleanup. Readers keep their complete database snapshots. Reclamation never releases the start gate or changes the selected publication.
A segmented header consists only of its fixed archived root. Maintenance checks the value size before copying or decoding that bounded header; a larger valid current value is an inline checkpoint. Fjall’s ordinary LSM size query can still read and cache the underlying value. Database reads, caches, memtables, snapshot retention and compaction are separate allocation owners; the fixed 2 MiB maintenance reservation bounds its caller-owned buffers rather than total database memory. Runtime storage requires its current format marker. A missing or invalid marker in nonempty checkpoint storage fails with an instruction to recreate the node state directory.
Local archive readers and received upload readers retain their staged artifact and disk quota through the storage job, including cancellation.
All checkpoint installation and generation-publication jobs reserve 2 MiB of bulk memory per node, independent of checkpoint bytes and count. Native lifecycle and Kafka conversion writes the current checkpoint encoding directly to a quota-owned file, using a 64 KiB buffered writer; it never collects converted entries or complete encoded output. The same file source drives local installation and ordered 64 KiB transfers to remote owners and replicas. Completed native files retain their actual length, digest and rounded disk quota until the final reader exits.
Restore preparation has a separate, per-node restore_metadata admission class, defaulting to
2 GiB. Before decoding, an admitted 512-byte read validates the first physical tar header as the
regular manifest.rkyv entry and checks its length against the record limit; the manifest
then verifies off the async workers under its own charge. A verified manifest sizes the retained
preparation reservation: 16 times encoded record bytes (including the manifest), 64 times NSPL
bytes, and 2 MiB of fixed overhead. Resource archives, guest saves and the materialized,
deduplicator and window Arrow groups remain on disk; their conversion is charged separately. The charge
covers overlapping owned archive values, aligned decode buffers, model parsing and planning
copies, branch text and typed-key conversion, and native serializer resolvers. Once the
description is decoded, the reservation also grows by the fixed share of every key the archive’s
deduplicator descriptors count: the key’s entry in the resident keyspace and its resolver in the
streamed checkpoint’s serializer scratch. A window descriptor’s record already carries each
retained row’s watermarks, so its record charge covers the per-row identities a window conversion
builds one group at a time. Admission refuses unaddressable estimates or unavailable capacity
before a restore changes the cluster. Parsing and the pure restore planner run as admitted bulk
CPU work.
The description, parsed Models and reservation have one shared lifetime. A native encoding job
retains that owner through cancellation; each entry is converted separately, and fixed-capacity
serializer scratch holds entry resolvers plus the largest entry’s nested resolvers. Resolver
scratch therefore depends on entry count, while strings and encoded checkpoint output stream.
The 2 MiB bulk charge measures bounded I/O and storage working memory, and does not describe total
restore memory. Physical checkpoint placement encodings remain limited to 60 KiB. Database caches,
memtables, compaction, and live-state materialization at START keep their respective owners and
allocation bounds. The generation publisher has no aggregate-payload admission limit.
Materialized capture row views and grouping arrays have an 8 MiB metadata ceiling per relay and are charged before capture. Archive identities are at most 1 MiB per group, and Arrow columns at most 8 MiB. Typed branch uniqueness conversion has an independent 8 MiB commands reservation; native restore row views have an independent 8 MiB relay reservation. Decoded columns are charged cumulatively to relay memory through installation; loading refuses further groups when that class is full. Row projection and Arrow encoding share one admission and one bounded bulk CPU job, so projected columns never wait for a second grant while retaining the first. Typed native identities are also converted only after their allocation is charged. Arrow decoding is admitted one section at a time on the bulk CPU workers. Native checkpoint readers keep one database snapshot and at most one 64 KiB stored chunk across all their section reads. Ordinary materialized sealing also writes bounded pieces to quota-owned disk; cross-node generator reads stream the sealed file, and periodic persistence replaces a segmented checkpoint only after its chunks are durable. Those paths support a container above the default bulk budget. The ownership-handoff metadata boundary remains bounded by its resident-entry admission.
Restore Checkpoint Storage Quota And Metrics
nervix-server --restore-staging-max-bytes <bytes> and
NERVIX_RESTORE_STAGING_MAX_BYTES set the node-local unpublished checkpoint limit, defaulting to
128GiB. This is separate from the quota for archive and transfer files. Admission counts the
current key and value bytes of every unpublished restored namespace across all domains, including
partial chunks, headers, indices and receipts. It measures value lengths without assembling
payloads. The complete incoming checkpoint footprint must fit before the first chunk is written;
the installation barrier prevents concurrent jobs from oversubscribing the allowance. An exact
staging retry first removes its own incomplete checkpoint data and reuses that allowance.
A node measures that usage by reading every unpublished entry once, and keeps the measurement for as long as it stays exact. A checkpoint that stages completely adds its admitted footprint, which is exactly the keys and values it wrote. Whatever else changes what the measurement counts forgets it: a staging attempt that fails after its first chunk or replaces an earlier attempt at the same checkpoint, a sweep that reclaims a namespace or leftover receipts, and a publication. The next admission or sweep then measures again, and so does the first one after a node starts. Admission and an idle sweep therefore read no staged entry, however much another restore left unpublished on the node, and a restore staged beside a generation that is still retained costs what it stages itself. The measurement is taken, kept and changed only under the installation barrier. Published checkpoint data is outside this quota; leftover receipts remain staging until removed. Lowering the allowance on restart preserves retained installations that already exceed it and refuses additional staging until usage fits or those attempts end. A quota refusal reports the limit, used bytes and requested bytes and leaves the target stopped with its installation gate closed. After terminal failure, maintenance reclaims the attempt. Use the retained execution reference to recover an uncertain applying command; a terminal failed restore requires a fresh target domain for another restore.
The public /metrics endpoint reports these node-local series after completed sweeps:
| Series | Meaning |
|---|---|
nervix_restore_staging_bytes | Current unpublished checkpoint key and value bytes |
nervix_restore_staging_keys | Current unpublished checkpoint key count |
nervix_restore_staging_limit_bytes | Configured unpublished checkpoint allowance |
nervix_restore_staging_reclaimed_bytes_total | Unpublished and unreferenced active checkpoint key/value bytes removed by completed sweeps since node start |
nervix_restore_staging_sweeps_total | Completed sweeps since node start |
nervix_restore_keyspaces_disk_bytes | SST allocation of shared checkpoint and publication keyspaces |
Logical usage excludes tombstones, database overhead and the bytes older snapshots retain. The SST allocation includes initial and published checkpoints, retained snapshot data and space awaiting compaction. Fjall reports its current LSM levels; files awaiting final unlink need not appear. It excludes the shared journal and other keyspaces. This quota bounds live unpublished data and does not impose a hard filesystem limit. Physical space falls as snapshots close and database compaction reclaims files. A cancelled or failed sweep updates no series and does not count its partial deletion; the next completed sweep reconstructs current usage, because a sweep forgets the kept measurement before it removes anything it counts.
Order Of Steps
A restore first receives the archive and plans everything it will do. Nothing changes before both have finished:
- Receive. The client streams the archive to the leader, which stages it in its staging area and checks that it is exactly the size and BLAKE3 digest the client declared.
- Verify. The leader reads the whole archive and verifies its manifest, every section’s length
and digest, every record, and each resource version’s bytes against the version’s root
checksum. It parses the
models.nsplof every domain the archive holds, which must hold oneCREATEof a model per statement. - Plan. The leader resolves every archived user under the user policy, checks that no restored domain name exists, pins every model to the resource versions the restore imports, and plans each domain’s models with the transaction planner against the domain as the restore will create it. A dry run ends here and reports the plan.
The restore then applies its steps in order, and records each step in the replicated state as it completes:
- Import users, for a cluster restore.
- For each restored domain, in archive order:
- Create the domain, stopped, with its declared resources.
- Import its resource versions: each version’s bytes are installed from the archive on the leader and checked against the version’s checksums, the version is published under its archived number, and the restore waits until every live node has installed it, exactly as an upload completes.
- Apply its models as one batch, with full graph validation and the leader’s content
checks: TLS material loads, lookup data loads, WASM modules compile, inference models load,
and UDFs prepare. The batch is not bounded by the statement and source-byte limits of a
transaction. Before recording this step, admit an installation generation bound to the
leader tenure, restore execution and mutation lease. Stage compatible branch lifecycle,
source offsets, WASM saves and materialized generations on the newly scheduled owners and replicas, then publish the
complete set on every target node. Recording completion releases the replicated start gate.
With
RESUME, that same effect installs the archived clock mapping and makes the domain running; a delayed coordinator cannot republish after activation.WITHOUT STATEpublishes an empty set, andWITHOUT SOURCE OFFSETSomits source positions.
A completed restore reports what it recreated, and each step:
restored the cluster from '/var/backups/nervix/cluster.nvxb': 2 domains, 5 resource versions, 14 models, users 1 created, 1 skipped, 0 replaced; restored domains are stopped
- applied: import users
- applied: create domain 'payments'
- applied: import resource versions of domain 'payments'
- applied: apply models of domain 'payments'
- applied: create domain 'ledger'
- applied: import resource versions of domain 'ledger'
- applied: apply models of domain 'ledger'
A dry run reports every step as planned, and for each domain the report of its model run: the
transaction impact report the transaction planner produces for the
batch of the domain’s models.
Each domain also reports its planned or applied status and archived start_version.
A step that fails ends the restore. The steps before it stay applied, and the outcome names the step and the reason, followed by the report of every step:
restore failed at step 'apply models of domain 'payments'': ...; the steps before it stay applied
A domain the failed restore created stays stopped, with the resource versions and models it
committed. Its durable installation gate keeps START blocked if state installation did not
complete. Staging failures leave the complete previously published set visible on each node; a
failure during distributed publication may leave nodes at different complete generations, but
the domain cannot run. A restore never creates a domain whose name exists, so restore the archived
domain again under another name with AS. A cluster restore that failed before it reached some
domains leaves them for RESTORE DOMAIN.
Retries, Disconnects And Leader Changes
A restore is a persistent command under its execution reference, like any command that changes the cluster; see Command Completion. The leader admits it once the archive is verified and planned, and from then on it holds the mutation lease of every domain it restores, so no other command changes those domains while it applies.
- A restore sent to a follower is redirected to the leader, and the client sends the archive there.
- Once the leader has received the whole archive, the restore goes on when the client disconnects.
- Sending the restore again with the same execution reference joins it: while it still applies on the leader, the answer says so at once, and once it finished, the answer is its recorded outcome and report. The Rust client sends it again until it has the outcome.
- A restore stream the client abandons before the archive arrived whole changes nothing, and releases the staging space it reserved. Sending the restore again with the same execution reference sends the archive again from its first byte.
- Only the leader the archive was streamed to holds it. If leadership moves, or the leader restarts, while a restore applies, the restore stays applying, and the next leader resumes it from its first step not recorded once the client sends the archive again with the same execution reference. The Rust client does so when it is redirected or its connection fails. A step is recorded together with its effect, so no step is applied twice. Resuming an unfinished state installation admits a new generation. Local and remote staging and publication revalidate authority at the storage mutation boundary; a delayed request from the preceding coordinator cannot replace the completed generation or clear its runtime handles.
- If no client sends the archive to the new leader before the retry validity of the execution reference ends, 15 minutes by default, the new leader ends the restore as failed. The steps it recorded stay applied, and the outcome says how many.
The staged archive is released when its restore finishes, or when the retry validity of the execution reference ends.
Restoring From The Command Line
nervix-cli restore runs one restore and exits:
nervix-cli restore cluster --input cluster.nvxb --on-existing-user skip
nervix-cli restore domain payments --input payments.nvxb
nervix-cli restore domain payments --input payments.nvxb --resume
nervix-cli restore domain payments --as payments_copy --input cluster.nvxb --dry-run
nervix-cli restore domain payments --input payments.nvxb --without-source-offsets
nervix-cli restore domain payments --input payments.nvxb --without-state
nervix-cli restore cluster --input cluster.nvxb --on-existing-user replace --format json
| Argument | Meaning |
|---|---|
cluster or domain NAME | What the restore recreates: every domain and user of a cluster archive, or the archived domain NAME. |
--input PATH | The archive file to restore from. |
--as NEW_NAME | Restores the domain under NEW_NAME. Only for domain. |
--on-existing-user fail, skip or replace | The user policy of a cluster restore. fail when omitted. |
--dry-run | Verifies the archive and plans the restore, changing nothing. |
--resume | Makes the complete restored domain running at its archived generation and clock mapping. |
--without-state | Installs configuration without runtime checkpoints. |
--without-source-offsets | Installs WASM, branch and materialized state, and leaves Kafka source positions unset. |
--format text or --format json | How the report is printed. Text is the default. |
While the archive streams, the command shows how much of it was sent on standard error, when
standard error is a terminal. It exits with a nonzero status whenever the restore did not complete.
With --format json the report is one JSON document:
{
"execution_reference": "0192d4e4-7b36-7c3e-9f00-5b2d8c3a1e44",
"input": "cluster.nvxb",
"mode": "apply",
"message": "restored the cluster from 'cluster.nvxb': ...",
"total_bytes": 48213504,
"blake3": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"captured_at": "2026-09-27 12:00:00 UTC",
"users": { "created": 1, "skipped": 1, "replaced": 0 },
"domains": [
{
"source": "payments",
"domain": "payments",
"resource_versions": 3,
"models": 9,
"status": "STOPPED",
"start_version": 2,
"planned_models": null
}
],
"steps": [
{ "step": "import users", "outcome": "applied" },
{ "step": "create domain 'payments'", "outcome": "applied" },
{ "step": "import resource versions of domain 'payments'", "outcome": "applied" },
{ "step": "apply models of domain 'payments'", "outcome": "applied" }
],
"warnings": []
}
mode is dry_run for a dry run, whose steps are planned and whose domains carry their model
run’s transaction impact report in planned_models. A step’s outcome is applied, planned,
failed or not_attempted. users is null for a domain restore.
warnings lists state sections skipped because their record kind or version is unsupported, or
their entity or schema no longer matches the restored schedule. Text output prints the same
warnings after the steps.
A failure prints {"error": {"code": "...", "message": "..."}} instead. The codes are
INVALID_ARGUMENTS, CONNECTION_FAILED, RESTORE_FAILED for a failure between client and server,
RESTORE_REFUSED for a restore refused before it changed anything, and RESTORE_INCOMPLETE for a
restore that failed at a step. A RESTORE_INCOMPLETE error carries the restore’s report as
report, in the shape above.
From The Web Console
The web console’s Backups dialog takes a backup as a browser download and restores an archive
the operator uploads, with the same statements, options, reports, and failures as nervix-cli.
It runs on the console’s own session and follows its leader redirect; a BACKUP or RESTORE typed
in the console’s REPL runs through the same dialog. Web Console
describes the dialog itself.
- Backup. The console sends
BACKUPas a command under an execution reference, then downloads the archive with theDownloadBackupcall over a console WebSocket, from the node that reported the backup. It hands the archive to the browser as a download only once its size and BLAKE3 digest match the backup’s summary, under the last component of the file the statement names. A download that fails in transport starts again from the first byte while the archive is retained. A download the leader refuses ends with its reason, and the backup itself completed. - A reload resumes a backup. The browser tab records the backup’s execution reference, query text, and domain until its archive is downloaded. A reloaded page sends the backup again under that reference, which returns the recorded outcome instead of running the backup twice, and downloads the archive again while it is retained, unless the download the reload interrupted had already collected it by queuing its last bytes. Closing the tab or signing in as another user forgets the record.
- Restore. The console reads the chosen file once to measure its size and BLAKE3 digest, and
streams it with the
RestoreBackupcall over a console WebSocket to the leader. The dialog runs theDRY RUNform first: its report, and each domain’s planned model run drawn as an impact report, must plan the current options and file before the restore itself can run. The restore’s report lists each step as applied, failed, or not attempted, and the result lists the warnings for the state the restore skipped. - Retries. Every attempt of a restore sends the same execution reference, statement, and archive. A redirect streams it again to the leader, and a lost connection or an unknown outcome streams it again from its first byte, for about ten minutes; after that the console reports the outcome as unknown and names the reference.
- What the browser shows. The dialog shows the summary a backup reports and the report a
restore returns, and never what an archive holds beyond them.
DESCRIBE BACKUPruns only innervix-cli. - Sensitivity. The browser saves an archive with its downloads directory’s permissions, not the
owner-only file
nervix-cliwrites. A downloaded archive holds password hashes, client secrets, and TLS private keys; keep it where only its owner can read it.
Archive Format
An archive is a tar stream. Every entry is a regular file with owner-only permissions and a zero modification time, and names longer than a tar header holds use GNU long-name entries. The manifest is always the first entry, so a reader validates everything that follows against it as the stream arrives.
| Section | Content | Present |
|---|---|---|
manifest.rkyv | The manifest record | Always |
users.rkyv | The users record | Cluster archives |
domains/<domain>/domain.rkyv | The domain record | Every domain |
domains/<domain>/models.nspl | The domain’s models as NSPL | Every domain |
domains/<domain>/resources/<resource>/<version>/version.rkyv | One resource version record | Every resource version |
domains/<domain>/resources/<resource>/<version>/archive.tar | The version’s original upload | Published versions, unless WITHOUT RESOURCES |
domains/<domain>/state/wasm_processor/<processor>/<branch>/descriptor.rkyv | Typed WASM checkpoint identity, schema fingerprint, generation and revision | Saved WASM branches unless WITHOUT STATE |
domains/<domain>/state/wasm_processor/<processor>/<branch>/guest.bin | Raw guest save bytes | With each WASM descriptor |
domains/<domain>/state/kafka_offset/<ingestor>/offsets.rkyv | Ingestor schema fingerprint and next offsets by topic and partition | Published Kafka domain offsets unless WITHOUT STATE |
domains/<domain>/state/branch_lifecycle/<kind>/<processor>/branches.rkyv | Typed branch keys, incarnations and LRU order | Published branch lifecycle unless WITHOUT STATE |
domains/<domain>/state/materialized_relay/<relay>/descriptor.rkyv | Domain/relay identity, raw schema fingerprint, revision, fence, branch generation, row and group counts | Every captured materialized relay unless WITHOUT STATE |
domains/<domain>/state/materialized_relay/<relay>/groups/<group>/identities.rkyv | Typed branch keys and low/high watermarks for the group’s rows | Every nonempty materialized group |
domains/<domain>/state/materialized_relay/<relay>/groups/<group>/columns.arrow | Exact-schema Arrow IPC columns in identity order | With each identity group |
domains/<domain>/state/deduplicator/<deduplicator>/<branch>/descriptor.rkyv | Deduplicator identity, schema fingerprint, typed branch key, revision, key count and group count | Every captured deduplicator branch unless WITHOUT STATE |
domains/<domain>/state/deduplicator/<deduplicator>/<branch>/groups/<group>/keys.arrow | Arrow IPC with one typed column per DEDUPLICATE ON expression and the seen_at timestamp of each key | Every nonempty deduplicator group |
domains/<domain>/state/window_processor/<processor>/<branch>/descriptor.rkyv | Window identity, schema fingerprint, window model digest, typed branch key, revision, branch incarnation, row sequences and watermarks, group count and delayed histogram removals | Every captured window branch with a branch lifetime unless WITHOUT STATE |
domains/<domain>/state/window_processor/<processor>/<branch>/groups/<group>/input.arrow | The retained input rows as Arrow IPC of the window’s input relay schema | Every nonempty window group |
domains/<domain>/state/window_processor/<processor>/<branch>/groups/<group>/arguments.arrow | Each retained row’s aggregate argument values, one nullable column per argument in demand order, row-aligned with the input rows | With each window input group |
Materialized group coordinates are zero-based decimal numbers padded to ten digits. A descriptor
with zero rows has zero groups. The archive reader accepts arbitrary section ordering, verifies
both sections of every group, and rejects missing, duplicate or inconsistent current groups.
Deduplicator and window groups use the same coordinates. <branch> is the branch key’s
fingerprint in hexadecimal, or unbranched. A deduplicator or window descriptor names at most as
many groups as it has keys or rows, and none when it has none; every named group must be present,
and a window group must hold both its input rows and its argument columns.
Every .arrow section is one canonical Arrow IPC stream: its schema message, one record batch
and the end-of-stream marker, which ends the section. Every message opens with the continuation
marker, and every length the stream declares, a message’s metadata, its body and each column
buffer, lies within the section. Its schema declares only the field types Nervix carries, without
a dictionary encoding, and its record batch declares the field nodes and buffers that schema’s
fields take. A restore refuses any other section before it decodes or allocates anything from it.
A resource named . or .. appears in a section path as %2E or %2E%2E.
A record section begins with a twelve-byte header: the magic NVXBKREC, then the record’s kind and
its format version, each a little-endian 16-bit integer. The rest is an
rkyv payload that a reader validates with bytecheck before it reads any value.
| Kind | Tag | Format version | Holds |
|---|---|---|---|
| Manifest | 1 | 2 | Archive format major version, producer and NSPL releases, cluster id, capture time, scope, whether resource bytes are included, each domain’s revision, Raft log entry and cut kind, and each section’s path, content kind, length and BLAKE3 digest |
| Users | 2 | 1 | Every user’s name and password hash as a PHC string |
| Domain | 3 | 1 | Pace, default placement policy, lifecycle status, start count, the point the latest start began from, the committed clock mapping, the logical instant the clock had reached, and each declared resource with its next version number |
| Resource version | 4 | 1 | The version’s domain, resource and number, its upload state, and for a published version its checksums, file count, sizes, creation time and creating node |
| WASM guest state descriptor | 5 | 1 | The saved branch key, schema fingerprint, guest state generation and checkpoint revision |
| Kafka domain offsets | 6 | 1 | The ingestor schema fingerprint and ordered topic and partition positions, expressed as the next offset to consume |
| Branch lifecycle | 7 | 1 | The owner kind, schema fingerprint, branch keys, last ingestion times and incarnations in LRU order |
| Materialized relay descriptor | 8 | 1 | Domain and relay names, schema fingerprint, revision, ownership fence, branch generation, row count and group count |
| Materialized group identities | 9 | 1 | Ordered row identities: an unbranched identity or typed branch fields, and low/high ingestion watermarks |
| Deduplicator keyspace descriptor | 10 | 1 | Domain and deduplicator names, schema fingerprint, branch fingerprint and typed branch fields, revision, key count and group count |
| Window state descriptor | 11 | 1 | Domain and window processor names, schema fingerprint, window model digest, branch fingerprint and typed branch fields, revision, branch incarnation, first and next admission sequence, each retained row’s low/high ingestion watermarks, group count, and each aggregate’s structure: rebuilt from the retained rows, or a linear histogram with its delayed removals as expiry time and bucket |
The archive format major version is 1. A reader refuses invalid record magic, an unsupported
manifest or required configuration record, a first entry other than the manifest, and any section
whose place, length or digest differs from what the manifest declares. Unknown optional state
record kinds and unsupported state record versions produce restore warnings and are skipped.
models.nspl holds one CREATE statement per model, separated by blank lines, in an order that
creates every model after each model its configuration names. Resource bindings name explicit
versions.
An archive holds password hashes, and its models and resources can hold credentials and other secrets. Store it as a secret.
Archive Fidelity Checks
The registered archive properties compare complete current manifests, users, domain lifecycle and clocks, resource catalogs, WASM descriptors and guest saves, Kafka offsets, ordered branch lifecycle entries, materialized generation descriptors and every identity group’s typed branch keys and watermarks. Valid synthetic Arrow groups include nullable, sensitive, nested, byte and floating-point columns. They exercise the production record validators, manifest-first tar writer, streaming reader and restore-content reader, checking every section’s membership, order, length, digest and bytes. Re-exporting verified extracted values produces the same archive bytes. Canonical NSPL documents also reparse to their complete ordered Models across multiple domains. Synthetic payloads cover empty data, tar block boundaries and long section paths; entry permissions remain owner-only.
Separate malformed-input targets check current record headers and fields, inconsistent metadata,
missing or reordered sections and truncated streams before verified contents reach installation.
Materialized cases also reject inconsistent row/group counts, missing identity or column sections,
invalid keys/watermarks and damaged Arrow bytes. Deduplicator and window properties compare
complete descriptors, typed branch keys, row sequences and watermarks, delayed histogram removals
and every Arrow group’s bytes. The restore conversion property streams every rebuilt keyspace and
checks it against the runtime’s own encoding of the same keys byte for byte, and rebuilds windows
both from archived sections kept whole and from groups split by a narrow identity record; their
malformed cases reject reversed watermarks, impossible
sequences and group counts, a zero incarnation, missing key, input or argument groups, misplaced
group numbers and descriptors that name more groups than the archive holds. The owning runtime column property separately
checks exact-schema encoding, restore conversion and complete native generation equality.
Schema agreement with the restored schedule remains the restore planner’s contract, exercised by
public restore scenarios. The one-node and three-node WASM/Kafka restore scenario compares every
archived field and raw resource/guest byte before the restored domain starts, then proves subsequent
START behavior and two-branch isolation. It asserts domain renaming, the stopped lifecycle and the
new guest lifetime explicitly; capture metadata belongs to each new backup. The materialized
one-node and three-node restore scenario also re-exports the stopped domain before START, comparing
every descriptor field, identity and Arrow byte, including generations larger than the bulk budget.
Its acknowledged materialized inputs retain an explicit event timestamp across retries. An
identical successful replay must preserve the exact revision count and all archived fields, even
when a failed earlier attempt reached only part of the relay fan-out.
Kafka offsets and branch lifecycle records are written by one streamed writer, whose output a
registered property compares byte for byte with rkyv’s encoding of the complete wire shape, and
two more properties stream records from stored native lifecycles and from Kafka offset checkpoints
in any partition order, with partitions recorded twice, comparing every archived value. The
native metadata backup scenario restores lifecycle and Kafka metadata above the bulk budget
stopped, interrupts its first re-export partway through a section, which publishes no archive, and
re-exports it again with every value identical while each node stays within its bulk budget. It
then restores that archive with RESUME and backs up the running domain, whose every lifecycle
branch and partition offset is the same.
The native metadata restore scenario also restarts the restored owner and replicas, preserves
their placements and exact stored values, and checks subsequent Kafka input and isolated output
for both tenants. just test-native-metadata-restore-repeat qualifies its three-node example ten
consecutive times with concurrency four and no retries. Each attempt retains its source revision,
source patch, scenario output and node logs under target/native-metadata-restore; only ten passing
examples produce a completion record. The same scenario remains registered in both complete
Deloxide selections, which additionally exercise the tracked blocking locks on the restore path.
The guest-save scenario uses two 40 MiB branch saves in both one- and three-node clusters. It
measures every owner’s bulk reservations during public backup, interrupts a guest section after
two chunks and verifies that no archive appears, then retries, restores with --resume, compares
the guest bytes exactly and checks the next even output of each branch. A mismatch names each
differing save with its lengths, digests, first differing offset and the bytes there, never the
whole saves. A resumed restore replays the inputs whose acknowledgements its archive does not hold,
so the exact comparison relies on every input of the source cut being acknowledged; a checkpoint of
a save this large can outlast the source’s ACK TIMEOUT under load, which the branch’s keepalive
of the inputs that checkpoint holds prevents from becoming a replay. See
WASM State And Recovery.
The deduplicator and window one-node and three-node scenarios re-export a stopped restore with
identical descriptors and Arrow groups, stop an installation before its first deduplicator or
window checkpoint, which leaves START gated, and release a delayed coordinator’s publication
after a resumed restore, which is refused without changing the active keyspaces and windows.
The running restore scenario re-exports its deduplicator state after the endpoint accepts a metric
probe and before admitting new payments, comparing exact descriptors and Arrow groups to the
source archive. That comparison includes each key’s original seen_at; the restored window’s
retained rows are checked by their subsequent output for both branches. A focused keyspace test
checks that a restored duplicate stays suppressed until its original three-minute deadline and is
accepted at that deadline. The public scenario observes eventual delivery after that boundary;
its subscription receive time is not an expiry timestamp because a loaded runner may delay the
first post or the read beyond the deadline.
RESTORE explicitly resets the source ownership fence to zero in the stored checkpoint; revisions
and branch generations remain exact. A stopped backup reads this checkpoint’s ordered groups from
the same immutable database view as its other state, stages one bounded group at a time and retains
the original Arrow bytes. A running or paused backup captures fresh runtime rows under their existing
assignment barriers. These representation
checks do not establish capture-fence or installation-ordering correctness, which retain their
production-owner concurrency and recovery evidence.
The registered keyspace and window properties additionally open admitted native checkpoint views
and compare every complete emitted Arrow group byte for byte with the resident path. The window
oracle decodes the same native checkpoint, since persistence can normalize unused Boolean buffer
bits of a live carrier. Restoring the bounded reader’s output compares all significant row values
and validity with the live publication. The checks compare revisions, counts, group boundaries,
sequences, watermarks and delayed removals. The stored-branch capture outline exercises a stopped
domain and a stopped restore with
a keyspace and a window above 32 MiB beside 24 small branches, checks exact re-export, and reports
sampled per-node bulk and metadata reservations separately from harness allocator statistics.
Mappings are included in their admission class; jemalloc’s resident figure excludes mappings
outside jemalloc and is not the process’s complete resident set.
Limits
| Limit | Value |
|---|---|
| One record section | 64 MiB |
| One archive | 64 GiB, the largest artifact a node stages |
| Archives and snapshot transfers a node stages at once | 128 GiB |
| Download chunk | 256 KiB |
| Frames a download queues ahead of its client | 4 |
| Retention | Until downloaded, or the retry validity of the execution reference ends |
| Restore chunk | 256 KiB |
| Restore frames the Rust client queues ahead of the transport | 8 |
| Restore archive retention | Until the restore finishes, or the retry validity of the execution reference ends |
| State section staging on an owner | Charged to the same node staging quota until fetched or expired |
| WASM guest save capture | One selected save at a time: restore_metadata reserves three times its stored checkpoint size for the inline Fjall value and decoded reader; three fixed 64 KiB bulk buffers cover segmented reads, transfer and staging. A save up to the 64 MiB guest limit can exceed the 32 MiB default bulk budget. |
| Kafka offset or branch lifecycle section capture | One checkpoint at a time per owner: restore_metadata holds twice the stored checkpoint plus 64 KiB for the whole section, beside serializer scratch of one resolver per entry; a 64 KiB bulk buffer streams the record |
| One Kafka offset or branch lifecycle section | The 64 MiB record limit |
| Native conversion I/O, checkpoint installation and complete generation publication per node | Fixed 2 MiB bulk working-memory reservation; 64 KiB buffers and checkpoint chunks |
| Retained archive metadata and preparation per node | 2 GiB default independent admission class; 16 times record bytes plus 64 times NSPL bytes plus 2 MiB |
| Native conversion serializer scratch | Fixed-capacity entry resolvers plus the largest entry’s nested resolvers, covered by the retained preparation charge |
| Materialized identity group / Arrow group | 1 MiB / 8 MiB |
| Materialized capture metadata / typed uniqueness metadata / native row views | 8 MiB each, independently admitted |
| Deduplicator key group / window input group / window argument group | 8 MiB each; capture fills a group to at most half that bound, and a single larger row takes a group of its own |
| Selected stored branch checkpoint capture | One checkpoint per owner at a time. Before opening, restore_metadata admits three times its stored length to cover an inline value’s copies. Four 64 KiB bulk blocks plus 8 KiB cover staging. The complete immutable mapping and its retained indexes remain admitted; selected key parts and decoded columns have separate temporary metadata charges. |
| Stored window structure conversion | restore_metadata admits a conservative eight times the native length plus 256 KiB for typed removals, descriptor projection and header scratch, independently of the mapping and bounded column buffers. A checkpoint that cannot fit these reservations is refused with its entity named. |
| One archived deduplicator keyspace or window | Not bounded by the bulk budget. A keyspace’s key parts and values are charged to restore_metadata while it converts, beside the fixed per-key share admitted with the description; a window conversion holds one archived section at a time |
| Window checkpoint identity record | 1 MiB of row identities; a larger archived group splits into groups that fit |
| Restore conversion admission wait | 30 seconds after the node first refuses one unit for room: a window section, a window while its checkpoint is finished, or a keyspace. The unit is converted again every 50 ms |
| Restored window rows a branch reopens | No row ceiling of their own; their row views are charged to the relay memory class while the branch reopens the window |
| Physical checkpoint placement encoding | 60 KiB, including domain and installation namespace |
| Unpublished restore checkpoint keys and values per node | 128 GiB by default; configurable with --restore-staging-max-bytes |
A backup larger than the staging quota fails. A backup that fits waits while the leader’s staging area is full, until retained archives are downloaded or expire and snapshot transfers finish.
A restore stages its archive under the same limits, and is refused rather than kept waiting when the archive is larger than one archive may be, or the leader’s staging area cannot hold it now; send it again once retained archives are released and snapshot transfers finish. A restore’s model batch is not bounded by the statement and source-byte limits of a transaction. Each node must admit the bounded installation or publication job within its bulk working-memory budget (32 MiB by default). A state set, and an individual materialized relay, deduplicator keyspace or window, may exceed that budget. An individual job that cannot be admitted fails with the start gate still closed.
A restored deduplicator keyspace or window that exceeds one 2 MiB state-sync response is installed
on every assigned owner and replica, but its replicas do not follow the revisions the owner
publishes after RESUME, which is the replica limit any keyspace or window of that size has. The
owner persists them: a window whose retained rows exceed one 8 MiB snapshot section is sealed in
pieces on quota-owned disk and published as a segmented checkpoint, so it survives a restart at
any size the node’s snapshot staging quota holds rather than only within the bulk budget.
Failures
A failed backup reports backup failed: and the reason, and its message never includes archive
contents. The reasons are:
- the named domain does not exist, or no domain is selected for
BACKUP DOMAIN - a domain’s committed models do not form a valid graph, do not render as NSPL, or do not parse back to themselves
- a domain’s clock mapping cannot be read at the capture time
- a resource version’s bytes are not installed on the leader, or do not match their catalog entry
- the archive is larger than one archive may be, or a record does not encode
- a captured branch lifecycle or Kafka offset checkpoint does not decode, its record exceeds the
64 MiB record limit, or
restore_metadatacannot admit its conversion now, naming the entity - a selected WASM guest save cannot be read or its
restore_metadataconversion cannot be admitted, or its staged write is interrupted; the failure names the processor without guest bytes - a quiesced domain cannot pause, drain, capture its owners or resume within its timeout
A capture that fails or is interrupted drops every section its owners staged for it. No archive is assembled from a partial capture, and the backup can be sent again. The same refusal applies to a selected stored keyspace or window whose immutable view, native validation, typed metadata or bounded projection cannot be admitted. Cancellation is checked between stored chunks, identities and keys; refused or interrupted staging drops its private files and returns their quota. No partial branch checkpoint becomes an archive section.
A refused restore reports restore refused: and the reason, and changed nothing. The reasons are:
- the stream did not carry the archive its start declared: its chunks add up to another size, or its bytes have another digest
- the leader’s staging area cannot hold the archive now, or the archive is larger than one archive may be
- the archive does not verify: the section and the check that failed are named
- a restored domain’s
models.nspldoes not parse, naming the domain and the line, or holds a statement that creates no model, naming the domain, the statement’s number and its line RESTORE CLUSTERwas given a domain archive, orRESTORE DOMAINnames a domain the archive does not hold- a restored domain name exists
- an archived user exists and the user policy is
FAIL - the archive was taken
WITHOUT RESOURCESand holds completed resource versions, or holds a version’s bytes that do not match its root checksum - a model binds a resource version the restore does not import as completed
- a domain’s models do not form a valid configuration, as the transaction planner finds
- an archived branch key the restore would install is not a key of the branching its restored entity declares, naming the section, the lifecycle entry, descriptor or record identity, the entity, the branch and the field; see Archived Branch Keys
- an archived materialized relay, deduplicator keyspace or window does not convert under the shape its restored model gives it, or its conversion cannot be admitted, naming the entity; a dry run runs the same conversion. A deduplicator or window conversion the node refuses only for room is converted again first, and is refused only once that refusal has lasted its 30-second wait
- the archive’s metadata, including the fixed share of every archived deduplicator key, exceeds the node’s available restore preparation budget
A restore that failed at a step reports restore failed at step '<step>':, the reason, and that the
steps before it stay applied, together with the report of every step. The reasons are a consensus
refusal of the step, a resource version that could not be installed or completed on every live
node, a model batch the leader refused, such as lookup data that does not load at its path or TLS
material that does not load, and state that does not convert or install. Installation converts
each keyspace and window as planning did, with the same wait for room, so a momentarily busy node
does not fail the step; a refusal for room that outlasts the wait does. A restore whose archive no
client sent to a new leader before the retry validity of its execution reference ended reports
restore stopped after the steps it recorded. No message includes password hashes or resource
bytes.
Rust Client Library
The workspace includes nervix-client-core, a native Rust client library built on the same session
gRPC API used by nervix-cli. It is the reference implementation of the Client Implementation
Manual, and Client Session
Protocol explains the protocol behavior it relies on.
For a complete graph, data-handle lifetimes, credit and failure decisions, read Client Ingestors And Emitters.
Capabilities:
Client::connect(...)andClient::connect_with_options(...)Client::execute(...), andClient::prepare_execution(...)withClient::execute_prepared(...)Client::transaction_status(),Client::attach_transaction(...)andClient::inspect_transaction(...)Client::list_domains(),Client::domain()andClient::set_domain(...)Client::subscribe(...),Client::unsubscribe(...)andClient::next_subscription()Client::attach_domain_clock(...),Client::detach_domain_clock(...),Client::domain_clock(...)andClient::next_domain_clock_event()Client::open_ingestor(...), withProducer::send(...),Producer::submit(...),Producer::rejoin(...),Producer::pending_submissions(),Producer::release(...)andProducer::close()Client::subscribe_emitter(...), withEmitterConsumer::next_batch(),EmitterDelivery::ack(),EmitterDelivery::retry(),EmitterDelivery::reject(...)andEmitterConsumer::close()Client::upload_resource_from_directory(...)Client::download_backup(...)Client::restore(...)andClient::restore_with_reference(...)Client::next_server_event(),Client::next_domain_list()andClient::leadership()Client::suggest(...)behind theautocompletefeatureClient::lookup_choices(...)
Suggestion pages report Ready, MissingContext, StaleContext, or LookupFailed. Every
candidate carries a UTF-8 byte text edit against the full input and a continuation requests the
next bounded page. The shared C binding exposes the same result through nx_session_suggest,
nx_suggestions_at, and nx_suggestions_continuation; its header is
crates/client-ffi/include/nervix_client.h.
Structured choice pages carry typed enum variants or domain, resource, and model references beside
separate presentation metadata. The request includes typed dependencies, search, and a
revision-fenced page cursor. Client::lookup_choices(...) retries this read-only request across a
session reconnect and returns its Ready, MissingContext, StaleContext, or LookupFailed
status without interpreting labels.
Every outcome carries a typed CommandDisposition: Completed, Failed, NotLeader with the
leader’s endpoints when discovery knows them, TransactionDetached, TransactionTakenOver,
OutcomeUnknown with its cause, ExecutionReferenceConflict, ExecutionReferenceExpired, or
PreviewStale. CommandOutcome::succeeded() is true for Completed.
Each execute call creates one stable execution reference and retains it through leader redirects,
transaction reattachment, and transport reconnects. A successful outcome means the command’s
administrative effect is usable on the current live-node set. If the caller cancels its future, the
cluster still owns any already-admitted effect; the client sends no cancellation and discards the
reply when it arrives. To submit the same logical request again, prepare it once with
prepare_execution, which fixes its execution reference, domain, and upload identity, and pass the
same ExecutionHandle to execute_prepared after an uncertain, cancelled, or timed-out attempt.
Every call is bounded by ConnectOptions: connect_timeout for each connection attempt (10
seconds by default), request_timeout for each request (120 seconds), and retry_timeout for the
call with all of its retries and redirects (120 seconds). BACKUP uses backup_wait_timeout
instead for its command wait and reply (ten minutes), shared across every domain cut, redirect and
reconnect. All four durations must be between one millisecond and 24 hours. A call whose deadline passes before a
command’s outcome is known fails with ClientError::UncertainCommand, which names the execution
reference. An https:// server is verified only against ca_certificate_pem: the client loads no
system roots, so a TLS connection needs the certificate authority that signed the server’s
certificate.
Every call returns its failure as an error_stack::Report<ClientError>. The report’s current
context, report.current_context(), is the failure a caller acts on, and the frames beneath it
keep its cause: a call’s transport status, the wire codec’s report beneath EncodeRequest,
InvalidUploadReply or InvalidRestoreReply, the name’s report beneath InvalidResourceName, a
local archive’s I/O error beneath BuildUploadArchive or ReadRestoreArchive, and the
BackupDownloadError beneath BackupDownload. A failure that may hide an admitted command or an
installed upload is UncertainCommand or UncertainUpload above the failure that left the
outcome unknown, so the execution reference or upload identity is read from the current context
and the cause from the frame beneath it. The client decides retries, session recovery and
uncertainty from those typed contexts, and a caller needs no text to decide either; {report:#}
renders the whole chain for display.
Native connections resolve a hostname through Hickory. By default the client loads the host’s
resolver configuration and hosts file once at connection setup. ConnectOptions::dns can name a
different DnsConfiguration or an already loaded DnsResolver shared with the caller’s runtime.
The same resolver is reused for initial connections, seeds, redirects, and reconnects. Each new
connection uses its current cached DNS answer, tries the addresses of the first answer’s family in
order, starts the other family’s addresses when 300 milliseconds pass without a connection, and
keeps the URL’s hostname for HTTP/2 authority and TLS verification. The connection timeout, ten
seconds by default, includes lookup and all connection work; a failed DNS configuration returns
LoadDnsConfiguration, and a failed lookup stays in the connection error’s cause chain. DNS does
not change execution identities, subscriptions, transactions, or cancellation.
Name Resolution describes the resolver.
Minimal example:
use nervix_client_core::{
Client, ConnectOptions, DomainName, SubscriptionEvent, SubscriptionRequest,
};
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let options = ConnectOptions::default().with_basic_auth("default", "nervix");
let domain = DomainName::parse("default")?;
let client =
Client::connect_with_options("http://127.0.0.1:47391", Some(domain), options).await?;
let result = client.execute("SHOW CLUSTER STATUS;").await?;
println!("{}", result.message);
let request = SubscriptionRequest::new("sampled_orders", "orders")
.dropping()
.with_batch_sample_rate("0.1")
.with_where_clause(
nervix_nspl::parse_expression("input.tenant = \"acme\"")?
);
client.subscribe(&request).await?;
if let SubscriptionEvent::Rows(rows) = client.next_subscription().await? {
for line in rows.display_lines()? {
println!("{line}");
}
}
client.unsubscribe("sampled_orders").await?;
Ok(())
}
A subscription delivers typed rows against the schema its subscribe reply announced.
SubscriptionRowsEvent::display_lines() renders each row as one JSON object, prefixed with its
concrete branch key as key=<object> payload=<object> on a branched relay; a sensitive field reads
"<masked>". The other subscription events report rows a dropping subscription could not deliver,
rows it skipped, and the end of the subscription with its reason: RelayChanged when the relay was
redefined, so the announced schema no longer describes its rows, or RelayRemoved when the relay
or its domain no longer exists. The end is the last event of that subscription, and the client never
opens it again on its own, because a new session would not change why it ended.
Client::subscription_lifecycle(&name) reads it as Ended from the moment the end arrives until
you subscribe again under the same name, which opens a new generation that announces the relay’s
current schema, or unsubscribe, which completes without a request and releases the name. An end
whose session ended before you read it is still reported, once, after the events that session left
unread are dropped; a subscription whose events overflowed reports the overflow as its last event
instead. See Sessions.
A subscription the server acknowledged, and did not end, outlives its session. When the session
ends, next_subscription() reports Interrupted, the gap before the subscription opens again, and
the client opens it again as a new generation on its next session. When that session refuses it, for
example because its relay no longer exists, next_subscription() reports RestorationFailed with
the server’s message and the wait before the next attempt; the client keeps trying on that session,
after a wait that starts at one second and doubles up to thirty seconds, until the subscription
opens, the session ends, or the subscription is deleted. Client::subscription_lifecycle(&name)
reads the state a subscription is in. subscribe and unsubscribe reopen a closed session like
every other call. Deleting a subscription that no open session holds, because its session ended or
the current session refused to open it again, completes without a request and releases the name.
Backing Up
execute runs BACKUP CLUSTER TO '<file>'; and BACKUP DOMAIN [<name>] TO '<file>'; as one
command and then downloads the archive the backup assembled into the named file. The download is
not bounded by the client’s command wait; each frame must arrive within the request timeout. The
client writes a private file beside the destination and moves it over the destination only once
the archive’s size and BLAKE3 digest match the backup’s summary, so the file never holds a partial
archive, and on Unix only its owner may read it. A download that fails in transport starts again
from the first byte while the server retains the archive.
The outcome’s backup field carries the summary: the archive’s size and digest, the capture time,
the instant the server stops retaining it, whether resource bytes are included, the number of
users, and each domain’s revision, section count and bytes. A download that still fails returns
ClientError::BackupDownload with the backup’s execution reference, above the typed
BackupDownloadError that says why. Running the same ExecutionHandle again returns the recorded outcome and
downloads the archive again, and Client::download_backup(reference, summary, destination)
downloads a summary’s archive directly. A download that receives the whole archive releases it on
the server, and a later download of it is refused.
For recovery from another process, Client::prepare_backup_with_reference(backup, reference)
creates a handle for execute_prepared with that durable reference. Select the original domain
and retain the scope, resources and capture options; the destination file may change. The server
enforces the semantic binding and retry validity. Each explicit wait attempt is bounded again by
backup_wait_timeout, and does not renew archive retention. TIMEOUT in NSPL remains a separate
budget for each running domain’s cut, so a cluster capture may validly exceed that duration.
execute refuses DESCRIBE BACKUP, which nervix-cli serves from a local file without a server;
the nervix-backup crate’s describe_archive reads and verifies an archive for other Rust
programs. The C binding runs BACKUP through nx_session_execute the same way and reports the
archive’s size and digest through nx_outcome_backup. See Backup And Restore.
Restoring
execute runs RESTORE CLUSTER FROM '<file>' ...; and RESTORE DOMAIN <name> ... FROM '<file>' ...; by streaming the named local archive to the leader on the session service’s restore stream,
beside the session; it refuses both inside a transaction. Client::restore(restore, on_progress)
runs a parsed Restore the same way and reports each number of archive bytes it hands to the
transport to on_progress, and Client::restore_with_reference(restore, reference, on_progress)
runs it under an execution reference the caller chose. The client reads the file once to declare
its size and BLAKE3 digest, and sends it in 256 KiB chunks.
A restore is not bounded by the client’s retry deadline: each frame must reach the transport within
the request timeout, and so must the reply once the last frame was sent. After a redirect, a lost
connection, or an answer that the restore still applies, the client streams the archive again under
the same execution reference, which joins the restore or returns its recorded outcome, so a leader
change resumes a restore instead of repeating it. An error that may hide an admitted restore is
ClientError::UncertainCommand with the restore’s execution reference, and running the restore
again under that reference recovers its outcome.
The outcome’s restore field carries the typed RestoreReport: the mode, the archive’s size and
digest, the capture time, what the users step did, each restored domain, and each step’s outcome.
A stream the leader refused before the restore ran is a failed outcome whose message names the
typed failure. The C binding runs RESTORE through nx_session_execute and reports the report’s
counts, whether it was a dry run, and whether a step failed through nx_outcome_restore.
Following A Domain Clock
execute routes ATTACH DOMAIN CLOCK; and DETACH DOMAIN CLOCK; the way it routes USE: it sends
them as typed attach and detach requests for the selected domain and never as commands. Without a
selected domain they fail with ClientError::NoActiveDomain, and while a transaction is active they
are refused like every other client-local statement. Client::attach_domain_clock(domain) and
Client::detach_domain_clock(domain) send the same requests for a domain named explicitly and
return the typed DomainClockAttachOutcome and DomainClockDetachOutcome, whose dispositions tell
an attachment from each refusal.
Once attached, Client::domain_clock(&domain) returns an AttachedDomainClock holding the newest
clock and accepted tick the session received. It is a copy taken when it is called, which later
frames do not change, so a caller reads it again for a newer clock. latest_tick() gives the tick
id, logical boundary, authority UTC observation, and serving node’s logical reading; frontier()
gives that boundary. It answers with the same vocabulary arithmetic the cluster runs,
DomainClockState::logical_time_at and DomainAdmissionWindow::reached, for a UTC instant the
caller supplies:
#![allow(unused)]
fn main() {
use nervix_client_core::{DomainClockEvent, DomainName, Timestamp};
let domain = DomainName::parse("simulation")?;
client.set_domain(Some(domain.clone())).await;
client.execute("ATTACH DOMAIN CLOCK;").await?;
let clock = client.domain_clock(&domain).expect("the session follows this clock");
let now = Timestamp::now();
let logical_now = clock.logical_time_at(now)?;
if let Some(frontier) = clock.frontier() {
println!("latest accepted tick boundary: {frontier}");
}
if let Some(window) = clock.admission_window(now)? {
// The newest center the ingestor has reached, admitted within SKEW on either side.
let occurred_at = window.latest_center();
println!("{logical_now}: send events stamped {occurred_at}");
}
match client.next_domain_clock_event().await? {
DomainClockEvent::Observed(observed) => println!("{}", observed.clock),
DomainClockEvent::Ticked(ticked) => println!("tick {}", ticked.tick.tick_id),
DomainClockEvent::Ended(ended) => println!("ended because {}", ended.reason),
DomainClockEvent::Interrupted(gap) => {
println!("{}: session lost; the clock is attached again on the next session", gap.domain)
}
DomainClockEvent::RestorationFailed(failure) => {
println!("{}: {}; retrying in {:?}", failure.domain, failure.message, failure.retry_after)
}
}
}
logical_time_at reads an unpaced clock as UTC itself and rounds a paced projection down;
wall_duration_until(now, target) returns the physical wait until a logical instant, rounded up so
waiting it never arrives early by the caller’s own UTC, and zero once the instant is reached;
admission_window returns None for an unpaced clock, whose ingestors admit every timestamp. A
stopped or uninstalled clock answers every question with a typed DomainClockReadError, and an
answer outside the timestamp or duration range is DomainClockReadError::Arithmetic. The answers
hold for the caller’s UTC: a host whose UTC is offset from the cluster’s reads logical instants and
admission centers shifted by that offset multiplied by the rate, and physical waits that end early
or late by the offset itself.
Client::next_domain_clock_event() reports what the session receives after each attach reply:
Observed with a changed clock, Ticked with the newest accepted progress, Ended when the server
ended the attachment because the domain no longer exists on the serving node, and Interrupted
when the session holding an attachment ended. An attach reply produces no event: domain_clock
reads the clock it carried, and the first event after attaching to a running paced clock is
normally its newest tick. Events are coalesced per domain, so a caller that reads none never holds
the session’s reader up. The newest state replaces an unread state and the newest tick an unread
tick, and an unread state arrives before a tick of its generation. An interruption discards the
unread observations before it, an end discards everything unread about its domain, the newest
restoration failure replaces an unread one, and a detach discards every unread event of the domain.
Domains are reported in the order of their first unread event. While a clock is interrupted,
domain_clock keeps the clock the lost session reported last, without a tick, and another lost
session before the clock is restored reports no second interruption. After a reconnect, the client
attaches every followed clock again on the new session before any other request, and the clock
that attachment reports follows the interruption as an Observed event; changes in between are not
reported. When the new session answers that attach while the old session still holds the
attachment, as after a redirect to the leader, the attachment moves without an interruption: a
clock that stays paced in the same generation keeps its newest tick, so domain_clock is never
older than a tick event already taken, and a Ticked event of the new session follows only for a
newer tick. A new session
that answers the attach with the domain not found ends the attachment with Ended, as the server
would. A node that is still starting answers that attach only once it has installed the cluster’s
committed domains, so a restart never ends an attachment. When the new session refuses that attach
for any other reason, the event is RestorationFailed with the server’s message and the wait
before the client sends the attach again on the same session; the wait starts at one second on
each new session and doubles up to thirty seconds. An attach the session leaves unanswered past
request_timeout ends that session, and the next one attaches the clock again. Waiting for the
next event reopens a closed session when a followed clock waits for it, including a call already
waiting when the unanswered attach ends its session; a failure to open one is returned while the
clock keeps waiting, and the next call tries again. A detach, or an end, stops following the
domain, and domain_clock returns None for it afterwards. A detach while a clock waits to be
restored reaches the current session: a closed session is opened again first, which attaches the
clock before the detach releases it, and an open session that has not restored the clock answers
NotAttached, after which the client no longer follows it either. The client never attaches the
clock again behind the detach: an attach it already decided to send to restore the clock is sent
first, and the detach releases what it attached, while a restoration that would attach the clock
during the detach waits for it to end and attaches the clock only if the client still follows it.
Through The Shared C Binding
The shared C binding reads the same events and attached clocks for C, C++, Python, JVM and Ruby
hosts; its header is crates/client-ffi/include/nervix_client.h. ATTACH DOMAIN CLOCK; and
DETACH DOMAIN CLOCK; run through nx_session_prepare and nx_session_execute like any statement,
for the session’s selected domain, and fail with NX_ERROR_INVALID_ARGUMENT when none is selected.
nx_session_next_clock_event waits for the next event, bounded by the same nx_cancel tokens and
deadlines as every blocking call, and hands out an nx_clock_event reference. While a followed
clock waits to be restored it opens a session; when none can open, it fails with NX_ERROR_CLOSED
if no server is known and otherwise with the connection, transport or deadline failure of that
attempt, and the next call tries again. The events are the Rust client’s, coalesced the same way:
nx_clock_event_kind_oftells anNX_CLOCK_EVENT_STATE,NX_CLOCK_EVENT_TICK,NX_CLOCK_EVENT_ENDED,NX_CLOCK_EVENT_INTERRUPTEDorNX_CLOCK_EVENT_RESTORATION_FAILEDevent apart, andnx_clock_event_domainborrows the name of the domain it concerns. A restoration failure reports that the session refused to attach an interrupted clock again, which it tries again after the growing wait the Rust client reports. The event carries only its kind and domain: the binding exposes neither the server’s message nor the wait.nx_clock_event_generationreads theSTARTgeneration of a state or tick event,nx_clock_event_statethe installation state of a state event,nx_clock_event_pacedthe period, skew, logical origin, UTC anchor and time rate of a paced one,nx_clock_event_tickthe id, logical boundary, authority UTC observation and serving node’s logical reading of a tick, andnx_clock_event_end_reasonwhy the server ended an attachment. Each fails withNX_ERROR_TYPEfor an event whose kind does not carry what it reads.- Instants are signed nanoseconds since the Unix epoch, the period and skew unsigned nanoseconds,
and the time rate a
double. Every field is written to an out-parameter the host provides, so reading an event allocates nothing on the host’s side. nx_clock_event_retainandnx_clock_event_releasecount references the waynx_event_retainandnx_event_releasedo, and a reference may be released on any thread.
nx_session_domain_clock reads domain_clock for a domain the session follows as an
nx_domain_clock reference, and writes NULL for a domain whose clock the session does not follow.
It never blocks. Right after ATTACH DOMAIN CLOCK; completes it is the clock the attach reply
carried, so a host that attaches to a running paced clock reads its START generation and
committed mapping before it uses the first tick. Every later observation and accepted tick replaces
it in the order the session received them, so a read is never older than an event the host has
already taken, including after a redirect moves the attachment to another session without an
interruption. Once the session holding the attachment ends, which NX_CLOCK_EVENT_INTERRUPTED
reports, it keeps the clock that session reported last, without a tick, until the restored
attachment reports the clock again. A read never
changes once taken, stays valid after the session is freed, and is counted with
nx_domain_clock_retain and nx_domain_clock_release like an event:
nx_domain_clock_domain,nx_domain_clock_generationandnx_domain_clock_stateread the domain, the generation and the installation state.nx_domain_clock_pacedreads the period, skew, logical origin, UTC anchor and time rate of a paced clock and fails withNX_ERROR_TYPEfor any other state, andnx_domain_clock_ticksays whether the session holds an accepted tick of the clock’s generation and reads it when it does.nx_domain_clock_logical_time_at,nx_domain_clock_wall_duration_until,nx_domain_clock_admission_windowandnx_domain_clock_admitsanswer with the arithmetic ofAttachedDomainClock: the logical time at a UTC instant, rounded down; the physical wait until a logical target, rounded up; the oldest and newest tick centers aTIMESTAMP ATingestor admits events around; and whether it admits a given event time. An unpaced clock reads UTC and admits every timestamp. A stopped or uninstalled clock fails withNX_ERROR_TYPE, and a result outside the timestamp range withNX_ERROR_INVALID_ARGUMENT.
An attach the server refuses completes as failed with the server’s reason, and
nx_session_domain_clock still reads the earlier attachment’s clock when the refusal is that the
session already follows it. An attach whose session was lost is sent again on the next session the
client opens. An attach cancelled or expired after its request was sent can still attach the
session: executing the same prepared execution again is answered after the earlier attempt, and
nx_session_domain_clock then tells whether the session follows the clock.
Events Across Reconnects
next_subscription(), next_domain_clock_event(), and next_server_event() read streams that
belong to the client rather than to one session, so a reader keeps calling them across reconnects:
- When a session ends,
next_subscription()reportsInterruptedfor every subscription that session held and the server had not ended, reopens a session, and opens each of them again as a new generation. With nothing to restore it waits for the next session the client opens, for example for its next command, and delivers the events of subscriptions opened there. next_domain_clock_event()reopens a session while a followed clock waits to be attached again, as described above.next_server_event()never opens a session itself. Notices end with the session that delivered them, including the ones not read yet, and the stream continues with the notices of the next session.
A failed read leaves its stream open. ClientError::EventOverflow from next_server_event() means
notices arrived faster than they were read: the client dropped the ones it held, and the next read
returns the notices that arrived after that gap. An error from reopening a session, such as
ClientError::RetryDeadline or ClientError::ConnectServer, leaves subscriptions and clocks
waiting to be restored, and the next read tries again. Only ClientError::SessionClosed ends a
stream: the session ended and the client knows no server to open another on, as happens to a
client built with Client::from_channel that has not been redirected.
Producers
Client::open_ingestor(domain, ingestor, expected_fields, limits) attaches a producer to a
client ingestor of the domain named explicitly, on the session’s
current exchange. expected_fields must be exactly the ingestor’s input schema, including each
field’s optionality and sensitivity, and limits asks for the batches and bytes the producer may
have outstanding. A refusal is ClientError::ProducerRefused with its typed
ClientProducerRefusal, and leaves nothing attached. The returned Producer reports what the open
established through description(): the schema, the START generation, the endpoint contract and
attachment, the ingestor’s policy, and the granted credit.
#![allow(unused)]
fn main() {
use std::num::{NonZeroU32, NonZeroU64};
use arrow_array::{Int64Array, RecordBatch, StringArray};
use nervix_client_core::{ClientProducerLimits, DomainName, IngestorName, ProducerOutcome};
let producer = client
.open_ingestor(
DomainName::parse("shop")?,
IngestorName::parse("orders_in")?,
expected_fields,
ClientProducerLimits {
batches: NonZeroU32::try_from(16)?,
bytes: NonZeroU64::try_from(8 * 1024 * 1024)?,
},
)
.await?;
let rows = RecordBatch::try_new(
std::sync::Arc::new(producer.arrow_schema()),
vec![
std::sync::Arc::new(StringArray::from(vec!["o-1"])),
std::sync::Arc::new(Int64Array::from(vec![1200])),
],
)?;
match producer.send(producer.batch(&rows)?).await? {
ProducerOutcome::Completed => println!("delivered"),
ProducerOutcome::NotAdmitted { refusal, .. } => println!("not admitted: {refusal:?}"),
ProducerOutcome::ProcessingFailed { failure, .. } => println!("failed: {failure:?}"),
ProducerOutcome::OutcomeUnknown { cause, .. } => println!("replay decision: {cause:?}"),
}
producer.close().await?;
}
Producer::batch and ProducerBatch::from_record_batch, behind the arrow feature, write a record
batch as the canonical stream the server accepts after checking it against the producer’s schema and
row limit; ProducerBatch::from_arrow_ipc takes a stream the application already wrote.
send waits for credit, submits the batch, and returns its terminal outcome. submit returns once
the producer holds the batch, and rejoin waits for that submission’s outcome. A submission keeps
its share of the credit until the application observes its outcome, so a producer whose application
stops reading outcomes stops being granted room for new batches. Cancelling a send or rejoin
future never loses the batch or its outcome: pending_submissions() lists every submission the
producer holds with its outcome once it has one, rejoin resumes the wait without sending the batch
again, and release lets go of a submission and its credit.
The client sends a batch again only when the server refused it temporarily, as Suspended or
Busy, after the ingestor’s declared backoff, and it waits while the producer’s admission is
suspended. It never replays a batch that failed or whose outcome is unknown. A batch that was sent
when the session ended is reported as OutcomeUnknown with SubmissionUncertainty::SessionLost,
and one still waiting to be sent as not admitted. Producer::admission() tells whether batches are
admitted now and Producer::end() how the producer ended: Closed, Ended with the server’s
reason, or ReopenRequired. The same handle restores an attachment after reconnect only when the
domain generation, endpoint contract, schema, policy and credit grant still match. connection()
reports active, interrupted, restoring, reopen required or closed. A changed or removed endpoint
requires the application to open another producer. close takes the producer by reference and
releases its attachment from a task of its own, so a caller that stops waiting still releases it;
a second close, or a close while the producer waits to be restored, returns at once. Dropping a
producer closes it without waiting.
Emitter Consumers
Client::subscribe_emitter(domain, emitter, expected_fields, limits) opens a competing consumer
of a native TO CLIENT emitter on the current session. The field list is exact, including order,
optionality and sensitivity. The open reserves its requested credit before it succeeds. The
returned EmitterConsumer::description() reports the schema, emitter ACK window, timeout and
retry pacing, maximum batch rows and bytes, and granted credit. A refusal is
ClientError::ConsumerRefused with a typed reason.
#![allow(unused)]
fn main() {
use std::num::{NonZeroU32, NonZeroU64};
use nervix_client_core::{ClientConsumerLimits, DomainName, EmitterName, EmitterSettlement};
let consumer = client.subscribe_emitter(
DomainName::parse("shop")?,
EmitterName::parse("app_output")?,
expected_output_fields,
ClientConsumerLimits {
batches: NonZeroU32::try_from(8)?,
bytes: NonZeroU64::try_from(8 * 1024 * 1024)?,
},
).await?;
while let Some(delivery) = consumer.next_batch().await? {
let batch = delivery.record_batch()?; // `arrow` feature
process(batch).await?;
assert_eq!(delivery.ack().await?, EmitterSettlement::Confirmed);
}
consumer.close().await?;
}
record_batch accepts a delivery only when its Arrow IPC stream declares exactly the Arrow schema
of the fields the emitter declares and holds one record batch of the delivery’s member count; any
other stream is a ClientError::UnexpectedReply. EmitterDelivery::decode_record_batch(fields, batch, members) applies the same check to bytes an application kept: it takes the fields of
consumer.description(), a delivery’s batch bytes and its members, and needs no live delivery.
delivery.retry().await keeps the same bytes and asks the server to reassign after physical
backoff. delivery.reject(reason).await sends one bounded, non-sensitive reason through the
emitter’s message error policy for all members. Each reassignment carries a new reference; a
stale reference reports StaleReference. Reading, receiving, or decoding the batch is never an
ACK. A server attachment ends with its session or endpoint. Applications using the sample loop
should handle ClientError::ConsumerInterrupted explicitly: the first read after a session loss
or endpoint relocation returns it, and later reads use a fresh attachment if its generation and
contract still match. connection() reports its current state. A changed or removed endpoint
returns ClientError::ConsumerReopenRequired and needs an explicit new open. An old delivery cannot
ACK through the new attachment; its reference expires,
and a settlement whose answer was lost returns ClientError::SettlementUnknown. The Rust client’s
exchange reader continues to route producer outcomes and command or clock replies while
application processing awaits ACK. A next_batch future dropped before its reply leaves the read
with the consumer, and the next next_batch of the same attachment receives that reply without
asking for another batch, so abandoning a wait never strands an attempt until its ACK timeout.
close releases the attachment the way a producer’s does.
The Paced Simulation Drivers are complete programs that combine a
followed domain clock, a producer and competing consumers on one session, in Rust and through the
shared C binding: they start their consumers before they await an attached outcome, pace their
TIMESTAMP AT values by the attached clock’s projections, and make their effects idempotent across
redeliveries and deliberate replays. They explicitly reopen changed endpoint contracts within a
START generation, validate the same expected fields and credit requests again, and keep unknown
submissions with their original handle rather than resending them.
Endpoints Through The Shared C Binding
The shared C binding exposes the same producers and consumers to C, C++, Python, JVM and Ruby
hosts, through crates/client-ffi/include/nervix_client.h. Every call forwards to the Rust handles
above, so credit, retries of temporary refusals, restoration, outcomes and settlements behave
exactly as described; the binding keeps no acknowledgement or reconnect state of its own.
- Opening. A host describes the fields it expects with
nx_fields:nx_fields_addstarts a field with its name, outermost type, nullability and sensitivity, andnx_fields_elementadds the element of each list around it, soARRAY<I16, 2, 2>is aFIXED_LISTof 2, aFIXED_LISTof 2, thenI16.nx_session_open_ingestorandnx_session_subscribe_emitteropen a producer or a consumer on a domain named explicitly, with the credit the host asks for, and copy the fields. A refusal isNX_ERROR_REJECTED, andnx_error_open_refusalreads itsnx_open_refusal, such asNX_OPEN_SCHEMA_MISMATCH. - Description.
nx_producer_schema,nx_consumer_schema,*_generation,*_contract,*_grantand*_policyread what the open established: the schema with every field’s nullability, sensitivity and type levels, theSTARTgeneration, the contract digest, the granted credit and batch limits, and the acknowledgement window, ACK timeout and backoff.nx_producer_stateandnx_consumer_stateread the connection,nx_producer_admissionthe admission, and*_reopen_reasonwhy a handle needs a new open. - Submitting. A host builds a batch from its own buffers with
nx_batch_builder_newfor the producer’s schema: onenx_batch_builder_statescall per nullable column, onenx_batch_builder_offsetscall per variable-length list level, and onenx_batch_builder_fixedornx_batch_builder_varlencall for the innermost values. Every call copies its buffers before it returns, and nothing is converted.nx_producer_submitwaits for credit, checks the batch against the producer’s schema and row limit, and returns the submission’s identity;nx_producer_rejoinwaits for its outcome and takes it, andnx_submission_outcome_*read its class, typed cause and message.nx_producer_pendinglists what the producer holds andnx_producer_releaselets a submission go. A host that writes canonical Arrow IPC itself submits it withnx_producer_submit_ipc, which copies it and leaves its checking to the server. - Consuming.
nx_consumer_nextreturns a reference-countednx_delivery: its identity, reference, source relay, branch fingerprint, member count and execution time, its stream, borrowed withnx_delivery_ipc, and its batch, whichnx_delivery_batchdecodes the first time it is asked for and then shares. A batch is read one level of a column at a time:nx_batch_statesfor the rows,nx_batch_offsetsfor each variable-length list level, andnx_batch_fixedornx_batch_varlenfor the values, each in one call.nx_delivery_ack,nx_delivery_retryandnx_delivery_rejectsettle the attempt and return the server’snx_settlement. Releasing a delivery settles nothing. - Interruption. After a session ends, the next
nx_consumer_nextreturnsNX_ERROR_INTERRUPTEDonce, a delivery read before the gap fails to settle withNX_ERROR_REJECTED, a submission sent before it isNX_UNCERTAINTY_SESSION_LOST, and a settlement whose answer the session lost isNX_ERROR_UNCERTAIN. The next calls restore the handles on a new session while their contract holds. A handle closed while it waits to be restored returns at once and is never restored. - Ownership. Producers, consumers, deliveries and batches may be used from several threads at once, and deliveries and batches are released on any thread; a field list and a builder are used from one thread at a time. A producer, a consumer and a delivery keep their session running until they are released, and a borrowed stream or value stays valid until the reference it was read from is released.
Transaction Handles And Attach
CommandOutcome::transaction describes the session’s transaction binding. Its TransactionStatus
holds the transaction id, the domain the transaction is bound to, its TransactionLifecycle
(Open, Committing, Committed, Failed with the failing operation and its error, Reverted, or
Expired), and its accepted, applied and pending operation counts. Treat the id as the durable
transaction handle:
#![allow(unused)]
fn main() {
client.execute("CREATE DOMAIN production;").await?;
client.set_domain(Some(DomainName::parse("production")?)).await;
let begun = client.execute("BEGIN;").await?;
let transaction_id = begun
.transaction
.as_ref()
.expect("BEGIN returns transaction status")
.transaction_id()
.to_string();
client.execute("CREATE SCHEMA notification (user_id I64);").await?;
// A different Client authenticated as the same user can take over the transaction.
let attached = recovered_client.attach_transaction(transaction_id).await?;
if !attached.succeeded() {
eprintln!("attach outcome: {}", attached.message);
}
}
BEGIN requires an already-existing selected domain and binds the transaction to it. Attaching
adopts that domain, so recovered_client follows the transaction’s domain without an explicit
set_domain.
While a transaction is open, execute first preflights a queueable statement against the
replicated prefix. An unsuccessful preflight leaves the transaction Open with the same pending
count, so callers may correct the command and continue using the same handle. A queued model
mutation’s message reports the effective quiesce level of its consecutive atomic model run at the
current prefix. A later mutation in that run may escalate the level or cancel the net change. An
exact append retry returns the originally admitted result without refreshing preflight or the
transaction’s activity timestamp.
The client automatically attaches its active transaction after a leader redirect or transport
reconnect before retrying a command. It retains each append’s execution reference and expected
position and only treats the exact recorded append as completion. A pending COMMIT waits for the
outcome recorded under its own reference, repeating the commit after an interruption; it completes
from that exact terminal outcome rather than from a coincidental progress count, and the call fails
as uncertain if retry_timeout passes first. A recovered committed operation returns a successful
CommandOutcome containing the retained aggregate quiesce output. A direct COMMIT outcome has no
per-statement statements; its message contains only the maximum quiesce level actually executed.
If a peer briefly has no leader address while an election converges, the client waits and retries
within retry_timeout instead of returning the transient NotLeader outcome. An OutcomeUnknown
outcome means the command may have been admitted and its result is not known yet, for example
because leadership moved while it applied; the client retries it with the same execution reference
within the same deadline, which recovers the recorded outcome.
An explicit attach by another session takes over the binding. Attach to a retained tombstone
returns an unsuccessful command outcome whose transaction status names Committed, Failed,
Reverted, or Expired; when commit produced aggregate output, diagnostics carries that one
aggregate rather than the individual statement results. Attach to an id removed after retention
returns an unknown-id outcome. transaction_status() keeps the
latest structured status so an interactive caller can render Open and Committing differently.
Inspecting A Transaction
Transaction Quiescence And Impact Inspection defines the report’s operation contributions, effective step scopes, actual engagement, and retained topology. This section describes how the Rust client receives it and fences a later commit.
DESCRIBE TRANSACTION answers twice: rendered in the outcome’s message, as TEXT by default or as
one JSON document with FORMAT JSON, and typed in CommandOutcome::inspection. The typed
TransactionInspection holds the inspected transaction’s status, the selected operation if the
statement named one, and the whole TransactionImpactReport, whichever format rendered the message:
#![allow(unused)]
fn main() {
let described = client.execute("DESCRIBE TRANSACTION OPERATION 2;").await?;
if let Some(inspection) = &described.inspection {
println!(
"{} operation(s) in {} execution step(s)",
inspection.report.operations().len(),
inspection.report.execution_steps().len()
);
}
}
The rendered message and CommandOutcome::inspection describe the same complete value. The server
does not paginate or truncate a large report, so callers that inspect transactions with many
operations or large affected graphs should budget for one response proportional to the expanded
report. A successful result always includes all operations, execution steps, topology, and recorded
outcomes.
CommandOutcome::transaction keeps describing this session’s own binding, so inspecting another
transaction by id changes neither transaction_status() nor the selected domain. An inspection
consumes no queue position: the next queued statement receives the operation number it would have
had. An inspection of the attached transaction refreshes the identified preview used by COMMIT
only when its report covers the attached transaction’s current accepted-operation position.
Inspecting another transaction, or receiving an older position, cannot replace that preview. A
stale-preview refusal leaves the previously reviewed basis in place; inspect the attached
transaction again before retrying COMMIT. A refused inspection is an unsuccessful outcome whose
message names why nothing was read.
DESCRIBE RESOURCE <name> is typed the same way: CommandOutcome::resource holds the
ResourceDescription beside the printed text, with the highest completed version as an Option,
every published version with its entries under their exact paths, and the models bound to each
version.
DESCRIBE WASM PROCESSOR <name> is typed too: CommandOutcome::wasm_state holds the
WasmStateInspection beside the text or JSON rendering, with the pinned module binding, the default
guest-state generation, the latest reset with its request reference, scope, phase, and reason, the
recorded rejected-state recoveries, and each current branch’s checkpoint stage, revisions, and
replica counts under its opaque fingerprint. RESET WASM PROCESSOR ... STATE is an ordinary
command: its success arrives only once the new lifetime is usable, and a retry the client makes
after an uncertain outcome reuses the same execution_reference, so it recovers the original
reset’s outcome rather than starting another. See
WASM State And Recovery.
Client::inspect_transaction(target, operation) also returns the typed InspectionOutcome from
the API. It follows leader redirects and reconnects with the session client’s normal request-ID
dispatch. A successful read of the attached transaction refreshes the same commit preview; a
rejected read changes no binding or preview.
Client Implementation Manual
This manual is the normative contract for a program that speaks the Nervix client session protocol. It states what an implementation must do to frame and verify messages, correlate requests, recover commands exactly, follow the leader, hold a transaction, read subscriptions without misreading a value, publish batches through producers, upload resources, and download backups. Client Session Protocol explains why the system behaves this way. This manual does not repeat those reasons, and the chapter does not repeat these rules.
The Rust client, nervix-client-core, is the reference implementation. The shared binding,
nervix-client-ffi, exposes it to C, C++, Python, JVM, and Ruby hosts, and a host that loads the
binding inherits every rule below from it except those in Using The Shared
Binding. An implementation that speaks the protocol itself, as the Go
and TypeScript conformance clients do, follows all of it.
The Rust reference implementation’s execution modes and native/browser capability boundaries are specified in Execution Primitives. Those repository build and verification contracts apply to the Rust implementation; independent clients continue to implement the protocol rules in this manual using their own runtime.
Conformance Language
The key words MUST, MUST NOT, SHOULD, and MAY are used as RFC 2119 defines them.
Each rule has an identifier, such as F-3, which Conformance Evidence ties
to the executable tests that exercise it. An implementation conforms when it follows every MUST
and MUST NOT rule for the parts of the protocol it uses. It MAY implement a subset, for example
commands without subscriptions, and MUST NOT send a request whose replies it does not handle.
The protocol has one current form: no version negotiation, no compatibility mode, and no fallback
encoding. An implementation MUST be generated from, or checked against, the
crates/client-wire/schema/session.fbs of the server release it talks to.
Messages At A Glance
Every request is a ClientMessage carrying a request identity and one ClientRequest. The server
answers each request with one terminal Reply, possibly delivered as transfer parts:
| Request | Served on | Served by | Terminal reply body |
|---|---|---|---|
CommandRequest | Ordered lane | The leader, except for the reads the chapter lists | CommandOutcome |
AttachTransactionRequest | Ordered lane | The leader | AttachOutcome |
SubscribeRequest | Ordered lane | Any node | SubscribeOutcome |
UnsubscribeRequest | Ordered lane | The session’s node | UnsubscribeOutcome |
AttachDomainClockRequest | Ordered lane | Any node | DomainClockAttachOutcome |
DetachDomainClockRequest | Ordered lane | The session’s node | DomainClockDetachOutcome |
OpenIngestorRequest | Ordered lane | Any node | OpenIngestorOutcome |
SubmitBatchRequest | The producer’s task | The session’s node | SubmissionOutcome |
CloseIngestorRequest | The producer’s task | The session’s node | CloseIngestorOutcome |
OpenEmitterRequest | Ordered lane | Any node | OpenEmitterOutcome |
ReadEmitterBatchRequest | Concurrently | The session’s node | ReadEmitterBatchOutcome |
SettleEmitterBatchRequest | Concurrently | The session’s node | SettleEmitterBatchOutcome |
CloseEmitterRequest | Concurrently | The session’s node | CloseEmitterOutcome |
SuggestRequest | Concurrently | Any node | SuggestOutcome |
ChoiceLookupRequest | Concurrently | Any node | ChoiceOutcome |
ListDomainsRequest | Concurrently | Any node | DomainList |
SelectDomainRequest | Concurrently | Any node | DomainSelectionOutcome |
InspectTransactionRequest | Concurrently | The leader | InspectionOutcome |
CancelRequest | On arrival | Any node | CancelOutcome |
Any request may instead end with RequestRejected, or with RequestCancelled when a cancellation
targeted it. A command sent to a follower is served there only when it is one of the reads the
chapter lists in Which Requests Need The
Leader; every other command is
answered with a LeaderRedirect disposition.
Unsolicited ServerMessage bodies carry no request identity:
| Body | Meaning |
|---|---|
LeadershipObserved | Which node leads, first on every session and again on every change |
DomainsObserved | The domain list, second on every session and again on every change |
DomainSnapshotObserved, ClusterObserved | The selected domain’s live graph and entities, and the cluster summary, after SelectDomainRequest |
ServerNotice | Text for display at Info, Warning, or Error |
SubscriptionRows, SubscriptionDeliveryLost, SubscriptionRowsSkipped, SubscriptionEnded | Frames of one subscription generation |
DomainClockObserved, DomainClockTicked, DomainClockAttachmentEnded | State, accepted tick progress, and end frames of one domain clock attachment |
ProducerAdmissionChanged, ProducerEnded | The admission state and the end of one producer |
SessionEnding | The last frame of a session the server ends |
An upload is a separate call with its own frames; see Resource Uploads. So is the download of a backup’s archive; see Backup Downloads, and a restore with its archive; see Restore Streams.
Frames
- F-1. An implementation MUST read and write every frame with code generated from
session.fbsbyflatc, or with code checked against it. It MUST NOT renumber, reorder, or omit schema members. - F-2. It MUST send exactly one finished frame per transport message, that is one gRPC message
or one WebSocket binary message, finished with its root’s file identifier,
NXCMfor aClientMessageandNXUMfor anUploadMessage, and without a size prefix. It MUST NOT split a frame across messages, join frames in one message, or send a WebSocket text message. - F-3. Before it reads any field of a received frame, it MUST check that the frame is at most
the frame limit of 4 MiB, at least 8 bytes long, and carries the expected identifier,
NXSMon a session andNXURfor an upload reply. Where its FlatBuffers runtime has a verifier, it MUST verify the frame with a nesting limit of 64, a table limit of the frame length divided by 4, and an apparent-size limit of 8 times the frame length. Where its runtime has none, as for Go and TypeScript, it MUST check every union discriminant, required field, and required value it reads, and MUST treat a read that its runtime fails, such as an offset outside the frame, as a protocol violation. - F-4. It MUST treat as a protocol violation a
NONEor undeclared union discriminant, an undeclared enum value, a missing required field, an absent optional scalar that a schema comment requires, a zero that a schema comment forbids, and an empty collection that a schema comment forbids. It MUST NOT skip such a value, default it, or map it to another one. - F-5. It MUST keep an absent optional scalar, declared
= null, distinct from a present zero, both when it reads one and when it writes one. For example, it omitsexpected_transaction_positionwhen it expects no position and writes0when it expects position zero. - F-6. It MUST NOT send a frame above 4 MiB, a string above 64 MiB, a vector above 262,144 entries, or tables nested deeper than 64.
- F-7. It MUST reassemble
TransferPartreplies into one reply per request identity. It MUST refuse a part that names another request identity or another total, a first total above the transfer limit of 64 MiB, a part whose offset is not the number of bytes received so far, and an empty chunk. It MUST verify the joined bytes as aServerMessageframe under the transfer limit that holds aReplyto the same request whose body is not itself a transfer part. It MUST accept other frames between the parts of one reply, and MUST NOT deliver a partial reply. - F-8. On a protocol violation it MUST stop reading the session, end every request still waiting on it as interrupted, and treat every command among them as uncertain under Commands And Execution Identity. It MAY open a new session.
- F-9. A value read in place borrows the frame it was read from. An implementation MUST keep the frame alive and unchanged while any such value is in use, and MUST copy what must outlive it. It SHOULD copy a small frame out of a large receive buffer before retaining it, as the Rust gRPC codec does below 64 KiB, so a retained frame does not pin the whole buffer.
Transports And Authentication
- T-1. A native client MUST open a session as the bidirectional gRPC stream
/nervix.session.Session/Exchangeover HTTP/2, and an upload as the client stream/nervix.session.Session/UploadResource. Each message MUST be one raw frame, not a protocol buffer, and uncompressed. It SHOULD set its gRPC message size limits to the frame limit. - T-2. A server address MUST be an
httporhttpsorigin whose path is/and that has no query, fragment, or user information, as an advertisedgrpc_uriis. A client that started overhttpsMUST NOT follow a redirect or a seed tohttp. A native client that resolves a hostname to one or more addresses MUST retain the original URI authority and verify the TLS certificate against that hostname on every connection, including redirects and reconnects. DNS and address attempts MUST fit within the client’s connection deadline. - T-3. A WebSocket client MUST connect to
/console/wson the leader’s console endpoint, usingwsfor anhttpendpoint andwssfor anhttpsone, and MUST send binary messages only. It MUST treat a close with code1003,1007,1008, or1009as a defect in what it sent. - T-3a. A WebSocket client MUST carry each backup download on its own connection to
/console/backups/download, and each restore attempt on its own connection to/console/backups/restore, with exactly the frames of theDownloadBackupandRestoreBackupcalls. A download connection carries one request. A restore connection MUST stay open after its last chunk, whose bytes complete the size its start declares, until the reply arrives: closing it would end the attempt in transport. A client MUST treat a close with code1001on either connection as a transport failure, after which A-6 and P-5 apply. - T-4. A native client MUST send
authorization: Basic <base64(user:password)>metadata on every call, including every upload. A WebSocket client MUST present the same credentials in anAuthorization: Basicheader or in theauthquery parameter, percent-encoded, because the server decodes the query as a form and would read a raw+as a space. - T-5. On
UNAUTHENTICATED, or401for the WebSocket, a client MUST NOT retry with the same credentials automatically. The server paces a user’s attempts after a failure.UNAVAILABLE, or503for the WebSocket, means the node could not verify the credentials now and judged nothing, so a client MAY retry with the same credentials after backing off, as it does after any other transport failure. - T-6. A client MUST NOT resend a frame that ended its call with
OUT_OF_RANGEorINTERNAL, because the server found the frame oversized or malformed. - T-7. A WebSocket client MUST expect its session to end with a
SessionEndingwhose reason is aLeaderRedirectwhenever its node does not lead. It then reconnects to the named leader’sweb_console_uriwith the path/console/ws, or backs off and retries when the redirect names no endpoint. - T-8. A client MUST NOT rely on keepalives or an idle timeout, since neither side sends keepalives, and MUST bound every wait with a deadline of its own.
Requests, Replies, And Cancellation
- C-1. A request identity MUST be non-zero and unique within its session, and MUST NOT be reused after its request’s terminal reply. A counter that starts at 1 on every new session satisfies this. A client that exhausts the 64-bit range MUST open a new session.
- C-2. A client MUST register the waiter for a request before it sends the request’s frame.
- C-3. A client MUST route every
Replyby its request identity alone. An unsolicited body never completes a request. A reply whose identity no request is waiting for, because its caller stopped waiting, MUST be discarded and MUST NOT complete any other request. - C-4. A client MUST expect exactly one terminal reply per request. After a
SessionEndingor the end of the transport, no reply follows for any request still in flight. - C-5. A client MUST NOT send a request identity that is in flight. The server answers such a
duplicate with
DuplicateRequestIdunder that same identity, and the original request’s own reply still follows, so the two cannot be told apart. - C-6. A client SHOULD NOT have more than 64 requests in flight on one session. A request
refused with
TooManyRequestsInFlightwas not admitted and MAY be sent again once an earlier request has its terminal reply. - C-7. A client MUST read and route replies whatever the application does with unsolicited frames. Every queue of unsolicited frames MUST be bounded, and a full queue MUST drop events and report the loss rather than stop the reader.
- C-8. A client MUST act on
RequestRejectedby its reason:InvalidRequest,UnsupportedRequest,UnsupportedValue, andDuplicateRequestIdreport a defect in the request. The client MUST NOT send it again unchanged.TooManyRequestsInFlightis covered by C-6.ReplyTooLargesays the complete reply cannot be carried. Repeating an unchanged read returns the same result; a command’s outcome is recovered by its execution reference like any lost reply.ServerBusyanswers a read, which a client MAY repeat later.
- C-9. A client MAY cancel a request with a
CancelRequestthat has an identity of its own. It MUST expectCancelOutcomeunder the cancellation’s identity and, when that outcome isRequested, the target’s own terminal reply as well, in either order. That reply isRequestCancelledor the target’s ordinary reply.BeforeAdmissionmeans the target has no effect.AfterAdmissionmeans its effect continues and is recovered by its execution reference, except that a domain clock attach answered so attaches nothing (K-1). A client MUST NOT present a cancellation as undoing anything. - C-10. A client that stops waiting without sending
CancelRequestMUST treat the request exactly as a cancellation after admission: the effect may happen, and a command’s outcome is recovered by its execution reference.
Structured Choice Lookups
- Q-1. A client that sends
ChoiceLookupRequestMUST use the target’s typed dependencies: domain for internal schema, branch, relay, codec, VHOST, signaling protocol, JSON/CBOR/AVRO wire schema, or resource choices; domain followed by a relayModelreference for relay fields; domain followed by a codecModelreference for the fields of its output schema; and domain followed by aResourcereference for completed resource versions. It MUST use the distinct wire-schema targets when a form requires an exact format. An ingestor source-reference target MUST carry a domain and MUST match the selected connector and entity; theHTTP ENDPOINTandWEBSOCKETS ENDPOINTtargets return endpoints of thatTYPE, and the others return clients of the pair’s connector. The ingestor codec target carries a domain and returns only decoding codecs. Its unbranched relay target carries a domain, while its branched relay target carries a domain followed by a branchModelreference. Ingestor error relays use the unbranched target. A branch-field lookup carries a domain followed by a branchModelreference. Decoded and relay output fields use the codec-field and relay-field targets above. Processor compatible-input, input-branch output, and materialized-relay targets each carry a domain followed by the first input relay’sModelreference. The first requires the same named schema and branch, the second the same branch, and the third a relay with materialized state in that branch. A junction’s first input uses the ingestor relay target for its selected branch; a reingestor’s first input uses the ordinary relay target. Reingestor routes that construct a different branch use the ingestor relay and branch-field targets. - Q-2. A client MUST use the returned
ChoiceValue, rather than its presentation label, for selection. For completed resource versions it MUST handleResourceVersionNumberandLatestResourceVersionas distinct values. It MUST NOT offer a version absent from the result as a completed upload. It MUST treatMissingContext,StaleContext, andLookupFailedas distinct outcomes, and MUST restart a paged lookup afterStaleContextrather than reuse its cursor.
Routing Statements
A client that accepts NSPL text sends some statements as requests of their own rather than as
commands. Sent as a command, each of these is refused with RequestFailed naming the request to
use.
| Statement | Sent as |
|---|---|
USE <domain> | Nothing: it sets the domain the client puts in later requests. A client MAY send SelectDomainRequest to receive that domain’s observations and to learn whether it exists. |
LIST DOMAINS | ListDomainsRequest |
CREATE SUBSCRIPTION ... | SubscribeRequest carrying exactly that statement |
DELETE SUBSCRIPTION <name> | UnsubscribeRequest |
ATTACH DOMAIN CLOCK | AttachDomainClockRequest for the selected domain |
DETACH DOMAIN CLOCK | DetachDomainClockRequest for the selected domain |
UPLOAD RESOURCE ... | An UploadResource call |
BACKUP ... | CommandRequest, then a DownloadBackup call for the archive its outcome summarizes; a browser makes the call over a console WebSocket of its own |
RESTORE ... | A RestoreBackup call that carries the statement and the archive it names; a browser makes the call over a console WebSocket of its own. Sent as a command, it is refused. |
DESCRIBE BACKUP ... | Nothing: the client reads the archive file itself. Sent as a command, it is refused. |
| Every other statement | CommandRequest |
- L-1. A client MUST put the selected domain in every
CommandRequest, or leavedomainabsent when none is selected. The server keeps no selected domain for commands. - L-2. A client MUST send a statement from the table above on its own. It MUST NOT send several
statements in one
CommandRequestoutside a transaction, and MUST NOT combineDESCRIBE TRANSACTIONorSHOW TRANSACTIONSwith any other statement. - L-3. While it holds a transaction, a client MUST NOT send
SubscribeRequest,UnsubscribeRequest,AttachDomainClockRequest, orDetachDomainClockRequest, which the server refuses then, and MUST NOT change its selected domain, which every request of the transaction must name.
Commands And Execution Identity
- E-1. A client MUST create one execution reference per logical command before it first sends
that command. The reference MUST be 1 to 128 bytes of ASCII letters, digits,
-,_, and.. For every command that can be persistent or part of a transaction, which is every command except the reads Exact Recovery lists, it MUST be a UUIDv7 whose timestamp is the client’s current time; a reference that is not one is refused before any effect. A client SHOULD use a UUIDv7 for every command. - E-2. A client MUST keep the reference together with the exact query text, the domain, the expected transaction position, and the expected preview until the command’s outcome is known. Every repetition MUST send all of them unchanged, under a new request identity. Explicit backup recovery MAY change the client-local archive destination as A-8 specifies; its server capture inputs and selected domain MUST remain unchanged.
- E-3. A client MUST NOT create a new reference for a command whose outcome is uncertain. It MUST repeat the command under the same reference until it receives a terminal disposition, or give up and report the outcome as unknown, naming the reference.
- E-4. A client SHOULD stop repeating a reference well before the retry validity, 15 minutes by
default, has passed since the reference was created, and its clock SHOULD stay within five minutes
of the cluster’s. A repetition after the record was reclaimed is refused with
ExecutionReferenceExpired. A client MUST NOT present that refusal as the outcome of the original command, and MUST decide from the disposition rather than the message text. - E-5. A client MUST check that the
execution_referenceof aCommandOutcomeequals its request’s reference, and MUST treat a mismatch as a protocol violation. - E-6. A client MUST act on each disposition as this table requires:
| Disposition | Required action |
|---|---|
CommandCompleted | Report success. already_existed means nothing changed. |
RequestFailed | Report the failure with its message and diagnostics. |
LeaderRedirect | Nothing was admitted. Follow Leader Redirect And Reconnect, then repeat under the same reference. |
TransactionDetached | Attach the named transaction again, then repeat under the same reference. |
TransactionTakenOver | Report it. The client MUST NOT attach the transaction back automatically. |
OutcomeUnknown | Back off and repeat under the same reference until another disposition arrives. The client MUST NOT report success or failure from it. |
ExecutionReferenceConflict | Report a defect in the client’s use of references. The client MUST NOT repeat the request. |
ExecutionReferenceExpired | Report the original command’s outcome as unknown. |
PreviewStale | Nothing was applied, and the transaction stays open. The client MUST NOT commit again against the current preview without the user’s decision. |
- E-7. A client MUST report a request of several statements from its
statements, one outcome per statement in written order. The command’s own disposition is that of the statement that ended it. - E-8. A client MUST treat
originas information only. ARecoveredoutcome is the outcome of the command, with the message and diagnostics recorded when it happened.
An unknown outcome from an interrupted finalization is subject to E-3 and E-6. Its message can describe effects that already applied; the typed disposition still requires recovery under the retained reference, query and transaction expectations.
Leader Redirect And Reconnect
- D-1. On a
LeaderRedirectthat names the leader with an endpoint, a client MUST open a session atgrpc_urifor a native client, or atweb_console_urifor a WebSocket client. Before it repeats a request that belongs to a transaction, it MUST attach that transaction on the new session. - D-2. On a
LeaderRedirectthat names no leader, or a leader without the endpoint the client needs, a client MUST back off and try again. It MUST NOT construct an address the redirect did not name. - D-3. A client SHOULD remember a bounded set of endpoints it learned from redirects and
LeadershipObserved, and try them, and its configured seeds, when its session is lost. - D-4. A client MUST bound every call with a deadline of its own. When a deadline passes before a command’s outcome is known, the client MUST report the command as uncertain, naming its reference, and never as failed or completed.
- D-5. After it loses a session, a client that restores session state MUST, in this order: open a new session; attach the domain clocks it follows; open the subscriptions it keeps again, each as a new generation, reporting the gap; attach its transaction; and only then repeat outstanding commands under their original references. It MUST have sent every clock attach and subscription of the new session before it sends the transaction attach, because a session that holds a transaction refuses both.
- D-6. A client MUST treat
SessionEnding, the end of the transport, and a protocol violation alike: every request in flight ends without a reply, and every command among them is uncertain.
Transactions
- X-1. A client MUST send
BEGINwith the selected domain, which must already exist, and MUST keep the transaction identity from the outcome’stransactionstatus as the transaction’s handle. - X-2. Every request that appends to the transaction the session holds MUST carry
expected_transaction_positionset to theaccepted_operationsof the newest status the client received for that transaction; that is0right after an emptyBEGIN. A request of several statements carries the position of its first append. The client MUST advance the position only from an outcome that reports it. - X-3. A client MUST report an append as accepted only from its own outcome:
CommandCompletedunder its own reference, carrying atransaction_admission. It MUST report a commit only from the outcome recorded under the commit’s own reference. It MUST NOT infer either from the transaction’s state or counts, includingCOMMITTED. - X-4. A client SHOULD send
COMMITwithexpected_previewset to the preview identity the user reviewed: the one from the last append’s admission, or from an inspection of the attached transaction at its current position. It MUST NOT refresh that preview from aPreviewStalereply, from an inspection of another transaction, or from an inspection at an older position. - X-5. While a commit is
COMMITTING, a client MUST keep waiting for the commit’s own outcome, repeating the commit under its reference after any interruption. - X-6. On
TransactionDetached, a client MUST sendAttachTransactionRequestfor the transaction, adopt the domain of the attached transaction, and repeat the command under the same reference and position.TransactionAlreadyFinishedreports the final status and aggregate outcome of a transaction that ended; the client MUST NOT treat it as the outcome of an outstanding append or commit. After a finished or failed attach, the client MUST repeat each outstanding command under its own reference and original position to obtain its recorded outcome or a definitive failure for a command that was never admitted. - X-7. Ending a session cleanly, by half-closing the gRPC request stream or sending a WebSocket close with no request in flight, reverts the open transaction the session holds. A client MUST end a session that way only when it intends that revert. Any other ending leaves the transaction open for a later attach.
- X-8. A client MUST expect an open transaction to expire after its idle timeout, 15 minutes by default, whether or not a session is bound to it. Only attaching, appending, and commit admission renew it.
- X-9. A client MUST NOT assume a read is linearizable. A read served by any node reflects that node’s applied state. Once a command completes, its effect is visible through each node in the leader’s completion participant set. A node the leader has retired can serve an older state until it catches up.
Subscriptions
- S-1. A client MUST open a subscription with
SubscribeRequestnaming the domain, carrying exactly oneCREATE SUBSCRIPTIONstatement, and settingsubscription_typetoRowexplicitly. - S-2. A client MUST record the handle and schema of
SubscriptionOpenedin the frame reader itself, before it routes any later frame, because the subscription’s rows can follow the reply immediately. - S-3. A client MUST route every subscription frame by its complete handle, the name together
with the generation. It MUST ignore a frame for a handle it does not hold, MUST NOT apply a frame
of one generation to another, and MUST expect the
SubscriptionEndedof an earlier generation to arrive after the opening reply of a later generation with the same name. - S-4. A client MUST treat
SubscriptionEndedas the last frame of its generation. AfterRelayChanged, it MUST NOT read later rows against the ended generation’s schema, and subscribes again to read the relay under its current definition. A client that keeps subscriptions across sessions MUST NOT open an ended generation again on a later session; only a new subscribe under the same name opens it, as a new generation. It MAY complete the deletion of an ended subscription without sendingUnsubscribeRequest, because the server keeps an ended name only until it is reused or its session ends. - S-5. A client MUST surface
SubscriptionDeliveryLostandSubscriptionRowsSkippedas gaps with their counts, and MUST NOT present the rows around a gap as continuous. - S-6. After
UnsubscribeOutcomereportsSubscriptionDeleted, nothing about that generation follows, and the client MAY reuse the name at once. - S-7. A client that keeps a subscription across sessions MUST open it again as a new generation
on the next session, unless the server ended it (S-4), and report the time between as a gap. If
its user deletes a subscription while an opening or reopening is in flight, a late successful
reply MUST be followed by an unsubscribe before the name is used again. When the new session
refuses the reopening, or leaves it unanswered, the client MUST report the refusal and SHOULD send
it again on that session after a wait that grows with each refusal, rather than at a fixed rate. A
client MUST complete the deletion of a subscription no open session holds, because the session
that held it ended or the current session refused to open it again, without sending
UnsubscribeRequest, and MUST release its name; a deletion whose session ends before it is answered is complete too. - S-8. A client MUST bound what it retains for subscriptions, per subscription and in total, and SHOULD let one subscription retain at least one frame of the frame limit.
- S-9. A client that stops reading a
BLOCKINGsubscription holds back the relay it reads. A client that cannot keep up SHOULD useDROPPINGdelivery.
Reading Rows
The reply that opens a subscription carries a RowSchema: the relay’s fields in declared order,
each with its name, type, nullability, and sensitivity, and, exactly when the relay is branched, the
declared branch with its key fields. Every later batch is positional against that schema.
- R-1. A client MUST read a
RowBatchonly against the schema of the generation its frame names. - R-2. Before it exposes any value of a batch, a client MUST check the batch against that
schema, and MUST treat a batch that does not conform as a protocol violation:
- a batch of a branched relay carries a
branch_key, and a batch of an unbranched relay carries none; - a row holds exactly one cell per schema field, and a branch key exactly one cell per key field, in declared order;
- a sensitive field holds
RedactedCellin every row, and no other field does; - only a nullable field holds
NullCell; - every other cell holds exactly the cell type of its field’s declared type, and a fixed-length list holds exactly its declared number of elements;
- a list element is never
NullCellorRedactedCell.
- a batch of a branched relay carries a
- R-3. A client MUST NOT convert a value to another type while reading it. An integer keeps its
width and signedness, a
DATETIMEis signed nanoseconds since the Unix epoch in UTC, aSTRINGis UTF-8 that may contain NUL, and aBYTESvalue is raw octets that may be empty or not UTF-8. - R-4. A client MUST keep a null value, a withheld value, and a present value distinct in its
data model. A presentation MAY render a withheld value as a placeholder such as
"<masked>". - R-5. A client MUST keep the exact bits of
F32andF64values, including the sign of a negative zero and the payload of a NaN, wherever its data model can hold them.
| Declared type | Cell | Value |
|---|---|---|
U8, U16, U32, U64 | U8Cell, U16Cell, U32Cell, U64Cell | Unsigned integer of that width |
I8, I16, I32, I64 | I8Cell, I16Cell, I32Cell, I64Cell | Signed integer of that width |
F32, F64 | F32Cell, F64Cell | An optional scalar that is always present, so a negative zero keeps its sign |
BOOL | BoolCell | Boolean |
STRING | StringCell | UTF-8 text |
BYTES | BytesCell | Raw octets |
DATETIME | DatetimeCell | Signed Unix nanoseconds in UTC |
| Fixed-length list | ListCell | Exactly the declared number of elements, each a cell of the element type |
| Variable-length list | ListCell | Any number of elements, each a cell of the element type |
Language Pitfalls
The conformance report carries values chosen to break careless readers: every integer width at both
extremes, 64-bit values on both sides of JavaScript’s safe-integer boundary, an absent and a
present-zero optional value, text with multi-byte characters and an embedded NUL, bytes that are not
UTF-8, empty text and bytes, -0.0, the smallest subnormal and the largest finite floats by their
bits, the extreme DATETIME nanoseconds, and a redacted field. The corpus adds lists, a NaN with a
payload, infinity, and nullable and sensitive branch key fields.
| Runtime | Pitfall | Required handling |
|---|---|---|
| JavaScript and TypeScript | A Number holds 53 bits of integer precision. | Read U64, I64, DATETIME, request identities, generations, and counts as BigInt. |
| JavaScript and TypeScript | JavaScriptCore, which Bun runs on, canonicalizes a NaN it materializes as a Number; V8 keeps the payload. | Read a float’s bits from the buffer as an unsigned integer when the bits matter, instead of calling the generated value() accessor. |
| JavaScript and TypeScript | Date has millisecond precision and a narrower range than the protocol’s nanoseconds. | Keep a DATETIME as a BigInt of nanoseconds. |
| Java and other JVM languages | There are no unsigned primitive types. | A U64 arrives in a long; compare and print it with the unsigned Long operations. Values read from the binding’s column copies are raw bytes of the declared width, so widen a U8, U16, or U32 without sign extension. |
| C and C++ | Strings may contain NUL, and the binding never terminates them. | Use the length every accessor returns; never treat a value as a C string. |
| Python | A memoryview borrows memory it does not own. | Keep the object that owns the buffer alive for as long as the view is used. |
| Every runtime | A borrowed value reads the frame it was verified in. | Keep the frame alive, and unchanged, while any borrowed value is used, and copy what must outlive it. |
Domain Clock Attachment
- K-1. A client MUST attach with
AttachDomainClockRequestand detach withDetachDomainClockRequest, each naming the domain. It follows each domain at most once. It MUST handle every disposition:DomainClockAttachedwith the clock,DomainClockAlreadyAttached,DomainNotFound, andRequestFailedfor an attach;DomainClockDetached,DomainClockNotAttached, andRequestFailedfor a detach.DomainClockNotAttachedmeans the session does not follow that clock, and the client MUST stop following it as it does afterDomainClockDetached. An attach answeredRequestCancelledattaches nothing, so the client MUST NOT follow the clock on its account; a detach answeredRequestCancelledwithAfterAdmissionhas detached. A client that attaches clocks again on its own, as K-5 requires, MUST NOT decide to send such an attach while a detach of the same domain is unanswered, and MUST send an attach it decided on before that detach ahead of it, so that the detach reaches the attach’s session behind it or finds that session ended. Once the detach is answered, no session then holds the clock on the client’s behalf. - K-2. A client MUST apply the clock in the attach reply before any state or tick frame for
that domain. The attach reply’s state, or a later
DomainClockObservedfor a changed generation, MUST be applied before aDomainClockTickedof that generation. It MUST ignore frames about a domain it does not follow and MUST treatDomainClockAttachmentEndedas the last frame about that attachment. - K-3. A client MUST treat each
DomainClockObservedas the newest installed clock and clear an older generation’s tick or any tick when the clock is not paced. It MUST retain only the newest acceptedDomainClockTickedper domain and generation, ignoring a tick id that does not advance. State changes and tick ids can be coalesced, so it MUST NOT expect every intermediate state or tick. It MUST discard unread ticks when it detaches or loses the session. - K-4. A client MUST read the tick generation and id as nonzero unsigned 64-bit integers; its
logical boundary, authority UTC observation, and serving-node logical reading are signed 64-bit
Unix nanoseconds. A clock’s generation is an unsigned 64-bit integer that is zero before the
domain’s first
START. Period and skew are unsigned 64-bit nanoseconds, the period is nonzero, and the time rate is a required positive finite double.DomainClockAttachmentEndedalways carries its reason. The serving-node reading anchors the tick when the client’s UTC differs from the cluster’s. Domains And Time defines the projection a client computes from a paced clock. - K-5. After it loses a session, a client that follows clocks MUST attach them again, and MUST
NOT assume it saw the changes or ticks made in between. It MUST clear the old tick frontier until
the new attachment reports accepted progress. It MUST treat
DomainClockAlreadyAttachedas the session holding the attachment, keep the clock it last received without a tick, and read the clock from that session’s later frames. A node that is still starting answers such an attach only once it has installed the committed domains, soDomainNotFoundin reply means the domain no longer exists, and the client MUST end the attachment asDomainClockAttachmentEndedwould. When the session refuses the attach for any other reason, the client MUST keep the attachment interrupted rather than drop the domain, and SHOULD send the attach again on that session after a growing wait. An attach the session leaves unanswered past the client’s request timeout ends that session, as any request that outlives the timeout does, and the client attaches the clock again on its next session; a client waiting for clock events MUST then open that session rather than wait for the ended session’s transport to fail. When a new session answers the attach while the previous session still holds the attachment, as after a leader redirect, the client MUST move the attachment without a gap: it keeps the newest tick of a clock that stays paced in the same generation, and ignores a tick of the new session that does not advance it.
Producers
- P-1. A client MUST open a producer with
OpenIngestorRequest, naming the domain, the ingestor, the exact fields it expects, including each field’s optionality and sensitivity, and positive credit no larger than 1,024 batches and 32 MiB. It MUST handle both dispositions:Openedwith the producer’s description, andRefusedwith aClientProducerRefusal. It MUST NOT open a producer while its session holds a transaction. - P-2. The producer’s identity is the request identity of its open. A client MUST start following that producer before the open’s waiter completes, so the admission changes and end that the server sends after the reply find it, and it MUST ignore frames about a producer it does not follow.
- P-3. A batch MUST be one canonical Arrow IPC stream: the schema message, exactly one record
batch, and the end-of-stream marker, uncompressed, without dictionary or extension encodings, and
with exactly the fields of the producer’s description in Nervix’s Arrow representation and
without field metadata. It MUST carry at most
max_batch_rowsrows andmax_batch_bytesbytes of the grant. - P-4. A client MUST track the credit its batches hold: each takes one of the granted batches and
its size of the granted bytes from the moment it is sent until its outcome is read. It MUST NOT
send a batch that does not fit; the server refuses it as
CreditExceededand ends the producer asProtocolViolated. - P-5. A client MUST expect exactly one
SubmissionOutcomeper batch and MUST NOT report completion beforeCompletedarrives; transport receipt is not an outcome. It MAY send a batch again only when its outcome isNotAdmittedwithSuspendedorBusy, only while admission is open, and only after the ingestor’s declared backoff, which starts atretry_backoffand doubles up toretry_max_backoff. It MUST NOT send again, by itself, a batch whose outcome isProcessingFailedorOutcomeUnknown, or one refused for any other reason. A batch sent again follows every batch sent since, so a client that needs submission order MUST NOT have more than one batch outstanding. - P-6. A client MUST stop sending new batches while the newest
ProducerAdmissionChangedsaysSuspended. Admission changes are coalesced, so it MUST NOT expect every intermediate state. It MUST treatProducerEndedas the last frame about the producer, every batch of which has already received its outcome. It MUST continue reading outcomes for admitted batches during an alteration’s suspension, including buffered partial batches. It MUST retain their credit until their outcomes arrive; suspension and a failed alteration do not authorize replay or a replacement producer. - P-7. A client MUST NOT send
CancelRequestfor a submitted batch; the server refuses it withInvalidRequest, and the batch’s outcome still follows. A caller that stops waiting MUST leave the outcome retrievable, so it is never lost. - P-8. A client MUST close a producer with
CloseIngestorRequest, and MUST expect every batch’s outcome before the close’s reply. When its session is lost, it MUST report every batch sent without an outcome as of unknown outcome and MUST NOT resend any such batch. A client retaining a desired producer MAY open a new attachment on a new exchange only after itsSTARTgeneration, endpoint contract, fields, policy and grant match the original description. The new open uses a fresh request identity. A changed contract or generation, a removed or stopped endpoint, or a schema mismatch requires an explicit new producer. A temporary capacity or owner refusal MAY be retried with bounded physical backoff.EndpointUnavailablewhile the serving node establishes its process-start catch-up proof is such a temporary refusal. Close and drop MUST win over an in-flight restoration.
Emitter Consumers
- E-1. A client MUST open a
TO CLIENTemitter withOpenEmitterRequest, naming its domain, emitter and exact ordered output fields, including optionality and sensitivity. It MUST supply nonzero batch and byte limits; the requested byte limit MUST hold one maximum-sized IPC batch. An open inside a transaction is refused. The open’srequest_idbecomes itsConsumerId. - E-2. A client MUST continue reading transport frames while an application waits for
consumer credit or processes an output batch. A pending
ReadEmitterBatchRequestdoes not acknowledge delivery and MUST NOT block command replies, producer outcomes, domain clock frames, orSettleEmitterBatchRequestreplies. A client whose caller stops waiting for a read SHOULD keep the read and hand its reply to the next read of the same attachment: the server may already have assigned an attempt to it, and a reply nobody reads leaves that attempt unsettled until the emitter’s ACK timeout. - E-3. A
Batchoutcome is one Arrow IPC stream with exactly the opened schema and one batch. The client MUST check its row count againstmembers, and treat the source relay and optional 32-byte branch fingerprint as metadata, never as extra fields. The fingerprint does not carry raw branch key values. A read may instead returnEndedwhen its attachment is gone. Since an end does not say whether the endpoint moved or changed, a client retaining the desired consumer MUST report the interruption and check a fresh open against its pinned contract. - E-4. A client MUST settle only the current attempt reference with
Ack,Retry, orReject. The rejection reason is nonempty, at most 1024 UTF-8 bytes, and MUST NOT contain sensitive values. It MUST awaitConfirmedbefore treating an ACK as confirmed. A stale reference cannot settle a replacement; repeating a confirmed ACK is idempotent only while the server retains that bounded result. - E-5. A retry or timeout can deliver the same stable identity and IPC bytes with a new
reference, possibly to a different worker. The client MUST design its application side effects
for this duplicate window. There is no durable consumer cursor. An owner or session loss ends
the attachment. A client retaining a desired consumer MAY reopen it on the current or a new
exchange only after the
STARTgeneration, endpoint contract, fields, window, timeouts, grant and batch limits match its original open. It MUST surface the interruption before delivering a batch from the new attachment. It MAY retryEndpointUnavailablewhile the serving node catches up, with bounded physical backoff. Removed or changed endpoints require a new consumer open by the application. - E-6.
CloseEmitterRequestreleases the attachment. Closing, losing the session, or losing the forwarding stream revokes its unresolved references. A delivery reference MUST be settled only through the exchange and consumer identity that delivered it; a lost settlement answer MUST be reported as uncertain, and a revoked reference MUST NOT be sent to a replacement attachment. Closing or dropping a desired consumer MUST fence a late restoration. Producers and consumers may share one session; an application may read and ACK output while a submitted producer batch waits for its graph outcome.
Resource Uploads
- U-1. A client MUST upload one archive per
UploadResourcecall. The first frame is anUploadStartwith a non-zerorequest_id, the domain, the declared resource, the upload identity, and a non-zerototal_bytes. The archive follows asUploadChunkframes in order, each non-empty and within one frame, adding up to exactlytotal_bytes, and then the client half-closes the stream. The Rust client sends 64 KiB chunks. Resources defines the archive format. A client that prepares a local archive MUST finish and flush it before using it as an upload body. A preparation failure or cancellation MUST abandon the partial archive while preserving the original failure or cancellation outcome. Local preparation has not admitted an upload. - U-2. A client MUST create one upload identity per logical upload, 1 to 128 bytes of ASCII
letters, digits,
-,_, and., and SHOULD make it a UUIDv7. It MUST keep the identity and the exact archive bytes until the upload’s outcome is known, and every attempt MUST send both unchanged. - U-3. A client MUST check that the reply’s
request_idequals the start’s, and that aResourceInstallednames the upload identity it sent. - U-4. A client MUST act on the reply:
ResourceInstalledreports the installed version.UploadFailedwithInvalidStream,ResourceNotDeclared,SizeMismatch, orQuotaExceededadmitted nothing, and the client MUST NOT send the same stream again unchanged.UploadFailedwithInstallationFailedreports that the upload failed, that its identity is bound to another archive, or that its completion could not be confirmed, and carries the assigned version when there is one. Repeating the same identity with the same archive returns the outcome recorded for it.LeaderRedirectmeans the client streams the archive again, with the same identity, to the leader, or backs off when no leader endpoint is named.
- U-5. When a call fails in transport, or its deadline passes, before a reply arrives, the upload’s outcome is uncertain. A client MUST send the whole archive again under the same identity to learn it, and MUST NOT create a new identity for it.
Backup Downloads
- A-1. A client MUST send
BACKUPas aCommandRequeston its own, under an execution reference that follows E-1 to E-4, and MUST download the archive only after aCommandCompletedoutcome that carries abackupsummary. It MUST keep the reference and the summary until the archive is downloaded or its retention ends. Each domain summary includes the capture cut kind and, for a quiesced cut, its engagement and release times and quiesce counters. - A-2. A client MUST download one archive per
DownloadBackupcall, sending exactly oneBackupDownloadRequestthat names the backup’s execution reference. The answer is oneBackupDownloadFailedorLeaderRedirectframe, or aBackupArchiveStart, the archive asBackupArchiveChunkframes in order, and aBackupArchiveComplete. Any other order is a protocol violation. - A-3. A client MUST check that the start’s size and digest equal the summary’s, MUST refuse an
archive that grows past that size, and MUST accept the archive only after
BackupArchiveCompletearrived and the bytes it received have exactly the summary’s size and BLAKE3 digest. - A-4. A client MUST NOT expose a partial or unverified archive under the name the user asked for, and SHOULD write the archive so that only its owner can read it: an archive holds password hashes and secrets.
- A-5. A client MUST act on the answer:
LeaderRedirectmeans the client downloads again from the leader, or backs off when no leader endpoint is named.BackupDownloadFailedwithExpired,NotOwner,NotRetained, orInvalidRequestis final for this archive, and the client MUST NOT present it as a failure of the backup itself, which completed.BackupDownloadFailedwithReadFailedleaves the archive retained, and the client MAY download it again.
- A-6. When a call fails in transport, stalls, or ends before
BackupArchiveComplete, a client MAY download the archive again, always from its first byte. It MUST NOT bound a download by the command’s request deadline, and SHOULD bound the wait for each frame instead. A client that lost the outcome ofBACKUPitself repeats the command under the same reference, as E-3 requires, to recover the summary. - A-7. A client MUST distinguish each domain’s quiesce timeout from the overall backup command wait. It SHOULD provide a bounded configurable wait without requiring the operator to predict the number of domains. All redirects and reconnects in one wait MUST share one deadline and execution reference. If that wait ends without a terminal outcome, the client MUST report the reference as uncertain and MUST NOT imply that admitted work was cancelled. The native client defaults to a ten-minute command wait; its duration is between one millisecond and 24 hours.
- A-8. Recovery MUST retain the original selected domain, scope, resource inclusion and capture options. The local archive destination MAY change; it is excluded from the semantic request binding. A client MUST respect an execution-reference conflict or expiry and MUST NOT silently issue a fresh reference. An explicit renewed wait does not renew archive retention or server retry validity, and the archive may already be unavailable when its outcome is recovered.
Restore Streams
- P-1. A client MUST send
RESTOREon its own, never in aCommandRequestand never while it holds a transaction. Each attempt is oneRestoreBackupcall: aRestoreStartwith a non-zerorequest_id, the restore’s execution reference, the statement as canonical NSPL, and the exact size and BLAKE3 digest of the archive; then the archive asRestoreChunkframes in order, each non-empty and within one frame, adding up to exactly the declared size; then the client half-closes the stream, or on a console WebSocket keeps the connection open for the reply (T-3a). The Rust client and the web console send 256 KiB chunks. - P-2. A client MUST create one execution reference per logical restore, following E-1 to E-4, and MUST keep the reference, the statement, and the archive’s bytes until the restore’s outcome is known. Every attempt MUST send all three unchanged, and the digest MUST be computed over exactly the bytes the client streams.
- P-3. A client MUST check that the reply’s
request_id, when present, equals the start’s, and that aCommandOutcomenames the execution reference it sent. - P-4. A client MUST act on the reply:
RestoreUploadFailedwithInvalidStream,InvalidStatement,SizeMismatch, orDigestMismatchchanged nothing, and the client MUST NOT send the same stream again unchanged.RestoreUploadFailedwithQuotaExceededorStagingFailedchanged nothing, and the client MAY send the same stream again later.- A
CommandOutcomeis the restore’s outcome, decided from its disposition as E-6 to E-8 decide a command’s:LeaderRedirectmeans the client streams the restore again, under the same reference, to the leader;OutcomeUnknownmeans it streams it again after a backoff; andCommandCompletedandRequestFailedare terminal. ARequestFailedwhose restore report names a failed step leaves the steps before it applied. Clients MUST decode each restored domain’s requiredstatusandstart_version, including in a dry run. WithRESUME, a completed outcome reportsRunningat the archived generation after all state is published; the default reportsStopped. Clients MUST also decode the requiredstart_versionin eachDomainInfofrom domain lists and domain-change events, and MUST reject missing required generation or status fields rather than supply a default.
- P-5. When a call fails in transport, or a frame or the reply does not arrive in time, the restore’s outcome is uncertain. A client MUST stream the whole archive again, from its first byte, under the same reference to learn it, and MUST NOT create a new reference for it. It MUST NOT bound a call by the command’s request deadline, and SHOULD bound each frame, and the wait for the reply once the last frame was sent, instead.
Using The Shared Binding
A host of nervix-client-ffi follows the contract in crates/client-ffi/include/nervix_client.h
and these rules:
- B-1. A host MUST prepare each logical command once with
nx_session_prepare, and MUST execute the samenx_executionagain afterNX_ERROR_UNCERTAIN,NX_ERROR_CANCELLED, orNX_ERROR_DEADLINEto recover its outcome. It MUST NOT prepare a new execution for a command whose outcome is uncertain. - B-2. A host MUST release every object it receives exactly once, with that object’s release function, and MUST NOT use a borrowed pointer after the object it was read from is released.
- B-3. A host MUST hold a reference to an event, taken with
nx_event_retain, for as long as it uses any frame or value borrowed from that event, and MUST release every reference exactly once. It MAY release a reference on any thread. - B-4. A host MUST read every string as a pointer and a length. Strings are never terminated and may contain NUL.
- B-5. A host MUST NOT call the binding from a thread that is driving a Tokio runtime.
- B-6. A host SHOULD read a batch one column at a time with the column accessors. For a string or bytes column it first passes a null data buffer to learn the size it needs.
- B-7. A host MUST treat
NX_EVENT_INTERRUPTEDas a gap in every subscription it names,NX_EVENT_RESTORATION_FAILEDas a refused attempt to open that subscription again, which the session repeats,NX_EVENT_CONSUMER_OVERFLOWas the end of that subscription’s delivery on this session, andNX_EVENT_ENDEDas the last event of that subscription’s generation, which the session never opens again. - B-8. A host that runs
BACKUPthroughnx_session_executereceives the archive in the file the statement names, and reads its size and digest withnx_outcome_backup. After an error that names the backup’s execution reference, the host MAY execute the samenx_executionagain, which recovers the backup’s outcome and downloads its archive while the server retains it. - B-9. A host MUST release every
nx_clock_eventthatnx_session_next_clock_eventornx_clock_event_retainreturns exactly once, and MAY release it on any thread. It MUST treatNX_CLOCK_EVENT_INTERRUPTEDas a gap in that domain’s clock: the changes and ticks in between are not reported, and the clock the restored attachment found arrives as the next state event, or, when the new session already held the attachment, with that session’s later events. It MUST treatNX_CLOCK_EVENT_RESTORATION_FAILEDas a refused attempt to attach that domain’s clock again, which the session repeats; that event carries only its kind and domain. Rust Client Library lists the accessors of each event kind. - B-10. A host that runs
RESTOREthroughnx_session_executestreams the archive the statement names, and reads what the restore’s report says withnx_outcome_restore. After an error that names the restore’s execution reference, the host MUST execute the samenx_executionagain, which streams the archive again and joins the restore or recovers its outcome. - B-11. A host that paces on a domain clock MUST read it with
nx_session_domain_clockonceATTACH DOMAIN CLOCK;completes, before it uses a tick, and MUST hold every later tick to the clock of the tick’s generation as the latestNX_CLOCK_EVENT_STATEevent, or a read taken after it, reports that clock. AfterNX_CLOCK_EVENT_INTERRUPTEDit MUST NOT assume that the clock it read before the gap still holds. AfterNX_ERROR_CANCELLEDorNX_ERROR_DEADLINEfrom an attach, it MUST execute the samenx_executionagain, as B-1 requires, and readnx_session_domain_clockto learn whether the session follows the clock, since that attempt is refused as already attached when the first one took effect. It MUST release everynx_domain_clockexactly once, and MAY release it on any thread. It SHOULD project logical time, waits, and admission with thenx_domain_clockprojections rather than reimplement the mapping’s rounding. - B-12. A host MUST describe an endpoint’s fields exactly with
nx_fields: every field in order, every level of a list type, and each field’s nullability and sensitivity. It MUST read why an open was refused withnx_error_open_refusal, and MUST NOT open a producer or a consumer while its session holds a transaction. - B-13. A host building a batch MUST give every column the levels its type has: states for a
nullable column when a row is null, offsets for every
LISTlevel, and the innermost values in their type’s exact width or as UTF-8 text. It MAY reuse or free its buffers as soon as each builder call returns. A host submitting its own Arrow IPC MUST write one canonical stream of the producer’s schema, P-3’s, and MAY reuse the buffer oncenx_producer_submit_ipcreturns. - B-14. A host MUST take every submission’s outcome with
nx_producer_rejoin, or let it go withnx_producer_release, since a submission holds its credit until then. Anx_producer_submitthat ends withNX_ERROR_CANCELLEDorNX_ERROR_DEADLINEsubmitted nothing, and the host MAY submit the batch again. Anx_producer_rejointhat ends either way leaves the submission with the producer, and the host MUST NOT submit its batch again. It MUST NOT treatNX_SUBMISSION_PROCESSING_FAILEDorNX_SUBMISSION_OUTCOME_UNKNOWNas proof that the batch had no effect, and replays such a batch only when its effects are idempotent. - B-15. A host MUST settle a delivery with
nx_delivery_ack,nx_delivery_retry, ornx_delivery_reject; releasing a delivery never acknowledges it. It MUST key its effects by the delivery’s identity, since a retried, timed-out, or revoked attempt comes again with a new reference, and afterNX_ERROR_UNCERTAINfrom a settlement it MUST treat the delivery as possibly settled. It MUST release everynx_deliveryandnx_batchreference exactly once, MAY release one on any thread, and MUST NOT use a borrowed stream or value after the reference it was read from is released. - B-16. A host MUST treat
NX_ERROR_INTERRUPTEDfromnx_consumer_nextas a gap: no delivery read before it can be settled, and what they carried is delivered again. It MUST treatNX_ERROR_REOPEN_REQUIREDas the end of the handle, read why with the handle’s reopen reason, and open a new one only after checking the endpoint it now expects.
Required State Machines
An implementation that supports a feature MUST behave as the corresponding state machine describes. The state names are not normative.
A request. It is registered before it is sent and ends exactly once:
stateDiagram-v2
[*] --> Registered: identity taken, waiter registered
Registered --> Sent: frame written
Sent --> Assembling: first transfer part
Assembling --> Assembling: next part in order
Assembling --> Answered: joined reply verified
Sent --> Answered: terminal reply
Sent --> Interrupted: session ended, transport lost, or protocol violation
Assembling --> Interrupted: session ended, or a malformed part
Answered --> [*]
Interrupted --> [*]: a command among them is uncertain
A session and a command. The chapter’s Reconnecting A Session shows both: a session moves between connecting, open, redirecting, awaiting a leader, lost, and restoring, and a command keeps one execution reference from preparation to its terminal disposition.
A transaction binding. The client tracks which transaction its session holds:
stateDiagram-v2
[*] --> Unbound
Unbound --> Bound: BEGIN completed, or TransactionAttached
Bound --> Bound: append accepted; position advanced from its outcome
Bound --> Detached: TransactionDetached, session lost, or leader changed
Detached --> Bound: TransactionAttached
Detached --> Finished: TransactionAlreadyFinished
Bound --> TakenOver: TransactionTakenOver
Bound --> Finished: COMMIT or REVERT outcome recorded, or the transaction expired
TakenOver --> [*]
Finished --> [*]
A subscription. The chapter’s Restoration And Bounded Consumers shows the lifecycle a client keeps for each subscription across sessions.
A producer and its batches. A producer handle retains its desired endpoint across sessions; each wire attachment lives on one session, and each batch holds its credit until its outcome is read:
stateDiagram-v2
[*] --> Opening: OpenIngestorRequest sent
Opening --> Refused: Refused
Opening --> Open: Opened, followed before the waiter completes
Open --> Suspended: ProducerAdmissionChanged Suspended
Suspended --> Open: ProducerAdmissionChanged Open
Open --> Closing: CloseIngestorRequest sent
Suspended --> Closing: CloseIngestorRequest sent
Closing --> Closed: every outcome, then the close reply
Open --> Ended: every outcome, then ProducerEnded
Suspended --> Ended: every outcome, then ProducerEnded
Open --> Interrupted: session lost; sent batches unknown
Suspended --> Interrupted: session lost; sent batches unknown
Interrupted --> Restoring: new exchange, fresh open
Restoring --> Open: generation and contract match
Restoring --> Interrupted: exchange lost again
Restoring --> ReopenRequired: generation or contract changed
Refused --> [*]
Closed --> [*]
Ended --> [*]
ReopenRequired --> [*]
stateDiagram-v2
[*] --> AwaitingCredit
AwaitingCredit --> Sent: credit taken, frame written
Sent --> Retrying: NotAdmitted Suspended or Busy
Retrying --> Sent: backoff elapsed, admission open
Sent --> Resolved: any other outcome
Sent --> Unknown: session lost
Resolved --> [*]: outcome read, credit returned
Unknown --> [*]: reported unknown, credit returned
Conformance Evidence
The rules above are exercised by these tests. The Cucumber scenarios run through
just test-scenarios --input <feature>, the unit tests through just test-package-lib <package>,
the wire and corpus tests through just test-client-wire, and the cross-language probes through
just test-client-conformance.
| Rules | Executable evidence |
|---|---|
| F-1 to F-7 | the_checked_in_corpus_is_what_the_encoder_writes_and_reads, the frame and transfer tests of nervix-client-wire such as a_union_discriminant_without_its_member_is_refused, parts_must_belong_to_the_transfer_and_arrive_in_order, and corrupting_a_row_batch_never_panics; the scenario A <runtime> client reads every frame of the conformance corpus the Rust encoder wrote for Go, Node.js, and Bun |
| F-8, D-6 | a_frame_the_contract_does_not_describe_ends_the_exchange and a_session_ending_frame_closes_the_waiters_of_its_exchange in nervix-client-core |
| T-1 to T-6 | Authentication, framing and protocol failures end a native session with their status and The console WebSocket closes a connection that sends something other than a session frame in session_protocol.feature; non_frame_messages_end_the_connection_with_a_close_code |
| T-3a | Every scenario of backup_restore.feature in the web console suite; a_console_download_answers_its_request_and_closes_normally, a_console_restore_is_answered_once_its_declared_bytes_arrived, a_console_restore_whose_client_leaves_early_is_not_answered, a_second_request_on_a_console_download_breaks_the_call, a_console_download_stops_at_its_client_close_while_frames_remain, and the shutdown cases in nervix-server; each_console_websocket_refuses_the_frames_of_another_call in nervix-client-wire |
| T-7 | Web console opened on a follower connects to the leader and Web console reconnects after leader switchover in connection_status.feature |
| C-1 to C-7 | request_identities_start_at_one_and_are_never_reused, response_reordering_cannot_take_another_requests_waiter, a_reply_no_request_waits_for_is_dropped, saturated_event_consumer_cannot_block_a_command_reply, and replies_reach_their_requests_in_whatever_order_they_arrive in nervix-client-core; untracked_domain_push_cannot_discard_a_pending_websocket_request and ordered_requests_beyond_the_in_flight_limit_wait_in_order_for_earlier_replies in the console; Web console refuses a command past its outstanding requests and recovers once they complete in nspl_repl.feature |
| C-8 | A malformed request is refused with a typed rejection and the session keeps serving in session_protocol.feature; a_rejected_request_surfaces_as_a_typed_error |
| C-9, C-10 | A long command leaves the session responsive and a waiter cancelled before admission admits nothing and Cancelling a durably admitted command ends only the wait for it in session_protocol.feature; cancelling_a_command_releases_its_pending_reply |
| Q-1, Q-2 | configured_choices unit tests for exact wire-schema, resource, version, VHOST, signaling, and dependency choices; typed_choices_and_lookup_states_round_trip in nervix-client-wire; A resource-backed codec selects a completed version and file explicitly and A codec can choose a wire schema staged earlier in its transaction in visual_create_codec.feature; WebSocket client and endpoint select an existing signaling protocol in visual_create_client_endpoint.feature; Go and Node.js corpus probes in client_conformance.feature |
| L-1 to L-3 | use_domain_is_served_by_the_client, list_domains_is_served_from_a_domain_list_request, execute_rejects_mixed_client_local_multi_statement_request, execute_rejects_client_local_command_during_transaction, and a_create_subscription_statement_is_sent_as_a_subscribe_request; Implicit multi-command requests are rejected in nspl_transactions.feature |
| E-1 to E-5 | In client_wire_failures.feature: A command lost after durable admission is recovered by its request identity, A reclaimed command identity stays expired after a durable restart, A race across leaders recovers a typed execution reference conflict, A command identity outside its retry window starts no effect, A full command history refuses new identities and keeps every retained result, Concurrent exact BEGIN retries join one durable execution, and Reusing a durable transaction identity with different content fails semantically; a_command_reply_for_another_execution_cannot_claim_success |
| E-6 to E-8 | Leadership lost after durable admission leaves an unknown outcome that a retry recovers in session_protocol.feature; an_unknown_outcome_is_recovered_with_the_same_execution_reference and replies_ask_for_the_routing_their_disposition_needs |
| D-1 to D-5 | A redirect names no endpoint for a leader that discovery cannot reach in session_protocol.feature; The Rust client reconnects through its original seed after the leader stops in client_wire_failures.feature; a_command_redirect_keeps_its_execution_reference, a_command_waits_for_an_election_and_is_sent_again_with_its_reference, a_closed_session_recovers_through_a_configured_seed, and a_reconnected_session_restores_subscriptions_before_it_attaches_its_transaction |
| X-1 to X-3 | In client_wire_failures.feature: A command missing from a committed transaction cannot report aggregate success, Replaying a BEGIN whose response was lost returns the original transaction, Replaying an accepted <append_kind> append does not preflight or append it again, and the two exact transaction batch retries; lost_begin_append_and_commit_replies_retry_the_exact_request and concurrent_commands_capture_transaction_position_in_send_order |
| X-4, X-5 | A commit fenced to a preview the transaction outgrew is refused and stays open in nspl_transactions.feature; a_commit_fences_against_the_basis_its_own_transaction_reported, a_refused_commit_does_not_adopt_an_unreviewed_basis, and an_older_inspection_cannot_replace_a_newer_queue_preview |
| X-6 | A session whose leader lost its binding re-attaches instead of failing and Attaching from a second session takes over an open transaction in nspl_transactions.feature; Web console recovers a reverted command's own outcome after its reply is lost and Web console resolves a Create command held when its transaction finishes during reconnect in nspl_repl.feature; a_detached_transaction_is_attached_again_before_the_command_is_retried |
| X-7, X-8 | A clean session close reverts its open transaction and An orphaned transaction expires and retains its outcome in nspl_transactions.feature; A stalled commit cannot block expiry, another domain, or tombstone cleanup in client_wire_failures.feature; Physical inactivity while every node is stopped expires an open transaction in client_wire_process_restart.feature |
| S-1 to S-6 | Every scenario of session_subscription_lifecycle.feature and session_subscription_options.feature; Published interest starts, reopens, and stops remote subscription fan-out in subscription_interest.feature; A native client does not restore a subscription the server ended and opens its name again on request in client_wire_failures.feature; Web console ends a relay tab the server ended and resubscribes it on request in nspl_repl.feature; a_subscription_type_must_be_selected_and_supported, a_subscription_the_server_ended_is_not_opened_again_on_a_new_session, an_end_its_session_lost_before_it_was_read_is_still_reported, and, in the console, a_generation_the_server_ended_ends_its_tab_which_keeps_its_rows_and_is_not_restored |
| S-7, S-8 | In client_wire_failures.feature: A reconnected native client restores acknowledged subscriptions, A native client deletes a subscription its lost session held and opens the name again, A native client reports a refused subscription restoration and deletes the subscription without the server, and A native client keeps a subscription active while it receives a row that fills most of a frame; Subscription restoration and typed transaction inspection survive the same leader loss in client_wire_qualification.feature; Web console restores a relay tab after its transaction finished while it reconnected, Web console restores a relay tab before it attaches its open transaction again, and Web console bounds a busy relay tab and keeps its REPL responsive in nspl_repl.feature; deleting_while_creation_is_in_flight_drains_its_late_success_before_name_reuse, cancelling_an_in_flight_restore_cleans_up_its_late_success, a_refused_restoration_is_repeated_after_a_growing_delay, deleting_a_subscription_whose_restoration_was_refused_needs_no_server, a_subscription_requested_on_a_closed_session_opens_on_a_new_session, one_subscription_overflow_preserves_other_subscription_events, each_subscription_retains_a_frame_of_the_frame_limit_and_overflows_alone_past_it, a_subscription_past_the_exchange_allowance_overflows_without_evicting_full_subscriptions, and a_row_frame_filled_to_the_frame_limit_reaches_an_active_subscription |
| R-1 to R-5 | A <runtime> client round-trips an operation, typed rows, an error and a closure in client_conformance.feature for every runtime; a_batch_round_trips_every_cell_kind_at_its_bounds, cells_must_follow_their_fields, branch_identity_must_match_the_schema, and lists_must_follow_their_element_type_and_length; a_batch_that_does_not_conform_to_its_schema_is_a_protocol_failure in the binding |
| K-1 to K-5 | A domain clock attachment reply precedes its frames, a detach reply follows them, and a transaction refuses both in session_protocol.feature; the state, tick, and client pacing outlines in domain_clock_attachment.feature; the owner-loss case in domain_clock_contract.feature; The CLI follows a domain clock across a cluster restart in cli_session.feature; A <runtime> client reads the running domain clock it attached to before its ticks and keeps its generations apart in client_conformance.feature for every binding host; an_attach_answers_once_its_node_has_installed_the_committed_domains, an_ended_exchange_interrupts_its_attachments_until_a_new_exchange_attaches_them, an_attached_clock_is_attached_again_on_a_new_session_and_reports_its_clock, a_refused_clock_restoration_is_repeated_on_the_same_session, a_clock_restoration_answered_already_attached_follows_the_new_session, a_clock_restoration_that_reaches_no_server_is_tried_again_by_the_next_read, an_attachment_moved_before_its_old_session_ends_keeps_the_tick_it_reported, a_detach_sent_while_a_refused_restoration_waits_releases_the_clock_for_good, a_detach_the_old_session_answers_during_a_reconnect_stays_released_on_the_new_session, a_restoration_left_unanswered_past_the_request_timeout_wakes_the_waiting_reader, a_detach_refused_during_a_reconnect_leaves_the_clock_restored_on_the_new_session, a_clock_attached_while_a_reconnect_opens_its_exchange_is_attached_again_on_it, an_attachment_moved_while_its_exchange_holds_it_keeps_the_tick_of_an_unchanged_clock, a_detach_waits_for_the_attaches_decided_before_it_and_holds_off_later_ones, ticks_coalesce_per_domain_and_follow_their_generations_state, the Shuttle check shuttle_a_detach_racing_a_restoration_leaves_no_attachment_of_its_clock, and the server_domain_clock_ticked.nxsm conformance frame |
| P-1 to P-8 | Every scenario of client_ingestors.feature and of client_ingestor_process_faults.feature, including the socket loss, full restart, relocation, and forwarding node death cases retaining one producer; a_lost_exchange_leaves_sent_batches_unknown_and_restores_the_producer, a_batch_waiting_for_admission_when_the_session_ends_is_definitely_unsent, a_new_domain_generation_requires_a_new_producer_open, and closing_a_producer_during_restoration_releases_its_late_open in nervix-client-core; every_open_refusal_round_trips, every_submission_outcome_round_trips, producer_events_round_trip_and_name_no_request, and the_largest_submitted_batch_fits_a_frame_and_one_byte_more_does_not in nervix-client-wire; the client_open_ingestor.nxcm, client_submit_batch.nxcm, server_ingestor_opened.nxsm, server_submission_*.nxsm, and server_producer_*.nxsm conformance frames |
| E-1 to E-6 (emitter consumers) | The saturated producer and concurrent consumer and clock restart, generation change, and emitter relocation cases in client_emitters.feature; a_consumer_reports_a_gap_then_reads_through_a_fresh_attachment, an_ended_consumer_attachment_reopens_when_its_contract_is_unchanged, a_changed_consumer_contract_or_generation_requires_a_new_open, temporary_consumer_capacity_refusal_retries_the_same_desired_contract, a_delivery_from_a_lost_exchange_cannot_ack_a_replacement, a_settlement_sent_before_the_session_lost_its_answer_is_unknown, a_read_whose_caller_stopped_waiting_is_taken_over_by_the_next_read, a_close_whose_caller_stopped_waiting_still_releases_the_attachment, and closing_during_restoration_releases_the_late_attachment in nervix-client-core; consumer_replies_round_trip_with_retained_arrow_body and the server_emitter_opened.nxsm conformance frame in nervix-client-wire |
| U-1 to U-5 | An upload stream the protocol does not allow is refused with a typed failure and admits nothing in session_protocol.feature; in resource_describe.feature, An incomplete upload does not admit content or consume its identity, Upload retry reports one assigned version, Upload retry after leader change reports the assigned version, and An uncertain upload completes once across installation and leader change; a_lost_upload_reply_retries_with_the_same_identity_and_archive and malformed_upload_replies_are_rejected_by_their_correlations |
| A-1 to A-6 | Every scenario of backup.feature, including A client that loses its download fetches the archive again until a download collects it, An archive is refused once its execution reference's retry validity ends, and Downloads of another user's backup, or under a reference without an archive, are refused; the console’s A console backup downloads a verified archive whose manifest lists the backed-up domains and A console backup download interrupted by a page reload resumes after the reload in backup_restore.feature; every_download_frame_round_trips and a_download_request_with_an_invalid_reference_is_refused in nervix-client-wire; the backup_download_* conformance frames |
| B-1 to B-7 | The binding tests of nervix-client-ffi, such as retained_references_keep_the_frame_until_the_last_one_is_released, string_and_bytes_columns_are_copied_with_offsets_and_borrowed_per_cell, every_event_kind_reports_its_subscription_and_count, and a_token_bounds_a_call_by_cancellation_and_by_deadline; the C, C++, Python, Java, and Ruby cases of client_conformance.feature |
| B-8 | a_backup_outcome_reports_its_archive and client_errors_are_classified_and_keep_their_causes in nervix-client-ffi |
| B-9 | a_retained_clock_event_outlives_a_reference_released_on_another_thread, a_session_reads_every_clock_event_kind_and_bounds_its_wait, and a_restoration_failure_names_its_domain_and_carries_no_clock in nervix-client-ffi; the clock cases of client_conformance.feature for C, C++, Python, Java, and Ruby |
| B-11 | a_clock_attached_after_start_is_read_before_its_first_tick, every_state_reports_only_the_fields_and_projections_it_carries, a_refused_attach_leaves_the_session_following_what_it_followed, an_attach_cancelled_after_it_was_sent_is_resolved_by_executing_it_again, several_domains_are_read_apart_until_a_detach_or_an_end_withdraws_one, an_interrupted_attachment_reads_its_last_clock_without_a_tick_until_it_is_restored, and a_domain_clock_outlives_references_released_on_another_thread_and_its_session in nervix-client-ffi; the clock cases of client_conformance.feature for the C ABI in process, C, C++, Python, Java, and Ruby |
| B-12 to B-16 | A <runtime> client publishes typed batches through a client ingestor and acknowledges their output through a client emitter in client_conformance.feature for the C ABI in process, C, C++, Python, Java, and Ruby; a_producer_reports_its_description_and_takes_every_outcome_class, a_refused_open_names_its_refusal_and_a_host_argument_is_checked_first, a_consumer_reads_and_settles_a_delivery_and_a_lost_confirmation_is_uncertain, a_reconnect_interrupts_a_consumer_expires_its_deliveries_and_restores_only_open_handles, every_kind_of_column_reads_back_from_the_batch_and_from_its_stream, a_builder_refuses_what_a_column_cannot_hold, and bolero_host_columns_round_trip_through_builder_and_stream in nervix-client-ffi |
| P-1 to P-5, B-10 | Every scenario of restore.feature, including An interrupted restore upload is sent again under its execution reference, A restore repeated under its execution reference joins it or returns its recorded outcome, and A leader change while a restore applies resumes it on the new leader; the console’s restore scenarios in backup_restore.feature; a_restore_start_round_trips, every_restore_chunk_round_trips_and_an_empty_one_is_refused, and every_restore_reply_round_trips in nervix-client-wire; the restore_* conformance frames; a_restore_outcome_reports_its_steps in nervix-client-ffi |
The client representation properties add complete generated-value evidence for F-1 to F-7, R-1 to R-5 and B-1 to B-7. They preserve integer widths, float bits, nanosecond timestamps, schema metadata, cell validity, nested lists, branch and subscription identities, and retained bytes. Opaque JSON and connector payloads are compared as exact text or bytes. The malformed-request scenario also checks both completion page bounds and then sends a valid completion and command on the same session.
Executable Examples
The conformance probes are complete, runnable clients, and each prints the same report as every other probe. They are qualification clients rather than supported SDKs. The Go and TypeScript probes each keep one request in flight and run no persistent command, so they use execution references that are not UUIDv7, which E-1 allows only for reads.
| Probe | Shows |
|---|---|
tests/client_conformance/go/probe.go | A native gRPC client: a pass-through frame codec, Basic authentication metadata, correlation by request identity, following a redirect’s grpc_uri with the same execution reference, and failing on OutcomeUnknown |
tests/client_conformance/node/probe.ts | A WebSocket client for Node.js and Bun: one binary message per frame, BigInt for every 64-bit value, float bits read from the buffer, and following a redirect’s web_console_uri |
tests/client_conformance/go/corpus.go, probe.ts corpus <dir> | Reading every corpus frame without a FlatBuffers verifier, checking identifiers, discriminants, and required values |
tests/client_conformance/c/probe.c, cpp/probe.cpp | The binding from C and from C++ with std::unique_ptr ownership |
tests/client_conformance/python/probe.py | The binding through ctypes, with a memoryview over a retained frame |
tests/client_conformance/java/Probe.java | The binding through the Foreign Function and Memory API, with arena-owned events |
tests/client_conformance/ruby/probe.rb | The binding through Fiddle, with collector-driven release |
Run with the clock argument, each binding probe follows a paced domain clock instead: it attaches
to a running clock, reads the clock the attach reported and its projections before it uses a tick,
follows a STOP and START into the next generation and the attachment the binding restores after
the scenario cuts its session, and detaches. Run with the
io argument, it opens a producer and a consumer, builds typed batches column by column, reads
their output one level at a time, and retries, rejects, and acknowledges it across a session the
scenario cuts.
The Paced Simulation Drivers are runnable applications rather than
qualification clients. examples/paced-simulation/python/paced_simulation.py follows B-11 through
the binding: it reads the attached clock before it submits anything, waits with
nx_domain_clock_wall_duration_until, stamps its readings within nx_domain_clock_admission_window,
pauses from NX_CLOCK_EVENT_INTERRUPTED until the next NX_CLOCK_EVENT_STATE, and runs its
consumers beside its producer on one session, as emitter consumer rule E-6 allows. The Rust driver
beside it does the same with the Rust client.
Both drivers explicitly reopen changed contracts within the current START generation, retaining
the original handle for outstanding producer outcomes. Only domain_stopped and
generation_changed wait for a later generation. Removed endpoints, incompatible fields, and
unusable consumer credit produce configuration errors; no unknown submission is resent.
just test-client-conformance builds every probe and runs it against one- and three-node clusters;
tests/client-conformance-ledger.md
records the runtimes, the build commands, and what each probe checks. The core of the TypeScript
client’s exchange shows C-1 to C-3 in a few lines:
async request(kind: wire.ClientRequest, build: Build): Promise<wire.Reply> {
this.nextId += 1n;
const id = this.nextId;
const builder = new flatbuffers.Builder(256);
const body = build(builder);
wire.ClientMessage.startClientMessage(builder);
wire.ClientMessage.addRequestId(builder, id);
wire.ClientMessage.addRequestType(builder, kind);
wire.ClientMessage.addRequest(builder, body);
builder.finish(wire.ClientMessage.endClientMessage(builder), 'NXCM');
this.socket.send(builder.asUint8Array());
for (;;) {
const message = await this.receive();
if (message.bodyType() !== wire.ServerBody.Reply) {
this.pending.push(message);
continue;
}
const reply = member(message.body(new wire.Reply()) as wire.Reply | null);
if (reply.requestId() !== id) {
throw new Error(`a reply names request ${reply.requestId()} while ${id} is in flight`);
}
return reply;
}
}
Because the probe keeps one request in flight and its replies are small, it treats a reply for any other identity, and any transfer part, as an error. A client with several requests in flight routes such a reply to the waiter its identity names, as C-3 requires, and reassembles transfer parts as F-7 requires.
What A Client Must Not Promise
A client built on this protocol MUST NOT tell its users that:
- a subscription delivers every row, delivers a row exactly once, or can be resumed from a position;
- a failed transaction, a cancelled command, or a lost session rolled anything back;
- a cancelled or timed-out command did nothing, unless the server reported it cancelled before admission;
- an uncertain command failed, or succeeded, before its own outcome was recovered;
- a subscription’s rows arrive in Arrow, or in any encoding other than typed Row frames, or that a columnar form of them exists;
- releasing, reading, or decoding a delivery acknowledged it, or that an acknowledgement whose answer was lost was not applied;
- a session, its subscriptions, its clock attachments, or its producers survive the loss of its connection;
- a submitted batch was delivered exactly once, or that a batch whose outcome is unknown or failed had no effect;
- a backup’s archive can be downloaded more than once, or survives a restart of the node that assembled it;
- a restore whose outcome is unknown changed nothing, or that a restore which failed at a step undid the steps before it.
Subscription Clock Lifetime
A filtered subscription reads the currently installed domain clock through its retained lifecycle, so opening before START does not prevent later filtering. Clients continue to handle domain-time-unavailable skipped-row notices and generation termination using the existing subscription protocol. Internal clock and metric handle retention introduces no additional client request, capability negotiation or recovery step.
Examples
Runnable end-to-end graphs live under examples/:
examples/iot/iot.nsplimplements MQTT smart-factory telemetry with branch-local quality gates, repartitioning, ordering, and alert emission.examples/nats-factory-windows/nats_factory_windows.nspldemonstrates NATS ingestion and route-local window aggregate construction.examples/datalake/datalake.nsplcombines MQTT CBOR, NATS Protobuf, Kafka JSON, correlation, materialized lookup state, Rust WASM enrichment, Kafka/Redis output, and Iceberg tables.examples/wasm-processors/wasm-dual.nsplwires Rust and Go guests into set-only multi-route WASM processors.examples/paced-simulation/paced_simulation.nsplis the graph of two runnable applications, a Rust driver and a Python driver over the shared C binding, that pace a sensor simulation by the domain clock through client ingestors and attached client emitters. Paced Simulation Drivers describes the graph and how to run both programs.
The Docker Compose setup explicitly provisions the external bucket, catalog namespace, and tables used by the datalake graph. Nervix never creates those external entities during node startup.
Kafka ingestion with route-local branch construction
CREATE SCHEMA user_id_branch (
user_id U32
);
CREATE BRANCH by_user SCHEMA user_id_branch TTL 5m;
CREATE SCHEMA notification (
user_id U32,
created_at DATETIME,
payload STRING OPTIONAL
);
CREATE WIRE JSON SCHEMA notification_wire MODE STRICT (
user_id integer,
created_at string,
payload string OPTIONAL
);
CREATE CODEC notification_codec
FROM WIRE JSON SCHEMA notification_wire
TO SCHEMA notification
ENCODE created_at AS RFC3339;
CREATE RELAY notifications
SCHEMA notification
BRANCHED BY by_user;
CREATE CLIENT kafka_main
TYPE KAFKA
CONFIG {
'bootstrap.servers' = 'localhost:9092'
};
CREATE KAFKA TOPIC INGESTOR kafka_notifications
FROM kafka_main
TOPIC 'notifications'
OFFSET BY CONSUMER GROUP 'nervix_consumer'
MODE ACK SEQUENTIAL ACK TIMEOUT 30s RETRY POLICY BACKOFF 200ms MAX 5s
ON QUIESCE SUSPEND DECODE USING notification_codec
TO notifications
INHERIT ALL
BRANCHED BY by_user SET user_id = message.user_id
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
The route output is explicitly inherited from the decoded input. The branch key is constructed only after output finalization and route filtering.
Transforming projection
CREATE DEDUPLICATOR project_notifications
FROM notifications WHERE input.active
DEDUPLICATE ON input.tenant, input.event_id
MAX TIME 10m
BRANCHED BY by_tenant
TO projected_notifications
INHERIT ALL EXCEPT raw, active
SET amount = amount + 1,
normalized = lower(trim(input.raw))
WHERE output.amount > 0
FLUSH IMMEDIATE
ON MESSAGE ERROR SEND TO processing_errors
SET error_reference = error.reference,
error_code = error.code,
source_id = input.event_id,
attempted_amount = partial_output.amount;
INHERIT and SET belong to this route only. Assignments run left to right. input.raw is the
original record, while output.amount is the finalized route value.
ONNX inference
Inferencers keep explicit tensor declarations and name the resource version they load. Their routes are set-only and do not inherit the source row:
CREATE INFERENCER score_message
FROM input_features
USING RESOURCE inference VERSION 1
FILE 'score.onnx'
INPUTS {
"features" DENSE TENSOR<F32>[128] = input.features
}
OUTPUT SCHEMA {
"scores" DENSE TENSOR<F32>[10]
}
BRANCHED BY by_tenant
TO scored_output
SET tenant = branch.tenant,
scores = scores
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG;
The INPUTS mapping may read the original row. Route construction sees immutable generated model
output, the branch, and declared materialized state, but not input or message.
Window construction
CREATE WINDOW PROCESSOR device_summary
FROM device_measurements
FILTER WHERE input.value >= 0.0
WIDTH 5m DURATION
STEP 1m DURATION
BRANCHED BY by_device
TO device_windows
SET device_id = branch.device_id,
samples = COUNT(input.value),
minimum = MIN(input.value),
maximum = MAX(input.value),
average = AVG(input.value)
WHERE output.samples > 0
ON MESSAGE ERROR LOG;
Windows have no AGGREGATE or FLUSH clause. input is legal only inside aggregate arguments,
though aggregate calls may participate in larger scalar expressions.
Generate from materialized state
CREATE GENERATOR synth_notifications
USING MATERIALIZED STATE notification_templates
EACH 100ms
BRANCHED BY by_tenant
TO generated_notifications
SET tenant = branch.tenant,
user_id = relay_state.notification_templates.user_id,
amount = relay_state.notification_templates.amount
FLUSH EACH 1s MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG;
A generator declares exactly one materialized relay. Every concrete materialized branch starts an independent task, and every route sees the same immutable state snapshot for a tick.
Codec emission with staged headers
CREATE EMITTER kafka_notifications
FROM notifications
TO KAFKA kafka_main TOPIC notifications_out
MODE ACK PARALLEL MAX 1000 ACK TIMEOUT 30s
RETRY POLICY BACKOFF 250ms MAX 30s
ENCODE USING notification_codec
INHERIT ALL EXCEPT secret
SET secret = leak_sensitive(input.secret)
WHERE output.active
INVOKE write_header('tenant', input.tenant),
write_header('trace-id', input.trace_id)
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
Header calls execute in order only after payload construction and filtering. Payload and header publication are all-or-nothing for a route attempt.
Paced Simulation Drivers
examples/paced-simulation holds two runnable applications that drive a paced simulation through
a Nervix graph. Each one follows the domain clock, submits sensor readings stamped with the tick
centers that clock reaches through a client ingestor, and applies
and acknowledges the readings the graph constructs through client
emitters, all on one session:
nervix-paced-simulation, inexamples/paced-simulation/rust, is built on the Rust Client Library.examples/paced-simulation/python/paced_simulation.pyis built on the shared C binding, throughctypesand the Python standard library alone.
Both take the same options, print the same report and write the same ledger and effect store, so
either one can continue a run the other began. They are complete programs meant to be copied: the
public scenarios in tests/features/runtime/paced_simulation.feature run them as published against
the published graph, on one node and on three.
The Client Ingestors And Emitters architecture chapter explains the endpoint ownership, ACK, credit, ALTER and recovery contracts these programs exercise.
The Graph
examples/paced-simulation/paced_simulation.nspl creates the paced domain paced_simulation
with a 100 ms tick period and an equal admission skew, and this graph in it:
| Entity | Role |
|---|---|
simulated_readings | Client ingestor of reading with TIMESTAMP AT occurred_at |
live_readings | Client ingestor of the same schema with TIMESTAMP NOW, for comparison |
readings | Relay of observed_reading, branched by sensor |
rejected_readings | Unbranched relay of the rejection notices the ingestors’ error routes publish |
observed_readings | Attached client emitter of the constructed readings |
rejection_notices | Attached client emitter of the rejection notices |
Each ingestor route keeps the reading, adds the domain time it was admitted at, and constructs one concrete branch per sensor. A reading the ingestor cannot admit becomes a rejection notice instead of an error in a log:
CREATE CLIENT PRODUCER INGESTOR simulated_readings
FROM SCHEMA reading MODE ACK PARALLEL MAX 8 ACK TIMEOUT 30s RETRY POLICY BACKOFF 100ms MAX 2s ON QUIESCE SUSPEND
TIMESTAMP AT occurred_at
TO readings
INHERIT ALL
SET admitted_at = now(),
timestamp_source = 'at'
BRANCHED BY by_sensor SET sensor = message.sensor
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR SEND TO rejected_readings SET reading_id = input.reading_id, occurred_at = input.occurred_at, error_code = error.code, error_message = error.message
ON GENERAL ERROR LOG;
The output emitter is attached, so a reading’s batch completes for its producer only once a consumer acknowledged the reading’s output. Its batches never mix sensors, because a client batch never combines concrete branches:
CREATE ATTACHED EMITTER observed_readings
FROM readings
TO CLIENT SCHEMA observed_reading
MODE ACK PARALLEL MAX 4 ACK TIMEOUT 30s RETRY POLICY BACKOFF 100ms MAX 2s
INHERIT ALL
BATCH MAX MESSAGES 256 MAX SIZE 256KiB
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
The exported schema carries the reading’s own event time, occurred_at, and the domain time the
ingestor admitted it at, admitted_at. Clock observations and deliveries are separate streams
with no order between them, so an application that needs an output’s event time reads it from the
output.
The graph needs nothing outside Nervix. Its file includes separate domain bootstrap and USE
phases. Create the domain, load the transaction beginning at BEGIN with the CLI’s domain
selection, then start the clock at the pace the simulation should run at:
nervix-cli --command "CREATE PACED DOMAIN paced_simulation WITH PERIOD 100ms SKEW 100ms;"
nervix-cli --domain paced_simulation --command \
"$(sed -n '/^BEGIN;/,$p' examples/paced-simulation/paced_simulation.nspl)"
nervix-cli --domain paced_simulation --command "START AT NOW TIME RATE 4.0;"
Check the printed command dispositions: ordinary --command currently prints a refusal without
a nonzero exit status. USE must run on its own when entered as an NSPL command.
Running The Drivers
The Rust driver:
just paced-simulation --server http://127.0.0.1:47391 --username default \
--password "$NERVIX_PASSWORD" --ticks 100 --sensors 3
just paced-simulation builds and runs the release binary, which is the same as
cargo run --release --package nervix-paced-simulation -- <options>.
The Python driver loads the shared binding named by --library or NERVIX_CLIENT_LIBRARY, and
needs Python 3.12 or later. just paced-simulation-python builds the binding and runs the driver
with it:
just paced-simulation-python --server http://127.0.0.1:47391 --username default \
--password "$NERVIX_PASSWORD" --ticks 100 --sensors 3
Any live node serves either driver; the node routes producers and consumers to the nodes that
execute the endpoints, so the session’s node need not execute any of them. The credentials can
come from NERVIX_USERNAME and NERVIX_PASSWORD instead.
| Option | Default | Meaning |
|---|---|---|
--server | http://127.0.0.1:47391 | The gRPC session endpoint of any live node |
--username, --password | NERVIX_USERNAME, NERVIX_PASSWORD | The session’s credentials |
--domain | paced_simulation | The paced domain the graph runs in |
--timestamps | at | at submits to simulated_readings, now to live_readings |
--ingestor, --emitter, --rejections | the example’s | The endpoints, when a graph names them differently |
--ticks | 50 | How many tick centers to submit readings for; 0 only replays |
--sensors | 3 | Sensors reporting at each tick center; each is a concrete branch |
--burst | 1 | Readings each sensor reports at each tick center |
--invalid-every | 0 | Stamp the first reading of every Nth tick before the admission window |
--consumers | 1 | Competing consumers of the output emitter |
--consumer-delay | 0s | How long the output consumers wait before they attach |
--consumer-leave-after | none | The last output consumer leaves after this many deliveries |
--processing-time | 0s | Application work per delivery, before its effect and its ACK |
--credit-batches, --credit-bytes | 8, 1MiB | The producer credit to ask for |
--inspect-every | none | Inspect the ingestor and the output emitter at this interval |
--ledger | paced-simulation-ledger.jsonl | The application-owned input ledger |
--effects | paced-simulation-effects.jsonl | The idempotent effect store |
--replay | off | First resubmit the ledger’s readings that did not complete |
--follow-generations | off | After STOP and START, continue in the new generation |
--deadline | 10m | How long to wait for outstanding outcomes once submitting stops |
Durations are written like NSPL durations, such as 250ms, 1s or 5m, and byte sizes as a
count of bytes, KiB, MiB or GiB.
Choosing The Pace
Three settings decide how fast a simulation runs and how much it produces:
START AT NOW TIME RATE <rate>sets how fast domain time runs against wall time:0.5runs the simulation at half real time and4.0at four times real time. A driver submits each tick’s readings once the clock reaches that tick center, so the rate paces the drivers too.- The domain’s
PERIODspaces the tick centers, and so the readings of one sensor. --sensorsand--burstset how many readings each tick center produces:sensors × burstreadings per period of logical time, in one batch.
Slow application processing does not slow domain time. A driver that falls behind submits the
centers it missed as soon as it can, stamped with their own times, and the ingestor admits them as
long as its window still retains them: the newest 256 reached centers. A center the window no
longer retains is skipped with a SKIPPED line instead of being submitted only to be rejected.
What A Run Does
- It opens one session and attaches it to the domain clock: the Rust driver through
Client::attach_domain_clock, the Python driver by executingATTACH DOMAIN CLOCK. The clock the attach reports decides whether the run can start: a stopped clock, an unpaced domain or a clock the node has not installed within 30 seconds ends the run with exit status 2. - It opens the rejection consumer and the output consumers before the producer, because a reading completes only once a consumer acknowledged its output: a run that waited for its outcomes before consuming would wait on itself. An endpoint that is not running on its node yet is asked again for up to 30 seconds; any other refusal ends the run with exit status 2.
- For each tick it reads the latest clock the session observed, waits until domain time reaches the next tick center, and stamps every reading of the tick with that center. It waits until the batch fits in the producer’s credit, then reads the window the ingestor admits event times in again, so a batch held back for credit is never checked against a window that has moved on. It writes the readings to the ledger, submits them as one batch, and awaits the batch’s outcome without holding up the next tick.
- Each consumer decodes a delivery, records its readings in the effect store, and only then acknowledges it.
- Once it submitted its last tick it waits for the outcomes it still awaits, and for the
rejection notices of its completed batches, up to
--deadline; closes its producer and consumers; detaches the clock; and prints its summary.
The drivers take the tick centers from the committed logical origin and PERIOD, one center per
period. Every wait, current logical instant and admission check comes from the attached clock’s
projections: wall_duration_until, logical_time_at and
admission_window in Rust, and nx_domain_clock_wall_duration_until,
nx_domain_clock_logical_time_at, nx_domain_clock_admission_window and nx_domain_clock_admits
through the binding. FLUSH EACH and the admission window follow domain time, while ACK TIMEOUT,
retry backoff, session liveness and the drivers’ own deadlines are physical.
The Report
Both drivers print one line per observation on standard output, each line whole, and the same
lines for the same run. Errors go to standard error, prefixed with error:.
| Line | Printed when |
|---|---|
CONNECTED server=… domain=… | The session opened |
CLOCK generation=… state=paced period=… skew=… origin=… anchor=… rate=… | The attach, or a later observation, reported a paced clock |
CLOCK generation=… state=stopped|uninstalled|unpaced | The clock is in another state |
CLOCK restoration_failed, CLOCK unavailable, CLOCK ended | Restoring the attachment failed for now, the session could not reopen, or the domain was removed |
INTERRUPTED clock domain=… | The session holding the clock attachment ended |
CONSUMER opened consumer=… emitter=… generation=… batches=… bytes=… | A consumer opened, with its granted credit |
PRODUCER opened ingestor=… generation=… batches=… bytes=… max_batch_rows=… max_batch_bytes=… | The producer opened, with its granted credit |
READY generation=… | The run starts submitting |
SUBMIT tick=… occurred_at=… window=…..… | The clock reached a tick center, inside that admission window |
WAITING credit outstanding_batches=… outstanding_bytes=… | The next batch waits for the producer’s credit |
SKIPPED tick=… reason=behind_admission_window | The window no longer retains a tick center the run fell behind |
SUBMITTED tick=… readings=… bytes=… | The producer holds the batch |
OUTCOME tick=… readings=… completed|not_admitted|processing_failed|outcome_unknown [cause] | The batch’s terminal outcome |
PROCESSING, DELIVERY … applied=… duplicates=…, ACK … confirmed | A consumer handled a delivery |
REJECTED reading_id=… occurred_at=… error_code=… error_message=… | A rejection notice arrived |
INTERRUPTED consumer=…, CONSUMER reopen_required|reopened|left|unavailable … | A consumer’s attachment changed |
CONSUMER delayed, PRODUCER unavailable, … close_failed, CLOCK detach_failed | A consumer waits to join, the session could not be restored for a submission yet, or closing failed |
REOPENED generation=… ingestor=… reason=… | The producer accepted a changed contract within the current START generation |
REOPENED generation=… ingestor=… after=… | The run moved on to a new START generation |
REPLAY tick=… readings=…, REPLAY expired|skipped … | A replay resubmitted, or could not resubmit, ledger readings |
INSPECT … | An inspection of the ingestor and the output emitter |
WAITING notices outstanding=…, NOTICES missing=… | The run waits for rejection notices of completed batches |
STOPPING reason=… | The run stops submitting |
SUMMARY … | The run ended |
The summary counts readings submitted, the readings of each outcome, the effects and
rejection_notices the consumers recorded for the first time, duplicates of either that the
store already held, the START generations the run planned in, ticks_observed, inspections,
credit_waits, the peak_outstanding_bytes the producer held, and the output
consumers_joined and consumers_left. The exit status says how the run ended:
| Status | Meaning |
|---|---|
| 0 | Every reading the run submitted completed |
| 3 | A reading did not complete: it was not admitted, its processing failed, or its outcome is unknown |
| 2 | The command line, the graph or the domain is not one the simulation can run against |
| 1 | The run failed on its own side, or outcomes were still outstanding at --deadline |
Observing A Run
--inspect-every runs SHOW INGESTORS and DESCRIBE EMITTER on the driver’s own session while it
produces and consumes, and prints the ingestor’s admission, producers, outstanding batches and
bytes, and the emitter’s consumers and retained batches:
INSPECT simulated_readings from=CLIENT PRODUCER schema=reading owner=node-2 status=running admission=open producers=1 outstanding_batches=1 outstanding_bytes=110592 admitted_batches=1
INSPECT emitter=observed_readings consumers=1 retained_batches=2
The same commands, DESCRIBE INGESTOR simulated_readings and nervix-cli domain-clock, work from
any other session; inspecting an emitter claims and acknowledges nothing. The ledger and the
effect store are ordinary JSON-lines files the application owns:
{"record":"reading","reading_id":"g1-t12-s0-0","generation":1,"tick":12,"sensor":"sensor-0","occurred_at":"2026-10-02T18:00:01.200Z","value":372,"ingestor":"simulated_readings","timestamps":"at","stamp":"center"}
{"record":"outcome","reading_ids":["g1-t12-s0-0","g1-t12-s1-0"],"outcome":"completed","cause":""}
{"kind":"reading","reading_id":"g1-t12-s0-0","sensor":"sensor-0","tick":12,"occurred_at":"2026-10-02T18:00:01.200Z","admitted_at":"2026-10-02T18:00:01.230Z","timestamp_source":"at","value":372,"generation":1}
A reading’s identity names the START generation, tick, sensor and burst position it was planned for, so it stays the same when the reading is replayed.
Event Time: TIMESTAMP AT And TIMESTAMP NOW
--invalid-every 3 stamps the first reading of every third tick one nanosecond further than the
skew before the oldest center the window retains. Submitted to simulated_readings, which reads
TIMESTAMP AT occurred_at, that reading is rejected: its batch still completes, because the error
route acknowledges the reading once its notice is published, and the rejection consumer records the
notice with code validation. Submitted to live_readings with --timestamps now, the same
reading is admitted, stamped with the domain time it arrived at, and its occurred_at travels
through unchanged.
TIMESTAMP NOW is still tested against the window: the arrival time lies less than one period after
the newest reached center, and it is admitted within the skew of that center. The example’s skew
equals its period, so every arrival is admitted. With a skew smaller than the period, arrivals that
fall between two windows are rejected like any other event time outside them.
Interruptions, Outcomes And Replay
The clock and the data endpoints recover independently, and neither waits for the other:
- When the session ends, the clock attachment reports
INTERRUPTED clockand the driver stops planning: the clock it read before the gap no longer says which event times the ingestor admits. It continues once the restored attachment reports the clock again. The producer and the consumers restore their own attachments when the domain’s START generation and their endpoint contracts are unchanged, and a consumer reportsINTERRUPTED consumeronce. - A batch whose session ended before its outcome arrived ends
outcome_unknown session_lost. The drivers never resend it on their own: it may have been admitted and processed. The run exits with status 3. --replayresubmits, before the run simulates anything, every ledger reading whose latest outcome is notcompleted, with its identity and content unchanged, when it belongs to the START generation the domain runs now and the window still admits its event time. A reading of another generation is never submitted against this one.- A replayed reading may already have been applied: its output may have been delivered before the outcome was lost, or redelivered after the session came back. The effect store is keyed by the reading’s identity, so the consumer acknowledges such a delivery as a duplicate without applying it again. Processing is at least once; each effect happens once because the application makes it idempotent, not because delivery is exactly once.
- A delivery whose acknowledgement was lost comes again with the same identity and a fresh reference, and is recorded as a duplicate.
- When an endpoint requires a new open because its contract or schema changed, it was removed,
it violated the protocol, or restoration was refused, the driver opens a fresh producer or
consumer immediately in the current START generation. Consumers print
CONSUMER reopen_requiredandCONSUMER reopened; the producer printsREOPENEDwith the unchanged generation and the reason. Anendpoint unavailablerefusal is retried within the existing 30-second physical open budget. A flush-only change preserves the contract and requires no application reopen. Clock and credit waits recheck the endpoint every 200ms, so a slow domain rate does not defer reopening until the next tick. Suspended admission pauses planning before it creates readings. An alteration drains admitted work before it publishes the changed contract. The example keeps its bufferedFLUSH EACH 100msbranched input route: an intake hold force-flushes partial batches through the open shared relay and waits for their ACK roots before the full alteration hold closes the relay gates. Admission stays suspended across both holds. An unsuccessful alteration keeps the committed contract and requires no reopen. - Each replacement validates the example’s exact fields again and requests the same credit
limits. A schema mismatch, removed endpoint, or unusable credit limit ends the run with status
2 and the endpoint’s refusal text. A fresh producer has fresh credit; outstanding submissions
retain their original producer and credit until their outcomes resolve. Nothing with an unknown
outcome is resent. A tick already planned when its producer ended is recorded as
not_admitted not_sentif it never left the client, or with the node’s refusal if it reached the node. These readings can be resubmitted only by an explicit--replay. STOPends the producer and the consumers with the generation; their batches that had not completed end not admitted or of unknown outcome. With--follow-generationsthe driver waits for the nextSTART, opens a new producer and new consumers under the new generation, and continues from that generation’s reached tick center. Without it the run finishes. Thedomain_stoppedandgeneration_changedreopen reasons wait for this next generation. Once the clock reports a later paced generation, the explicit new producer also retriesdomain stoppedwithin the same 30-second open budget: the serving node can report that clock before its endpoint-open view has applied the correspondingSTART. Both drivers still require the opened producer’s own generation to have a paced clock before planning any readings. Opens in the current generation retain their immediatedomain stoppedrefusal, and schema, missing-endpoint and other terminal refusals remain terminal while following a generation.- With
--consumer-delaythe output emitter has no consumer at first: it retains the batches it could not deliver, whichDESCRIBE EMITTERshows, and the producer stops being granted credit once its outstanding batches fill it. The backlog is bounded by that credit. Consumers that join drain it with explicit acknowledgements, and one that leaves with--consumer-leave-afterhands the rest to the others.
Stopping
Ctrl-C stops the planning. A batch still waiting for credit has sent nothing and ends not admitted
with the cause not_sent; the driver then waits for the outcomes it awaits, closes its endpoints,
detaches the clock and prints its summary, as at the end of every run.
Limits
The ledger and the effect store are files the example flushes but does not make durable; an
application that needs its input to survive a crash keeps a durable outbox. Nervix persists
neither payloads nor delivery state: an owner lost while it held a batch loses it, and only a
replay from the application’s own ledger recovers it. A rejection notice the run did not receive
before --deadline is delivered to the next consumer of the rejection emitter, which records it
then.
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.
Architecture And Internals
This section explains how Nervix is built and how it behaves internally.
Use it for:
- control-plane and data-plane structure
- execution primitives: backend selection, native and browser capabilities, permissions, and verification boundaries
- data-plane concurrency: publication, ownership, ordering, production-owner models, and diagnostic locks
- execution plans: the committed schedule, typed revisions, local binding, publication, and recovery
- typed failure ownership, the report model, fixed outcome boundaries, sensitivity, recovery, enforcement, and public diagnostics
- typed absence, semantic states, validation boundaries, state identity, and boundary encodings
- connector crate ownership, the shared source and sink contract, and host execution
- HTTP emitter request preparation, delivery, lifecycle, and qualification
- client ingestors and emitters: native batches, application ACKs, credits, forwarding, restoration, buffered ALTER and paced workflows
- domain-clock mapping, authority, lifecycle, progress, and execution-time semantics
- cluster interconnect security, traffic isolation, and delivery semantics
- host name resolution: the node’s asynchronous DNS resolver, its configuration, cache and bounds, the client-library hooks and every call site, residual driver and browser resolution, deployment limits, and the evidence behind them
- client-to-node communication: the FlatBuffers session protocol, request correlation and dispositions, exact recovery, redirect and reconnection, Row subscriptions, uploads, and the shared client binding
- transaction impact planning, scoped quiescence, inspection, and retained outcomes
- consensus durability, replication pacing, log retention, and snapshot recovery
- resource versions, pinned bindings,
LATESTresolution, rebinding, and the HTTPS listener refresh - WASM guest state: checkpoints, generations, resets, rejected-state recovery, and replay
- shutdown phases, drain guarantees, and crash recovery
- integration-test lifecycle: harness deadlines, node startup, teardown, and the suite watchdog
- deterministic interconnect simulation: the Turmoil build mode, fault model, seeded replay, failure records, scenario matrix, and CI budget
- property testing and fuzzing: registered Bolero targets, current-domain corpora, ordinary regressions, sanitizer libFuzzer runs and exact failure replay
- the expression VM: compilation, columnar execution, kernels and SIMD, function families, window aggregates and sketches, and adding a function
- SIMD kernels: typed buffer ownership, dispatch and build targets, exact results, caller catalog, qualification, and measured evidence
- runtime semantics that are easier to understand from the implementation side
- relay/state internals
If you are looking for NSPL usage, setup, or feature-level guidance, start in the Manual.
Architecture Overview
At a high level, Nervix has two major halves:
- a control plane that owns definitions, scheduling, lifecycle, and coordination
- a data plane that moves records through the runtime graph
The graph itself is built out of persisted NSPL models:
SCHEMAWIRE JSON SCHEMAWIRE CBOR SCHEMAWIRE AVRO SCHEMACODECRESOURCERELAYCLIENTVHOSTENDPOINTINGESTORJUNCTIONDEDUPLICATORREINGESTOREMITTER
Those models are stored in the registry and scheduled into a DomainSchedule. The schedule says
which nodes exist in a domain, which server is primary for each node, and which servers hold
replicas. A relay is one of those runtime nodes: it has one owner, and only materialized relay state
has scheduler-selected replicas.
The decision layer converts each committed domain schedule into one in-memory typed execution revision, including node identity, placement, branch and schema contracts, resources, source and sink plans, processor programs, and message-error routes. The runtime binds local resources and executes that revision without reading a Model. Execution Plans follows construction, publication, incremental application, rebuilds, and recovery end to end.
Each declared layer module carries a gated nervix::layer source contract alongside its ownership
documentation. The compiler uses that semantic owner through aliases, re-exports and source moves.
Configuration Models cannot enter data-plane interfaces or bodies; connector engines explicitly
declare that they consume plans. General infrastructure, including consensus persistence, may
retain Models when its owning contract requires them. Parser definitions belong to the language,
edges and harnesses; import and manifest provenance is checked separately before cfg selection.
The session command, mutation, transaction and subscription adapters are edges, as are archive
section encoding and restore intake. Their consensus and decision dependencies retain those layers’
ownership. Data-Plane Concurrency
states the finite compiler rules and their supported effects.
This graph configuration is persisted with strong control-plane consistency. It is separate from runtime execution state and from the hot-path records moving through the graph.
Before a transaction changes that graph, the control plane plans its ordered execution steps and their before/after affected topology. Its typed report separates required pause scopes from actual gate engagement and remains inspectable through commit and retention. Transaction Quiescence And Impact Inspection defines those guarantees.
Each boundary carries its decisions forward as types: absence stays explicit, distinct states carry their own data, and required identities are validated before execution or recovery. The Typed States And Validation Boundaries chapter covers these rules across the language, registry, runtime, connectors, and clients.
The Errors And Diagnostics chapter explains which layer owns each failure, how typed reports cross boundaries, and how runtime and public edges report them.
Consensus access is restricted by operation. Observers can read locally applied state and watch changes. Proposers can also attempt replicated mutations, administrators manage membership and leadership transfers, and protocol receivers apply Raft messages independently of those capabilities. Consumers receive only the capabilities their responsibilities require. Observation does not imply a linearizable read, and proposal authority does not imply that the node is currently leader.
Raft still checks leadership when accepting a proposal. If leadership changes after a command is admitted, the server preserves the leadership-loss result so the client can redirect to the new leader or wait for an election. Long-running transaction commits validate leadership between steps; their replicated progress survives leader loss so the new leader can continue execution. Raft protocol listeners remain available on every live node through elections and membership changes.
The runtime then instantiates that schedule:
- ingestors attach to external systems or local endpoints
- relay owners buffer and route records, including fan-out to remote consumer nodes and sessions
- junctions, deduplicators, and reingestors transform or route records between relays
- emitters encode records and publish them externally
Ingestors and emitters meet external systems through connector crates. The server composes a validated typed plan with an integration-specific source or sink, and its data-plane host owns intake, routing, buffering, acknowledgements, and lifecycle. The Connector Crates And The Connector Contract chapter defines that boundary, its source and sink families, delivery and commit points, special integrations, and failure semantics. The Ingestors and Emitters manuals define the public NSPL forms.
HTTP Emitter Architecture traces one HTTP request from its validated configuration and Arrow source record through preparation, response classification, retry, acknowledgement, and recovery, with the one- and three-node qualification evidence.
Every expression follows the same one-way conversion. Parsing produces expression Models; registry validation compiles them to check exact types and sensitivity. The decision layer lowers the committed schedule into typed programs, and node installation compiles them against its bound schemas and UDFs. The data plane executes those programs over Arrow batches with a caller-supplied domain timestamp. The VM Functions chapter defines that pipeline, columnar execution and its selected-row conditionals, row and batch errors, kernel and SIMD choices, every function family, branch-local window aggregates and sketches, and how to add a function. Expression Functions owns the public function contracts.
Clock ownership follows the same one-way conversion. NSPL parsing turns PERIOD, SKEW, start
timestamps, and rates into validated vocabulary values. The control plane commits one mapping and
fenced authority for a paced START. Each data-plane execution binds a capability for the exact
domain and generation and obtains one timestamp snapshot before calling an expression engine or a
WASM guest. Engines accept that timestamp as input and cannot read actual UTC. Logical deadlines
carry their domain and generation; operational deadlines are a separate process-monotonic type
whose construction is limited to timeout, retry, and external-I/O owners. Actual UTC enters the
data plane through one physical-time owner in the connector contract and is projected into logical
time or used by an explicit external observation contract.
The Domain Clock chapter defines the mapping, lifecycle generation, authority fence, progress delivery, local installation, execution snapshots, admission arithmetic, and logical-deadline boundary in detail.
Runtime execution has its own persistence boundary. Selected execution-node state is persisted through periodic snapshots and replication, but in-flight message batches and ACK state are hot-path memory only. Relay buffers, concrete presence, fan-out, and metrics are owner-local and unreplicated; optional materialized records use the relay’s state replicas.
RESOURCE sits between the control plane and the runtime. The control plane numbers each uploaded
version and completes it only after every live node has verified and installed the same archive.
Every model that uses a resource pins one completed version, and its runtime consumers load exactly
that version from their node’s local copy. An upload adds a version and moves nothing; only a model
mutation, such as REBIND RESOURCE, changes the version a model binds. A TLS VHOST is one such
binding: the HTTPS listener of every node presents the certificate of the version it pins.
The Resource Versions And Bindings chapter defines catalog and store
ownership, the version lifecycle, what each binding loads and when, LATEST resolution, rebinding,
the DYNAMIC TLS refresh, and failure and recovery.
All node-to-node traffic uses one mutually authenticated TLS 1.3 and HTTP/2 listener. Independent management, command, replication, relay, and bulk pools isolate traffic, while typed operations, bounded payloads, admission quotas, memory budgets, and deadlines keep one workload from exhausting the node. Application health is observed separately from transport connectivity.
The Cluster Interconnect chapter defines peer identity, connection topology, wire contracts, resource isolation, exchange forms, relay delivery and reconciliation, consensus and bulk traffic, domain-clock progress, application health, lifecycle behavior, and observability.
Clients reach the cluster through a separate public boundary. Every node serves the client session protocol: FlatBuffers frames, verified against one public schema and bounded by fixed session limits, over native gRPC and the console’s binary WebSocket. A request identity pairs each reply with its request, while a durable execution reference names a command’s effects across retries, redirects, reconnects, and restarts, so a client recovers an uncertain outcome exactly instead of repeating it. Row subscriptions deliver typed cells encoded directly from Arrow columns.
The Client Session Protocol chapter defines that boundary, its framing, correlation, dispositions, exact recovery, redirect and reconnection, subscriptions, uploads, bindings, and guarantees, and the Client Implementation Manual states what a client implementation must do.
Stopping a node is its own ordered lifecycle. One owner advertises that the process incarnation is terminating, closes public admission, keeps the services admitted work depends on alive while the local graph drains, and only then tears down the node’s tasks, connections, and storage. One physical deadline bounds all of it, and a repeated signal or an expired deadline ends the process the way a crash would.
The Shutdown And Recovery chapter defines stop requests, phases and outcomes, the deadline and exit statuses, cordon versus terminating placement eligibility, intake stop, graph drain and force flush, ownership handoff during shutdown, connector acknowledgement and commit boundaries, terminal teardown, what survives each ending, and restart recovery.
The rest of this section splits control-plane semantics from data-plane semantics because that distinction is fundamental to how Nervix behaves.
Architecture Rules And The Debt Ratchet
Nervix separates facts about Rust source from facts about the APIs a compilation resolves. The source checks inspect inactive code too. The compiler checks identify definitions, normalized types and source-owned contracts in each declared legal configuration. A method name alone is never evidence of its owner or effect.
just ratchet runs both owners. just validate and just validate-ci require the same gate;
formatting is applied by the former and checked by the latter. The compiler emits ordinary
nervix:: diagnostics. The four structural counts compare with debt-baseline.json; a count
may decrease and cannot increase. just ratchet --update records a decrease only after the
complete gate passes. No numeric allowance controls a semantic rule.
Rule Contracts
The fifteen rule families below have one implementation each. The family name remains useful for describing its purpose; a compiler family can emit more than one diagnostic where a missing contract and a violated contract require different corrections.
| Family | Purpose and owning fact | Allowed example | Rejected example | Enforcement and qualification |
|---|---|---|---|---|
data_plane_lock_acquisitions | Resolve synchronization APIs and the execution contract of their callers. Concurrency owns contexts, effects and bounds. | A task-local HashMap::entry; a registry acquisition with a lifecycle contract. | A recurring callback reaching a DashMap shard or a lifecycle-only helper. | Compiler sync_acquisition, lifecycle_call, unknown_effect; resolved receiver, helper, callback, trait, macro and expectation fixtures. |
bare_error_signatures | A Nervix failure contract carries its complete contextual report. Errors owns outcome, library and foreign-interface classifications. | Result<T, Report<DomainError>>; an exact reason-bearing outcome contract. | An owned failure in an alias, inferred future or stream without its report. | Compiler bare_error_signature; associated-output, alias, inference, metadata and report-carrier fixtures. |
discarded_results | Preserve a failure or classify recovery at its consuming operation. | A propagated failure or the recovery owner’s explicit Discarded operation. | A failed outcome discarded through a binding, projection or drop. | Compiler discarded_outcome; complete and partial-discard, borrowed, tuple and collection fixtures. |
bare_unwrap_and_expect | A canonical Option/Result panic needs a construction or checked guarantee, expressed through meticulous. | verified after checking the condition; an unrelated object’s method named expect. | Canonical unwrap or expect, including aliases and UFCS. | Compiler bare_panic; canonical/custom methods, dereference and call-form fixtures. |
model_matches_in_data_plane | Configuration Models stay inside their owning boundary; data plane and connector engines consume plans. | A vocabulary Model or a typed connector plan. | A forbidden Model in a body, normalized interface or nested aggregate. | Compiler model_in_data_plane; source layer, value and input contracts, cross-crate and projection fixtures. |
parser_imports_outside_edges | Language dependencies belong to the language and its declared edges. | The session adapter consuming public parsing; a runtime consuming Models. | A language reference outside its authorized layer, including aliases. | Compiler parser_dependency plus disjoint inactive import/manifest provenance; qualified names, reexports and dependency fixtures. |
data_plane_cluster_awaits | Recurring processing retains observations instead of awaiting cluster control access. | A lifecycle observation; reading an installed immutable publication. | Recurring work awaiting a declared cluster-access capability. | Compiler cluster_await; direct, bound-future, helper and trait fixtures. |
string_node_ids | Declared node identities use the shared typed vocabulary. Typed States owns the identity contract. | ClusterNodeId; an ordinary string unrelated to node identity. | A declared identity interface or field represented by a string. | Compiler node_identity; alias, interface, aggregate, field and metadata fixtures. |
write_once_rwlock_fields | Installation-only state is an installed value rather than replaceable locked absence. | An installed value; a genuinely mutable field with its lifetime reason. | An installed field containing an RwLock, or an unclassified candidate requiring review. | Compiler installed_state and state_contract; normalized representation, lifetime, review and exception fixtures. |
clamped_arithmetic | The resolved primitive integer or Duration API chooses saturation or wrapping instead of checked overflow. | Checked arithmetic; pointer wrapping; an Instant elapsed-time query; a specifically justified retry ceiling or modular mixer. | An integer/Duration saturating or wrapping operation without its actual arithmetic meaning. | Compiler clamped_arithmetic; integral and Duration owners, aliases, UFCS, callbacks, authored macros and narrow-expectation fixtures. |
combinator_control_flow | Canonical Option/Result adapters must not hide a branch or a sequence. | One transformation, an iterator pipeline, or an unrelated custom method. | map_or/map_or_else, successive governed adapters, or a governed adapter with a closure containing statements. | Compiler combinator_control_flow; canonical/custom receiver pairs, UFCS, closure structure, diagnostic cardinality and source spans. |
as_casts | Count actual Rust cast expressions, not words in imports, attributes or qualified paths. | use Value as Alias; r#as; <Type as Trait>::item; ordinary literals. | An expression using as, including a const expression nested in a qualified type. | Stable source parser; inactive cfg, raw identifiers, nested syntax, macro fragments and bounded source properties. Clippy independently denies conversions in its broader build scope. |
testing_feature_struct_fields | A product struct has one field shape rather than a shape selected by testing. | A test-only item, an API gate or a conditional documentation attribute. | A named or tuple struct field whose presence depends on testing, including a conditional cfg attribute. | Stable source parser; field ownership, tuple, inactive, compound-predicate and cfg_attr controls. Enum fields are outside this specific struct rule. |
files_over_3000_lines | Keep product and in-crate test files within a source-size bound. | At most 3,000 lines, counting a final line without a newline. | An eligible file with more than 3,000 lines. | Source discovery and physical line count; exact boundary and newly authored source tests. |
functions_over_complexity_30 | Keep each function’s decisions within a cyclomatic complexity of 30. At full coverage a function’s CRAP score equals its complexity, so a function above 30 exceeds a CRAP threshold of 30 at any coverage. Function complexity owns the analyzer and its limits. | A function the pinned analyzer scores at most 30; an existing function above 30 that grows, which keeps the count. | A function newly scored above 30, including one in an inactive configuration or an in-crate test file. | Pinned cargo-crap analysis over source discovery; exact 30/31 boundary, inactive configuration, macro, trait and nesting limits, eligibility, and missing, failing, other-version, skipped-source, empty, configured, undiscovered and unreadable-file fixtures. |
The Option/Result adapter catalog covers and_then, filter, map, map_err, map_or,
map_or_else, ok_or, ok_or_else, or_else, unwrap_or, unwrap_or_default and
unwrap_or_else. It resolves their inherent owner. A custom map_or is not a canonical branch,
and an Iterator’s map is not an Option/Result adapter. A chain is diagnosed once at its outer
governed call; a later binding separates steps. A closure’s statement structure is a source
fact, not a claim that the compiler understands the business meaning of its transformation.
Arithmetic recognition uses the defining crate and inherent primitive integer/Duration owner. Type aliases and UFCS preserve that identity. A function reference retains the arithmetic diagnostic before a callback or function-pointer type can erase its definition. A wrapper’s authored body is checked when its configuration compiles; matching words on the wrapper do not grant it permission or make it a canonical API. Instant’s saturating elapsed-time query is a query with a defined zero result, and pointer wrapping is pointer arithmetic. Neither is the integer/Duration overflow choice this family enforces.
Scope And Configuration
Compiler coverage is precisely tools/nervix-lint/configurations.toml: the ordinary workspace’s
libraries and binaries, browser root, testing library and separate legal Shuttle, Loom, Turmoil
and Deloxide configurations. The diagnostic variants have their own library and binary
invocations. They are never combined with --all-features. Every expected target and report
must be present, current and complete. Authored macro expansion and captured caller tokens retain
provenance; generated build-directory code and external definitions are explicit exclusions.
The compiler does not infer the APIs of code that no declared configuration compiles.
Source discovery includes tracked Rust and newly authored Rust not ignored by Git. The four
counts retain their product-debt scope: directory components named tests, benches or
examples are excluded. In-crate tests.rs, *_tests.rs and test_fixtures.rs count toward
file size and function complexity but are excluded from cast/field debt. Test-only items are excluded from those two
syntax counts. Other inactive configuration branches remain visible. These exclusions belong
to these counts; they do not exempt a file from the primitive, manifest, clock or language
provenance validators, which own their broader authored-source contracts.
The stable source parser reads original Rust text and walks cast expressions and attributes attached to actual struct fields, retaining token locations even beside Unicode comments. It also parses Rust fragments in authored macro groups after replacing metavariable markers with their identifiers, preserving token locations. Repetition bodies are visited independently. Opaque macro DSL words and invalid fragments are not reinterpreted as Rust expressions. An invalid top-level Rust file fails with its owning path and location. This does not expand a macro or prove which tokens an external procedural macro would generate.
The parser requires no rustc internals or analysis driver. just typed-ratchet-source-build
builds it on the stable toolchain. Native compiler coverage builds it on that producer’s pinned
toolchain so LLVM profiles and exporters match. The tooling workspace stays outside product
dependencies, including browser and ordinary release graphs.
Function Complexity
functions_over_complexity_30 counts the functions whose cyclomatic complexity exceeds 30, as one
pinned analyzer measures it: cargo-crap at the version scripts/cargo_crap.py declares, the same
build the advisory CRAP report of just check-coverage runs. just install-cargo-crap builds that
version with cargo install --locked into target/tools/cargo-crap-<version>/, through the
compiler wrapper the host configures, and keeps a build that is already there. just ratchet,
just test-ratchet-units, just check-coverage, just test-crap-report and
just coverage-crap-report-runner depend on it, and the CI checks, coverage and extra-tests
jobs build it through kache. A binary that reports another version is refused with
the command that installs the pinned one; a different version in PATH is never used.
The complexity is classical McCabe: one, plus one for every if, for, while, loop, match
arm, &&, || and ?. A function at exactly 30 is not counted and one at 31 is. Growing a
function that already exceeds 30 leaves the count unchanged; a new function above 30, or one that
crosses it, raises the count.
The analyzer walks the repository root with the eligibility above. It reads syntax and expands nothing, so these are outside the count:
- a
#[test]function and everything inside a#[cfg(test)]module; - a function inside a macro, whether an authored
macro_rules!body or an invoked item macro such ascfg_if!; - the body of a trait’s provided method;
- a function nested in another function’s body, and the decisions of a closure, which belong to no reported function.
Fixtures pin each limit, so a new analyzer version that changes one fails a test. Inactive configuration branches stay visible.
Missing analysis never reads as zero debt. The count fails, naming the cause, when the pinned
binary is missing or reports another version; when the analyzer exits with an error or writes a
warning, which it does only for a source it could not read or parse; when it measures no function;
when its report has another version or a ? weight other than one; when a .cargo-crap.toml
in the repository root or above it could change the analysis; when it measures a file source
discovery does not govern, such as one in a nested repository; and when a discovered file is
hidden, a symbolic link or tracked but ignored by Git, which the analyzer’s walk would skip.
just ratchet --show functions_over_complexity_30 lists each function’s file, line, name and
complexity. The baseline holds counts rather than sites, so when a count rises the gate lists every
site of that count and the function a change added is named among them. Decomposing a function is
the repair. Extracting a forwarding wrapper only to move the number is the workaround
just lint-audit attributes to a lowering of this count.
Exceptions And Ownership Review
A semantic exception is a reason-bearing expect at one operation or binding, gated through
cfg_attr(nervix_lint, ...) or nervix_primitives::expect_lint!. A module or function allowance,
blanket warning suppression, missing reason, unused expectation or expectation covering another
operation fails. Arithmetic reasons state the actual ceiling, capacity or modular computation;
“cannot overflow” does not justify wrapping. A retained defect names its repair task.
Source counts have no semantic exception channel. A current structural occurrence remains counted until its owner changes it; a compiler expectation cannot hide its inactive source. Likewise, source provenance cannot be waived by declaring a lifecycle or error contract.
Review still owns the truth of reasons, lifetime classifications, architectural roles and bounds. The compiler checks their placement, shape and supported effects, not arbitrary whole-program ownership. Finite API recognition does not establish business semantics for an unrelated custom method, an erased indirect transformation or an opaque dependency. A paired rejection fixture proves the specified route; it does not prove every possible alternative absent.
Authoring And Qualification
Add a semantic rule in Rust beside its owner. Qualify an allowed/rejected pair that differs in the semantic fact being enforced, not just a name. Include aliases, call forms, macro origin, caller or metadata effects when applicable, exact diagnostics and source locations, and invalid, unfulfilled and widened expectations. Change the receiver or definition to an unrelated object as a false-positive control; restore the canonical API through an alias or UFCS as a false-negative control. Source rules also qualify inactive branches and lexical distractions.
Use just test-ratchet-units, just test-typed-ratchet-reports,
just test-typed-ratchet-compiler, just test-typed-ratchet-contracts and
just test-typed-ratchet for the source, report, ordinary and separate mode qualification.
just typed-ratchet --inventory --show displays a calibration without claiming the diagnostic
gate passed. just ratchet requires the gate. Cache qualification remains
just qualify-typed-ratchet-cache; changed source, annotations, rules, dependencies and
configuration invalidate evidence.
The registered typed-source-syntax Bolero property uses bounded current Rust syntax, complete
source input/site equality and deliberate cast/field counts. Ordinary corpus/randomized checks
are required; just fuzz typed-source-syntax is actual sanitizer-backed fuzzing, and CI runs that
mode only for a PR labeled fuzz. A sanitizer skip supplies no fuzz or coverage evidence.
Compiler mode qualification checks analysis, not execution of the runtime’s concurrency models.
The sanitizer compiler adapter preserves the source parser’s span contract as described in
Property Testing And Fuzzing.
The concurrency, error and
state chapters retain those operational owners.
Auditing Lint Workarounds
A rule that misjudges code invites code shaped around the rule. The textual counters this gate
replaced matched method spellings inside named files, so lowering a count sometimes meant
respelling an operation or moving it rather than changing what the rule was about. just lint-audit finds code shaped like such a workaround and attributes it to the history that
produced it.
The compiler pass records review candidates beside its acquisition findings in every complete
analysis, so the audit reads the evidence just ratchet uses and runs no second analysis.
just lint-audit --inventory audits with warning levels while the gate still has open
diagnostics, and --configuration selects declared configurations as an inventory. Each
candidate carries its owning body, that body’s execution contract (declared, or recurring by
reachability from a recurring caller), its spans and macro origins, and the configurations that
compiled it.
| Kind | Resolved pattern | Controls that are not candidates |
|---|---|---|
repeated_lookup | Keyed operations (get, get_mut, contains_key, insert, remove, entry, indexing and their IndexMap forms) of a standard, hashbrown, IndexMap, DashMap or JSON map collection, on one receiver place with one key value, where a later operation can run after an earlier one. guarded marks a later operation in a branch the earlier result selected. | Exclusive branches, a receiver or key assigned between the operations, different keys or collections, and another type with map-like method names. |
key_allocation | A borrowed lookup handed an owned copy of its key, or an entry handed a copy that a hit discards. | Borrowed keys, owned insertion keys, Copy values, shared-handle clones and formatted keys. |
borrowed_copy | An owned copy whose only use is a borrow, such as &value.clone() or text.to_string().len(). | Copies that are moved, Copy values, shared-handle clones and text formatted from another value. |
duplicated_branch | A call or assignment that every branch of one exhaustive if chain or match repeats, with the same resolved callees, generic arguments and type. | An if without else, exits, constructed values, and arms that dispatch one spelling over differently typed bindings. |
forwarding_wrapper | A synchronous function whose whole body passes its parameters unchanged and in order to one local function, with its caller count and whether it carries a source contract, implements a trait method or calls into another file. | Transformed, reordered or partial arguments, library callees and constructors. |
discarded_error | Result::ok, unwrap_or, unwrap_or_default, is_ok, is_err, an unwrap_or_else, map_err or or_else closure that ignores its failure, an Err(_) arm, if let Ok without else and let Ok(..) = .. else, when the failure carries information. | Unit, uninhabited and zero-sized failures, and failures handed on. |
disguised_clamp | Checked primitive-integer or Duration arithmetic, or a checked integer conversion, whose absence falls back through unwrap_or, unwrap_or_default or unwrap_or_else. | Overflow decided by its own statement. |
relocated_acquisition | A synchronization acquisition outside the files the textual lock counter read, written by a commit that lowered that count. | Acquisitions in the counter’s files, and ones no lowering commit wrote. |
Attribution blames each candidate’s lines in the working tree, reads the diff hunk of the commit
that completed them, and replays the history of debt-baseline.json. For every count the
candidate’s kind could have worked around, it records whether that commit introduced or lowered the
count, whether its hunk, the rest of the candidate’s file or the commit’s other files removed the
counter’s governed spelling (such as .entry( for the lock counter, counted only in the files that
counter read at the time), and whether the code predates the count. The report keeps the hunk’s
removed and added lines around the candidate’s line. When the completing commit only carried the
pattern forward, the earlier commits of the same lines are read for that evidence and the one found
is recorded as the origin. --pull-requests also reads, through gh, the commits a squash merge
folded away, finding its pull request by the number the subject names or, for a subject without one,
by the merged commit, and records a commit that removed the spelling while writing one of the
candidate’s own lines. A read that ends with a server error or a dropped connection is attempted up
to four times with a doubling backoff; a failure that persists, or any other failure, ends the audit
without a report.
The suggestion is suspected when a count event coincides with a spelling removed from the
candidate’s own hunk or file, or a folded commit rewrote the candidate’s lines away from it. A
removal in another file explains the candidate only for the lock counter, which read named files, so
moving an operation out of them lowered it. The suggestion is possible when only an event or a
removal is present, or when the code was written into a file the lock counter read before the
compiler resolved that count, since such code could avoid the spelling without changing the count;
unattributed when the code was written while a count existed without any of these; pre-existing
when it predates every plausible count; and uncommitted for lines not yet committed. A suggestion
is evidence for review, never a classification: chronology alone proves nothing.
Candidate identities hash the kind, file, owner, primary source and pattern facts without byte
offsets, so an unrelated edit keeps them, and a repeat within one owner takes an ordinal.
audit.json and audit.md land in target/lint-audit/<source identity>/. They are generated
evidence: review dispositions, inventories and measurements belong on the owning task, and nothing
reads them back as policy.
Review classifies each candidate as confirmed lint-caused, suspected, unrelated, pre-existing or
intentional, recording the source revision, owning rule, before and after code, reason and
disposition. A repair keeps lazy and fallible initialization, side-effect counts, borrowed-key hit
behavior, exact equality, lifetimes, reentrancy, error and acknowledgement semantics and ordering.
An entry is not a repair where a hit would discard an allocated key, where a callback runs between
the lookup and the insertion, or where the miss path needs the whole collection. Fewer searches do
not make an entry faster either: on a small ordered map whose hits dominate, a lookup can cost
less than building the entry, so the measurement covers hit-heavy and miss-heavy key
distributions. A performance claim needs measured lookup, comparison and allocation counts and
same-host Criterion or just benchmark-ab evidence; a shorter function or a lower count is not a
speedup.
Errors And Diagnostics
The architecture rule matrix records the compiler contracts for failure signatures, discarded outcomes, canonical panic operations and Option/Result control flow. Expanding an error projection into an explicit Result branch preserves its complete report, context and recovery disposition. The source parser reports malformed authored Rust through a typed path/location context over the original parse cause; rendering includes that cause once.
Nervix gives a failure its meaning at the boundary that can decide what went wrong. That meaning
travels through the graph as a semantic error and an error-stack report. A public edge renders a
diagnostic only after it has made the decision the error permits. Ordinary control outcomes, such
as waiting for materialized state or following a new leader, remain distinct from failures.
The web console applies the same distinction to structured choice lookups: an absent form
prerequisite shows a neutral hint, and stale context offers a fresh request. A failed lookup,
closed session channel, or unreadable reply appears as an alert.
This chapter owns the error and diagnostic model across layers: who gives a failure its meaning, how a report is created, enriched, inspected and rendered, which outcomes are not failures, where a report ends at a fixed wire, ABI or stored shape, what a diagnostic may say, and how the rules are enforced. It names the error types of every layer. The contract a failure belongs to stays with the chapter that owns its subsystem:
| Subject | Owning chapter |
|---|---|
| Missing values, distinct semantic states and the boundary that validates them | Typed States And Validation Boundaries |
| Row and batch errors of the expression VM, and each function’s error contract | VM Functions and Expression Functions |
| Guest result codes, rejected saved state and failed checkpoints | WASM State And Recovery and Rust WASM Guest SDK |
| Source and sink failures, per-record outcomes, retry and acknowledgement | Connector Crates And The Connector Contract |
| Lookup outcomes and which caller owns a failed lookup | Name Resolution |
| Exchange forms, remote failure classes on the wire and relay acknowledgements | Cluster Interconnect |
| Durable appends, uncertain writes and a failed consensus store | Consensus Storage And Replication |
| Planning, binding and message-error delivery while a schedule is applied | Execution Plans |
| Primitive mode, target and permission rejection; distinctions between verification outcomes | Execution Primitives |
| Branch-local execution and acknowledgement tracking | Data Plane and Data-Plane Concurrency |
| Command dispositions, typed rejections and exact recovery | Client Session Protocol |
| Planned and actual transaction impact diagnostics | Transaction Quiescence And Impact Inspection |
| Stop and drain phases, and the outcomes a stopping node drops | Shutdown And Recovery |
The NSPL forms for error routes are in Message Errors and Error Routes.
The Report Model
A Failure Has One Owner
A fallible operation returns error_stack::Result<T, E>: a semantic thiserror context inside an
error-stack report. E belongs to the module that decides what the failure means, and the
report around it carries every cause and context the failure gathered on its way to the caller.
The owner extends its existing error type when an operation gains another failure case. A second
type for the same failure would force callers to reconcile two meanings. Values a caller acts on
belong in typed fields, such as a domain, a node, a relay, a revision, or a limit and the size
measured against it; display formatting happens when the result is reported.
Creating A Report And Adding Context
The owner creates the report where it detects the failure, with Report::new over its own
context. An error a library returned enters the same way: change_context on the library’s result
makes that error the first context of a new report and puts the owner’s context above it. Each
outer layer that changes what the failure means then adds its own context with change_context,
naming its operation, node, route, branch, placement, or target, and keeps the report it received.
It does not format a cause into a string and then classify that text as a new failure, and it does
not replace the report by a clone of its current context.
A context describes its own failure once:
- It names its own operation and identity and leaves its cause to the frame beneath.
error-stackrecords the#[source]chain of the error a report is created from as frames of their own, so a context that also printed its source would name the cause twice in the rendered chain. A context with a#[source]therefore leaves the source’s wording to the next frame. - It does not repeat an identity a context above it states. The node error policy names the node, so the contexts beneath it do not, and a node that declares a setting names itself above the setting’s error.
- It carries no payload value. Sensitive Data And Observability states what a context may name.
A source recorded that way is a frame of text: the rendered chain shows it, and a caller cannot
find it by type. A cause a caller must classify therefore stays a typed context of the report, or a
typed field of the context above it, as the resolver’s DnsLookupFailure is of a RabbitMQ or MQTT
connection error.
Attachments are not contexts, and the rendered chain does not show them. A printable attachment carries the description a connector or a task gives its own failure, and one reader uses it: an emitter shows the first printable attachment of a failure as its transient error, and only without one the outermost context. A typed attachment carries a value for one consumer, as a sink attaches the retry delay its receiver stated for the emitter host to read. Neither replaces a context: a caller decides from contexts and typed fields.
Two failures of one operation stay one report. When a model alteration fails after its domain paused and resuming the domain fails too, the resume’s report is added beside the alteration’s and one context is placed above both, so a caller still finds a leadership loss in either branch. A report cannot be copied. A failure that reaches several routes or input batches is therefore reported once, with the union of their acknowledgements, as one runtime event.
Deciding From A Report
A caller decides from typed data and never from rendered text. Most decisions read the report’s current context. A few look for a typed context beneath it, or call the lookup the cause’s owner provides.
| Caller | Typed data it reads | Decision |
|---|---|---|
| Rust client | ClientError as the current context | Recover the session and send the request again, report an uncertain command or upload, or return the failure |
| Shared C binding | ClientError as the current context, and the BackupDownloadError beneath a failed download | The NX_ERROR_* kind of the failure |
| Session edge | ConsensusError::LeadershipLost as the current context of a proposal report | A leader redirect in place of a failed command |
| Transaction application | RuntimeError’s revision preparation and readiness timeouts as the current context | Apply the cluster state again and retry, in place of failing the transaction |
| Session transaction binding | SessionTransactionBindingError as the current context | The TransactionTakenOver or TransactionDetached disposition |
| Postgres, MySQL and ClickHouse sinks | The driver’s SQLSTATE, code or named rejection in the current context | Whether the destination refused rows for good or the attempt failed as infrastructure |
| Connectors that resolve through a driver’s DNS hook | DnsLookupError::find_in over the causes the driver wraps around the lookup | Keep the typed lookup failure beneath the connection failure |
| Emitter host | The retry delay a sink attached to its publish failure | Lengthen the retry schedule to the delay the receiver stated |
| WASM runtime | The saved-state verdict of the guest-call failure, an invalid emission, or an exhausted execution limit | The stage the failure is reported under, whether rejected-state recovery starts, and whether the instance is discarded |
| Restore conversion | An execution admission or staging refusal among the report’s contexts | Release what the conversion holds and convert again within its wait for room |
Outcomes That Are Not Reports
An ordinary outcome is a typed value, never an error report. Waiting for or skipping a message
whose materialized state is unavailable, following a new leader, a temporary Busy or Suspended
refusal a producer resends, a settlement’s StaleReference, a domain clock attachment’s
AlreadyAttached and every command disposition are results their caller branches on. Each section
below says which outcomes of its boundary are ordinary.
A failure of one row or one record is a typed value in its batch’s outcome too. The expression VM
returns the row errors of a batch beside its result, each a SideError with its reason and
expression span. A sink answers a write with the records it delivered, the records it rejected,
each with a structured message error, and at most one infrastructure failure of the attempt, which
is a report; a record in neither list stays unresolved. Branch construction keeps the outcome of
each row. None of these builds a report or formats a message per row: the typed value becomes a
structured message error only where a route’s policy reports it, and hot paths do not allocate a
formatted diagnostic when the variant already names the failure. A source poll is the exception
that carries reports: it returns the report of each record it could not read beside the messages
it could, so the rest of one external response continues through intake.
Fixed Outcome Boundaries
A report is local to the process that created it. Where a failure crosses a wire, an ABI, a stored record or a public protocol, the shape on the other side is fixed, and the report is projected into it exactly where that shape is constructed. That construction is the one place a report’s current context is cloned or its chain rendered into a field; Guarantees And Limits lists the contexts that still copy a cause as text.
Leading empty consensus probes return their matching boundary, conflict or higher vote through the existing management response. A conflict, higher vote or incomplete acceptance ends that input before data-stream admission. A management request failure uses the same unreachable-peer error as a heartbeat; subsequent data-stream setup failures remain transport failures. Neither path retries inside the adapter or changes the caller’s retry gate.
An ended consensus append input completes normally without a peer operation or a peer error. With a first request available, stream setup may return the existing typed admission, membership or transport failure as an unreachable-peer result. The caller’s retry gate runs before that setup attempt, so reporting an immediate local refusal does not create an unpaced retry loop.
| Boundary | Fixed shape | What crosses | Where a report resumes |
|---|---|---|---|
| Replicated transaction mutation | The Raft response’s TransactionMutationError | The state machine’s typed refusal, cloned from the report’s current context when the response is built | The proposer creates a new report from the typed refusal |
| Consensus append stream | The stream’s wire records | A matching, conflicting or higher-vote answer, or the request error | The leader hands Raft an unreachable-peer error that names the target and the reason |
| Interconnect remote operation | The failure’s class and subject, with the answering node’s description for an executed failure only | The classified result; the local chain is not serialized | The requester keeps the class beside its own target and placement context |
| Remote relay payload refused before admission | The refusal’s reason text | The rendered chain | The forwarding node receives the reason as text |
| WASM guest ABI | An integer result code, and the reason on the global-error channel | The code of the guest error’s current context, or the rendered chain as the reason of a guest failure | The host creates a guest-call report that holds the code or the reason |
| Structured message error | The record’s reference, code, operation, affected fields, time and message | The typed reason that selects the code and a non-sensitive message | The error route receives the record, never the report |
| Session command | The command’s disposition, message and diagnostics | The disposition from typed data, and the rendered chain as the message | The client receives a value; the Rust client reports its own failures |
| gRPC and HTTP edges | A status | The status the failure’s class selects; a refused credential check names only the refusal | A client classifies the status |
| Shared C binding | The NX_ERROR_* kind and a message | The kind from the current context and the rendered chain as the message | The host language raises its own error |
Foreign Interfaces
A foreign trait that accepts only a standard error receives one that holds the whole report, never
a clone of the report’s current context. The DNS hooks hand Hyper, Reqwest, Smithy, MQTT and Redis
a DnsLookupReport, and the owner’s own lookup, DnsLookupError::find_in, recovers the typed
failure from the causes the library wraps around it. The node’s command-line value parsers return a
refusal that displays the whole chain, because clap prints only the plain Display of the error a
parser returns. The consensus store hands OpenRaft’s storage traits an I/O error that holds its
StorageFailure. anyhow remains at integration and tooling boundaries whose caller has no domain
choice to make: the gossip library’s transport traits, whose methods return its result type, and
the benchmark load generator.
Rendering
A report’s plain Display is its current context alone. Its alternate form, {error:#}, is every
context from the outermost to the cause, joined by : , as in failed to start domain 'edge': failed to apply runtime revision 7: failed to build domain execution for 'edge': failed to load lookup 'zips': .... Runtime reporting renders every context in the report it receives, using
alternate Display ({error:#}), including report-bearing tracing fields. A failed command’s
message, a runtime event, a negative acknowledgement’s reason, a domain’s instantiation error, the
CLI’s text and JSON reports and the C binding’s error message are that chain.
Reports remain typed until these reporting decisions; sensitivity rules continue to apply to every context. A boundary renders a failure once and reuses the text: a failed dead-letter dispatch renders its report once and uses that chain in both its runtime event and its negative acknowledgement. Clock arithmetic and attachment, source cadence and lifecycle, Kafka partition inspection, relay dispatch and acknowledgement delivery, and state checkpoint publication and replica catch-up preserve their causes at those boundaries. Nested message-error construction reports retain the VM or Arrow cause.
Three renderings are not the whole chain, each by its owner’s decision. An ingestor’s transient
status shows the most specific cause of a source failure, because the connector contract’s own
context names only the operation that failed. An emitter’s transient status shows the description
its sink attached, which for the HTTP sink is the description of the whole chain. A failed
consensus proposal’s message is its context followed by the storage or Raft error beneath it. A
node and the benchmark command return their report from main, so the process that exits with it
prints the report’s debug form: every context, with its attachments.
sequenceDiagram
participant Client
participant Session as Session edge
participant Registry as Registry decision
participant VM as VM frontend
Client->>Session: Submit statement
Session->>Registry: Validate semantic Model
Registry->>VM: Compile expression against schema
VM-->>Registry: Typed failure with expression span and kind
Registry-->>Session: Report with owning node and route context
Session-->>Client: Failed command with diagnostic and source span
Ownership And Propagation
Layers
Dependencies point inward, and so does error ownership: a layer names the errors of the layers inside it and adds context above them, and no inner layer knows how an outer one reports. The subsections after the boundary table follow this order.
| Layer | Error owners | What their callers receive |
|---|---|---|
| Primitives | Modeled adapters keep the error types of the library they stand in for, such as the watch channel’s RecvError and SendError | The library’s own error shape, never a report |
| Vocabulary | Model alteration errors, CanonicalNsplError, ArchivedCountError, DurationTextError and the name and value errors of the shared types | A report that names the Model, field or value refused, with nothing above it yet |
| Language | ParseFromSourceError for the lexer and parser, FormatError for the formatter | A report whose context holds the stage, the rejected text and every diagnostic’s byte span |
| Engines and infrastructure | The VM’s CompileError, RuntimeError and row errors; UdfError; JaqProgramError and JaqFormatError; CodecError; the WASM ProtocolError, GuestError and WasmGuestError; the connector contract’s SourceError, SinkStartError and SinkPublishError above each connector’s own errors; ConsensusError and StorageFailure; the interconnect’s TransportError, RequestError and RemoteOperationFailure; DnsConfigurationError and DnsLookupError; AdmissionError and ExecutionError; ArchiveWriteError and ArchiveReadError | A report, a typed per-row or per-record outcome, or a typed class of a remote result. An engine decides nothing about the graph, so its errors name no node policy |
| Decisions | RegistryError, RelocationPlanError, RestorePlanError and TransactionPlanningError | A report that names the node, route, operation and fields of the refused model or plan |
| Data plane | RuntimeError above ExecutionBuildError; IngestorStartError, EmitterStartError and GeneratorError; PlannedGeneralError, RouteOutputError, RelayProcessorError and the other node contexts; RuntimePersistenceError; the domain clock’s DomainClockAccessError and DomainClockWaitError | A report for the node’s policy, with the acknowledgements of the work that failed, or a structured message error for a route |
| Control plane | AppError, DomainAlterError, BackupError, RestoreRefusal and the transaction and lifecycle errors | A report the session edge turns into a command result |
| Edges | The session service’s GrpcAuthenticationError, SessionTransactionBindingError and SnapshotEncodingError; ClientError and the Rust client’s DomainClockReadError; the CLI’s and the benchmark command’s own contexts | A command disposition, a status, an NX_ERROR_* kind, or the rendered chain |
Boundaries
| Boundary | Failure meaning it owns | What its caller can decide |
|---|---|---|
| Vocabulary Models and the execution-graph description | Alterations the stored Model refuses; invalid placement members, inferencer tensor schemas, and upload identities; values canonical NSPL cannot spell; and execution-graph encoding or decoding | Refuse the command and keep the stored Model unchanged, or report which statement or graph could not be rendered or decoded. |
| NSPL language and formatter | Source text the lexer or parser rejects, with the stage that rejected it, the rejected text, and every diagnostic’s message and byte span; statements the formatter cannot render, and formatted output that does not reparse to the statements it came from | Report the stage and underline each diagnostic in the text that was submitted, or leave a file unchanged and report the formatter defect. |
| Arrow record and batch layer | Schema, field, column, row, and batch construction or decoding failures | Reject a malformed batch or a field operation without inventing a replacement value. |
| Bounded execution | A memory charge a class could not grant (AdmissionError), a job refused because its class’s wait queue is full, a closed pool, a job that panicked on its worker (ExecutionError), and a job that stopped at a Cancelled check because its caller stopped waiting | The job’s owner maps each to its own typed outcome. A refusal judged nothing, so it stays retryable: an emitter keeps its rows, an ingested payload fails its dispatch rather than its decode and an endpoint answers it as a retryable rejection, a credential check answers UNAVAILABLE rather than failing authentication, and a restore’s deduplicator or window conversion releases what it holds and converts the refused window section or keyspace again, within a 30-second wait for room, rather than reporting archived state of another shape. The unfolding of a payload a quiesce buffer retained, or of a poll a paced source handed over, is not refused at all: nothing could present it again, so it waits for a place. The job that admits one key group into a keyspace a restore is converting waits for a place too, because the keys admitted so far could be presented again only by converting the keyspace from its first group. So does a payload a source handed over without an acknowledgement, when its transport survives a held loop; any other such payload is refused, reported as an ingestor error and counted in nervix_ingestor_unfolding_refused_total. A panic is the job’s own defect. |
| Expression VM frontend and runtime bridge | Invalid expression scopes, types, sensitivity, compiled program inputs, and evaluation failures | Refuse a model during validation, or classify an affected row or batch during execution. VM Functions owns execution detail. |
| Stateful processors | Branch-local deduplication, ordering, window, correlation, inference, and WASM execution or state failures, and a processor task’s restore of the branches its lifecycle checkpoint names (ProcessorBranchTaskError) | Apply the processor’s message or node policy, or fail a checkpoint and its held acknowledgements. A restore that fails installs no branch and stays pending: the task retries it after a backoff, holds its input, and refuses a lifecycle checkpoint or guest-state reset with BranchesUnrestored until an attempt succeeds. Shutdown And Recovery owns that sequence. |
| Connector crates and host | Integration-specific configuration, decoding, external source and sink outcomes; host-owned routing, retry, flush, and acknowledgement failures | Separate a record rejection from a source or sink failure and follow the configured retry or acknowledgement contract. Connector Crates And The Connector Contract owns those contracts. |
| Materialized state and lookups | Dependency resolution, field and schema checks, defaults, lookup evaluation, snapshot opening, and state exchange | Use a declared absence policy only for unavailable state; report a genuine failed read or invalid state. |
| Ingest grouping and relay batching | Route grouping, branch-key construction, Arrow batch assembly, relay admission, and delivery failures | Keep the affected concrete branch and fail or retry the correct in-memory attempt. |
| Client ingestor endpoint | Whether a submitted batch is a canonical Arrow IPC stream of the input schema (ClientBatchError), whether the node had capacity to validate it, and how its acknowledgement root resolved | Answer the batch as not admitted with its defect or a temporary refusal, or with its terminal outcome, and end a producer with the reason its attachment ended. |
| Registry, placement, and planning | Invalid domain models, references, capabilities, branch relationships, flush contracts, schedules, and placements | Reject the command before activating an invalid graph or refuse a relocation plan. |
| Runtime installation | A committed revision this node cannot plan or apply, a domain execution it cannot build or change (RuntimeError::BuildDomainExecution above the ExecutionBuildError step that failed), and ingestors that do not start once their revision is prepared | Keep the previously applied schedule as the predecessor for a retry, and report the whole chain with the failed command, the domain’s instantiation error and the runtime event. |
| Backup archive format | Records that do not encode or exceed their size limit, and archives whose structure, record headers, record values, section lengths or digests do not match what the manifest declares | Refuse to write an archive, or refuse a whole archive naming the section and the check that failed. |
| Restore planning | Archives a restore cannot apply to the cluster: the wrong scope, a domain the archive lacks or the cluster has, an archived user the cluster has under ON EXISTING USER FAIL, resource versions the archive does not hold consistently, models that bind no restored version, and archived branch keys that are not keys of the branching their restored entity declares | Refuse the restore before it changes anything, naming the domain, user, resource, version, or model, or the archive section, entry, entity, branch and field of the key. |
| Interconnect | Authentication, framing, limits, transport, and the class and subject of a remote operation failure | Distinguish a transport failure from a peer’s rejection, absence, unreadiness, or executed failure. |
| Deadlock detector and diagnostic run | A detector that cannot be installed or is installed twice (InstallError), a diagnostic run that cannot install it or record its starting evidence (DiagnosticError), and evidence that does not encode, decode, fit its bounds, write or read (EvidenceError) | Refuse to start a diagnostic process, or refuse a whole evidence file naming the check that failed. A deadlock finding is not one of these errors: it is a diagnostic result that ends the process with its own status. |
| Native client and its edges | A call that could not connect, was refused or cancelled, was answered outside the protocol, or lost its session (ClientError), above the transport status, the codec’s report, a local archive’s I/O error, or the failure that left a command or upload uncertain | Retry, recover the session, recover an uncertain command or upload by its reference or identity, or display the whole chain; the CLI and the C binding classify from the current context. |
| Control plane and public edges | Transaction and lifecycle results, command dispositions, session diagnostics, and HTTP response selection | Return a recoverable command outcome or an appropriate response to a client or operator. |
Cluster Availability Reports
SHOW CLUSTER STATUS keeps gossip visibility and effective member unavailability separate. A Raft
member missing from the raw live gossip set gets a visibility warning. Its unavailable-member
warning comes from the cluster’s effective availability snapshot, which retains an established
peer while its health observation still bridges a gossip lapse. The reason names application
health when that owner excludes the peer, or Chitchat when gossip excludes it after the retained
observation ages out. A client observing the warning therefore sees the same availability owner
that controls member removal. The report is an observation; the mutation checks availability again.
Vocabulary Models
The vocabulary is the innermost owner, and its Model operations report the same way. An alteration
is applied to a copy of the stored Model, which replaces the original only when every operation
succeeds, so a refusal leaves the stored Model unchanged. Each refusal names what it refused in
typed fields: the field, input relay, route target, or materialized dependency, or the stored and
requested names when an alteration targets another Model. An ingestor alteration whose head names
another connector and entity than the stored ingestor reads is refused with both typed kinds, which
its message renders as their phrases, such as the stored ingestor reads HTTP ENDPOINT, so ALTER KAFKA TOPIC INGESTOR cannot alter it. An input, route, or dependency operation
that junctions, deduplicators, reorderers, and reingestors share is reported in the altered
processor’s own error where it is detected, so the report begins at the failure rather than at a
conversion. Canonical NSPL rendering refuses only a value the language has no spelling for: a NaN or
infinite F64 literal, which the error carries, or a codec declaration its wire format cannot
express, such as encoding rules on SYSLOG or a JAQ-transformed format without a program, which the
error names by codec. The execution-graph description keeps the JSON encoder’s or decoder’s error
beneath its own when the public wire form cannot be written or read. It keeps a typed columnar
JSON writer error beneath a named codec encode failure. Unsupported columns and invalid string
offsets fail batch preparation with the codec name. Required nulls identify their field and row;
write failures retain their source without quoting a payload value. Registry planning keeps an
alteration’s report beneath its invalid-model refusal of the named Model, and the refusal quotes the
rejection’s message, so a failed ALTER shows the same reason the vocabulary gave. SHOW CREATE
answers a Model canonical NSPL cannot spell with a fixed diagnostic.
NSPL Language And Formatter
The language layer reports rejected source the same way. Lexing and parsing each create the report
at the stage that failed, and its context names that stage and holds the rejected text with every
diagnostic’s message and byte span into it. A batch of statements is lexed once and each statement
is parsed from its own run of those tokens, so a diagnostic indexes the whole submitted text
wherever in the batch the rejected statement starts. The session edge turns the stage into the
failed command’s lex error or parse error message and passes every span through unchanged, so a
client underlines it in the text it sent. A statement grammar reads an expression it embeds from
the statement’s own tokens, with the grammar a standalone expression uses, for as long as that
grammar can go on, and its next clause begins where the expression ends. Where the expression
cannot begin at all, the diagnostic expects the placeholder the clause names, such as
where_expression. Where the expression goes on with a token and then fails, the statement reports
the expression grammar’s first diagnostic at the tokens of the statement where it failed, with the
message the standalone reader gives the same text. Where a complete expression is followed by a token
no clause of the statement expects, the statement reports that token with its own expectations. An
expression’s diagnostic carries no expectations of the expression grammar, so completion inside an
unfinished expression offers nothing rather than guessing at expression syntax. A caller that owns
a larger operation adds its own context above the
language’s report instead of copying the diagnostics into its error: splitting a client batch reports
that the batch could not be split, and the formatter reports a source that did not parse, the line
of a statement the vocabulary could not render, or a rendering defect whose output changed meaning
or no longer parses. The formatter’s command line reads the language’s report beneath its context
to draw each diagnostic over the whole file at its line, and writes a defect as the report’s whole
chain, ending with the cause the vocabulary or the reparse gave.
Expression VM, UDFs And Jaq
The expression VM returns reports for compile, batch, and runtime failures. CompileError keeps
its typed diagnostic code, stable code spelling, operation span, and safe message; validation adds
the model and route context without losing that cause. Roto setup returns UdfError reports and
Roto’s VM injector returns runtime reports, so a failing Arrow operation can remain in the chain.
Jaq returns JaqProgramError or JaqFormatError reports for compilation, evaluation, and format
conversion. A codec or runtime caller retains that report under its operation context. VM row
errors remain typed values in the batch outcome and are formatted only when a message error is
reported; this conversion does not turn them into report allocations per row.
A compile failure fails validation or binding as a report. An evaluation failure of one row is a
row error in the batch’s outcome, which the route’s ON MESSAGE ERROR policy receives as a message
error with the evaluation code, and only a failure of the batch as a whole is a runtime report.
Row Errors And Batch Errors owns which failure is
which.
Binding a lowered program on a node returns a RuntimeVmCompileError report that names the
program and its node: a filter, a FILTER-MAP route, an output branch construction, a WASM output
construction or its refused INVOKE, and a generator output. The lowering, LOOKUP_HASH_MAP,
materialized-state binding or VM compile failure stays beneath it. The binding names no domain;
its caller adds that context above it, as the processor plan, the entrypoint binding, and an
emitter’s or generator’s start do.
WASM Guests
The WASM FlatBuffers decoder reports protocol failures with their verified payload cause. It checks
the complete header length before reading the identifier; a truncated header returns the typed
length or identifier error even when its size prefix matches the received bytes. The Rust
guest SDK retains that report beneath its envelope or snapshot meaning, and its Processor
callbacks return guest-error reports. It renders a failure only when returning an ABI code or
global-error reason; rejected snapshot bytes and rejected application state keep their distinct
codes and text. The host retains a typed guest-call cause beneath the failed operation, so its
runtime caller can still distinguish a resource limit, invalid emission, and a saved-state verdict
without classifying a rendered string. Callback and checkpoint acknowledgement decisions stay the
same; WASM State And Recovery owns those boundaries.
A guest export answers the host with an integer, and the SDK renders a guest report only there. A guest’s own failure puts the rendered chain on the global-error channel, latches the instance’s error state and returns the error-state code; a panic latches the same state with the panic’s reason. Any other guest error returns the code of its current context: an invalid size, an access out of bounds, an uninitialized guest, an Arrow IPC failure or an envelope protocol violation. A saved state the guest refuses returns one of two codes of its own, for snapshot bytes it cannot decode and for application state it rejects, and reports its reason on the same channel without latching, because the host discards an instance whose restore failed. The host turns each answer into a typed guest-call cause: the code of an export, a global-error reason, a trap, exhausted fuel, an exceeded memory limit or an invalid emission. Rejected-State Recovery owns what the host does with each verdict, and Error Handling what a guest author returns.
Ownership preparation of a stopped WASM domain carries its validated durable checkpoint inventory
without guest execution. A stopped clock is ordinary passive state, not a missing-checkpoint
failure or a reason to reset guest state. Guest restore failures are classified when the running
revision restores the save under the active clock at START.
Codecs And Ingress
Codec compilation, decoding and encoding return CodecError reports. The codec context names the
codec, and the field when one is at fault, and the typed reason stays beneath it: a
CodecContractError for a declaration or use the wire format does not support, such as a missing
wire field or ON INGESTION program; a FieldDecodeError or FieldEncodeError for a value that
does not fit its field; a SyslogDecodeError or SyslogEncodeError for a syslog frame; the Arrow
builder’s RuntimeSchemaError; or the CBOR, Avro, simd-json, protobuf or UTF-8 error that failed.
An unfolding payload keeps the report of the message that failed and names its zero-based input and
output position after it. The ingest group returns that report unchanged. A source host keeps it
beneath its intake’s decode or dispatch failure and reports the whole chain, an HTTP or WebSocket
endpoint renders the whole chain in its decode notice, a lookup line keeps it beneath the line it
failed on, and an emitter keeps it beneath the record it rejects or the encoding it could not start. The rendered chain reads as the codec
diagnostic did before: codec 'events_codec' failed to parse field 'user_id': ... followed by the
Arrow builder’s own reason. A codec that fails to compile while a domain execution is built keeps its
report beneath RuntimeError::BuildDomainExecution, which names the domain. A codec failure whose
parser or writer error is its #[source],
such as a simd-json, CBOR, Avro, protobuf, I/O, UTF-8 or timestamp error, leaves that error out of
its own message: error-stack records a context’s source as the frame beneath it, so the rendered
chain names each cause once.
Schemaful JSON parsing has one codec decode failure carrying the simd-json source. Malformed syntax, invalid UTF-8, and invalid escapes enter through that failure; object shape, missing or unexpected fields, nullability, exact wire types, integer ranges, datetime parsing, base64, and nested sequence shapes keep their existing typed codec or runtime-schema failures. Diagnostics name the codec and field when one is known and never attach the rejected payload value.
Codec jaq transformations are compiled during registry validation for every declared direction. A syntax error names the codec, domain, and direction and rejects the transaction before the model is committed. The browser keeps the draft editable so the program can be corrected and submitted under the same name.
An endpoint that decodes a payload but cannot dispatch or flush its ingest group uses the same rendered report chain in its runtime event and log. A temporary unfolding admission refusal logs its report chain while preserving the endpoint’s retryable refusal outcome.
Stored Shapes And Archives
The vocabulary’s ArchivedCountError reports a fixed-width archived count that the receiving
target’s usize cannot represent. Archive decoding retains it beneath the owning storage or
transport failure. Registry Model records validate their current frame signature and report
RegistryError::InvalidModelArchive with a recreation instruction for an unrecognized shape. A
record’s key holds its domain, Model kind and name exactly as a commit encodes them; a key spelling
a name another way or holding bytes after its encoding is RegistryError::DecodeKey, so no stored
record is read as another Model. Consensus validates its complete current keyspace namespace and
state encoding and reports StorageFailure::InvalidState with a recreation instruction. Window snapshot decoding reports
WindowSnapshotIssue::Header with a recreation instruction for an invalid current frame signature.
These boundaries reject unrecognized data before its counts can be reinterpreted; none clamps,
truncates, or supplies a replacement value. See Archived Counts.
An rkyv archive can pass shape validation and still contain a value that its vocabulary decoder refuses, including a typed name, size limit, reference, or range inside a list. The owning boundary reports its existing decode failure with the value’s cause. The decoder drops values it has already read and releases any partially read list or fixed array, boxed value, or shared pointer allocation before returning that failure; it neither publishes a partial value nor retains memory for a refused archive.
Runtime State Storage
Runtime state storage keeps the cause of each failure beneath its RuntimePersistenceError. Opening
the store keeps the storage engine’s error beneath its keyspace, read, write or synchronization
failure. An encoding or decoding failure keeps the serializer’s error, or the StoredStateIssue the
stored bytes have, such as a key without its domain separator or a restore whose staged inventory
differs, beneath EncodeState or DecodeState, so the rendered chain still reads failed to decode runtime state: runtime state key has no domain separator. A memory or storage refusal while a
materialized relay’s restored snapshot is opened is a storage admission or execution failure, never
a decoding failure. A caller keeps the storage report beneath its own context:
RuntimeStateOperationError::Persistence for a replica’s installation or a snapshot task,
OwnershipHandoffError::Persistence for a handoff, and the domain build’s ExecutionBuildError
step, which names the node and the state kind, for a state assignment.
Native Kafka stream conversion retains the rkyv validation failure beneath DecodeState and the
placement-qualified StateReplicationError::Capture. A cancelled conversion keeps Cancelled at
that same boundary without publishing the candidate table. Installation refused after an assignment
changes retains StateAuthorityError beneath RuntimeStateOperationError::Authority and that
placement-qualified capture context, so a caller can distinguish malformed bytes from lost
authority.
Name Resolution
The node resolver’s own errors belong to nervix-dns. A resolver configuration that cannot be
loaded is a DnsConfigurationError, which fails node startup beneath
failed to load the name resolver configuration and a native client’s connection as
ClientError::LoadDnsConfiguration. A lookup that fails is a DnsLookupError: the host as the
caller wrote it and one DnsLookupFailure, the closed set that
Lookup Outcomes lists. A client library that resolves
through one of the resolver’s hooks receives the DnsLookupError itself and keeps it among the
causes of its own connection error, where DnsLookupError::find_in recovers it. The HTTP request
sink, RabbitMQ, Syslog, WebSocket, Redis, MQTT, ClickHouse, SQS and the native session client keep
it beneath their own failure, as the paragraphs below describe. HTTP polling, Prometheus, Sentry,
OTEL and Iceberg report their failed request, export or catalog call without it, so their
diagnostics do not name the lookup failure.
During cluster startup, a node that has recovered Raft peer endpoints logs a failed lookup of its
configured bootstrap host at warn and continues with the recovered seeds. A node without recovered
peer endpoints keeps the lookup error beneath AppError::StartCluster, because it needs the
configured bootstrap host for first contact. A failed lookup of the node’s own advertised endpoint
also fails startup.
The node’s own trace export waits for the resolver installed by startup. Its connector’s
TraceConnectError distinguishes a closed installation, a connection timeout, and a Hyper
connection failure retaining its DnsLookupError cause. Tonic and the OTLP SDK report the failed
export. The export timeout encloses DNS and TCP after resolver installation; a missing DNS
configuration still fails startup as
AppError::LoadDnsConfiguration. Telemetry failures have no connector retry or ACK disposition.
Connectors
A connector reports through the contract’s own contexts and keeps its integration’s error beneath
them. SourceError names the connector and the operation that failed: opening, reading,
acknowledging, rejecting, suspending, resuming or closing. SinkStartError separates an invalid
configuration and a missing external entity from a failed initialization. SinkPublishError
separates a failed publish, finish or commit, which the host retries on its backoff, from a
misconfiguration, which it does not retry. A record the destination refuses for good is none of
these errors: it is a rejection in the write’s per-record outcome, with the structured message
error the route’s policy receives.
Failure and observation owns the contract, and
the paragraphs below record what each integration keeps beneath it.
The connector helper errors for OTEL, Syslog, WebSocket signaling, Postgres, MySQL and ClickHouse
carry error_stack::Report from the failing operation. A caller adds context at a connector or
host ownership transition; it does not recreate the top-level error from its formatted text.
Syslog TLS material reports keep the file, certificate or rustls cause, and stream frame reports
remain beneath the connection failure. WebSocket signaling compilation and execution retain jaq,
frame encoding and transport causes. The runtime’s Syslog source-plan and signaling compilation
errors keep those reports as typed fields while preserving their startup messages.
An HTTP request attempt that fails is an HttpAttemptError, owned by the HTTP sink: a timeout, a
DNS, connection, TLS, send or response-header failure, an invalid destination, or a retryable or
authentication status with its number. The sink keeps the resolver’s DnsLookupError, the
response-header failure, or the socket or TLS error as the context beneath it, and attaches the
description of the whole chain, such as
HTTP TLS handshake failed: invalid peer certificate: UnknownIssuer, to its publish failure. The
emitter reports that description as its transient error and runtime event for as long as the
request stays pending, which DESCRIBE EMITTER shows. It names the status or the cause of the
connection and never the evaluated target, a header value, a credential or a body. A refused
request is not an attempt failure: it is a record rejection with code external, operation
publish and its numeric status.
HTTP request-field compilation retains the VM report beneath the emitter’s request-field context and attaches its safe message for diagnostics; an invalid request program never starts the sink.
Iceberg object storage retains the Iceberg storage error contract when it installs the node’s
HTTP resolver. Invalid object URLs are DataInvalid, and an unsupported Azure connection string
is FeatureUnsupported. Building the storage HTTP client or an OpenDAL operation can fail as
Unexpected, with the underlying error retained as its source. Those failures enter the existing
sink failure and retry path; they do not release a staged record’s acknowledgement before commit.
A RabbitMQ connection that fails is a RabbitMqConnectError, owned by the connector’s connection
module: an invalid address or CA file, a lookup failure that keeps the resolver’s DnsLookupFailure
as a typed field, no address that accepted a connection, a failed or overdue TLS handshake, a Lapin
runtime that could not be created, or a failed AMQP handshake, each naming the broker host where one
is involved. The source keeps it beneath its connect and resume contexts, so DESCRIBE INGESTOR
shows the deepest cause, such as the resolver’s own lookup error. The sink changes it into a
configuration failure for an invalid address or CA file and an initialization failure otherwise,
leading with the connection error’s message, which DESCRIBE EMITTER shows. Neither attaches
credentials from the address.
A RabbitMQ publish that ends with the broker closing the sink’s channel is classified by the
broker’s own reason, which the sink reads from its connection. A refusal of a message body larger
than max_message_size is a RabbitMqRecordError, owned by the RabbitMQ sink, which carries the
body size and the limit as typed fields and becomes a record rejection of that message with code
external and operation publish, reaching every member of a batch message. Any other close, a
lost connection, and a close whose reason never arrives fail the attempt as an infrastructure
failure, which the emitter retries on its backoff.
Syslog emission and WebSocket-client ingestion retain DNS failures from the node resolver beneath
their existing infrastructure contexts: SinkStartError::Initialize while a Syslog sender opens
and SourceError::Resume while a WebSocket source connects or reconnects. The resolver’s typed
missing-name, no-address, timeout, invalid-name or transport cause stays in the error report.
Address, TLS and WebSocket-upgrade failures remain connection outcomes. None is a record rejection,
and a DNS result by itself never marks a Syslog record delivered.
Redis command connections receive the node resolver through the driver’s DNS hook. A failed
initial or later pool connection keeps the resolver’s typed failure in
RedisClientError::Resolve; a later failure sits beneath SinkPublishError::Publish for the
emitter host to retry.
A Pub/Sub source resolves before opening its dedicated stream and retains DnsLookupError beneath
RedisPubSubSourceError::Resolve and SourceError::Resume. A refused address, failed TLS name
check or failed Redis protocol setup is also a connection failure. None rejects an input message
or changes Redis Pub/Sub’s server-acceptance boundary for PUBLISH.
An MQTT client’s event loop reports a failed or lost broker connection as an
MqttConnectionError, owned by the connector’s connection module. The socket connector hands the
driver the resolver’s DnsLookupError as the failure of a lookup, and the driver keeps it as the
cause of its connection error. The connector finds it there and reports
MqttConnectionError::Resolve, with the host and the resolver’s DnsLookupFailure as typed fields
and the DnsLookupError beneath it. Any other failure keeps the driver’s own description, such as
an address that refused the connection, a failed TLS handshake, including a certificate that does
not name the configured host, or a refused MQTT handshake. A source keeps the error beneath
MqttSourceError::Connect while it connects and MqttSourceError::Receive while it reads, and
those beneath SourceError, so DESCRIBE INGESTOR shows the deepest cause: the resolver’s lookup
error or the driver’s description. A sink’s event loop records the connection error’s message as
the emitter’s transient error, which DESCRIBE EMITTER shows, and reconnects on the emitter’s
retry policy. None rejects a record or acknowledges input. A QoS 1 or 2 publish keeps the driver’s
ClientError when the client refuses to take a record, and its PublishNoticeError when an answer
leaves a record unresolved, beneath SinkPublishError::Publish, each with the connector’s
description of the failure for the emitter’s transient error. A missed ACK TIMEOUT is the
confirmation window’s ConfirmationWindowError, which the window describes the same way.
ClickHouse and SQS reach the node resolver through their drivers’ own DNS hooks, which hand the
driver the resolver’s DnsLookupError as the failure of the lookup. The driver carries it as a
cause of its connection error, and the connector finds it there by type and keeps it as the context
beneath its existing infrastructure failure: SinkPublishError::Publish for a ClickHouse insert or
an SQS send, SinkStartError::Initialize while an SQS sink looks up its queue, and the
SqsSourceError of looking its queue up, receiving or deleting beneath the source’s SourceError.
An SQS source looks its queue up when an instance resumes, so that failure is SourceError::Resume
above the typed lookup failure: a queue the service does not have is SqsSourceError::MissingQueue,
which names it, and the source host shows the deepest cause as the source’s transient error and
retries it. The failure’s message names the host and the lookup failure, which DESCRIBE EMITTER
and DESCRIBE INGESTOR show. Any other failure to reach the service, such as a refused connection
or a certificate that does not name the configured host, is described by every cause of the driver’s
connection error, which describes the connection and carries neither credentials nor a record; a
response from the service keeps its existing description. None is a record rejection, and none
acknowledges input.
OTEL keeps a row conversion failure in the invalid-record channel and names its mapped key as the affected field. A lower OTEL value type or range error stays in the internal report until the rejection is constructed. Database sink insert reports keep transport driver or pool causes while the connector inspects the current typed error for definite row rejection. Postgres SQLSTATE, MySQL SQLSTATE and code, and ClickHouse named rejection remain the same external classifications. Database response text that could quote a bound value is discarded after extracting that safe classification; diagnostics do not quote the row payload.
A Pulsar message refused for good is a PulsarRecordError, owned by the Pulsar sink: a message
larger than the maximum message size the broker announced, which carries the measured size of its
metadata and payload and the limit as typed fields, or a message the broker answered with
NotAllowedError, which carries the broker’s reason. Either becomes a record rejection with code
external and operation publish. Every other failure of the client, its connection or the broker
stays an infrastructure failure of the attempt, which the emitter retries.
Connector Status Observation
Each source or sink publishes its safe transient error and optional retry together. Repeated healthy operations read the retained status without writing it; a transition clears an active failure. Reporting a different error without selecting a new retry preserves the active retry. DESCRIBE renders error, backoff and remaining wait from one immutable observation. A failed record obtains its prepared message-error route from its task’s retained routing publication and preserves that plan while its VM program and delivery execute.
Console Drafts
The visual hash-map and Roto UDF forms validate incomplete drafts before rendering canonical NSPL. Missing resource versions, codec output fields, argument types, or source are shown as form validation errors while the draft stays editable. After submission, lookup loading and Roto compilation or test failures retain their server diagnostics; correcting the same draft starts a new command without claiming the failed creation succeeded.
Junction and reingestor drafts report the input, materialized dependency, route, or assignment whose required reference or expression is incomplete. A retained reference whose domain or upstream choice changed is reported as a changed context and must be selected again. These are local draft errors before canonical NSPL exists. A completed Model still passes through registry validation, whose schema, branch, sensitivity, and graph diagnostics remain authoritative and appear in the editable form after a rejected submission.
Absence, Validation, And Planning
Materialized Dependencies
Materialized dependencies run in declaration order against the current branch. An available
record binds at once. A declared default supplies typed constant fields, with omitted optional
fields becoming typed nulls. REQUIRED SKIP drops that message; REQUIRED WAIT retains its batch
in memory, applies backpressure, and restarts resolution from the first dependency after progress.
These are successful resolution outcomes, not error reports. A missing record during an ownership
handoff may take the same declared policy when a remote description says rejected, absent, or not
ready. A transport failure or an executed remote failure does not become absence.
Schema mismatch, a repeated dependency, an invalid default expression, a missing required default
field, and a snapshot that cannot be decoded are failures. The materialized read or snapshot owner
reports them with relay and placement context, and the branch-local processor that resolved the
dependency names its concrete branch. Branch-local processor and relay failures name their branch
the same way, so another branch cannot be mistaken for the failed one. A failure names a concrete
branch by the fingerprint of its key, as described under
Sensitive Data And Observability, and unbranched work as
unbranched. Data Plane owns branch execution and
Cluster Interconnect owns snapshot exchange.
The materialized installation owner refuses a lower snapshot revision with
RuntimeStateOperationError::MaterializedSnapshotRevision { received, current }, a snapshot from an
earlier branch lifecycle with MaterializedSnapshotBranchGeneration { received, current }, and one
sealed under a superseded ownership fence with MaterializedSnapshotFence { received, current },
preserving both values in each diagnostic. It checks these before publishing any restored row,
alongside assignment validation. An assignment that no longer grants the installation fails as
RuntimeStateOperationError::Authority with the assignment’s StateAuthorityError beneath, and the
snapshot exchange keeps the refusal beneath its own install failure. A cached snapshot from another
assignment is rebuilt under the current fence rather than reported as current; a fence change during
encoding remains the snapshot owner’s typed OwnershipChanged failure.
sequenceDiagram
participant Processor
participant Resolver as Materialized resolver
participant State as Relay state
Processor->>Resolver: Resolve ordered dependencies in branch
Resolver->>State: Read current branch record
alt Record available
State-->>Resolver: Typed fields
Resolver-->>Processor: Ready
else Record unavailable
State-->>Resolver: No record
Resolver-->>Processor: Skip, Wait, or declared default
else Read or snapshot fails
State-->>Resolver: Typed failure
Resolver-->>Processor: Report with relay and branch context
end
Registry Validation And Planning
Registry validation and planning refuse invalid models before graph activation. Validation names the
owning node, the route when the rule is route-local, the operation, and relevant fields or
references. A branch mismatch in an error route, for example, identifies the source route, error
relay, and both branch declarations. A flush-based route without FLUSH EACH or FLUSH IMMEDIATE
fails validation; the registry does not supply a cadence. The current registry reports this as an
invalid-model failure naming the node and output in its diagnostic. An ingestor whose body names a
client of another connector, or an endpoint of another TYPE, than its head’s entity reads is
refused with a typed failure naming the domain, the ingestor, its connector and entity, the client
or endpoint, and both connectors or both endpoint types, such as KAFKA TOPIC ingestor 'orders_in' in domain 'shop' requires a KAFKA client, but client 'cache' is a REDIS client. Planning failures
likewise retain the selected entity or placement so an operator can correct the request. A
relocation the graph cannot plan is a RelocationPlanError report beneath RelocationError::Graph:
a member that does not exist names the domain and the member, a server-listener ingestor names the
ingestor, a corridor whose FROM/TO pairs are all disconnected, a hard group whose overrides
request different strategies lists every member of that group, and an override outside the unit
names its member. The failed command renders the chain, such as relocation plan failed: junction 'chain_distant' is not part of the relocation. See Control Plane for
activation and Typed States And Validation Boundaries for required state.
Visual schema, branch, relay, and subscription editors report incomplete names, fields, types,
modes, references, branching, capacities, instance limits, filters and sample rates before
submitting, while retaining the editable draft. Once a completed branch command reaches the
registry, the registry remains the owner of branch key validation: a schema containing BYTES,
even under a collection type, returns the branch, domain and field in its diagnostic. The console
presents that command failure inline and does not replace it with a local guess or silently change
the selected schema. A subscription filter is parsed locally only to confirm that it is an
expression. The server compiles it against the relay’s schema when it creates the subscription, and
its refusal of an unknown field or scope, a type mismatch, a relay that no longer exists, or an open
transaction is the subscription’s failure, shown inline without opening a tab. A predicate that does
not lower or compile is a SubscriptionPredicateCompileError naming the subscription, with the VM’s
failure beneath, and the failed command renders the whole chain.
Domain activation has typed failures for a relay or codec missing its schema, a codec missing its wire definition, a relay missing its branch or carrying an invalid branch TTL, and an endpoint missing its VHOST or signaling protocol. The report identifies the owning relay, codec, or endpoint and the missing reference. The control plane builds activation, resources, entrypoints, emitters, processors, message-error routes, placement and the ownership fingerprint as one typed revision before runtime installation. A planning failure leaves the previously applied schedule as the predecessor for a retry; runtime installation adds domain context and never selects a fallback configuration.
Resource planning checks the committed lookup key and codec, generator materialized source, output branch and route construction, and WASM guest-state generation before runtime binding. These failures name the owning node and relevant relay, codec, or field. A missing lookup file is rejected during candidate binding validation; malformed records remain a loader failure when the pinned file is decoded. Neither failure silently selects another resource version.
ONNX binding preflight preserves the distinction between invalid tensor bindings, an ONNX loading failure, worker refusal, worker panic and inspection timeout. Worker refusal retains the bounded executor’s report beneath the inferencer context; it does not classify an uninspected model as malformed. The thirty-second inspection bound covers admission to a bulk worker through release of the temporary session. Debug events record submission, worker entry, builder initialization, model loading and session release. A timeout also records the bulk class’s configured workers, running jobs and pending jobs, so an operator can correlate queue delay with dependency work. These are observations of the run; a successful later inspection does not identify the cause of an earlier timeout.
Runtime Installation
Installing a domain on a node, whether it builds the domain’s execution, applies a schedule delta,
swaps or reassigns nodes, or applies a dynamic update, returns a report whose top context is
RuntimeError::BuildDomainExecution, naming the domain. The step that failed stays beneath it as an
ExecutionBuildError, which names the node, relay, lookup, codec, signaling protocol or WASM
processor it concerns and keeps the step’s own report beneath itself: a domain clock that does not
bind; UDFs, protobuf descriptors or a lookup that do not load; a WASM module that is not prepared;
forced recovery or prepared handoff state that does not activate; runtime state that cannot be
placed, assigned, restored, shared or purged; a relay, emitter or processor task that does not stop
or hand off its branches; processor plans, message-error routes or reingestors that do not bind or
start; an emitter flush change the running task does not apply; an execution revision without the
plan of a node it names; an installed execution that is gone; and a node whose state access its
assignment does not grant. A codec or signaling protocol that does not compile, an ingestor, emitter
or generator that does not start, and a WASM state reset the processor’s task does not take,
answer or apply keep their own reports directly beneath the domain context, because those already
name what failed.
Each admitted runtime revision is applied by the session service. A committed revision that cannot
be planned against the one applied before it is RuntimeError::PlanScheduleRevision, with the
decision layer’s report beneath. A revision this node cannot apply fails as
RuntimeError::ApplyRevision, naming the revision, above the runtime’s report, and an ingestor of a
running domain that does not start once the revision is prepared fails as
RuntimeError::StartIngestors above the ingestor’s start report. A failed command and the event a
session receives render the whole chain, such as failed to start domain 'edge': failed to apply runtime revision 7: failed to build domain execution for 'edge': failed to load lookup 'zips': ...,
and a domain whose build failed records the same chain as its instantiation error. Revision
preparation and readiness timeouts keep variants of their own, which a transaction recognizes in
the report’s top context to retry its application rather than fail it.
Ingestors, Emitters And Generators
Ingestor and reingestor planning has typed failures for an ingestor whose source is missing or
resolves to another kind or name, a missing codec, a route or input relay missing from the domain,
a route whose declared branch is not the branch of its relay, reingestor inputs whose schemas
differ, a node without inputs or routes, and a filter, route or branch construction that cannot be
lowered. The report names the ingestor or reingestor, the route or input relay where the contract
is route-local, and the operation. Binding the lowered programs on a node has its own typed
failures: a relay or branch schema the node has not instantiated, a program that does not compile
against the node’s schemas, lookups, state and UDFs, and a route or input the node cannot prepare.
The last carries the runtime planning failure beneath it, such as a relay without its registry or
an unparseable flush or collection cadence, rather than restating it. Runtime installation adds
domain context to either report, and an ingestor that fails to start while its domain execution is
built records that report as its transient error. A program that does not compile keeps the
runtime binding’s RuntimeVmCompileError beneath the binding context, and the domain’s binding
failure renders the whole chain, ending with the VM’s own CompileError.
Starting an ingestor on a node returns an IngestorStartError report. An ingestor already running,
a domain execution or codec the node has not instantiated, and a binding failure beneath the
binding context are start failures of their own. Every failure to compose or open the source is
IngestorStartError::Initialize, naming the ingestor and its domain, with the cause beneath it: a
SourceStartError for a missing node resolver, signaling protocol or endpoint, Kafka DOMAIN
offsets this node does not own, or a delivery-mode duration that does not parse, or else the report
of the client configuration, connector plan, source instance or domain cadence that failed. The
runtime keeps the whole report beneath its own context: the domain build’s
RuntimeError::BuildDomainExecution, or RuntimeError::StartIngestors when the session service
starts the ingestors of a prepared revision. The ingestor’s transient status shows the start
report’s chain, such as failed to initialize ingestor 'syslog_source' in domain 'edge': invalid Syslog client config key 'framing': UDP does not use stream framing, and the failed command shows
the same chain beneath the contexts above it. An ingestor that recorded no start failure of its own
is not running because its domain’s execution failed to build, and its transient status shows the
domain’s instantiation error instead. A build attempt clears the start failures its predecessor
recorded before it can fail, so an ingestor never shows a failure a later attempt did not record.
Emitter execution planning has typed failures for missing source relays or codecs, an unresolved or mismatched client, unsupported publishing mode, an invalid source predicate or route, invalid HTTP request fields or SQS ordering group, empty or invalid row mappings, nonliteral OTEL resource attributes, and invalid Iceberg commit settings. Each report names the emitter and, for a source predicate, its relay. The decision fails before a new emitter plan or remote consumer edge is published. Binding a valid plan against installed schemas and UDFs may still fail during startup; opening an external sink may fail independently and follows the emitter’s retry policy.
Starting an emitter on a node returns an EmitterStartError report. EmitterStartError::Start
names the emitter and its domain, and the step that failed stays beneath it: an input relay the
node has no schema or resolved branching for, a client whose configuration does not resolve, a
codec the node has not instantiated or that cannot hold a batch, a route, HTTP request fields,
ordering group or FROM WHERE that does not compile, with the compile failure beneath it, and an
invalid sink client configuration or input collection policy. Starting a generator returns a
GeneratorError report the same way: GeneratorError::Start names the generator and its domain
above an output relay the node has not instantiated, an output program that does not compile, a
cadence the domain clock cannot bind, an invalid output flush policy, or a source relay without a
dispatch gate. The domain build keeps either report whole beneath
RuntimeError::BuildDomainExecution, so a failed command renders it after the domain, such as
failed to build domain execution for 'edge': failed to start emitter 'audit' in domain 'edge': the route program did not compile: FILTER-MAP compile failed for 'audit': ....
A relay, processor, generator or emitter setting whose value does not parse is a
NodeSettingError naming the setting and its value, with the duration parser’s error beneath it or
the byte-size parser’s reason in its message. The node that declares the setting adds its own
context above it, so the setting’s error does not repeat the node: GeneratorError::FlushPolicy
names the output relay, EmitterStartError::CollectPolicy the emitter’s input collection, and
MessageErrorHandlingError::FlushPolicy the message-error route and its relay. An emitter’s
VALUES mapping that does not compile is a MappedValuesError naming the sink and the emitter, with
the VM’s failure beneath, under the emitter’s sink initialization failure.
Stopping an emitter’s task to swap it fails as a ScheduledEmitterStopFailure, which keeps the
task running beside a ScheduledEmitterStopError report: the task was unavailable, did not accept
its stop command in time, dropped its answer, did not drain before its deadline, or answered that
its drain failed, with the task’s own failure beneath. The swap installs the retained task again
and fails with ExecutionBuildError::StopEmitter above the stop report, beneath
RuntimeError::BuildDomainExecution. The emitter holds the task’s own description of a failed
drain as a printable attachment, which a rendered chain does not show, so StopEmitter carries that
description and its message ends with it. A later stop can still end the retained task.
Node Startup
Node startup validates execution memory limits before admitting any work. A Commands budget must hold both the bounded resident replication window and one bounded normalized command-state write; the larger requirement controls admission. Arithmetic that cannot represent either requirement is a typed execution-configuration failure. A budget below the selected requirement names the memory class, operation, configured budget, and required bytes, so the node fails startup with an actionable diagnostic instead of discovering insufficient storage capacity while applying a transaction.
Memory-pressure watermarks are validated where the node reads its options. A low watermark that is
not below the high one, or a zero check interval, is a MemoryPressureConfigError carrying the
configured values beneath AppError::InvalidMemoryPressureConfig, so the node does not start. The
supervisor’s constructor validates the same configuration beneath
MemoryPressureError::InvalidConfig, and an allocator sample that jemalloc refuses is
MemoryPressureError::ReadJemalloc, naming the control it read, the epoch, stats.allocated or
stats.resident, with jemalloc’s own error beneath. A refused sample at startup fails it beneath
AppError::InitMemoryPressureMonitor; a refused sample while the node runs is logged with its whole
chain and the supervisor keeps its current pause state until the next sample.
Startup applies each domain’s stored schedule before the node serves. A registry that cannot list
those changes fails as AppError::ReadStartupRuntimeChanges, a stored graph that does not plan into
an execution revision as AppError::PlanStartupRuntime, and a revision the runtime does not install
as AppError::ApplyStartupRuntime; the last two name the domain. Each keeps the registry’s or the
runtime’s report beneath it, so the node’s exit renders the whole chain rather than the top context
of the failure.
Runtime Message Errors
The Message Error Record
A record-specific failure can become a structured message error. It carries a stable reference,
machine-readable code, operation, affected field paths, occurrence timestamp, and a non-sensitive
message. The code classifies evaluation, validation, external, or internal failure; the operation
names the work that failed. This record is the route-policy view of the failure, rather than a copy
of the internal report. The route may inspect the eligible original input, its captured
materialized-state snapshot, and an all-optional partial_output of construction completed before
failure. An error handler whose own construction fails does not recursively invoke itself.
Paced ingest admission reports a rejected event timestamp as a message error with code
validation and operation admit. A null declared timestamp has the same classification and
names its field. The host keeps the rejected row’s source metadata and ACK attached while each
output route applies its message-error policy. Accepted rows from the same decoded group continue
through their normal routes.
Before a domain execution or replacement becomes active, the registry selects each DLQ route and resolves its source, partial-output and destination schemas, branch declarations and flush contract, then lowers its ordered SET assignments. The runtime binds that program, lookups, state and UDFs once for that installed revision. Missing inputs, codecs or relays fail planning; missing runtime relay services, invalid flush settings or a failed VM compilation fail binding with the owning node and DLQ relay. A failed record uses the installed plan and never reads the scheduled Model or compiles its handler. If unavailable, it reports the delivery failure and does not acknowledge the source record. Binding a buffered route prepares its bounded queue; successful running publication starts the worker once and the plan retains its exact delivery handle. A failed record sends directly through that handle. Replacement cancels the preceding handle before installing the new worker and drains the earlier task, preserving its pending acknowledgements. Changing to immediate delivery or withdrawing the route also retires its buffered worker.
ON MESSAGE ERROR belongs to the route and handles record-specific work. Ingestor and emitter
ON GENERAL ERROR handles node-wide source and sink failures. A WASM processor’s node-wide ON GLOBAL ERROR handles guest failures outside an individual message route. Error delivery preserves
the branch in which the operation failed; ingestor errors are unbranched. See Runtime Node Error
Policies, Message
Errors, and Error Routes for the
public behavior and syntax.
Hot paths retain typed failure variants and row or batch error masks; they do not allocate a formatted diagnostic for every row or batch when the variant already names the failure. Formatting belongs at the policy or public reporting boundary. This keeps error construction from changing the processing cadence and avoids putting source payloads in reports.
Sink And Emitter Rejections
A batch payload’s rejection becomes one message error per member, each a copy of the sink’s
structured error: the members share its reference, so an operator can see that they failed
together, while each keeps its own occurrence time and branch. A prepared row request’s rejection,
such as an OTEL Export request the receiver refused, does the same for every row the request
carried. A sink answer that breaks the write contract, by naming a record the write did not carry
or answering twice for one, is not a message error of any member. Neither is a row request sink’s
preparation that breaks its contract: one that answers for a row the write did not hand over,
answers twice for a row, prepares rows out of source order, prepares a request that carries no row,
or leaves a handed-over row unanswered. Either fails the attempt without a retry as the typed
emitter error that names the violation, the preparation keeps nothing, and the emitter’s unresolved
rows then follow ON MESSAGE ERROR as a failed publish.
The Sentry sink rejects a final serialized event above its decompressed event limit before sending
the envelope. The Syslog sink rejects a UDP datagram above its payload limit, a stream frame whose
octet count needs more than ten digits, or an LF-bearing non-transparent TCP frame before writing.
A row sink — ClickHouse, Postgres, MySQL or MongoDB — and a batching OTEL sink, which prepares
its requests from mapped rows, measure each request they would send. If halving still leaves one
row whose own request exceeds MAX SIZE, that row receives a validation message error of the
encode operation naming the measured size and the limit, such as
Postgres insert of one row measures 1219 bytes, above MAX SIZE 600B, and the other rows are sent
in bounded requests. A row within MAX SIZE that its destination could never
accept is the destination’s external rejection of the publish operation, named with the
destination’s limit: a Postgres insert above the largest protocol message the server reads, or a
MongoDB document above its 16 MiB document limit, which is rejected before the write that would
carry it. A SQL write that fails for a reason specific to its rows is written again one row at a
time, and each row the destination still refuses receives an external publish error with the
destination’s reason. That includes a violated MySQL CHECK constraint, which the server reports
under the generic SQLSTATE HY000, and a Postgres cardinality violation, which is how
ON CONFLICT DO UPDATE refuses one insert that carries a key twice. An OTLP receiver’s
partial_success has no member identities, so it acknowledges the entire request and emits a
warning instead of inventing per-record rejections. An OTLP/gRPC export the receiver never answered
— a timeout, a lost connection, an unreadable answer — is an infrastructure failure the host
retries, whatever code tonic reports for it. An answered status other than INVALID_ARGUMENT,
RESOURCE_EXHAUSTED or a code the OTLP specification lists as retryable is a misconfiguration
failure, which the host does not retry.
An HTTP emitter rejects a record at the first request field that fails, in the order it evaluates
them: METHOD, PATH, and then each header write. A failed expression keeps the evaluation
code and an invalid value has the validation code. Method and path failures report the publish
operation and name their request field, method or path, beside the fields the expression reads;
a header write reports invoke with its zero-based invocation position. A body the codec cannot
encode rejects its record with the encode operation when a flush releases it. The message names
the emitter and the violated rule and never quotes the evaluated value. An admitted request keeps
its original source record and the materialized state its batch was admitted with until it
completes, so every rejection of it after admission gives the handler the same input, state and
attempted codec record that a request-field failure does.
An HTTP endpoint’s complete final 2xx headers deliver the record. Other 3xx/4xx statuses,
except 401, 403, 407, 408, 425 and 429, reject only their record with code external
and operation publish; 101 has the same outcome. The rejection message includes the numeric
status but no evaluated destination, headers or body. The exception statuses and 5xx keep the
request pending as infrastructure failures; 401, 403 and 407 name authentication or
authorization in their typed cause. DNS, connection, TLS, timeout, malformed or oversized response
headers, invalid final framing and loss before complete final headers are infrastructure failures
as well. The connector reports them without a request URL or sensitive response value. The emitter
host owns their retry schedule and keeps the prepared request and ACK lease while they are pending.
A valid Retry-After on a retained status can only lengthen that schedule: it never turns a
delivered or rejected record into a retry, and an invalid one is ignored rather than reported.
A batch container that cannot be produced keeps its BatchContainerError report in the packing
outcome until the emitter builds the message error of each member. That message error is a fixed
public outcome, so only the report’s typed reason selects its code and message: the evaluation
failure beneath the reason can quote the payload it evaluated, and is not rendered.
Whole-Batch And Node Failures
A planned batch that fails as a whole, rather than one message at a time, returns a
PlannedGeneralError report together with the acknowledgements of every message the batch held.
The error names the step that failed: preparing lookup inputs or the input batch, executing the
program, materializing an error input row or the successful rows, building the output batch, a row
sidecar whose count differs from the batch, a row selected outside the input, an output relay whose
schema was not prepared or does not match, an ordering group column that is missing or not
STRING, or the HTTP requests of the batch. It names the program by its clause, such as
FILTER WHERE, FILTER-MAP or branch construction, and the failure the step returned, such as the
VM’s RuntimeError or a RuntimeSchemaError, stays beneath it. The node’s general or internal
error policy renders the whole chain when it reports the failure and resolves the
acknowledgements. That report already names the node, so the planned failure does not repeat it.
Branch construction keeps the outcome of each row as a typed value. A row whose branch SET
recorded an error holds that SideError, and a row the program did not select says so; neither
builds a report or a message until the row is reported. An ingestor route reports such a row
through its ON MESSAGE ERROR policy with the evaluation code and the set operation. A
reingestor route fails its whole batch with a PlannedGeneralError naming the input row, with the
row’s failure beneath it. On either kind of route, a branch field that cannot be read or is null,
and a program that writes no branch field, fail the whole batch.
A failure that ends a node’s work as a whole, rather than one message of it, reaches the node’s
policy as a typed error_stack::Report. An ingestor and an emitter apply their ON GENERAL ERROR
policy to such a failure, and so does a WASM processor whose guest instance cannot be created,
whose input cannot be encoded for its guest, or whose guest callback fails. Every other processor
failure, every reingestor failure and a branch dispatch task that fails are internal errors, which
always fail the acknowledgements of the work. The policy renders the report’s whole chain once.
The runtime event and every negative acknowledgement carry that chain after the node’s kind, its
name, the policy’s class and its domain, such as
junction 'enrich' internal error in domain 'orders': ..., and the log entry records the chain
beside the node and domain fields. No owner formats a reason string for the policy, and the context
an owner adds does not repeat the node’s name. Two contexts keep naming their node because they are
also reported without the policy: a WASM instance’s lifecycle failure, which a coordinated or
guest-requested state reset reports on its own, and an ingest group’s failure to hand a batch to a
branch entrypoint, which the group’s flush also returns to its caller. A branch whose domain
routing can no longer be read fails the acknowledgements it was handed with that routing failure
directly, without a policy or a runtime event.
Each failure reports its owner’s typed context above the cause it kept:
RouteOutputError, which every node that buffers route output shares, for holding a route’s output under its flush policy and releasing it: a domain clock that cannot be read while output is buffered or released, a flush deadline that cannot be started, inspected or waited for, a route without a flush policy, buffered output that does not concatenate, a relay without a branched entrypoint on the node, and a relay that refused the forwarded output. A clock that cannot be read while output is released fails the output of every route the node holds, as one report.RelayProcessorErrorfor a relay processor’s input and its own operation: collected input that cannot be inspected or concatenated, an unpreparedFROM WHEREorFILTER WHEREprogram, an execution time that cannot be read, aDEDUPLICATE ONor reordererBYkey whose input cannot be built or whose expressions fail, an input batch that does not decode into messages, and output buffers that do not match the routes.CorrelatorError,WindowProcessorErrorandInferencerOutputErrorfor the steps of a correlator, a window processor and an inferencer, andWasmInstanceErrorandWasmOutputErrorfor a WASM processor’s instance and the output its guest emitted. The VM, Arrow, ONNX or relay batch failure stays beneath. A WASM output route’s FILTER-MAP reports thePlannedGeneralErrorstep a planned batch reports, and the callback whose output did not forward is named by its kind. Window statistics reject non-finite arguments or results at their respective boundaries. Overflow of a finite-input intermediate sum is handled by the moment kernel’s normalized mean, so it does not itself classify the window as failed; constant overflow-scale runs keep zero centered moments. SIMD Kernels owns that numerical path and its reference bounds.ProcessorBranchTaskError,BranchEntrypointErrorandReingestorErrorfor the concrete branch work of a processor, a branched entrypoint and a reingestor: the domain time of accepted input that cannot be read, a branch that cannot be instantiated, a branch task that is gone or whose dispatch task failed, and input a processor receives before it has restored its branches.EmitterRuntimeErrorfor an emitter’s own processing: a publish batch whose rows stopped agreeing, a source filter that failed for its named input relay, and a batch whose publish failed but which cannot be split into the messages its message-error policy decides, which keeps the emitter’s description of the publish failure.
The relay interaction a node consumes its inputs through fails with a RelayInteractionFailure:
a RelayInteractionError report together with the acknowledgements of every batch the interaction
still held. A failure to collect names the relay, with the domain clock, deadline or concatenation
failure beneath it, and a deadline the interaction cannot resolve is its own variant. The consumer
reports the failure under its own policy. An emitter whose own wake deadline cannot be resolved
keeps its buffered work and records the failure as its transient error instead, because no
wall-clock fallback exists for a logical cadence.
A branch runtime, processor branch, generator, reingestor, emitter or relay task that stops because it cannot bind or read its domain clock or routing snapshot, or cannot wait for one of its deadlines, reports a runtime event that names the task and its domain and renders the whole chain of the failure, so a stale clock generation or an unrepresentable deadline beneath the clock failure stays visible.
An owner-delivery admission failure logs the domain, relay, branch scope and target together with the transport report, including retained cancellation and rejection causes, before returning the undelivered batch. This keeps admission and connection failures visible even when the batch carries no acknowledgement, without logging branch field values.
Cross-Node And Public Boundaries
Interconnect
The duplex writer’s typed TransportError::ProgressTimeout means that a frame awaiting send
capacity received no nonzero flow-control grant for its write progress bound. It does not bound
the total time to transfer a frame. Expiry resets the exact stream; steady byte progress preserves
the stream even when a large frame spans several progress intervals.
The interconnect owns three kinds of failure. TransportError is a connection, TLS, framing,
limit, deadline or relay delivery failure of the transport itself. RequestError is a typed
request that could not be admitted, encoded, delivered or answered, with the peer’s own
RemoteRequestFailure when the peer’s transport refused it. RemoteOperationFailure is the
answering node’s classified result of an operation it received, Rejected, Unavailable,
NotReady or Failed, with a subject that is a domain, an entity, a state or a subscription
interest. Failure Ownership And Persistence
owns what each means for a requester.
The interconnect validates and bounds the wire request before its operation handler runs. A
delivery correlation that has no free position reports CorrelationCapacity; an exhausted
generation reports CorrelationIdentityExhausted; failure to reserve record storage reports
CorrelationMemory. A receiver whose relay budget has no room for a batch’s acknowledgement watches
holds the batch back unadmitted rather than failing it, because that lack of room is temporary. It
reports RemoteAckAdmission, with the typed AdmissionError beneath it rather than rendered into
its own text, only for watches larger than the whole budget or a hold still without room when the
five-minute admission bound has passed, before runtime admission. These refusals judge no payload
and preserve source retry ownership. Ending a registered delivery before admission or shutting its
owner down resolves its held shares negatively once. Stale generations and registrar runs are ordinary
unmatched reports logged at debug, and never resolve current work.
A transport error describes connection, admission, framing, deadline, or delivery failure. A remote
control response instead preserves a typed failure subject and one of four classes: rejected by
the answering node, unavailable there, temporarily not ready, or executed and failed. A requester
can use class and subject for routing, retry, and recovery without parsing text. Only an executed
failure carries the answering node’s opaque operator description. That text is an explicit wire
boundary for an already classified failure; it is not used to recover a new class. Runtime-state
replication, a replica’s branch checkpoint listing included, and materialized-snapshot description
use this envelope, and local errors retain the remote class alongside their target and placement.
Checkpoint and branch catalog request failures keep the typed interconnect request cause beneath
that target and placement context. Their replica diagnostics render the cause chain, distinguishing
deadline, admission, connection and framing failures without retaining checkpoint payloads.
The checkpoint description carries only revision, length and digest. Its subsequent bulk fetch
retains the target and placement context on a transport, memory admission, declared-length,
truncation or digest failure; none can publish or acknowledge a partial checkpoint. An ownership
handoff destination converts the same fetch failure to a checkpoint preparation failure before
persisting or activating the candidate state. Diagnostics include the revision and lengths when
useful, never guest bytes.
Kafka offset replica catch-up uses the same typed envelope for its revision description. A
subsequent bulk stream failure remains a transport request failure, with the target, placement and
typed staging, admission, verification or native conversion cause retained beneath it. The
replica diagnostic prints that cause chain. Cancellation or failed conversion publishes no
partial table, and an assignment-token refusal keeps a delayed transfer from overwriting a
promoted owner. A commit that lacks its required replica acknowledgement still reports the
existing quorum deadline; catch-up retries do not turn that deadline into a successful commit.
A stopping node’s stopping_node_drain request answers with outcomes of its own instead, because
its only subject is the authenticated sender: completed or failed, each with the leader’s report for
the sender’s log, or not the leader, which sends the sender to the leader it observes next. A node
that receives the request without leading changes nothing. If leadership is lost during a drain,
the interrupted report remains in the former leader’s log and the new leader continues from the
committed schedule within the original remaining budget. This classification uses the typed
command disposition and the retained consensus leadership observation; it never interprets report
text. A failed drain while the answering node still leads remains failed. When that request fails
in transport, the drain counts as requested but unanswered:
the sender reports its drain-support phase abandoned and still clears the cordon the request may
have set; see Topology Cases. A unit whose ownership handoff
cannot prepare within its share of the stopping node’s drain budget appears in the leader’s failed
drain report with its kind, name, former owner and typed entity-gate cause, including pending node
and work counts when quiescence timed out. Exhausting the remaining unit budget is reported as a
failed drain instead of waiting for the sender’s outer timeout.
Schedule eligibility compares live voter identities and the placement candidates after the
captured cordons and terminating flags are applied. A cordoned former owner’s later terminating
advertisement is an unchanged planning input. A changed live incarnation or eligible destination
still reports SchedulePlanningStale::Eligibility; changed membership or cordons is rejected
by the separate topology fence.
Relocation validates membership and liveness, then reports a captured terminating incarnation as
RelocationError::DestinationTerminating, then a committed cordon as DestinationCordoned.
The planning snapshot captures termination independently from the composed placement destination
set, so a cordon cannot be interpreted as termination and a terminating cordoned voter retains its
termination reason. These diagnostic facts do not add a publication fence for an already excluded
former owner.
A handoff confirms closed admission while its local admitted work and flush can still be pending.
The coordinator’s exact-operation drain query retains the pending node and counts; exhaustion
reports DomainAlterError::EntityQuiesceTimeout and releases the held scope. Independent schedule
units remain available to continue, and a subsequent attempt can retry the stalled unit. A gate
that expires during engagement reports EntityGateOperationError::EngagementExpired and releases
its partial intake and relay holds. The preparation deadline bounds engagement, drain and capture
independently of the activation lease. A typed reply timeout can retry the same engagement identity
and scope within that budget; rejection and other request failures keep their classification.
A listing that arrives but names a
branch key that does not decode is a failure of its own, distinct
from a failed request. A relay payload that does not decode is RuntimeError::DecodeRemoteRelay,
naming the domain and relay, with the RemoteRelayDecodeError that says what the payload got wrong
beneath it: no admission registration, a body that is not one Arrow section of the relay’s schema,
metadata or acknowledgement sidecars whose row count differs from the body’s, acknowledgement
registrations on a subscription fan-out, a branch key that does not decode, or rows that do not
assemble into a relay batch. The Arrow body, BranchKeyError or relay batch failure stays beneath
that. An Arrow body that is not one canonical IPC stream framed within its own bytes is
ArrowBodyError::Framing, naming the IpcFramingDefect: a message without its continuation
marker, a stream cut inside a message, a negative length, metadata that is not an Arrow message, a
column buffer outside its message’s body, or bytes behind the end-of-stream marker. The decoder
refuses it before it allocates or reads anything from a declared length, for a relay body and for
an Arrow section of a sealed snapshot or a backup archive alike. A client batch names the same
defects as the reason of its Malformed defect. A stream that is framed within its bytes and
declares what Arrow’s reader would panic on, a field type Nervix does not carry or a record batch
at odds with its own schema, is refused by the same scan as ArrowBodyError::Decode, a defect of
the body, before the reader reads it. Should the reader still panic on a body the scan admitted,
that ends its decode job, and the decoder reports it as ArrowBodyError::Decode too rather than
as work the node could not execute: decoding the same body again would panic again. A decoded
batch the local relay boundary refuses is the separate
RuntimeError::DispatchRemoteRelay. Remote payload handling returns these as error-stack
reports: the receiver logs the whole chain, and a payload it refused before admitting it is
answered with the chain rendered as the reason.
Cluster Interconnect
defines the exchange forms, limits, deadlines, and relay acknowledgement boundaries. A record
acknowledgement lost between two nodes becomes an ordinary negative acknowledgement: the node that
forwarded it fails it once the receiver has reported nothing about it for fifteen seconds, with a
reason that names the silent receiver, and the source retries the record as it retries any failed
acknowledgement; see
Record Acknowledgements The Receiver Stops Reporting.
Local interconnect transport, typed request, and streaming-handler failures carry reports through
their callers. A layer that changes the failure’s meaning adds context to the existing report, so
the caller can still inspect the transport or producer cause. The HTTP/2 and rkyv boundary sends
the classified remote result or its rejection text, rather than serializing the local cause chain.
sequenceDiagram
participant Requester
participant Wire as Interconnect
participant Owner as State owner
Requester->>Wire: Request snapshot description for placement
Wire->>Owner: Validated, bounded request
alt Ownership moved or state not ready
Owner-->>Wire: Rejected or NotReady with typed subject
Wire-->>Requester: Classified remote result
Requester->>Requester: Apply dependency absence policy if appropriate
else State operation failed
Owner-->>Wire: Failed with subject and opaque description
Wire-->>Requester: Classified remote failure
Requester->>Requester: Retain failure in local report
else State available
Owner-->>Wire: Description with size, digest, revision and fence
Wire-->>Requester: Successful description before bulk transfer
end
Replication Routing
Replication frame routing treats an absent state, an ended route or a replaced assignment as an ordinary non-admission: it creates no state and records no replica progress. Synchronization request admission retains the existing rejected-assignment outcome. A WASM reset lifecycle wait reports the existing typed superseded-state failure when its retained assignment loses identity or primary ownership, the existing replica-plan-shrunk failure when its promised boundary loses replicas, and the existing replica-confirmation failure when its deadline expires.
Consensus
A proposal, a linearizable read or a membership change returns a ConsensusError report, and its
variants are the classes a caller routes on. LeadershipLost carries the leader this node
observes, when it knows one. RaftStorage is a fatal storage error Raft reported for a write, and
Storage a failure of the node’s own consensus store. RaftWrite is any other failed write,
Conflict a change the replicated state machine refused, and startup, transport, linearizable-read
and membership failures have variants of their own. The class of a failed write is decided once,
from the type of the Raft error: an answer that forwards to a leader is LeadershipLost, a fatal
storage error is RaftStorage, and everything else is RaftWrite. The Raft or I/O error stays
beneath the context as the report’s cause.
The session edge reads the current context of a proposal’s report. LeadershipLost becomes a
NotLeader disposition with a redirect to the observed leader when nothing of the command was
admitted, and OutcomeUnknown with leadership loss as its cause when leadership moved while the
command’s admission was being decided or after it. Any other class fails the command with the
operation and the consensus owner’s message: the context’s text, followed by the storage or Raft
error beneath it for a storage or write failure.
A refusal of the replicated state machine is data every voter must agree on, so it is a typed value
in the Raft response and never a report. ConsensusConflict distinguishes an expired execution
reference and a conflicting one, with the kind of the conflict, from a refusal described by its
reason. TransactionMutationError is the refusal of a transaction mutation, such as an unknown or
finished transaction, an owner or position that does not match, a stale preview, or a lost domain
mutation fence. The state machine evaluates a mutation with reports and clones the report’s current
context into the response where the response is built; the report’s frames stay on the node that
evaluated it. The proposer wraps the typed refusal in a new report as
ConsensusTransactionError::Mutation, beside Consensus for a proposal that failed and
InvalidResponse for a response of another kind.
The durable store’s own failure is a StorageFailure: a write beyond its admitted byte budget, a
record that does not encode, a failed database write, stored state of an unrecognized shape, a
superseded snapshot generation, and Stopped for every operation after a failed write, whose
message tells the operator to restart the node. Appended
Batches owns what a failed write means
for the requests that waited on it.
Commands And Sessions
At the public edge, the session maps a typed validation or execution result to a command
disposition, message, and diagnostics; a transaction’s admitted and retained outcomes stay
distinct from a new execution. Replicated admission preserves an expired execution reference and
the kind of a conflicting reference as typed consensus conflicts. The session returns
ExecutionReferenceExpired or ExecutionReferenceConflict from those variants, including when a
leader change lets the replicated check discover the conflict after the leader’s local check. A
client never has to classify those refusals from message text. Consensus reports also keep Raft
leadership, fatal storage, and other write failures distinct through the control plane. A
command attempt that returns OutcomeUnknown, a leader redirect, or TransactionDetached
preserves that disposition at finalization and leaves its admitted execution applying. Recovery
determines its terminal outcome under the same reference, including during reconciliation. A
transaction mutation refusal crosses the Raft response as its exact typed outcome and becomes a
new report on the proposing node; the client still receives the same disposition and
acknowledgement semantics. Command
Dispositions defines each disposition, the phase
that produces it, and what a client may conclude from it, and typed request rejections are covered
in Rejections. DESCRIBE TRANSACTION and
SHOW TRANSACTIONS read beside an attached transaction only as separate requests; combining either read with another statement
returns a session planning diagnostic before anything enters the queue. An incomplete impact
report carries its planning diagnostics and cannot supply a commit preview. A stale preview is a
recoverable command disposition that applies no effects and tells the client to refresh its
inspection before retrying COMMIT. Transaction Quiescence And Impact
Inspection defines the planned and actual report outcomes these
diagnostics describe. A buffered ingestor’s intake-only drain uses the same typed entity-quiesce
timeout, naming the domain, pending node, work counts, and outstanding ACK roots. Its failure
releases the attempted hold and retains the committed endpoint contract. A failure while
releasing that intake hold keeps the typed release cause beneath the model alteration’s gate
error; it cannot publish the replacement. Parse diagnostics retain precise expected and found tokens and byte spans
into the submitted source for a client to underline, whichever statement of a batch was
rejected. Validation diagnostics attach a source span when the
relevant identifier is present in the submitted text; failures without a source location have an
unlocated diagnostic. HTTP endpoints choose their response status at the boundary according to
the request and failure category. For example, HTTP ingestion returns 202 Accepted after
admission and 503 Service Unavailable, with Retry-After when provided, when the runtime cannot
admit the payload; malformed requests and unknown paths are rejected separately. The resource
upload edge maps classified archive quota failures to 413, invalid archives to 400, and
unclassified store failures to 500. Sessions describes client command and delivery
behavior, while Inspecting A Transaction describes
retained command outcomes and ALTER Lock And Quiesce Classification
describes impact inspection.
The session service’s own edges report the same way. A gRPC call whose credentials do not
authenticate it fails as a GrpcAuthenticationError report, Required, Failed or Busy, and the
call ends with UNAUTHENTICATED, or with UNAVAILABLE when the node could not verify the
credentials now; the status names only the refusal. A command on a transaction its session is no
longer bound to fails as a SessionTransactionBindingError report, whose current context selects
the TransactionTakenOver or TransactionDetached disposition and whose chain is the command’s
message. A domain snapshot that cannot be encoded for a session is SnapshotEncodingError::Graph
or Frame above the graph serializer’s or the frame encoder’s report, and the node logs that chain
instead of sending the snapshot.
A model alteration that pauses its domain reports each step of the pause and the resume above the
report of the owner that failed: DomainAlterError::PauseDomain and ResumeDomain above the
consensus report of the domain’s lifecycle change, and StopIngestion and RestoreIngestion above
the runtime’s report of the cluster state it could not apply. When the alteration fails after the
pause and resuming the domain fails too, ResumeAfterAbandonedAlter keeps both reports beneath it,
the alteration’s failure first, so a consensus leadership loss in either still answers the command
with a leader redirect. The failed command, the session’s error broadcast, a backup’s capture
failure and the step’s impact diagnostic render the whole chain.
Domain Clock Attachment
A domain clock attachment answers with its own typed disposition rather than a command disposition.
An attach is Attached with the observed clock, AlreadyAttached when the session already follows
that domain’s clock, DomainNotFound when the committed domains the serving node installed hold no
such domain, or Failed; a detach is Detached, NotAttached when the session does not follow
that clock, or Failed. Every disposition but Failed names its domain. Failed carries only its
message, and the server answers it only while the session holds a transaction. A node that is still
starting answers an attach only once it has installed the committed domains, so DomainNotFound
never reflects a restart. While the session holds a transaction, both fail with the session-local
refusal that other session-scoped statements receive. The server ends an attachment with a frame
whose typed reason is DomainRemoved, and the Rust client reports a lost session as an interruption
of each clock it follows before it attaches again. An attach the new session refuses is not an error
of any call: the Rust client reports it as a typed restoration failure carrying the refusal’s
message and the wait before it tries again, and reports a refused subscription reopening the same
way. An attach the new session leaves unanswered past the request timeout ends that session instead,
and the next session attaches the clock again; one it answers DomainNotFound ends the attachment
as the server would. In the shared C binding a restoration failure carries only its domain, without
the message or the wait. Each disposition’s message is display text for a client that prints it; a
client decides from the variant. The Rust client’s execute turns any disposition but Attached or
Detached into a Failed command outcome carrying that message, and its clock helper reports a
stopped or uninstalled clock, or a projection outside the timestamp range, as a typed
DomainClockReadError. When execute runs either statement, it fails with NoActiveDomain while
no domain is selected and refuses the statement as client-local while a transaction is active,
before it sends a request. Waiting for the next clock event returns a failure to reopen a session
while the followed clocks keep waiting to be restored. The same statements sent as command text are
refused as session-local commands that need their own request. See Domain Clock
Attachment.
On the serving node, a state, tick or end frame that does not fit the session’s frame limit is not
sent and is logged as a warning: delivery waits for the next state or tick, and an end it could not
send still ends the attachment. Accepting clock progress fails only for a domain the node does not
hold, and a report that does not match the committed fence, or is not newer than the accepted tick,
is ignored without an error. That failure reaches the node’s sessions as the error notice
failed to apply domain clock progress for '<domain>' with its whole chain, and its log as a
warning.
The CLI’s domain-clock subcommand classifies attach refusals from those variants. A missing
domain and an already attached clock have distinct typed CLI errors. An attach or detach answered
Failed is ClockAttachRefused or ClockDetachRefused with the server’s message attached to the
report, and exits nonzero; a detach answered NotAttached succeeds, because the clock is not
followed either way. Transport or session failures
while attaching, reading events, or detaching retain their underlying report beneath the CLI
operation that failed.
The web console shows an automatic attach refusal in the clock panel and event log without retrying it. If its bounded request hand-off refuses a clock request before the session sends it, the console reports that local refusal in the event log; an automatic attach also leaves the panel in the refused state until the selected domain or connection changes.
If a paced clock cannot convert one period through its rate, the authority can still emit its already-due first tick. Scheduling a later tick then reports a rate-conversion or cadence error and stops production. A next-boundary overflow reports its own clock arithmetic error. None of these cases emits an early tick or silently clamps the interval.
Rust Client, CLI And C Binding
Every call of the Rust client returns an error_stack::Report<ClientError>. Its current context is
the failure a caller acts on, and the frames beneath keep the cause: a call’s transport status, the
wire codec’s report beneath EncodeRequest, InvalidUploadReply or InvalidRestoreReply, the
name’s report beneath InvalidResourceName, a local archive’s I/O error beneath
BuildUploadArchive or ReadRestoreArchive, a suggestion request’s value report beneath
InvalidCursor or InvalidCompletionPageSize, the event queue’s overflow beneath EventOverflow,
and the BackupDownloadError beneath BackupDownload. A failure that may hide an admitted command
or an installed upload is UncertainCommand or UncertainUpload above the failure that left the
outcome unknown. The client decides retries, session recovery and uncertainty from those typed
contexts and never from text, and a context names its own operation without repeating its
transport status, which the next frame shows. The CLI adds the operation it ran above the client’s
report, failed to connect to the server or the request to the server failed; its text and JSON
failure reports, its JSON inspection errors and its event-stream notices render the whole chain. A
restoration failure the client reports as a subscription or domain clock event carries the whole
chain as its message. The shared C binding classifies a failure from the current context, a failed
backup download from the BackupDownloadError beneath it, and returns the whole chain as the
error’s message.
Resource archive construction returns its first I/O failure beneath BuildUploadArchive.
Failure and cancellation abandon the partial temporary archive; they perform no destructor
finalization that could replace the cause, panic, or start asynchronous cleanup. If the enclosing
command deadline cancels local construction, the caller receives its typed RetryDeadline.
The client opens no upload call until archive finalization and flush have both succeeded.
A Rust client subscribe or unsubscribe runs on a task of its own, so that an attempt its caller stops waiting for still completes. Its caller classifies that task’s report by its current context, and recovers the session and sends the request again exactly as for any other call; a failure a new session cannot remedy is returned as that report, with its own classification.
The native Rust session client loads its Hickory resolver before opening a server channel. An
unreadable or invalid resolver configuration is ClientError::LoadDnsConfiguration above the
resolver’s configuration report; the shared binding classifies it as a connection failure. A
failed lookup within an initial, seed, redirect, or reconnect attempt is
ClientError::ConnectServer. Tonic retains DnsLookupError in that transport error’s cause chain,
so callers can inspect its host and typed failure. The outer session retry deadline can instead
end the wait as RetryDeadline. Connection timeouts and TLS name failures remain connection
failures and do not become command dispositions. OTEL gRPC reports a failed lookup or
connection through its existing infrastructure export failure; the emitter host keeps the batch
and its acknowledgement under the declared retry policy. No record rejection is inferred from DNS.
Native endpoint recovery keeps failure states distinct. A lost producer submission resolves to
ProducerOutcome::OutcomeUnknown(SessionLost) when its frame was sent but no outcome arrived; the
client never calls that batch not admitted or replays it automatically. A consumer read crossing a
session gap returns ClientError::ConsumerInterrupted before any batch from the replacement
attachment. ClientError::ConsumerReopenRequired names a changed, stopped or removed endpoint that
needs a fresh application open; ConsumerSessionUnavailable means the bounded reconnect attempt
did not establish a session. A delivery from a revoked attachment returns
DeliveryReferenceExpired before settlement, while SettlementUnknown means a settlement request
may have reached the server but its answer was lost. The application must resolve such an ACK with
its own idempotency policy. None of these errors claims that a downstream effect did or did not
occur.
Before a restarted serving node has proved linearizable catch-up, both native endpoint opens use
the ordinary retryable EndpointUnavailable refusal. They do not report a missing or stopped
domain from that node’s stale local snapshot as a terminal application error.
The shared C binding converts a clock-event wait’s error_stack::Report<ClientError> at its
reporting boundary. It classifies the typed current context as an NX_ERROR_* kind and retains
the report’s contextual message. A cancelled or expired wait returns NX_ERROR_CANCELLED or
NX_ERROR_DEADLINE without writing an event handle. Clock accessors return NX_ERROR_TYPE when
the event kind or installation state lacks a requested field and leave outputs untouched;
generation, state, and end-reason accessors require a non-null output pointer. The projections of
an nx_domain_clock convert the Rust client’s error_stack::Report<DomainClockReadError> the same
way: a stopped or uninstalled clock is NX_ERROR_TYPE, because it holds no logical time to read,
and arithmetic outside the timestamp range is NX_ERROR_INVALID_ARGUMENT, because the instant the
host passed is out of range for that clock. An attach refusal is not an error of the call: it
completes with NX_DISPOSITION_FAILED and the server’s message, and nx_session_domain_clock
reports whether the session follows the clock afterwards.
Producers And Consumers
A producer answers with typed values rather than command dispositions. A refused open carries a
ClientProducerRefusal, every submitted batch one ClientSubmissionOutcome, and an ended producer
one ClientProducerEndReason; see Producers. The outcome
classes are the failure classification for a batch, and a client decides from them alone:
NotAdmitted guarantees that no row entered the graph, ProcessingFailed reports an admitted batch
whose acknowledgement failed and may have had effects, and OutcomeUnknown reports a batch whose
effects cannot be established. An invalid batch names one ClientBatchDefect; its message and the
detail of a processing failure are bounded to 1 KiB, never quote a payload value, and are display
text only. A temporary refusal, Suspended or Busy, is an ordinary outcome that a producer
resends on its declared backoff, not a failure. A node that cannot reserve memory or a worker to
validate a batch answers Busy rather than holding the batch, and a batch that fails after it was
dispatched resolves its acknowledgement root negatively, which the producer receives as
ProcessingFailed with Rejected.
A consumer open is either Opened or a typed EmitterOpenRefusal for the domain, emitter kind,
schema, execution availability or capacity. A settlement is an ordinary typed outcome:
Confirmed, StaleReference, WrongConsumer, InvalidReason, or ConsumerEnded. It is never
inferred from a timeout or from reading a batch. A bounded application rejection reason is
treated as non-sensitive display text and applies the emitter route’s message error policy to
each member. IPC encoding failures and an output row above the declared byte limit follow that
policy, without quoting the row. Owner or forwarder loss revokes the attempt; the delivery remains
volatile, and a client must not interpret a lost ACK reply as successful processing.
The shared C binding converts the producers’ and consumers’ reports at its reporting boundary as it
converts a clock-event wait’s. A refused open is NX_ERROR_REJECTED, and nx_error_open_refusal
reads its typed refusal. A batch built for another schema, or with too many rows or bytes, a
builder input that does not fill its level or is not UTF-8, and an identity the producer does not
hold are NX_ERROR_INVALID_ARGUMENT, and nothing is sent. A stream a host submits is not checked
before it is sent: the producer answers it with its outcome, which a submission reports as a value
rather than a failure, NX_SUBMISSION_NOT_ADMITTED with its batch defect for a stream that is not
the canonical one. A closed or ended producer is NX_ERROR_CLOSED, and a consumer read past its
close is too. ConsumerInterrupted is NX_ERROR_INTERRUPTED and ConsumerReopenRequired or a
producer that must be opened again is NX_ERROR_REOPEN_REQUIRED, read with the handle’s reopen
reason; a consumer whose bounded reconnect failed is NX_ERROR_CONNECT. A settlement of an
expired reference is NX_ERROR_REJECTED, and SettlementUnknown is NX_ERROR_UNCERTAIN. A
cancelled or expired wait returns NX_ERROR_CANCELLED or NX_ERROR_DEADLINE without writing its
output. A submission cancelled that way sent nothing, and an outcome wait leaves its submission
with the producer. A read stays with the consumer, which hands its reply to the next read, and a
settlement may still have reached the server.
The paced simulation drivers classify a refused replacement
producer or consumer as a configuration error and exit with status 2, retaining the typed
refusal or schema diagnostic. A consumer’s refusal ends input planning and interrupts outstanding
outcome, rejection-notice and producer-close waits, including after planning finished: batches
whose output contract cannot be consumed cannot finish that close.
The application retains their ledger entries for an explicit --replay; reopening never resends
an unknown submission automatically.
Backup And Restore
Backup
A backup’s failures are owned where they are decided. The archive format reports an
ArchiveWriteError for a record that does not encode, a record above the 64 MiB record limit, a
streamed record whose caller stopped it or whose destination failed, a section path a tar header
cannot name, or bytes that differ from the manifest entry they were written for, and an
ArchiveReadError for an archive whose first entry is not the manifest, a
record with a foreign magic, kind, or format version, an invalid record value, a missing,
misplaced, unexpected, or out-of-order section, and a section whose length or digest differs from
the manifest. Each names the section path and the check as typed fields, and none carries section
bytes. The control plane’s backup execution reports a BackupError: no configuration yet, no
selected or no existing domain, models that are not a valid graph or do not render or parse back to
themselves, a clock mapping that cannot be projected, a resource version that is missing on the
leader or differs from its catalog entry, a record that does not encode, and an archive the
staging area cannot hold. A quiesced capture also names its domain when the mutation lease, pause,
drain, owner capture, or resume fails or times out, or when its coordinator loses the leader tenure
under which it acquired the cut. Owner capture failures are classified at the
interconnect boundary without guest bytes in the failure. A branch lifecycle or Kafka offset
section names its entity when its checkpoint does not decode, its serializer scratch or conversion
cannot be admitted to restore_metadata, or its record cannot be written; the failure renders every
context of its report. A selected WASM guest section likewise names its processor when its
checkpoint read, restore_metadata admission, or staged write fails or is interrupted; no failure
includes guest bytes. Stored materialized capture refuses
malformed headers, inconsistent group or row counts, oversized identity or column frames,
truncated checkpoints and failed stored chunk digests. These
typed codec/storage failures follow the same domain capture failure path without column bytes.
An owner still applying the selected revision waits within a five-second bound; a closed
applied-state authority or an expired catch-up
wait is a domain capture failure. A leadership change during that wait refuses the capture before
state is read. The failed command’s message is
backup failed: followed by that
error’s text. A download the server does not serve is answered with a typed refusal,
InvalidRequest, NotRetained, Expired, NotOwner or ReadFailed, or with a redirect to the
leader, and a call without valid credentials ends with UNAUTHENTICATED. The client reports a
BackupDownloadError beneath ClientError::BackupDownload, which carries the backup’s execution
reference: the server’s refusal, a transport failure, a stalled or interrupted stream, a missing
leader or a redirect loop, frames out of order or undecodable, an archive that differs from the
backup’s summary, or a local write failure. An undecodable frame and a request that does not
encode keep the codec’s report beneath InvalidFrame and EncodeRequest, rather than a copy of
its error in the context. Only a transport failure, a stall, and an interrupted
stream are retried, from the archive’s first byte. The C binding classifies a refusal as
NX_ERROR_REJECTED, a transport failure as NX_ERROR_TRANSPORT, a mismatched or malformed
archive as NX_ERROR_PROTOCOL, and a write failure as NX_ERROR_INVALID_ARGUMENT, and names the
execution reference so a host can run the backup again. No diagnostic of a backup includes archive
contents, password hashes, or resource bytes.
The native backup command wait is bounded independently of each domain’s quiesce budget and the
archive’s per-frame stall bound. An exhausted command wait reports ClientError::UncertainCommand
with the durable reference. The CLI’s JSON BACKUP_FAILED report includes
error.execution_reference for that uncertainty and for ClientError::BackupDownload; its text
report names --execution-reference as the recovery option. Reusing that reference preserves the
server’s conflict, expiry and retention authority.
The CLI’s delivery of a downloaded archive to standard output has failures of its own, which
follow the complete download that released the server’s copy. A staged archive that could not be
read, or a write to standard output that failed, is WRITE_FAILED: the report keeps the typed
error and its I/O cause, and names the durable reference with the verified archive the CLI kept,
error.archive in JSON, as the recovery, because running the backup again cannot download a
collected archive. A staging directory that could not be removed after every byte was delivered is
CLEANUP_FAILED, which names the reference and the directory, error.staging in JSON. A staging
directory that could not be created is WRITE_FAILED before admission, without a reference. So is
a standard output that would discard the archive, checked before anything is staged: the null
device, and a standard output that was closed when the CLI started, which the CLI finds holding the
null device and cannot tell apart from it. A standard output the CLI could not inspect is
WRITE_FAILED before admission as well, with its I/O cause.
The web console owns its own typed download and restore failures. A download failure names the
server’s refusal, a transport failure, a stalled or interrupted stream, a missing leader or a
redirect loop, frames out of order or undecodable, an archive that differs from the backup’s
summary, or an archive the browser could not save; it retries a transport failure, a stall, an
interrupted stream and ReadFailed from the first byte, and reports the rest as the reason the
completed backup’s archive was not downloaded, never as a failure of the backup. A restore stream
failure names an archive file the browser could not read or that is empty, a transport failure, a
stall, a missing reply, a reply that does not decode or answers another request or reference, or a
restore whose outcome stayed unknown through its repetitions, which the dialog reports naming the
execution reference. A refusal of the stream is shown as restore refused (<failure>): <message>,
and the restore’s own outcome as the dispatcher renders any command’s. The console WebSocket
transport closes a call whose client broke its framing with the codec’s close code, a second
download request with 1008, an answer that does not fit a frame with 1011, and every call when
the node stops with 1001.
A captured-section opening refused only for Snapshot request capacity retains its inventory and retries within one 30-second opening deadline. The typed capacity classification determines this retry; other request failures end the fetch. Deadline expiry remains a capture failure, and an admitted or partially consumed response is never reopened by this admission retry.
Stored branch capture keeps storage and admission failures beneath BackupStateCaptureError,
with Keyspace or Window naming the entity at native conversion. Mapping admission is decided
before a file is mapped and before a group allocates its owned values. Keyspace execution refusal
uses DeduplicatorArchiveError::Admission with the executor report beneath it; it does not
classify an unexamined checkpoint as undecodable. A cancelled or refused reader releases its view,
staged files and reservations, and the capture owner discards the incomplete section inventory.
Restore
A restore’s failures are owned where they are decided, in the order the restore meets them. The
restore stream refuses what its frames get wrong with a typed RestoreUploadFailure:
InvalidStream, InvalidStatement, SizeMismatch, DigestMismatch, QuotaExceeded, or
StagingFailed, and a call without valid credentials ends with UNAUTHENTICATED. The control
plane’s RestoreRefusal then names an archive the leader could not read, an unavailable or
unaddressable retained preparation reservation (MetadataAdmission, with the executor’s typed
admission failure beneath it), one that does not verify, with the archive format’s
ArchiveReadError beneath it, a domain whose models.nspl does not parse, with the line and the
parser’s diagnostic, a statement that creates no model, with its number and line, a restore that
cannot apply to this cluster, and a domain whose models do not form a valid configuration, with the
transaction planner’s report beneath it.
BranchKey names the archive section, the lifecycle entry or descriptor, the entity and the domain
of an archived branch key that is not a key of the branching its restored entity declares, with
ArchivedBranchKeyError beneath it: a key that is no typed branch key, with the runtime’s
BranchKeyError naming the field beneath that, a key that is unbranched where the entity runs in a
branch or concrete where it runs unbranched, a key of none of the branches an ingestor’s or
reingestor’s routes write, or a key that is not a key of the entity’s one branch, with
BranchKeyShapeError naming the missing, undeclared or mistyped field and RuntimeValueTypeError
the declared and found types beneath it. UndeclaredBranching names an entity for which the
restored schedule resolves no branching, with RestoredBranchDeclarationError beneath it.
Installation repeats both checks before it stages any state and keeps the same chain beneath the
failed step.
Beneath a restore that cannot apply, the decision layer’s RestorePlanError names the domain, user,
resource, version, or model: a domain archive given to RESTORE CLUSTER, a domain the archive does
not hold or the cluster already has, an archived user the cluster has under ON EXISTING USER FAIL,
a resource the domain does not declare, a version outside its declared sequence, completed without
checksums, or without its bytes, bytes that do not match the version’s root checksum, and a model
that binds a version other than a restored one by number. Each of these is reported as restore refused: and its reason, and changes nothing.
Materialized archive descriptors and identities reject invalid counts, names, typed branch fields,
watermark order and supported record headers before runtime installation. RestorePlanError::MissingClock
refuses a paced RESUME without its committed mapping. RestoreRefusal::MaterializedState names
the domain and relay when preflight conversion fails; MaterializedRestoreError distinguishes
invalid section lengths, identities, a record identity whose branch key is not a key of the
relay’s branching (BranchKey, naming the section and the identity), Arrow schema or row counts,
metadata limits, admission, cancellation, framing and staging. A preflight admission refusal records no restore progress and
can be presented again; it is reported as preparation refusal without judging the archive invalid.
Native MaterializedSnapshotError checks framing, metadata bounds, unique keys,
counts, exact schemas and complete container consumption. Diagnostics carry typed causes and
entity identities, without payload columns or branch field values. Replica installation refuses
a revision older than its currently installed materialized revision.
Once admitted, a step that fails ends the restore as restore failed at step '<step>': and its
reason, with the restore’s report: the consensus command that records a step refuses it with a
RestoreStepConflict naming the step and the domain, user, resource, or version, and a resource
import, domain model batch, or state installation keeps its own failure beneath the step. The steps
before it stay applied, and the message says so. RestoreStateInstallationError distinguishes an
incomplete installation that blocks starting a domain from authority that no longer permits
mutation. The runtime store reports a stale or competing published generation as
RuntimePersistenceError::RestoreGeneration.
Native conversion keeps its codec failure beneath SnapshotStagingError::Encode and the restore
step; cancellation and encoding beyond admitted disk quota discard the temporary artifact while
retaining its memory and quota until the job actually exits. NativeEncoding identifies the native
state kind and retains the serializer’s typed cause. Staging Create, Write and Read also
retain the underlying I/O cause beneath a semantic context. Window reports the requested position
and length and the artifact’s exact length without overflowing a diagnostic sum or exposing
checkpoint contents. InvalidCheckpointChunks covers a missing, misordered, truncated or
digest-mismatched current chunk set or a conflicting publication inventory. RestoreRead,
Cancelled, Synchronize and storage admission preserve their owning failure boundary.
CheckpointPlacementTooLarge rejects an encoding beyond the bounded storage key allowance.
InvalidStorageFormat requires recreation of the node state directory when the required current
format marker is missing or invalid.
RestoreStagingQuota carries the node’s unpublished checkpoint limit, current usage and incoming
checkpoint footprint; RestoreStagingSize rejects an unrepresentable accounting sum. A quota
failure remains a storage failure beneath the admitted restore step and leaves its activation gate
closed.
Node-local maintenance logs admission, cancellation or storage failure and retries on its next sweep without changing the command outcome or gate. Metrics are updated only for a completed sweep, so a partial cancelled deletion cannot claim a completed reclamation count. Maintenance treats an absent or inline selected header, or another selected checkpoint revision, as ordinary chunk unreachability. Malformed chunk coordinates or bounded checkpoint headers keep their typed storage errors. Bounded deletion may already have committed earlier batches before cancellation or a later error; restart or the next successful sweep resumes from remaining keys. Completed reclamation counts include unreachable active chunks as well as unpublished staging.
Staging or publication failure leaves the durable start gate in place, including a failure after the
complete generation’s pointer became durable but before runtime handles were cleared. Exact
publication retry completes durability and bounded cleanup under the same authority and inventory.
Transaction planning reports a blocked START as TransactionPlanningError::RestoreInstallation,
naming the domain and restore execution before lifecycle admission. The client receives a definitive
failure.
No restore diagnostic includes password hashes or resource bytes. When a state section’s entity is absent from the restored schedule or its schema fingerprint differs, installation skips that section and the successful command carries an unlocated warning diagnostic. The same applies to a verified state record whose kind tag or version is unsupported. The CLI includes those warnings in text and JSON reports.
The client reports an archive it cannot read as ClientError::ReadRestoreArchive with the path and
the I/O error kind, above the I/O error when the client read the file itself, an empty file as
ClientError::EmptyRestoreArchive, a failed call as ClientError::Restore with its status, and a
reply that does not decode as ClientError::InvalidRestoreReply above the codec’s report; an error
that may hide an admitted restore is ClientError::UncertainCommand with its execution reference,
above the failure that left the outcome unknown. The C binding classifies an unreadable or empty
archive as NX_ERROR_INVALID_ARGUMENT, a failed call as NX_ERROR_TRANSPORT, and an undecodable
reply as NX_ERROR_PROTOCOL.
Sensitive Data And Observability
Error variants and public diagnostics identify operation, field, entity, and placement rather than carry sensitive payload values. Error-route metadata and hot-path logs must not reveal sensitive input, credentials, key paths, or certificate contents. A route that deliberately copies an input field into its ordinary output still obeys the normal explicit sensitivity rule. Operators can correlate a stable error reference with a code and affected fields without seeing the secret.
What A Diagnostic May Name
A report’s contexts reach sessions as failed commands and server notices, logs as they are, and other nodes as reasons, so every context is written as public text. Each owner applies the rule at the point where a value could otherwise enter a diagnostic:
| Where a value could enter | What the diagnostic carries |
|---|---|
| A payload a codec rejects | The codec, the field when one is known, and the reason; never the rejected value |
| An expression that fails for a row | The typed row error with its code and expression span, and in a message error the affected field paths |
| A batch container that cannot be produced | The typed reason alone; the evaluation failure beneath it can quote the payload and is not rendered |
| A row a database refuses | The SQLSTATE, code or named rejection; response text that could quote a bound value is discarded |
| An HTTP request that fails or is refused | The status or the cause of the connection; never the evaluated target, a header value, a credential or a body |
| A broker address or a driver’s connection error | The host and the cause of the connection failure; never the credentials of the address or a record |
| A WASM guest’s saved state and a checkpoint transfer | The revision, the lengths and the digest; never guest bytes |
| A backup or a restore | The section path, the entity and the check that failed; never archive contents, password hashes, resource bytes, payload columns or branch field values |
| Credentials that do not authenticate a call | The refusal alone, in the call’s status |
| A client batch’s defect and the detail of a processing failure | Display text bounded to 1 KiB that quotes no payload value |
| A failure another node executed | That node’s operator description, as opaque text beside an already typed class and subject |
| A concrete branch | The fingerprint of its key, as the next section describes |
A structured message error is the same rule for a route: its message is non-sensitive, and the handler reads the failed input, its state snapshot and its partial output as typed fields under the ordinary sensitivity rules, never as text in the error.
Naming A Branch
A branch schema may declare key fields SENSITIVE, so nothing that names a concrete branch in
text renders its key’s field values. Errors, runtime events, negative acknowledgement reasons and
logs name the execution a failure belongs to as branch <fingerprint> or unbranched; a log
record carries the same text in its scope field. One type renders that text, and a branch key has
no display form of its own, so a context that names a branch cannot print its key. The fingerprint
is the lowercase hexadecimal digest of the branch’s canonical key text, the identity DESCRIBE WASM PROCESSOR checkpoint lines and DESCRIBE BACKUP print and transaction-impact reports carry for
the same branch, so an operator matches a failure to those reports by that text. The per-branch
statistics the execution graph carries to sessions name each branch by the same fingerprint, and
per-branch metric series are keyed by it. A session subscription, which masks sensitive key fields,
is where a key’s other field values are read.
The fingerprint is a plain digest of the key text, computed without a secret. It hides a key only
as far as the key’s values are hard to guess: someone who can guess a sensitive key value can
compute its fingerprint and confirm the guess against a diagnostic. The rule also governs only text
that names a branch in a failure, an event, a log or a statistic. A statement or a sink that an
operator directs at branch data follows its own contract: SHOW RELAY <relay> MATERIALIZED STATE
prints each entry’s key and payload (Relay), and an SQS emitter’s FROM BRANCH group
is the record’s branch key (Emitters).
Log Levels
Per-message and per-batch detail belongs at debug or trace; info is for lifecycle,
administration, topology, and unusual transitions. A failure the caller recovered from and is the
only witness of is recorded at debug. Metrics And Observability
defines the available metrics and their aggregation.
Recovery, Panics, And Enforcement
Recovery Classes
Some outcomes are intentionally not propagated. discarded records why an already handled or
irrelevant result owes no further action. reported is used when the recovering call is the only
witness; it logs the failed operation at debug. A channel send with no receiver means shutdown
when the receiver stopped with its node, domain, or task (means_shutdown), or withdrawal when a
requester, session, or observer left (means_peer_left). If the receiver was guaranteed to remain,
its absence is a broken invariant. A watch value needed by later subscribers uses send_replace
so loss of current subscribers cannot leave stale state. Shutdown And Recovery
owns where these outcomes occur during stop and drain.
Closing the runtime’s checkpoint announcement task owner after drain cancels pending dispatches and retry waits, then joins them before withdrawing their routes. This cancellation is an ordinary terminal task ending; replica synchronization supplies any missed checkpoint availability hint.
Panic Classes And Guarded Dependencies
Broken internal guarantees take the explicit panic classes assured for a construction or platform
guarantee, verified for a condition checked on the current path, and todo for a deliberately
unimplemented path. An actually reachable failure instead becomes a typed error or a valid state
in the type. A dependency that panics on input a caller can supply is such a failure too: its owner
refuses that input with a typed error before the dependency reads it, and that guarded read is the
only way the rest of Nervix reaches the dependency. The vocabulary’s duration parser,
parse_duration_text, refuses text whose spans would overflow humantime’s duration arithmetic
with DurationTextError::TooLong, and every reader of duration text uses it: NSPL literals, Model
settings, window aggregate arguments, node command-line options and their environment variables,
benchmark settings and test harnesses. Clippy rejects a direct call to humantime::parse_duration
and any use of humantime::Duration, whose text conversion reads through the same parser. Arrow’s
IPC stream reader trusts what a stream declares: it panics on a field type or a type parameter it
does not implement, on a list without its child, on a buffer that reaches past its message body,
on a validity bitmap shorter than the nulls it is declared to hold, on an offsets buffer that ends
inside an offset, on variadic buffer counts no field takes and on a fixed-size list too long to
count, and it allocates a message’s metadata and body from their declared lengths before reading
them. Every reader of an Arrow IPC stream from outside the node, a relay body, a snapshot section,
a producer’s batch or a WASM guest’s generated pool, therefore first scans the stream: the
continuation markers and lengths inside the stream, the schema message first with only the field
types Nervix carries, uncompressed record batches that declare exactly the field nodes and buffers
that schema’s fields take with every buffer inside its message body, and the end-of-stream marker
ending the stream, where a generated pool may instead end after its last message, as the Arrow
format allows. The scan alone opens Arrow’s reader over such a stream. A stream it refuses fails
before the reader sees it: as misframed or undecodable for a relay body or a snapshot section; as
malformed, of another schema or of invalid data for a producer’s batch; and as unreadable, or as
declaring a field Nervix does not carry, for a generated pool. Two readers of Arrow IPC stay
outside the scan because neither reads a stream from outside the node: the Iceberg sink reads back
the staged files the node itself wrote, inside a storage job whose panic the executor reports as a
failed commit, and the Rust client reads the deliveries a node wrote.
DurationTextError describes only the reason, humantime’s own for malformed text or
it is longer than a duration can be, so each owner keeps its diagnostic around it: the setting,
the text it could not read, then that reason. An owner with a typed error of its own, such as the
command line, an ingestor’s start, the activation, entrypoint, runtime and emitter plans, the
WebSockets signaling compiler, and the benchmark settings, also keeps the DurationTextError
beneath it. The benchmark settings name the parameter and the value it could not use in
SettingsError::InvalidParameter, with the parser’s own error, a ByteSizeError, or a
ParameterValueError beneath it: a value that is not a scalar or not a string, one beyond the
template integer range, or a flush interval that would need a run longer than any benchmark can
measure. A node given such a value on its command line or in an environment variable names the
option and the reason and exits with status 2 before it starts. A dropped result with no stated
recovery class does not establish that it was handled.
Benchmark Tooling
The rest of the benchmark tooling returns reports too. Catalog discovery and loading name the
benchmark, the implementation and the file, and a template that does not compile or render keeps
the template engine’s diagnostic beneath it once. A metrics report names the line, metric, series
or quantile it could not use, with the reason as a typed cause, such as a sample value that is not
a number or a histogram whose buckets decrease. A comparison names the run directory and the
artifact it could not load, an A/B summary the arm whose artifact failed, and topic provisioning
the topic and the partition count it waited for. The benchmark command adds the benchmark and
implementation it was running above those reports and above the client’s own report of a
connection or statement, and prints the whole report when it exits with status 1; run-all
records each failed implementation with its chain.
The Reported-Error Guard
just validate-typed-errors, run by just validate, rejects textual
Result<_, String> in product code without a baseline. It remains a separate source rule,
including inactive source. The compiler architecture gate, run by just ratchet, owns three
resolved Rust diagnostics:
| Diagnostic | Contract it checks |
|---|---|
nervix::bare_error_signature | A canonical Result carrying a Nervix-owned standard error returns a contextual Report, directly or inside resolved Future::Output and Stream::Item. |
nervix::discarded_outcome | A let wildcard, unused statement or resolved drop consuming a Result, report, owning collection or transparent wrapper states its handling or recovery class. |
nervix::bare_panic | A resolved inherent Option or Result unwrap/expect uses meticulous to state its guarantee. |
The same driver emits the source-owned layer, Model, language, awaited cluster, identity and installation-state diagnostics described in Data-Plane Concurrency. They reject compiler-visible architectural contract violations and share the exact-operation expectation checks. They do not classify a product operation’s error or replace its contextual report.
Aliases, renamed imports, borrowed failure definitions, associated types, generic substitutions, inferred closures and boxed future/stream contracts use compiler types and trait obligations. A same-named method on an ordinary type carries that type’s own contract. A plain value, borrow or optional ordinary value is not a dropped failure. Matching an outcome, propagation and the resolved recovery traits are explicit handling.
Resource state and guards can retain already observed reports while owning a separate lifetime. Releasing such a handle is ordinary lifetime management. The discard check follows transparent outcome storage and complete standard-error report carriers; it does not recursively treat every field of an arbitrary resource owner as a new operation outcome.
An error’s owner classifies a pure conversion refusal, cancellation signal, semantic row or record outcome, fixed wire response, or modeled external-library error at its exact type:
#[cfg_attr(nervix_lint, nervix::error_boundary(
outcome, reason = "this conversion returns a pure range validation refusal"
))]
The library kind names the external contract a modeled primitive preserves. A classification
may also name one exact return contract, including a trait method; it never inherits from a
module, package or enclosing callable. Both kinds require a meaningful reason. Malformed,
duplicate and misplaced classifications fail. Source metadata crosses crate boundaries.
A type’s Error suffix establishes no classification.
Foreign hooks that require std::error::Error receive a carrier owning the complete report.
The compiler checks that every carrier variant retains a report, including through boxed or
shared ownership. Optional reports do not establish that guarantee. DnsLookupReport retains
the lookup report at the Hyper boundary; the node trace connector retains its connection report
through Tonic. The consensus append stream, control-operation responses and handoff responses
keep their fixed semantic wire outcomes; local operation failures acquire context before their
wire projection. VM row reasons and connector record rejections retain their semantic channels.
Node database opening and runtime key reads preserve their storage cause beneath the owning failure. The paced simulation’s ledger, effect store and run return reports, retain decoding and client causes, and classify exit status from typed contexts. Its terminal reporting prints the context chain. A newer consumer refusal or node observation may explicitly discard the report it supersedes.
These diagnostics use normal Rust lint levels and one-operation reason-bearing expectations.
The required gate rejects unresolved warnings, broad suppression and unfulfilled or widened
expectations. No numeric allowance approves a failure site. Fixtures exercise real compiler
resolution; current API doctests pair supported calls with compile_fail examples. For a new
fallible site, a reviewer identifies the deciding layer, ordinary outcome or failure class,
actionable fields, context across boundaries and the public diagnostic or recovery that ends the
path. That classification preserves branch and sensitivity rules.
Tooling And Diagnostic Runs
The compiler architecture gate owns typed tooling failures for invalid source contracts,
conflicting findings and incomplete compiler passes. ContractProblem retains the specific
argument, kind or missing contract coordinate inside an error-stack report until the Rust
diagnostic boundary formats it. Reports preserve source location, resolved receiver/operation,
owner and compiled configuration context. Missing or stale analysis fails rather than becoming a
zero debt count. These are repository validation errors and add no runtime failure variants.
Data-Plane Concurrency owns the gate’s coverage and synchronization policy.
The isolated architecture compiler emits ordinary Rust tool diagnostics:
nervix::sync_acquisition, nervix::lifecycle_call, nervix::unknown_effect and
nervix::invalid_contract, together with the typed error diagnostics above. Source contracts and narrow reason-bearing expectations own the
architectural classification. Invalid contracts, unfulfilled or widened expectations, incomplete
compiler reports and changed inputs fail the repository command; they are tooling failures, with
no runtime error or public protocol disposition. The diagnostic gate rejects unresolved Nervix
warnings too. Data-Plane Concurrency
states the rule boundary and the claims the compiler does not make.
The external Chaos controller distinguishes an observation failure from a product recovery
failure. A degraded-link bandwidth probe that cannot establish its connection, deliver its full
payload, observe the receiver’s successful exit, or retain its helper evidence reports the probe’s
sender, receiver, address and port. Its adjacent probe record preserves readiness, transfer
deadline, both helpers’ outcomes, received byte count and cleanup outcomes, with raw helper logs.
A timeout of the Docker client also ends the exact sender container; it does not leave a live
transfer to alter a later measurement. A failed required attempt remains evidence even after a
subsequent successful qualification. The controller’s measurement budget does not extend the
product’s recovery budget. The external Chaos runner owns the
commands, measurement interpretation and retained artifact paths.
Network-fault preflight refusals retain the failing stage and original command status, exact
arguments and bound, elapsed time, helper identities and both raw streams. The operation record
exists before execution, so an interrupted inspection remains identifiable. Cleanup captures the
injector’s state and logs before removing it and records its own failures independently of the
original refusal. A sidecar that disappeared between enumeration and inspection is confirmed
absent and recorded as an ordinary outcome; a failed inspection of a still-present sidecar fails
cleanup. The public setup error reports the actual refusal rather than inferring that the worker
lacks netem or iptables capabilities from a Docker client failure. A successful helper exit alone
does not establish successful Docker-client completion within the inspection bound.
Only the ping helper’s missing-replies outcome qualifies blocked traffic. A Docker execution
refusal or a probe timeout supplies no effect observation and fails preflight with its own status.
The real preflight failure controls qualify separately from a scenario’s timed verifier self-check.
They resolve the pinned tool images before starting an injector with automatic pulls disabled.
Missing-image preparation retains each inspection, pull and confirmation outcome and its raw streams;
a preparation refusal ends qualification before any fault or product node starts.
CI retains their raw outcomes before starting the suite; failure to complete that qualification
fails the shard without consuming a product scenario’s execution window.
Directed-link packet observations distinguish an incomplete sample from an effect outside the
configured limits. The latter is an injection qualification failure whether too few packets were
lost or too few replies remained; it does not assert that the fault was absent or that product
recovery failed. Burst samples use 400 packets inside the existing observation window, and the
effect verifier preserves the original received and lost fractions. Failed observations remain
retained alongside subsequent qualified samples.
Kafka end-offset observations retain their exact command, raw streams, command outcome, parsed
offset, start time, elapsed time and effective bound before the controller relies on them. Output
stability requires consecutive successful equal observations above the accepted-input minimum.
A failed query or a value below that minimum resets the sequence, and an individual query cannot
outlive the remaining 30-second observation window or the whole-run deadline. The final record
distinguishes an exhausted whole-run budget (controller) from failed observations, insufficient
output or a moving boundary (observation). None of those observations alone proves a missing
record; the exact ledger owns that verdict. Baseline suite budgets separately account for the full
600-second verifier preparation allowance and the topology’s execution allowance, without
extending a product deadline.
Kafka administration borrows no stdin or interactive terminal from the controller. A failed final
query retains its command failure classification even after earlier successful reads, and a
refusal to create or finish an evidence record fails the controller instead of accepting an
unrecorded observation.
End-offset queries execute in the existing broker, with no ephemeral container lifetime inside
each observation window. The packaged-binary preflight retains its labeled helper until final
run cleanup and records the command and container inspection as separate outcomes. An expired
Docker client command remains a failed observation even if inspection sees a successfully exited
helper; a missing or invalid inspection is also a failure. The controller does not reinterpret
such refusals as missing binaries. Its live Docker event recording begins before this probe.
Publishing a validated startup status is a controller operation. Its byte copy and atomic rename
share a bounded command, clipped to the remaining run time, and retain their native outcome and
monotonic elapsed seconds under diagnostics/startup-status/. A refused or expired publication
keeps the attempted public status and fails without claiming a product readiness defect. A final
transcript is published only after its copy succeeds; failure to record the operation also refuses
publication. Subsequent container enumeration preserves failure under its own bounded command.
The end-to-end benchmark driver distinguishes fresh Kafka partition leader readiness from a
failed measurement. Before any warm-up input, its empty-topic preparation retries only the
driver’s typed NotLeaderForPartition and LeaderNotAvailable metadata failures within one
thirty-second query budget per topic. A nonempty topic, another query failure, or an exhausted
budget fails preparation with the Kafka cause retained. Measured delivery and output auditing
keep their own failure boundaries and are never repeated by this preparation check.
A diagnostic node’s findings retain a typed source. WaitForGraph/ActiveCycle establishes an
active tracked-lock cycle and ends the diagnostic process with status 3 after recording.
LockOrderViolation/PotentialCycle is historical order, retained as unreviewed evidence while the
operator’s workload continues. It never becomes a confirmed outage merely because the history is
cyclic. Local qualification requires a correction or explicit non-overlap/shared-reader/lifecycle
proof and a retained regression reference, keeping the original cycle. Missing or truncated source
context, unreviewed potential order and active findings cannot qualify; selected exports cannot
qualify the source process by hiding another finding.
An overload identifies the handoff, order history or evidence/output retention owner. Lost context,
failed output/recording and a recording deadline are diagnostic execution failures, exit status
4, never a clean run. The recorder writes directly to the standard error descriptor and uses a
cancellable ten-second deadline for each finding. Expiry exits without another write, because the
same descriptor may be blocked. No error is returned to the code whose locks deadlocked. Nothing
logs protected application values or uploads artifacts. The detector callback performs bounded
handoff rather than reporting I/O; a sink panic aborts so a swallowed callback failure cannot look
clean. A tracked constructor before installation panics with a configuration failure.
nervix-deadlock owns EvidenceError and review refusals: unsupported format version, malformed or
out-of-bound current values, invalid proof, missing finding, nonpotential finding, incomplete
context, and an exclusive mode contradicting a reader proof. The ordinary local report tool
preserves these in error_stack::Report, exits 4 on an operation failure and 5 when valid
evidence does not qualify. Before its verdict, qualify prints one evidence summary: line of
counts: findings by kind, unreviewed and nonqualifying records, repeated deliveries of a retained
cycle, and findings lost to each overload source. A qualification without that line is a tool
failure, never a clean observation. It does not reinterpret a prior evidence shape.
Data-Plane Concurrency owns the selections,
source correlations, deduplication, bounds, triage examples and detection gaps, including omitted
requested-read edges and the upstream dispatcher backlog. Qualification is about recorded evidence
and requires the run’s completion/coverage record too. The diagnostic lane classifies each
supervised process’s ending, an active cycle, a diagnostic failure, a signal, a timeout, a leftover
process, incomplete accounting, and missing, partly written or nonqualifying evidence, as one named
failure class with its own status, and reports a failed invocation’s recorded active cycle as the
failure it is.
Qualification Evidence
The rules of this chapter are held by the source guard, resolved compiler fixtures, Cucumber scenarios through the public interface, registered Bolero properties for input a decoder must refuse, and unit tests beside each owner.
| Guarantee | Evidence |
|---|---|
| The guard resolves the contracts it claims | just test-typed-ratchet-contracts: resolved error aliases, associated outputs, generic calls, inferred closures, nested Future and Stream returns, complete foreign report carriers, named discarded outcomes, canonical panic APIs and source classifications across crate metadata. The distinct Result<_, String> source rule’s unit tests run with just test-docs |
| Local storage and foreign hooks retain complete typed causes | just test-typed-error-runtime checks database opening and trace DNS causes. Paced-driver ledger and effect-store tests retain typed I/O and decoding causes; paced_simulation.feature checks a contextual clock failure and malformed-file causes rendered once on one and three nodes |
| A parse or lex diagnostic locates its token in the submitted source, in any statement of a batch | typed_error_qualification.feature: the byte span and underlined text of a single statement, of a later statement of a batch, and of a rejected character |
| A validation failure keeps its owning model and its specific cause | typed_error_qualification.feature: a VM compile failure names the model and the unknown function, a message-error branch mismatch names the node and route, and a rejected alteration names the model and the refused operation and changes nothing |
| A message error keeps its concrete branch and omits sensitive input | typed_error_qualification.feature, with interleaved records of two branches, and node_error_policies.feature for the structured operation of a route filter |
| A runtime event and a failed command render the whole chain | runtime_report_chains.feature for a source cadence above its clock arithmetic, lookup.feature for a domain build above a lookup, its line and its codec, and lookup_hash_map.feature for one internal error of a whole batch |
| A cause appears once in a rendered chain | inferencer.feature: the cause of an unreadable ONNX model appears exactly once in the failed command’s message |
| A branch is named by its fingerprint | wasm_processor.feature: the server error of a branch with a sensitive key carries the fingerprint and not the key value. The runtime’s unit tests hold every branch-local context to the same rendering |
| A consensus failure keeps its class at a client mutation | consensus_failure_reports.feature: creating a domain or a user, cordoning and draining a node, and queueing a transaction statement each report the failure that rejected the proposal |
| A session ends with the status of its failure | session_protocol.feature, authentication.feature and cli_session.feature |
| The formatter reports rejected source with its stage and exit status | nspl_format.feature |
| Input a decoder must refuse fails with the owner’s typed error and never panics | The registered Bolero targets nspl-statement-text, models-duration-text, wasm-protocol-malformed, runtime-arrow-bodies-malformed, client-producer-batches-malformed, relay-wire-messages-malformed, client-discriminators, backup-malformed-records and consensus-state-corruption |
Every registered Bolero property runs as an ordinary randomized and corpus test for each pull
request. The sanitizer-backed fuzz campaign over the same targets runs in CI only for a pull
request labeled fuzz; any other run skips it, and a skipped campaign is no fuzz evidence.
Property Testing And Fuzzing owns the
commands and the gate.
Paired compile_fail and compiling doctests hold current APIs beside their failure paths. The DNS
hook accepts DnsLookupReport as a standard error, and the recovery API classifies a real outcome.
The VM’s function injection receives the selected rows’ earlier errors together with domain time.
Resolved compiler fixtures hold the contextual-report, discarded-outcome and canonical panic rules;
the source guard separately rejects textual string errors, including inactive code.
Adding A Fallible Operation
A new fallible site, or a change to an existing one, answers these in order:
- Which layer decides its meaning. The failure belongs to the owner that can say what went wrong, and that owner’s existing error type gains the case. A second form of a failure the owner already represents is a duplicate to remove.
- Whether it is a failure at all. An ordinary outcome is a typed value its caller branches on.
A failure a caller, a payload or a configured limit can reach is a typed error. A condition that
cannot happen is a broken invariant and takes
assuredorverifiedwith the guarantee as its reason. A row’s or a record’s failure stays a typed value in its batch’s outcome. - Which typed fields let the caller act. The context carries the identities and values a caller decides from, and no preformatted reason. It carries no payload value, and it names a branch by its scope.
- Which context must cross each boundary. The report is created at the failure, and each layer
that changes its meaning adds its own context above it. A context names its own operation, does
not print its
#[source], and does not repeat a node or a domain that a context above it names. - Where the report ends. A fixed wire, ABI, stored or public outcome is projected from the report where that outcome is constructed, and the owner states which typed data selects it. A foreign trait receives a standard error that holds the whole report.
- Which diagnostic or recovery class closes the path. A reporting boundary renders the chain
once with
{error:#}. A failure the caller survives states its recovery class withdiscarded,reported,means_shutdownormeans_peer_left. - Which evidence holds it. A failure observable through a command, a session, an endpoint or
a runtime event has a Cucumber scenario that asserts its message, disposition or status there. A
decoder’s rejection has its registered Bolero target. The change keeps
just validateandjust ratchetpassing without raising a count.
The classification preserves branch and sensitivity rules at every step.
Guarantees And Limits
The model guarantees:
- Every product signature that returns a Nervix error returns it inside a report, apart from the
shapes The Reported-Error Guard lists, and no product signature
returns a
Stringerror. - A decision is made from typed data: a current context, a typed context beneath it, a typed field, or the class of a remote result. No retry, redirect, acknowledgement or disposition is selected from rendered text.
- A projection leaves its fixed outcome as it was. NSPL diagnostics and their spans, VM error codes, wire and ABI results, retry and acknowledgement decisions and command dispositions do not depend on how many contexts a report holds.
- A failure of one row or one record costs no report and no formatted message until a policy reports it.
- No context, message error or log names a payload value or the field values of a branch key.
It does not guarantee:
- A report does not cross a process. Another node receives a class and a subject, or reason text; a guest receives a code; a client receives a disposition and a message. The frames stay on the node that created them.
- A rendered chain is display text. Its wording changes with any context in it. A client decides from dispositions, statuses and typed outcomes, and a chain is for the person reading it.
- Compiler checks have a finite surface. They resolve the declared configurations and the documented owning wrappers and APIs. They do not prove whole-program ownership or determine an arbitrary resource owner’s recovery policy. The distinct string-error guard reads source text.
- A fingerprint is not a secret. It hides a branch key only as far as the key is hard to guess.
The shipped code falls short of the model in these places:
- Reasons that copy a cause as text. The registry keeps a vocabulary rejection’s report beneath its invalid-model refusal and also quotes the rejection in the refusal’s reason, which is the text the session shows. A model alteration’s rollback, backup drain timeout and entity gate failures, and a backup’s domain capture failure and timeout, carry the failure they report as a reason or details string, the rendered chain or its outermost context, with no typed cause beneath them. The interconnect copies a cause’s outermost context into the transport failure it places above that cause, so the rendered chain says it twice.
- Sources printed by their context. The interconnect’s I/O, TLS and HTTP/2 transport errors
print the source
error-stackalso records beneath them. - A node or a domain named twice. An emitter’s or generator’s start and a processor’s planning
name their node above a VM compile failure that names it again, and a start report beneath the
domain build names the domain again, as in
failed to build domain execution for 'edge': failed to start emitter 'audit' in domain 'edge': .... - Storage failures without their cause. Some runtime state-store reads, writes and synchronizations still report their own context without the storage engine’s error. Key walks used by replica and WASM state operations retain that cause beneath their read context.
- A formatter defect without a statement. A verification defect the formatter cannot attribute to a statement is reported at line 1.
- A literal canonical NSPL cannot spell. A float literal too large for
F64reads as infinity, which canonical rendering then refuses.
Typed States And Validation Boundaries
Nervix represents absence and distinct semantic states in its types. A zero, empty string, empty collection, maximum integer, or all-zero digest is a value in its own right; it does not stand for a missing value or a different state. The owner of each state decides when a value is required, validates it there, and passes the resulting typed value to the next layer. This keeps the registry, execution plans, runtime, clients, and diagnostics from assigning different meanings to the same raw value.
This chapter describes the landed typed-state architecture across those boundaries. The Cluster Interconnect chapter owns node-to-node wire forms, membership, relay delivery, and transport failures. Domain Clock owns clock installation, authority, and time bounds. Shutdown And Recovery owns drain, handoff, and restart semantics. The state rules here apply to those systems without replacing their detailed contracts.
Execution Plans follows the validated values into a complete installed revision.
A State Has One Owner
Text is parsed into a semantic Model, registry validation resolves its references and contracts, and planning produces an execution plan. Each stage passes forward a value whose type expresses what has already been established. Runtime code executes the plan; it does not reinterpret a Model or infer a state from a display label. Shared identities live in the vocabulary so every layer uses the same meaning for a domain, branch, node, timestamp, or schema fingerprint.
Source-owned identity and installation contracts
Gate these reviewable contracts with cfg_attr(nervix_lint, ...):
#[cfg_attr(nervix_lint, nervix::identity(node, reason = "destination cluster member"))]
destination: ClusterNodeName,
#[cfg_attr(nervix_lint, nervix::state(installed, reason = "constructed before branch admission"))]
branches: OnceLock<NamedBranches>,
identity(node, ...) marks shared vocabulary identity types, and declares an identity requirement
on an exact field, parameter, type alias or function return. The compiler resolves aliases and
normalizes types. References, arrays/slices, Option, Vec, Box and shared Arc wrappers carry their
typed identity; Result checks its success type. A tuple or unrelated container containing a typed
node does not make another string member an identity. Identity types must belong to the vocabulary.
The current vocabulary includes cluster node names, process-incarnation identities, graph node
references and domain node references. Ordinary labels and external pass-through strings carry no
identity requirement merely because their identifier contains node.
state(installed, ...) describes a field’s installation lifetime and forbids a resolved RwLock
anywhere in its normalized representation, including aliases and outer wrappers. Immutable
construction, one-time initialization or an appropriate publication primitive preserves the owning
lifetime. A field containing RwLock<Option<name or shared handle>> without a state contract emits
nervix::state_contract for review. Its type cannot establish whether the value is installed once
or replaced repeatedly. A genuinely replaceable field declares state(mutable, ...) with the
replacement’s actual owner and reason. The annotation proves neither a runtime lifetime nor a
publication ordering; affected runtime corrections still need their owner tests and models.
These are ordinary compiler diagnostics in the complete architecture gate, without numeric allowances or a filename-based identity policy. The developer guide describes qualification and evidence.
The representation depends on the question being asked:
| Question | Representation | Boundary obligation |
|---|---|---|
| May the value be absent? | Option<T> | Keep None distinct until the owner either permits absence or reports a missing required value. |
| Which semantic state exists? | An enum variant with the data of that state | Match the variant; do not reconstruct the state from a string, count, or collection shape. |
| Is a present value valid? | A validated type or a fallible conversion | Reject invalid input where the owner has enough context to explain it. |
| Can a physical format encode the state? | A private wire, storage, Arrow, or atomic encoding | Decode at the boundary and expose typed operations to callers. |
Consensus replication replies have explicit initial-probe, ordered-stream and finished phases. Leading empty requests confirm a log boundary over management capacity; the first data request transfers the remaining input to one ordered stream. Empty commit updates then remain in that stream’s order. A failed or rejected initial probe ends its input, so no data stream can follow an unconfirmed boundary. These phases are volatile protocol state and are never persisted.
A pure conversion refusal is an ordinary typed outcome, even when it implements
std::error::Error for a parsing trait. Its owner states that contract at the exact error type
with reason-bearing nervix::error_boundary(outcome, ...) metadata. A contextual operation failure
returns a report; a missing value does not silently turn one into a success. The resolved compiler
gate and report-carrier obligations are described in Errors And Diagnostics.
Zero work outstanding, an initial sequence value, an empty payload, and a genuinely empty result
remain ordinary values. A documented scalar function may return zero for a particular input;
that result is part of the function contract. Arrow values under a null or failure validity mask
also do not encode absence in the numeric lane: the mask does. An Option or enum is needed when
the state is absent or different, not merely because a literal looks special.
The guest protocol keeps an absent branch key and an absent source token distinct from present values. Empty application-state bytes remain present inside the Rust SDK’s snapshot envelope. Reset acceptance, reset refusal, an unusable snapshot envelope and rejected application state have distinct typed ABI verdicts. Protocol decoding checks the complete size-prefixed header before identifier access and verifies offsets and counts before constructing owned values.
Absence And Distinct States
Consensus append input. None from the caller’s request stream means that input has ended;
the adapter returns an empty answer stream and opens no transport. Some carries the first typed
append request, retained exactly once while setup runs. A membership, admission or transport error
after obtaining that request remains a failure naming its peer; it is not inferred from ended
input. The caller’s retry and cancellation gate runs before this distinction is resolved.
Runtime checkpoint exchange. A state synchronization answer uses None only when the
requester already holds the owner’s current revision. Some carries a typed revision, byte length
and digest; zero-length checkpoint bytes remain a present checkpoint. The subsequent bulk request
names that exact revision. The receiver verifies the declared and received lengths and digest
before it constructs an installable state entry, so a partial stream is a transfer failure rather
than a different absence or an empty checkpoint. A handoff carries the same descriptions beside
their typed placements and prepares state only after each selected stream verifies.
Node trace export. A tracing guard either has no trace export or owns its provider and resolver
publication together. The publication carries Option<DnsResolver>: absence means startup has not
installed the node resolver, and presence carries that same resolver’s shared handle. A closed
publication before installation is a distinct connection failure, not a choice of another resolver.
The lazy connector waits through the publication primitive; its export connection timeout starts
only after installation, preserving early startup spans.
Branching. A validated branch declaration is either unbranched or carries its named branch
and resolved schema together. The schema supplies the branch fields and their sensitivity, so a
runtime plan cannot pair a present branch schema with an independently missing sensitivity set.
Unbranched execution has no branch key; a concrete key has a nonempty declared shape. An empty
field list or synthetic root identifier does not select a branch. A concrete key holds only finite
floats, because the VM turns a non-finite float result into a row error and a non-finite float has
no canonical text to key a branch by. A record’s fields are not held to that rule: a payload number
rounding past an F32 field’s range, an Avro float or a producer’s Arrow batch can bring a NaN or
an infinity in, and every JSON rendering of a record, such as a materialized report, a hash map
answer or a hash map key, writes one as the string NaN, Infinity or -Infinity. A key read from a stored checkpoint or a peer that holds one
fails with BranchKeyError::NonFiniteFloat, and one holding a datetime that is not RFC 3339 text
fails with BranchKeyError::RemoteFieldValue; both name the field. A concrete key is a key of a
branch schema when it holds exactly the schema’s fields, each with a value of exactly its declared
type; a missing or undeclared field and a value of another type are distinct
BranchKeyShapeError states naming the field, never a value converted to fit. The subscription
Row encoder holds each key to its relay’s branch with that one check, and a restore holds every
archived key it installs to the branching its restored entity declares before staging it; see
Backup And Restore. Materialized-state reads use the
incoming concrete branch, or the actual unbranched state, and reporting keeps the optional branch
identity until the display boundary. The structural ASCII graph projection is domain-free; a
serialized graph retains its real typed domain.
An emitter’s buffered Arrow carrier keeps its typed source relay and optional concrete branch key.
The relay’s declared branch name is fixed, so the pair identifies the exact source branch even if
another relay uses the same key fields and values. Payload assembly compares the pair before
combining carriers, and unbranched absence remains None throughout buffering and packing. Each
buffered row is one of three states: pending, carried by a batch payload or prepared request the
emitter retains, or resolved. A retained payload’s members are therefore neither packed or prepared
again nor mistaken for resolved rows, which a delivered flag could not express.
A client emitter’s consumer, delivery identity and ACK attempt are separate typed identities.
The optional concrete branch remains an internal BranchKey; the client sees only its opaque
fingerprint, never its key values. An attempt is pending or assigned to one consumer with one
reference and deadline. Retry, timeout or detach makes that reference stale before another
attempt becomes live. Confirmed ACK, retry and rejection results have bounded retention for
idempotence and expire independently of the delivery identity. An absent consumer is not encoded
as an empty consumer id, and an unbranched batch has no synthetic branch fingerprint.
The Rust client’s desired producer and consumer handles have distinct active, interrupted,
restoring, reopen-required and closed states. An attachment belongs to one session exchange; a
replacement receives a fresh request-derived identity. The producer’s unresolved sent batch has
an unknown outcome, rather than a missing outcome defaulted to not admitted. A consumer reports
the interruption before delivery from a replacement attachment. Its prior delivery reference is
expired, while a settlement sent before losing its answer is separately uncertain. The domain
START generation and endpoint contract fingerprint are required values on every successful
consumer open, so absence cannot be mistaken for a matching contract.
Expression scopes and errors. The VM frontend receives a scope policy that says whether a
bare field may be read, written, both, or neither. A generated or set-only route reports an
unavailable message or input scope during lowering, rather than inventing a namespace that
later fails lookup. Message errors carry a MessageErrorOperation variant through evaluation and
route handling. Its display text is derived from the variant; changing a label cannot select a
different operation. Structured errors identify the operation and affected fields without
exposing sensitive payload values.
Progress. A node’s applied schedule revision is optional before its first successful
application. Once applied, every u64 revision, including the maximum, is a real revision that
suppresses equal or older publications. The revision is recorded under the lock that serializes
schedule application, so the decision and its recorded state stay together. Consensus byte-based
snapshot retention likewise distinguishes no request from a request made while there was no
completed snapshot, and from one made at completed index zero. The trigger holds the optional
completed index that existed when the request was made; it suppresses duplicate requests for that
same completed snapshot without confusing its absence with index zero.
Remote acknowledgements and admission. A routing position is open with its generation and optional correlation, or permanently closed. A correlation owns either a delivery’s record rows or an admission response channel. Pending rows distinguish waiting for admission from admitted silence; ordered parked progress retains its required-wait guard. The wire number is an opaque position/generation/row encoding, validated behind this owner with the registrar process identity. No numeric value means missing, and an exhausted generation never wraps into a live identity. An authenticated peer is starting, awaiting membership with its bound epoch, live with that epoch, or ended. The admission choice’s private atomic byte exposes only pending, admitted and cancelled; one compare-and-exchange chooses an irreversible verdict. Borrowed admitted work and released transport permits have separate lifetimes. Cluster Interconnect owns their delivery guarantees and bounds.
Client ingestors. An ingestor’s input is either a transport, which carries its source and the
codec that decodes it, or a client source, which carries the schema its batches hold and its
producer policy. There is no optional codec beside an optional schema, so an ingestor cannot claim
both or neither. A producer’s admission is Open or Suspended; a batch’s outcome is one of four
variants, each carrying its own typed cause, so a completed batch has no cause and a refused one no
failure; and a producer’s end is one typed reason. A producer’s identity is the request identity
that opened it, and a forwarded producer’s link key is never reused within its process, so neither
has a reserved value meaning none. The client ingestor endpoint’s published counts are genuine
zero counts, never markers.
Sources and sinks. A source or a sink is addressed by the connector that serves it and the
entity it consumes, and its Model states both in its shape: the union holds one variant per
connector, that variant holds one variant per entity, and each entity has its own Model with only
the fields its statement writes. A Kafka topic source therefore cannot hold a Pub/Sub channel, and
an OpenTelemetry sink is a logs, a traces or a metrics sink, so only a metrics sink carries a
metric and no signal value sits beside mappings that might contradict it. The kind that names a
pair is read from the Model and never stored beside it. An ingestor alteration is nested the same
way: its head selects one connector and entity, and the operations of that entity set a source of
that entity’s own Model, so ALTER KAFKA TOPIC INGESTOR cannot carry a Redis channel and no kind
field sits beside the source it sets. Only a transport’s operations include the codec that decodes
its payloads, so a client producer alteration cannot hold one. A stored ingestor of another pair is
refused by comparing the two kinds. The names an external system owns, such as a Kafka topic or a NATS subject, are distinct
types from Nervix names: each is checked against its entity’s own rule once, when it is parsed or
decoded, and keeps its text exactly as written, case included, so a connector sends what the
statement says. What every source or sink of a pair
declares, whatever its statement says, is declared once on that entity’s Model in the vocabulary:
whether its messages carry headers, the typed metadata namespace they expose, whether it runs on
every node, what its body references, and for a sink whether it writes headers, needs a codec,
requires batching and which publishing modes it accepts. A client Model names the connector it
configures as a typed kind, so a source or a sink is matched with its client by comparing kinds
rather than labels. A session producer’s source is no transport, so the transport union holds no
Model of it; the kind union still names it, because capability decisions ask about every source.
Endpoint availability. Gossip publication carries an optional typed interconnect endpoint. Parsing a present advertised endpoint checks its host and port syntax. Membership admission requires that validated interconnect endpoint; an incomplete publication cannot become a reachable member through an empty address. Other service advertisements, including the console, may be absent independently. Absence says the service is unavailable; it does not invent a reason. Peer selection and transport handling follow the interconnect contract.
Startup voter evidence. The reconciliation task owns a process-local set of typed node names actually observed live, including observations made while following. An absent voter in that set means no live observation; Chitchat’s current dead verdict does not supply one. A first relayed heartbeat can therefore leave a voter unobserved even though gossip knows its identity. Expiring the ten-second startup grace ends the observation requirement without declaring that voter live, and a leadership change retains the original deadline. The set is discarded after expiry. See Whole-Cluster Restart Keeps Ownership.
Identity Across Scheduling And Recovery
Schema-bound runtime state is keyed by a computed SchemaFingerprint supplied by the committed
schedule. Branch aggregate and Kafka offset state that do not depend on a schema identify that
independence explicitly. No fingerprint byte pattern means “missing” or “independent”; a computed
fingerprint is accepted for its identity even if its bytes happen to be zero. Scheduling must
publish the required fingerprint before schema-bound state can be loaded or replicated. Missing
required identity is an error, not a default digest.
The runtime Kafka offset key remains schema-independent. A backup archive also records the ingestor’s scheduled fingerprint with those offsets, so restore applies them only to the same ingestor contract after publishing its target schedule.
Kafka replica catch-up describes revision state as Current or Advanced(revision), with typed
remote-operation failures for refusal. An advanced revision selects a bulk native checkpoint;
its header must name a revision newer than the replica’s request and no earlier than the described
revision. Length, digest and the current archived shape are verified before a replacement table
is published through the retained replica assignment token. A cancelled or invalid transfer
leaves the installed state intact. Every successful synchronization returns the held revision,
including a Current answer, after validating replica authority. It is acknowledged again so
lost progress reports recover without another transfer; a promoted or replaced assignment rejects
reports through a retained token.
The runtime checks a stored state’s schema identity against the current scheduled identity before accepting it. WASM guest state additionally uses its generation for the concrete branch. A state from another schema or generation cannot become current merely because it has a later revision. This identity check is separate from the snapshot and handoff durability rules in WASM State And Recovery, Consensus Storage And Replication, and Shutdown And Recovery. Those chapters define when state is saved, transferred, and recovered; this chapter defines how the current state is identified.
Nervix keeps one current stored and wire shape. Producers and consumers of a changed identity are updated together. A previously stored shape that cannot supply required identity fails to load clearly and must be recreated; it is not defaulted into the current state. Tests construct the current shape and assert its behavior.
Archive Preparation Admission
Archive preparation reads the first physical tar header into the backup crate’s ManifestHeader.
That type carries the regular manifest entry’s validated identity and bounded encoded length.
Restore uses its length to admit manifest decoding before allocating owned archive values. The
full archive reader uses the same boundary and then verifies the manifest and every section; see
Backup And Restore.
Archived Counts
Native usize and NonZeroUsize counts use the vocabulary’s CountAsU64 archive adapter. Every
count-bearing archived field selects it explicitly, so its stored width is 64 bits independently
of the archive’s pointer width. Zero remains valid for an ordinary count; a nonzero count uses the
archive’s validated NonZeroU64 representation. Encoding preserves every bit of a supported
native count. Decoding converts once with usize::try_from and returns ArchivedCountError if the
receiving target cannot represent the value. No narrowing cast, clamp, or default supplies a count.
This contract covers relay capacity, transaction positions and operation numbers, queue limits, application progress and outcomes, plan and report counts, topology counts, WASM inspection totals and omitted-entry counts, and histogram delayed-removal bucket indices. The owning stored namespaces and frame signatures identify the current count-bearing shape before decoding, and the interconnect’s wire fingerprint fences it between nodes. Unrecognized stored state fails clearly and must be recreated. Complete equality properties exercise the production representations at their range boundaries; see Property Testing And Fuzzing.
Atomic States On The Data Plane
An ACK root’s ownership handoff can be tracking a bounded number of active shares or be complete.
REQUIRED WAIT can leave a tracking root temporarily idle without completing it. Attachments,
wait releases, and final completion race, so the ACK owner stores the state in one atomic word and
changes it with compare-and-swap transitions. A reserved word value is private to that owner;
callers observe typed tracking, idle, and completion outcomes rather than doing arithmetic on the
encoding. Completion cannot be reactivated. This preserves the contentionless ACK path and the
root tracker’s accounting without adding a lock. The delivery and handoff boundaries themselves
are described in Data-Plane Concurrency and
Shutdown And Recovery. Execution Primitives owns which
backend supplies the atomic; selecting a modeled atomic does not validate the owner’s encoding or
prove its transitions without the corresponding production-owner check.
A source instance owns starting, ready and retired states in a private atomic byte. Its handle alone
interprets starting=0, ready=1 and retired=2. Compare-and-swap permits readiness changes only before
retirement; a retired source cannot become ready or change a replacement instance. This relaxed
scalar publishes no payload or other location. Buffered message-error workers separately own
Prepared, Running and Ended lifecycle states; a fallible binding prepares the bounded queue,
and successful running publication starts its worker once. An ended worker is never restarted.
The domain clock’s atomic maximum has a different meaning. Its initial i64::MIN is the
documented identity for a maximum computation, and zero elapsed time is the value of an elapsed
duration clamped at its physical anchor. Neither encodes an absent clock. The
Domain Clock chapter defines clock absence, installation states, authority
fencing, and overflow handling.
An entity-gate operation owns pending, held, released and failed outcomes. A held operation has closed its admission scope and requested its force flush. Ownership capture also requires a freeze owned by that exact coordination identity. The exact operation’s drain-status query publishes it only after observing no affected admitted work or flush obligations. The operation’s registry guard keeps its hold alive through that observation and publication. Request cancellation leaves the receiver-owned engagement and its original lease intact; retries await the same operation and renew no lease. The preparation deadline separately bounds engagement, drain and capture.
A domain schedule’s planning snapshot keeps its captured consensus cordons with two distinct identity sets: all live voters and eligible placement destinations. Termination of an already cordoned voter leaves both sets unchanged; losing or restarting that voter changes its live identity and invalidates the plan. Schedule and transaction publication validate the composed destination set and retain the independent consensus topology fence. The snapshot also retains the terminating live voters independently for destination diagnostics. After membership and liveness, relocation reports that process state before a committed cordon. Its terminating set is a captured diagnostic fact, while publication continues to fence the composed destination identities; termination of an already cordoned former owner still changes no eligible destination.
External Encodings And Conversion Failure
The stopping-node drain action carries a required remaining Duration budget. Its sender deducts
elapsed request time before each leader redirect; the receiving leader owns the typed budget for
the handoffs it performs. Preparation and activation deadlines are derived from that one value,
and exhaustion produces a failed drain outcome. The stopping-node answer distinguishes a failure
while its coordinator still leads from an interrupted drain whose coordinator no longer leads.
The latter routes the remaining moves through the next leader without resetting the budget or
repeating committed moves. Classification consumes the typed command disposition and consensus
leadership observation rather than matching diagnostic text. The interconnect verifies the archived
action
and fences its current representation with the wire fingerprint. Shutdown And Recovery
owns the lifecycle and deadline policy.
At an external boundary, a protocol may require a raw tag, signature, null lane, or optional scalar. The adapter owns that encoding and turns it into a typed internal state. In the other direction it encodes the typed state once. A format signature or syslog boundary marker can remain raw on the wire, while callers see the state the format describes. Arrow batches remain the data-plane payload throughout this conversion; codecs use typed builders and column values, and a single addressed message is a view into a batch.
A relay batch carries its rows’ ingestion watermarks the same way, in two Arrow buffers of
Unix-nanosecond timestamps beside the payload. A kernel reads them as i64 lanes; a row addressed
on its own, the interconnect wire, and a materialized-state snapshot receive typed timestamps,
converted once where the row leaves the batch.
A conversion failure never becomes another valid payload value. The MongoDB sink returns a typed per-record failure if a value cannot be represented in BSON, including an unsigned value above BSON’s signed range. It does not publish that record with a replacement BSON null or a changed conflict key. An actual null remains null, and other valid records in the batch can proceed under the connector’s record outcome contract. See Connector Crates And The Connector Contract for the host’s publish and acknowledgement boundaries.
VM integer count and position operands retain their signedness and full magnitude. A large unsigned input does not narrow to the largest signed integer; an unsupported result size reports a per-row error before allocating it. UUID-v7 construction reports an unsupported pre-epoch time as a row error rather than substituting the epoch. Exact schema types and Arrow validity still govern the rest of the expression. The public function results are documented in Expression Functions.
A submitted client batch is validated as a whole before any row is admitted: a stream that is not
exactly one uncompressed record batch of the ingestor’s canonical Arrow schema, with valid columns
and within the row and byte limits, is refused with its typed defect. No value is cast, widened,
coerced, or defaulted to make a batch fit, and no subset of a malformed batch is admitted.
The shared Arrow IPC boundary checks canonical message framing, declared bodies and column buffer
ranges before invoking Arrow’s reader. A malformed frame reports ArrowBodyError::Framing for a
relay or snapshot body and ClientBatchError::Malformed for a client batch; neither gains a partial
row from it.
Session replies carry typed command purpose and outcomes. An upload failure can carry an optional assigned nonzero resource version; before assignment, the version is absent. Diagnostic spans can be absent, while a present span beginning at offset zero is still present. The web console uses the typed outcome for domain-selection dispatch instead of matching reply message text. The FlatBuffers encoding preserves these optional fields and typed variants across the session edge; Client Session Protocol defines how a receiver keeps an absent optional value distinct from a present zero.
The shared C binding gives clock observations their own nx_clock_event_kind and installations
their own nx_clock_state. Only NX_CLOCK_PACED has a mapping for nx_clock_event_paced; tick and
end-reason accessors likewise require their corresponding event kinds. An interruption or a refused
restoration carries a domain but no invented generation or end reason. A mismatched accessor returns
NX_ERROR_TYPE without changing its outputs, so absence cannot look like a zero generation or
timestamp. The paced and tick accessors allow omitted output pointers for fields a host does not
need.
The clock of a followed domain, nx_domain_clock, always has a generation and a state, so those
accessors cannot fail. A domain the session does not follow reads as a NULL clock rather than a
stopped one, a clock without an accepted tick answers false from nx_domain_clock_tick rather
than a zero tick, and an unpaced clock reports no admission window rather than an unbounded one.
Only a paced clock has a mapping for nx_domain_clock_paced, and a stopped or uninstalled clock has
no logical time, so its projections return NX_ERROR_TYPE instead of a fabricated instant.
Completion replies likewise carry a SuggestionStatus variant for ready, missing, stale, or failed
context and an optional continuation. The server resolves typed semantic references from one
committed configuration read with the requesting session’s ordered transaction prefix applied;
a missing or mismatched transaction binding reports stale context instead of silently discarding
queued changes. Each candidate carries an explicit UTF-8 byte-range edit. The CLI and web console
apply the edit after checking the cursor boundary, and the web console converts browser UTF-16
selection offsets at its edge. A continuation is bound to the input, domain, revision, and
candidate set, so a changed context cannot silently reuse a page.
Structured-control lookups use a separate typed choice boundary. A request names its semantic
target and carries each dependency as a ChoiceValue; a placement choice therefore depends on a
domain-pace variant rather than on the text PACED. An internal-schema, branch, or relay choice
depends on a typed domain reference and returns a kind-qualified Model reference, and a relay-field
choice depends on the domain and relay references and returns a typed field reference. Results keep
the same typed union for enum variants and domain, resource, model, or field references, with
label, detail, and group held separately as presentation. The FlatBuffers discriminant selects
behavior. A missing typed dependency is MissingContext, and a page cursor binds the dependencies,
revision, candidate values, and presentation so changed form state is StaleContext rather than a
silently retargeted page. The browser keeps missing prerequisites, stale context, empty results,
loading, and failures as separate choice states. A missing prerequisite carries a hint; stale
context offers a fresh lookup; only lookup and transport failures are alerts.
Incomplete schema, branch, relay, and subscription form values stay in browser drafts. The
completed conversion creates the current schema, branch, or relay Model, with field order,
optionality, sensitivity, wire format and mode intact, or the current subscription client
statement. A relay draft’s branching starts unselected, a state distinct from unbranched execution,
so a completed relay is never unbranched by omission; its materialized state is absent until
LAST BY TIMESTAMP is chosen, because a relay without one is itself valid. A schema, branch, or
relay selection retained after its captured domain changes is explicitly invalid until reselected;
no empty name or fabricated Model stands for a missing selection.
Junction and reingestor drafts use the same boundary. A junction’s branch is unselected until the operator chooses unbranched execution or a named branch; a reingestor route separately chooses preserve, unbranched, or a named outgoing branch. A materialized dependency remains incomplete until its relay and absence policy are selected. Ordered input, dependency, assignment, and route drafts become the existing semantic Model only when every required choice and expression builds. Changing the captured domain or a dependent reference invalidates selected references while keeping the operator’s draft text visible for correction.
Diagnostic evidence states
The diagnostic owner distinguishes active cycles, potential order and overload. Potential records
carry Unreviewed or Reviewed(TriageProof), with a bounded explicit basis, reason and retained
regression reference. New source/lifetime context revokes a review. Live, Ended and Unrecorded
lock instances preserve different observations; absent construction or acquisition context stays
absent. Reused source sites never replace run-local instance identity.
ProcessRecord requires its compile-time/runtime diagnostic selection, and an artifact requires
whole-process or selected scope. A selected export cannot qualify a source process whose other
findings it omitted. The current version-2 wire conversion checks every required field, bound,
identity and review once; unsupported versions fail from their header without another shape or a
default. Data-Plane Concurrency owns the
current representation, qualification policy and coverage limits.
Validation And Failure Boundaries
Native connection options validate every timeout at setup: one millisecond through 24 hours. The backup command wait has its own required duration, distinct from an optional per-domain quiesce override and the ordinary request and retry bounds. Backup execution identity projects the typed scope, resource inclusion and capture options together with the selected domain; the local output destination remains a client download concern. Recovery may change that destination while the server continues to validate the original semantic request and execution reference.
The registry rejects unresolved or contradictory contracts before a graph becomes active. It resolves branch names, schemas, fields, and sensitivity as one branch state. Scheduling supplies the required runtime-state identity. Runtime owners enforce transitions that depend on concurrent execution or recovery. Connectors and the session edge validate external representations when they decode or publish them. A caller does not compensate for a failed lookup, absent required field, type mismatch, or conversion by supplying a default zero, empty value, or null.
Generated compiler findings carry a required execution-context field. An explicit null means that analysis has not established a source contract; it is an unknown effect, not a cold classification. The report decoder requires that field even though its value is optional. Complete compiler, configuration and worktree identities are also required before generated evidence is reusable.
A vocabulary type that validates its value when it is parsed or constructed validates it again when it is decoded, from JSON and from the archive alike. Every name type, a command execution reference, a resource upload identity, a JSON path, connection-pool bounds, and an emitter’s message and size limits decode through the rule that constructs them, and accept only a value that rule produces unchanged. Stored or received data therefore cannot hold a name with an upper-case letter, a path past its step limit, a minimum above its maximum, or a size that is not a whole number of its unit; such data fails to decode with a typed error and is never normalized into a valid value. A timestamp is its signed Unix nanoseconds, and every conversion into one goes through them, so two spellings of one instant, such as an offset and its UTC equivalent, or a leap second and the second after it, are one timestamp. Every duration Nervix reads from text, whether an NSPL literal, a domain-clock period or skew, a Model’s timeout, interval, retention or TTL, a window aggregate’s delay, or a node’s command-line option, is read by one guarded parser in the vocabulary. It refuses text whose spans could add up to the most seconds a duration holds with a typed error; the grammar library it wraps would panic on such text instead of failing, and Clippy rejects every other way of reaching that library’s parser.
Archive validation establishes the archived structure, then each vocabulary value is checked when it is read back. A refusal in a list can therefore follow values that were already constructed. The archive reader owns each completed value and frees it on refusal, together with the incomplete list or fixed array and any box or shared pointer allocation. No decoded fragment crosses the storage or transport boundary, and a stored record of an invalid current shape still requires recreation.
The current archive readback inventory for containers with a semantic value that may refuse its archived representation is:
| Boundary | Fallible contents inside archived containers |
|---|---|
| Interconnect | ApplicationCompletionPeersResponse.peers; RelayMetadata.acks; PrepareOwnershipHandoffStateRequest.checkpoints; the relays and affected_entities of EntityGateRequest and EntityDrainStatusRequest; the emitter_publishing lists of DomainDrainStatusEnvelope and EntityDrainStatusEnvelope; DescribeRelayRequest.bindings; ownership handoff checkpoint responses. These contain typed node, relay, emitter, model, or domain names. |
| Consensus replication and persistence | AppendEntriesRecord.entries, including membership configurations and nodes; durable batches of Raft entries and consensus state; the Vec and Box descendants of ConsensusCommand, domain schedules, command execution, transaction plans and reports, and stored models. They contain typed names, references, limits, and operation ranges. |
| Registry | Stored Model variants, including schema fields, nested ParseAsType boxes, processor routes and inputs, connector configuration, and recursive expression vectors and boxes. Their typed names and other checked vocabulary values may refuse after earlier members decode. |
| Runtime state identity | StoredHandoffPreparation.checkpoints and StoredForcedRecoveryPreparation.checkpoints contain placement envelopes with checked domain and model names. |
| Runtime window snapshot | WindowDelayedRemovalSection.removals contains CountAsU64 buckets. A stored count above usize::MAX is refused on a narrower host. |
There is no archived shared pointer field at these Nervix boundaries today; names share an Arc
only in memory, and archive as text. The shared pointer reader is covered by the same dependency
fix and by a refusal test. The deduplicator, Kafka offset, materialized identity, branch LRU and
other runtime state snapshot containers, together with backup sections and deadlock evidence, first
deserialize primitive or raw string wire values, then validate their meaning after the complete
wire value owns its allocations. Their malformed input tests still check that every archive decode
frees its allocations.
Constant integer division prepares a SignedDivisor or UnsignedDivisor at the kernel boundary.
Its unsigned magnitude is NonZeroU64, and its private reciprocal state distinguishes a power-of-two
shift from a multiply-high reciprocal. A zero input produces no prepared divisor; the numeric
kernel reports failed lanes with the existing failure mask. Datetime callers already hold validated
positive strides, so they can assert that preparation succeeds. These per-call values are execution
artifacts and are never persisted; VM Functions owns their
arithmetic and failure contracts.
One in-memory domain activation plan resolves each relay’s compiled schema, branch retention and materialized-state presence; each codec’s schema and wire definition; and each endpoint’s VHOST and signaling reference. A missing reference is a typed planning failure before installation. The same plan shape feeds running and passive builds. Passive builds retain the planned materialized relay identities and endpoint routes, while admission remains stopped. A server-side listener stays bound on every live node independently of graph placement or domain leadership.
Materialized origination is an exclusive task capability: moving it transfers the mutable branch selections, while reads and snapshot installation remain independently retainable. Read publications carry an immutable row with its Arrow schema and watermarks. Branch membership and an ended row publication are distinct states; absence in live state owns the answer even when storage retains an earlier checkpoint. Recreating a branch allocates a fresh publication. Installation validates assignment capability, branch generation, captured fence and revision before replacing any row; the current branch lifecycle and revision cannot move backwards.
Server endpoint configuration and source availability are distinct states. The immutable route table contains configured definitions; a bound source lifetime contains an optional prepared intake. Source ending publishes absence through that lifetime before removing its route binding. A retained request or WebSocket cannot interpret absence as a replacement source with the same node identity. A lease loaded before ending may complete; later admission sees absence. Domain replacement and teardown end the domain’s lifetimes and replace all of its route definitions together, preserving other domains. Unbind names the exact binding allocation so a preceding source’s close cannot end its replacement.
A second in-memory decision, the domain’s entrypoint plans, resolves every ingestor’s source, client, codec and routes and every reingestor’s inputs, node filter and routes. It records how a route’s records get their branch key as one of three states: unbranched, keeping the incoming key, or constructing a new key with a lowered program. The two branched states carry the branch and the retention of the relay the route writes, so the branch a route declares and the branch its entrypoint retains are one value, and a route whose declaration disagrees with its relay fails planning. An ingestor’s transport class is read from the one source it declares rather than stored beside it. A Kafka ingestor’s offsets are either a consumer group or domain offsets together with their placement. The planner lowers every filter, route and branch construction before a node binds them, so a bound route always carries its compiled program and a running task has no missing-program or undeclared-branch state to check.
The owner reports a semantic typed error; contextual propagation uses error-stack. A
per-record conversion error is reported at the record boundary without formatting a fresh
message on every hot-path operation. Diagnostics contain the relevant identity, operation, and
field names, while sensitive payload values stay out of errors and logs. A truly optional value
continues as Option until its consumer decides whether absence is valid. A label or rendered
string is only a presentation of the state and never an input to execution.
Replicated command admission distinguishes a reference that has expired from one bound to a different owner, domain, transaction position, or content in its typed conflict result. The session carries that distinction into the public command disposition; rendering its message does not choose the disposition.
Error-route branch validation carries the node, source route, error relay, and both branch
declarations as typed data. Direct emitter VALUES validation identifies a sensitive external
target by name and requires explicit leakage; neither error needs the source payload value.
Restore Installation Authority
RestoreLifecycle is a closed Stopped/Resume policy in the semantic restore Model. Text omitting
RESUME selects Stopped at the language boundary. The restore plan carries distinct initial
stopped state and activation state. Both require the archived start generation and latest start
point; paced activation also requires the archived mapping. Required report and DomainInfo
generation fields are validated at their wire boundary, including the valid generation zero.
Materialized archive descriptors and row identities are archive-owned types. Their raw schema
fingerprint binds once to the archived start generation for native storage, independently of the
installation authority; payload values stay exact-schema Arrow columns. Typed branch identities,
watermark ordering, counts and framing are validated before those values reach the runtime, and
each record identity’s branch must be a key of the restored relay’s branching.
A captured archive section carries its publication key, content kind and staged artifact as named fields. Guest capture retains a selected checkpoint handle until its scheduled generation is checked; its admitted storage job records the size for later admission, and opening that handle returns the checkpoint revision with the reader for those exact bytes.
Deduplicator and window capture likewise distinguish a live publication from an unopened stored checkpoint in the captured-state union. A stored selection carries its immutable view and inspected size, and opening it returns its actual revision with an admitted immutable artifact. Only a validated keyspace or window reader exposes counts, boundaries or columns. The control plane selects the scheduled owner and schema before opening; it receives typed conversion results rather than native bytes. A window’s live and stored readers retain their own required lifetime and metadata.
A materialized capture carries either current rows or stored checkpoints as distinct variants. A captured materialized checkpoint is a selected immutable source. Opening it produces a reader whose complete header is required; there is no optional or defaulted generation metadata. Each bounded group retains its scalar identities and original Arrow bytes, and completion checks the whole declared group and row count. Running and paused domains capture current rows; stopped domains capture stored checkpoints. An empty generation still has its descriptor and zero group/row counts. RESTORE resets the stored ownership fence to zero explicitly.
A restored domain has a replicated Pending installation from creation and an Installing
authority once its state generation is admitted. Neither state permits START. Completion of the
exact current generation removes the installation; terminal command failure does not. Authority
carries leader identity and term, execution reference, mutation lease revision and generation.
Checkpoint revision remains guest history and cannot grant installation authority. Validation at
the applied-state boundary holds its read guard across the storage mutation and handle clearing.
The state store validates authority-bound receipts, checkpoint headers and ordered chunks before
synchronizing the complete namespace. One small active-generation record then selects that set
atomically and is synchronized before completion. It retains both authority and inventory for
exact retry and stale-attempt rejection. Cancellation after selection preserves the closed start
gate until the same authority completes durability and cleanup.
Runtime storage has distinct initial and restored namespaces; restore installation generation is separate from guest-state generation and checkpoint revision. A current checkpoint is either an inline checkpoint or a segmented checkpoint with required revision, length and digest. A read uses one database view for namespace selection and payload data. A queued checkpoint job retains its selected namespace and validates that it remains current before writing. The storage format marker is required for nonempty checkpoint storage; corruption or absence fails explicitly and requires recreation rather than inventing a namespace for the stored keys.
A branch-aggregate metric checkpoint revision identifies one exact encoded payload. Capture retains its elapsed fields once, so another descriptor, stream or persistence request cannot choose different bytes for that revision. Initial, replica and persisted recovery preserve the received bytes with their revision. A dirty metric update selects the next revision; a snapshot mutex serializes capture and installation at that placement’s cold boundary.
Active chunk maintenance validates the placement and fixed revision/offset coordinates before forming a chunk-set cursor. A set remains referenced only when the same view’s selected header is segmented and names that exact revision. A missing or inline header is an ordinary unreferenced outcome; malformed coordinates or a malformed bounded header remain typed storage failures. Segmented headers have only their fixed archived root, so a size check bounds the caller’s copy before decoding. The required current storage shape makes larger valid values inline; Fjall may still read/cache those values internally. No second persisted liveness field or default is added.
Restore reclamation receives a borrowed RestoreStateRetention from one locked applied revision.
Absence of an applied log means catch-up is unknown and retains all generations. A generation
ahead of that log is also retained. Once applied, only the exact generation of an applying restore
requires unpublished storage; terminal, expired or absent execution records do not. Publication
identity remains a separate node-store protection, and reclamation never converts an incomplete
installation into a completed one. Key/value usage is reconstructed from current namespace keys,
including partial chunks without a receipt, rather than a defaulted persisted counter.
Namespace cursors use the vocabulary’s canonical decode entry point for the required domain
name. It requires lowercase stored text and retains the typed NameError beneath the storage
format failure.
Qualification Evidence
The integration harness’s startup owner classifies a failed attempt once into a terminal outcome or a transient retry carrying its listener family. A listener bind failure names the addresses to replace; a readiness timeout names all listeners. The launcher applies that decision after cleanup within the same absolute startup budget. See Integration Test Lifecycle for its occupied-listener regression and endpoint observation.
The typed states qualification ledger maps each confirmed finding to its owning change, current source path, unit test, public Cucumber scenario, and local validation command. It records the checks on the landed implementation rather than claiming a new behavior change in this documentation chapter.
The focused public selection passed 217 scenarios and 2,784 steps across branch and scope
rules, materialized state, error routes, MongoDB emission, VM functions, clock boundaries,
membership and recovery, session protocol, and the browser console. The applicable runtime
scenarios include one-node and three-node examples; ownership and recovery scenarios use the
production sticky scheduler. Owner tests passed 2,399 tests across Models, VM, consensus,
interconnect, MongoDB, Client Wire, client core, browser, and server. just test-shuttle passed
70 distinct checks under two schedules for ACK and state-recovery concurrency. The same
qualification passed just validate and just ratchet.
These results establish the listed semantic paths and their tested failure boundaries. The ledger distinguishes source-audited findings from demonstrated runtime behavior and records which values remain legitimate zeros, empty content, Arrow masked lanes, and private encodings.
Recurring Task Observations
The paced simulation applications represent endpoint-open intent as CurrentGeneration or
FollowingStart. Only the latter permits a bounded retry of DomainStopped after observing a
later paced generation; initial and contract-change opens retain the stopped-domain refusal.
The opened producer’s generation and its paced clock still own permission to plan readings.
Healthy connector status is absence of a failure; a failure contains its safe error and an optional retry in one publication. Domain-clock publication carries lifecycle pause, generation and start point beside the installation. Entity assignment absence invalidates a retained checkpoint reader; a present assignment contains its state identity, optional primary, executors and replicas together. An absent primary represents an assignment without one primary owner. Replication routes retain that same slot and an optional state intake; ending an intake permanently fences its exact route before the routing publication withdraws it. Assignment absence and ended intake are distinct states: identity removal invalidates every reader, while state retirement may leave the entity’s assignment available for storage-backed synchronization. Force-flush readiness is privately encoded as idle=0, available=1 and closed=2. Only its owning type writes or decodes that byte; the coordinator remains the authority for generation and claim state. The hint has no cross-location data-publication contract.
Connector Crates And The Connector Contract
An external integration meets Nervix at a data-plane boundary. The ingestor and emitter manuals define its NSPL surface and delivery options. This chapter defines who owns the work after validation: the shared connector contract, each integration’s transport, and the host that drives it. Data Plane describes what happens to the resulting Arrow batches inside the graph.
Execution Plans describes how one committed revision supplies those typed source and sink plans to the host. HTTP Emitter Architecture follows this boundary for one outbound request, from Arrow preparation to response and recovery.
Ownership and dependency direction
| Layer | Responsibility |
|---|---|
| Vocabulary and registry | Define and validate source capabilities, schemas, delivery modes, header availability, branches, and references before activation. The vocabulary nests every source and sink Model by connector and entity, and each pair declares its capabilities once there. The registry names no connector crate. |
| Decision and composition | Convert a validated Model into a typed source or sink start plan once. The registry decides every ingestor’s source plan together with its codec and lowered routes, validating the source name, kind, client and route identities as one decision. The server is the composition root and the only crate that names every integration. It resolves client resource mounts before opening a connector. |
nervix-connector | Define source and sink operations, typed boundary values, and opaque host services. It knows neither a driver nor graph execution state. |
nervix-connector-* | Own one integration’s driver, connection and configuration interpretation, transport headers, protocol acknowledgements, and per-record results. A crate implements the source contract, the sink contract, or both. |
| Host data plane | Own tasks, intake and Arrow decoding, branch routing, ACK trees, quiesce, buffering, retry and flush scheduling, metrics, events, and drain. It executes typed plans; it does not parse NSPL or read a Model during data-plane execution. |
The contract may name shared vocabulary types, Arrow, the async and error facilities needed by its boundary, the node’s bounded executor, and configuration helpers for TLS, HTTP clients, service URLs, resource mounts, and physical time. It must not name the registry, runtime, relays, branches, schedules, or a driver. An integration crate may depend on the contract, vocabulary, Arrow, and its own driver stack. It must not depend on the server, another integration crate, or runtime collectors. Driver libraries belong in that integration’s manifest, with test-harness dependencies kept separately. A connector receives resolved configuration and a typed plan, not graph routing authority. Two sources have no external driver and live in the server. The node’s own HTTP/HTTPS endpoint uses the same source lifecycle and host intake. A client source admits the batches applications submit through their sessions; it has no connector crate, because the session protocol is its transport, and its host is the client ingestor endpoint described under Integration-specific boundaries.
The contract and integration engines declare nervix::input(plans, reason = "...") alongside their
source-owned nervix::layer(engine, ...) contracts. Resolved configuration Model types, constructors
and matches are rejected in their interfaces and bodies even though other infrastructure engines
may own persisted Models. Module contracts travel with source moves. The compiler and independent
language import/dependency checks run through just ratchet; these classifications introduce no
runtime policy or connector lifecycle change.
The contract and every integration crate take their execution-sensitive primitives from
nervix-primitives: synchronization, tasks, the timers and monotonic instants their deadlines and
backoff are measured with, and every socket a connector opens itself. A modeled build therefore
selects them for the whole graph, as Execution Primitives
describes. The sockets and timers a driver library creates inside itself stay the driver’s and are
outside that selection. A connector’s own code never resolves a name through the operating system:
it resolves through the node’s resolver, as the sections below describe, and a driver that resolves
inside itself is one of the residual paths Residual
Resolution lists.
Connector hygiene standard
The following standard applies to each connector crate, its declarations, and the server paths that compose and report it. It also applies when another feature adds or changes one of those paths.
- One source for names. The vocabulary’s
ConnectorKinddisplay supplies connector names; source and sink entity names come from their vocabulary declarations. Configuration errors, transport and HTTP-client labels, metrics, logs, dataflow identifiers andDESCRIBEoutput derive their names from those sources. A required namespace or prefix is derived once and reused, rather than introducing another spelling or case table. Protocol field names and external configuration passed through to a driver retain their external contract. - Typed causes until reporting. A connector owns semantic error enums using
thiserrorand propagates context witherror-stack. Preserve a driver’s typed error beneath the connector’s operation context instead of replacing it with itsDisplaytext. A cause that Nervix can classify is a typed variant, not a free-textreasonorResult<_, String>. Text received as part of an external protocol remains external data; it does not replace an available typed cause. Render a report only at the boundary that presents it, retaining the cause for callers that must decide whether to retry, reject a record or fail a start. Sensitive payloads and credentials stay out of rendered diagnostics. - Annotations describe the actual outcome. A
nervix::error_boundary(outcome|library, ...)contract belongs on the exact error type or return contract it classifies, with a reason describing that path. An annotation may call a result a record rejection only when the implementation actually returns that record’s rejection. A failed connection, refused start or failed request is not made record-specific by its annotation. Errors And Diagnostics owns these classifications and reporting boundaries. - Unit tests exercise the owning paths. Cover each implemented publish path, window or batch algorithm, header and metadata mapping, and acknowledgement operation through its production owner. Include successful delivery and the supported rejection, transient and unanswered outcomes, partial confirmations, ordering and bounds where the path has those states. Exercise source polling, reads and close when the connector owns them. A path the connector does not implement needs no fictional test. Unit tests complement the public Cucumber scenarios and concurrency checks required by the behavior; they do not replace them.
- Properties cover encodings and measures. Each connector-owned encoding or encoded-size
measure has a bounded Bolero property over valid current values, registered in
tests/bolero-targets.tomlfor ordinary and fuzz execution with its source-adjacent corpus. Lossless conversions compare the complete original value; size properties compare the measure with the exact production encoding, including headers and framing that count toward the limit, and exercise the limit boundaries. Lossy or one-way mappings assert their declared contract. A crate using a shared encoding or measure exercises that shared owner rather than copying its implementation. Property Testing And Fuzzing owns registration, budgets, execution and retained failure evidence.
A connector change keeps this chapter and the relevant ingestor or emitter manual current, runs
just validate and the focused checks for its changed paths, and leaves the architecture ratchet
at or below its baseline. Documentation changes also run just book. Execution-mode evidence
follows the changed protocol or lifecycle, as Data-Plane
Concurrency describes; changing labels or documentation alone does
not introduce a synchronization claim.
Before closing a connector epic, review changes landed from parallel work for additional name sources, free-text causes, inaccurate boundary annotations, and publish, mapping, batch or acknowledgement paths missing their unit or property coverage. Record the review, evidence and any compliance tasks on the owning ClickUp epic and tasks. Implementation reports and temporary audit findings are not repository documentation.
DNS for HTTP and Iceberg
The node loads and validates one nervix-dns resolver at startup, and every integration below
connects through it. Name Resolution owns that resolver: its
configuration, answer order, cache and TTL bounds, lookup budgets and failures, the dependency
features that select it, and the drivers that still resolve by themselves. These sections own how
each integration connects through it.
Composition passes the resolver’s handle into the HTTP polling and Prometheus source plans and into
the HTTP request, Sentry, OTEL HTTP, and Iceberg sink plans. The shared HttpClientConfig installs
it on the Reqwest 0.13 clients of HTTP polling, Prometheus, Sentry and OTEL HTTP. The HTTP request
sink resolves each request’s target host itself, as its request transport
describes. Iceberg REST uses a separate Reqwest 0.12 client with that same resolver. Iceberg object
storage uses OpenDAL 0.57 with a Reqwest 0.13 client that the connector builds with the node
resolver and installs through HttpClientLayer; the layer also supplies HTTP calls made by
OpenDAL’s credential providers through its accessor info. That client takes no timeout_ms, CA file
or client identity from the client’s configuration; OpenDAL’s own timeout layer bounds each storage
operation. The Reqwest 0.12 client receives an explicit AWS-LC rustls configuration with bundled
trust roots, including in an isolated Iceberg connector build. The three Iceberg backends keep their
S3, GCS, and Azure property mappings, URL-derived bucket or container, timeout and retry layers, and
commit boundary. The standalone OpenDAL S3 detect_region helper constructs its own client, but
Nervix does not call it; S3 operator construction requires its configured region or the driver’s
environment policy.
No client Nervix builds ever initializes Reqwest’s default Hickory resolver: each replaces it with
the node resolver before its first request. That default could choose a public name server if
reading system DNS configuration failed. A bad node resolver configuration therefore fails node
startup, while a lookup failure reaches the existing source or sink failure path. Request URLs, HTTP
authority, proxy behavior, TLS verification, custom trust and identity, and connection pools remain
with the HTTP client. A client’s timeout_ms includes DNS resolution, connection setup, TLS and
response handling; the resolver’s own 30 second ceiling only bounds clients without a shorter
request timeout, which includes both Iceberg clients. DNS failures use the host’s existing retry
policy and do not create application-level probes or acknowledgements.
DNS for RabbitMQ
Composition also passes the node resolver into every RabbitMQ source plan and sink configuration.
The connector reads the client’s addr as an AMQP URI and takes its host from the URL grammar,
because Lapin’s own grammar substitutes localhost for an IPv6 literal host. Every connection resolves that host again through the node resolver: a sink’s start, its
reopening after a failed publish, and each source instance’s resume. A literal IPv4 or IPv6 address
is its own answer. The connector tries the answers in resolution order, each within an equal share
of what remains of a 30 second budget, and for amqps completes the TLS handshake within what is
left. The handshake verifies the broker certificate against the host addr names, whichever of its
addresses was dialled, trusting the platform roots and the client’s tls_ca_file. The connector
then hands the established transport to Lapin through Connection::connector. Lapin’s own
reconnection stays off, so it asks that hook for a transport exactly once and runs the AMQP
handshake over it. The AMQP handshake still has no deadline of its own, and the URI’s
connection_timeout query parameter has no effect.
Lapin’s hickory-dns feature is deliberately not selected. It resolves through one process-wide
async-rs resolver built on first use from the host’s /etc/resolv.conf, which ignores the node’s
resolver configuration, hosts snapshot, lookup budget and concurrency bound. That resolver also
keeps name-server connections whose tasks ran on the Tokio runtime of its first lookup, so a later
runtime inherits connections of one that may have stopped. just validate-dns-dependencies keeps
the feature off in the isolated RabbitMQ connector and in the server, and checks that Lapin’s TLS
stays on AWS-LC.
A failed connection is a RabbitMqConnectError. A lookup failure keeps its DnsLookupFailure, and
unreachable addresses, a failed or overdue TLS handshake, and a failed AMQP handshake are their own
variants, as is a Lapin runtime that could not be created. A source reports the failure as a resume
failure and retries on its declared RETRY POLICY. A sink reports an invalid address or CA file as
a configuration failure and every other connection failure as an initialization failure, and the
emitter host reopens it on its backoff. Neither path acknowledges undelivered data: a source that
cannot connect holds no delivery, and a sink that cannot connect confirms nothing, so the input
stays unacknowledged until a later connection delivers it. A connection that fails before its AMQP
handshake has started no Lapin thread, and a failed handshake ends that thread and closes the
socket. An established connection is not closed because its host’s answer changed or expired; the
next connection uses the new answer.
DNS for Redis
Composition gives the node resolver to the shared Redis command pool and to each Pub/Sub source
plan. Redis 1.7.1 exposes AsyncConnectionConfig::set_dns_resolver for command connections. The
connector’s bb8 manager installs that hook whenever it opens a physical pooled connection;
pool bounds, health checks and lease ownership remain with the existing pool and host. A pooled
socket may remain open past an answer’s TTL. A replacement socket resolves the host again.
The driver’s Pub/Sub convenience connection does not accept that DNS hook. A source instead
resolves the configured redis:// or rediss:// hostname through the node resolver on every
initial subscribe and resume, and opens a dedicated TCP or TLS stream. It tries the returned
addresses in order inside a 30-second budget shared by DNS, TCP, TLS, Redis setup, and subscribe.
It hands the stream and the original client’s settings to Redis’s PubSub::new, so the driver
still performs its authentication, database selection and protocol setup before SUBSCRIBE. A Unix socket remains
a direct Unix connection with no DNS query. The dedicated subscription never occupies a pooled
command connection.
For rediss://, the configured hostname remains the TLS server name after an address is chosen.
The source’s TLS configuration keeps the same optional client certificate and key as the command
client. Without a CA file both paths use native roots; a configured tls_ca_file replaces those
roots in both paths. The shared TLS configuration helper exposes that replacement policy
explicitly because other connectors add a configured CA to their default roots.
The Redis hook gives a lookup at most 30 seconds, inside the 30-second connection timeout the pool sets on the driver for the whole connection. Pub/Sub connection attempts have one 30-second budget. Missing names, empty answers, silence and transport failures are connection outcomes. They do not reject a record or confirm a publish; the host resumes the source and retries failed sink work according to its existing policy. A Pub/Sub connection broken by the broker or network is dropped and reopened with a new lookup, while an established connection is not interrupted solely because its answer expires.
DNS for MQTT
Composition passes the node resolver into every MQTT source plan and sink configuration. Both
connect through rumqttc’s event loop, which asks a socket connector for the TCP stream of every
connection it opens: the first, and each one after a connection is lost. Each client installs its
own connector with MqttOptions::set_socket_connector in place of the driver’s default, which
resolves with Tokio’s lookup_host. On every call the installed connector resolves the host the
driver names, the host of the client’s addr, through the node resolver, so a sink’s event loop
resolves again on each reconnection and a source instance on each resume; a literal IPv4 or IPv6
address is its own answer. It dials the answers in resolution order with the driver’s own
per-address dialer, connect_socket_addr, which applies the driver’s NetworkOptions to each
socket. Each attempt gets an equal share of what remains of the driver’s connect timeout, five
seconds, which already bounds DNS, the address attempts, TLS and the MQTT handshake of one
connection together, so resolution adds no deadline or retry of its own.
The driver layers the rest on the returned stream as before. For mqtts it completes TLS with
the client’s CA and optional client identity and verifies the broker certificate against the host
addr names, whichever of its addresses was dialled. It then runs the MQTT handshake with the
client’s identity and session. Nervix’s build selects neither the driver’s proxy nor its WebSocket
transport, so no other connection path exists.
A failed lookup reaches the driver as the resolver’s DnsLookupError, which the driver keeps as the
cause of its connection error. The connector finds it there and reports MqttConnectionError::Resolve
with the host and the lookup failure; every other failure keeps the driver’s own description. A
source reports the failure as a resume failure and retries on its declared RETRY POLICY, or on
the host’s reconnect cadence in MODE NO ACK. A sink’s event loop reports it as the emitter’s
transient error and reconnects on the emitter’s retry policy. Neither acknowledges undelivered
data: a source that cannot connect receives nothing, and a QoS 1 or 2 sink confirms a record only
once the broker acknowledges it, so the input stays unacknowledged while the broker name does not
resolve. An established connection is not closed because its host’s answer changed or expired;
the next connection uses the new answer.
DNS for ClickHouse and SQS
Composition passes the node resolver into every ClickHouse sink configuration and into every SQS
source plan and sink configuration. Both connectors keep their driver’s HTTP client and hand the
resolver to it at the driver’s own DNS hook, so every new connection resolves its host again while
the request URL, its authority, the TLS server name and the signing inputs remain what the client
configured. nervix-dns implements both hooks on the node resolver: Hyper’s resolver service for
hyper-util’s HttpConnector, and Smithy’s ResolveDns.
A ClickHouse client builds HttpConnector over the node resolver in both of its forms. Without TLS
entries it is the plain HTTP client the driver would build for itself in this build, which has no
driver TLS feature: an https address fails its request, and the connector keeps the driver’s
60-second TCP keepalive and 2-second pool idle timeout. With tls_ca_file or client identity
entries the same connector is wrapped in the AWS-LC rustls configuration those entries build,
trusting the bundled WebPKI roots, the platform’s native roots and the configured CA, and serving
http or https addresses. Hyper dials a literal IPv4 or IPv6 host without asking the resolver,
dials a name’s answers under its dual-stack policy, described in Dialling The
Answers, and applies the URL’s port or its scheme’s
default. The connector’s timeout_ms bounds the send and the response of an insert, and the
connection and its lookup run inside that response wait.
An SQS client installs a Smithy HTTP client whose connector resolves through the node resolver with
build_with_resolver. Without tls_ca_file the client is the SDK’s default HTTPS client with that
resolver: AWS-LC, the platform’s native roots, and a proxy taken from HTTP_PROXY, HTTPS_PROXY,
ALL_PROXY and NO_PROXY or their lowercase forms, whose host the node resolver resolves while the
proxy resolves the service. With tls_ca_file it trusts that CA alone and uses no proxy, as before.
The SDK signs each request with SigV4 for the configured endpoint before the connector resolves its
host, so the signature, the Host header and the certificate check all name that host, whichever
address accepted the connection. The SDK’s default 3.1-second connect timeout, and the operation and
attempt timeouts a sink’s timeout_ms sets, include the lookup. The client names static credentials
and its region, so aws-config never consults the default credential or region chains that could
reach IMDS, ECS, STS or SSO over HTTP. It still builds its SSO token chain, which SQS never asks for
a token because it signs with SigV4; the loader receives the same HTTP client, so that chain would
use the node resolver too.
Both hooks give a lookup at most 30 seconds, like the Reqwest hooks; the client’s own deadline
cancels it sooner, and a lookup cut short that way fails as that deadline’s timeout rather than as a
lookup failure. A lookup failure reaches the connector as a cause of the driver’s connection error,
where the connector finds the typed DnsLookupError and keeps it as the context beneath its
existing failure: a ClickHouse publish failure, an SQS sink start or publish failure, and an SQS
source resume, read or acknowledgement failure. The report’s message names the host and the lookup
failure. An SQS source plan builds its client from configuration and TLS material alone; each
instance looks its queue up when it first resumes and keeps the queue’s URL. A failure there, a
lookup failure included, is a resume failure, which the host shows as the source’s transient error
and retries on the source’s retry policy. A queue the service does not have is the typed
SqsSourceError::MissingQueue, which names it, whether the service answers with AWS’s
query-compatible code AWS.SimpleQueueService.NonExistentQueue or, as ElasticMQ does, only with the
JSON protocol’s error type QueueDoesNotExist. Any other failure to reach the service, including a
certificate that does not name the configured host, is described by every cause of the driver’s
connection error, which describes the connection and never a record; a service’s own response keeps
its existing description. None of these failures rejects a record or acknowledges input. The host
retries each on its declared backoff. An SQS sink keeps the SDK’s own retries disabled, while an SQS
source keeps the SDK’s standard retry mode, which makes up to three attempts at a request whose
connection failed, lookup failures included, before the source reports the failure. Pooled
connections stay open when their host’s answer changes or expires; the next connection resolves
again.
DNS for OTEL gRPC
The OTEL sink receives the node’s resolver in its typed sink configuration. Its gRPC transport
builds a Tonic 0.14 lazy channel with a custom Hyper HttpConnector that resolves through that
resolver. Constructing the sink or channel asks no DNS question and opens no connection. When an
export first needs a connection, Hyper resolves the configured endpoint’s host, dials its answers
under its dual-stack policy, and applies the endpoint port. Tonic keeps the original URI for HTTP/2
authority and TLS server-name verification, and retains its configured roots, optional client
identity, metadata, compression, and request timeout. A client that sets timeout_ms uses it as
both the request timeout and the connection timeout, which encloses DNS, address attempts, and TLS.
Without timeout_ms the channel has no connection deadline, and only the DNS hook’s 30-second
ceiling bounds the lookup.
An unresolved name or failed connection remains an infrastructure export failure under the sink host’s existing retry and ACK policy; the export failure reports the gRPC outcome, not the lookup failure beneath it. A successful pooled channel may outlive an answer’s TTL; a new connection resolves again through the node resolver. OTEL HTTP protobuf continues to use the shared Reqwest path described above.
The native prepared-request check uses a real loopback DNS authority and gRPC receiver. It observes an initial negative answer without delivering or rejecting the prepared log record, publishes the receiver’s address, and requires another DNS question and exactly one accepted request with the original prepared bytes inside ten seconds. The public one- and three-node outline also checks log and trace exports after clearing an unavailable sink and a negative DNS answer. These are bounded recovery observations, not a simulation of Tonic or a guarantee about every external collector’s availability.
sequenceDiagram
participant NSPL as NSPL and Models
participant Registry as Registry and decisions
participant Host as Server host
participant Driver as Connector crate
participant Graph as Runtime graph
NSPL->>Registry: Semantic Model
Registry->>Registry: Validate capabilities and references
Registry->>Host: Validated typed start plan
Host->>Driver: Open with resolved configuration
Driver-->>Host: Source messages or sink outcomes
Host->>Graph: Decode, route, and track ACKs
Blocking work and the node’s executor
A connector runs on the host’s asynchronous tasks and never blocks one. Synchronous work it does
itself, such as writing or reading a local file, goes through the node’s bounded executor, which the
sink host hands a connector as SinkHost::executor. The connector chooses the storage or CPU class
of its work and the memory it charges; the executor admits that work beside the rest of the node’s,
and a refusal is the connector’s own typed failure. The Iceberg sink writes and reads its staged
Arrow IPC files on the filesystem storage workers, so a node that cannot take the job now fails the
publish or the commit, which the host retries as it retries any other.
A driver call that parks its thread while it waits on the network computes nothing, and admitting it would hold a worker idle for the whole wait. librdkafka’s producer flush and metadata fetch are such calls, so the Kafka connector runs them on the runtime’s blocking pool as declared owners outside the executor, bounded by the host’s flush deadline and a per-request metadata timeout. Blocking work outside the executor lists every declared owner, and the boundary check rejects any other connector code that names the pool.
The source and sink traits carry source-local compiler execution contracts. Polling, message/header access, acknowledgements, publish, retry, flush and commit callbacks are recurring. Source open and close are explicit lifetime boundaries; Kafka domain-offset initialization is a resume installation boundary. Implementations inherit the trait method’s frequency. External driver, conversion and callback dispatch is documented at its owning callable, and local callback bodies remain checked. These annotations describe this chapter’s existing lifecycle; they supply no runtime admission or ownership proof. Data-Plane Concurrency owns their syntax, inheritance, diagnostics and operation-specific repair expectations.
Source boundary
Endpoint and Syslog sources are assigned to all live voters when a schedule is prepared, including
cordoned nodes excluded from placed work. Restore, rescheduling, and voter removal use that same
decision, so a restored coordinator serves intake after RESUME even when its relays run elsewhere.
Each domain revision installs its ingestor plans with its schedule. Building a domain, swapping or relocating an ingestor, starting the ingestors a runtime revision leaves missing, and placing Kafka domain offsets all read those same plans; none of them reads the ingestor’s Model. Starting an ingestor binds its codec, node filter, routes and branched entrypoints against the installed domain surfaces and parses its declared acknowledgement before any connector instance opens, so a start that fails leaves nothing running.
A source plan combines connector-specific settings, validated capabilities, and the host’s ACK
policy. Capabilities state whether header reads are available, which typed metadata scope exists,
whether quiesce is supported, the nonzero instance count, the supported acknowledgement mode, and
the position class the source’s entity declares.
The source owns open, suspend, resume, close, the next broker batch or scheduled poll, and
the meaning of acknowledging or rejecting its own position. A broker message lends its payload,
headers, typed metadata, and transport position to the host. Positions remain connector-owned;
the host never interprets one as a graph identity. Each source entity also declares its
acknowledgement confirmation, how far its transport has confirmed positions once acknowledging them
returns; see Confirmed acknowledgements.
A client source’s plan is not a connector plan: it carries the input schema, its compiled form, the
producer policy of window, ACK timeout, and retry backoff, and the endpoint contract digest producers
attach to. It declares no header or metadata scope and supports only SUSPEND.
The metadata boundary has distinct header, Kafka, and Syslog scopes. Kafka carries topic,
partition, offset, and headers; Syslog carries the peer address. The scopes, and the fields of
each with their exact types, are declared once in the vocabulary, and each source entity names the
scope its messages expose. Registry validation reads that declaration for the fields an ingestor’s
programs may name, the host builds its metadata columns from the same fields, and a connector’s
metadata row names the scope it carries, so the three cannot disagree. Transport headers are visited
from the borrowed message in arrival order. The host copies them only when the quiesce control
decides on a payload, which may retain it, or a WebSocket session must retain them after the source
message ends. Header support and each
system’s mapping are explicit: HTTP endpoint and polling headers, WebSocket upgrade headers,
Kafka record headers, NATS headers, Pulsar properties, RabbitMQ AMQP headers, and SQS attributes
retain their connector-specific semantics. Other connector and entity pairs do not offer header
reads.
read_header and read_headers are validated against those capabilities; header values do not
travel through relays unless an ingestor writes them into schema-backed fields. See
Header Context for the exact expression behavior.
The session completion resolver uses the same vocabulary source capability before the full
ingestor is parsed, so it offers those functions only for sources that can read headers. Its
emitter INVOKE completion similarly uses the vocabulary sink capability to offer write_header
only for sinks that can write headers. Runtime validation remains authoritative.
The source host decodes consecutive payloads into one ingest group’s Arrow builders. For a schemaful JSON codec, that group also owns mutable payload scratch and simd-json parser buffers; the connector continues lending immutable payload bytes, and the host reuses its storage until the group closes. Compiled field keys direct borrowed JSON values into typed columns without a serde tree or an intermediate row representation. A rejected payload abandons only the partial Arrow row it started, including any list value it left half-filled at any depth, preserving the accepted rows and transport positions around it: every payload decodes into its group exactly as it would alone. At group execution, the host resolves event timestamps into one Arrow column and tests paced-domain admission with one bitmap. It selects the accepted batch and its ACK and metadata sidecars together. Each timestamp rejection retains the source row’s ACK for its route-local message-error delivery; accepted neighbors continue into the graph. A source position is acknowledged only after every message it unfolded into has completed its own route or error delivery.
Remote delivery retains that host-owned ACK share in a bounded delivery owner, with independent
row outcomes and an exact generation identity. Admission refusal or cancellation before admission
returns the share negatively; admitted records remain pending through keepalives and required
waits until their terminal outcome or the silence bound. A relay owner whose relay memory has no
room for the acknowledgement watches of a delivery holds the delivery back unadmitted instead of
refusing it, and the host’s dispatch waits for that admission within its five-minute bound. A
source in MODE NO ACK therefore stops reading while its delivery is held, as under any relay
backpressure, and its output is delivered once the relay owner has room rather than lost. In an
acknowledged mode the held rows’ shares stay pending without keepalives, so a hold longer than the
source’s ACK TIMEOUT rejects their positions and the source delivers them again, possibly after the
held delivery was admitted. Relay construction retains the domain’s
ACK-root tracker, so intake does not resolve a mutable domain registry for each row. Receiver
watchers retain the sender’s full process identity and charge their memory to the relay budget;
sender replacement or shutdown ends them. These correlations remain volatile and are never a
connector position or persisted commit. Cluster Interconnect owns their
capacity and deadline contracts.
The host refreshes pending ACK progress every 100 milliseconds during connector requests and
retries. This cadence must stay shorter than a source’s one-second ACK TIMEOUT so a pending
attached sink attempt does not make the source replay work while the sink still owns it.
Each source host retains its exact instance readiness handle. Poll success and suspend/resume publish through that scalar, without looking up the ingestor registry. Replacing an ingestor retires its preceding handles before installing the new instances; host drop also retires its own handle. Retirement is final, so late polling or teardown cannot mark a replacement ready or unready. Readiness observers aggregate the installed instance handles. Quiescence and ACK/commit ownership remain with the host’s existing loop and controls.
The host runs three source loop families, with a listener using the broker loop:
| Family | Host behavior | Source behavior |
|---|---|---|
| Broker and listener | Open instances, manage readiness and quiesce, request batches, decode and dispatch, wait for ACK roots when configured, then acknowledge or reject positions and pace retry. | Subscribe, receive, expose transport positions and metadata, and perform transport ACK or rejection. Syslog is a listener with no broker ACK. |
| Paced | Bind and wait on the domain cadence, hand the scheduled instant to one poll, admit its returned messages without broker ACKs, and report poll failures. | HTTP and Prometheus perform one transport poll; they do not bind a domain clock or choose the cadence. |
| Request scoped | Bind endpoint routes to request intake, admit and dispatch each request there, replay retained quiesce work, then unbind on close. | The endpoint source has no polling transport or broker position. |
| Client batches | Keep the producers of one client ingestor, admit their batches one at a time through one admission worker per execution, give each admitted batch one ACK root, and answer each batch with its outcome. | There is no source connector: producers submit Arrow IPC batches through the session protocol. |
The broker, paced and request-scoped loops each drain what their instance’s quiesce buffer
retained once the ingestor resumes, oldest first. A retained payload stays in its buffer, counted
with its bytes, until its messages enter their ingest group, and the buffer hands out nothing
behind it until then. Its sender was already answered and its source has moved past it, so the
host admits its ON INGESTION unfolding to wait for a place on the node’s extension workers rather
than to be refused, ahead of work that asks afterwards. A shutdown or a new quiesce ends the wait
and returns the payload to the front of its buffer, so a drain delays neither. A payload its codec
rejects leaves the buffer and is reported.
At the start of a graceful stop, the host closes intake on the stopping node before the control
plane moves scheduled sources or sinks. The shutdown quiesce suspends broker polling and listener
admission regardless of the ingestor’s declared ON QUIESCE mode; already admitted payloads and
ACK roots continue through the graph. The connector does not choose the move or finish its own
acknowledgements early. A batch’s work stays outstanding until its source has confirmed the
batch’s positions on the transport, so both the move and the in-place drain wait for that
confirmation; see Confirmed acknowledgements. A source started on
the destination node after handoff uses that node’s open intake.
A live payload a source hands over without an acknowledgement, a paced poll or a batch read in a
MODE NO ACK, cannot be presented again either. An acknowledged batch the extension workers refuse
is rejected for its source to deliver again, and an endpoint refuses a body its sender sends again;
a live unacknowledged payload follows its source’s unacknowledged admission instead, which each
source’s composition declares. Waiting holds the source’s loop, so a source waits for a place only
when its transport stays connected with bounded memory while nothing reads it:
| Source | Unacknowledged admission | The transport while its loop is held |
|---|---|---|
| HTTP polling, Prometheus | Waits for a place | Nothing is read. The next poll is delayed, and the domain cadence coalesces the occurrences it missed. |
| NATS | Waits for a place | The client’s connection task keeps reading and answering the server, and holds at most the subscription’s 65,536 messages before it drops the newest as a slow consumer. |
| ZeroMQ | Waits for a place | PULL messages stay in the kernel buffers, and then the pushing peers’ sends wait. |
| Syslog | Waits for a place | The kernel drops UDP datagrams beyond the socket’s receive buffer; TCP and TLS connections fill the listener’s 64-frame queue and then push back on their senders. |
| MQTT | Refused and counted | The event loop, which sends the keep-alive, runs only while the loop reads, so the broker closes the connection once one and a half keep-alive intervals pass without a packet from the client. |
| Redis Pub/Sub | Refused and counted | The client’s connection task reads every message into an unbounded queue. |
| WebSocket client | Refused and counted | Server pings go unanswered while frames are unread, and a server that pings closes the connection. |
KAFKA TOPIC in MODE NO ACK | Refused and counted | A consumer-group member that does not poll for max.poll.interval.ms leaves its group, and a domain-offset instance would keep its partitions through an ownership handoff. |
PULSAR TOPIC in MODE NO ACK | Refused and counted | The consumer engine blocks on a full receive queue, and the consumer’s close, which suspension and stop perform, then waits on it. |
A waiting payload races the ingestor’s stop and every new quiesce decision. The stop drops it. A
new decision, for a source whose input passes through the quiesce control, hands the payload to the
control as input that arrived under that decision: BUFFER retains it, DROP drops and counts it,
and a suspension lets it dispatch, so it waits on. A refused payload is reported as an ingestor
error and counted in nervix_ingestor_unfolding_refused_total. A Pulsar message the host refused
stays unacknowledged until its consumer reconnects.
For broker sources, None admits without an ACK root; Sequential requests one message and
waits for its ACK tree; Parallel requests up to the declared in-flight limit within its batch
timeout. The host takes in an acknowledged batch as one batch acknowledgement and waits for every
accepted message’s ACK outcome before acknowledging the batch’s transport positions; a message a
quiesce buffer or drop policy took over adds nothing to wait for. A failed or timed-out ACK rejects
the positions and retries according to the source’s delivery policy. What the host does when
rejection itself fails depends on the position class each source entity declares, which states what
its positions still mean once the connection that delivered them is gone. The host handles each
class once:
| Position class | Entities | After a failed rejection |
|---|---|---|
replayable | Kafka, Pulsar | The host retains the positions, suspends the source, and reestablishes its assignment. It retries the same rejection before polling any later batch or committing a later position, so a retried rejection must not depend on a poll. The Kafka source therefore does not seek a partition that a consumer-group rebalance has moved away, because only a poll can assign that partition again. |
broker_redelivered | RabbitMQ, SQS, MQTT | The host retains the positions, suspends the source, and resumes it. Once the resume succeeds it discards the retained positions, counts them in nervix_ingestor_transport_redeliveries_total, and reads again: the broker redelivers everything the lost connection did not acknowledge, and the new connection can neither acknowledge nor reject a delivery of the old one. |
none | Every source with only NO ACK, the paced sources, endpoints and client producers | Nothing is pending. The host keeps no position and reads again after its retry delay. |
A transport acknowledgement that fails because the connection died is handled the same way: the
host rejects the positions, that rejection fails on the same lost connection, and the class decides.
A broker_redelivered source therefore delivers at least once across a reconnect, and a message
whose acknowledgement the broker never received is ingested again. A transport without an
acknowledged delivery mode has no redelivery guarantee from Nervix. The
sequence below shows an acknowledged broker policy. The precise source-specific effects and NSPL
modes are in Ingestors and
Shutdown And Recovery.
sequenceDiagram
participant Source as Source connector
participant Host as Source host
participant Graph as Attached graph paths
Source->>Host: Batch with payloads, metadata, positions
Host->>Graph: Decode and dispatch Arrow batch
Graph-->>Host: One ACK outcome per accepted message
alt All ACK roots succeed
Host->>Source: Acknowledge transport positions
alt The transport confirms them
Source-->>Host: Confirmed
Host->>Host: Report progress, release the batch's work
else The confirmation fails
Source-->>Host: Typed acknowledgement failure
Host->>Host: Show the transient status
Host->>Source: Reject transport positions
end
else Intake or an ACK root fails
Host->>Source: Reject transport positions
Host->>Host: Apply declared retry policy
end
Confirmed acknowledgements
acknowledge returns only once the source’s transport has confirmed the positions, as far as the
source’s entity declares in its acknowledgement confirmation, or with a typed acknowledgement
failure. A source whose client returns before that declares so rather than reporting a confirmation
it did not get:
| Acknowledgement confirmation | Entities | When acknowledging returns |
|---|---|---|
answered | SQS | Once the broker answered: an SQS message is acknowledged by a DeleteMessage request, and acknowledging returns with its response. |
written | RabbitMQ | Once the acknowledgement is written to the connection. AMQP does not answer a basic.ack, so nothing more can be confirmed. |
queued | Kafka, Pulsar, MQTT | Once the client queued the acknowledgement, before the broker answered it and possibly before it reached the connection. A Kafka consumer-group commit is never awaited, a Pulsar acknowledgement returns once queued, and an MQTT PUBACK is written when the client’s event loop is next polled. |
none | Every source with only NO ACK, the paced sources, endpoints and client producers | The source acknowledges nothing on its transport. |
The host reports progress for a batch only once acknowledging it returns. Until then the
ingestor’s transient status stays as it was, and the batch’s work stays outstanding: the last of
the batch’s acknowledgement roots to resolve keeps counting against its domain and ingestor until
the source loop drops the batch acknowledgement after acknowledge or reject returns. A drain or
an ownership handoff that waits for admitted acknowledgement roots therefore also waits for the
source’s confirmation, and a node’s graceful stop completes outstanding acknowledgements before
the source closes, within the drain timeout. A root that waits only on what a drain does not wait
for, such as a REQUIRED WAIT dependency or a window that retains its row, still does not hold
the drain, and neither do the resolved roots of the batch it belongs to, because the batch cannot
be acknowledged until it resolves.
A confirmation that fails is the ingestor’s transient status, which DESCRIBE INGESTOR shows until
a later acknowledgement is confirmed, and the host rejects the positions for their position class
to handle. A stop that reaches the source loop once a batch’s acknowledgement roots have resolved
does not abandon the batch: the loop takes the resolved outcome before the stop and confirms the
batch first. A batch whose roots had not all resolved when its source stopped is left unconfirmed;
the source instance logs source instance stopped before its transport confirmed positions it read; the transport presents them again with the count, and its transport’s own redelivery
contract applies.
Sink boundary
Each committed domain schedule becomes one complete typed execution revision before it reaches the host. That revision carries placement, entrypoint and emitter plans, and the exact ownership-handoff fingerprint of the committed schedule. It decides one typed emitter execution plan per scheduled emitter before publishing the domain execution. The plan resolves its sink clients and codec, retains its ordered source relay edges and lowered source predicates, and lowers its route, HTTP request fields, SQS ordering group and the mappings of row and row request sinks. It also converts OTEL resource literals and the Iceberg commit cadence and size into connector values. Initial startup, reassignment and an entity swap use that same plan. A swap publishes new source and remote consumer edges from the new plan; it does not reconstruct the emitter from a Model in the host. The host resolves resource mounts and binds the lowered VM programs to installed schemas and UDFs when it starts the task. A retry reopens the sink with the same typed configuration.
A TO CLIENT SCHEMA emitter is a native sink plan, not an external connector plan. Its typed
plan carries the exact output schema, declared BATCH limit and required ACK window, timeout and
retry pacing, with no client object or codec. The host constructs Arrow IPC from the projected
columns, retains each payload’s bytes and source member positions through the common prepared
payload contract, and resolves every member only after the application ACK or route-level message
error. A failed or interrupted attempt retains the prepared payload for the host’s next publish.
The node’s client emitter endpoint owns competing consumer attempts and physical retry; the host
continues to own flush, branch routing, quiesce, source ACK propagation and message errors. No
connector crate reads the native output or chooses its graph semantics.
The host prepares one write for one of four sink contracts. A record sink receives
codec-encoded keys, payloads, headers, optional ordering groups, timestamps, and the identity the
host assigned each record of the write. A row sink receives a run of host-projected Arrow
carriers of one source relay and concrete branch, the target columns their mapped columns are
written to, and each carrier’s selected rows, execution time and, for a sink that retains them,
acknowledgements; it encodes its external representation from those columns. A row request
sink receives the same projected columns one carrier at a time and prepares requests from them
once: each prepared request is the exact bytes the connector will send and the source positions of
the rows it carries. The host retains every prepared request and hands it back, under an identity
it assigns for the write, until the connector answers for it. An HTTP request sink receives
prepared requests: each carries the identity the host assigned it, the validated method, the target
normalized on the client’s origin, the application headers after case-insensitive replacement, and
the exact body bytes the codec produced or no body at all. The host evaluates VALUES once per
batch and excludes rows with mapping errors before calling a row sink or a row request sink. It
retains the ACKs of the source rows every record, mapped row or request carries, so no runtime ACK
map enters the connector. Each publish is one call per write, never a virtual call per row.
The host compiles a row or row request sink’s VALUES projection before opening that sink. A failed VM
inference or compilation retains its typed VM report beneath a MappedValuesError that names the
sink and the emitter, then the sink-initialization context. The emitter follows its existing
initialization retry policy; the connector never receives a partially compiled mapping.
The ClickHouse row sink uses the shared columnar JSON writer for JSONEachRow. It prepares typed
column readers and string escape masks once for each carrier of a write, then writes each selected
row in mapping order without building per-row JSON values. It selects the writer’s octet encoding
for binary columns, so a String column stores a BYTES value’s own octets rather than base64
text. It keeps the host’s request cadence and per-record outcomes.
An ordering group exists only where the sink plan declares one; today that is the SQS
FIFO GROUP. The host binds the lowered declaration, evaluates it once per filtered source batch, and
carries the result beside the batch: the batch’s branch key for FROM BRANCH, or a string column
for an expression, whose null rows keep the reason they have no group. It selects that column
through the rows the emitter’s route keeps, so each record keeps the group of its own input row. A
record without a group is rejected by the host under the emitter’s message error policy and never
reaches the connector, which receives each remaining record’s group already evaluated. An emitter
whose plan declares no group carries nothing beside its batches.
Each connector classifies definite delivery and rejection per record, and may report one
infrastructure failure for the attempt. A record sink answers for a record under the identity the
host gave it, and a row sink answers for a mapped row under that row’s source position. A row
request sink refuses a mapped row under its position while it prepares requests, and answers for a
prepared request under the identity the host gave it. The host checks a record or request sink’s
answers against the write before applying any of them: an answer for a record the write did not
carry, or a second answer for one record, breaks the contract and fails the attempt without a
retry. A record left unanswered without a reported failure leaves the attempt unresolved, and the
host retries it, because nothing says that record was not written. It checks a row request sink’s
preparation the same way before it retains anything: every row it handed over must be a member of
exactly one request or refused, every request must carry a row, and every member must follow the
members of the requests before it in source order, so the retained requests are sent in the order
the connector prepared them. A preparation that breaks any of these fails the attempt without a
retry and retains nothing, because the same connector would prepare the same rows the same way
again. The host
applies the answers to the corresponding ACK roots and error policy. The emitter task owns its buffer, maximum batch size, flush cadence,
the commit cadence of the rows a staging sink holds, retry schedule, fault injection, stop deadline,
and metrics. The connector owns the external
operation and its completion point. A receiver-requested delay, which a connector attaches to the
attempt’s failure, can extend, but cannot shorten, the host’s retry backoff; the backoff sequence
advances as it would without it. The host ignores a delay whose end its monotonic clock cannot
represent, so the backoff alone decides that wait. finish lets a transport empty a client-side
queue within the remaining stop deadline; Kafka uses it. A sink may keep its client after a
publish failure when reopening it would discard staged work or a persistent session, or, as the
HTTP request sink does, when it holds nothing between attempts and reopening it would clear the
transient failure its pending work is still waiting out.
A brokered record sink in MODE ACK awaits its broker’s answers through the contract crate’s
ConfirmationWindow. The sink hands each record to its driver and gives the window the record’s
identity, its execution time and the future through which the broker answers for it: a
confirmation, a refusal for good with the message its message error reports, or a failure that
leaves the record’s outcome unknown. The connector maps its driver’s answer to one of those three;
the window decides nothing else about the driver. A record’s deadline is ACK TIMEOUT after its own
hand-over. While the window holds MAX records it waits for the oldest answer until the oldest’s
deadline and no longer before the sink hands over another record, so at most MAX records await
answers at once and ACK SEQUENTIAL is a window of one. Once every record was handed over, the
window waits for each remaining answer, oldest first and each until its own deadline. An answer
given at the instant of its deadline is an answer. A failure ends the publish at once: the driver
could not take a record for a reason that is not the record’s own, the oldest answer failed or
missed its deadline, or a deadline is past the monotonic clock’s range. The window describes a
failure of its own, a missed deadline or one past the clock’s range, in a printable attachment,
which the emitter shows as its transient error; a failure the connector hands it carries the
connector’s own description. The window holds the publish’s outcome, and both ways of ending a
publish go through it, so before it reports a failure it settles every answer the broker already
gave, the oldest’s included: a confirmed record is delivered and a refused one rejected, and the
host’s retry never resends a record the broker confirmed. A record without an answer, or whose
answer failed, stays unresolved and is sent again, which is the at-least-once delivery MODE ACK
promises. The registered Bolero property connector-confirmation-window holds the window to a
reference model of this rule over scripted answers given within, at, past or never by each deadline,
failed answers and driver failures, and the Shuttle checks of the
confirmation window hold its settlement
while answers arrive concurrently with the publish.
The MQTT sink awaits its QoS 1 and QoS 2 answers through the window. A completed handshake confirms
a record, a reason code about the record itself (not authorized, an invalid topic name or an
invalid payload format) refuses it, and every other ending leaves it unresolved: the client, its
connection or its session failed, or the reason code is not about the record. The client’s request
queue holds MAX requests, as many as the window awaits, and the event loop takes them in order,
so the client can refuse a record only while the queue still holds an earlier failed attempt’s
requests, before the broker answered any record of the publish. The Kafka, Pulsar and NATS
JetStream sinks still await their answers through windows of their own, which drop their in-flight
records when the driver refuses to take one, so a retry can resend records their broker already
confirmed; RabbitMQ settles its records when it loses a channel instead.
The task loop keeps the connector state, buffer, retry schedule, backoff, and reconnect decision in one mutable owner. Force flushes, cadence or retry wakes, and input-triggered publishes all apply one outcome transition: success clears retry state and records in the sent metrics the rows the sink delivered, each once however many attempts that took and never a rejected one, a retryable failure defers the owned work and decides whether to reconnect, and a terminal failure records the rows delivered before it and routes every other still-owned source row through the emitter’s message error policy. Stop requests retain their separate deadline-bounded final flush and transport finish, and a stopped interaction performs its final drain before the loop exits.
An ownership handoff closes source admission and boundary dispatch while installed routes and sinks finish their admitted work and requested flush. Engagement confirms the closed admission hold; the participant’s exact-operation drain-status query publishes its ownership freeze only when that local drain completes. Capture requires the held operation and its exact freeze identity, so it cannot prevent a route from completing its flush. Preparation retains its original deadline; the distinct gate lease protects capture and activation. Shutdown And Recovery owns the complete handoff lifecycle and failure outcomes.
A quiesced backup keeps these source and sink tasks installed. The host stops domain source admission according to each source’s declared quiesce policy, reports admitted ACK roots and publishing sinks to the domain drain, and runs a confirming force flush after admitted work clears. Listeners for other domains continue serving. A sink that does not confirm before the backup’s quiesce timeout leaves the backup incomplete; it is not treated as a successful publish.
Generated source loops flush pending work when paused and release their in-flight work guard before waiting for resume. They acquire a new guard before executing again. This lets the host finish quiescence and capture fresh materialized state while the installed generator task waits.
When that policy sends a failed emitter record to a DLQ, the host executes the message-error SET program bound during domain installation or replacement. The prepared route retains the input and optional attempted codec-record schemas, the source branch, relay target and flush cadence. The connector receives no error-record Model or VM program and makes no DLQ routing decision.
Terminal node or domain teardown is a different boundary from a successful stop request. After its drain budget ends, it cancels an emitter task even when the connector is waiting for an external answer. The host drops that task’s prepared payloads and unresolved ACK guards without turning the cancellation into a delivered response or a record-specific message error. The source’s acknowledgement and recovery contract then decides whether the record returns. An entity-pause swap uses the stop request instead and cannot install the replacement until its old task drains successfully.
For a record sink using the emitter BATCH clause, the host selects rows from successive
Arc-backed Arrow carriers released by one flush. It retains each carrier’s source relay, exact
branch key, execution time and original batch and row position. The host prepares members in
arrival order and seals a payload when the source relay, branch, key, ordered headers, ordering
group or codec container metadata changes. The codec encodes each candidate under MAX SIZE, and
the host subdivides a candidate that does not fit; Arrow memory accounting still belongs to
FLUSH. The connector sees one encoded record per completed payload and answers for it under the
record’s identity, without learning which rows the payload carries. A candidate whose container
cannot be produced fails each of its members with one message error, which the container failure’s
typed reason decides; the evaluation failure beneath that reason can quote the payload it
evaluated and is never rendered.
The connector owns the destination’s own limit on each record, whether the record carries one row
or a whole batch payload, because only the connector knows what it writes around the payload.
Where it can learn that limit it measures the complete message and rejects a record that cannot
fit before writing it, with a reason naming the size and the limit, so the rejection follows the
message error policy instead of failing the transport: the MQTT sink measures the PUBLISH packet
against the Maximum Packet Size of the broker’s latest CONNACK and the largest packet MQTT can
express, the SQS sink counts attributes and the FIFO group against 256 KiB, and the Kafka producer,
the NATS client and the Pulsar client check message.max.bytes, max_payload and the
maxMessageSize of the connection’s CommandConnected with the key, headers or message metadata
they write. A limit the connector cannot learn stays with the destination, and the destination’s own
answer decides the outcome. A Pulsar broker answers a message above a topic’s own maxMessageSize
policy with NotAllowedError, a definitive rejection of that message. RabbitMQ never tells a client
its max_message_size, but its refusal of a larger body closes the channel with the limit named,
so the RabbitMQ sink rejects the first message of the write the broker had not answered whose body
exceeds that limit, with the same kind of reason, and writes the messages the broker discarded
behind it again on a new channel. Anything else a destination reports when a message exceeds its
limit is classified like any other publish failure.
Syslog sends a completed codec payload as one transport frame. Its UDP writer rejects a frame above 65,507 bytes, octet-counted TCP and TLS reject a count needing more than ten digits, and non-transparent TCP rejects one containing LF. The Sentry writer accepts one JSON event per envelope and checks the final event after default fields are added against the 1 MB decompressed event limit. It writes every other member as the codec wrote it, a number to its last digit. Both classify a definite local refusal as a record rejection; their transport and service failures retain their existing retry boundaries.
The host owns that membership. It keeps every payload it offers the sink, with its exact bytes,
key, headers, ordering group and member positions, in the emitter buffer beside the batches the
members came from, and marks the members prepared so that no later attempt packs them again. A
confirmation delivers every member at the completion point MODE selects. A rejection routes every
member through ON MESSAGE ERROR with the sink’s one error reference, each member keeping its own
execution time, branch and acknowledgement. A payload the attempt left unanswered, because the sink
failed or its outcome is unknown, stays retained unchanged. The next attempt, whether a retry, a
force flush or a drain, writes it again byte for byte with the same members, ahead of any payload
packed after it, and never writes a payload the sink already confirmed or rejected. The retry
schedule keeps the members’ acknowledgements alive while they wait, and the buffer counts them as
work the node still holds until they resolve. The retained payloads live with the buffer rather
than the connector, so reopening a connector between attempts keeps them; only a failure that ends
the attempt for good releases them, and their members then follow the error policy with every other
unresolved row. A row sink names every member itself, so its retry writes only the rows it left
unresolved; MongoDB’s per-document results shrink a retried bulk write this way.
A row sink divides its own writes, because only the connector can measure what it sends. The
host projects each buffered carrier once and hands the sink every run of successive carriers of
one source relay and concrete branch in one call, so a write never spans relays or branches. The
typed plan passes the emitter’s BATCH limits to the connector, which narrows them to what its
destination takes in one request and divides the run by one rule the contract crate owns:
candidates in packing order of at most the row limit, a candidate whose exact measured size exceeds
the byte limit halved and measured again with the rest returned to the front, and a single row that
still exceeds it rejected alone. A row over MAX SIZE is a validation error of the encode
operation; a row within it that the destination could never take is the destination’s external
rejection of the row. Every request answers for the rows it carried, and a failed request leaves its
rows and every later request’s unresolved for the host to retry, so a retry packs the same rows into
the same requests again.
The database sinks divide the whole run. ClickHouse measures the JSONEachRow body of an insert,
each row’s line encoded once and sent as a slice of one buffer. Postgres measures the one unnest
statement every insert of the write executes and the text arrays it binds, exactly as the driver
encodes them, and never lets that size exceed the largest protocol message the server reads, which
bounds the Bind message that carries the arrays. MySQL measures each multi-row statement and every
value’s binary-protocol encoding, and narrows the row limit to the rows whose placeholders fit the
65,535 one statement binds. MongoDB measures each inserted document with the _id the driver
prepends, or each upsert’s filter and update documents, and rejects a row whose document exceeds
the server’s 16 MiB document limit before any write, because the driver would refuse the whole write
for it. A record-specific failure of a multi-row SQL write is isolated by writing its rows one at a
time; a Postgres cardinality violation, which is how ON CONFLICT DO UPDATE refuses one insert that
carries a key twice, is isolated the same way. MongoDB answers per document, so it needs no
isolation pass.
Registered Bolero properties hold these rules against their owners, with measures that grow and
shrink with a candidate: connector-row-request-division the division rule and both limits,
emitter-batch-subdivision and emitter-batch-packing the candidates, seals, relay, branch and
envelope grouping and the exact container bytes, codec-bounded-encodings and
bounded-write-limits the exact sizes under MAX SIZE, emitter-row-preparations and
emitter-retained-payloads the membership and byte-for-byte retry, and sqs-batch-requests,
mqtt-publish-packets and postgres-insert-measures the connector-specific measures. See
Property Testing And Fuzzing.
Iceberg is a row sink that stages each carrier of the run as its own file, which its commit publishes.
OTEL is a row request sink, which the host hands one carrier per preparation. Without BATCH it
prepares the successfully mapped rows of the carrier as one Export request. With BATCH, its typed
plan passes the emitter’s limits to the connector, which divides the successful positions by the
same rule, measuring the exact uncompressed protobuf Export request, including resource and scope,
before optional gzip or transport framing; a record whose request alone exceeds MAX SIZE is
refused while preparing, as a validation error of the encode operation. The connector converts
each row once and samples the log records’ observed_time_unix_nano from actual UTC once per
preparation, then encodes each request once.
The retained boundary is that encoding: the exact protobuf bytes of the Export request and the
positions of the rows it carries, kept in the emitter buffer beside the batch payloads and HTTP
requests other sinks retain. It is the quantity MAX SIZE measures and the message the receiver
decodes. Gzip and the gRPC or HTTP framing are applied to it on every attempt; both are
deterministic functions of those bytes and of the client configuration, which cannot change while
the emitter runs, so an attempt compresses the retained bytes exactly as the one before it did.
Keeping compressed or framed bytes instead would tie the retained request to one transport
attempt, and the gRPC client compresses a message itself. Over gRPC the connector sends the retained
bytes through a pass-through codec rather than re-encoding a message.
The host hands the retained requests to the connector in the order they were prepared, ahead of any
request prepared after them. An accepted request delivers every row it carries, a request the
receiver refuses rejects every row it carries with one shared error reference, and neither is sent
again. A request whose outcome the connector did not learn stays retained with its bytes and rows,
and so does every request after it in that write. The next attempt, whether a retry, a force flush
or a drain, sends it byte for byte, observed timestamps included, through whichever connector the
emitter holds by then; the host reopens an OTEL connector after a failed publish, which is why the
retained request lives with the buffer. Its rows are never mapped, converted or regrouped again.
A receiver’s partial_success still acknowledges the whole request with a warning, because OTLP
does not identify the rejected members.
An OTLP/gRPC export whose receiver never answered is retried: tonic reports a request timeout, a
connection lost before the answer, or an answer it cannot read as a status carrying the local
failure as its source, which no status a receiver sends has. An answered status is classified by
its code: INVALID_ARGUMENT refuses the request, RESOURCE_EXHAUSTED and the codes the OTLP
specification lists as retryable — CANCELLED, DEADLINE_EXCEEDED, ABORTED, OUT_OF_RANGE,
UNAVAILABLE and DATA_LOSS — are retried no sooner than the receiver’s RetryInfo, and any other
code fails the attempt as a configuration the endpoint cannot accept. OTLP/HTTP retries a
connection failure, a timeout, a lost response, and HTTP 429 or 5xx, refuses on 400, and fails
on any other status.
An HTTP emitter’s request fields are the host’s, not the connector’s. When the emitter admits a
batch, the host evaluates one compiled program over each record’s original input, its finalized
codec record and the batch’s materialized state, only for the rows route WHERE kept: METHOD and
PATH are written into a write-only request namespace, and each write_header invocation is
evaluated in written order. It validates each row’s method, then its target on the client’s
origin, then each header write, rejects a row at the first field that fails, and buffers the rest
with the fields they were admitted with, beside their Arrow rows, together with their original
source records and the batch’s materialized state, which the message error of a later rejection
reads. The request-field program must compile before the sink starts; a compile failure keeps the
VM cause beneath the host’s emitter context. When a flush releases a row, the host encodes its body
and retains the request, as a prepared payload with that one member, in the
same buffer that retains batch payloads. Every attempt hands the connector the retained requests
unchanged, ahead of any request prepared after them, so a retry repeats the request the destination
may already hold. One connector publish call awaits at most one request across the emitter
execution’s served sources and branches. It sends them in their handed-over order, each on a
fresh HTTP/1.1 connection using the node resolver and the shared rustls trust and client-identity
configuration. The connection closes after final headers; no unread body can be reused. Its one
physical timeout_ms spans DNS, connect, TLS, send, interim headers and complete final headers.
The connector resolves the target’s host through the node resolver within that timeout and tries
the answers in order without dividing it among them. The connector sends no startup probe, follows no redirect, stores no response cookie, answers no
authentication challenge with another request, and has no independent retry policy. The host
alone schedules another application attempt.
The transport generates Host, Connection: close and, for a present body,
Content-Length. It does not add Accept-Encoding or Content-Type. Application header writes
can supply Accept, content type, authorization and cookie values. Without an application
Accept, the transport adds Accept: */*.
Each interim and final response header block is checked for at most 128 fields and 64 KiB of name
and value bytes; invalid final framing fails before the status is classified. Complete valid final
2xx headers deliver one request, without awaiting or interpreting its body. 408, 425, 429,
5xx, 401, 403, 407 and transport or header failures end the attempt with the current and
later requests unresolved; the authentication statuses retain a distinct infrastructure reason.
Other 3xx/4xx and 101 reject their one request with a structured external message error,
then publication continues with the next request. The host applies delivered and rejected
members, branches and acknowledgements and keeps unresolved prepared bytes for retry.
A failed attempt keeps its cause beneath the attempt’s own error: the resolver’s lookup failure, or the socket or TLS error. The connector attaches the description of that chain to the attempt’s publish failure, and the host reports it as the emitter’s transient error and runtime event. It names the status or the transport cause and never the evaluated target, a header value or a body. The connector keeps its client after a failed attempt, so the host’s retry sends the pending request without reopening it, and the failure stays reported until the request resolves. The host’s sent metrics count a delivered request’s codec record as payload, or nothing for a request without a body; the method, target and headers are request metadata.
When the final head of a retryable or authentication status carries exactly one Retry-After
field, the connector reads it as RFC 9110 delay-seconds, whole digits only, or as an HTTP date
in IMF-fixdate, RFC 850 or asctime form. It compares a date with actual UTC, read through the
contract’s physical-time owner as the response arrives, and a date already past asks for no delay.
It attaches the resulting delay to the attempt’s failure as the receiver-requested delay. Two such
fields, any other text, a number beyond 64 bits, a date after 2262, or a delay that would end after
2262 attach nothing, and neither does an interim head or a delivered or rejected request. The host
waits for the longer of this delay and its backoff on its monotonic clock, so the domain’s
TIME RATE never shortens it.
For a sink that stages writes, the lifecycle reports what it holds for its next commit, either rows that wait for a domain-logical commit cadence or rows that reached its maximum commit size, together with a staged-message count, pending ACKs, and a commit operation. The host owns the cadence. It arms the cadence’s logical deadline at the first cadence check that finds rows staged, which follows the write that staged them, so rows that waited in the emitter buffer still wait the whole cadence after they are staged, and rows that later writes stage join that commit without moving it. The host includes the deadline in the emitter wake and commits once it is reached or the sink reports its size boundary. A retry and a drain force the commit without reading the domain clock, which a stopped domain no longer has. The host keeps retained ACKs alive during commit and retry, and reports sent metrics only after the commit reports publication. A failed commit retries on the emitter’s declared backoff until it succeeds or the stop deadline ends the attempt. See ACK Semantics And Effective Delivery for the user-visible delivery consequences.
sequenceDiagram
participant Graph as Runtime graph
participant Host as Emitter host
participant Sink as Sink connector
participant External as External system
Graph->>Host: Arrow batch and attached ACKs
Host->>Host: Buffer, encode or project, and flush
Host->>Sink: One publish call per write
Sink->>External: Write records or mapped rows
Sink-->>Host: Delivered, rejected, and attempt failure
opt Sink retains staged ACKs
Host->>Sink: Commit on cadence, size boundary or drain
Sink->>External: Publish staged data
Sink-->>Host: Commit report and resolved ACKs
end
Host-->>Graph: Apply completion or failure
Integration-specific boundaries
-
Kafka source. The driver inspects topic partitions and reads or commits Kafka offsets. A rejection seeks each rejected partition the consumer is still assigned back to its earliest unacknowledged record. A consumer-group rebalance can move a partition to another group member while its batch is in flight, and Kafka cannot seek a partition the consumer no longer fetches. That partition needs no seek: whichever member is assigned it next, this consumer included, resumes it from the committed offset, which never passes an unacknowledged record. With
OFFSET BY DOMAIN, the host supplies typed access to replicated next-offset state and a committed partition schedule. The leader observes partition topology and commits assignments; executing sources follow that schedule. Offset snapshots can lag a crash, so this mode remains at least once. Commits and start-point resets wait for the assigned state replicas within the native checkpoint bulk operation’s thirty-second budget. A missing acknowledgement still fails the commit and follows the host’s existing rejection and retry policy. Kafka ingestion defines the recovery details. Replica ACKs reach that installed offset state through a resolved, retired-on-ending route and the existing assignment publication. The host’s announcer retains its route and follows replica assignment changes without reading node-wide state or execution registries. The Kafka commit and acknowledgement boundary remains the state’s highest durable replica progress. -
Pulsar client. The Pulsar source and sink build on Nervix’s fork of
pulsar-rs,nervix-io/pulsar-rs, because the released crate discardsCommandConnected. The fork keeps each connection’s announcedmaxMessageSize, refuses a message whose serialized metadata and payload exceed it before writing it, as the Java client does, and resolves a send the broker answers withSendErrorwith that error’s server code and reason. The broker itself only closes the connection on a frame larger than the maximum plus 10 KiB of framing, which would fail every other message in flight on it. -
Syslog stream listener. Each accepted TCP or TLS connection has one task that owns its frame buffer and reads into it directly, at most 8 KiB per read and never beyond the connection’s frame bound of
max_message_sizeplus the eleven bytes of the longest octet count and its space. Framing moves a cursor through the buffer and copies each payload into the frame the intake queue carries; the bytes before the cursor are discarded once, when the next read needs room, so the bytes a read delivers are moved at most once however many frames they hold. The decoder finds a space or LF withmemchrand remembers how far it has examined the frame at the cursor: an octet-counted frame keeps its parsed count until its payload is complete, and an unterminated non-transparent frame is searched for its LF only in the bytes a read added. An octet count, at most eleven bytes with its space, is read whole again when a read splits it. A frame split anywhere across reads yields the same payload and the same typed framing failure as one read holding the whole stream. Syslog ingestion defines the framing rules. -
Syslog decoding, SQS bodies and OTEL identifiers. The SYSLOG codec checks header fields and scans
STRUCTURED-DATAthrough byte classes ofnervix-simd-kernels: the first byte outside printable US-ASCII ends a header check, and two forward scans find the byte that ends eachSD-IDandPARAM-NAMEand the",\or]in eachPARAM-VALUE, classifying each 64-byte block at most once. The codec resolves its schema’s fields against the fixed contract when it is compiled, so decoding appends each column by a typed field rather than by reading its name. The SQS sink takes a record’s body in one pass that validates UTF-8 withsimdutf8and classifies characters against the XML 1.0Charproduction over the same bytes while they are cached, and turns the admitted bytes into the request body without validating them again; invalid UTF-8 outranks an excluded character, and either is a record rejection. Attribute values pass through the same character classifier. The OTEL sink decodes trace and span identifiers withfaster-hexafter checking their exact length and refuses all-zero identifiers. The registeredotel-hex-idsproperty compares all bytes and acceptance with an independent per-digit scalar decoder for trace, span and parent-span keys, including lowercase, uppercase, mixed case, arbitrary UTF-8 text and the exact key and reason of every refusal. -
Iceberg sink. It stages Arrow data locally on the node’s filesystem storage workers, prepares data files, and publishes a catalog update once its
COMMIT EACHcadence, which the host measures from staging, ends or its staged files reach the maximum commit size. Staging does not complete an ACK; successful catalog commit does. The sink retains ACKs and its client while a failed commit is retried. Iceberg emission defines the external commit and duplicate limits. -
RabbitMQ sink. The broker answers no
NO_ACKmessage itself, so the sink ends aNO_ACKwrite with one round trip on its channel, which the broker answers only after it has taken every message written before it, and the answer delivers the write; a write costs that one round trip, not one per message. A broker that closes the channel names its reason, and Lapin hands it only to what was waiting on the channel at that moment, so the sink reads the reason from its connection’s events, once for every channel it loses. The size refusal leaves the connection open, and the sink opens its next channel on it; a message the broker had not confirmed ahead of the refused one may be in its queue, so the attempt then fails for the host’s retry. Any other loss is an infrastructure failure, and the host reopens the sink. RabbitMQ emission defines the public behavior. -
Client source. Each client ingestor a node executes has one endpoint task that outlives a single execution of the ingestor. It owns the attached producers, the batches each queued, their round-robin admission into the execution’s one acknowledgement window, and every batch’s outcome. The execution’s admission worker validates a batch as one canonical Arrow IPC stream of the input schema, checking bounded IPC message and buffer declarations before Arrow decoding. It then tracks a new ACK root with the ingestor’s drain accounting, reads the quiesce state, and only then dispatches the batch through the ingestor’s filter and routes, so a quiesce either counts the batch or refuses it with nothing dispatched. The batch’s outcome is its ACK root’s resolution under the declared ACK timeout, which counts time without acknowledgement progress. An alteration that keeps the endpoint contract finds the same producers attached once the new execution is installed; a changed contract, a new domain generation, removal, relocation, and shutdown end them with the reason that applies. Before a model alteration closes a shared downstream relay gate, an intake-only hold suspends the affected ingestors across the cluster and force-flushes their buffered routes. Their admitted ACK roots drain through the open relay and sink. The full entity hold takes over suspension before the intake hold is released; a failed drain retains the committed execution and the admitted batches’ original outcome ownership. Ingestors defines the public behavior.
-
Pooled sinks. The connector owns the driver’s pool and borrowed connection. The host owns the lease on the node’s named client and the runtime wait while no connection is available. Database client pools defines the bounds.
-
Listening entities. A configured server-side listening port is bound on every live Nervix node, independent of leadership and placement. This includes Syslog and server endpoint listeners. The host owns their lifecycle across joins, restarts, elections, and shutdown; their transport implementations remain in their owning integration or server edge.
-
Named Syslog and WebSocket clients. Outbound Syslog UDP, TCP and TLS senders and WebSocket
wsandwsssources resolve through the node’s asynchronous resolver for each new connection attempt. The node resolver owns DNS cache and TTL policy; the connectors keep their framing, upgrade, TLS, signaling and retry behavior. DNS and ordered address attempts share a physical connection budget. Syslog UDP binds a socket in each attempted address’s IP family and sends to the first address whose socket setup succeeds. TCP and TLS senders and WebSocket clients try successive answers within that budget. TLS verifies the configured name, and a WebSocket upgrade retains its original URL authority, path and query. The source host cancels a pending resume when shutdown or quiesce changes its lifecycle state. DNS and connection failures remain infrastructure outcomes for the host to retry; a resolved address alone never completes a Syslog record or an input acknowledgement. -
WebSocket client source. The WebSocket connector crate supplies the outbound
WEBSOCKETS CONNECTIONsource throughSourceConnectorandBrokerSourceConnector. It has no emitter or listening endpoint. Its plan carries resolved client configuration, the node resolver and an optional compiled signaling protocol.openprepares local transport and TLS state without resolving a name or connecting;resumeresolves and connects, completes any signaling handshake, and only then reports readiness. Accepted data from a successful handshake is queued for polling before new socket frames. Text and binary frames become raw payload bytes for the host to decode; protocol control frames remain transport concerns. Suspension drops the connection while retaining queued payloads, and close also clears that queue. Transport failure requires another resume of the retained source. The host composer currently resolves the named signaling protocol and chooses buffered intake, a flush after each intake, and refusal with counting when unacknowledged unfolding cannot enter the executor. Holding its polling loop would also stop answering the peer’s pings. These transport requirements belong in a connector-owned contract declaration; the composer currently hard-codes them instead. The source ownership header names this boundary violation. The source’s position carries no external acknowledgement, and its acknowledge and reject operations are no-ops; reconnection supplies no durable replay guarantee. The host owns graph intake, routing, quiesce, admission and retry cadence. Server endpoint listeners use the server’s request-scoped source and share only the signaling engine with this outbound connector. -
WebSocket signaling. The WebSocket crate owns the compiled signaling engine shared by client sources and server endpoint sessions. Its ordered send and wait clauses, acceptance policy, per-connection state and handshake timeout govern when frames become payload; the caller supplies the accepted-data sink, and the host still owns intake and routing. Domain activation resolves the endpoint’s VHOST and signaling reference first. The runtime binds the pinned protobuf resource, when present, and hands its send and wait message descriptors to the compiler. The compiled artifact retains the wire format, compiled jaq programs and timeout; each connection owns its handshake state. The compiler never chooses a graph route or an endpoint listener. A number keeps its value between a frame and the programs that read and write it: a protobuf
doubleto its bits. The compiler currently also receives the configuration Model’sSignalingWireFormatandSignalingProtocolOnConnect, and parses the latter’s timeout text. This contradicts the connector engine’s typed-plan input boundary: the decision layer must convert those Model fields and the duration into a typed signaling plan before compilation. The crate and signaling module’s ownership headers name this violation; sharing the engine with endpoint sessions does not transfer Model conversion, resource resolution or routing into the connector. The accepted-data callback is currently the crate’s ownSignalingDataSinkinterface. Its host service belongs in the shared connector contract; the signaling module’s header names this separate host-interface ownership violation. Registered conversion properties exercise complete messages with explicit scalar presence: double and float bits, full-width integers, booleans, strings, ordered repeated fields and absent defaults. The generated JSON omits empty repeated fields, which protobuf does not distinguish from absent ones. A separate malformed-frame property refuses invalid field tags, truncated fixed-width and length-delimited fields, oversized varints and incomplete packed float elements through the production decoder. -
Server endpoint intake.
HTTP ENDPOINTandWEBSOCKETS ENDPOINTingestors share one server-owned intake; each head names the protocol its endpoint serves, and the registry refuses an endpoint of anotherTYPEbefore activation. The endpoint source binds one prepared intake lifetime to all of its configured routes before reporting readiness. Domain definitions and bound lifetimes share one immutable publication. HTTP resolves it once per request; a WebSocket retains its route and signaling protocol for the connection, including data accepted during signaling. Close or drop ends that exact lifetime before withdrawing it, so a retained route cannot attach to a replacement source. A request already holding an intake may finish. Configured routes without a live intake reject with HTTP 503, and after signaling an established WebSocket closes with 1013 on its next refused payload. Domain teardown removes only that domain’s definitions and ends its bound lifetimes, preserving other domains publishing the same host and path. The host continues to own quiesce, buffering, decoding, routing, flush cadence, and error reporting.
Failure and observation
The compiler failure-contract gate reads exact source-owned semantic outcome classifications for SQL mapped-column refusals and MongoDB value/document refusals. Their callers produce definitive record rejections with the record position and safe field path. Infrastructure and batch failures return contextual reports. Errors And Diagnostics owns the compiler classification contract; these annotations do not change acknowledgement or retry policy.
An ingestor opens all source instances before registration, so a failed source start leaves no
running ingestor. An invalid emitter declaration or expression fails planning before its new
execution plan is published. A sink initialization failure is reported as an emitter initialization error;
the emitter remains unavailable and retries opening on its configured backoff. Invalid settings
and missing external topics, queues, tables, namespaces, or other required entities surface as
start errors: Nervix does not create them as a side effect. During execution, read and publish
failures are classified separately from definitive record rejections. The host reports transient
status and events, retries infrastructure failures, and preserves the configured message and
general error policies. The general error policy receives the host’s typed report of a failure
that ended a batch’s work and renders its whole chain once: the event and every negative
acknowledgement carry it after the emitter’s name and domain, and the log records it beside them.
A general error the connector reports itself reaches the same policy with the connector’s
description. An emitter that does not start on a node reports an EmitterStartError naming the
emitter, its domain and the step that failed, such as a client configuration that does not
resolve, a codec that cannot hold a batch, or a program that does not compile. A swap whose old
task does not stop keeps that task installed and running, and fails with the task’s own
description of its drain failure, or with the stop failure when the task did not answer. A commit
failure keeps staged ACKs pending and visible until retry or drain failure; it never turns staging
into success. A forced ending loses in-memory batches and ACK state, leaving external redelivery to
each source’s contract.
Connector-owned fallible helpers return contextual reports. Syslog configuration and frame
decoding create a report at the failed parse, read, or framing check; the source adds its
connection or lifecycle context before the host receives it. WebSocket signaling keeps its jaq,
frame encoding, and transport causes beneath the compiled protocol or session failure, and reads its
connect timeout through the vocabulary’s guarded duration parser, keeping its DurationTextError
beneath the invalid-timeout failure. The server
retains the Syslog plan and signaling compiler reports in its runtime startup errors. A connector
may turn a report into the existing source or sink outcome only at that boundary, while preserving
the typed cause and rendering only non-sensitive configuration or transport details.
The host’s source start keeps every connector report it composes an ingestor from. A client
configuration that does not resolve, a connector plan that fails, such as a Syslog, Redis,
WebSocket, Pulsar or SQS plan, an instance whose open fails, and a domain cadence that does not
bind all stay beneath the ingestor’s initialize failure, which names the ingestor and its domain.
Host-owned refusals, such as a missing node resolver, signaling protocol or endpoint, Kafka
DOMAIN offsets this node does not own, or a delivery-mode duration that does not parse, are typed
causes in the same place. Host decoding returns the codec’s report through the ingest group
unchanged and keeps it beneath the intake’s decode failure, which a source loop reports with its
whole chain; the host never renders a codec failure into text before that boundary. A context whose
parser or driver error is its #[source] leaves that error out of its own message, because the
report carries the source as the frame beneath it: a rendered chain names each cause once. A
Syslog max_message_size or addr value that does not parse therefore reads as the key and value
followed by the parser’s error, both at the source’s start and in the Syslog sink’s configuration
diagnostic.
The endpoint source also reports the full ingest-group dispatch or flush chain in its runtime event and log. A refusal to admit unfolding remains retryable and its diagnostic retains the executor cause without treating the payload as decoded or rejected.
Paced-source resume and poll status, source suspend and close events, and cadence failures render the full report supplied by their owner. Kafka partition-watch failures similarly name the ingestor and domain before the full inspection report, both during its initial inspection and subsequent polls. A stopped clock or timestamp arithmetic failure therefore remains visible beneath the cadence context. Rendering keeps the source’s existing readiness, retry and acknowledgement decisions and includes only the non-sensitive details the typed errors own.
For OTEL, each selected row’s conversion report becomes that row’s existing invalid-record
outcome, with its signal key as the affected field. Postgres, MySQL, and ClickHouse inspect the
typed insert error before deciding whether to isolate a rejected row or fail the whole attempt.
The whole-attempt report retains a transport driver or pool cause beneath SinkPublishError; a row
rejection keeps the destination’s safe SQLSTATE, error code, or named rejection reason. These
context changes do not alter request grouping, successful delivery, retries, or ACK ownership.
The host owns ingestor and emitter metric updates, transient status, and runtime events.
Source and sink tasks retain one immutable error/retry publication, so successful operations read
and clear only an actual failure. DESCRIBE observes error and retry together; retry state still
counts toward a drain when the payload buffer is empty. Pooled Redis and SQL sinks retain one wait
slot per sink and publish it only after a connection borrow returns Pending. Its guard clears on
completion, error or cancellation; sink teardown removes its own registration. Confirmation
attempts retain the emitter’s startup counter. Quiesced payload and client outcome metrics use
children resolved with their owning task. See Data-Plane Concurrency
for publication and lifetime rules. Source
open, resume, suspend, and close transitions have lifecycle logs; publish, retry, and commit
failures carry connector identity without sensitive payload values. For metric names and
DESCRIBE fields, use Metrics And Observability. A client
source’s endpoint publishes its producer, outstanding, and window counts after every change and
counts every batch it answers by outcome and cause. The
Ingestors and Emitters manuals document connector-specific
status and delivery output; Shutdown And Recovery owns the
drain boundary. This chapter does not redefine those output formats.
Adding an integration
- Declare each entity it serves in the vocabulary: one Model per entity with its capabilities,
its delivery modes, and a typed name, checked by the entity’s own rule, for each external name
its statement writes. The connector and entity together name the statement’s head, such as
KAFKA TOPIC INGESTOR. Then add the connector’s grammar module, which owns each entity’s body, and its completion. Keep external driver configuration raw only where pass-through is its intentional contract. - Validate schema, reference, branch, header, quiesce, delivery, and external contract rules in the registry. Convert the validated Model into one typed start-plan variant before execution; a source’s variant belongs to the registry’s ingestor planner.
- Add one crate under
crates/connectors/with its ownership header, driver dependencies, and source or sink contract implementation. A brokered sink that awaits its broker’s answers inMODE ACKdoes so through the contract’sConfirmationWindow, never a window of its own. Add its composition mapping in the server; do not teach the contract or registry about the driver. - Add unit coverage for transport behavior and feature scenarios through NSPL, including three-node execution and failure paths. Confirm the crate boundary and run the repository’s validation and connector tests.
- Update the Ingestors or Emitters manual, relevant client and metric documentation, and the NSPL skill routing when its user-facing guidance changes. Regenerate the book.
The connector split was functionally qualified across 46 integration feature files and 359 scenarios, including three-node examples, in Connectors 14’s evidence. That evidence supports the current source and sink behavior; it does not claim throughput parity. Its Kafka A/B measurements found lower bounded-pressure rates and a separate 16-partition commit failure that require owner investigation. An integration must preserve its own transport and delivery semantics rather than assuming a generic envelope or exactly-once external outcome.
HTTP Emitter Architecture
An HTTP emitter turns each eligible relay record into one outbound request. Its method, target,
application headers, and optional codec body are evaluated for that record. Complete, valid final
2xx response headers are the external delivery boundary. The endpoint can still apply a request
whose response Nervix never receives, so delivery is not an exactly-once external effect.
This chapter follows the implemented HTTP path across the language, decision layer, emitter host, and HTTP connector. The Emitters manual owns the public NSPL contract and field-level rules. The Connector Crates And The Connector Contract chapter owns the shared host and connector boundary; Control Plane, Domain Clock, Resource Versions And Bindings, and Shutdown And Recovery remain authoritative for their respective lifecycle, time, binding, and recovery rules. The HTTP emitter acceptance ledger records the public qualification evidence summarized below.
From NSPL to an active sink
The language parses TO HTTP and its ordered METHOD, PATH, MODE ACK RETRY POLICY, and body
selection into semantic Models. The registry validates the complete candidate against the same
domain’s TYPE HTTP client, codec, exact expression types, available scopes, sensitivity, source
branches, and error route. The decision layer lowers the validated model into a typed execution
plan. A running data-plane task receives that plan; it does not read a Model or reparse NSPL.
The server composes the HTTP connector with the emitter host. These conversions and their
installation boundary are described in Execution Plans.
The sink has one request/response mode, with an explicit retry policy and exactly one body choice:
TO HTTP <client>
METHOD <string_expression>
PATH <string_expression>
MODE ACK RETRY POLICY BACKOFF <duration> MAX <duration>
(ENCODE USING <codec> | WITHOUT BODY)
METHOD and PATH are structured expressions, not client settings. HTTP MODE ACK has no ACK
window or ACK TIMEOUT; NO_ACK, parallel publication, and the optional emitter BATCH clause
are unavailable. The emitter still requires a route-local FLUSH policy. COLLECT FOR may gather
input records before execution, while FLUSH governs when prepared work is released. Neither
merges several records into one HTTP body. The TYPE HTTP client’s polling method setting has
no effect on the emitter’s required method expression.
A request with a codec body
Here outgoing and event_body_codec are already declared in the domain. The codec maps an
event_body schema with event_id and payload into one JSON object. The example uses an
operator-provisioned destination; an input with method PATCH and path
/v1/events/42?notify=true sends that method and target. Its tenant stays outside the body.
CREATE CLIENT api TYPE HTTP CONFIG {
'endpoint' = 'https://api.example.com',
'timeout_ms' = 5000
};
CREATE ATTACHED EMITTER deliver_event
FROM outgoing
TO HTTP api
METHOD input.request_method
PATH input.request_path
MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
ENCODE USING event_body_codec
INHERIT event_id, payload
INVOKE write_header('Content-Type', 'application/json'),
write_header('X-Tenant', input.tenant),
write_header('X-Body-Event', output.event_id),
write_header('Idempotency-Key', input.event_id)
FLUSH EACH 100ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
The client endpoint is an origin only: http:// or https://, a host, optional port, and no
credentials, non-root path, query, or fragment. PATH supplies the origin-relative target and
query. The client requires a positive, schedulable timeout_ms for every attempt. Creating or
starting the emitter makes no destination probe and provisions nothing at the endpoint. See the
complete public example for the schema, wire schema,
codec, and relay declarations.
A request without a body
This alternative uses the same relay and client. WITHOUT BODY uses the source record for
input, message, and bare fields. There is no output schema, encoded input copy, or dummy body.
It allows route WHERE and header invocations, but not INHERIT, SET, or VALUES.
CREATE EMITTER delete_event
FROM outgoing WHERE input.request_method = 'DELETE'
TO HTTP api
METHOD 'DELETE'
PATH input.request_path
MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
WITHOUT BODY
INVOKE write_header('Idempotency-Key', input.event_id)
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
The receiver gets zero content octets. HTTP framing may omit Content-Length for this request;
the transport supplies it when a body is present. GET and HEAD, in any ASCII case, require
WITHOUT BODY, including when the method is computed at runtime. CONNECT and TRACE are
unavailable in any ASCII case. Other valid methods retain their evaluated spelling.
Scopes, order, and sensitivity
Method, path, header-name, and header-value expressions must have exact, statically non-null
STRING type. There is no implicit cast or null omission. A codec route can read original
input, finalized output, the working message at its finalized-output stage, and declared
materialized state. A bodyless route has input and source-valued message, but no output or
partial_output schema. Bare fields follow message. Branch fields, relay-name qualifiers,
and source-envelope header reads are unavailable. A route may consume multiple source relays
with the same payload schema, keeping each relay and concrete branch’s collection separate.
Materialized dependencies and message-error targets must be compatible with every source’s exact
branch declaration.
For an admitted batch, the host resolves materialized dependencies and takes one domain execution snapshot. Each eligible record then passes through this sequence:
- Evaluate its source predicate.
- Construct and finalize the codec record, when there is a codec.
- Evaluate route
WHERE. A filtered record evaluates no request field and sends nothing. - Evaluate and validate
METHOD, thenPATH. - Evaluate
write_headerinvocations in written order. - Release the record on its declared
FLUSHboundary. - Encode the finalized codec record, if present, and send the prepared request.
All these expressions use the batch’s execution and materialized-state snapshots. Preparation or
encoding failure sends no part of that record’s request. An external sensitive value, whether in
the method, target, header name or value, or codec body, requires explicit leak_sensitive(...).
Headers read by an ingestor do not propagate through a relay unless copied into schema-backed
fields. Expression Functions owns the public function contracts;
Errors And Diagnostics owns the safe failure representation.
Origin, TLS, and resource versions
The connector uses the node’s configured resolver, verifies HTTPS trust and hostname, and can load a CA and a paired client certificate and key from the HTTP client’s resource mount. A client can, for example, bind already uploaded TLS files as follows:
CREATE CLIENT secure_api TYPE HTTP
MOUNT api_tls VERSION 3
CONFIG {
'endpoint' = 'https://api.example.com',
'timeout_ms' = 5000,
'tls_ca_file' = '{{ api_tls }}/ca.pem',
'tls_cert_file' = '{{ api_tls }}/client.pem',
'tls_key_file' = '{{ api_tls }}/client.key'
};
VERSION LATEST is also accepted at creation, but resolves to a completed number when the
statement applies. The stored binding and runtime plan keep that number across restart and
relocation. Uploading a newer version does not move the client. A client-definition change or
resource rebind follows the normal configuration-entity domain pause; changing only the
emitter’s client reference takes an entity pause. Validation checks the origin, timeout, and
certificate/key pairing before activation, without contacting the endpoint. The
Resources manual gives the upload and mount syntax; the
resource-version chapter owns pinning and
rebind behavior.
Request preparation and transport ownership
The emitter host owns intake from Arrow batches, per-source and per-branch collection, route
programs, the original source row reference, the captured materialized state, flush cadence,
prepared request retention, source membership, ACK leases, backpressure, retry scheduling,
metrics, and drain. It projects existing Arrow columns into the codec and request-field programs;
it does not create a row-map payload. The HTTP connector receives a typed, already prepared
request containing the validated method, normalized target, application headers, and either exact
codec bytes or no body. It owns HTTP connection and TLS handling, transport-generated fields,
response parsing, Retry-After interpretation, and per-request outcomes. It sees neither graph
branches nor upstream ACKs. The connector chapter’s HTTP sink boundary
defines the shared contract and host loop in detail.
One HTTP request is prepared for each eligible source record, even when COLLECT FOR or FLUSH
groups records. The host stores the request as a prepared payload with one source member, along
with the original record and admitted state snapshot. The method, target, headers, and encoded
bytes are frozen for local retries. Nondeterministic expressions and codecs do not run again.
Retained encoded bodies count toward node memory pressure; FLUSH MAX BATCH SIZE measures
logical Arrow bytes and is not an encoded HTTP body limit.
The connector sends prepared requests sequentially, with at most one awaiting final response headers per active emitter execution across its served relays and branches. Requests released by one flush preserve their publication order, and unresolved work stays ahead of later work. There is no total order across independent executions, relays, or branches, nor an ordering promise for effects an endpoint applies after a connection is lost. Each attempt uses a fresh HTTP/1.1 connection, closed after final headers, so an unread response body cannot become the next response. The client’s physical timeout covers DNS, connect, TLS, send, interim heads, and the complete final head; queue time and backoff do not consume the next attempt’s timeout.
The transport generates Host, Connection: close, and Content-Length for a present body. It
adds Accept: */* only if the application did not write Accept. It adds no Accept-Encoding or
Content-Type. It does not store response cookies, issue a second request for an authentication
challenge, follow a redirect, or retry independently of the host. The codec’s bytes are the entire
body: there is no added wrapper, newline, form encoding, or compression. Writing
Content-Encoding describes bytes the codec already produced and does not transform them.
Validation and supported bounds
The registry rejects statically known invalid literals when the graph is validated. Dynamic
values undergo the same checks per record before publication. A later write_header call replaces
an earlier value for the same case-insensitive name, but an invalid or reserved earlier write
still rejects the record. An empty header value is sent as empty, and a nonempty value cannot have
leading or trailing space or horizontal tab. UTF-8 non-ASCII values are allowed. Header values
cannot contain CR, LF, NUL, DEL, or other disallowed ASCII controls.
| Boundary | Supported limit or rule | Failure |
|---|---|---|
| Method | Nonempty ASCII HTTP token, at most 64 bytes; CONNECT and TRACE excluded; GET and HEAD bodyless only | Literal: candidate rejected; dynamic: message error before send |
| Target | Origin-relative, exactly one leading /; normalized path and query at most 8 KiB encoded | Literal: candidate rejected; dynamic: message error before send |
| Application headers | At most 128 after replacement and 32 KiB of UTF-8 name and value bytes in total; each invocation also has the 32 KiB bound | Invalid literal: candidate rejected; dynamic violation: message error before send |
| Response headers | At most 128 fields and 64 KiB of field-name and value bytes in each interim or final block | Attempt remains unresolved and retries |
| Attempt | One request awaiting final headers per execution; positive, schedulable client timeout_ms | Attempt failure retains work; timeout is physical |
| Body | No extra HTTP-specific fixed encoded-body size bound | Encoded bytes remain under node memory pressure until resolved; endpoint 413 rejects the record |
The target is parsed relative to the client’s origin and cannot change its scheme, host, or port.
Absolute URLs, // authority targets, fragments, backslashes, whitespace, controls, *, malformed
percent escapes, and targets normalized to a leading // fail before sending. URL normalization
percent-encodes non-ASCII text and removes dot segments, while preserving %2F, repeated query
keys, their order, a literal +, and an empty query after a trailing ?. For example,
/v1/a/../events?tag=a&tag=b&q=a+b becomes /v1/events?tag=a&tag=b&q=a+b.
The HTTP specification’s target table
shows the boundary cases.
The transport owns Host, HTTP/2 pseudoheaders, Content-Length, Transfer-Encoding,
Connection, Keep-Alive, Proxy-Connection, TE, Trailer, Upgrade, and Expect.
write_header cannot set them. Authorization, Content-Type, Content-Encoding, Accept,
Cookie, and application headers are writable. Headers are compared without ASCII case; their
wire casing and order are not guaranteed.
Outcomes, retry, and acknowledgements
The connector classifies a complete valid final head. Interim heads never acknowledge a record.
Response bodies and trailers do not enter graph data or decide delivery, and the connector does
not wait for or buffer a whole response body. Invalid final framing fails the attempt even if
the status line is 200.
| Final response or exchange | Result |
|---|---|
200–299, including 202 and 204 | Deliver exactly this source record when final headers are complete. 202 does not promise later endpoint processing. |
408, 425, 429, or 500–599 | Retain the request and all later work; retry after the host’s wait. |
401, 403, or 407 | Retain and retry, reporting an authentication or authorization infrastructure cause. |
Other 300–499, or 101 | Reject only that record through ON MESSAGE ERROR, then continue after the policy completes. Redirect Location is never followed; 304 and 409 are not delivery. |
| DNS, connection, TLS, send, timeout, malformed response, oversized head, or loss before complete final headers | Retain and retry as an infrastructure failure. |
A delivered record leaves the pending set and is never included in a later local retry. A definitively rejected record follows its route’s message error policy and likewise leaves the pending set. Only unresolved prepared requests are sent again. A transient failure holds the current request and later records behind it, across the execution’s relays and branches. The HTTP connector keeps its client after a failed publish, allowing the host to retain the reported failure while the next attempt is in flight.
The first physical backoff is BACKOFF, doubles on repeated failures up to MAX, and resets
after a flush completes. There is no attempt cap. On a retryable final status, exactly one valid
Retry-After can extend the next wait: ASCII whole seconds, or an IMF-fixdate, RFC 850, or
asctime HTTP date compared with actual UTC when received. A past date contributes zero. The host
waits for the larger of this delay and its backoff, even if the server delay exceeds MAX.
Missing, repeated, malformed, or unrepresentable values (including a delay ending after the
supported year 2262 timestamp range) add no delay, and an interim head or a
terminal response cannot change this classification. The connector’s
physical-time boundary and Domain Clock distinguish
actual UTC and monotonic waits from domain time. TIME RATE scales COLLECT FOR and FLUSH EACH
and the expression snapshot; it never scales HTTP timeouts, backoff, or Retry-After.
FLUSH IMMEDIATE uses the existing physical minimum batching window.
ATTACHED keeps each unresolved record’s upstream ACK lease until HTTP delivery or its message
error policy resolves it. DETACHED acknowledges the source at relay fan-out, but still confirms
HTTP responses, retries unresolved requests, applies backpressure, and routes terminal errors.
Source recovery depends on that source’s own acknowledged-delivery contract. A lost final response
may mean the endpoint already applied the request; retrying it can duplicate the external effect.
Nervix supplies no idempotency key. The application can send a stable event identifier, as the
Idempotency-Key invocation above does, and arrange endpoint-side deduplication. A generated
value is stable during a local retry because preparation is retained; source replay after a
restart may evaluate it again. ACK Semantics And Effective Delivery
defines the wider source/sink combinations.
Errors and inspection
A request-field error rejects its record at the first failed field. Failed expressions retain
code evaluation; invalid method, target, or header values use validation. Method and path
errors use operation publish and name method or path. A header failure uses invoke and its
zero-based invocation index. Codec failures use encode. A terminal response uses code
external, operation publish, and its numeric status. These errors identify the emitter and
safe operation or field without quoting the evaluated method, target, header value, credential,
request body, or response body. A message-error route sees the original eligible input and the
captured materialized-state snapshot; a codec route also offers the attempted record as an
all-optional partial_output. The error stays in its source branch. A bodyless route has no
partial_output. Errors And Diagnostics
owns the typed propagation and public redaction contract.
SHOW CREATE EMITTER and formatting preserve the request expressions, ordered header
invocations, body selection, and retry/flush policies. DESCRIBE EMITTER shows the client,
configured method and path, body: codec or body: without body, batch: none, publishing mode,
flush policy, and transient error. The failure stays visible while a request remains pending,
including during its retry attempt, and clears when it is delivered. It carries a status or safe
transport cause and the retry wait, never request values. Normal retryable failures remain pending
regardless of ON GENERAL ERROR; that policy cannot turn them into delivery success.
The emitter’s messages_total sent count increases once per delivered source record, not per
attempt or terminal rejection. Its bytes_total sent count uses the ordinary logical Arrow
payload accounting for a codec record. A bodyless delivery adds zero output payload bytes.
Method, target, headers, and response bytes are not payload bytes or metric-label values. See
HTTP inspection and metrics for the public rendering.
Replacement, drain, and recovery
Changing the sink’s method, path, client reference, body selection, or retry mode takes an
ENTITY_PAUSE; a FLUSH-only change is DYNAMIC. Changing source membership or a client
definition has its existing DOMAIN_PAUSE contract. ALTER EMITTER ... SET TO HTTP restates the
complete method, path, mode, and body selection. SET CLIENT, SET MODE, and SET ENCODE USING
retain their current meanings. DROP ENCODE cannot select no body; use SET TO ... WITHOUT BODY.
The whole retained construction and error scope must validate against that selection. For
example, an emitter with INHERIT cannot switch to WITHOUT BODY while retaining that clause.
Changing construction or header invocations requires a complete DROP and CREATE replacement
inside one transaction. A bad candidate or refused commit leaves the active emitter in place.
For example, this replacement changes the first emitter’s method and path without replacing its codec construction or header invocations:
ALTER EMITTER deliver_event
SET TO HTTP api
METHOD 'POST'
PATH '/v1/events'
MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
ENCODE USING event_body_codec;
SHOW CREATE EMITTER deliver_event;
DESCRIBE EMITTER deliver_event;
The retained INHERIT still initializes the codec body. A transition to WITHOUT BODY would
require a replacement that also removes INHERIT, so the operator uses DROP and CREATE in one
transaction for that change. For a destination that cannot drain, the supported operational path
is STOP;, change the emitter while the domain is stopped, then START;, after deciding how the
source will replay unresolved work.
An entity pause gates intake and drains already admitted requests under their original client,
resource binding, destination, and prepared bytes. A failed drain leaves the mutation unapplied;
it does not move pending requests to a new endpoint. DESCRIBE TRANSACTION reports the required
scope and actual engagement under Transaction Quiescence And Impact Inspection.
When a destination cannot be repaired in place, an operator can stop the domain, alter the
stopped emitter, and start the domain again. That path discards volatile prepared attempts and
relies on the source’s replay contract; it can repeat an effect the destination already applied.
The Control Plane chapter owns mutation and drain decisions.
Graceful node shutdown stops intake, force-flushes buffered HTTP work, and waits for eligible requests only within the existing physical drain deadline. Expiry or forced ending cancels an in-flight attempt without marking it delivered. Prepared method, target, headers, body, retry position, and ACK state are volatile. An attached acknowledged source can redeliver after restart; an unacknowledged source cannot recover a record it has lost. The Shutdown And Recovery chapter owns stop phases, the former-owner fence, and the source recovery boundary; this chapter adds no alternate shutdown protocol.
Qualification and practical limits
The completed 14-criterion matrix names each public scenario and the receiver observation that closed it. Every HTTP feature case ran on one-node and three-node clusters. Each row below identifies its primary public evidence; the ledger gives the exact scenario names, example counts, and receiver controls.
| Criterion | Primary evidence | Observed result |
|---|---|---|
| 1. Constant and computed requests | http_emitter.feature | Two records send distinct methods, targets, headers and exact codec bytes; a constant request and an encoding rejection are observed. |
| 2. Absent body and method rule | http_emitter.feature | Bodyless DELETE, GET and HEAD send zero content; literal and computed GET/HEAD with a codec are refused. |
| 3. Scopes and filtering | http_emitter.feature | input and finalized output produce different captured values; a filtered record evaluates no failing request field. |
| 4. Configuration and sensitivity | http_emitter.feature | 22 invalid statements are refused; an explicitly leaked method, path, header and body reach the receiver. |
| 5. Header replacement and bounds | http_emitter.feature | Case-insensitive replacement, empty and UTF-8 values succeed; 22 invalid, reserved or oversized writes reject without sending. |
| 6. Target normalization and bounds | http_emitter.feature | The specified path table, non-ASCII query and repeated keys match; ten unsafe or oversized targets reject before send. |
| 7. Response-head success | http_emitter_responses.feature | 200, 202 and 204 deliver; stalled and 32 MiB bodies are abandoned; malformed or excessive interim and final heads retry. |
| 8. Stable retry membership | http_emitter_retries.feature, http_emitter.feature | The delivered first record is not resent; the failed second request repeats byte for byte, including a generated header and body. |
| 9. Infrastructure retry and timing | http_emitter_responses.feature, http_emitter_transport.feature, http_emitter_retries.feature, http_emitter_inspection.feature | Every retryable status and connection cause remains inspectable while pending; measured physical backoff and Retry-After survive domain acceleration. |
| 10. Terminal rejection | http_emitter_responses.feature, http_emitter_retries.feature | 101, redirects, 400, 404, 409 and 413 route safe status errors with original input, state and branch; Location receives no request. |
| 11. TLS and mTLS | http_emitter_transport.feature | Mounted client identity succeeds; untrusted, wrong-host and missing-certificate handshakes acknowledge nothing. |
| 12. Relay and branch isolation | http_emitter.feature, http_emitter_transport.feature | Two relays and two branches retain their own request values and error branches behind collection. |
| 13. Inspection and replacement | http_emitter_lifecycle.feature, http_emitter.feature, tools/nspl_format.feature | Both body modes round-trip; failed ALTER and COMMIT preserve the active emitter; admitted requests drain to their original destination. |
| 14. Drain and replay | http_emitter_lifecycle.feature, http_emitter_retries.feature | Graceful shutdown force-flushes; forced ending leaves a Kafka offset for replay; a lost response permits the same effect twice. |
The receiver’s high-water mark stayed at one request awaiting a final head for one emitter serving two relays and two branches; a separate regression observed two for two concurrent clients. A stalled or 32 MiB response body did not delay the next record, and the receiver observed abandoned connections after timeout, stop, and forced drain. Shuttle checks covered cancelled attempts and unresolved membership. Attached Kafka offsets remained uncommitted while a request was pending. The emitter publishes no per-emitter memory figure, so this evidence does not establish a fixed per-emitter memory ceiling; retained encoded bodies are governed by node memory pressure.
After the receiver fixture update, all seven HTTP features ran together with 180 scenarios and 2,870 steps passing without a retry. The affected HTTP polling, shared emitter, and shutdown suites passed another 168 scenarios and 1,764 steps. The ledger records the exact commands, focused unit and Shuttle results, documentation build, validation, and an earlier saturated cluster run whose three-node setup commands met leadership changes. These results qualify the implemented behavior and its stated limits; the ledger remains the detailed evidence record.
Client Ingestors And Emitters
Authenticated applications participate in a Nervix graph through persisted, domain-owned
CLIENT PRODUCER ingestors and TO CLIENT emitters. Producers submit native Arrow batches;
consumers receive constructed Arrow output and explicitly acknowledge, retry, or reject each
delivery attempt. An attached client-to-client path completes its input only after the application
ACK. Endpoint configuration survives a restart. Payloads, attachments, delivery attempts and ACK
state are volatile, so applications own replayable input and idempotent effects.
The native endpoints, automatic restoration, shared C binding, and Rust/Python paced drivers are implemented. This chapter connects their delivered contracts. The following chapters remain the owners of their respective rules:
| Contract | Authoritative documentation |
|---|---|
| Source syntax, admission and source outcomes | Ingestors |
| Sink syntax, construction, batching and graph ACK boundary | Emitters |
| Public attachments, subscriptions and clock observation | Sessions |
| Rust handles and shared binding | Rust Client Library and the Client Implementation Manual |
| FlatBuffers, transport, correlation, cancellation and restoration | Client Session Protocol and the Client Implementation Manual |
| Model validation, typed revision construction and installation | Execution Plans |
| Native source/sink composition and the common emitter host | Connector Contract |
| Authenticated forwarding and cross-node failures | Cluster Interconnect |
| Failure classification and safe diagnostic content | Errors And Diagnostics |
| Generation, execution snapshots, timestamp admission and deadlines | Domain Clock |
| Mutable owners, publication, credit and attempt synchronization | Data-Plane Concurrency |
| Typed identities, absence and validation boundaries | Typed States |
| ALTER scopes, actual engagement, rollback and inspection | Transaction Quiescence |
| Planned drain, node shutdown and restart admission | Shutdown And Recovery |
| Runnable application and its command-line options | Paced Simulation Drivers |
Ownership And Routing
The ingestor or emitter name is the attachment target. An application open neither creates a graph
node nor adds a graph edge. CREATE CLIENT ... TYPE ... describes an external connector connection;
native endpoints instead name an internal schema directly and use the existing session listeners.
Starting a domain needs no connected application. With no consumer, an emitter retains bounded
pending work and applies graph backpressure.
The language parses NSPL once into structured Models. The registry validates domain references, exact schemas, sensitivity, branch declarations, construction, policies and capabilities. Pure decisions lower those validated inputs into native source and sink plans in one execution revision. The data plane executes plans without reading Models or reparsing executable NSPL.
| Layer | Responsibility on this path |
|---|---|
| Vocabulary | Schemas, domain generations, endpoint contracts, typed attachment/delivery identities, limits and outcomes |
| Language | CLIENT PRODUCER INGESTOR and TO CLIENT grammar, semantic Models, canonical rendering and composed completion |
| Decisions | Validate the candidate graph and produce typed source, route, sink and message-error plans |
| Data plane | One ingestor endpoint/admission owner; concrete branch route tasks; relay ownership; emitter host and competing-consumer attempt owner |
| Control plane | Commit configuration, install revisions, establish generations, coordinate scoped holds and ownership handoff |
| Edges | Authenticate sessions, reserve attachment capacity, correlate requests, route control/data and forward to the scheduled owner |
| Application client | Retain desired handles and pending results, process output concurrently, decide replay and maintain application identities |
A native gRPC session may enter through any live node. That serving node routes each open to the endpoint’s scheduled execution; producer, relay and emitter owners may all be different nodes. The console’s binary WebSocket uses the same endpoint operations and FlatBuffers contract, but console sessions are served by the leader. Public endpoint scenarios exercise both transports. The Rust library and its shared C binding connect through native gRPC; the console’s ordinary inspection flow does not open a consumer or claim output.
flowchart LR
App[Application producer and consumer] <--> Session[Serving node session]
Session <--> Ingestor[Scheduled ingestor owner]
Ingestor --> Relay[Scheduled relay owner]
Relay --> Emitter[Scheduled emitter owner]
Emitter <--> Session
Client graph nodes bind no additional port. Session listeners belong to every live node and remain independent of placement and leadership. Native payloads and their ACKs follow the data plane; administrative commands retain their leader routing. A healthy native attachment continues through a leader change that leaves its execution owner and session intact. A console leader change ends the WebSocket session and is a session gap.
Concrete Branches And Forwarding
Each ingestor has one scheduled execution and a shared source ACK window. Its routes construct outgoing branch keys. A producer cannot choose a relay, inject a branch key as transport metadata, or create another execution by connecting again. Route buffering and downstream execution remain local to each concrete branch. Unbranched work has an absent branch key.
An emitter consumes all its configured source branches. Collection, flush, prepared payloads and ACK windows remain separate for each source relay and concrete branch. Equal keys from different source relays never merge. Consumers receive the constructed schema plus a source-relay name, optional opaque branch fingerprint, member count, execution snapshot, delivery identity and attempt reference. The fingerprint exposes no branch-key values. Copy a required branch value into a schema field earlier in the graph and export that field explicitly.
Remote producers use a shared, authenticated relay-class duplex link per serving/owning node pair.
Before admitting a forwarded batch, the owner takes a source-window slot and sends Admitting.
The serving node records possible admission and answers Clear; only then may the owner dispatch
the batch. This costs one additional link round trip. If the owner or link is lost, the serving
node can truthfully report batches it never cleared as NotAdmitted(ProducerEnded). Cleared,
unresolved batches become OutcomeUnknown(OwnerLost). Losing the serving node instead leaves
every sent batch without an outcome uncertain to the application as SessionLost.
A remote consumer has its own authenticated relay-class duplex stream. The owner sends attempt metadata and IPC bytes in ordered chunks of at most 1 MiB; the serving node checks the announced length and reassembles one delivery before answering the application read. Settlement and heartbeat writes proceed beside response reads. Both forwarding forms use two-second idle heartbeats and a ten-second peer-silence limit. The Interconnect owns setup budgets, peer identity, quotas and link-ending semantics.
Configure A Native Graph
This complete graph accepts application events, constructs a branch per tenant and exports the
declared output. It requires a running Nervix cluster and authenticated session credentials;
there is no external broker, queue, codec, resource upload or connector client to provision.
The private_note value is deliberately exported through explicit leakage.
Save the block as application.nspl. The model definitions form one transaction. The CLI commands
below create the domain, select it with --domain, load the graph, then start it in separate phases.
BEGIN;
CREATE SCHEMA application_event (
event_id STRING,
tenant STRING,
value I64,
private_note STRING SENSITIVE
);
CREATE SCHEMA application_output (
event_id STRING,
tenant STRING,
value I64,
private_note STRING
);
CREATE SCHEMA tenant_key (tenant STRING);
CREATE BRANCH by_tenant SCHEMA tenant_key TTL 10m;
CREATE RELAY application_events
SCHEMA application_event BRANCHED BY by_tenant CAPACITY 16;
CREATE CLIENT PRODUCER INGESTOR submit_events
FROM SCHEMA application_event
MODE ACK PARALLEL MAX 8 ACK TIMEOUT 30s
RETRY POLICY BACKOFF 100ms MAX 2s
ON QUIESCE SUSPEND
TIMESTAMP NOW
TO application_events
INHERIT ALL
BRANCHED BY by_tenant SET tenant = input.tenant
FLUSH EACH 10ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
CREATE ATTACHED EMITTER receive_events
FROM application_events
TO CLIENT SCHEMA application_output
MODE ACK PARALLEL MAX 4 ACK TIMEOUT 30s
RETRY POLICY BACKOFF 100ms MAX 2s
INHERIT event_id, tenant, value
SET private_note = leak_sensitive(input.private_note)
BATCH MAX MESSAGES 256 MAX SIZE 256KiB
FLUSH EACH 10ms MAX BATCH SIZE 1MiB
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
COMMIT;
Load it through the CLI, using the cluster’s actual credentials and server address when they differ from these examples:
nervix-cli --server http://127.0.0.1:47391 --username default \
--password "$NERVIX_PASSWORD" --command "CREATE UNPACED DOMAIN application;"
nervix-cli --domain application --command "$(cat application.nspl)"
nervix-cli --domain application --command "START;"
nervix-cli --domain application --command "SHOW CREATE INGESTOR submit_events;"
nervix-cli --domain application --command "DESCRIBE EMITTER receive_events;"
Open application.receive_events with exactly application_output’s fields, start its read/process/
ACK loop, then open application.submit_events with exactly application_event’s fields and submit
Arrow batches. Request, for example, eight batches and 1 MiB of credit for each handle. A single
application runs the consumer loop concurrently with producer outcome waits. Waiting for attached
producer completion before consuming its output waits on the application’s own ACK.
The library examples show the operations; the
paced drivers below supply complete executable programs.
sequenceDiagram
participant P as Producer
participant I as Ingestor
participant R as Relay
participant E as Attached emitter
participant C as Consumer
P->>I: Submit one Arrow batch
I->>R: Construct and route its rows
R->>E: Deliver each source/branch batch
E->>C: Output plus current attempt reference
C->>C: Apply idempotent application effect
C->>E: ACK current attempt
E-->>C: Confirmed
E-->>R: Resolve attached ACK share
R-->>I: Resolve source root when all shares finish
I-->>P: Completed
Construction, State And Error Policies
The source schema describes input before construction; the sink schema describes output after
construction. Transforming routes start empty, use permitted INHERIT and ordered SET, and
initialize every required output. Optional fields finalize as typed nulls. Filters and route
predicates retain their ordinary scopes. Client sources have no header or connector-metadata
scope; client sinks have no codec, header invocation or direct VALUES mode.
Sensitivity is part of the public schema contract. Exporting a sensitive value requires explicit
leak_sensitive(...) or permitted inheritance with LEAK SENSITIVE, even if the declared output
field is sensitive. Client output carries the deliberately exported value; relay-subscription
masking belongs to the observation interface. Expression Functions
owns conversion and leakage semantics.
Ingestors cannot read materialized state. Emitters can declare ordered, node-wide dependencies
after their source list and before TO. Each dependency names a materialized relay in the same
domain with the exact branch declaration of every source. For example, the following additional
configuration runs in the application graph above, selected with --domain application. Its
typed default works before a branch has materialized state; another source may later populate
tenant_limits.
BEGIN;
CREATE SCHEMA tenant_limit (maximum I64);
CREATE RELAY tenant_limits
SCHEMA tenant_limit BRANCHED BY by_tenant
WITH MATERIALIZED STATE LAST BY TIMESTAMP;
CREATE ATTACHED EMITTER receive_bounded_events
FROM application_events
USING MATERIALIZED STATE tenant_limits DEFAULT { maximum = 1000 }
TO CLIENT SCHEMA application_output
MODE ACK SEQUENTIAL ACK TIMEOUT 30s
RETRY POLICY BACKOFF 100ms MAX 2s
INHERIT event_id, tenant, value
SET private_note = leak_sensitive(input.private_note)
WHERE input.value <= relay_state.tenant_limits.maximum
BATCH MAX MESSAGES 256 MAX SIZE 256KiB
FLUSH IMMEDIATE
ON MESSAGE ERROR LOG
ON GENERAL ERROR LOG;
COMMIT;
Open a consumer of receive_bounded_events before sending more input: this second attached
emitter is an independent fan-out share. DEFAULT binds a complete typed constant record when
state is absent. REQUIRED SKIP resolves missing-state input successfully; REQUIRED WAIT retains
it in memory, holds its ACK and applies backpressure. Dependencies resolve in written order and
restart from the first declaration after a wait. They never scan other branches. Whole-branch
eviction ends its suspended work. Runtime Nodes and
Relay own the complete state contract.
Every client emitter requires both BATCH MAX MESSAGES <1..65536> MAX SIZE <bytes> and an explicit
FLUSH. BATCH bounds the encoded IPC stream; FLUSH ... MAX BATCH SIZE bounds logical Arrow
value, offset and validity bytes, excluding spare buffer capacity. FLUSH EACH is branch-local
domain time. FLUSH IMMEDIATE is the physical 100 microsecond minimum batching window, with the
normal forced-flush exceptions; it is not a guarantee of one-row output. Optional input collection
has its own per-source/per-branch logical cadence. Each ingestor route also requires FLUSH and
ON MESSAGE ERROR; general errors remain node-wide.
A malformed native input is refused as a whole before any row enters the graph. After admission,
row errors follow their route policy and do not make the batch an atomic transaction. On an
ingestor route, IGNORE acknowledges the failed row, LOG negatively acknowledges it, and
SEND TO acknowledges it once its constructed error record is published to the error relay.
An attached error emitter is therefore outside that source ACK chain. Error records use the
ordinary typed code, operation, affected fields, reference, occurrence time and non-sensitive
message. Ingestor error targets are unbranched; other error targets preserve the failed execution’s
exact branch. Error construction never recursively invokes the same policy.
Native Batches And Public APIs
A producer payload is one canonical Arrow IPC stream: schema message, exactly one record batch
and end-of-stream marker, uncompressed, with no dictionary or extension encoding and no field
metadata. Every column is present, in declared order, with Nervix’s exact Arrow representation,
nullability and valid array lengths, nesting and buffer ranges. The node scans the stream before
Arrow’s reader reads any of it: its framing, that its schema declares only field types Nervix
carries, and that its record batch declares the field nodes and buffers that schema’s fields take,
every buffer inside the message body. A field of another type, such as a dictionary-encoded or a
view column, is refused as a schema mismatch, and a record batch at odds with its own schema as
invalid data. Sensitivity flags are compared
through the opened field description rather than invented Arrow metadata. A one-row payload is
still a batch. There is no inference, coercion, numeric widening, timestamp parsing or partial
admission to make a mismatched stream fit.
The output is likewise one canonical stream of the announced output schema. The client checks its row count against the delivery’s member count. DATETIME uses the existing Unix-nanosecond Arrow representation; strings, bytes and lists retain their exact representations and typed nulls. Read the schema mapping and manual rules P-3 and E-3 before writing IPC with another language’s Arrow tools. The generic wire crate carries opaque IPC bytes and does not require Arrow for an observation-only client.
Rust Handles
Opens name an explicit domain, endpoint, exact ordered SchemaField list and positive
ClientProducerLimits or ClientConsumerLimits. They run outside transactions, validate committed
running execution and reserve usable credit before success. The description includes schema,
START generation, endpoint contract, attachment, policies, credit and batch limits. A later USE
does not retarget a handle. Typed refusals leave no attachment or reservation.
| Rust operation | Meaning and ownership |
|---|---|
Client::open_ingestor | Open a producer of a committed client ingestor |
Producer::batch / ProducerBatch::from_record_batch | Validate and write canonical IPC with the optional arrow feature |
ProducerBatch::from_arrow_ipc | Retain a stream written by application Arrow tooling |
Producer::send | Submit and wait for a terminal source outcome |
Producer::submit / Producer::rejoin | Retain a submission, then join its outcome without resending |
pending_submissions / release | Inspect retained identities/results or release interest; unresolved credit remains owned until its outcome resolves |
Client::subscribe_emitter | Open one competing worker of an emitter |
EmitterConsumer::next_batch | Read one attempt; abandoned reads remain retrievable by the next read of that attachment |
EmitterDelivery::record_batch | Decode native output with the optional arrow feature; decoding is not ACK |
EmitterDelivery::decode_record_batch | Decode retained delivery bytes under an emitter’s declared fields and a row count, as record_batch does for a live delivery |
ack / retry / reject | Settle the attempt and inspect the typed confirmation |
Handle description / connection | Inspect the pinned contract and active/interrupted/restoring/reopen-required/closed state |
Producer admission / end | Inspect the newest admission state and terminal or explicit-reopen reason |
Handle close / drop | Fence restoration and release the attachment; dropping unfinished output does not acknowledge it |
The client exchange keeps reading transport frames while the application waits for credit or
performs work. Cancellation of send or rejoin leaves bounded pending submission state
inspectable. Rejoining retrieves the original result. Closing proceeds from its own task even if
the caller stops waiting. The Rust Client Library owns
the supported signatures, errors and examples.
Shared C Binding And Buffer Lifetimes
C, C++, Python, Java and Ruby hosts use the existing Rust binding through
nervix_client.h.
It forwards to the same Rust handles and recovery owner. Its blocking calls belong on application
threads, never a thread driving a Tokio runtime. Native data access is bulk column access or
canonical IPC, without per-value language callbacks or row-map payloads.
| Binding operation | Buffer and reference contract |
|---|---|
nx_fields_add / nx_fields_element | Describe every field and list level, nullability and sensitivity; opens copy the description |
nx_session_open_ingestor / nx_session_subscribe_emitter | Open with explicit domain, fields and credit; read a typed refusal through nx_error_open_refusal |
nx_batch_builder_states, offsets, fixed, varlen | Copy supplied column buffers before returning; the host may immediately reuse or free them |
nx_producer_submit / nx_producer_submit_ipc | Retain immutable IPC under a returned submission identity; IPC submission copies the host’s bytes |
nx_producer_pending / rejoin / release | List identities, take a terminal outcome and return credit, or release interest without cancelling effects |
nx_consumer_next | Return one retained nx_delivery; cancelled/expired reads remain with the consumer |
nx_delivery_ipc / nx_delivery_batch | Borrow IPC without copying; decode/cache the batch on first access and return a batch reference |
nx_batch_states, offsets, fixed, varlen | Read column levels in bulk; strings and bytes use pointer plus length and may contain NUL |
nx_delivery_ack / retry / reject | Settle through the delivering attachment and await the server’s settlement result |
| Delivery/batch retain and release | Keep borrowed views alive; releasing storage never settles a delivery |
nx_producer_close, nx_consumer_close, handle free | End attachments and fence late restoration; an abandoned close continues cleanup |
A field list or builder is used from one thread at a time. Producers, consumers, deliveries and batches support the header’s concurrent-use contract; delivery and batch references may be released on any thread. Each retained reference must be released exactly once. No borrowed pointer outlives the reference it was read from. Producer, consumer and delivery handles keep their session alive until released; retaining bytes does not retain the right to ACK an ended attempt.
A cancelled or expired C submission wait before the producer holds the batch submits nothing.
After an identity was returned, cancelled/expired nx_producer_rejoin keeps that submission and
its result with the producer: join it again. A cancelled or expired settlement may already have
settled the attempt. Repeating it reads its retained result or a stale reference; it cannot undo
the application’s effect. Manual B-12 through B-16
states these requirements, and the
conformance ledger
records the host probes and their limits.
Completion, Attempts And Replay
One submitted batch owns one ordinary source acknowledgement root. It receives exactly one terminal outcome:
| Outcome | Established fact | Application action |
|---|---|---|
NotAdmitted | No row of this attempt entered the graph | Only Suspended and Busy retry automatically on declared physical backoff; other causes require an application decision |
Completed | Its source root resolved successfully under the configured graph policies | Retire input according to those policies and the application’s durability requirements |
ProcessingFailed | Admitted ACK work failed as rejected or timed out; some effects may have happened | Retain input and reconcile or deliberately replay with idempotent effects |
OutcomeUnknown | A terminal processing result cannot be established after possible admission/interruption | Preserve the event identity and input; never infer non-admission or silently resend |
Completed is precise about the graph’s source root. Filtered rows, REQUIRED SKIP, explicit
error handling and detached boundaries can resolve it without external delivery of the original
row. Each acknowledging connector retains its own success boundary. Source ACK timeout measures
physical acknowledgement inactivity; downstream progress/keepalives can extend the wait. A timed-out
source result does not cancel admitted work, which may still finish afterwards.
Consumers of one emitter compete fairly for available work. One attempt belongs to one worker; another worker receives it only after revocation. Consecutive batches of one branch may go to different consumers. Sequential mode permits one unresolved batch per source relay/concrete branch. Parallel mode permits up to its declared count on that stream across the whole pool and allows effects and ACKs to complete out of order. Attaching more workers multiplies neither window. Separate emitters from the same relay provide independent fan-out, each with its own ACK share. There is no total order across sources or branches, or worker affinity.
Producers share the ingestor’s single source window. A temporarily refused batch is sent again after backoff behind batches sent in the meantime. Several outstanding submissions can therefore produce output in a different order from their first sends. Keep one producer batch outstanding when that order is required.
Reading, decoding, queuing or transporting output is not ACK. ack awaits Confirmed; retry
retains work on declared physical backoff; reject follows the emitter’s message-error policy for
every member. Rejection reasons are nonempty, at most 1024 UTF-8 bytes and non-sensitive. The
application settles the entire batch. If it applied only some rows before failure, redelivery can
repeat every row.
Retry, ACK timeout and consumer loss revoke the current reference before reassignment. The
original IPC bytes, members, delivery identity and execution snapshot remain frozen while that
emitter execution retains the batch; a replacement attempt has a fresh reference. Late ACKs
cannot resolve a replacement. Repeated ACK is idempotent only while its bounded result remains;
released or revoked references expire. Revocation cannot undo an application effect.
Lost confirmation is SettlementUnknown in Rust or NX_ERROR_UNCERTAIN through the binding.
With ATTACHED, application ACK remains in the upstream chain. With DETACHED, upstream resolves
at the normal earlier detach boundary, while the emitter still retains output, retries and
applies local backpressure. The latter cannot establish that the application applied an effect.
For production replay, persist an application outbox before submitting and atomically deduplicate
or reconcile effects using stable event identifiers. A live delivery identity helps with attempt
redelivery, but is not a durable event ID across owner loss or a new submission. Nervix provides
no durable producer-result journal, consumer cursor, late-subscriber replay or exactly-once
application transaction.
Credit, Memory And Transport Progress
Positive batch and byte limits are requested at open and reserved before success. A consumer’s byte grant must hold one batch at the emitter’s declared maximum. An unusable reservation is refused immediately rather than attaching an application that cannot complete its own graph work. Producer and consumer reservations are separate and cannot borrow from each other.
| Limit | Current bound and scope |
|---|---|
| Attachments | 32 producers and independently 32 consumers per session |
| Requested outstanding batch count | 1 through 1,024 per handle |
| Session credit bytes | 32 MiB for producers and independently 32 MiB for consumers; 64 MiB total reserved credit |
| Node credit bytes | 128 MiB for each direction; 256 MiB total reserved credit, charged at every serving/owning node retaining forwarded work |
| Retained emitter IPC | A separate 128 MiB node work budget, including output waiting for a consumer |
| Native batch rows | At most 65,536; emitter BATCH may set a smaller count |
| Session frame | 4 MiB by default, including the complete FlatBuffers envelope |
| Reassembled reply | 64 MiB by default, with each transfer part fitting the frame limit |
| Producer IPC submission | One frame; the grant’s max_batch_bytes is the smaller of byte credit and frame payload allowance |
| Emitter IPC batch | Its declared BATCH MAX SIZE, at most 32 MiB, and within the consumer’s grant; replies above one frame use bounded transfer parts |
These are scoped credit and retained-IPC budgets, not a bound on whole-process memory. Relay,
processor, Arrow construction, executor and transport allocations have their own bounds. Forwarded
data may be retained on both nodes and is charged there independently. Producer submission framing
reserves envelope room (currently 256 bytes); applications use the announced grant, not the nominal
4 MiB frame value. Transferable consumer replies do not enlarge the one-frame producer submission.
No data is truncated to meet a bound. Invalid/oversized input is refused before admission; an
output row that cannot fit alone follows ON MESSAGE ERROR.
Submitted work keeps credit through transport, graph processing and outcome observation. Reading an output does not return its application credit; settlement or attempt revocation does. Pending terminal results and parked reads remain bounded. A producer whose application stops observing results stops gaining room. A consumer with no room receives no additional attempt. Fair service operates among ready producers/consumers while respecting each stream’s window.
With no eligible consumers, prepared output fills the node work budget and the emitter stops taking more relay work. This is ordinary waiting/backpressure, not external success or a message error. It propagates to producers through the graph’s buffers and attached ACK roots. Unused ingress credit is fenced during quiesce; an in-transit batch is admitted before the fence or refused as not admitted, never hidden through the pause.
ACKs, producer outcomes, admission changes, lifecycle, command replies, keepalives and clock observations have control capacity beside data. The receive loop never awaits a submission’s graph ACK. Producer submissions use their credit rather than the ordinary request-count allowance; consumer reads occupy one of the session’s 64 ordinary in-flight requests and leave the ordered lane free. Keep that request capacity available for settlement and administration. The SDK continues reading while application credit is exhausted.
Priority applies before handing frames to an ordered transport. Frames already handed to HTTP/2 or WebSocket remain ordered, and a peer that stops reading can stall its connection until physical liveness ends it. Separate sessions may isolate throughput. Sending beyond producer credit is a protocol violation: the new batch is refused without graph admission and the offending attachment ends; preceding batches still receive their outcomes.
Domain Time And Fencing
Data handles pin the domain’s START generation. The optional, domain-keyed clock attachment is
a separate observation interface with its own restoration and coalescing. Its frames travel on the
session’s control lane, with one replaceable tick slot per attached domain. It has no user
attachment name or generic data-stream handle. It observes committed time rather than
granting authority to set it. Tick progress can skip; clock/data events establish neither a barrier
nor a total order. Applications needing event time declare it in exported fields.
TIMESTAMP AT <field> preserves exact event time and uses the server’s ordinary paced admission.
TIMESTAMP NOW samples server domain time at delivery, including after quiesce. It also passes
through admission: SKEW smaller than PERIOD can reject arrivals between reached windows.
The newest 256 reached centers bound admission; a delayed clock observation does not relax it.
Expressions for an accepted execution unit share one snapshot, and emitter retries retain it.
| Time owner | Operations |
|---|---|
| Domain logical time | Event admission, FLUSH EACH, input collection and simulation cadence |
| Physical time | ACK inactivity/timeouts, retry backoff, session/link liveness, caller/credit-wait deadlines, drain and shutdown budgets |
| Physical system batching | FLUSH IMMEDIATE minimum window |
Slow consumers and domain pauses do not stop time. TIME RATE scales logical pacing, not a socket
timeout or a drain allowance. A stopped, uninstalled or stale paced clock never becomes wall time
as a fallback. Domain Clock owns this split.
| Fence | What it prevents |
|---|---|
| START generation | Work or restoration silently crossing a stop/start lifetime |
| Endpoint contract and exact schema/policy/grant comparison | Automatic acceptance of changed construction, routing, schema or acknowledgement behavior |
| Scheduled execution authority and process-start catch-up proof | A restarted former owner admitting through stale recovered state |
| Session exchange, producer/consumer identity and attachment | Delayed operations reaching an attachment opened in its place |
| Stable delivery identity plus fresh attempt reference | A revoked ACK settling a new assignment |
| Exact quiesce coordination identity and lease | A stale coordinator releasing a different hold |
An open succeeds only after current routing and capacity are installed. During process-start
catch-up, an endpoint open is temporarily EndpointUnavailable; stale local missing/stopped state
is not exposed as terminal before the barrier. A node unable to establish the required current
open/admission authority refuses that operation. The startup proof is not a continuous quorum
lease that revokes already-admitted runtime work after every connectivity loss.
ALTER, Failures And Restoration
Endpoint fingerprints describe their effective public contract. Ingestor fingerprints cover input, mode, timestamps, filters, routes, errors and referenced branch declarations. Emitter fingerprints cover their canonical contract and ordered output fields. Flush cadence is normalized out, so a flush-only change keeps attachments and pending delivery identities. Other effective contract changes require explicit application acceptance and a fresh open.
Buffered Ingestor ALTER
For an alteration that pauses an ingestor and gates a shared downstream relay, the coordinator:
- Engages an ingestor-only admission hold across the live nodes and stops new admission.
- Force-flushes existing route work and drains admitted source ACK roots while relay gates stay open. Partial batches from every concrete branch can reach their acknowledging sinks.
- Engages the full subgraph hold before releasing the intake hold. Both holds own suspension, so releasing intake cannot briefly reopen admission.
- Publishes the validated replacement only after its required drain and engagement succeed, then releases the exact holds.
The intake and full scopes have separate authenticated coordination identities and share one physical alteration deadline. Transaction inspection records each scope’s actual engagement, failure and release. Admitted submissions retain their original outcome and credit owners; consumers continue processing and ACKing them beside the ordered command lane. Intake-drain failure or full-hold rejection releases attempted holds and retains the committed contract and attachments. No failed alteration authorizes replay of unknown input.
A cancelled wait for a durably admitted command ends only the caller’s wait: execution and exact
command recovery remain cluster-owned. Coordinator abandonment instead leaves receiver-owned
release and lease cleanup responsible for its exact holds. Transaction Quiescence
owns those coordination and persisted-impact rules. The paced demonstration uses the published
buffered FLUSH EACH 100ms input route.
Events And Application Decisions
| Event | Delivered boundary and recovery |
|---|---|
| Quiesce or memory pressure | Suspend admission, refuse unadmitted batches and keep admitted ACK work/control progressing |
| Failed ALTER or planned drain | Leave the candidate unapplied, release owned holds and retain current contracts |
| Successful contract change | Drain under the old owner; end affected attachments; validate a fresh application open against the new contract |
| Planned relocation | Stop moved intake, force-flush/drain, commit handoff and end prior bindings as relocated; matching desired handles restore with fresh attachments |
| Consumer disconnect or forwarding-stream loss | Revoke attempts; surviving owner retains unresolved bytes for reassignment after physical backoff |
| Producer disconnect | Detach; queued uncleared work is unadmitted, while admitted work may finish without a connected caller |
| Owner loss | Lose its volatile payload/ACK state; preserve truthful not-admitted versus unknown classification and rely on upstream/application replay |
| Serving-node or session loss | Interrupt handles; every sent unresolved submission is SessionLost; restore connectivity without resending |
| Native leader change with healthy execution/session | Continue data attachments; command recovery follows its existing leader routing |
| STOP / new START | End data attachments under the ended generation; new opens require explicit acceptance of the new generation |
| Endpoint or domain removal | End affected handles; fresh opens refuse absent targets |
| Node shutdown or forced ending | Stop intake and follow the bounded drain/terminal contract; unresolved work remains failed or unknown, never fabricated completed |
| Explicit close/drop during restoration | Fence late open and release any attachment it created; the handle cannot resurrect |
The client recovery owner retains desired producers and consumers. It can restore the same handle with a new wire attachment only while generation, endpoint contract, fields, policies and granted behavior still match. Temporary owner/capacity refusals retry on bounded physical backoff. Consumer reads expose interruption before restored output; previous delivery handles never send ACKs through the replacement exchange. Unresolved sent submissions remain unknown, and definitely unsent submissions retain their not-admitted boundary. Changed contracts/generations require a new application open. Clock restoration proceeds independently.
| State | Persisted or retained across an owner/process loss? |
|---|---|
| Domain, schemas, endpoint Models and committed schedules | Persisted control-plane configuration |
| START mapping/generation and scheduling authority | Persisted under their owning lifecycle contracts |
| Materialized/processor/guest computation state | Only the state its own architecture contract persists; it is not a client payload journal |
| Payloads, route buffers, source ACK roots and credit/result slots | In memory; no durable client recovery history |
| Consumer queues, prepared deliveries, attempt references and ACK maps | In memory; owner loss can require upstream replay |
| Application outbox, event IDs and effect deduplication | Application-owned durability, independent of endpoint restoration |
The cluster prerequisites for this delivery are resolved: recovered Raft-member endpoints now seed authenticated gossip contact for a restarted former bootstrap leader (Interconnect, Shutdown); a routed record reaching no runtime consumer fails its ACK (PR #504); and forwarded record acknowledgements whose receiver stops reporting resolve negatively rather than appearing successful (Interconnect). These fixes underpin the completed cluster recovery prerequisites. They do not make client payloads durable. Native owner-kill, owner-freeze and forwarding-node-kill scenarios separately verify the client clearance, unknown-outcome and restoration boundaries.
Run The Paced Drivers
The Paced Simulation Drivers publish the complete branched graph and Rust/shared-binding Python programs. In a repository checkout with a running cluster, load that graph and start its domain, then run either application:
nervix-cli --command \
"CREATE PACED DOMAIN paced_simulation WITH PERIOD 100ms SKEW 100ms;"
nervix-cli --domain paced_simulation --command \
"$(sed -n '/^BEGIN;/,$p' examples/paced-simulation/paced_simulation.nspl)"
nervix-cli --domain paced_simulation --command "START AT NOW TIME RATE 4.0;"
just paced-simulation --server http://127.0.0.1:47391 \
--ticks 100 --sensors 3 --inspect-every 1s \
--ledger /tmp/rust-input.jsonl --effects /tmp/rust-effects.jsonl
Alternatively, with Python 3.12 or later:
just paced-simulation-python --server http://127.0.0.1:47391 \
--ticks 100 --sensors 3 --inspect-every 1s \
--ledger /tmp/python-input.jsonl --effects /tmp/python-effects.jsonl
The file includes bootstrap and USE phases; the load command extracts its graph transaction
and selects the domain through the CLI. Check the printed command dispositions before starting
an application; ordinary --command currently prints a refusal without a nonzero exit status.
The drivers read NERVIX_USERNAME and NERVIX_PASSWORD, or explicit credential options. The
recipes build the required binary or shared library. Each application opens consumers before
submitting, uses the clock helper’s projections for tick centers and admission, writes an input
ledger and applies effects by stable reading ID before ACK. --timestamps now selects the
comparison ingestor; --invalid-every 3 exercises the error route. The rejection-notice consumer
is outside the original source ACK chain, so the drivers separately wait for those notices.
For a running instance, save this compatible ingestor change as alteration.nspl; it demonstrates
same-generation reopening with buffered input:
BEGIN;
ALTER CLIENT PRODUCER INGESTOR simulated_readings
SET FROM SCHEMA reading
MODE ACK PARALLEL MAX 4 ACK TIMEOUT 30s
RETRY POLICY BACKOFF 100ms MAX 2s
ON QUIESCE SUSPEND;
COMMIT;
nervix-cli --domain paced_simulation --command "$(cat alteration.nspl)"
The drivers recheck endpoint state during clock/credit waits every 200 ms and retry an unavailable
endpoint within their existing 30-second physical open budget. They report REOPENED with the
same generation for accepted non-lifecycle replacements, request their configured credit again
and validate exact fields. Old pending submissions keep their original producer and credit;
replacement credit cannot retire them. Unusable schema, removed endpoint or insufficient consumer
credit is a configuration error, including after planning ends. A consumer refusal interrupts
final-outcome, rejection-notice and producer-close waits.
Only domain_stopped and generation_changed wait for another START, and
--follow-generations explicitly enables that continuation. --replay deliberately resubmits
ledger readings whose latest outcome was not completed, preserving ID/content only while their
generation and event-time window remain usable. It also covers a planned tick refused during a
contract change. No unknown input is automatically resent. Redelivery/replay can repeat output;
the example effect store deduplicates it. The files are flushed but not made durable, so a
production application must supply its own durable outbox/effect transaction.
Exit status 0 means submitted readings completed, 3 reports unresolved/not-admitted/failed readings,
2 reports configuration refusal, and 1 reports an application failure or its final wait deadline.
SUMMARY and the report lines show outcomes, effects, duplicates, generations, credit waits,
peak outstanding bytes and consumer membership. The driver chapter owns their complete option,
report, stopping and replay contracts.
Inspection, Tuning And Troubleshooting
Inspecting endpoints is read-only and claims no work. Run these commands with
nervix-cli --domain application --command '<one statement>', one invocation per statement,
or in a REPL that has selected the domain:
SHOW CREATE INGESTOR submit_events;
SHOW CREATE EMITTER receive_events;
SHOW INGESTORS;
DESCRIBE INGESTOR submit_events;
DESCRIBE EMITTER receive_events;
DESCRIBE RELAY application_events;
The ingestor reports its schema, owner, admission, producers, forwarded producers and outstanding/ admitted batches and bytes. The emitter reports consumers, forwarding credit, retained IPC, incomplete application batches, retries, ACKs and rejections from its scheduled owner. Metrics use bounded domain/ingestor/emitter/outcome/cause labels, without session, delivery or event IDs. Gauges describe current volatile state; counters retain only their owning node’s documented lifetime. They cannot prove a particular application’s ACK or durable completion. Metrics And Observability owns the metric names and interpretation. Transaction inspection separately exposes intake/full engagement and release.
| Symptom | Check and action |
|---|---|
| Open refused | Read the typed refusal: explicit domain, running endpoint kind, exact ordered fields, transaction binding and usable credit; retry only a temporary unavailable/capacity condition |
| Producer awaits credit | Observe pending outcomes, consumer presence, admitted ACK window, relay pressure and materialized REQUIRED WAIT; keep consuming/ACKing concurrently |
| Output waits with no consumer | Open a consumer with room for the declared maximum; use additional competing workers for processing capacity or separate emitters for copies |
| Output has no expected branch field | Export the required value through schema-backed construction; the opaque fingerprint is metadata |
| Invalid native stream | Check canonical schema, exact types/nullability, one batch, row/byte bounds, no compression/dictionaries/extensions/field metadata and valid column buffers |
| Source processing failed | Read its rejected/timeout cause and route error policy; partial effects may already exist |
| Stale ACK or uncertain settlement | Match the current attachment/attempt; reconcile idempotent effects instead of treating a replacement reference as the old one |
| Paced admission rejects rows | Inspect reached centers, generation, exact event time and skew; a clock observation cannot extend the server’s window |
| ALTER cannot drain | Continue admitted outcome reads and application ACKs; inspect its actual scope/failure and shared physical deadline before accepting a replacement |
| Unknown outcome after loss | Preserve application input and ID, establish current generation/contract and deliberately reconcile/replay |
Choose producer credit for the largest canonical input and required overlap, within session/node
budgets. Choose consumer bytes to fit the declared maximum IPC stream and account for all other
handles on that session. Set BATCH MAX SIZE with headroom for schema/IPC overhead and unusually
large rows; FLUSH MAX BATCH SIZE measures a different size. A larger ACK window permits more
unresolved work and out-of-order effects; a sequential stream or one outstanding producer batch
provides its narrower order. Increasing consumers cannot exceed per-stream windows or byte budgets.
Tune logical flush/collection for latency and boundary cost, and physical ACK timeout for network
and application processing. Use independent sessions when transport contention matters. Diagnose
with endpoint state, bounded codes and counts rather than logging sensitive payloads or arbitrary
application rejection text.
Acceptance And Evidence Boundaries
The repository’s public scenarios establish the following current behavior. Runtime outlines use one and three nodes and gRPC/WebSocket where applicable; placement-specific cases select the production sticky scheduler. The links name executable evidence, not a claim that every failure interleaving has been explored.
| Contract | Public acceptance source |
|---|---|
| Exact native input, transforming branches, shared producer window, over-credit refusal, cancelled result waits and control progress | client_ingestors.feature |
| Branch/source isolation, attached and detached ACKs, pooled consumers, independent emitter fan-out, retry/reject/timeout, sensitive export, oversized output and credit restoration | client_emitters.feature |
| Real owner kill/freeze, forwarding-node kill, cleared versus uncleared batches and fresh restoration | client_ingestor_process_faults.feature |
| Bulk C ABI and C/C++/Python/Java/Ruby round trip, lifetimes, deadlines, credit and session gaps | client_conformance.feature and the client conformance ledger |
| Runnable Rust/Python pacing, generation separation, current-window admission, saturation, non-owner entry, clock-authority movement and deliberate replay | paced_simulation.feature |
The buffered ALTER outlines are named An alteration drains buffered branches on a shared relay before replacing its client contract and A rejected <failure> releases buffered client admission and retains its committed contract. They check shared-relay branch isolation, explicit transaction
completion, separate persisted intake/full impact attempts, successful drain and both failure
stages through gRPC/WebSocket on one/three nodes.
The paced driver outlines include:
The <driver> driver drains buffered routes and reopens its consumers and producer within one START generation on <cluster_size> nodes.The <driver> driver retains a refused tick across a producer contract change and explicitly replays it on <cluster_size> nodes.The <driver> driver fails clearly when reopening a <change_kind> <endpoint> on <cluster_size> nodes, covering endpoint removal, schema and credit mismatch.The <driver> driver reports a delayed refusal while awaiting its final outcome on <cluster_size> nodes.
The conformance ledger distinguishes live shared-binding endpoint tests from independent
Go/TypeScript corpus readers. It records that ACK-confirmation loss is injected by a binding unit
test rather than timed in a live probe, and that those probes do not qualify TLS. Rust
compile_fail doctests are paired with compiling examples of current imports/signatures in normal
tests and CI. They qualify specific Rust API restrictions; C ABI ownership, runtime behavior and
client conformance keep their own evidence.
Registered Bolero properties run ordinary randomized/corpus checks on every PR. Sanitizer CI runs
only for a pull request carrying fuzz; unlabeled, scheduled and workflow-dispatch runs expect a
sanitizer skip. That skip proves no sanitizer execution or coverage. Explicit local just fuzz
and just fuzz-all remain available. Property Testing And Fuzzing
owns the inventory, bounded campaigns, exact replay and failure retention.
Shuttle explores interleavings of production credit, admission, attempt and close owners. Loom addresses registered memory-ordering claims; Turmoil addresses the supported simulated-network boundary; real-process/Chaos cases address actual crashes. Deloxide diagnoses active cycles only among its tracked blocking locks, with async waits, atomics, network waits and Python locks outside that observation. No one evidence form replaces the others, and native model/diagnostic coverage is kept separate from ordinary coverage.
Domain Clock
The domain clock is the time boundary between Nervix’s control plane and data plane. The control plane commits what time means for a domain; every data-plane node installs that definition and uses a capability bound to the exact domain lifecycle generation. Expression engines, processors, and connectors receive time from that capability instead of choosing a clock themselves.
This chapter describes the internal architecture. See Domains And Time
for the NSPL surface and operator-facing behavior.
Explicit transactional START and STOP establish or revoke generations at ordered step
boundaries; Transaction Quiescence And Impact Inspection owns how
those lifecycle steps affect later planned scopes and the retained impact report.
The central rule is that a paced clock is a committed mapping, not a stream of ticks. Each node projects its own current UTC observation through the same mapping. Tick progress records which logical boundary the current authority has reached, but it never replaces the mapping or advances a node’s readable time.
Replicated And Node-Local State
The complete clock contract is split across replicated control-plane state and bounded node-local state:
| State | Meaning | Ownership and durability |
|---|---|---|
| Domain pace | PACED with a positive PERIOD and nonnegative SKEW, or UNPACED | Part of the replicated domain configuration |
| Lifecycle generation | The domain’s start_version, advanced by every committed START | Replicated with the domain lifecycle record |
| Paced mapping | Physical UTC anchor, logical origin, and positive finite time rate | Replicated for a running paced generation |
| Authority fence | Monotonic authority revision and an optional node identity, including its process incarnation | Replicated separately for each paced domain |
| Progress | Generation, authority revision and identity, plus the latest produced tick | Transient and replaceable; sent over the interconnect and never persisted |
| Installed clock | Missing, stopped, uninstalled, installed-unpaced, or installed-paced state | Node-local runtime state derived from the committed revision |
| Last read and observed progress | The node’s nondecreasing read watermark and newest accepted tick observation | Node-local in-memory state |
The lifecycle generation and authority revision fence different changes. A new START increments
the generation and establishes a new mapping. Assigning, moving, or revoking the producer
increments the authority revision without changing the mapping or generation. Including the node
incarnation in the authority identity prevents a restarted process with the same node name from
acting as the preceding process.
An unpaced domain still binds a domain-and-generation clock capability, but its source is actual UTC. It has no paced mapping, authority, tick production, or admission window.
Paced-Time Projection
A paced mapping contains:
wall_started_at: the UTC instant sampled when the generation was establishedlogical_start: the logical instant requested by the domain starttime_rate: the positive finite ratio of logical time to physical elapsed time
For a current UTC observation wall_now, the vocabulary model computes:
elapsed = max(0, wall_now - wall_started_at)
logical_now = logical_start + floor(elapsed * time_rate)
Rounding down prevents a read from claiming a logical nanosecond that has not been reached. A UTC observation before the physical anchor contributes zero elapsed time. The runtime then raises the node’s read watermark for the installed generation to the projection and returns the watermark, so a local clock adjustment cannot make normal reads move backward.
The inverse operation converts a future logical target into a physical duration:
wall_wait = ceil((target_logical - current_logical) / time_rate)
A positive result is at least one nanosecond. Rounding up prevents a logical deadline from firing early. Both operations use checked duration and timestamp arithmetic. Origins, projections, and tick boundaries must remain within signed Unix-nanosecond timestamps; periods and skew must fit in unsigned 64-bit nanosecond durations. A value that exceeds those bounds produces a typed error.
TIME RATE affects only the relationship between elapsed physical time and logical time. It does
not scale PERIOD, SKEW, preserved source timestamps, or a duration after that duration has been
classified as physical policy.
Establishing A Generation
RESTORE ... RESUME activates the archived start generation and latest start point after complete
state publication. A paced restore installs its committed wall/logical mapping and time rate
verbatim; elapsed downtime advances its projection. The leader selects a new authority from this
cluster’s live voter incarnations. Archived authority and frontier never establish that authority
or reset the mapping. A paced archive missing its mapping is refused before restore admission.
Default restoration leaves the domain stopped with no installed mapping; a subsequent START
establishes a new generation by the procedure below. See
Backup And Restore.
A direct or transactional START reaches the same replicated lifecycle transition. A transaction
resolves the concrete start, paced mapping, and initial authority while its complete commit plan is
admitted, then persists that decision with the frozen step. Restart and leadership recovery execute
that stored decision against its captured control-plane inputs instead of sampling a new anchor or
selecting a different authority.
- The command handler reads the current domain and samples UTC once for the physical anchor.
- It resolves the requested start into a concrete logical origin and rate.
START AT NOWbecomes a concrete timestamp before it is committed;START AT <timestamp>preserves the supplied logical origin. A resume uses readable current domain time when it is available and otherwise resolves from the sampled UTC at rate one. - A paced start selects one live voter incarnation as its initial authority. The command fails if no eligible authority exists.
- One consensus command marks the domain running, increments its lifecycle generation, stores the mapping, and advances the authority fence. An unpaced start increments the generation without storing a paced mapping or authority.
- Every node applies the resulting control-plane revision to its local domain-clock lifecycle before materializing executable work from that revision.
- The selected authority produces progress once its own node has installed the mapping and authority fence. It delivers progress to each live peer after that peer advertises that it installed the domain clocks of the required revision; neither waits for graph activation.
The mapping is therefore available before a graph task can bind its clock. A node joining later installs the existing mapping, logical origin, rate, generation, and authority fence. Its join time does not become a new clock anchor.
STOP commits the inverse lifecycle boundary. It marks the domain stopped, removes the active
mapping, and advances the authority fence to an unassigned state in the same transition. The
runtime clears accepted progress for the generation and wakes bound waiters, which observe a typed
stopped or stale-generation error. A later START creates another generation.
A producer of a client ingestor is bound to the generation it
attached under, which its open reply reports. STOP ends it, and so does an execution installed
under a later generation, both as domain stopped; a producer never carries batches from one
generation into the next.
The internal pause used while altering a running model does not establish a generation. It keeps the mapping and authority active while ingestion and generators are withheld, so logical time and already-armed deadlines continue through the quiesce cycle.
Authority Selection And Reconciliation
The current consensus leader reconciles authority ownership whenever domain state, Raft topology, or effective node availability changes. Candidate identities are the intersection of effectively available node incarnations and current Raft voters. Effective availability retains an established incarnation through a gossip loss while its latest application-health observation remains within the node-unavailability interval and has not marked it unavailable. If that observation becomes stale, the node follows gossip’s liveness verdict. Duplicate observations for one node name collapse to the newest incarnation.
Selection is deterministic. Candidates are ordered by node name, the domain name is hashed, and the hash selects one position in that ordered set. Every leader presented with the same domain and candidate set therefore proposes the same owner, while domains can distribute across the voter set.
The replicated reconciliation is a compare-and-set operation. It changes the authority only if the domain still has the expected lifecycle generation, is still paced and active, and still has the expected authority fence. This stops work computed by a preceding leader or topology view from overwriting newer state. Every successful reassignment or revocation increments the revision.
Leadership transfer alone does not alter the mapping or authority. Losing the authority’s exact node incarnation causes the leader to select and commit another eligible incarnation. If none is eligible, the authority becomes unassigned and the paced clock is unavailable for execution until an owner is committed again; the mapping itself remains replicated.
A reconciliation proposal that does not commit is proposed again 200 milliseconds later, for as long as the node remains leader, without waiting for another change in domain state, Raft topology or availability. A newly elected leader refuses writes until a quorum acknowledges its leadership, so the first proposal it makes on taking over can be refused. When the lost authority also held consensus leadership, the survivors first elect a leader, and that leader’s reconciliation commits the replacement on a later attempt rather than when some unrelated state next changes.
Application-health probes finish their bounded attempts despite concurrent gossip updates. This allows continuous failures to exclude a stopped authority even after its advertised endpoint has changed; a stale healthy observation must not leave it indefinitely eligible for clock production.
On each node, a producer task exists only when all of these values agree with committed runtime state:
- domain name
- lifecycle generation
- mapping and period
- authority revision
- full authority node identity and incarnation
An obsolete producer is cancelled and retired before that node starts a successor for the same domain. The old task retains its immutable fence while it exits, so even an in-flight delivery cannot impersonate the replacement authority.
Every node installs a committed reassignment ahead of its graph installation. The node’s domain clock owner applies each committed revision directly: when a domain’s installed generation, status, configuration, start point and mapping already equal the committed ones, it installs the committed authority fence at once, which starts or retires the local producer and fences received progress. Graph installation can wait on a node that left, for example while a forced failover drains a relay toward it, and it does not hold back the clock takeover on the replacement authority or on any receiver. Every other lifecycle change, such as a new generation, a stop, or an added or removed domain, remains with graph installation, which installs a revision’s lifecycles before its work. Each node records the committed revision whose lifecycle each domain holds and never replaces it with an older revision’s, so a graph installation that completes after a newer fence was installed cannot restore the superseded authority.
After reassignment, the replacement authority reconstructs the frontier from the unchanged mapping; each receiver’s own installation of the fence controls when it can receive progress.
Tick Production And Progress Delivery
Tick identifiers are one-based. Tick 1 is the logical origin, and tick n is at:
logical_start + (n - 1) * period
The authority projects current UTC through the committed mapping and determines the latest due
tick by direct arithmetic. If scheduling delay spans several periods, it emits only that latest
due boundary and continues with the first future boundary. It never loops through or queues every
missed tick. A newly assigned authority can consequently reconstruct the current frontier without
persisting the previous producer’s counter.
After an actual emission, the authority waits at least one period divided by the rate in physical
time before emitting another tick. This keeps consecutive observations spaced correctly when tick
1 was delayed after START; a late next boundary is coalesced to the newest due id as usual.
Each progress report carries the lifecycle generation, authority revision, full authority
identity, tick id, logical boundary, authority UTC observation, and period. The authority applies
the report to its own runtime directly. Remote delivery uses the typed domain_clock_progress
HTTP/2 management request described by Cluster Interconnect.
For each installed remote node and domain, the producer owns one delivery loop with at most one request in flight and one replaceable pending report. Publishing another tick overwrites the pending value, so a fast clock or slow peer cannot create an unbounded queue. A node-wide progress subquota bounds concurrent attempts independently of health, relay admission, cancellation, and terminal outcomes.
Targets must be live and must advertise that their domain clocks match the committed runtime revision the producer started from, or a later one. Each node’s domain clock owner follows every committed domain and schedule change, and it advertises a runtime revision once every domain clock the node holds, authority fence included, equals that revision’s committed lifecycle. It advertises neither graph preparation nor activation, so a target whose graph installation waits still receives progress. A lifecycle change that only graph installation installs, such as a new generation of another domain, holds the advertisement until graph installation installs it. A new or reconnected target receives the producer’s newest retained report when its advertisement reaches the required revision. Each request has a two-second physical deadline. Failure waits on a 200-millisecond physical backoff and then retries the newest report. Cancelling the authority stops an in-flight request and its backoff.
A process stop request does not cancel clock authority reconciliation, progress production, progress delivery, or local installation. Those services remain active during application drain support so work admitted before listener shutdown can still take execution snapshots and wait on logical deadlines, and so a committed ownership handoff can install the schedule and clock state it needs. Terminal teardown cancels them only after drain support completes or explicitly reports abandonment. See Shutdown And Recovery for the phases these services span.
The receiver first gets the reporting node name from the mutually authenticated interconnect. It accepts progress only when the domain already exists and is not stopped, and the report matches the committed generation, authority revision, full authority identity, authenticated node name, and a nonzero tick id. A report is newer only when its tick id advances and its authority UTC observation does not precede the retained one. Duplicate, reordered, delayed, and superseded reports are ignored.
The receiver publishes the newest accepted tick id, logical boundary, and authority UTC observation
through a per-domain watch. The compare and replacement are serialized by the watch so concurrent
deliveries cannot replace newer progress with an older report. Generation changes and stops clear
it with send_replace; a late observer subscribes before reading and sees the current value at
once. Progress does not use the report’s logical timestamp, UTC timestamp, or period to replace the
installed mapping. The unit response
therefore means that the receiver evaluated the report against its current fence; it does not mean
that the report established time. Progress can never create a missing domain.
Local Installation And Bound Capabilities
Each runtime node keeps one shared lifecycle allocation per domain, and graph tasks hold thin clock handles bound to its domain and lifecycle generation. Control-plane synchronization publishes each installation change into that allocation by atomically replacing the complete installation, then notifies logical waiters. This makes a lifecycle change visible to every existing handle and gives logical waiters one notification source to observe. The same immutable publication contains its pause state, lifecycle generation and last start point. Ingest groups read pause/admission and time from one publication. Kafka domain-offset polls and filtered subscriptions retain the lifecycle allocation; a subscription reads its current installed generation even when it opened before START. Generators, processors and materialized relay tasks retain their generation-bound clock. These reads do not resolve the runtime’s domain or execution registry again. A paused paced domain keeps its installed mapping while publishing pause, and a later generation still invalidates a clock bound to its predecessor.
A pause-only publication does not notify installation observers when it keeps the installed mapping unchanged; intake sees pause through the lifecycle publication while execution keeps its existing logical waiters and mapping.
The local installation states have explicit meanings:
| Installation | Read result |
|---|---|
| Missing | The domain is absent from this runtime |
| Stopped | The generation exists but cannot execute domain work |
| Uninstalled | A paced active generation lacks either its mapping or assigned authority |
| Installed unpaced | Reads actual UTC through the domain-bound capability |
| Installed paced | Projects actual UTC through the committed mapping, period, and skew |
Binding an active clock fails for missing, stopped, or uninstalled state. A handle that was bound
before a later START fails with a stale-generation result. Reads and logical waits revalidate the
shared installation; no failure path substitutes wall time for a paced clock. Passive graph state
for a stopped domain may retain a generation-bound handle for ownership purposes, but attempts to
read it still fail as stopped.
Ownership handoff and forced recovery of a passive WASM revision transfer its validated durable
checkpoint inventory without guest callbacks or clock reads. Guest restoration waits for START
and its active clock generation; an unreadable stopped clock does not invalidate the saved state.
Reads take no lock. A read loads the published installation, validates the handle’s generation and the installation state against it, projects actual UTC through the source that installation holds, and raises the read watermark published with it using one atomic maximum. A concurrent read therefore observes either the complete preceding installation or the complete replacement, and a replacement never alters an installation that an in-flight read already holds. Synchronization derives each replacement from the installation it replaces and publishes it only while that installation is still current; synchronizing an unchanged installation publishes nothing and wakes no waiter.
Each read watermark covers one uninterrupted installation of a generation. A replacement that keeps the same generation installed shares its predecessor’s watermark, so reads of that generation do not decrease even when they race the replacement. Any other replacement starts a new watermark. A read that loaded an earlier generation can therefore raise only that generation’s watermark and cannot clamp reads of a later generation, and a generation that becomes uninstalled starts from a new watermark when its mapping and authority are installed again.
Applying a cluster revision synchronizes all domain lifecycles before applying its schedule. A joining node therefore cannot instantiate work and then discover that its clock mapping is absent. Removing a domain marks the shared lifecycle missing before node-local state is discarded.
Session Attachment
A client session follows a domain’s clock with ATTACH DOMAIN CLOCK and stops following it with
DETACH DOMAIN CLOCK. The attachment observes the installation and the accepted tick progress of
the node serving the session. It adds no interconnect traffic: every node serves its sessions from
its own installation and accepted progress, and no node advertises its attachments. Nothing about
an attachment is persisted. Domain Clock Attachment
states the public contract,
Client Session Protocol how the attachment
travels in the protocol, and the
Client Implementation Manual what
every client implementation must do with it. This section owns how an installation and accepted
progress become what an attached client observes.
The Observer
The runtime exposes an observer of one domain’s lifecycle publication and progress watch. It subscribes to both notifications before its first read, so an installation or a tick published after a read always wakes it. It maps each installation to the public observed clock, a vocabulary model shared by the server and the Rust client:
| Installation | Observed clock |
|---|---|
| Missing | None: the attachment ends |
| Stopped | Stopped, with its generation |
| Uninstalled | Uninstalled, with its generation and no mapping |
| Installed unpaced | Unpaced, with its generation |
| Installed paced | Paced, with its generation, period, skew, and committed mapping |
An observed clock carries no authority identity or revision. An authority move that keeps the installation therefore publishes nothing to an attached client.
The observer reads a tick only while the installation is paced and of that tick’s generation. It adds the serving node’s own logical reading, taken as the tick frame is built: one snapshot of the installed generation, read through the same watermarked read that execution uses. The observer retains the progress watch’s sender while the attachment lives, so removing the domain closes neither wait before the lifecycle publishes missing and wakes delivery to send the end frame.
Attachments And Their Delivery
A session’s attachments belong to its ordered lane. The lane holds them in one map keyed by domain, so a session follows each domain clock at most once, and runs one delivery task per attachment. Attach and detach run on that lane in order with the session’s commands, and both are refused while a transaction is open.
An attach first waits until the node has installed the committed domains since it started, or until the session ends. Before that installation every domain is missing from the node’s runtime, so the node cannot tell a domain the cluster lacks from one it has not installed yet. Waiting keeps a node that is still starting, as after a restart, from refusing a committed domain as not found. The runtime counts its installations through a watch and advances the count only after an installation has put every domain in place, so the lookup that follows the wait finds each domain that installation holds. A domain the lookup misses is one the installed committed state lacks, and the reply refuses it as not found. The wait has no bound of its own. The attach is admitted before it waits, so a cancellation of the request does not end the wait, and the session’s later ordered requests wait behind it; short of the installation, only the end of the session ends it.
The attach reply carries the observation read when the observer was created. Delivery starts only once that reply is queued, remembering the observation the reply carried. A reply that was not queued, because the request was cancelled, the session ended or the reply did not fit, announced nothing, and nothing is delivered after it. The task first sends any accepted tick of the reply’s generation, if one exists. On each wake it reads the newest installation and queues a state frame on the session’s control lane only when the observation differs from the one the client last received. Replacing an installation with an equal one, an authority move within a generation and the alteration pause therefore send nothing, though newly accepted progress still wakes delivery. A state frame waits for room on the control lane; changes published meanwhile collapse into the newest installation read after it is queued.
Before each tick the task reads the installation again and sends a changed state frame first, withdrawing a tick it has not handed to transport yet. A tick holds one replaceable control-lane slot: newer progress replaces its contents until transport takes it, including while the lane is full. Each attached domain therefore has at most one pending tick, and a slow client receives the newest accepted id instead of a backlog. A state or tick frame that does not fit the session’s frame limit is not queued: delivery logs a warning and waits for the next change. An end frame that does not fit is logged the same way, and the attachment ends without it.
Detach stops the delivery task and waits for it to end before queueing its reply. When the observer
reports the domain missing, the task marks the attachment ending, queues the end frame with reason
DomainRemoved, and ends. Because the mark precedes the frame, a request the client sends after
reading the frame finds the attachment ending: an attach releases it and is served as a new
attachment, and a detach reports it not attached. The end of the session stops every delivery task
without a frame.
Delivery Order
For each attached domain, a client can rely on this order:
- The attach reply precedes every frame about its attachment.
- A tick follows the attach reply or state frame that reported its generation as paced. No tick of an earlier generation follows a newer state, and an unpaced, stopped or uninstalled clock is followed by no tick until a state frame reports it paced.
- Within one generation, delivered tick ids increase. They may skip, because the authority coalesces missed periods and a replaced tick slot keeps only the newest.
- The end frame is the last frame of its attachment.
- No frame about the domain follows the detach reply.
Frames about different domains share the control lane but have no order a client may rely on.
What An Attached Client Observes
| Event | What the serving node sends the session |
|---|---|
START | A state frame with the new generation: paced with its period, skew and mapping, or unpaced. Ticks of the new generation follow once the node accepts them; an unpaced domain sends none |
STOP | A stopped state frame for the generation, and no further ticks |
| Automatic pause while altering a running model | Nothing: the installation and mapping are unchanged, and ticks continue |
| Authority committed unassigned | An uninstalled state frame for the same generation, and no ticks. Once an authority is committed again, a paced state frame repeats the generation and its unchanged mapping, and ticks resume with larger ids |
| Authority moved directly to another voter incarnation | No state frame: the mapping is unchanged. Ticks continue from the replacement once it produces them and the serving node has installed its fence, and their ids may skip |
| Consensus leader change | Nothing, unless authority reconciliation also commits one of the authority changes above |
| A committed revision the serving node installs no longer holds the domain | One end frame with reason DomainRemoved, which ends the attachment |
| The serving node stops, or the connection is lost | Nothing more: the session ends without an end frame, and its attachments end with it |
| An attach reaches a node restarted before its first installation | The reply waits for that installation, so it never reports a committed domain as not found |
A client keeps following a clock across a lost session by attaching to it again. The Rust client
turns the end of a session into an interruption of every clock that session held, keeping each
clock’s latest state but not its tick. It attaches the followed clocks again on its next session
before publishing that session to the client’s other requests, and so before any transaction it
attaches there. It reports the clock a restored attachment carries as an observation when the
attachment was interrupted or the clock changed; the changes in between are not reported. When the
new session answers the attach while the previous session still holds the attachment, as after a
leader redirect, the attachment moves without an interruption: a clock that stays paced in the
same generation keeps its newest tick, so the clock a caller reads is never older than a tick event
it has already taken, and a tick of the new session is reported only when it is newer. A
restoration the new session refuses is reported and sent again on that session after one second, a
wait that doubles with each refusal up to thirty seconds, for as long as the client follows the
clock and the session stays open; each new session starts again from one second. A restoration
left unanswered past the client’s request timeout ends that session instead, like any request
that outlives the timeout, and the next session attaches the clock again; a caller already waiting
for the next clock event is woken and opens that session. A restoration refused as not found ends
the attachment with reason DomainRemoved.
A detach and a restoration of the same clock never cross. The Rust client registers a detach before it sends it, and no restoration decides to attach the clock while a detach of it is in flight: a restoration that would has its attach wait until the detach has ended, and sends it only if the client still follows the clock. A restoration attach decided before the detach is sent first, and the detach waits for it, so it reaches the session behind that attach and releases what the attach restored. Once the detach is answered, no session holds the clock on the client’s behalf, and the client follows it only if a later attach of the caller’s own attaches it again.
Guarantees And Non-Guarantees
An attachment guarantees the delivery order above and that every state it reports is a complete installation the serving node published. It does not provide the following:
- Replay. Ticks are replaceable, so a slow client receives the newest accepted tick, never a backlog, and installation changes published while a state frame waits collapse into the newest. A new attachment starts from the current installation and the newest accepted tick. Nothing reports the changes a lost session missed.
- Persistence. Attachments are session-local hot state. A session end, a node stop or a restart discards them on the server, and a client restores one only by attaching again.
- A distributed order. Each node serves its sessions from its own installation and accepted progress. Sessions served by different nodes can observe the same change or tick at different moments, and a node that has not accepted a tick or installed a change yet reports it later. No frame says anything about another node’s state.
- A disciplined clock. The serving node’s logical reading in a tick frame is that node’s own
projection of its UTC, so it carries that host’s offset multiplied by
TIME RATE, and it is older than the frame’s arrival by the delivery latency. Synchronized host UTC, which the cluster requires of every node, is a precondition for the serving node’s readings too. A client whose UTC is not disciplined like the cluster’s projects logical instants and admission centers shifted by its own offset multiplied by the rate, and its physical waits end early or late by the offset itself; it can anchor itself on the serving node’s reading instead.
Client Libraries
The Rust client keeps, for each followed domain, the clock the attach reply or the latest state frame carried and the newest tick of that clock’s generation, and answers logical time at a UTC instant, the physical wait until a logical target and the admission window with the vocabulary’s clock arithmetic, the arithmetic the ingestor’s admission uses. Its clock events are coalesced per domain, so a caller that reads none never holds the session’s reader up: the newest state and tick of a domain replace older unread ones, and an interruption or an end replaces the observations before it.
The shared C binding gives hosts a separate retained nx_clock_event handle for later state
changes, ticks, interruptions, refused restorations, and ends through nx_session_next_clock_event.
Its typed accessors expose the domain, generation, state and paced mapping, tick, or end reason.
Through nx_session_domain_clock a host reads the latest followed clock itself as a retained
nx_domain_clock, so a host attaching to an already paced clock reads the generation and mapping
the attach reply carried before it uses the first tick, and projects logical time, waits and
admission with the Rust client’s arithmetic instead of reimplementing it.
Execution-Time Snapshots
The clock is sampled once when a unit of domain work is accepted. The resulting execution snapshot contains the validated lifecycle generation and one timestamp. The runtime passes that snapshot to VM, Roto, and WASM execution instead of giving those engines a context-free time source.
A function the VM calls out to receives that time as part of the call. The VM’s
FunctionInjector::inject_with_context takes the snapshot’s now as a Timestamp beside the
function, its arguments, the selected rows, the source span and the rows’ prior errors. Roto’s
UdfExecutor implements it, and a domain UDF’s now() returns the injected time, so a UDF runs at
the snapshot of the unit that calls it. Paired doctests beside the trait and beside UdfExecutor
hold this signature: a call with a Timestamp compiles, while a call that passes a raw integer or
omits the time does not.
One snapshot is shared by all expressions in that unit, including construction, routing,
deduplication, ordering, correlation, windows, inferencer mappings, emitter filters, VALUES, and
subscription predicates. This prevents two expressions in one operation from observing different
logical instants. A paced domain may place its logical time before the Unix epoch, and expressions
consume the snapshot as it is: now() returns it, while uuid_v7(), whose timestamp field starts
at the epoch, fails each message that evaluates it rather than encoding a different instant.
Message-error handling retains the failing operation’s snapshot. Work that begins later, such as a
processor flush, scheduled callback, or message released from materialized REQUIRED WAIT,
receives a fresh snapshot for that execution.
A coordinated WASM state reset does not change the domain lifecycle generation, mapping, authority, or logical frontier. Preparing a fresh guest and saving its initial state each use a snapshot from the currently installed domain clock. The snapshot belongs only to that initialization operation; it is not inherited from the branch instance being replaced. Publication cancels the old branch instance and all timeout handles it owned. A fresh initialization may request its own timeouts, but no deadline armed by the replaced instance can fire in the new state lifetime even though domain logical time continued across the reset. See Coordinated Reset.
Checkpoint and reset inspection samples existing state without taking a domain execution snapshot or advancing the logical frontier. A reported checkpoint revision and reset generation describe durability and lifetime identity, not domain time; neither may establish or alter the clock used by a later guest callback.
An NSPL reset uses this same clock contract. Repeating its durable command reference resumes the same guest-state lifetime replacement without changing the domain clock mapping.
Generated records use the snapshot assigned to their generating operation. Buffered emitter
batches and their retries retain the snapshot from acceptance, while external observation fields
whose contract is actual UTC obtain that value at the shared source-host intake boundary or their
connector boundary. An Iceberg emitter’s COMMIT EACH is not measured from that retained snapshot:
the host reads a fresh snapshot at the first cadence check after a flush stages rows and arms the
commit’s logical deadline from it, and a retry or drain forces the commit without a clock read.
An HTTP emitter’s prepared method, target, headers, and optional body retain the execution
snapshot of the admitted record through retries and an entity-pause drain. Changing the emitter
does not re-evaluate an admitted request under the replacement. Attempt timeouts, retry backoff,
an HTTP-date Retry-After, and shutdown or drain deadlines remain physical waits; a domain’s
TIME RATE does not shorten them.
A native client emitter similarly freezes the Arrow IPC bytes, source members, delivery identity and execution snapshot when the batch is prepared. Consumer retry or ACK timeout gives that same batch a new attempt reference without re-running expressions at a later domain time. Its ACK deadline, retry backoff, interconnect heartbeat and peer-silence deadline are physical waits.
Admission Windows
Paced ingestion obtains its execution time and admission window from one clock read. Given the
installed logical origin origin, period period, skew skew, and projected now, the reached
frontier is:
frontier = floor((now - origin) / period)
The eligible centers are the newest 256 positions ending at that frontier. Before a full history
exists, retention clamps at position zero; afterwards the first retained position is
frontier - 255. An event is admitted when its timestamp is within the inclusive skew distance
of any retained center.
The window is reconstructed in constant space from the mapping. It does not scan or depend on
received progress history. Delayed progress therefore cannot change admission, and tolerance past
the latest center cannot make a future center eligible. TIMESTAMP NOW uses the same snapshot that
created the window. TIMESTAMP AT <field> preserves the decoded event timestamp before testing it
against that window.
An ingest group resolves its event timestamp column once. Admission compares signed nanosecond
values against the first and last reached centers in lanes, then tests the exact period remainder
for values between them. The period reduction is prepared once for that window. A bitmap selects
accepted Arrow rows and their timestamp and ACK sidecars together. Rejected rows retain their own
ACKs and follow each output route’s message-error policy with code validation and operation
admit; a rejected timestamp cannot discard another row of the group. A missing declared timestamp
is also rejected for that row. The single-timestamp admission check has the same inclusive bounds.
SIMD Kernels describes the buffer arithmetic, exact period reduction, bitmap results, and scalar/level qualification. The clock owns the eligible centers and ingestion snapshot; the kernel consumes their already-resolved bounds and never reads time or establishes a frontier.
Unpaced ingestion has no admission window. Its clock snapshot still supplies delivery time, while an explicit event timestamp or connector-owned source timestamp remains preserved source time.
A client ingestor takes one ingestion snapshot for each batch its admission worker dispatches, after
the batch is validated and its acknowledgement root is tracked. TIMESTAMP NOW gives every row of
the batch that snapshot, and TIMESTAMP AT <field> preserves each row’s own field; paced admission
then tests each row against the window exactly as for any other ingestor. A client batch carries no
connector-owned source timestamp, so a paced domain requires its client ingestors to declare a
timestamp. The batch’s ACK TIMEOUT and its producer’s retry backoff are physical monotonic bounds;
neither waits on nor stretches domain logical time.
Logical And Physical Deadlines
A logical deadline carries its domain, lifecycle generation, and target logical timestamp. Waiting for it follows a loop:
- Read and revalidate the bound clock.
- Return a fresh execution snapshot if the logical target is already due.
- Convert the remaining logical delta to a physical duration using the installed mapping.
- Arm a process-monotonic deadline for that duration.
- Wake on the deadline, lifecycle notification, or cancellation, then evaluate the logical predicate again.
The monotonic timer is an implementation mechanism for waiting; the due predicate remains in the domain’s logical coordinate. Lifecycle changes are checked after every wake. Cancellation is a separate typed outcome rather than a clock read.
Reset coordination and its ninety-second initial-checkpoint deadline are physical, monotonic bounds. They do not wait for, pause, or re-anchor logical time. A reset that fails before generation publication restores the old branch instance with its existing logical deadlines. After generation publication, recovery completes the fresh lifetime and never recreates the old instance or its timers.
Recurring domain cadence is anchored to its initial logical schedule. The source host waits for each HTTP or Prometheus polling occurrence and passes its scheduled logical instant into the connector’s poll operation; connector crates never bind or wait on a domain clock. HTTP polling and generator cadence begin immediately, while Prometheus polling begins after one interval. When work misses multiple occurrences, the cadence returns the newest due instant once and advances directly to the first future boundary. Consumers that need both meanings keep the scheduled due instant separate from the fresh execution snapshot taken when work actually runs.
Connector Crates And The Connector Contract defines the paced source’s host and connector responsibilities; this chapter owns the cadence’s time mapping and missed-occurrence behavior.
The architecture keeps four time classes distinct:
| Time class | Internal use |
|---|---|
| Domain logical time | Expressions, explicit domain cadence, collection and flush cadence, TTL, retention, window completion, and guest-requested timeouts |
| Preserved source time | External event timestamps, broker metadata, and window membership inputs |
| Physical monotonic time | Network deadlines, retry and backoff, acknowledgements, cancellation, shutdown, drain, state checkpoint deadlines, safety timeouts, and physical batching minima |
| Actual UTC | Paced projection input, unpaced domain reads, administrative records, security validity, explicitly external observation fields, and the comparison of a server-supplied HTTP date, such as Retry-After, with the present |
Logical and physical deadlines are different types, and neither converts to the other. A
LogicalDeadline carries its domain, lifecycle generation and target logical timestamp, and only a
bound DomainClock creates one. The clock’s wait_until(deadline, &CancellationToken) runs the
loop above. It returns the reached deadline with a fresh execution snapshot, or a typed
DomainClockWaitError: the clock became unavailable, including a deadline of another domain or
generation, the wait was cancelled, or the physical deadline could not be scheduled. A
PhysicalDeadline holds a private process-monotonic instant. The connector contract’s
PhysicalDeadlineCapability creates one with after(timeout) and waits for it with
wait_until(deadline), and only the owners the repository clock-boundary check declares may take
that capability. Paired doctests beside the runtime’s clock capabilities, which build with the
server’s testing capability, hold these signatures: each wait compiles with its own deadline
type, and handing either wait the other type fails to compile. A rejected snippet is evidence about
that snippet, not proof that no other path mixes the two classes. The clock-boundary check, the
Shuttle checks of logical waiters and the public scenarios remain the evidence for the time
classes.
Actual UTC enters the data plane through a dedicated boundary, and expression engines cannot read it directly. That boundary and the capability that arms physical deadlines belong to the connector contract, which the runtime and every connector crate share, so a connector stamps arrival time through the same owner the runtime uses. Repository validation checks these ownership boundaries across the workspace, so a new runtime or connector path must choose its time class explicitly.
Physical monotonic time is measured with the timers and instants of the
primitive boundary,
nervix_primitives::time, which selects them for the build’s execution mode and grants no clock
permission: it reads no actual UTC, arms no physical deadline on a caller’s behalf, and a logical
deadline armed on its timer stays logical, as described above. Its timers follow the clock of the
runtime that polls them, so an ordinary test on a paused runtime checks a physical deadline’s
elapsed behavior exactly, and a simulated interconnect host waits in simulated time. Shuttle does
not model time: under Shuttle a timeout never measures its deadline, and a check decides whether it
wins. HTTP request deadlines and Retry-After therefore stay physical, flush cadence keeps the
domain clock, and no check establishes progress by waiting.
The web console’s clock display is an external observer: its clock-display module reads browser UTC
to project an attached paced mapping for the screen. That projection does not enter a node’s read
watermark, alter tick progress, or supply domain time to execution. The repository clock-boundary
check lists this module among the owners that may read actual UTC directly, and the console’s clock
projection samples browser UTC only through it. The check rejects the Rust UTC reads it recognizes
elsewhere in the console; it does not recognize the browser’s own Date.now(), which the console
reads for display-only freshness of its dataflow graph and its reconnect countdown.
The source check masks cfg(test) module bodies through the shared Rust scanner, including modules
with source contracts or additional attributes. It preserves line coordinates and product code
after each fixture module. A harness layer contract alone grants no clock permission to product code.
Recovery And Distributed Guarantees
The paced mapping, lifecycle generation, and authority fence recover from consensus state. Tick progress, delivery loops, local read watermarks, and process-monotonic deadlines do not. After a restart, nodes install the committed mapping and the leader reconciles an authority for the current live voter incarnations. The producer recomputes the latest due boundary from the mapping.
This produces the following failure behavior:
| Event | Result |
|---|---|
| Leader transfer | The committed mapping and authority remain unchanged unless the effective candidate set also requires reconciliation |
| Authority loss or restart | Consensus advances the authority revision and assigns another live voter incarnation; the mapping and lifecycle generation stay fixed. When the authority also led consensus, the survivors elect a leader first. Every node installs the new fence ahead of its graph installation |
| Node join or reconnect | The node installs the committed generation before execution and receives the newest retained progress once it advertises its installed domain clocks |
| Session attach before a restarted node installs | The attach waits for the node’s first installation of the committed domains, so it never reports a committed domain as missing |
| Delayed old progress | Generation, revision, identity, and authenticated-peer checks discard it |
STOP followed by START | Stop revokes the authority; start increments the generation and commits a new mapping and fence |
| Mapping or tick arithmetic overflow | Projection, deadline conversion, boundary, and tick-id operations report typed clock errors instead of wrapping or changing anchors |
| Missing mapping or authority | Paced access fails as uninstalled; it never falls back to actual UTC |
The authority’s projection, cadence, tick-boundary and rate-conversion failures render the full typed report in both the runtime event and the corresponding warning. Timestamp overflow includes the timestamp arithmetic cause beneath the clock operation. Clock attachment and progress-apply events, source cadence errors and message-error flush-clock failures preserve their supplied contexts in the same way, including the unavailable lifetime beneath a failed deadline wait.
All nodes project from their local UTC observations. The shared mapping and per-node nondecrease
rule keep one generation coherent, but they do not make simultaneous reads on different hosts
identical and do not create a distributed total order. Operators must synchronize and monitor host
UTC. Host offset appears in each node’s projection and is multiplied by TIME RATE; event SKEW
is an admission tolerance rather than a clock-synchronization budget.
Evidence
The paired doctests in this chapter check specific current API restrictions: a logical wait takes a logical deadline, a physical wait a physical one, and a function the VM calls out to receives its execution time. They check signatures, not behavior. Clock generations, attachment ordering, the attach’s wait and paced simulation are runtime obligations, held by checks that run the production owners:
- The Shuttle checks of the clock lifecycle, which Data-Plane Concurrency lists, explore nondecreasing reads of one generation, refusal of a replaced generation, publication order, each wakeup of a logical waiter, the attachment’s delivery order across ticks, unassignment, reassignment and a generation change, and an attach waiting for the node’s first installation.
- Public Cucumber outlines over one and three nodes cover the attachment contract, a client pacing simulated events by its attached clock, the CLI following a clock through its generations, refusing a missing domain and following a clock across a cluster restart, the console’s clock display, and every client runtime’s conformance probe reading an attached clock. Single scenarios cover the CLI restoring a clock after a transport loss and a reconnected console restoring its attachment.
- Registered Bolero properties keep the complete values of clock periods, skews and rates, of the attach and detach statements, and of the clock requests, replies and frames. Mutations of a current clock frame decode deterministically or fail within the verification budgets.
The Bolero properties run as ordinary randomized and corpus tests on every pull request. Their
sanitizer-backed libFuzzer campaigns run in CI only for a pull request labeled fuzz; every other
event deliberately skips them, and a skip supplies no fuzz evidence.
Property Testing And Fuzzing owns
that policy.
Cluster Interconnect
The cluster interconnect is the authenticated transport between Nervix nodes. It carries cluster membership, consensus traffic, control-plane requests, domain-clock progress, relay batches, replicated runtime state, resources, and snapshots. It is present on every live node and remains available through leader changes, placement changes, elections, and recovery.
The interconnect owns peer authentication, transport connections, wire framing, traffic isolation, bounded decoding, deadlines, and the delivery protocol for remote relay batches. The operation that uses it still owns the meaning of a request, the state it changes, and any durable recovery policy. In particular, a transport response is not automatically a statement that runtime work completed. Relay delivery exposes separate receipt, admission, and downstream-completion boundaries so callers can distinguish those outcomes.
For a transaction, a timed-out remote gate response may mean that engagement happened. The control plane retains that uncertainty and any later recovery scope in the impact report; Transaction Quiescence And Impact Inspection defines those outcomes. This chapter owns the transport deadline and failure signal.
Errors And Diagnostics explains how local typed reports and remote failure classes reach their callers and public edges. This chapter owns their wire representation and transport failure semantics.
Clients never use this transport. Client Session Protocol owns the public client boundary: its listeners, authentication, FlatBuffers frames, request correlation, command dispositions, and client recovery. The interconnect carries only node-to-node traffic, including the subscription fan-out that feeds a client’s Row frames.
Source-local compiler contracts distinguish peer/slot installation from recurring stream, frame, admission and acknowledgement operations. Retained admission records and placement progress name their bounded protocol key and transition bound. Transport selection reads immutable target and connection publications. Delivery generations own remote ACK rows; authenticated peer owners own relay protocol collections under short, peer-scoped transition guards. A retained slot admits one worker through an atomic claim; established operations read that claim. The compiler’s contracts do not establish wire delivery or concurrency guarantees; those remain the protocols and checks described here. Data-Plane Concurrency owns the compiler authoring contract.
Simulation Boundary
The transport also runs, unchanged, inside a seeded Turmoil network simulation. That simulation is a test harness outside product ownership, and Deterministic Interconnect Simulation owns it: its build mode, the fault model, supervision, replay and failure records, the scenario matrix, the commands and CI budget, and the limits of what it establishes. Execution Primitives owns the backend and target selection that make those seams build consistently across the dependency graph. The interconnect owns only the seams the simulation plugs into. Each seam has one production behavior, which is what every node uses:
- Sockets and name resolution. The TCP listener and outbound streams are the sockets of the
primitive boundary,
nervix_primitives::net, which are Tokio’s operating-system sockets, and peer names resolve through the node’s own resolver, described in Peer Name Resolution. In the dedicatedturmoiltest build the boundary selects Turmoil’s simulated TCP, and the peer resolver answers from the simulated DNS table through the lookup the boundary offers in that build alone, never constructing the node resolver; TLS, HTTP/2, the envelope codec, and Arrow IPC above them are the same code in every build. - Certificate time. Each credential bundle carries the one UTC clock its certificates are judged by, as described in Peer Identity And Authentication. Production bundles use the system clock.
- Identity entropy. The process epoch and relay grant identifiers are drawn from the transport’s configured entropy, which in production is the operating system’s secure random source.
- Deadlines. Every transport deadline is an instant of the boundary’s clock,
nervix_primitives::time, whose timers are Tokio’s, so it follows the clock of the runtime it runs on: connection setup, request and progress timeouts, reconnect backoff, relay grant lifetimes, and the drain deadline derived from certificate expiry. - CPU work. Encoding and decoding run through
nervix-execution, which submits each admitted CPU job through the boundary’s CPU-job mechanism. In the Turmoil build that mechanism runs the job as a task on the simulated scheduler, under the same admission, charge, and cancellation policy.
The transport’s concurrent maps use per-process hash seeds. Where a walk over one of them causes effects, the walk runs in the key’s semantic order instead of map order: retiring removed or departed peer targets, cancelling a node’s or every connection slot, re-establishing preconnected slots after a credential replacement, and sending relay progress reports. Walks that only count, take a maximum, or remove entries independently of one another keep map order, because it cannot change their result.
The Shuttle and Turmoil scheduler modes cannot be selected together; that build fails to compile with a diagnostic naming both. The normal runtime dependency graph contains neither.
Listener And Peer Topology
Each node exposes one TCP listener for all node-to-node traffic. Every accepted connection uses mutually authenticated TLS 1.3 and HTTP/2. The listener is independent of Raft leadership and graph placement, so every live node can receive every class of interconnect operation. TLS provides confidentiality and integrity as well as peer authentication.
Cluster discovery supplies the current node identity, incarnation, and advertised endpoint for each peer. A bootstrap seed, configured or recovered, is the exception: the initiating node knows the endpoint before it knows the remote node identifier, then obtains and authenticates that identifier from the peer certificate. Once discovered, a peer is addressed by its authenticated identity rather than by an unverified endpoint claim.
On restart, the node also uses the peer endpoints in its recovered Raft membership as gossip seeds, excluding its own endpoint. These are contact hints, not current discovery advertisements: the bootstrap exchange authenticates the answering node, and gossip then replaces the hint with that node’s current incarnation and advertised endpoint. This lets a former bootstrap node contact survivors even when its deployment has no configured bootstrap host. A node with recovered peer endpoints can also start when its configured bootstrap host does not resolve: it logs that lookup failure and contacts the peers whose recovered endpoints do resolve.
A peer advertises three endpoints independently: its interconnect endpoint as a host and port, and its client and web-console endpoints as URLs. Discovery converges field by field, so each one is either available, meaning the peer has published a value this node accepts, or unavailable. An advertisement that has not arrived yet and one this node cannot read are the same unavailable state, carry no reason, and are replaced by whatever a later round publishes. An unavailable endpoint is never filled in from a default, another field, or an earlier value, so no consumer can rebuild an address the peer has not advertised.
Discovery also carries whether the advertised process incarnation has begun terminating. That state belongs to the incarnation rather than the stable node identifier: it keeps the process available to finish existing ownership handoffs and consensus work, but removes it from new placement destinations. A restarted process has a new incarnation and does not inherit the advertisement.
The failure detector retains dead process identities separately from the live peer view. Explicit Raft member removal uses the newest observed live or dead identity to fence the stopped process; dead identities alone never make a peer eligible for membership admission. Scheduling can retain an already established health target while gossip liveness lapses, as described below. Explicit member removal is checked against the mutation owner’s effective availability, including that retained health target. Gossip absence can precede effective unavailability. Removal qualification establishes the target on both surviving voters and observes effective unavailability through each public membership warning before issuing the mutation, so either voter can own the leader’s check. The warning uses the effective availability snapshot, while gossip visibility is reported separately. Its reason names application health when that owner excludes the peer, or Chitchat when gossip excludes it after the retained health observation has aged out.
Connections are directed. Both nodes in a pair build their own outbound connections because some operations, including relay acknowledgements and cluster events, travel back over the receiver’s outbound management connection. A single connection never changes traffic class after it has been bound.
The standard pool layout is:
| Pool | Primary traffic | Outbound connections per peer | Streams per connection | Readiness |
|---|---|---|---|---|
| Management | Membership, health, consensus control, clock progress, relay progress and outcomes | 1 | 64 | Preconnected |
| Commands | Control-plane and runtime requests | 1 | 32 | Preconnected |
| Replication | Consensus log entries, runtime-state descriptions and acknowledgements, ownership handoff control | 1 | 8 | Preconnected |
| Relay | Arrow record batches | 2 | 64 each | Preconnected |
| Bulk | Resources, runtime checkpoints and snapshots, consensus snapshots | 1 | 4 | On demand |
A peer is transport-ready only after all five preconnected outbound connections are live. Bulk traffic does not delay readiness; its connection is opened when needed. The two relay connections allow unrelated relay channels to make progress independently while preserving ordering within each logical channel.
The built-in topology limit is 64 peers. A node admits at most 768 incoming and outgoing connections in total and performs at most 32 connection handshakes concurrently. Those limits cover the six possible connections in each direction for every peer: five preconnected connections plus the on-demand bulk connection.
Peer Name Resolution
A node resolves every host name the interconnect dials through the node’s resolver. Name Resolution owns that resolver: its configuration options, the order in which it answers literal addresses, hosts-file names and DNS names, its cache and TTL bounds, its limit on concurrent lookups, its failures, and what it does not implement of the operating system’s name service. This section describes where and when the interconnect asks it, and what a failed lookup means for a peer.
Where The Interconnect Resolves
At startup a node resolves its own advertised interconnect endpoint, whose first address becomes
its gossip identity address, and its configured bootstrap endpoint, every address of which becomes a
gossip seed. Each lookup has the connection setup timeout, five seconds. Failure to resolve the
node’s own endpoint fails startup with failed to start cluster membership. A configured bootstrap
endpoint must resolve when the node has no recovered Raft peer endpoints; otherwise its lookup
failure is logged at warn and skipped. An invalid configured bootstrap endpoint still fails
startup. The node also resolves the recovered Raft members’ advertised endpoints in parallel. Their
successful answers become additional gossip seeds; an unavailable recovered endpoint is logged at
warn and skipped so it does not prevent the node from starting or using another reachable member.
These startup answers are not looked up again:
gossip keeps dialling the seed addresses they produced, and the gossip identity address stays the
one resolved at startup. Once gossip discovers a live peer, the interconnect replaces the recovery
hint with that peer’s current advertised endpoint.
A discovered peer is registered at the interconnect endpoint it advertised, and every attempt to
open one of its pool connections resolves that endpoint again, inside the attempt’s connection setup
deadline. The attempt dials the resolved addresses in order, giving each an equal share of the time
that remains, so an address that refuses or never answers leaves time for the next one. The
connection budget and ordered address-attempt policy are owned by nervix-dns and shared with
outbound connector transports; interconnect retains its socket and peer failure classification.
The first address that accepts carries the TLS handshake, HTTP/2 and the connection hello, inside
the same deadline; a failure there fails the attempt. The advertised host stays the TLS server
name, which the peer’s certificate must name, and the authority of every request on the connection;
a literal IPv6 host is written in brackets there. A bootstrap exchange is the one exception: it
dials the exact seed address it was given, and the node it authenticates is dialled there until
discovery publishes the node’s own endpoint.
Pool connections are keyed by the advertised endpoint rather than by an address, so a changed answer or an expired TTL never retires an established connection. Only the next connection attempt, after a connection ends, uses the new answer once the resolver’s cached one has expired. A changed advertised host or port is a different endpoint and retires the old one, as described in Connection And Credential Lifecycle.
The connection setup deadline bounds each lookup, and the pool slot’s reconnect backoff, described
in Connection And Credential Lifecycle, is the only retry
around it, so DNS retries never multiply the transport’s own. A failed lookup is a connection setup failure: the slot retries with its
backoff, the attempt is counted with reason resolution in
nervix_interconnect_connection_failures_total, and the failure is logged at debug as
interconnect pool connection failed with the peer’s node, endpoint, pool class and slot. Only
outbound pool connections are counted; the startup lookups and the bootstrap exchange are not. A
peer whose name does not resolve stays a health target, and its probes fail as they would for a peer
that cannot be reached.
Peer Identity And Authentication
Every node certificate is signed by the configured cluster CA and supports both the TLS client and server roles. It contains exactly one canonical identity URI:
nervix://cluster/<cluster-id>/node/<node-id>
The certificate also contains a DNS or IP subject alternative name for the node’s advertised endpoint. Local credentials are rejected at startup unless their cluster identifier, node identifier, endpoint name, validity period, and key all agree. A remote connection is accepted only when all of the following are true:
- its certificate chains to the configured cluster CA
- its identity URI names the same cluster
- its node identifier is the expected peer identifier, once that identifier is known
- its endpoint subject alternative name matches the advertised endpoint being contacted
- its certificate is currently valid
- TLS negotiates the
h2application protocol
“Currently valid” is judged by the UTC clock of the local credential bundle. Production bundles use the process wall clock. Rustls verification on both sides of the handshake and the transport’s own validity and expiry checks read that one clock, so they cannot disagree about a certificate.
The first HTTP/2 exchange binds the connection to its authenticated node, traffic class, advertised endpoint, current process epoch, and wire-contract fingerprint. The receiver cross-checks those claims against the TLS identity and the connection slot it is accepting. This prevents a valid peer from relabeling a connection as a different node or traffic class.
Three identities serve different purposes:
- The certificate node identifier is the stable authenticated identity of a node.
- The discovery incarnation and endpoint generation identify the current cluster presence and advertised address of that node. The incarnation also names the run of a node that registered a relay admission or record acknowledgement, as Acknowledgement Registrations describes.
- The process epoch identifies one running interconnect process and fences in-memory delivery state across restarts. It is drawn from the transport’s entropy when the transport binds, which in production is the operating system’s secure random source.
Replacing an endpoint, restarting a process, and rotating a certificate therefore have distinct meanings even when the stable node identifier does not change.
Coordination operations use a typed identity composed of the authenticated coordinator node, its current process epoch, and a process-local sequence. The sequence begins independently in every process; the node and process epoch make equal sequence values distinct across concurrent leaders and restarts. For every coordination request, the receiver verifies the node and process epoch against the bound connection before the application handler can observe the request or open a coordinated response stream. A process therefore cannot issue or replay an identity that belongs to another node or to an earlier run of the same node.
Entity-gate engagement binds that identity to one canonical domain, relay set, affected-entity set, and purpose. A retry succeeds only for the same identity and complete scope; using the identity for a different scope is rejected. Drain status and release carry the same identity, so a delayed message from another coordinator or process incarnation cannot observe or remove the hold. Planned ownership-handoff capture, preparation, confirmation, activation, and discard also carry this identity. Prepared handoff state persists it in the sole current stored shape and checks it again before activation or cleanup after a restart. The handoff’s committed schedule-transition ID still names the schedule change; it does not authorize coordination traffic.
A model alteration that includes an ingestor and shared downstream relay uses two entity-gate operations. An intake-only scope, with no relay gates, first suspends the affected ingestors on every node and drains their admitted work. The full subgraph operation then engages before that intake operation is released. Each operation has its own authenticated coordination identity and exact scope, and both use the same alteration deadline. Status, retries, receiver-owned release, and lease expiry retain the normal identity checks throughout this overlap. The coordinator does not change an existing operation’s scope to advance the drain.
A coordinated WASM guest-state reset uses the same authenticated coordination identity and adds its typed reset scope to the entity-gate purpose. Engagement publishes a branch-selective relay fence: one concrete fingerprint, the explicit unbranched instance, or all concrete branches. A retry must match the complete domain, relay and entity set, purpose, and scope. It cannot widen a one-branch hold or release another reset’s hold.
An operator’s NSPL reset enters through the ordinary typed session command protocol and ordered transaction planner. The transaction step retains the command execution reference and exact scope; the leader uses that reference when invoking this same coordinator request. A reconnect or a new leader resumes the recorded step with the same identity. No separate public reset wire request or JSON command path is introduced.
The management pool carries one typed coordinator request, which a node sends to the leader for a
reset that starts outside the ordered transaction path, such as a guest’s request for a new lifetime
of its own branch. It carries the stable execution reference and exact reset target to the leader,
which runs the single control-plane operation. Its lower-level runtime requests have three actions.
Prepare reaches only the scheduled processor owner and creates fresh guest state while retaining
enough stopped branch state to abort before publication. ActivateCommittedSchedule reaches every
gate participant, applying the committed schedule locally to replicas before the owner writes and
replicates its initial checkpoint. This ordered activation does not join the ordinary cluster-wide
runtime-revision barrier: an owner whose initial checkpoint failed is the node that keeps that
barrier incomplete. The background runtime applicator still owns the node’s prepared and ready
revision observations. Abort is valid only before publication and restores the retained branch
tasks. Responses carry the complete classified failure chain. A transport success proves only that
the receiver performed the requested action; the Raft-backed Publishing and Ready schedule
phases remain the durability and usability boundaries.
One more management request belongs to the same operation. A processor that declares
ON REJECTED STATE RESET raises a refused guest-state lifetime from the node that owns the branch,
and that node forwards it to the current leader as a typed recovery request naming the domain, the
processor, the refused branch or the explicit unbranched instance, the generation whose snapshot was
refused, and which of the two guest verdicts it gave. It carries no reset reference: the leader
derives one from that identity, so a request that is retried, forwarded again after a leadership
change, or raised by a new owner drives the very same coordinated reset instead of a second one. A
leader that finds the branch already past the reported generation answers that the lifetime is gone
rather than resetting the one that replaced it. The response carries the classified failure chain
and is not itself the durability boundary; the recovery’s spent attempt and the reset’s schedule
phases are.
The coordinator records every destination as an attempted participant before sending its prepare request. A timeout, cancellation, or missing response after the destination persisted the request therefore remains explicit cleanup work. A participant that refuses capture, preparation, forced recovery preparation, confirmation, activation, or reconciliation answers with a rejection whose text is its complete failure chain rather than only the outermost failure, so the coordinator reports, for example, a destination WASM guest’s classified restore failure with its stage, branch, module, and saved state revision. Discard is exact and idempotent over the coordination identity, transition ID, domain, and entity; a delayed discard for one operation cannot remove a replacement prepared by another operation.
The current leader reconciles durable preparations after leadership or live-incarnation changes. It first commits a consensus barrier and sends its log position with the reconciliation request on the replication pool. Each participant applies through that position before consulting its local committed schedule. An exact ownership transition in that schedule preserves its preparation even when the original coordinator or destination process has gone. An uncommitted preparation remains only while the original coordinator process and both bound participant incarnations are live and the exact ownership-handoff gate is still held; every other preparation is discarded durably. A second pass after the gate deadline reclaims work that was active during the first pass.
Ownership-handoff engagement answers after admission is fenced and its force flush is requested. The coordinator then queries the exact operation’s drain status. A participant publishes its capture freeze before returning a drained status, and capture requires that coordination identity’s freeze in addition to its held gate. Pending status retains the work counts used by timeout diagnostics. Each engagement request keeps the two-second reply bound; a typed timeout retries the same identity and complete scope within the original preparation deadline. A retry observes the existing receiver-owned operation and lease. Other request failures keep their existing classification.
After a receiver admits a new gate engagement or release, a receiver-owned task finishes that state transition even if the requesting connection disappears. Coordinator loss therefore cannot strand an operation in a partially engaged state or cancel cleanup after the receiver accepted it.
Each receiver lease is a distinct in-memory instance even when an identical request is re-engaged after release. Its deadline task may remove only that exact instance. An earlier deadline can therefore neither release nor erase a replacement lease occupying the same logical operation key.
A stopping node moves its own scheduled work with one typed commands-pool request to the current
leader, stopping_node_drain. It names no node and carries one of two actions: drain, which cordons
the sender, carries its remaining drain budget, and moves its scheduled work through planned
ownership handoffs, and release, which clears the cordon that drain set. The leader acts for
the node the authenticated connection belongs
to, so the certificate that admitted the connection is the whole authorization: a node can drain
and uncordon only itself, and no user credential takes part. The answer is completed or failed, each
with the leader’s account of the action for the stopping node’s log, or not the leader, which leaves
the sender to ask the leader it observes next. A node that does not lead when the request arrives
changes nothing. A leader that loses leadership before completing a drain also answers not the
leader, logs its interrupted report, and preserves any moves already committed. The next leader
plans the remaining moves from the current schedule, within the sender’s remaining budget. A
leader that loses its leadership while it writes a release also answers not the leader, because
clearing a cordon again through the next leader is harmless. The leader runs the action in a service
task of its own,
so a drain that has begun finishes, and releases what it holds, even when the sender’s deadline
abandons the request first. For a drain that deadline is what remains of the sender’s drain
timeout; the leader also uses the budget carried by the drain action to bound each unit’s gate,
state preparation, and activation waits, leaving a window for later units and the sender’s in-place
drain. For a release it is the bound
Releasing The Drain Cordon states.
Topology Cases owns when a node sends it.
Wire Contract And Payloads
All nodes in a running cluster use one current wire contract. A fixed fingerprint covers the set of supported operations and their encoded shapes. A fingerprint mismatch rejects connection setup; there is no version negotiation or alternate decoding path. A wire-contract change therefore requires a coordinated cluster stop and start with all nodes on the same version. The subscription-interest visibility request includes the advertisement version, and its current wire fingerprint fences that request shape during connection setup.
The fingerprint also covers fixed-width 64-bit counts in Models, transaction commands and records,
and WASM inspection results. These fields use the vocabulary’s CountAsU64 adapter and checked
native decoding; an unrepresentable count fails decoding. Window processor state has current
runtime-state kind tag 8, and its bulk snapshot codec validates the NVXWIN64 frame signature
before decoding histogram delayed-removal bucket indices in the same count representation.
Control records use bounded rkyv archives. The receiver validates an archive, including its shape
and nesting depth, before exposing it to an operation handler. Encoded and decoded memory is charged
to the traffic class before decoding begins. Unknown operations, a pool mismatch, malformed
archives, and values above the operation limit fail at the transport boundary.
Validation checks an archive’s shape. Semantic values such as typed names are checked when the record is read back, so a record can be refused after part of it was read. The shared archive reader owns each completed list or fixed-array element while reading the next, and releases the initialized prefix and the list, boxed, or shared allocation if a later element is refused. A relay grant request whose acknowledgement registrations include a registrar that is no node’s name is refused with everything already read freed. The operation handler receives no part of that request.
Control-operation responses preserve a typed failure class and subject across the wire. A receiver can distinguish a node that rejects ownership, an unavailable subject, a subject that is not ready, and an operation that ran and failed without parsing display text. Only the final class carries an operator-facing reason; callers decide retry and relocation from the class and subject.
Relay metadata uses the same validated control encoding, while relay bodies remain Arrow IPC from the source relay to the destination runtime. Bulk operations transfer opaque byte chunks and let the owning resource, snapshot, or state protocol interpret the stream.
Before the receiver gives a relay Arrow stream to Arrow’s reader, the shared IPC framing owner
checks every message’s continuation marker, metadata, declared body and column buffers against the
received bytes. A malformed stream reports ArrowBodyError::Framing before the reader can allocate
or slice from an unchecked length. A reader panic on malformed metadata that passes framing is
reported as ArrowBodyError::Decode.
The primary payload limits are:
| Payload | Maximum size |
|---|---|
| Management event | 64 KiB |
| Command | 1 MiB |
| Replication batch | 2 MiB |
| Encoded relay batch | 32 MiB |
| Decoded relay batch | 32 MiB |
| Relay decode scratch space | 16 MiB |
| Bulk transfer chunk | 64 KiB |
| Snapshot section | 8 MiB |
A receiver also holds an Arrow body to its own framing before it decodes it. The body is one canonical IPC stream: every message opens with the continuation marker, and the end-of-stream marker ends the body. Every length the stream declares must lie within the bytes the body carries: each message’s metadata, its body, and each column buffer inside that body. A body that is framed otherwise is refused as misframed, so a declared length never sizes an allocation the body does not back, and a column buffer is never sliced outside its message. The same scan refuses, as undecodable, a body whose schema declares a field type Nervix does not carry, a dictionary encoding among them, or whose record batch is at odds with that schema. It opens every Arrow section of a sealed snapshot, a checkpoint or a backup archive.
HTTP/2 flow control adds another bound. Each stream begins with a 64 KiB receive window, each connection begins with a 256 KiB receive window, and request headers are limited to 16 KiB. A receiver releases more HTTP/2 credit only as it accepts and accounts for more data. Large streamed objects can therefore cross the cluster without either endpoint holding the whole object in memory.
Traffic And Resource Isolation
An operation’s type fixes its pool, admission quota, response type, and deadline policy. Callers do not select a less restricted pool at the call site. This preserves traffic isolation even when a busy subsystem issues many requests.
The management connection reserves its 64 outbound stream slots as follows:
| Use | Reserved slots |
|---|---|
| Shared management operations | 32 |
| Discovery | 4 |
| Application liveness | 8 |
| Progress reports and relay status | 8 |
| Runtime and relay admission | 4 |
| Relay cancellation | 4 |
| Relay terminal outcomes | 4 |
The replication pool reserves two of its eight slots for the active ordered consensus-append stream and a replacement while the previous generation closes. The other six are shared replication slots. The bulk pool reserves two slots for resources, one for snapshots, and one for other bulk work. Commands and relay bodies use the shared capacity of their dedicated pools.
With the standard node capacity, typed requests also have independent node-wide admission quotas, applied separately to incoming and outgoing work across all peers and connections:
| Request use | Standard admissions per direction |
|---|---|
| Shared | 1,024 |
| Consensus append | 8 |
| Resource transfer | 8 |
| Snapshot transfer | 8 |
| Discovery | 8 |
| Application liveness | 32 |
| Domain-clock progress | 8 |
| Runtime and relay admission | 8 |
| Relay cancellation | 4 |
| Relay terminal outcomes | 4 |
These reservations mean that ordinary management traffic cannot consume the capacity required to resolve already-started relay work or determine whether a peer is healthy.
Leasing an established stream allocates nothing and acquires no shared discovery map. An immutable
persistent target table selects the peer’s fixed pool arrays. Each slot retains its cancellation
lifetime, one atomic worker claim and an ArcSwapOption containing the current authenticated
connection. A lease retains that exact connection and takes its existing pool/subquota reservation.
Only a first use can win the worker claim, including on-demand bulk. The worker clears its connection
publication on loss and publishes a fresh connection after authenticated reconnect. The cold
connection registry remains available for registration, statistics and exact teardown; predecessor
teardown removes only its own allocation.
Endpoint or TLS replacement cancels the preceding slots before publishing new fixed arrays. Peer retirement therefore cannot expose a replacement connection through a stale slot. A dial-address update for an unchanged endpoint preserves slot identity and affects only the next connection attempt. Such an update retains the fixed arrays’ shared owner under its new target wrapper, so a concurrent peer withdrawal still withdraws that exact pool lifetime. A recreated pool has a distinct owner, even at the same endpoint, and survives a predecessor’s withdrawal. The fixed array scan retains round-robin selection and each class’s existing capacities.
An operation that finds every stream of its class and subquota to a peer leased waits for one to be released, within its request deadline. Each peer keeps one wakeup for each class and subquota, and a released stream wakes a waiter of exactly its own class and subquota. A single wakeup shared by every waiter could reach one that cannot use the released slot, while the waiter that can use it slept on until its deadline. The waiter registers for that wakeup, and for any change of the peer’s connections, before it checks the pools a second time, so a release between its two checks still wakes it. The wakeups belong to the peer rather than to one of its targets, so a waiter of a target that was replaced meanwhile still hears a release of the replacement.
A connection stops granting stream leases the moment it begins to drain, whether it is retiring or the transport is shutting down. A lease checks for the drain only after it holds its slot, so it either returns that slot at once or was granted before the drain began and is one the drain waits for. The drain therefore completes only after every leased slot has returned, and the connection grants no further lease while it closes.
Memory and CPU execution are isolated by class:
| Class | Memory budget | CPU execution class |
|---|---|---|
| Management | 8 MiB | Control |
| Commands and replication | 24 MiB | Control |
| Relay | 192 MiB | Data |
| Bulk | 32 MiB | Bulk |
The total interconnect memory budget is 256 MiB. Reservations cannot borrow from another class. The relay budget holds two worst-case operations, each consisting of a 32 MiB encoded body, a 32 MiB decoded batch, and 16 MiB of decode scratch space. The commands and replication budget holds the four decoded replication batches a follower keeps resident beside one batch being encoded, so a follower whose receive window is full can still produce the answer that releases it. Startup validates the relationships among operation limits and these budgets, including the paired work that must fit for independent operations to keep making progress.
Stream slots are isolated per connection, and therefore per peer. Admission quotas, memory budgets, and CPU wait queues are isolated per class and shared by every peer of the node. A slow or stalled peer therefore holds at most the stream slots of its own connections. Once it holds the 32 shared management streams of a connection, a further shared operation to that peer waits for a slot until its own deadline, while liveness, cancellation, and discovery still use the reserved streams of the same connection. Every connection to another peer keeps all of its slots. A stream whose reader stops reading holds one HTTP/2 stream window of response bytes: flow control stops the producer once that window is spent, and five seconds without progress resets the stream and returns its admission and memory. A relay batch the receiving application holds without admitting keeps the reservation of its exact body, the largest decoded batch, and decode scratch. Another channel still receives grants and admission beside it, and cancelling the held batch returns the whole reservation once the application drops the batch.
A CPU class admits each job separately: one of its workers runs, a bounded number wait, and the
next job is refused at once instead of joining an unbounded queue. A typed request runs several
jobs of its class in turn, encoding its payload and envelope and later decoding the response.
While a burst keeps the queue full, a request that was admitted for one job can therefore be
refused at its next one. A request refused at its first encode fails with RequestError::Encode,
and one refused at a later stage fails with RequestError::Transport. Every refused job holds no
memory charge, and every other class keeps admitting independently.
The stalled-peer simulation checks these bounds with exact values: a stalled peer and a healthy peer share one hub, and every observation of the hub’s pools, streams, admissions, worker queues, and memory budgets stays within its configured bound until teardown releases them.
Exchange Forms
The interconnect provides five exchange forms. Each keeps transport mechanics separate from the operation’s domain semantics.
- Bounded event. The sender transmits one validated event and receives an empty success response after the receiver accepts that event. Cluster events and asynchronous acknowledgements use this form.
- Typed request and response. The request type declares its operation name, response type, traffic class, admission subquota, deadline, and live-target requirement. Remote typed errors are kept distinct from transport failures. Application health and domain-clock progress use this form so their reserved quotas and typed outcomes apply independently of generic events.
- Streamed response. The receiver declares the exact byte length, then sends a flow-controlled
sequence of chunks. The reader rejects early end, extra bytes, and a stalled chunk. Dropping the
reader cancels the stream and releases its reservations. The producer’s opening callback and
each chunk carry a local
StreamHandlerErrorreport; an admission, storage, or encoding cause stays beneath that handler context until the response reaches the wire boundary. - Ordered duplex stream. Each direction sends length-prefixed, validated frames in order and may close independently. Opening the stream is bounded by the operation’s declared setup deadline. An idle established stream is valid; the owning protocol sets deadlines for answers it is awaiting. The initiator’s sender reports when the peer’s flow control last accepted its bytes, so that protocol can tell a slow answer from a peer that accepts nothing. Receiving is cancel-safe on both ends. While a frame is being written, each nonzero flow-control grant restarts the five-second write progress bound. A frame has no total transfer deadline while bytes keep advancing; zero-capacity notifications do not restart the bound. Expiry resets that stream with the typed transport progress timeout. A frame is decoded under the pool’s memory and CPU admission after it leaves the stream, and a receive its caller abandons, because a timer or command it selects against won, leaves that decoding to the next receive, which finishes the frame before it reads another. No abandoned receive loses a frame or reorders one. A stream has one codec, fixed by its operation’s pool class: the opening message, the initiator’s frames and the responder’s answers are all encoded and decoded through it, under that class’s memory and CPU admission and within its payload limit, so what one end encodes is exactly what the other decodes. A frame that would grow past the limit fails to encode, as a typed failure of the end sending it, before any of its bytes is sent. Consensus append traffic, client producer links and client consumer streams use this form.
- Relay delivery. A management-plane grant reserves receiver capacity before an Arrow body is sent, followed by explicit runtime admission and optional downstream record acknowledgements.
A typed request separates the operation’s own outcome from the transport’s. A handler that cannot produce its value answers with one of four classes, and the asking node acts on the class rather than on any text. Rejected means the answering node does not serve that subject at all, so a caller working from a stale schedule resolves the owner again instead of retrying the same node. Unavailable means the subject does not exist there. Not ready means it exists but cannot answer yet, so the identical request can succeed later. Failed means the operation ran on the answering node and lost.
Every class names the subject it refused: a domain, one entity of a domain, one kind of runtime state held for an entity, or one subscriber’s interest in a relay. The caller therefore reports what failed without retaining the request it sent, and it never has to parse a message to decide what to do. Only the failed class carries the answering node’s own description, because a failure inside another node’s subsystem is opaque to the caller and that text exists for the operator reading it. State snapshot exchange, dataflow node status, domain and entity drain status, entity gating, metric description, relay, hash map and ingestor description, hash map queries, and subscription-interest visibility all answer in these terms.
Each handler registration publishes a complete replacement dispatch table, so an arriving operation finds its handler without taking a lock and registrations that race each other all take effect. Registrations racing for one operation name within an exchange form publish it exactly once, and every other one is rejected as already registered. Discovery likewise publishes the complete live-node set at once, and the live-target check a typed request makes reads that set without a lock. A request waiting for its target to leave registers for membership changes before it reads that set, so a change published between the read and the wait still ends the wait. Until discovery publishes its first set, every target counts as live so that bootstrap discovery can reach its peers.
Connection setup has a five-second deadline covering TCP, TLS, HTTP/2, and connection binding. The default bounded request deadline is ten seconds and includes time waiting for an admission or stream slot. An operation can declare a tighter or longer semantic deadline; application health uses one second, domain-clock progress uses two seconds, and resource and state stream openings use longer operation deadlines. Once a streaming body is moving, five seconds without progress fails that transfer. Queueing never creates an unbounded extension to a declared deadline.
Remote Relay Delivery
The relay protocol connects the owner-local fan-out described in Relay to a concrete runtime branch on another node. It preserves the Arrow batch as a columnar payload and carries only bounded metadata beside it.
A logical outgoing channel is identified by the authenticated destination node, payload role, domain, destination relay, and concrete branch. Different channels may run concurrently. Within one channel, the sender preserves FIFO order and permits at most one batch to wait for runtime admission. Each sender channel has a unique incarnation and a monotonically increasing sequence number.
For fan-out, the source encodes an Arrow batch once and shares those immutable encoded bytes across destinations and transport retries. Delivery metadata, admission state, and attached record acknowledgements remain specific to each destination channel.
A delivery proceeds as follows:
- The sender leases a relay-body stream before requesting receiver capacity. This prevents a saturated body pool from holding a remote reservation that it cannot use.
- The sender requests a grant over the reserved management admission quota. The request identifies the delivery process, channel, sequence, payload length, branch, relay, and acknowledgement shape.
- The receiver verifies the sender process epoch and delivery order. Before issuing a grant, it reserves the encoded body, maximum decoded batch and scratch memory, an incoming queue item, and capacity for the eventual terminal admission outcome.
- The grant remains valid for five seconds. The sender transmits the exact Arrow IPC body over the relay pool and names the grant and both process epochs.
- The receiver reads exactly the granted length under the reservation, records body receipt, and places the work in its bounded application queue. The HTTP/2 body response confirms receipt by the receiving process only.
- The application lane validates the body’s Arrow IPC stream, which must hold a schema message
of the field types Nervix carries, one uncompressed record batch that declares the field nodes
and buffers that schema’s fields take with every buffer inside its body, and the end-of-stream
marker, and then the exact schema, metadata, branch, and record-acknowledgement count, then
resolves the configured concrete runtime branch. Work for one channel remains ordered,
while other channels continue independently. A payload that fails this validation never reaches
the branch. The receiver keeps the failure as a report of
RuntimeError::DecodeRemoteRelaywith the typedRemoteRelayDecodeErrorbeneath it, which names what the payload got wrong and keeps the decoder’s own failure beneath that, and answers the payload’s admission registration negatively with the whole chain rendered as the reason. A payload that carries no admission registration is refused the same way, with no registration to answer. - When the concrete runtime branch accepts the batch, the receiver sends a terminal admission outcome over the reserved management capacity. The sender can then release the channel for the next batch. A batch that carries record acknowledgements is admitted only once the receiver has charged its acknowledgement watches; while the relay budget has no room for them, the receiver holds the batch back unadmitted, as Bounded Correlation And Peer Owners describes.
- If the send includes record acknowledgements, later acknowledgement events report downstream processing completion. Detached sends and subscription fan-out end at admission.
These steps expose three intentionally different outcomes:
- Body received means the complete Arrow body reached the receiving process.
- Admitted means the intended concrete runtime branch accepted the batch.
- Acknowledged means the downstream work represented by an attached record acknowledgement completed.
While admission or an attached acknowledgement is unresolved, the receiver sends coalesced progress
events through the reserved management quota. The sender treats five seconds without progress as a
stalled exchange and bounds the total admission wait at five minutes. Once the delivery is admitted,
the sender waits for an attached record acknowledgement only while the receiver keeps reporting it,
and fails one the receiver reports nothing about for fifteen seconds, as
Acknowledgement Registrations describes. Progress keeps a live
attempt from being mistaken for a disconnected one; it does not change the delivery outcome.
For an attached acknowledgement, progress also carries a monotonic sequence and whether all of its
remaining handoff shares are parked, on REQUIRED WAIT or in a window that retains their rows. Each upstream node parks or reactivates its
own attached share in sequence order, so a domain drain excludes a parked chain across relay hops.
The eventual terminal acknowledgement still resolves every share; parking does not acknowledge the
source or persist an acknowledgement. Admission progress carries no parked state.
Acknowledgement Registrations
The sender correlates the runtime admission of a delivery and every attached record acknowledgement it waits on through a registration it places in the delivery. A registration names the waiting entry by a number and names the run of the sending node that registered it: the node and the discovery incarnation of that run. Every process numbers its registrations from one, so the number alone repeats across restarts of a node, and the run is what keeps the registrations of different runs apart.
The receiver returns every progress event and terminal outcome to the node the registration names,
carrying the whole registration back with it. The sending node resolves an entry only when the
registration names its current run. A receiver can still be resolving what an earlier run
registered after that run ended: a record acknowledgement whose downstream work completes after the
sending node restarted is the common case. Such an outcome is rejected and logged at debug. It
never resolves the admission or record acknowledgement the current run holds under the same number,
so a success reported for an earlier run can never acknowledge a record, and so commit it at its
source, before its own delivery completed. The earlier run’s entries ended with its process, and
its sources redeliver the records they had not committed.
A receiver issues a grant only when the admission registration names the authenticated sending node. Admission bookkeeping on both ends is keyed by the peer and the whole registration, so a terminal outcome addressed to an earlier run neither completes nor retires the admission a later run registered under the same number.
Record Acknowledgements The Receiver Stops Reporting
A receiver reports every record acknowledgement it holds to the node that registered it. While the downstream work the acknowledgement stands for continues, it sends a report 100 milliseconds after its previous one was delivered or given up, and when the work completes it sends the terminal outcome once. Each report and the outcome is one bounded event that the receiver retries for up to five seconds and then gives up. Once the outcome is given up, or the receiver’s run ends, nothing reports that acknowledgement again.
The registering node therefore waits for an acknowledgement only while the receiver keeps reporting it. From the moment the delivery that carries the acknowledgement is admitted, a sweep once a second counts the passes in which the receiver reported nothing about it, and fails the acknowledgement once fifteen seconds of such passes have gone by. The bound outlasts two consecutive reports that each exhaust their five-second deadline, so a receiver that is still working on the record is not mistaken for one that stopped. Before admission, the delivery guard resolves cancelled work and the sweep bounds an abandoned registration at the five-minute total admission wait. The sweep counts its own passes rather than elapsed time, so a registering node whose own execution stalled, such as a paused container, does not fail acknowledgements whose reports it could not receive meanwhile.
A failed acknowledgement resolves negatively exactly once. The sweep and report transitions use
the exact delivery generation’s guard, so a report that arrives first keeps the acknowledgement
pending, and a terminal outcome or the delivery’s own failure that resolves it first leaves the
sweep nothing to fail. The source attempt fails with it and redelivers the record along the current
routes, so a sink that already completed the record can receive it again. A report or outcome that
arrives after its acknowledgement failed finds no entry and is rejected at debug.
Every node on a record’s path applies the same bound. A relay owner that routed a record to the
node of an attached consumer reports the record alive to the source’s node for as long as the
record’s acknowledgement tree is unresolved. Without the bound, a terminal outcome lost between the
consumer’s node and the relay owner would keep the source’s acknowledgement alive, and its
ACK TIMEOUT from ever passing, for the rest of the relay owner’s run. With it, the relay owner
fails the acknowledgement fifteen seconds after the consumer’s node fell silent, stops reporting
the record, and the source’s retry takes over. Each sweep that failed acknowledgements logs, at
warn, one line per receiver with the number it failed.
Bounded Correlation And Peer Owners
The sender has 8,192 delivery positions and a separate 8,192 admission positions. One delivery owns all its record rows, with independent outcomes; filling delivery capacity therefore leaves room to register its runtime admission. The opaque acknowledgement number encodes a position, generation and row. Resolution validates all three, together with the registrar’s full discovery identity. An exhausted generation is sealed permanently. A delayed report, terminal reply or cleanup cannot change a replacement occupying the same position. Unused positions are claimed on demand; only retired positions enter the bounded free queues. Shutdown seals fresh claims before scanning positions that were ever claimed, so a concurrent registrar either occupies a position shutdown visits or finds it closed. The once-a-second silence sweep scans the same claimed positions and no others: a share whose first claim races a sweep is counted from that sweep or from the first one after its registration, and a node that has forwarded nothing sweeps nothing.
Record storage and receiver ACK watches are charged to the relay memory budget. One task per
admitted batch multiplexes its row watches, with a fixed charge of 1 KiB per row plus 4 KiB for the
task; a wide frame therefore does not allocate one task per acknowledgement. Pending rows report
progress every 100 milliseconds, below the registrar’s fifteen-second silence bound even when two
reports each exhaust their dispatch deadline. The same cadence keeps local emitter and
message-error acknowledgements alive while a connector request is pending, giving a one-second
source ACK TIMEOUT multiple chances to observe progress. Refusing record storage is typed and
occurs before the delivery is sent. Cancellation before admission resolves every held share
negatively and returns its position. Completion of the last record returns the delivery storage and
its charge.
The receiver charges a batch’s watches before it decodes the batch and before runtime admission.
While the relay budget has no room for them, typically because the watches of earlier batches are
still reporting their rows, the receiver holds the batch back instead of refusing it. The batch
stays unadmitted, so the transport keeps reporting it alive and its sender keeps waiting within the
five-second progress and five-minute total admission bounds; the sender’s channel carries nothing
else meanwhile, which pushes back on the source that fills it. The held batch keeps the reservation
of its grant. Every 10 milliseconds the receiver reads the budget and takes the charge once it
fits. It never waits in the budget’s queue, because a place there takes every byte the class
releases until it is satisfied, which would keep the node’s other relay work, including forwarding
the rows whose acknowledgements end the watches it waits for, behind it. A held batch counts no
refusal. The hold ends as soon as the sender cancels the delivery, its peer ends, or the runtime
stops. Only watches larger than the whole budget, or a hold still without room when the
five-minute bound has passed, are refused, with the typed admission cause beneath the runtime’s
RemoteAckAdmission. Each watch returns its row’s share as soon as it reports the row’s terminal
outcome, gives that outcome up, or ends because the registrar run left or changed, so room
returns at the rate rows are reported rather than a whole batch at a time; the task’s share
returns when the batch’s last watch ends or the runtime shuts down. The membership writer
publishes immutable process identities; a watcher gives initial discovery five seconds and stops
once a previously observed registrar is absent. Terminal delivery retains the five-second event
deadline described above; this does not promise suppression of replay duplicates after lost outcomes.
An authenticated connection retains its peer’s protocol owner. That owner alone mutates ordinary grant, attempt, channel, admission and outbound-correlation collections. Each synchronous guard is scoped to that peer, is released before transport waits, and follows peer then record lock order. Runtime admission and cancellation use one irreversible atomic verdict. Peer removal or epoch replacement cancels unadmitted records and releases transport item and terminal permits, including when runtime still borrows an admitted intake. Its admitted verdict remains valid. Decoded metadata keeps its memory charge until its last borrower releases it.
With incoming queue capacity Q, each peer holds at most Q grants, attempts, active channels and
admission records, at most 2Q channel watermarks, and at most 2Q outbound attempts and admission
correlations. New unrelated channels are refused when retained watermarks occupy their budget;
they are never evicted early to admit a replay. Lost terminal replies remain reconcilable. A
sixty-second sweep reclaims protocol state after ten minutes without progress or reconciliation;
live admission reports renew retained records. Authentication before gossip membership permits
discovery, while relay operations wait for live membership. An owner still awaiting membership
ends after ten minutes and closes its bound intake connections. Routing publication has at most
the configured peer limit; normal frame and ACK operations use retained owners rather than a
node-wide shared-map guard.
Ordering, Retry, And Reconciliation
A delivery identity combines the sender process epoch, receiver process epoch, channel incarnation, and channel sequence. The receiver also records the expected content for that identity. Repeating the same identity with different content is rejected.
After a connection loss, the sender first reconciles with the same receiver process. If the body was already received or the batch was admitted, the receiver returns that known state and the sender does not resend the body. Sequence watermarks reject reordering and duplicate enqueue. Inactive sender channels rotate after five minutes. A receiver keeps a channel’s watermark while a batch granted on that channel is unresolved, and for at least ten minutes after the watermark was last recorded or consulted by a grant, status, or cancellation request, so ordinary reconnects can still reconcile. A consultation refreshes retention under that peer’s protocol guard, together with its sequence decision. Independent peers have independent guards.
The relay reconciliation and receiver-restart simulations check this boundary through the production authenticated connection. They lose the reply after an Arrow batch reaches the receiver, reconnect to the same process or restart it, and require reconciliation within this retention contract and an indeterminate result against a new process epoch.
Each attempt carries the channel and admission identities it was granted under. Once the receiver has delivered an attempt’s terminal outcome, it retires that same attempt: it advances the channel watermark and releases the attempt, its channel occupancy, and its admission without rebuilding either identity or looking the admission up again.
Relay attempts, grants, watermarks, admission state, and record-acknowledgement owners are in-memory hot-path state. A receiver process-epoch change therefore makes an unresolved delivery indeterminate. The transport does not replay it automatically against the new process. If the source’s policy calls for another attempt, that attempt opens a new channel incarnation and is a new delivery identity.
Cancellation And Branch Changes
Cancellation and runtime admission compete through one atomic transition. A cancellation that wins before admission permanently fences that delivery identity, including when cancellation reaches the receiver before the original grant request. If admission has already won, cancellation returns the known admitted result and does not undo work handed to the runtime.
Removing or evicting a concrete branch cancels its unadmitted channel generation and releases its reserved work. If the branch appears again, it uses a fresh channel incarnation and sequence. This keeps a previous branch lifetime from being confused with the new runtime instance.
Consumers That Leave The Receiver
The relay owner also fences the start of each buffered batch’s fan-out against its local schedule swap. It acquires a dispatch permit before reading the local and remote consumer sets, and keeps that permit through fan-out, attachment of the consumer ACK shares, and resolution of the owner’s share. A swap closes the gate and waits for existing permits before changing those sets. If the gate is already closed when a buffered batch starts fan-out, the owner fails its record acknowledgements and the source retries after the schedule changes. It cannot wait for the gate while holding that buffered batch: the swap’s drain counts the batch, so such a wait would prevent the swap from finishing. In particular, an attached sibling on the old destination cannot acknowledge a batch while another attached consumer moves to a new destination before the owner selected routes for that batch.
The receiver decides admission before it hands the batch to the runtime consumers of the relay, and it hands the batch to the consumers that run on the node at that moment. An owner routes an attached batch to a node because its schedule places an attached consumer of the relay there. That consumer can leave the node after the owner routed the batch: a forced recovery moves every runtime node off a node that application health or gossip judged unavailable, including one that is still running, and the node stops the moved consumer when it applies the published schedule. The gap between admission and dispatch can be long: the receiver sends the terminal admission outcome to the owner before it dispatches the batch, and that send can take up to its five-second deadline while the owner is unreachable.
A routed batch that carries record acknowledgements and finds no attached consumer of its relay on the receiving node fails those acknowledgements. Its admission stands, so the transport does not send it again, but the owner receives a failed record acknowledgement and fails its source attempt. The source then redelivers the record along the owner’s current routes. The receiver never reports an attached record acknowledged when no attached consumer took the batch, so an attached record cannot be committed at its source without a consumer having completed it. A batch without record acknowledgements, such as a detached send, completes at admission as before.
Membership, Consensus, And Bulk Transfer
Cluster membership gossip uses management discovery capacity. It discovers topology and incarnations but does not replace application health checks. Gossip payloads remain below the management-event bound, so discovery cannot allocate an arbitrary wire message. A node that cannot take an exchange answers with a typed refusal rather than text: the message exceeds the gossip bound, the sending node could not be registered as an outbound peer, or its gossip receiver has shut down. Chitchat hands each outgoing datagram to a four-message queue for that destination. One worker per destination drives its interconnect requests in order, under the one-second request deadline; an unreachable peer therefore cannot hold the gossip loop while it receives from or sends to healthy peers. A full destination queue drops its newest datagram, and the next gossip round retries. Closing the transport cancels queued work and exchanges in flight.
Each node’s Chitchat live set is its own failure-detector estimate. An isolated peer can remain listed live until its missing heartbeats are observed, even after its links stop carrying requests; application probes provide the separate signal that eventually makes its work eligible for failover.
Chitchat continues to select known dead peers for exchanges during its 24-hour dead-node retention period. When application health has retired one of those peers from the outbound pool, a gossip exchange reinstalls its known route before sending. The replacement connection still authenticates the peer’s node identity and advertised endpoint. This lets a node with no configured bootstrap seed contact retained peers again after a partition heals; the returning exchange restores gossip membership, after which application health and Raft reconciliation use the current advertisements.
Admission to consensus membership requires an available interconnect endpoint. A discovered node without one is not an admission candidate, so it is neither added as a learner nor promoted to voter, and it becomes eligible on the round that publishes an endpoint this node accepts. The client and web-console advertisements are independent of admission: a node joins, votes, and leads with either of them unavailable. Membership replaces the recorded address of an existing member when its advertised endpoint changes. A returning voter remains a voter, while a learner still waits to catch up before promotion. The recorded address is a startup contact hint; live Raft traffic uses authenticated discovery by node identity. A node removed from membership stays out until it returns with a newer incarnation.
A redirect to the leader names only the advertised endpoints discovery has established. A client redirected during an election that has not yet observed the new leader’s client endpoint receives the leader identity without a redirect target rather than a guessed address, and retries until an endpoint appears. Leader Discovery, Redirect, And Reconnect defines how clients follow it.
Terminal teardown closes the gossip exchange path before it asks the gossip loop to stop. Closing the path first cancels destination workers and their queued or in-flight requests, so a peer that is itself stopping cannot hold teardown until an exchange deadline.
Session subscription interest also propagates through gossip. The key encoding is private to the
cluster layer: whenever the live-node state watcher changes, each node rebuilds an immutable index
from domain and relay to the interested node incarnations and advertisement versions and publishes
it through ArcSwap. A relay owner loads that snapshot and performs borrowed domain and relay
lookups, so per-batch remote fan-out neither formats a gossip key nor waits on the gossip mutex.
Subscription creation waits for the exact subscriber incarnation and at least the current interest
key’s gossip version to appear in every live node’s published index before it reports success.
The creating subscription holds its lease while capturing that version and waiting for visibility.
After withdrawal and reopening, an
advertisement from before the withdrawal cannot satisfy this handshake, even when the subscriber
node has not restarted. A withdrawal disappears from fan-out when the next gossip state snapshot is
published.
A node advertises interest in a relay exactly while at least one of its session subscriptions
holds a lease on it. Every subscription takes one lease before it attaches and releases it exactly
once, when it is withdrawn, abandoned before it was announced, or ended by its relay, so any
number of subscriptions from any number of sessions share one advertisement and the last release
withdraws it. Each write of the advertisement reads the lease count while it holds the gossip lock
that orders the writes, so the last write always matches the count: a release that finishes late
cannot withdraw the interest of a subscription that attached after it. The count per relay is
exported as nervix_session_subscriptions. Row
Subscriptions defines what the subscriber’s node
does with the batches this fan-out delivers.
Consensus separates traffic according to the progress it protects:
- heartbeats, pre-votes, votes, leadership notifications, linearizable runtime-admission reads, and other small control exchanges use management capacity
- each leader-to-follower log uses one ordered duplex stream on the replication pool
- runtime-state descriptions, catalog listings, acknowledgements and ownership handoff control use the remaining replication capacity; checkpoint bodies use the bulk pool’s Snapshot subquota
- consensus snapshots use the snapshot reservation on the bulk pool
The ordered append stream can keep multiple batches in flight while preserving follower order. Its consensus-level window bounds a follower to 16 outstanding batches and 16 MiB of unacknowledged log data. Heartbeats and elections remain on management capacity, so a full append window does not block leadership traffic.
The consensus adapter obtains its first append request before opening the duplex transport. This lets the caller’s retry and cancellation gate pace a setup attempt even when the transport refuses it immediately because its target left or its quota is full. The transport still reports that typed failure without a retry loop of its own. Ended input opens no connection. Leading empty requests use the management heartbeat operation, including a finite probe that ends before data is ready; its answer preserves the previous-log match, conflict or higher vote. The first nonempty request opens replication and is submitted once after the follower accepts the stream headers. Empty commit updates following that data remain ordered on the same duplex stream. The five-second setup bound starts after the caller gate; the management probe retains its heartbeat-derived deadline.
Elections begin with a pre-vote exchange. A voter asks whether a quorum would accept its next term before it persists that term or becomes a candidate. A restarted voter whose log is stale, or whose discovery connections are not ready yet, therefore cannot advance the term and tear down the healthy leader’s replication streams. Pre-vote requests use their own typed management operation and apply the same authenticated-origin check as vote requests.
A follower bounds the same stream from its own side. It keeps at most four decoded batches resident at once, each charged to the commands and replication budget from the moment it is decoded until its Raft core answers it, which happens only once the batch has been appended durably. A follower that reaches the bound stops reading frames, so the leader’s flow-control window closes and it stops sending rather than growing the follower’s memory. Decoded batches belonging to a stream the leader has already torn down are released with that stream instead of staying queued behind it.
The append stream opens under its five-second setup deadline. A follower answers a batch only after appending it durably, so the leader does not time out individual answers. While a batch is outstanding, it ends the stream only after five seconds in which no answer arrived and the follower’s flow control accepted none of the leader’s bytes. That bound is independent of the heartbeat interval, which still sets the deadline of each heartbeat.
A process restart does not admit ownership-sensitive runtime execution from its recovered local
state. The restarting node sends raft_runtime_admission_read to the leader through the reserved
management admission capacity. The leader performs a strict Raft ReadIndex, which confirms its
authority with a quorum and returns the inclusive committed log boundary for the read. A follower
then waits until its own state machine has applied that exact log ID before it reads the coherent
domain, clock-authority, and schedule state used to install runtime execution. The application
discards any runtime-state snapshot taken before this proof.
Admission retries on transport, leadership, and quorum failures without depending on a domain or schedule notification. Consensus, discovery, administration, shutdown, the interconnect listener, and configured application listeners continue running during the wait. Runtime routes remain absent, so listening connectors cannot hand payloads to recovered graph execution. The proof is process-start admission only; connectivity loss after admission does not revoke execution.
Resource archives and runtime-state snapshots use streamed bulk responses with an exact declared length. Consensus snapshots use bounded begin, chunk, and finish operations on the same isolated bulk class. Receivers stage and validate the owning artifact while releasing HTTP/2 credit chunk by chunk. The whole transfer may exceed the 32 MiB bulk-memory budget because only bounded chunks and the active decoded section are resident at once. Resource Versions And Bindings defines when a node fetches a resource archive and how it verifies and records the fetched version.
Backup state capture uses typed drain, capture, inventory, and fetch operations. The leader reads
each node’s admitted work through a management-class drain request and requests a separate
confirming force-flush round after all nodes appear quiet. The leader sends a management-class capture
request with its coordination identity and applied cut revision to each live node, then reads each
node’s management-class inventory of staged sections. A capture request’s deadline is the remaining
budget of the domain’s cut rather than a fixed request deadline, because an owner stages its share of
the domain’s state before it answers; a WITHOUT PAUSE capture takes the default cut budget. A receiver waits up to five seconds for its
state machine to apply that revision, installs its current runtime plan, and rechecks the sending
leader before it captures. A closed applied-state authority or a catch-up deadline refuses the
capture; a follower that is still applying the cut does not produce an archive from earlier state.
A captured section is fetched over a
snapshot-subquota bulk response: the inventory declares its path, length, content kind and digest,
and the leader stages and verifies the stream before adding it to the archive. The owner keeps a
staged section only for the coordinator process that requested it and releases expired stages.
The fetch stream authenticates that process identity before its handler can consume the stage; a
partitioned or cancelled fetch leaves any unconsumed stage available until expiry.
An owner streams each branch lifecycle and Kafka offset section from the cut’s database snapshot
into its staged file under a restore_metadata conversion charge and a 64 KiB bulk buffer, so the
inventory declares a section’s exact length and digest without the owner holding the section
whole. It also checks a WASM save’s scheduled generation before opening that save, then streams
its raw bytes from the same snapshot into a staged section under an independent
restore_metadata charge and three fixed 64 KiB bulk buffers. The stored read supplies the descriptor’s
revision. An interruption fails the capture before its inventory is published, and the
coordinator can retry with a new cut. Materialized inventories distinguish archive descriptors,
scalar identity groups and Arrow column groups. Their bounded sections are staged from a fresh
assignment-qualified capture and fetched through the same authenticated stream; the capture never
transfers an entire container as one bulk response allocation. The leader verifies each length and
digest before assembly.
An opening refused solely for request capacity is retried within one 30-second opening deadline;
other failures end the fetch. The captured stage remains unconsumed on an admission refusal.
Completed backup and materialized-state response streams release their Snapshot request and
connection-stream permits before local file verification or Arrow decoding.
The coordinator streams native lifecycle and Kafka metadata into quota-owned files before
installation. It retains the verified description’s separate restore_metadata preparation
charge through conversion, while buffered file I/O reserves 2 MiB of bulk memory. Complete
encoded records are never copied into a local request or retained for remote transmission.
Native checkpoints and guest saves share the file source and bounded chunk path. See
Backup And Restore for preparation admission and its limits.
Restore state installation uses the snapshot bulk subquota after the stopped-domain schedule is published. The leader admits a replicated installation authority carrying its identity and term, the restore execution, mutation lease revision and installation generation. Every request carries that authority. A receiver waits for its generation to apply and authenticates the sending leader. A begin request declares placement, length and digest; chunks are ordered and at most 64 KiB; finish verifies the staged file, then reads it directly inside a filesystem storage job that reserves 2 MiB and stages bounded checkpoint chunks into an invisible installation namespace. It retains the upload’s disk-quota owner through that job. There is no full checkpoint buffer or nested staged-reader reservation. Incomplete transfers expire under the node’s staging quota. Materialized restoration first converts archive-owned identities and exact-schema Arrow sections to a staged native sealed file. That file uses these same begin/chunk/finish requests, preserving its revision and branch generation and establishing its ownership fence in the new cluster. For a stopped domain, owner capture selects materialized checkpoint readers from the same database snapshot as the other captured state. Each reader retains its selected namespace and chunks until bounded identity conversion and raw Arrow staging finish. Running and paused domains use fresh generation capture under their assignment barriers. The capture inventory carries both through the existing materialized section kinds and Snapshot stream contract. Stored capture never activates the domain.
Large materialized containers never use the encoded metadata install request.
After all checkpoints are staged, the leader sends each target node a publish request with the
complete checkpoint and byte counts. The receiver reserves a fixed 2 MiB, validates receipts,
headers and chunk digests one checkpoint at a time, synchronizes the generation data, and commits
and synchronizes one active-generation pointer. It then deletes obsolete keys in bounded batches.
The inventory size and total payload bytes do not determine the publication reservation.
An empty inventory clears unassigned nodes and implements configuration-only restoration. Local
and remote mutations revalidate the exact authority under the applied-state read guard, held
through the storage mutation and clearing of runtime handles. Publication of a new authority or
release of the replicated start gate requires the corresponding write guard, so a delayed
coordinator cannot mutate after its successor completes installation. The store also retains the
published authority and inventory to reject lower or competing generations. An exact retry must
carry the same counts and repeats durability and cleanup before acknowledging completion.
A failure after pointer commit can leave that complete generation selected with the start gate
closed; it cannot authorize START or clear handles before durable completion.
The domain’s replicated start gate is released only after all nodes acknowledge publication.
RESUME activates its archived lifecycle in that same completion effect; delayed coordinators
are refused once the domain is running. The transfer authority remains independent of the
archived domain start generation and the relay checkpoint revision.
The seeded transport checks stream six 6 MiB sections, above the default 32 MiB bulk budget,
through capture fetch and ordered state installation after link repair. A second check restarts
the receiving process after the first 64 KiB install chunk: its replacement refuses an incomplete
finish and publishes only after a complete new 36 MiB transfer. The handlers retain counts and
digests; these checks qualify the authenticated transport, while the public restore scenarios
and storage checks qualify native containers, atomic publication and activation.
Their 300-second simulated completion deadline follows two intentional request deadlines and
allows the 64 KiB fetch and install chunks to traverse a seeded link at its 100 ms maximum
latency. The enclosing 320-second simulated horizon, 400,000-step cap and 90-second wall cap
still fail a transfer that does not complete.
The registered backup-capture-wire property checks complete drain, capture, inventory and fetch
messages through the production payload codec under each request’s declared class limit. It covers
every drain action, all ten counters at zero, their full-width maximum and independent generated
values, all twelve inventory section kinds, ordered and empty inventories, arbitrary digests,
bounded Unicode paths, and capture/drain/inventory success and refusal responses. The fetch response
remains an opaque byte stream. backup-capture-wire-malformed separately
checks typed refusal or complete stable re-encoding of damaged and arbitrary message bytes, and
release of management and bulk reservations. restore-installation-wire covers every installation
action and response, with all eight runtime state kinds branched and unbranched. The server’s
backup-section-mapping property checks the total, injective content mapping and distinct validated
archive paths. These properties supply wire and mapping evidence; the transport simulations and
public scenarios retain the authentication, fencing and recovery evidence described above. See
Property Testing And Fuzzing for their domains and commands.
Capture fetch and state installation share the peer connection’s one reserved snapshot stream slot. The archive staging phase finishes and releases its fetch stream before installation starts; an installer cannot retain a fetch stream while awaiting another snapshot request to that peer. Staging and publication run on the admitted filesystem worker class. Authority is checked inside the storage job after admission, so waiting for a worker cannot preserve an expired installation right. Durable synchronization does not run on the async reactor.
A runtime-state placement names exactly the state it addresses: the domain, entity, state kind, and
concrete branch; for every kind of state except branch-aggregated metrics and Kafka domain offsets,
the fingerprint of the schemas the state is laid out by; and for WASM processor guest state the
generation the committed schedule names for that branch. Branch-aggregated metrics and Kafka domain
offsets depend on no schema, so their placements carry no fingerprint and stay current across every
schema change of their entity. A backup archive separately records the Kafka ingestor’s schema
fingerprint and checks it against the restore target before installing offsets. A checkpoint carries
no identity of its own: a synchronization reply,
a handoff checkpoint, and a forced-recovery preparation each carry it beside the placement that
names it. A node answers a synchronization request, and acts on a checkpoint announcement or a
handoff checkpoint, only while the placement is current on that node, so an owner never serves, and
a replica never installs, state written under a replaced schema fingerprint or guest state of a
generation that has been replaced. A node that has not applied a schedule naming the entity has no
fingerprint to place its schema-bound state under, and refuses to place it rather than address the
state under an assumed identity. A replication acknowledgement counts only toward the placement it
names, so an acknowledgement for a replaced generation never satisfies the replica quorum of the
current one.
The schedule fingerprint an ownership handoff or forced recovery is bound to covers those schema
fingerprints and generations, so a preparation staged against an earlier schema or generation cannot
activate after a later one is committed. The control plane computes the exact fingerprint of the
committed schedule before runtime installation and carries it in the complete typed execution
revision used for activation and reconciliation; a node does not reconstruct it from a second
schedule view.
WASM handoff and forced-recovery preparation on a passive revision validate and retain the complete
checkpoint inventory without running guest callbacks or reading the stopped domain clock. The
guest validates its saved bytes when START installs the active revision, so stopped time is not
classified as missing checkpoint state.
Window state also binds to its current window model. A model replacement with unchanged schemas
therefore addresses a different checkpoint and cannot install rows accumulated under the preceding
window definition.
The same rule fences a coordinated reset. Once its Publishing schedule is committed, every
runtime-state request for the replaced WASM generation is stale even while the new initial
checkpoint is still being made durable. Replicas that were offline install the committed schedule
before accepting state, then synchronize only the new placement. A reset does not delete old bytes
through an unbounded cluster sweep; generation-addressed reads make them unreachable immediately,
and the existing bounded state-store retention removes them locally.
The leader forwards coordinated reset requests with a typed reason, so a guest request, operator request, transaction effect, and rejected-snapshot recovery keep their provenance across nodes. The existing remote describe exchange returns typed checkpoint facts from the execution owner: generation, revisions, stage, and required and confirmed replica counts. The receiver combines them with its scheduled binding, reset, and recovery facts, accepting only checkpoints of the schedule’s current generation. This read does not request synchronization, take ownership, or change a checkpoint’s completion state. A stored checkpoint whose previous replica boundary is unknown is reported as such rather than treated as newly confirmed.
A replica acknowledges a branch-state checkpoint — WASM guest state, deduplicator and window state, and the branch lifecycle that names the branches — only after it has written the checkpoint to its own stable storage and synchronized it, never on receipt. A replica that already holds the announced revision, or a newer one, synchronizes and acknowledges what it holds again, so an acknowledgement lost in transit is replaced by the next announcement instead of stranding the owner. A node without stable storage acknowledges nothing. The owner of a WASM processor branch releases the source acknowledgements a guest checkpoint covers only once every replica the committed schedule assigns has acknowledged that checkpoint’s revision; a replica that is unreachable, lagging, or failing to install stops those acknowledgements from being released rather than letting them through, and the checkpoint fails after its ninety-second deadline. The owner announces a WASM processor’s new branch to its replicas as soon as the branch appears. Every catch-up round of a replica synchronizes the owner’s branch lifecycle before it installs any branch checkpoint, and the replica refuses a checkpoint of a branch that lifecycle does not name, as for a branch the owner has evicted. WASM State And Recovery defines the checkpoint these acknowledgements complete.
A replica catches the branch-keyed entities it replicates up in rounds, one replica task for each entity, through two replication-class request kinds and, when a checkpoint advanced, a bulk stream. A state synchronization request names one placement and the revision the replica holds of it. The owner answers through the actual state handle published when that placement was installed: with the newer checkpoint’s revision, length and BLAKE3 digest, or with nothing when it is current. The owner reads storage only when it holds no live state for the placement.
Branch-aggregate metrics retain the exact encoded bytes first selected for each dirty revision;
elapsed times are captured once. Describing, streaming and persisting that revision therefore
agree byte for byte, and restoration retains the incoming bytes until the next metric update.
The replica opens a Snapshot-subquota bulk stream for the described revision; the owner refuses a revision it no longer
holds. Chunks are at most the configured bulk chunk size (64 KiB by default). Each side admits the
whole captured or received allocation against restore_metadata, up to a 256 MiB checkpoint
transfer bound. The replica checks the stream’s declared length, received length and digest before
the checkpoint can reach its assignment-fenced installation and stable-storage acknowledgement.
Debug events name the placement, revision and declared length when streaming and after verification,
without logging checkpoint bytes. A branch checkpoint listing request names the entity’s branch
lifecycle placement and the cursor the replica’s previous listing
returned. The owner answers with the next changes of its catalog of the entity’s branch
checkpoints, in the order they happened and at most 256 of them: each branch whose checkpoint
changed, with the state that checkpoint belongs to and its newest revision, and each branch whose
state went away, followed by the cursor after them and whether more changes follow. The listing
restarts from the catalog’s beginning when the replica has no cursor, when its cursor belongs to
another catalog, such as one a replaced or restarted owner kept, and when it is older than the
oldest removal the catalog kept. An owner that holds no branch state of the entity answers that it
holds none. Both requests are answered only while the answering node is assigned the placement. Admission
reads the retained entity assignment from the immutable routing publication; state and catalog
selection never reads the execution, identity or replicated-state registries. Route withdrawal
ends the exact state handle before replacement, while an already admitted request may finish on
the state it borrowed. A
round synchronizes the lifecycle, reads the catalog’s changes, and requests only the checkpoints of
the branches that changed or were announced, so a round in which no branch changed sends two
requests however many branches the entity has.
Checkpoint descriptions and catalog listings each use their operation’s five-second deadline, including admission, connection capacity, the answer and decoding. A selected checkpoint’s bulk stream has a sixty-second progress deadline. The one-second replication poll interval schedules the next idle round; it does not shorten an in-flight request’s deadline. A branch-aggregated replica uses the same checkpoint operation deadline. Polls and announcements retry a failed exchange, while the owner’s checkpoint completion deadline remains independent and may fail if a replica cannot confirm in time. Replica request diagnostics retain the typed transport cause beneath the target and placement context, so a timeout can be distinguished from capacity, connection and framing failures without inspecting checkpoint bytes.
The owner of a placement offers its newest checkpoint to the replicas the committed schedule assigns, and repeats the offer every 100 milliseconds to each replica that has not acknowledged that revision, until every one of them has, the node stops being the placement’s primary, the placement’s state goes away, or the node stops. A newer checkpoint taken meanwhile raises the revision on offer instead of starting a second offer. Each announcer retains the installed route and reads its primary and replica set from the same assignment slot used by checkpoint execution. Removal, a replaced identity or loss of primary ownership ends its next step. Terminal runtime teardown cancels a pending announcement dispatch or retry wait after domain drain, then joins the announcers before withdrawing their routes. These messages are availability hints; the replica’s periodic synchronization supplies a hint cancelled during shutdown. The owner records the highest revision each replica acknowledged, so an acknowledgement delivered after a newer one never lowers it, and a Kafka offset commit waiting for its replica quorum, like a WASM checkpoint waiting for its replicas, completes on the acknowledgement that satisfies it rather than at its deadline. Only a node that holds a placement’s state announces it: an ownership handoff announces the final checkpoints it captured from live state, and the placements it fills from its storage or with an empty checkpoint reach replicas through their own synchronization. A replica acts on an announcement by waking the task that keeps its copy of that placement current, which keeps the announcement until it next waits when it is busy; an announcement of a placement the replica holds no state for wakes nothing. The announcements of a branch-keyed entity’s lifecycle and branch checkpoints wake the entity’s one replica task, which takes the newest announcement of each at the start of its next round and fetches or acknowledges each announced revision then. The task also runs a round every replication poll interval, so a checkpoint whose announcement was lost reaches the replica within one interval.
The owner publishes an empty final window checkpoint when it evicts a concrete window branch. A replica that installs that revision replaces the evicted branch’s rows and sketch panes with the empty state. The branch lifecycle checkpoint records an incarnation for each concrete branch; restoring a window checkpoint with a different incarnation starts an empty window. Reusing a branch key after eviction therefore cannot attach a prior lifetime’s retained rows, even when the earlier checkpoint remains on a replica. Window checkpoints carry a sealed container with separate bounded Arrow sections for retained input and aggregate arguments, plus bounded typed sections for delayed histogram removals. Its revision, row count, and branch incarnation are checked across the sections before restoration; the ownership handoff and replica installation fences still govern whether the checkpoint can be installed. A section that exceeds its bulk limit or disagrees with the container fails to open. Sealing also refuses a container that cannot fit the available bulk memory reservation, instead of waiting for a reservation larger than that budget.
Materialized relay snapshots seal to quota-owned files one bounded identity/Arrow group at a time. Descriptions retain the file’s exact length, digest and revision; fetch responses open an independent reader and release each bounded chunk before the next. The sealed cache retains no whole-container byte allocation. Local periodic persistence writes the same container as 64 KiB segments, synchronizes data before replacing the header, and retains the namespace selected before executor admission. Neither path requires a reservation proportional to container length. The synchronous ownership-handoff metadata boundary still admits its resident checkpoint entry against the bulk budget; exceeding that admission is a typed checkpoint refusal.
Kafka offset replica catch-up first asks describe_kafka_offsets on the Commands pool whether
the owner’s revision advanced. Its typed answer distinguishes an unchanged revision, a newer
revision, and the shared remote-operation refusal classes. An unchanged checkpoint is neither
encoded nor transferred. A newer checkpoint uses sync_kafka_offsets on Bulk with the Snapshot
subquota and a thirty-second progress deadline, independently of the replica polling cadence.
The owner retains the admitted offset topology and reads its conservative revision before the
offset slots, encodes the current native checkpoint directly into a quota-owned staging file, and
sends a forty-byte revision/digest header followed by chunks of at most 64 KiB. The declared
response length bounds the complete native payload. Encoding scratch and native conversion use
the separate restore_metadata admission; bounded file I/O and transport use Bulk. Neither the
replication message limit nor the bulk memory ceiling bounds the whole checkpoint’s length.
The receiver stages bounded chunks, releases the response stream at EOF, verifies exact length
and digest, and decodes current archived entries directly into a new table under admitted CPU
work. Cancellation is checked between file blocks and native entries. Only complete conversion
reaches the replica’s assignment-token installation barrier; a promoted or replaced assignment
rejects a delayed transfer. The replica acknowledges its installed revision afterward. Kafka
commit and reset waits use the native bulk operation’s thirty-second budget. The owner
therefore accepts a replica that completes this checkpoint after a small Commands response budget;
an absent acknowledgement still ends the commit at the operation deadline. Transfer and admission
failures preserve their typed causes in the replica diagnostic and retry on a later round.
Every successful poll also repeats the replica’s held revision when the checkpoint is unchanged,
so a lost acknowledgement does not strand the owner’s quorum wait or require another transfer.
The replica checks its installation assignment before reporting that revision; promotion or
replacement fences a retained poll’s acknowledgement as well as its delayed installation.
Debug checkpoint events identify encoding, receive and installation boundaries by placement and
revision, with declared byte length once known. Their timestamps distinguish encoding, transfer
and conversion delays without logging checkpoint payloads or partition offsets.
Ownership handoff capture returns the same checkpoint descriptions as a bounded control response. The destination fetches each exact revision from the source over the Snapshot bulk subquota before validating its state and publishing the prepared handoff. A failed, truncated, superseded or digest-mismatched fetch fails preparation without installing a partial checkpoint. The source serves a handoff fetch from the exact checkpoint capture persisted in its state store, or from the retained committed WASM checkpoint. An ordinary replica fetch selects the published state handle. A committed WASM save is already on stable storage before capture, even when a newer published save is waiting for replicas. Capture describes and releases each WASM branch save before reading the next branch, so its control inventory retains descriptors rather than every guest save allocation. Other state kinds complete their existing persistence steps before description. No checkpoint body enters a replication-class message. Preparation retains its verified checkpoint set until activation, so the destination’s total held bytes still grow with the number and size of the entity’s branches.
Runtime-state synchronization replies and materialized-snapshot descriptions carry the shared typed remote-operation failure envelope. Rejection, absence, temporary unreadiness, and execution failure remain distinct across the node boundary, and the requester keeps that classification in its local replication or snapshot-exchange error. Only an execution failure includes the serving node’s opaque diagnostic text. A materialized snapshot is streamed only after a successful typed description identifies its exact length, digest, revision, fence, and branch generation; the placement that the request names supplies its schema fingerprint.
Runtime-state message properties use the production bounded encoder and decoder on each
request’s Commands, Replication or Bulk class. The runtime-state-replication-wire target checks
complete request and response values, including every state kind, schema and WASM generation,
model kind, checkpoint read kind, ordered listing, full-width counters, checkpoint length and
digest, and each remote-operation refusal and subject. Branch floats are compared by their bits,
including signed zero and nested collection values. runtime-checkpoint-listing-owner additionally
checks the complete runtime owner listing across its semantic conversion and the replication
response codec. Its current branch-key domain is nonempty, has distinct canonical field names,
valid datetime text and finite floats; datetime spelling is canonicalized by the runtime owner.
The separate runtime-state-replication-malformed and runtime-checkpoint-listing-malformed
targets exercise bounded damaged frames, encoding limits and typed invalid-branch refusals in
removed and revised entries. Each property checks that no codec reservation survives its case.
These are message and conversion checks; transport framing, scheduling and recovery retain their
separate simulation and public-scenario evidence. Run each named target with just test-bolero <target> for randomized/corpus checks and just fuzz <target> <seconds> for an actual sanitizer
campaign through the same assertion.
RkyvMessage, the public supertrait and response bound of the typed request contracts, is
nameable at the interconnect crate boundary. Its existing encode/decode methods use the owning
executor, pool class and byte limit; semantic conversion callers use that same codec rather than
recreating serialization or validation outside the engine. This introduces no separate codec,
message shape or execution mode.
Domain routing retains each materialized relay’s installed-state publication from the shared state-replication routing owner. Branch reads and generator scans use that relay’s immutable index, while the selected route validates its retained assignment identity. Retirement ends the exact state intake before removing its member; an ending predecessor cannot withdraw its replacement. Materialized installation refuses a lower revision, fence or branch generation before changing live rows. Transport decoding and the snapshot container remain owned by their existing engines; this publication introduces no wire or stored-container variant.
A materialized dependency reader may observe the committed destination just before that node
activates its prepared state, or the previous destination just after it leaves the assignment. A
rejected, absent, or not-ready snapshot description in this handoff window means the dependency has
no available record for that read. The dependency policy then waits, skips, or supplies its declared
default; REQUIRED WAIT retains the batch and retries after routing, state, or bounded poll progress.
Execution failures and transport failures remain errors.
The Control Plane defines when replicated changes and resources become authoritative. The Data Plane defines how a local execution consumes transferred state. The interconnect supplies bounded delivery between those owners and does not reinterpret their state.
Client Producer Links
A client may open a producer for a client ingestor through any
live node. When the serving node does not execute the ingestor, it forwards the producer to the node
that does over one client_producer_link: an ordered duplex stream on the relay pool, admitted
through the shared relay subquota, with a five-second setup deadline. A serving node keeps at most
one link to each owning node, opened by the first producer that needs it and shared by every
producer it forwards there, so forwarding holds one stream per peer however many producers use it.
Two opens racing for one owner start one link.
The opening frame names the serving node, and the owning node refuses a link whose named node is
not the peer it authenticated. Each forwarded producer then has a key the serving node assigns and
never reuses within its process, so a late frame about an ended producer can never reach a later
one. The serving node sends Open with the domain, ingestor, expected fields, credit, and the
largest batch one submission may carry, then the producer’s Submit frames carrying the Arrow IPC
bytes of each batch and its Clear frames, and finally Close or Detach. The owning node answers
with Opened and the producer’s description or Refused with its refusal, then the producer’s
Admitting, Outcome and Admission frames, and finally Ended with a reason or Closed.
Frames of one producer keep their order in both directions, so its open precedes its batches and
its outcomes, admission changes, and end follow the answer to its open. A batch travels at most once
over the link, and the frames are validated and charged to the relay memory class like every other
relay-pool operation. A frame is bounded by the relay pool’s payload limit of 32 MiB, which the
largest batch a session admits, 4 MiB less its submission envelope, fits with room to spare.
Two registered properties hold the link’s frames to the stream’s codec.
client-producer-link-frames generates the opening message, every frame of the serving node and
every answer of the owning node, and requires each to reach the other end with every field it was
sent with. client-producer-link-frames-malformed damages those encodings, reads a frame as
another direction’s type and reads arbitrary bytes, and requires the codec’s typed decode failure
or a frame the codec keeps whole. Neither leaves relay memory charged.
The owning node queues a forwarded batch like any other, but admits it only once the serving node
has cleared it. When the batch’s turn in the ingestor’s window comes, the owning node takes a slot
of the window for it and sends Admitting naming the batch. The serving node records that the
batch may now be admitted, answers Clear, and from then on counts the batch as possibly admitted;
only on that Clear does the owning node hand the batch to its admission worker. The serving node
clears only a batch it forwarded and holds no outcome for, and it answers in the order it was asked,
so clearances return in the order the owning node requested them. Admitting a forwarded batch
therefore costs one more round trip of the link. A batch awaiting its clearance holds its slot of
the window, so a serving node that stops answering holds at most the slots it was asked to clear,
until the silence limit ends its link.
The serving node waits at most 20 seconds for the owning node to answer a forwarded open, including
opening the link. Both ends send a heartbeat after two seconds without other frames and treat ten
seconds without hearing anything as a lost link; the owning node skips a heartbeat rather than queue
it behind 64 unsent answers. When a link ends for any reason, the serving node refuses the opens it
has not heard back about as EndpointUnavailable and ends every producer the link carried as
OwnerLost. Before that end, it answers each batch of the producer that it never cleared — whether
it sent the batch or the link ended before it could — as NotAdmitted with ProducerEnded: the
owning node admits nothing without a clearance, so none of those batches entered the graph. Only
the batches it cleared become OutcomeUnknown with cause OwnerLost. A crashed owning node closes
its connections and ends the link at once; one that stops answering without closing them ends it
when the silence limit passes. The owning node detaches the link’s producers: their admitted
batches continue through the graph with nobody left to answer them, and the batches it queued or
was clearing are dropped unadmitted, releasing their slots of the window. A later producer opens a
new link.
Each end reserves the producer’s granted bytes in its own 128 MiB producer budget: the serving node for the batches its session holds, and the owning node again for the batches it retains for another node. The link adds no reservation of its own beyond the transport’s per-frame charge. Client Session Protocol describes what the client sees.
Client Consumer Streams
When a session opens a consumer of a TO CLIENT emitter executing on another node, the serving
node opens one client_consumer_stream duplex stream for that attachment on the relay pool and
shared subquota. The opening request names the authenticated serving node, domain, emitter, exact
schema fields and granted credit. The owner checks the peer identity, reserves the grant in its
own 128 MiB consumer budget and attaches to its local emitter endpoint before replying Opened
with the endpoint description or Refused with a typed cause. The serving node has already
reserved the same grant against its session and node budgets.
The owner sends each attempt’s identity, fresh ACK reference, source relay, opaque branch
fingerprint, member count, execution snapshot and total byte count before its Arrow IPC bytes.
It splits those bytes into ordered chunks of at most 1 MiB, each charged by the relay pool and
well within its 32 MiB payload limit.
The serving node checks the announced length against the grant and reassembles one delivery before
answering a client read. It sends Settle frames with a stream-local correlation key and receives
one typed result for each; an independent writer keeps settlement and heartbeat sends from
blocking response reads. Both ends send two-second heartbeats and end the attachment after ten
seconds of peer silence. A lost stream detaches the owner’s consumer, revokes its attempts and
allows the retained batches to be reassigned. Output and ACK state stay volatile, so an owner
loss can require upstream replay. Client Session
Protocol owns what clients observe.
client-consumer-stream-frames and client-consumer-stream-frames-malformed hold the stream’s
frames to its codec as the two link properties hold a link’s: every opening message, serving-node
frame and owning-node answer reaches the other end whole, a chunk of the largest size included, and
damaged bytes fail with the typed decode failure or decode to a frame the codec keeps whole. They
check each frame alone. Putting a delivery’s chunks back together stays with the serving loop, and
no property splits a delivery at arbitrary bytes. client-emitter-deliveries holds the Arrow IPC
stream those chunks carry, from the sink that writes it to the client library that decodes it.
Domain Clock Progress
A paced domain’s mapping, generation, and authority fence are committed control-plane state. The interconnect carries only replaceable progress from that committed authority. It uses a typed management request in the reserved progress subquota, rather than an unclassified cluster event. The response confirms that the authenticated receiver evaluated the report against its current fence; it does not establish or replace the receiver’s committed clock mapping.
For each domain and installed remote node, the authority owns one delivery loop with at most one request in flight and one latest pending report. A newer logical frontier replaces the pending report while the loop waits for capacity or a response, so a fast clock or slow peer cannot create an unbounded tick queue. The node-wide progress quota bounds attempts across every domain and peer. Liveness, relay admission, cancellation, and terminal outcomes retain their independent capacity.
Only live peers that advertise installation of the domain clocks of the required revision receive progress. A node installs a reassigned authority fence ahead of its graph installation and advertises the revision through discovery once every domain clock it holds matches it, so a receiver whose graph preparation waits on a departed node still receives progress. A node that joins or reconnects receives the newest retained frontier once its advertisement reaches that revision. Each attempt has a two-second physical deadline. A failed attempt waits 200 milliseconds, then retries the newest frontier; authority shutdown cancels both an active request and its retry delay.
The wire report contains typed logical and UTC timestamps and no process-local monotonic instant. The receiver authenticates the reporting node and applies the committed generation, revision, authority, and fence checks before retaining the frontier. A stale, duplicate, reordered, or superseded report cannot replace the mapping or move logical time backward. See Domains And Time for clock semantics outside the transport boundary.
HTTPS Listener Installation
Every node’s HTTPS listener presents the TLS VHOST certificates of the runtime revision that node
applied, and it installs them before it reports that revision prepared. A command that creates,
changes, or drops a VHOST confirms the installation with the typed management request
https_listener_installation, which uses the reserved progress subquota and a two-second deadline.
The leader answers for itself in process and asks every other live process incarnation for its
latest installation at or after the command’s runtime revision. The answer names the answering
incarnation and reports the installed revision, a failure with that node’s own description, or that
the revision is still pending.
An answer from another incarnation, a transport failure, and a pending answer all leave that
incarnation pending, and it is asked again every 250 milliseconds until the command’s completion
deadline. A failed installation ends the wait at once, so the command reports the failing node
without waiting for the deadline. The request reads installation state and changes nothing, so a
repeated or late request is harmless.
The DYNAMIC TLS Refresh defines what every
listener presents and how a failed installation fails or rolls back the command.
Application Health And Availability
An established HTTP/2 connection and a successful transport PING show that bytes can move; they
do not show that the peer application can accept work. Nervix therefore probes application health
through a typed management request with reserved liveness capacity.
A peer becomes a health target and an outbound target once discovery publishes its interconnect endpoint. A target with no usable endpoint is neither probed nor dialled, and its availability stays unknown until discovery publishes one. A previously established target remains eligible for probes and outbound connections through a Chitchat liveness loss while application health still has an observation of it from the node-unavailability interval, as described below. A different incarnation or advertised endpoint must establish a new target.
Each health round has at most one probe in flight for each peer and at most 32 probes across the node. A probe has a one-second total deadline. Results are published as they complete, so a silent peer occupies only its own concurrency slot. The next regular round begins roughly one second after the previous round finishes. Gossip updates remain pending while those bounded probes finish and start the next round immediately afterward. They never cancel an in-flight round: repeated gossip updates must not prevent an unreachable peer’s deadline from becoming a failed observation.
Every result is bound to the exact certificate node identifier, discovery incarnation, endpoint generation, advertised address, and observation time that were targeted. A healthy response must return the same application identity. If discovery replaces the incarnation or endpoint while a probe is running, its late result is ignored.
Health observations distinguish:
- Healthy: the current target returned the expected application identity within the deadline.
- Failure: the current target returned an error or did not answer within the deadline, including a target whose advertised host does not resolve.
- Capacity exhausted: the probe could not obtain its reserved local capacity.
A missing, stale, or capacity-exhausted observation produces unknown availability. It does not mark a peer unavailable and does not extend a previous run of failures. Only continuous, fresh failures for the configured node-unavailability interval produce unavailable status; a healthy observation resets that run. Consensus membership continues to use the cluster topology established by gossip.
Scheduling and runtime availability retain a previously discovered incarnation that gossip stops listing live only while its latest application observation is younger than the node-unavailability interval and has not made it unavailable. A healthy, briefly failing, or capacity-refused observation from that interval keeps the peer available. A peer with no completed observation in the interval falls back to its gossip liveness, even though no failure was recorded. A stopped peer therefore leaves scheduling and runtime availability no later than when gossip declares it dead and its last observation has aged out, whether or not any probe to it completes.
During startup, Chitchat’s dead set can also contain a voter whose first heartbeat arrived through another peer’s digest: the failure detector has insufficient heartbeat intervals to establish liveness. Automatic scheduling uses a separate process-local live-observation history for its first ten seconds, including observations made before acquiring leadership. A voter that has never been observed live keeps scheduling in that bounded wait even when Chitchat lists it dead. Once all current voters have been observed or the grace expires, ordinary effective availability governs automatic failover. See Whole-Cluster Restart Keeps Ownership.
Command completion reads the leader’s effective availability view through the
application_completion_peers management progress request. The response names the leader’s
incarnation, Raft term, and required process incarnations. A follower uses it only while its own
Raft leader and term still match, and includes its own incarnation in the barrier. Failure to reach
the leader leaves the command pending. This avoids conflicting completion sets when application
health is asymmetric: a connected follower may still probe an unreachable peer successfully after
the leader has retired that peer. Revision and HTTPS listener progress requests remain bound to the
reported incarnation.
SHOW CLUSTER STATUS exposes the interconnect address, endpoint generation, observation age,
observation outcome, and derived availability. Its connected status means the latest application
probe is healthy, rather than merely that a transport pool exists.
Connection And Credential Lifecycle
A preconnected pool slot that fails reconnects with exponential backoff beginning at 200 milliseconds and capped at five seconds. A peer removal, incarnation change, or advertised endpoint change retires the old target and cancels work tied to its slots. New operations use only the new target generation. A new DNS answer for the same advertised endpoint is not a target change: it leaves established connections in place and is used by the next connection attempt.
Each established HTTP/2 connection sends a protocol ping every 15 seconds and waits at most ten seconds for its acknowledgement. The outbound and inbound ends both close a session that cannot answer, independently of the operating system’s TCP retransmission timeout. Closing the outbound end starts its pool slot’s bounded reconnect; closing the inbound end releases the per-peer class slot so that reconnect can be accepted. Ordinary request deadlines still apply to individual operations during the detection window.
Packet loss on a link that stays up is left to TCP. The host kernel retransmits lost packets under its own retransmission timeout, which grows exponentially while losses continue, and the interconnect does not tune it. An exchange over a lossy link therefore takes as long as its retransmissions, and one whose deadline passes first fails with a timeout that its owner may retry. Nervix sets no bound on how long such loss delays the traffic the link carries. A Quorum Over A Lossy Link describes what it means for commits.
Interconnect certificate, key, and CA files are watched as one credential bundle. A candidate must be complete, valid, and identical in two consecutive reads before it replaces the active bundle, so a multi-file update cannot install a mixed generation. An invalid or partially written candidate leaves the current credentials active while the watcher continues trying.
TLS loading identifies the CA certificate, node certificate, or node private key by kind and keeps PEM parsing failures as typed categories. Error reports and watcher logs omit credential file paths and malformed PEM input bytes.
After a valid replacement, new outbound pools use the new credentials and existing inbound HTTP/2 connections begin graceful shutdown. Certificate expiration is also mapped to a process-monotonic deadline when a connection is authenticated, so a connection cannot remain open beyond the validity of either peer certificate even if the wall clock later moves.
A replacement publishes the new bundle and its generation as one value, under the generation after the one it replaces, so two replacements that race still publish distinct generations. Each connection records the generation of exactly the credentials it authenticated with. An inbound connection compares that record with the published generation, without a lock, whenever it accepts a stream and whenever a replacement is announced, and begins graceful shutdown once the published generation has moved past it. The replacement publishes before it retires the outbound pool slots. An outbound connection set up from the replaced credentials checks the published generation once it is established: one that checks after the publication is refused, and one that checked before it is held by a slot the retirement then ends, so replaced credentials never outlive their replacement.
Application shutdown has three ordered phases. A server process issues the stop request that
starts them when it receives its first SIGINT or SIGTERM, and one shutdown deadline measured
from that request bounds all three. The stop request first marks the local process incarnation as
terminating and then closes admission on its public gRPC, connector, observability, and console
listeners, closing the client connections they had accepted. Its interconnect listener and
registered handlers remain available on that live node throughout the drain-support phase. They
continue carrying queued and active relay payloads, admission and record acknowledgements,
runtime-state replication and checkpoints, ownership-handoff coordination, domain-clock progress,
and the schedule revisions that activate committed ownership. Drain support first moves scheduled
work to a live replacement node when one exists and then completes the work the node already
admitted in place, so these consumers remain available until the node’s own graphs are quiescent.
The relay payload lane retains its transport admission guard for every queued or active payload.
Sender-side runtime drain accounting retains the tracked root until admission and every requested
record acknowledgement resolve; this includes roots created for sources in MODE NO ACK. Ownership
handoff does not complete until its replication acknowledgement and committed activation have been
observed. These owners keep admitted work visible while the supporting interconnect consumers are
still running.
Only after drain support completes or reports abandonment does terminal teardown call transport
shutdown. Transport shutdown rejects new interconnect admission, cancels pool and operation waiters,
and retires every pool connection the node opened: each stops leasing and closes once its leased
streams return. Connections that peers opened to the node close at once, together with the handlers
still serving their streams, so a peer’s request the node has not answered fails instead of
completing. Whatever remains after ten seconds is then closed. Connection setup, including the name
resolution it performs, and incomplete TLS handshakes remain inside this bound. A repeated SIGINT
or SIGTERM, or the shutdown deadline passing, ends the process without running the rest of its
shutdown, so its peers observe its connections ending exactly as they do when the process crashes.
See Shutdown And Recovery for the complete phase contract, the deadline and exit statuses, and what each ending preserves.
Failure Ownership And Persistence
Transport failures identify name-resolution, setup, authentication, admission, encoding, decoding,
flow-control, timeout, remote-response, and target-departure failures separately. Typed remote
errors remain available to the operation owner, which decides whether a request is safe to retry.
The interconnect does not infer idempotency for arbitrary control-plane or runtime operations.
Local transport operations return error_stack reports. TLS and wire failures retain their typed
causes beneath TransportError; typed requests and stream readers add RequestError context while
retaining the transport report. Deadline, shutdown, quota, cancellation, relay-rejection, and
indeterminate-delivery decisions inspect the current typed context, not formatted report text.
Runtime dispatch, relay admission-response and remote acknowledgement logs render every context
of those local reports. State synchronization, checkpoint announcement and replica catch-up logs
also retain the underlying request, placement or storage cause when their owner adds context.
When remote ACK registration cannot reserve correlation memory, the affected record’s negative
acknowledgement carries the registration report chain, including its admission cause.
An answering node sends its established remote failure class or stream rejection text over the
wire; a local report’s cause chain is not serialized into an HTTP/2 response.
The exact source owners classify RemoteRequestFailure, RemoteOperationFailure,
OwnershipHandoffFailure, gossip refusals and resource-publication answers as fixed wire outcomes
for the compiler failure-contract gate. The responding operation retains its local contextual
report before projection. Errors And Diagnostics owns that source
contract, including the distinction between a wire outcome and an operation failure.
Connections, request state, relay grants, delivery reconciliation, progress trackers, and acknowledgement maps are never persisted. Durable control-plane state remains in consensus, and selected runtime state remains in its owning snapshot or replication mechanism. This boundary is why process epochs are part of relay delivery identities, why an unresolved result across a receiver restart is reported as indeterminate, and why an acknowledgement registration names the run of the node that registered it.
An ownership-handoff gate lease is also in-memory coordination state. Releasing or expiring that lease removes the runtime fence but does not report a persisted preparation as cleaned up. Only an exact durable discard, activation, or schedule-based reconciliation resolves that preparation.
Observability
Each node exports interconnect measurements through its local metrics endpoint. The main groups
cover connection and stream occupancy, pending operations, setup failures and resets, quota
exhaustion, request latency, relay channels and grants, admission wait, unresolved delivery age, and
bulk-transfer bytes. Interconnect memory, worker queues, reactor delay, and consensus retention show
whether pressure originates in transport, execution, or the protocol using it. A connection attempt
that could not resolve its peer’s advertised host is counted with reason resolution, apart from
setup, handshake, capacity, and closed failures, so a DNS outage is visible as itself.
Typed-request observations identify application health as operation liveness and replaceable
domain-clock delivery and HTTPS listener installation probes as operation progress, so their
request counts, outcomes, latency, and quota failures can be evaluated independently. A client
producer link’s opening, failure, silence, and the producers it ends or detaches are logged at
debug on both ends; the owning node’s client-ingestor metrics count forwarded producers apart
from local ones.
Metric labels are bounded dimensions such as traffic class, direction, operation, outcome, and reason. They do not include peer, domain, relay, branch, delivery identity, or payload values. An owner-delivery admission failure logs its domain, relay, non-sensitive branch fingerprint and target, together with the transport report’s retained cancellation and rejection causes. The undelivered batch and branch field values stay out of that diagnostic. Per-batch and payload-bearing logs use debug or trace levels and do not expose sensitive field values. See Metrics And Observability for the metric and logging contract.
Name Resolution
Nervix turns host names into addresses when a node connects to its peers, when a connector opens a
connection to an external system, and when a native client opens a session. Wherever Nervix decides
how a host is resolved, it asks one resolver owned by the nervix-dns crate: Hickory’s
asynchronous DNS client, configured from a resolv.conf-format file and a hosts file and held to
the cache, time and concurrency bounds this chapter describes. A lookup through it never occupies
Tokio’s blocking pool and never calls the C library resolver. Some external drivers resolve hosts
inside code that accepts no resolver from its caller; they keep the operating system’s resolver,
and Residual Resolution names every one of them.
This chapter owns the resolver itself: its ownership and lifetime, its configuration, the order in which it answers, its cache, its bounds and cancellation, the outcomes of a lookup, the hooks that hand it to client libraries, the dependency features that select it, the paths that resolve elsewhere, the simulation boundary, and the evidence behind each claim. The boundaries that resolve through it keep their own facts, and this chapter links to them rather than restating them:
- Cluster Interconnect owns when a node resolves its own endpoint, its bootstrap seeds and its peers, and how a failed peer lookup is classified and counted.
- Connector Crates And The Connector Contract owns how each integration installs the resolver, dials, completes TLS, and reports a connection it could not open.
- Client Session Protocol owns native session connections, their seeds, redirects and reconnection.
- Errors And Diagnostics owns the error types that carry a failed lookup through each owner.
- Shutdown And Recovery owns how a stopping node ends a pending connection attempt.
- Integration Test Lifecycle owns the DNS fixtures of the scenario harness, and Deterministic Interconnect Simulation owns the simulated names of the Turmoil build.
Transport selection retains pool-slot handles in immutable target publications. A changed dial address for the same advertised endpoint preserves those lifetimes. Established leases retain the authenticated connection; only its reconnect worker reads the current dial policy and resolves the endpoint again. Publication changes do not add DNS lookups to established leasing or change the resolver, setup budget, address-attempt order, TLS name or HTTP authority.
Ownership
nervix-dns sits in the engines and infrastructure layer. It owns one resolver’s configuration
reading, hosts-file snapshot, answer cache and TTL bounds, the bound on concurrent lookups, the
typed outcome of every lookup, the physical connection budget that a lookup shares with the address
attempts after it, and the hooks that hand the resolver to the client libraries a node connects
with: both Reqwest versions, hyper-util’s HttpConnector, and Smithy’s HTTP client. It must not
know Models, peers, connectors, graphs, or any protocol’s retry policy. A caller gives it a host, a
port and a time budget, and receives addresses or a typed failure.
| Owner | What it owns about name resolution |
|---|---|
nervix-dns | The resolver, its policy and bounds, its failures, its client-library hooks, and the connection budget and ordered address attempts |
| The server, as composition root | Loading the node’s resolver once at startup from the node’s DNS options, and handing clones to the runtime, the interconnect, and the node’s own trace exporter |
nervix-interconnect | When and where a peer endpoint is resolved, the TLS and HTTP/2 identity of the connection, and peer failure classification |
nervix-connector | The shared HTTP client configuration that installs the resolver on the Reqwest clients of HTTP polling, Prometheus, Sentry and OTEL HTTP |
| Each connector crate | Installing the resolver its typed plan carries at its driver’s hook, or resolving and dialling itself; its TLS, framing, protocol handshake, and the error it reports |
| The host data plane | Retry, acknowledgement, intake, quiesce and shutdown decisions around a connection that could not be opened |
nervix-client-core | The native session client’s resolver and the Tonic connector that dials through it |
Connector crates receive the resolver as a value in their typed source or sink plan, so a connector
never loads configuration or chooses a resolver itself, and the registry, which names no connector
crate, never sees DNS at all. The interconnect receives it wrapped in its PeerResolver, the one
seam where the Turmoil build substitutes simulated names, as
Build Modes describes.
Resolver Lifetime
A node loads its resolver once, at startup, after its TLS material and before it opens its runtime state or binds the interconnect. Loading reads the resolver configuration and the hosts file on the blocking pool, so a slow filesystem never stalls a reactor thread, and then builds Hickory’s resolver on the node’s Tokio runtime. The read is one of the declared owners of the blocking pool outside the bounded executor, because a node loads its resolver before it builds its executor and a client tool loads the same resolver without one; see Blocking work outside the executor. Neither file is read again: a changed resolver configuration or hosts file takes effect when the node next starts.
Clones of the resolver share its configuration, hosts snapshot, answer cache and concurrency bound. The node hands one clone to the runtime, which passes it into the typed plan of every source and sink that resolves through it, one to the interconnect, and one to its own OTLP trace exporter when enabled. Nothing about the resolver is process-wide: it lives in no static, its name-server connections and background tasks run on the runtime that built it, and they end when its last clone is dropped. A runtime that shuts down with a lookup in flight is not held open by that lookup.
A runtime built without a resolver exists only in unit tests that start no external connector. A
source or sink that needs the resolver fails to start there with the reason
the node DNS resolver is not installed; it never falls back to another resolver.
A native session client loads its resolver once per client, before its first connection, from the
host’s files or from the configuration its caller names, and reuses it for the first connection,
seeds, redirects and reconnects. An owner that already holds a resolver hands its own over
instead, and the CLI passes its DNS options through. The
shared C binding always loads the host’s /etc/resolv.conf and /etc/hosts.
Node Trace Export
With --otel-enabled, the process installs its subscriber and batch span exporter before node
startup. The lazy Tonic 0.14 connector waits for startup to publish the node’s resolver. Creating
the channel performs no lookup or dial. Startup loads DNS at its usual point, after TLS material
and before runtime state, then publishes a clone to this service. Early spans remain in the batch
exporter’s existing bounded queue. No second resolver is constructed, and a DNS configuration
failure remains a node startup failure.
Every new collector connection uses the node’s configuration, hosts snapshot, cache and concurrency
bound. The endpoint retains its hostname and authority. Waiting for resolver installation stays
outside the connection timeout so slow startup retains its early spans. Once installed, the OTLP
export timeout bounds DNS and TCP address attempts together; the request uses the same timeout.
It defaults to ten seconds; OTEL_EXPORTER_OTLP_TRACES_TIMEOUT overrides
OTEL_EXPORTER_OTLP_TIMEOUT, both in milliseconds, with invalid values ignored as the exporter
defines. Compression and metadata environment settings remain interpreted by opentelemetry-otlp.
The node’s exporter configures no Tonic TLS roots or identity. An https collector therefore fails
at export time with Tonic’s TLS-required error even though the connector dependencies enable TLS
support. The OTEL sink’s explicit TLS configuration is a separate contract.
Export failures belong to the telemetry SDK. They neither stop a running node nor enter connector retry or acknowledgement paths. If startup ends before installing DNS, dropping the tracing guard closes the resolver publication so a pending connection fails without waiting for a resolver that will never arrive. The guard flushes the provider while the node’s Tokio runtime still exists.
The Tonic connector’s standard-error hook owns a TraceConnectFailure carrying the complete
Report<TraceConnectError>. Resolver-installation closure, timeout and connection failures retain
their typed contexts and the connection’s cause chain. DNS hook carriers are checked by paired
compiling and compile_fail examples in just test-typed-ratchet-product-docs.
Configuration
Three server options select what the node’s resolver reads. The native CLI accepts the same three options and environment variables for its own resolver, as Command Line Client lists.
| Option | Environment variable | Default | Meaning |
|---|---|---|---|
--dns-resolver-config | NERVIX_DNS_RESOLVER_CONFIG | /etc/resolv.conf | A resolv.conf-format file: its nameserver lines, its last search or domain line, and the ndots, timeout, attempts, and edns0 options |
--dns-hosts-file | NERVIX_DNS_HOSTS_FILE | /etc/hosts | A hosts-format file consulted before DNS |
--dns-name-server | NERVIX_DNS_NAME_SERVERS | none | A name server address with its port, repeatable or comma-separated, that replaces the resolver configuration’s nameserver lines while the rest of that file still applies |
The resolver configuration is read with the C library’s grammar:
- Each
nameserverline names a server on port 53. A server is asked over UDP and, when an answer arrives truncated, again over TCP. A negative answer from a server is final; the resolver does not ask another server whether the name exists after all. - The last
searchordomainline is the search list. When the file has neither, the domain of the host’s own name is the search list, as the C library does; a host name that is not a valid DNS name contributes none. ndots,timeoutandattemptsdefault to 1, 5 seconds and 2, the C library’s defaults.attemptscounts the tries of each query, as the C library counts them, and a file that asks for none still gets one;timeoutbounds each try.edns0enables EDNS.- A line the grammar cannot read is logged as the warning
ignored a line of the resolver configuration, with the file and the reason, and skipped. Every other directive and option the grammar reads has no effect, includingsortlist,rotate,single-request,single-request-reopen,use-vc,no-aaaa,trust-adandinet6.
--dns-name-server replaces only the nameserver lines, so the resolver configuration must still
exist and be readable, and its search list and options still apply. The hosts file must exist and be
readable too; an empty file is valid.
Loading fails, and the node does not start, with failed to load the name resolver configuration
above one of these causes:
| Cause | When |
|---|---|
the resolver configuration '<path>' could not be read | The file is missing or unreadable; the operating system’s error is attached |
the resolver configuration '<path>' names no name server | The file has no nameserver line and no --dns-name-server replaces them |
the search domain '<domain>' is not a valid DNS name | The search list holds a name DNS cannot represent |
the hosts file '<path>' could not be read | The hosts file is missing, unreadable, or cannot be parsed |
the resolver could not be constructed | Hickory rejected the resulting configuration |
loading the resolver configuration stopped before it finished | The blocking read did not complete because its task was cancelled or panicked |
A file without name servers is an error rather than an instruction to ask the local host, which is what the C library does with one. Nothing falls back to a public resolver or to the C library.
Resolution Order
A host resolves in this order:
- A literal IPv4 or IPv6 address, bracketed or not, is its own answer. No query is sent and no lookup slot is taken, and the port the caller supplied is kept.
- A name the hosts file lists, compared exactly as written and without case, answers with every address the file gives it, IPv4 addresses first. Nothing is asked of DNS for that name in either family, so an entry with only an IPv4 address never waits for an IPv6 query, and search domains do not apply to it.
- Any other name goes to the name servers. A name with a trailing dot is fully qualified and asked
exactly as written. Any other name is completed by the search list according to
ndots, as the C library completes it.
A lookup that reaches the name servers asks the IPv4 and IPv6 questions in parallel and answers with every IPv4 address first and then every IPv6 address, each family in the order the server gave it, without duplicates. Either family is enough: a name with only IPv6 addresses resolves to them, and only when both questions fail does the lookup fail, with the IPv4 question’s outcome. The resolver does not choose between families and does not sort by reachability; the caller dials the answers, as Dialling The Answers describes.
localhost, and every name under it, is answered with 127.0.0.1 and ::1 without a query when
the hosts file does not list it, as RFC 6761 asks of a resolver. Names under .local are asked of
the configured name servers like any other name; nothing is resolved by multicast DNS. A host that
is neither an address nor a valid DNS name fails as an invalid name before anything is asked.
When the configuration names several servers, a query goes to two of them at once, preferring those that have answered fastest so far, and the first usable response wins. The C library instead asks its servers one at a time in file order.
Caching And TTLs
Answers from the name servers are cached per name and record type, in a cache of 4,096 entries, for the TTL the answer carries. The TTL is capped at one hour for an address answer and at thirty seconds for a name that does not exist or has no address. A node therefore keeps using an address its name no longer has for at most an hour, or for the answer’s own TTL when that is shorter, and a name published after a node was told it does not exist resolves on that node within thirty seconds, or sooner when the negative answer’s own TTL is shorter. There is no minimum: an answer with a TTL of zero is not reused. Literal addresses and the hosts file are answered from memory and never cached.
An expired answer is asked again on the next resolution. Expiry never closes a connection: an established connection stays open until its own owner ends it, and a changed answer or an expired TTL takes effect only when that owner opens its next connection. Where Nervix Resolves links each owner’s own statement of this rule.
Bounds, Deadlines And Cancellation
Every lookup runs within a budget its caller gives it, and a lookup that exhausts that budget fails
as a timeout. The budget covers the whole lookup: waiting for a lookup slot, every name the search
list produces, both address families, and every try against every server. The resolver
configuration’s timeout bounds each try and its attempts count the tries, so a configuration
with short tries ends a lookup against a silent server well inside a generous budget.
At most 64 lookups past the hosts file run at once on one resolver, a lookup answered from the cache included, which is enough for every peer of a full 64-peer topology to reconnect together. A further lookup waits for a slot inside its own budget. Literal addresses and hosts-file names never take a slot.
A caller that dials for itself shares one physical connection budget between the lookup and the address attempts after it: the lookup receives the budget, and each answer, dialled in resolution order, receives an equal share of whatever time remains when its attempt starts. An address that refuses at once leaves its unused share to the attempts after it, and one that never answers cannot consume their time. Handshakes that the caller performs after an address accepts use what is left of the same budget.
A client library that resolves through one of the resolver’s hooks cannot pass the deadline of the request it resolves for, so every hook lookup has a budget of thirty seconds, and the library’s own request or connection deadline, where it has one, can end the lookup sooner. A lookup cut short by that deadline reports the library’s timeout rather than a lookup failure.
Cancelling a lookup, by dropping it, aborting its task or ending its budget, releases its slot at once. The resolver never retries a failed lookup; the only retries inside a lookup are the tries its configuration asks for, all within the budget. Whether and when a failed connection is attempted again belongs to the caller’s existing policy, so name resolution never multiplies a retry policy.
Lookup Outcomes
A lookup ends with at least one address, or with a DnsLookupError that names the host as the
caller wrote it and one DnsLookupFailure. Its message is resolving '<host>' failed: <reason>.
| Failure | Reason in the message | Meaning |
|---|---|---|
NameNotFound | the name does not exist | A name server answered that the name does not exist |
NoAddresses | the name has no IPv4 or IPv6 address | The name exists but has no address in either family |
Timeout | no answer arrived in time | The budget ended, or every try of the configured servers timed out |
Refused | a name server refused or failed the query | A server answered with an error such as a refusal or a server failure |
Unreachable | no name server could be reached | No server could be asked, or none answered with a usable response |
InvalidName | the host is not a valid DNS name | The host is neither an address nor a valid DNS name |
With a search list, the failure is the outcome of the last name that was asked, while the error still names the host as written. The error carries the host and the resolver’s own description of the failure, and nothing from a request, a record or a credential.
A client library that resolved through a hook receives the lookup’s whole report as the failure of
its lookup. Every hook’s trait accepts only a standard error, and an error_stack::Report is not
one, so the report crosses that boundary inside a DnsLookupReport: Hyper’s resolver service
takes any error that converts into a boxed standard error, Reqwest’s hooks take a boxed standard
error, Smithy’s a ResolveDnsError, and the MQTT and Redis dialers an std::io::Error. The library
keeps it among the causes of its own connection error, however many errors of its own it wraps
around it. It shows the lookup’s own message, so a library that renders its causes names the
failure once, and its Debug form keeps what the lookup recorded beneath its failure: the name
servers’ own answer, or the budget that ran out. DnsLookupReport::find_in finds the report there
again, and DnsLookupError::find_in the typed failure in it, including through the Arc and
std::io::Error wrappers Redis places around its causes, so a connector can keep the typed lookup
failure beneath its own context. Which paths do so is listed under
Operator Diagnostics.
Where Nervix Resolves
Every path below resolves through the node’s resolver, or through the native client’s own resolver for a session. Apart from the interconnect’s lookups at startup, each path resolves its configured name again for every new connection it opens.
| Path | How it reaches the resolver | What bounds the lookup | Canonical description |
|---|---|---|---|
| Interconnect at startup: the node’s own advertised endpoint, its bootstrap endpoint, and the recovered Raft members’ endpoints | PeerResolver, which calls the resolver directly | The connection setup timeout, five seconds, for each lookup | Where The Interconnect Resolves |
| Interconnect: every pool connection to a discovered peer | PeerResolver inside each connection attempt | The attempt’s five-second connection setup timeout | Where The Interconnect Resolves |
| HTTP polling and Prometheus sources, Sentry and OTEL HTTP sinks | Reqwest 0.13’s dns_resolver, installed by the shared HttpClientConfig | The client’s timeout_ms for the whole request when it is set, otherwise the thirty-second hook budget | DNS for HTTP and Iceberg |
| Iceberg REST catalog and its OAuth token request | Reqwest 0.12’s dns_resolver on the client given to RestCatalogBuilder::with_client | The thirty-second hook budget | DNS for HTTP and Iceberg |
| Iceberg S3, GCS and Azure object storage and its credential providers | Reqwest 0.13’s dns_resolver on the client the connector installs through OpenDAL’s HttpClientLayer | The thirty-second hook budget, inside OpenDAL’s own operation timeouts | DNS for HTTP and Iceberg |
| HTTP request emitter | The connector resolves each request’s target host itself | The request’s one physical timeout_ms | Request Preparation And Transport Ownership |
| RabbitMQ sources and sinks | The connector resolves and dials, then hands the transport to Lapin’s Connection::connector | Thirty seconds shared with the address attempts and TLS | DNS for RabbitMQ |
| Syslog UDP, TCP and TLS senders | The connector resolves and dials each new sender | Thirty seconds shared with socket setup, or with the attempts and TLS | Integration-specific boundaries |
WebSocket ws and wss client sources | The connector resolves and dials each connection and resume | Thirty seconds shared with the attempts, TLS and the upgrade | Integration-specific boundaries |
| ClickHouse sinks, with and without TLS entries | Hyper’s resolver service on hyper-util’s HttpConnector | The insert’s timeout_ms when it is set, otherwise the thirty-second hook budget | DNS for ClickHouse and SQS |
| SQS sources and sinks, with and without a custom CA | Smithy’s ResolveDns, installed with build_with_resolver | The SDK’s 3.1-second connect timeout, which encloses the lookup, and a sink’s timeout_ms as its attempt and operation timeouts | DNS for ClickHouse and SQS |
| The node’s own OTLP trace export | Tonic 0.14’s lazy custom connector, which waits for the node resolver installed during startup | The OTLP export timeout, ten seconds by default, enclosing DNS and TCP attempts after resolver installation; the same timeout bounds the request | Node Trace Export |
| OTEL gRPC sinks | Tonic 0.14’s lazy channel over hyper-util’s HttpConnector with Hyper’s resolver service | timeout_ms when it is set, as the connection and request timeout; otherwise the thirty-second hook budget alone | DNS for OTEL gRPC |
| Redis command pools | Redis’s AsyncConnectionConfig::set_dns_resolver, installed for every new physical bb8 connection | The thirty-second hook budget, inside the thirty-second connection timeout the pool sets on the driver | DNS for Redis |
| Redis Pub/Sub sources | The connector resolves and dials a dedicated stream, then hands it to Redis’s PubSub::new | Thirty seconds shared with the attempts, TLS, Redis setup and SUBSCRIBE | DNS for Redis |
| MQTT sources and sinks | The connector’s socket connector, installed with MqttOptions::set_socket_connector, for every connection the driver’s event loop opens | The driver’s five-second connect timeout | DNS for MQTT |
| Native CLI and SDK sessions | The client’s resolver, as Hyper’s resolver service under Tonic 0.13’s connector, for the first connection, seeds, redirects and reconnects | The session’s connect timeout, ten seconds by default, which encloses the lookup, the attempts and TLS | Leader Discovery, Redirect, And Reconnect |
Two interconnect uses are resolved once, when the node starts: the address that identifies the node in gossip, which is the first answer for its own advertised endpoint, and its bootstrap seeds, which gossip keeps dialling at the addresses the startup lookup produced. Where The Interconnect Resolves describes both. No path writes a resolved address into its configuration, a plan, a Model or persisted state, so a service can move to new addresses without an alteration. Captured backup-section openings have one 30-second caller deadline that includes retries for request-capacity refusal. Pool connection lookups retain their five-second attempt budget; the caller deadline and stream ownership are described in Membership, Consensus, And Bulk Transfer.
Dialling The Answers
The resolver returns every answer; the caller decides how to dial them. A caller that dials for itself shares one connection budget between the lookup and its attempts, tries the answers strictly in resolution order, one at a time, and keeps the first that succeeds, so every IPv4 answer is tried before any IPv6 answer and no attempt races another:
| Caller | Budget | One address attempt | After an address succeeds |
|---|---|---|---|
| Interconnect pool connection | The connection setup timeout, five seconds | TCP connection | TLS, HTTP/2 and the connection hello within the same timeout; a failure there fails the attempt |
| RabbitMQ source or sink | Thirty seconds | TCP connection | TLS within the same budget, then Lapin’s AMQP handshake, which has no deadline of its own |
| Syslog TCP sender | Thirty seconds | TCP connection | No further step |
| Syslog TLS sender | Thirty seconds | TCP connection and TLS | No further step |
| WebSocket client source | Thirty seconds | TCP connection, TLS and the opening upgrade | The client’s signaling protocol, when it declares one, under that protocol’s own TIMEOUT |
| Redis Pub/Sub source | Thirty seconds | TCP connection and TLS | Redis setup and SUBSCRIBE within the same budget |
| MQTT source or sink | The driver’s connect timeout, five seconds | TCP connection with the driver’s network options | TLS and the MQTT handshake by the driver within the same timeout |
Syslog UDP has no connection to confirm, so a sender binds a socket in the family of each answer in
turn and sends to the first answer whose socket setup succeeds. The HTTP request emitter tries the
answers in order inside its one physical timeout_ms, which it does not divide: an answer that
never responds uses the rest of that attempt.
A client library that resolves through a hook dials with its own connector. Reqwest, Smithy,
ClickHouse’s client and Tonic all dial through hyper-util’s HttpConnector, which tries the
answers of the first answer’s family in order, starts the other family’s answers when 300
milliseconds pass without a connection, as RFC 6555 describes, and divides a configured connect
timeout evenly across the answers. The Redis driver dials every answer of a new command connection
at once, TLS included, and keeps the first that completes.
Connection Identity
Resolution chooses where to connect and nothing else. Every caller keeps the configured host as the identity of the connection, whichever address accepted it:
- TLS. The server name sent in the handshake and the name the certificate must carry are the configured host. A certificate that names only the dialled address, or another host, is rejected, and a literal IPv4 or IPv6 host is verified as an IP address. The interconnect additionally requires the peer’s cluster identity, as Peer Identity And Authentication describes.
- HTTP authority. The
Hostheader, the HTTP/2:authority, the WebSocket upgrade’s URL, and the request path and query stay as configured. A literal IPv6 host is written in brackets there. - Proxies. Every Reqwest client Nervix builds, for HTTP polling, Prometheus, Sentry, OTEL HTTP
and both Iceberg clients, keeps Reqwest’s default of honoring the proxy environment variables
HTTP_PROXY,HTTPS_PROXY,ALL_PROXYandNO_PROXY. When a request goes through a proxy, the node’s resolver resolves only the proxy’s host; the proxy resolves the service, an HTTPS request tunnels through it withCONNECT, and TLS still verifies the configured host. An SQS client without a custom CA takes its proxy from the same variables under the same rule, and one with a custom CA uses no proxy. No other path uses a proxy. - Request signing. An SQS request is signed with SigV4 for its configured endpoint before its
host is resolved, so the signature, the
Hostheader and the certificate check name the same host. - Redirects. A Reqwest client that follows a redirect resolves the new host through the node’s resolver like the first, and a native session that follows a leader redirect resolves the leader’s advertised host through the client’s resolver. The HTTP request emitter follows no redirect.
Failure Ownership
A failed lookup is a connection failure of the path that asked, reported through that owner’s existing error and handled by its existing policy. It never rejects a record, never acknowledges or commits input, and never marks a message delivered: a source that cannot connect holds no delivery, and a sink that cannot connect confirms nothing, so the input stays unacknowledged until a later connection delivers it.
| Owner | What a failed lookup does | What tries again |
|---|---|---|
| Interconnect pool slot | Fails that connection attempt and counts it with reason resolution | The slot’s reconnect backoff, described in Connection And Credential Lifecycle |
| Node startup | Fails startup when its own endpoint does not resolve, or when the bootstrap endpoint does not resolve and there are no recovered Raft peer endpoints; otherwise logs and skips an unresolved bootstrap or recovered peer endpoint | The operator or process supervisor for a failed start; reachable recovered peers for a restart with an unresolved bootstrap endpoint |
| A source | Fails its connection or its resume | The ingestor host, on the source’s retry policy or, for a source in MODE NO ACK, its reconnect cadence |
| A sink | Fails its start, its reopening or its publish | The emitter host, on its backoff and retry policy |
| A Redis command pool | Fails the physical connection the pool was opening | The emitter that borrows from the pool, on its retry policy |
| A native session | Fails the connection attempt as ClientError::ConnectServer | The session’s bounded reconnect policy until its command wait ends; BACKUP uses backup_wait_timeout |
A shutdown or quiesce that arrives while a source is resuming cancels the resume, together with every lookup, dial and handshake in it, as Stopping Intake describes. Errors And Diagnostics names the error each owner reports and how the lookup failure stays beneath it.
Dependency Selection
Nervix selects each library’s resolver deliberately. The locked versions and selections are:
| Library | Locked version | Selection |
|---|---|---|
Hickory: hickory-resolver, hickory-proto, hickory-net | 0.26.3 | The node’s resolver, on Hickory’s Tokio runtime. The resolver is built from the configuration nervix-dns reads itself, never from Hickory’s own system configuration reader, which other dependencies compile in. DNS over TLS, HTTPS or QUIC and DNSSEC validation are not selected anywhere in the dependency graph, so resolution adds no second TLS provider beside AWS-LC |
| Reqwest | 0.13.5 | hickory-dns is selected. Every client Nervix builds replaces Reqwest’s own Hickory adapter with the node’s resolver before its first request, so that adapter never reads the host’s configuration; left to itself it would fall back to Google’s public name servers when it could not |
| Reqwest, for Iceberg REST | 0.12.28, with Hickory 0.25.2 | Required by iceberg-catalog-rest 0.10.1. hickory-dns is selected and the client installs the node’s resolver the same way; its TLS stays on AWS-LC with bundled roots |
| OpenDAL | 0.57.0 | Iceberg object storage receives a Reqwest 0.13 client through HttpClientLayer, which also serves OpenDAL’s credential providers. OpenDAL’s standalone S3 detect_region helper builds its own client, and Nervix never calls it |
hyper-util | 0.1.21 | HttpConnector::new_with_resolver with the node’s resolver as its resolver service, for ClickHouse, OTEL gRPC sinks, native sessions and the node’s own trace exporter |
| Smithy’s HTTP client | 1.4.2 | build_with_resolver with the node’s resolver as its ResolveDns, for SQS |
| Tonic | 0.13.1 | connect_with_connector over that HttpConnector, for native sessions |
| Tonic, for OpenTelemetry | 0.14.6 | connect_with_connector_lazy over that HttpConnector, for OTEL gRPC sinks and the node’s own trace exporter |
Lapin and async-rs | 4.12.0 and 0.8.12 | hickory-dns deliberately off: it resolves through one process-wide resolver built on first use from /etc/resolv.conf, which ignores the node’s configuration and bounds and keeps name-server connections of the first Tokio runtime that used it. The connector hands Lapin an established transport through Connection::connector instead |
| Redis | 1.7.1 | AsyncConnectionConfig::set_dns_resolver for pooled command connections; the Pub/Sub source hands PubSub::new a stream it dialled itself |
rumqttc-next | 0.34.0 | MqttOptions::set_socket_connector in place of the driver’s lookup_host connector; its proxy and WebSocket transports are not selected |
| MongoDB | 3.9.1 | dns-resolver selected for SRV and TXT discovery; see Residual Resolution |
A feature selected anywhere in a workspace build is unified into every crate of that build, so a
selection missing from one connector can be hidden by another. just validate-dns-dependencies
therefore checks each affected crate’s own dependency graph, and runs in just validate and in CI:
- The HTTP, Prometheus, Sentry, OTEL and Iceberg connectors select Reqwest 0.13’s
hickory-dns, and the Iceberg connector also selects Reqwest 0.12’s without Reqwest 0.12’s Ring provider. - The RabbitMQ, Syslog, WebSocket, Redis, MQTT, ClickHouse and SQS connectors, and for RabbitMQ also the server, depend on the node’s resolver and Hickory’s Tokio runtime.
- Neither the RabbitMQ connector nor the server selects the Hickory feature of Lapin,
amq-protocol,amq-protocol-tcporasync-rs. - RabbitMQ, Redis, MQTT, ClickHouse and SQS keep their TLS on AWS-LC.
- The MongoDB connector selects the driver’s
dns-resolverfeature.
The interconnect, the native client crates and nervix-dns itself are not checked as roots: each
depends on nervix-dns directly and selects no optional resolver feature. The check has no rule for
the connectors that resolve through their drivers.
Nervix-authored code cannot reach the operating system’s resolver at all.
just validate-primitive-boundary, also part of just validate, rejects tokio::net::lookup_host
and the ToSocketAddrs traits wherever Nervix code names them, and the primitive boundary’s sockets
offer a lookup only in the Turmoil build, where it answers from the simulated DNS table, as
Execution Primitives
describes. A dependency that resolves inside itself is outside that check, which is why Residual
Resolution lists each one.
Residual Resolution
The paths below resolve names without the node’s resolver, because the library that owns their connection takes no resolver, transport or socket from its caller at that boundary in the locked version. They keep working exactly as their drivers define, and each is named here so that no path is assumed to be covered:
| Path | Who resolves, and how | Why the node’s resolver is not used |
|---|---|---|
MongoDB sources and sinks, mongodb 3.9.1 | For a mongodb+srv address, the driver’s own Hickory resolver, selected by its dns-resolver feature and built from the host’s /etc/resolv.conf, looks up the SRV and TXT records, and repeats the SRV lookup while it monitors the deployment. The host of every server address it connects to, whether listed in the address or discovered through SRV, is resolved with Tokio’s lookup_host | The driver accepts no resolver for the addresses its sockets connect to. Its Hickory feature covers discovery only and does not replace that lookup |
NATS sources and sinks, async-nats 0.49.1 | The driver resolves each server address with Tokio’s lookup_host before its per-address connection timeout | No resolver or socket hook |
PostgreSQL pools, sqlx-postgres 0.9.0 | The driver connects with Tokio’s TcpStream::connect((host, port)) | No resolver or socket hook at the pool’s connection boundary |
MySQL pools, mysql_async 0.36.2 | The driver connects with Tokio’s TcpStream::connect((host, port)) | Its resolved_ips option fixes one address list for the life of the pool, which would pin a name to one answer |
Pulsar sources and sinks, pulsar 6.9.0 | The driver resolves broker and proxy URLs with Url::socket_addrs on a blocking worker, then dials the addresses | No resolver hook |
Kafka sources and sinks, rdkafka 0.39.0 with librdkafka 2.12.1 | librdkafka resolves bootstrap and broker hosts in its own native threads | librdkafka’s resolver callback is not exposed by the Rust binding |
ZeroMQ sources and sinks, zeromq 0.4.1 | The driver’s TCP transport connects with Tokio’s TcpStream::connect((host, port)) | No socket hook |
| The web console | The browser resolves the page’s origin and its WebSocket and fetch requests | The browser owns resolution |
Tokio’s lookup_host and TcpStream::connect with a host name, and Url::socket_addrs, resolve
through the C library on a thread of Tokio’s blocking pool, and librdkafka calls it on its own
threads. Such a lookup follows the host’s nsswitch.conf, NSS
modules and multicast DNS where the host provides them, the node’s --dns-* options do not apply to
it, and only the driver’s own connection timeout bounds it. A thread stays occupied until the C
library returns.
Deployment Limits
The node’s resolver implements the resolv.conf and hosts-file behavior this chapter describes and
nothing else of the operating system’s name service:
- It does not consult
nsswitch.conf, so the hosts file always answers before DNS and no other source is ever asked. - It loads no NSS module. Names served only by LDAP, NIS,
mdnsormyhostnamedo not resolve; in particular a node’s own host name resolves only when the hosts file or DNS lists it, which container runtimes arrange by writing it into the container’s hosts file. - It sends no multicast DNS, and it applies no
sortlistand none of the options Configuration lists as having no effect. - It reads its files once. A change that a DHCP client, a VPN or a network manager makes to
/etc/resolv.confreaches the node only when it next starts. A local stub such as thesystemd-resolvedlistener at127.0.0.53keeps a stable address while its own upstream servers change, so a node that names the stub follows such changes without a restart. - A platform split-DNS policy applies only as far as the name server the configuration names
applies it, which is the case for the
systemd-resolvedstub. - It speaks plain DNS over UDP and TCP to the servers it is given. It does not validate DNSSEC and does not use DNS over TLS or HTTPS.
Answers are not authenticated, so an address is only as trustworthy as the DNS path that returned it. Every TLS connection verifies the certificate against the configured host rather than the dialled address, so a forged answer cannot impersonate a TLS server, and the interconnect always uses mutually authenticated TLS. A plain-text connection trusts whatever address it was given.
Docker’s embedded DNS and Kubernetes cluster DNS, with their search lists and ndots, are reached
through the resolv.conf those platforms write into each container. The Compose cluster in
Docker addresses nodes by container name through Docker’s embedded DNS.
The Kubernetes resources in the repository advertise each pod’s fully qualified name under a
headless service that publishes pods before they are ready, so a pod’s name resolves as soon as the
pod has an address. Such a name has more labels than the platform’s ndots:5 and is asked as
written before any search domain.
Because the interconnect gives a lookup at most its five-second connection setup timeout, keep the
resolver configuration’s timeout well below five seconds when a node has more than two name
servers: a try goes to two servers at once, and under the default five-second timeout two silent
servers consume the whole budget before a third is asked.
Build Modes And The Simulation Boundary
| Build | Peer names | Connector and client names |
|---|---|---|
| Normal | The node’s resolver through PeerResolver::new | The node’s resolver, or the client’s own |
turmoil | PeerResolver::simulated, which answers from the simulated host’s DNS table through the lookup the primitive boundary offers only in this build | Not part of the simulation |
shuttle | The production PeerResolver, but sockets are Tokio’s and outside every model, and a Shuttle execution has no Tokio reactor, so no Shuttle check opens a socket or resolves a name | The same: no Shuttle check opens a connector or a session |
loom | Not modeled | Not modeled |
| Browser | The web console runs no interconnect | The browser resolves; nervix-dns and Hickory are absent from the web console’s dependency graph |
The Turmoil build still compiles nervix-dns for its typed failures and its connection budget,
but it never constructs a resolver, reads a resolver configuration or hosts file, or sends a DNS
packet, so a simulated host cannot reach the real network and shares no resolver state with another
host. A simulated name that the table does not hold fails as a name that does not exist, and a
simulated lookup does not wait on the caller’s budget. The simulation therefore exercises the
production transport over names, while Hickory’s protocol, cache and failure handling are checked
against local DNS authorities outside it, as Evidence describes.
Deterministic Interconnect Simulation owns that
build.
The resolver bounds its lookups with the primitive boundary’s semaphore and measures its budgets
with the boundary’s timers, and it publishes no state to a data-plane protocol, so it has no Shuttle
check or Loom model of its own; the concurrency bound and cancellation are checked by its own tests
against real lookups. nervix-dns cannot be built for the
browser at all, because it requires the native capability of nervix-primitives.
Evidence
Each form of evidence establishes a stated part of this contract and nothing more.
tests/dns-resolution-ledger.md is the acceptance record: it names every path, the delivery that
moved it, and each test and scenario by name.
The OTEL prepared-request check starts with a one-second negative DNS answer, then publishes a real loopback gRPC receiver. It requires a fresh question and one accepted export with the exact prepared bytes within ten seconds. This qualifies recovery through the connector’s existing lazy channel; it does not attribute a historical Collector observation timeout or model the external collector. The public one- and three-node log/trace outline retains that timeout’s failed attempt separately from successful controls and later qualification.
| Evidence | Command | What it establishes | What it does not |
|---|---|---|---|
| The resolver’s own checks against an in-process DNS authority | just test-dns | Literal and hosts-file answers without a question; search completion and fully qualified names; IPv4-first dual-stack answers; positive and negative TTL reuse and expiry; missing names, empty answers, refusals and invalid names as distinct failures; a silent server ended by the budget and, sooner, by the configured timeout and attempts; lookups beyond 64 waiting and freed by cancellation; a resolver rebuilt and torn down with its runtime; the configuration grammar and its rejections; both Reqwest hooks, the Hyper connector and the Smithy hook dialling a later answer, keeping the URL authority, following a redirect, reconnecting after TTL expiry, being cancelled by a request timeout, and keeping the typed failure and the lookup’s whole report as a cause | The authority answers over UDP only, so truncation and DNS over TCP, a name server that cannot be reached, and the order in which several servers are asked are not exercised |
| Connector and client unit checks | just test-package-lib <package> | Each dialling connector’s ordered attempts, the time an unresponsive address leaves for the next, literal addresses without a lookup, typed lookup failures beneath the connector’s error, TLS failures and budgets; the native client’s ordered answers, deadline and typed failure; SigV4 headers naming the configured host | Behavior through a whole node |
| Public scenarios | just test-scenarios --input <feature> | Nodes whose resolver asks the harness’s DNS authority, described in DNS Authorities, on one- and three-node clusters, with topology-specific cases on three: cluster formation through names and IPv6 literals, hosts-file and single-label names, a peer followed to a new address after its name stopped resolving, a stopped leader rejoining through its advertised name, and a recovered voter restoring quorum while its bootstrap name does not resolve; every migrated connector over plain and TLS transports through a fixture name; certificates checked against the configured host; outages answered as a missing name, no address or silence, with offsets held and delivery after recovery; changed answers followed after a restart; the CLI and a client’s named seeds | Resolution through a real recursive resolver, and redirects to a named session endpoint, because every advertised client endpoint in the harness is a literal address |
| The Turmoil simulation | just test-turmoil | The production transport resolving peer names through the simulated table on every connection, deterministically and under replay | Anything about Hickory, which the simulation never constructs |
| Dependency graph checks | just validate-dns-dependencies | The feature selections of Dependency Selection in each affected crate’s own graph | Runtime behavior |
| The primitive boundary check | just validate-primitive-boundary | No Nervix-authored code calls the operating system’s resolver through tokio::net::lookup_host or ToSocketAddrs | Resolution inside a dependency |
| The external Chaos suite | just chaos | Three node containers that advertise and bootstrap by container name through Docker’s embedded DNS with the production resolver, including a recovered voter whose stopped bootstrap container no longer resolves | Arbitrary DNS failures, changed answers beyond the stopped container, and connector names |
The scenario features are cluster/dns_resolution.feature, the runtime/*_dns_resolution.feature
files for RabbitMQ, Redis, MQTT, Syslog, WebSocket, ClickHouse and SQS, the fixture-name scenarios
in the HTTP polling, Prometheus, Sentry, OTEL, Iceberg, HTTP emitter and WebSocket client features,
tools/cli_session.feature, and runtime/client_wire_qualification.feature.
The consensus append-admission unit fixtures construct the ordinary resolver from temporary resolver and hosts files with an explicit loopback name-server address. Their transport rejects a departed target before lookup; these tests qualify the caller’s retry and ended-input boundaries, and make no DNS-answer or network-resolution claim.
The MQTT changed-answer and restart scenario requires the new post-restart payload inside a bounded delivery wait. Its persistent QoS 1 source may replay the preceding payload before that marker; the resolver qualification proves reconnecting delivery and preserves MQTT’s replay semantics. The OTEL fixture-name metric scenario observes a successful export from both concrete emitter owners before checking the Collector’s HTTP and gRPC output.
Guarantees And Non-Guarantees
The evidence above establishes these guarantees:
- A lookup through the node’s resolver never occupies Tokio’s blocking pool or calls the C library, and a configuration that cannot be loaded fails startup instead of selecting another resolver.
- Every lookup ends within its caller’s budget, at most 64 run at once on a node, and a cancelled lookup frees its slot at once.
- An address answer is reused for at most one hour and a negative answer for at most thirty seconds, and neither beyond its own TTL.
- Apart from the interconnect’s startup lookups, every path resolves its configured name again for each new connection.
- TLS verification, the HTTP authority and SQS request signing name the configured host, whichever address accepted the connection.
- A failed lookup never rejects a record, acknowledges input or commits an offset.
- The Turmoil build never constructs the production resolver, reads a resolver configuration or hosts file, or sends a DNS packet.
Nervix does not guarantee:
- Any part of the operating system’s name service beyond the resolver configuration and the hosts file, as Deployment Limits lists.
- That the paths Residual Resolution lists follow the node’s DNS options, cache or bounds.
- Authenticated or encrypted DNS.
- That HTTP polling, Prometheus, Sentry, OTEL and Iceberg name the lookup failure in their diagnostics.
- That an established connection follows a changed answer before its owner ends it.
- That the HTTP request emitter reaches a later answer when an earlier one never responds within
its
timeout_ms.
Operator Diagnostics
A node that loads its resolver logs at info:
loaded the name resolver configuration resolver_configuration=/etc/resolv.conf hosts_file=/etc/hosts name_servers=ResolverConfiguration
name_servers reads Explicit([...]) with the servers --dns-name-server named. A line of the
resolver configuration that could not be read is logged at warn as
ignored a line of the resolver configuration, with the file and the parser’s reason, and a node
that cannot load its resolver exits with the startup failure Configuration
describes.
A failed lookup is visible through the path that asked. The resolver itself logs no individual lookup and exports no metric of its own.
| Where | What shows the failure |
|---|---|
| RabbitMQ, Redis, MQTT, Syslog, WebSocket, ClickHouse and SQS, and the HTTP request emitter | DESCRIBE INGESTOR or DESCRIBE EMITTER shows the lookup failure on its transient error: line, with the host and the reason, such as resolving 'rabbitmq.example.internal' failed: the name does not exist. An SQS source looks its queue up when an instance resumes, and shows a lookup failure there on the same line |
| HTTP polling, Prometheus, Sentry, OTEL and Iceberg | DESCRIBE INGESTOR or DESCRIBE EMITTER reports the failed request, export or catalog call, such as OTEL gRPC export ended without an answer from the receiver, without the lookup failure beneath it |
| The interconnect | nervix_interconnect_connection_failures_total{reason="resolution"} counts failed pool connection attempts by pool class, and each attempt is logged at debug as interconnect pool connection failed with the peer’s node, endpoint, pool class and slot and an error reading resolving <endpoint> failed: <reason> |
| Node startup | A recovered Raft member that does not resolve is logged at warn as could not resolve a recovered Raft peer for gossip; with recovered peer endpoints, an unresolved configured bootstrap host is logged at warn as could not resolve the configured bootstrap host for gossip and skipped. An own endpoint, or a bootstrap endpoint needed for first contact, fails startup as failed to start cluster membership |
| The CLI | The command fails with failed to connect to server and prints its causes, the last of which is the lookup failure, such as resolving 'nervix.example.internal' failed: the name does not exist |
| The shared C binding | The call fails with NX_ERROR_CONNECT, and its message joins the same causes |
To see what a node sees, ask the name servers its resolver configuration names, remembering that
the node answers a name its hosts file lists without asking DNS and never consults
nsswitch.conf.
Recovery Examples
These follow from the contract above; Evidence names the checks behind each.
- A broker’s name stops resolving. A source fails its resume with the lookup failure, which
DESCRIBE INGESTORshows, and tries again on its retry policy. It receives nothing and acknowledges nothing meanwhile, so what its system keeps for it, such as a queue’s messages, is delivered after it reconnects, while a system that keeps nothing for an absent subscriber, such as Redis Pub/Sub, delivers only what is published after the reconnection. A sink that cannot reconnect confirms nothing, so its input stays unacknowledged and a Kafka source feeding it holds its offset, whileDESCRIBE EMITTERshows the failure. Once the name is published again, the first attempt after the cached negative answer expires, at most thirty seconds after it was cached, connects and delivers. - A name server goes silent. Each lookup ends at the caller’s budget, or earlier at the
configuration’s
timeoutandattempts, asno answer arrived in time, and the caller retries on its own schedule. Established connections keep working, because nothing re-resolves an open connection. - A service or peer moves to new addresses. Open connections stay where they are until their owner ends them. The next connection resolves again once the cached answer expires and dials the new addresses; a peer that returns at a new address after its name stopped resolving is reached again the same way.
- The first answer cannot be reached. A caller that dials for itself moves to the next answer when the first refuses or its share of the budget ends; a caller that dials through a library moves on under that library’s policy, as Dialling The Answers describes.
- The DNS configuration must change. Edit the resolver configuration or hosts file, or change
--dns-name-server, and restart the node; a rolling restart applies it across a cluster. A node that names a local stub such assystemd-resolvedfollows the stub’s upstream changes without a restart. - A node will not start.
failed to load the name resolver configurationnames the file and the reason. Make the file readable, give it anameserverline or pass--dns-name-server, or correct the search domain, and start the node again.
Client Session Protocol
The client session protocol is the public boundary between a client and a Nervix node. It carries NSPL commands and their outcomes, transaction attachment and inspection, completion and choice lookups, domain selection and the observations that follow it, Row subscriptions, domain clock attachments, producers that submit batches to client ingestors, resource uploads, backup downloads, and restore streams. The CLI, the web console, the Rust client, every host of the shared Rust binding, and independent implementations in other languages all speak it.
The protocol owns framing, verification, the session limits, request correlation, cancellation, typed dispositions, the redirect to the leader, the lifecycle of a session, and delivery of the unsolicited frames a session receives. The operation behind a request still owns what that request means: the control plane decides what a command does and when it is complete, the transaction planner decides what a transaction affects, the resource catalog decides when a version exists, and the runtime decides what a relay delivers and when a submitted batch completes. A reply is therefore never more than the statement it makes. Four facts stay distinct at this boundary, and a client that confuses them either repeats an effect or reports one that did not happen:
- Bytes transferred. A frame reached the other side. Nothing about a request follows from it.
- Work admitted. The cluster durably recorded a command under its execution identity. The work continues whatever happens to the session that sent it.
- Effects completed. The command reached its completion boundary, and its terminal outcome is retained under that identity.
- Waiter cancelled. A client stopped waiting. Before admission nothing began; after admission the effect continues and can be recovered by its identity.
This chapter explains why the protocol behaves as it does. The Client Implementation Manual states, as rules, what a client implementation must do. Each links to the other instead of repeating it. Command Completion owns the completion boundary and the retention of execution identities, Control Plane owns the transaction lifecycle, Transaction Quiescence And Impact Inspection owns the impact report, Resource Versions And Bindings owns the version lifecycle behind an upload, Backup And Restore owns what a backup archive holds, Domain Clock owns the clock a session can follow, Errors And Diagnostics owns how typed failures become diagnostics, and Shutdown And Recovery owns the phases of a node stop.
A Boundary Separate From The Interconnect
Clients never speak the Cluster Interconnect, and nodes never use the session protocol to talk to each other. The two boundaries answer different questions and are built differently:
| Client session protocol | Cluster interconnect | |
|---|---|---|
| Peers | Untrusted clients acting for a registry user | Cluster nodes holding certificates from the cluster CA |
| Authentication | Basic credentials of a registry user on every call or upgrade | Mutual TLS 1.3 with one node identity URI per certificate |
| Listeners | The session gRPC listener and the console listener of every node | One interconnect listener per node |
| Encoding | FlatBuffers frames verified against one public schema | Bounded rkyv control archives and Arrow IPC relay bodies |
| Payload of a relay | Typed Row cells, positional against an announced schema | Arrow record batches |
| Identity of work | A request identity per session and a durable execution reference per command | Coordination identities, process epochs, and delivery identities |
| Evolution | One current schema; no version field, negotiation, or fallback | One current wire contract, fenced by a fingerprint at connection setup |
A follower does not forward a client’s leader operation over the interconnect on the client’s behalf. It answers with a redirect that names the leader, and the client reconnects there, so every leader operation arrives at the leader on a session the client itself holds. Subscriptions are the one path that crosses both boundaries: the relay owner fans a batch out to the subscriber’s node over the interconnect, and that node encodes Row frames for its own sessions.
Ownership By Layer
| Layer | Owner | What it owns |
|---|---|---|
| Vocabulary | nervix-models | Execution references, domain, relay, subscription and user names, timestamps, the transaction status, preview identity and impact report, the resource description, and the observed domain clock. |
| Edges | The wire crate, nervix-client-wire | The FlatBuffers schema, frame verification and ownership, the session limits, the typed requests, replies, events, transfers and rows the schema describes, the display text of a row, and how frames travel in gRPC and WebSocket messages. It knows no registry, runtime, consensus, parser, Arrow, or client dispatch. |
| Edges | The session service in nervix-server | Authenticating a call, one session per transport connection, correlation, the ordered and concurrent lanes, cancellation against admission, typed rejections, reply encoding and transfer, the control and subscription lanes a session’s frames wait in, unsolicited events, domain clock attachments, the upload stream, the backup download stream, the restore stream, and carrying a download or a restore over a console WebSocket of its own. |
| Edges | The Row encoder in nervix-server | Binding one subscription generation to the row schema it announces and writing selected Arrow rows into bounded frames. |
| Control plane | The command pipeline and transaction use cases | Durable execution identity, admission, exact recovery, domain mutation ownership, plan fencing, the transaction lifecycle, and the completion barrier. |
| Control plane | Backup execution and retained archives | Assembling a backup’s archive from one applied revision, retaining it under the backup’s execution reference until a download collects it or its retry validity ends, and the bounded stream that sends it. |
| Control plane | Session subscriptions | Creation and deletion, the generation each subscription opens with, its lifecycle, its filter and sampling, the delivery of one generation to its session, and the interest lease it holds on its relay. |
| Data plane | Relay subscription fan-out | The subscribers of one relay, the definition they were attached under, and ending every subscriber before a batch of another definition can reach it. |
| Edges | The Rust client, nervix-client-core | Connecting, TLS selection, the dispatcher that pairs replies with requests, execution identity across retries, redirect and reconnect, transaction binding and previews, desired subscriptions and their restoration, followed domain clocks, uploads, and backup downloads verified against their summary. |
| Edges | The shared binding, nervix-client-ffi | The C ABI through which C, C++, Python, JVM and Ruby hosts drive the Rust client’s state machine, with borrowed frame access, bulk column copies, retained domain clock events, retained reads of the clock of each followed domain with its projections, and the Rust client’s producers and consumers with their typed outcomes, settlements and Arrow batches built and read one column level at a time. |
| Edges | The web console and the CLI | Consumers of the same protocol with bounded buffers of their own. The console speaks it over its WebSockets: the session, and one per backup download or restore stream, whose archive it verifies or measures itself; the CLI uses the Rust client. |
The server is the composition root: it is the only crate that names the wire crate, the command
pipeline, and the runtime together. The wire crate names neither Arrow nor the parser, so an
implementation in another language needs only the schema. The Rust client additionally names the
language edge, which it uses to split a request into statements and to recognize the statements it
serves itself, such as USE; everything it knows about the cluster arrives over the protocol.
The Rust client and CLI declare their edge ownership in gated compiler source contracts. Their session maps remain client-owned synchronization outside the server record-path acquisition rule. Server relay subscription fan-out remains recurring data-plane execution with its own source contracts and exact-operation repair expectations. The annotations change no session framing, recovery or delivery guarantee. Data-Plane Concurrency owns this compiler boundary. Execution Primitives owns the shared backend selection, portable reference types and declared unmodeled exceptions used by the Rust client, binding and browser; those build choices change no wire contract.
Wire Format
One Schema, Eight Frame Roots
crates/client-wire/schema/session.fbs declares every shape of the public boundary, once. Eight
root files name the table a frame is rooted at and the four-byte file identifier it carries:
| Root | Identifier | Direction | Carried by |
|---|---|---|---|
ClientMessage | NXCM | Client to server | The gRPC Exchange stream and the console WebSocket |
ServerMessage | NXSM | Server to client | The gRPC Exchange stream and the console WebSocket |
UploadMessage | NXUM | Client to server | The gRPC UploadResource stream |
UploadReply | NXUR | Server to client | The gRPC UploadResource stream, exactly once |
BackupDownloadRequest | NXBQ | Client to server | The gRPC DownloadBackup call, exactly once |
BackupDownloadMessage | NXBD | Server to client | The gRPC DownloadBackup stream |
RestoreMessage | NXRM | Client to server | The gRPC RestoreBackup stream |
RestoreReply | NXRR | Server to client | The gRPC RestoreBackup stream, exactly once |
A frame is one finished FlatBuffer that carries its root’s identifier and no size prefix. The transport delimits frames, so a frame never has to describe its own length and a receiver never parses a length that a peer chose.
A ClientMessage holds one request and the request identity that correlates its replies. A
ServerMessage holds either a Reply to one request or one unsolicited body: a server notice,
leadership, the domain list, the selected domain’s snapshot and cluster summary, subscription rows
and the notices about them, a domain clock frame, a producer’s admission change or end, or the
ending of the session. An unsolicited body
carries no request identity and never completes a request.
Verification Before Reading
A receiver verifies a frame once, when it takes ownership of the bytes and before it reads any
field. The frame must fit the session frame limit, hold at least a root offset and identifier, carry
the expected identifier, and pass FlatBuffers verification. Verification bounds every offset and
length, requires every (required) field, validates every string as UTF-8 with its terminator, and
limits nesting depth, the number of tables, and the apparent size of the traversal. The table and
traversal budgets scale with the frame being verified rather than with the limit: a frame of n
bytes may hold at most n / 4 tables and may expand to at most eight times its own size, so a small
frame whose offsets alias one another cannot make a receiver walk far more than it sent.
Reading then applies the rules the verifier does not know. Every union is required, so the NONE
discriminant is never valid. A union discriminant or enum value the schema does not declare is
rejected, and so is an absent optional scalar that a comment requires, a zero where a comment
requires a non-zero value, an empty collection that must not be empty, a vector or string longer
than the session limits, a set that is not in canonical order, and a value the receiver’s platform
cannot represent. A receiver never substitutes a default for a rejected value.
Scalars declared = null are optional: absent means not provided, and a present zero is a value.
The distinction carries meaning in several places. An append that expects position zero is not an
append that expects nothing, and a float cell holds its value as an optional scalar because a
defaulted field would lose the sign of a negative zero. Other scalars read as their declared
default, zero or false, when absent.
Verified bytes are immutable, and every value the wire crate hands out borrows from them, so no view reads unverified bytes and no read verifies again. A verified frame pins the buffer it was received in for as long as it, or any value that keeps it, is alive. The gRPC codec therefore copies a frame smaller than 64 KiB out of the transport’s read buffer, which grows to the largest message the connection has carried, and takes a larger frame without a copy.
Session Limits
One set of limits governs a session. The transports configure their message limits from it, so an oversized message is refused before it is buffered, and every encoder checks each string and vector against it before the FlatBuffers builder grows, so no Nervix encoder produces a frame that a receiver holding the same limits rejects.
| Limit | Default | What it bounds |
|---|---|---|
| Frame | 4 MiB | One gRPC message or one WebSocket binary message. It matches the receive limit gRPC applies by default. |
| Transfer | 64 MiB | One reply reassembled from transfer parts. |
| Nesting depth | 64 | Nested tables in a frame, counting the root as the first level. The deepest structure of the schema that does not recurse, a node coverage in an execution step’s affected topology inside an inspection reply, nests 13 tables; the rest is for nested list values and list field types. |
| Collection entries | 262,144 | Entries in one vector. |
| String | 64 MiB | Bytes in one string. |
Every Nervix server and client uses these defaults. The wire crate refuses a configuration whose frame limit is outside 1 KiB to 256 MiB, whose transfer limit is below the frame limit, whose nesting limit is outside 16 to 128, or whose string limit exceeds the transfer limit. The upper bounds keep the verifier’s budgets representable in the 32-bit address space of the browser build, and the nesting bound caps the stack a hostile frame can make the verifier and the decoders use.
Replies Larger Than One Frame
A reply may be larger than one frame; a rendered transaction report is the usual case. The server
encodes the complete reply once, from one read of its state, and cuts that single frame into
TransferPart replies. The parts of one reply share its request identity and its total size, arrive
in order with contiguous offsets starting at zero, and each carries a non-empty chunk. The client
joins them and verifies the result as a frame of its own, under the transfer limit, which must hold
a Reply to the same request whose body is not itself a transfer part.
A transferred reply is therefore exactly as trusted as one that fit a frame. It is never delivered
truncated, and it can never mix two reads of the server’s state. Parts of one reply stay in order,
but other replies and unsolicited frames may arrive between them, which is why a client keeps one
reassembly per request identity. A reply above the transfer limit is not sent at all: the request
receives the typed rejection ReplyTooLarge instead. A reply that carries an impact report is
encoded on the bulk workers under the bulk memory class; when neither memory nor a worker is
available for it, the request receives ServerBusy, which is safe to repeat because such a reply
belongs to a request that changes nothing.
One Current Form
The protocol has exactly one current form. Frames carry no version, a session negotiates nothing,
and neither side keeps a compatibility reader for an earlier schema. A changed shape is a
coordinated change of the server, the Rust client, the binding, the console, the conformance
clients, and the corpus in one commit. A peer built against another schema meets the ordinary
verification and reading rules: an undeclared request kind is rejected as UnsupportedRequest, an
undeclared enum value as UnsupportedValue, and a client rejects a server discriminant it does not
know rather than skipping it.
The conformance corpus in crates/client-wire/conformance pins the bytes the Rust encoder writes
for a set of requests, replies, and events chosen to reach what live traffic does not, such as
extreme values, nested lists, and every refusal of a domain clock attachment, together with
corpus.report, the reading of those bytes that every other reader must reproduce.
just test-client-wire checks that the checked-in bytes are exactly what the encoder writes, and
just update-client-wire-corpus regenerates them for review.
The registered client representation properties also compare complete generated current values through the production codecs. Separate targets verify malformed bytes under fixed limits; Arrow properties compare selected logical values and validity with decoded Row cells, using float bits and explicit redaction, while binding properties check column copies and retained frame lifetime. The client representation coverage map records their schema coverage and ownership boundaries. Both ordinary corpus replay and sanitizer-backed fuzzing run the same assertions through the property target inventory.
Transports, Endpoints, And Authentication
Every live node runs both client listeners. Neither depends on leadership or placement, and both close only when the node begins to stop.
Native gRPC
Native clients reach the gRPC service nervix.session.Session, which serves four methods and
answers every other path with UNIMPLEMENTED:
| Method | Shape | Frames |
|---|---|---|
/nervix.session.Session/Exchange | Bidirectional stream | ClientMessage frames answered by ServerMessage frames. One call is one session. |
/nervix.session.Session/UploadResource | Client stream | UploadMessage frames answered by exactly one UploadReply frame. One call is one upload. |
/nervix.session.Session/DownloadBackup | Server stream | One BackupDownloadRequest frame answered by BackupDownloadMessage frames. One call is one download. |
/nervix.session.Session/RestoreBackup | Client stream | RestoreMessage frames answered by exactly one RestoreReply frame. One call is one attempt of a restore. |
Each gRPC message holds exactly one frame, delimited by gRPC’s own length prefix. The messages are not protocol buffers, and no generated gRPC service code is involved: the codec hands the frame bytes to the transport unchanged and verifies what it receives. The server speaks HTTP/2 only, accepts no message compression, and sets gRPC’s message limits to the frame limit, so a message above it is refused before it is buffered.
The listener serves plaintext HTTP/2 on --addr (NERVIX_ADDR, default 127.0.0.1:47391) and
advertises --grpc-advertise-addr (NERVIX_GRPC_ADVERTISE_ADDR), or the listen address when that
is unset. --grpc-mode https (NERVIX_GRPC_MODE) serves the listener over TLS on
--grpc-https-listen-addr instead and advertises --grpc-https-advertise-addr. That mode presents
the development certificate that scripts/generate_dev_tls.sh writes under tls/dev in the source
tree the server was built from; no option selects another certificate, and the Kubernetes deployment
runs the listener in plaintext mode.
Console WebSocket
The web console reaches the same session engine over a WebSocket, GET /console/ws on the console
listener: --web-console-listen-addr (NERVIX_WEB_CONSOLE_LISTEN_ADDR, default 0.0.0.0:47420),
and optionally a TLS listener, --web-console-https-listen-addr with --web-console-tls-cert and
--web-console-tls-key. Each binary message holds exactly one frame in either direction and nothing
else: no size prefix, never two frames, never part of one. Ping, pong, and close messages belong to
the WebSocket connection and never carry a frame.
The console session is served only by the leader. A console session on any other node is ended at
once with a SessionEnding whose reason is a LeaderRedirect naming the leader, and the console
reconnects to the leader’s advertised console endpoint. A console session also ends this way when
its node stops leading, which the node checks every 250 milliseconds.
The console listener serves three WebSockets, each carrying one kind of call with exactly the frames its gRPC method carries:
| Path | Call | Frames |
|---|---|---|
GET /console/ws | The Exchange session | ClientMessage frames answered by ServerMessage frames. One connection is one session. |
GET /console/backups/download | One DownloadBackup call | One BackupDownloadRequest frame answered by BackupDownloadMessage frames, then a normal close. |
GET /console/backups/restore | One RestoreBackup attempt | RestoreMessage frames answered by exactly one RestoreReply frame, then a normal close. |
A frame of another call’s root fails verification and closes the connection with 1007. A download
connection takes exactly one request: another data message after it closes the connection with
1008, and a close or a failed connection stops the download before it sends another frame, which
releases only its own hold on the archive. Any node serves a download and a restore, and each
answers from its own role exactly as its gRPC method does: a node that does not retain the archive
and is not the leader answers a download with a redirect, and a restore sent to a follower is
answered with a redirect outcome.
A WebSocket client cannot half-close a stream the way a gRPC client does: once it sends a close, it
reads nothing more. A restore over the console WebSocket therefore ends with the chunk that
completes the size its start declares, and the connection stays open for the reply. A close or a
failed connection before that chunk ends the attempt in transport, which changes nothing and
releases what the leader staged for it. A frame the restore cannot read, such as a chunk before the
start or a second start, ends the stream at that frame, and the restore refuses it without reading
further. The server closes with 1011 the rare answer it cannot fit into a frame.
The console has no upload stream: it uploads a resource through its own HTTP path,
/console/resources/upload, where the leader builds the archive from the files it receives, as
Resource Versions And Bindings describes.
Authentication
Both transports authenticate a registry user with Basic credentials before any frame is read. A gRPC
call presents authorization: Basic <base64(user:password)> metadata, on every call, and a call
that fails authentication ends with UNAUTHENTICATED. The console upgrade presents the same
credentials in an Authorization: Basic header or, because a browser cannot set headers on a
WebSocket, as the auth query parameter; a failure answers 401 with a Basic challenge and never
upgrades. GET /console/auth checks credentials without opening a session. Passwords are verified
against Argon2 hashes held in consensus, on the node’s credentials worker under a charge of the
working memory the stored hash’s parameters make Argon2 allocate. That class serves only password
work, so saturated data, extension or bulk work never delays a login, and concurrent attempts wait
for it in turn. A node whose credentials class cannot take the verification now answers
UNAVAILABLE, or 503 without an upgrade on the console: the
credentials were not judged, so the attempt neither counts as a failure nor paces the user, and the
same credentials may be presented again. After a failed attempt for a user, that user’s later
attempts are paced to ten per second until one succeeds.
Authentication establishes who owns what a session does: its transaction binding, its execution references, its subscriptions, and its uploads. It is the only access control. Every authenticated user may run every command in every domain; ownership decides only who may attach and inspect a transaction, and scopes execution references and upload identities to their user.
Transport Failures
A failure of the framing ends the connection, because nothing after it can be trusted. Everything a well-formed frame can get wrong is answered inside the session, as a typed reply to the request it names.
| Failure | Native gRPC | Console WebSocket |
|---|---|---|
| Credentials missing or wrong | UNAUTHENTICATED | 401, no upgrade |
| Credentials the node could not verify now | UNAVAILABLE | 503, no upgrade |
| Message above the frame limit | OUT_OF_RANGE, refused before buffering | Close 1009 |
| Message that is not a valid frame, or a frame of another call | INTERNAL | Close 1007 |
| Text message | — | Close 1003 |
| A second request on a download | — | Close 1008 |
| The node began to stop, during a download or a restore | The call is cut | Close 1001 |
| Frame without a valid request identity | SessionEnding with ProtocolViolated, then the end of the stream | The same SessionEnding, then a normal close |
| Session ended by the server | The end of the stream, with status OK | A normal close |
Requests, Lanes, And Cancellation
Request Identity Versus Execution Identity
Every ClientMessage carries a request identity: a non-zero 64-bit value the client chooses and
never reuses within the session. Every reply to that request is a Reply naming the same identity,
and a request receives exactly one terminal reply, delivered whole or as transfer parts. The
identity exists only to pair replies with the request waiting for them. It is scoped to one session,
which is one gRPC Exchange call or one WebSocket connection, and a new session starts over.
A command’s execution reference is a different identity with a different purpose. It names the command’s effects durably, across retries, redirects, reconnects, leader changes, and full restarts, and it outlives every session that carries it. A retry after a lost reply is a new request, with a new request identity, that repeats the same execution reference. Confusing the two breaks both guarantees: repeating a request identity cannot recover an effect, and minting a new execution reference for a retry can apply an effect twice.
Two Lanes
A session serves each request on one of two lanes:
- The ordered lane serves the requests that change the session or may change the cluster:
commands, transaction attach, subscribe and unsubscribe, domain clock attach and detach, and
producer and consumer opens. They
run one at a time in the order the client wrote them. A mutating command never overtakes the
command written before it, and a subscription never opens before the command that created its
relay has completed. Every command runs on this lane, including a
SHOWorDESCRIBE, so within one session a read command waits behind a command that is waiting for its completion boundary. - The concurrent lane serves the requests that only read the session: completion suggestions, choice lookups, listing and selecting domains, and transaction inspection. Each runs as its own task beside the ordered lane, from the view of the session that the ordered lane last published, so a long command never delays a completion, an inspection, or a domain request. Their replies can arrive in any order. Selecting a domain changes only which domain’s observations the session receives.
A cancellation is on neither lane: it is answered as soon as it arrives. Neither is a submitted
batch or a producer close: the receive loop hands both to the producer’s own task and reads the
next request at once; see Producers. A session admits at most 64 requests in flight,
counting both lanes and producer closes but not submitted batches, which their producer’s credit
bounds instead. A request beyond that is refused with
TooManyRequestsInFlight rather than queued, which also bounds the requests waiting for the ordered
lane; nothing about the refused request was admitted. The web console never sends a request that
keeps its order past that limit: it holds the request, in the order it was issued, until an
earlier reply frees a place. Its completions, choice lookups, and domain selections go out at once
and report the refusal if they find the session full. A request identity that is already in flight
is refused with DuplicateRequestId, and that refusal necessarily names the same identity as the
request still in flight.
Choice lookups on the concurrent lane return typed values for structured client controls. A domain
dependency selects internal schemas, branches, relays, codecs, VHOSTs, signaling protocols,
JSON/CBOR/AVRO wire schemas, or resource catalogs; a domain and relay reference select relay
fields; a domain and codec reference select that codec’s output schema fields; a domain and resource
reference select completed resource versions. Wire-schema targets have separate discriminants, so
a codec cannot mistake a JSON wire schema for a CBOR or AVRO schema with the same name. A
visual ingestor uses a distinct source-reference target for each of the 14 connector and entity
pairs that read an external system; a client producer ingestor selects its schema through the
schema target instead. Each source-reference target requires a domain and offers clients of its
pair’s connector, except the HTTP endpoint and WebSocket endpoint targets, which offer endpoints of
that TYPE. Its decoding-codec target offers codecs that can decode. An unbranched-relay
target needs a domain; a branched-relay target also needs the selected branch and offers relays
with exactly that branch. Ingestor error relays use the unbranched target. A branch-field target
takes a domain and branch reference. Ingestor decoded and output fields use the existing
codec-field and relay-field targets. A junction or reingestor asks for another input relay with a
domain and the first relay reference; candidates have that relay’s exact named schema and branch.
The same dependencies with the output-relay target select the input branch, and with the
materialized-relay target select only relays with materialized state in that branch. Route
construction of an unbranched or named branch uses the ingestor relay targets, while branch keys
and record fields use the branch-field and relay-field targets. These processor targets resolve
against the attached transaction prefix, and their page digests include the selected input
definition. The typed target and dependencies remain part of the cursor identity. A
completed-version value
is either an explicit number or LATEST, not a label to parse. Resource catalogs include resources
staged earlier in the attached transaction, while version choices include completed uploads only.
The cursor binds the selected candidate set and its definitions; a changed context returns
StaleContext instead of continuing an earlier page. Sessions
owns the complete lookup contract and the server’s transaction-aware resolution.
flowchart LR
Client -- ClientMessage --> Decode[Verify and decode]
Decode -- commands, attach, subscribe, unsubscribe, clock attach and detach, producer and consumer opens --> Ordered[Ordered lane: one at a time, in written order]
Decode -- suggest, choices, domains, inspection --> Concurrent[Concurrent tasks]
Decode -- cancel --> Cancel[Answered at once]
Decode -- submit batch, close producer --> Pumps[Producer tasks]
Pumps --> Control
Ordered --> Control[Control lane: 16 frames]
Concurrent --> Control
Cancel --> Control
Clocks[Domain clock deliveries] --> Control
Deliveries[Subscription deliveries] --> Rows[Subscription lane: 16 frames]
Control -- always taken first --> Transport
Rows -- taken while the control lane is empty --> Transport
What A Session Sends First
A session queues its frames on two bounded outbound lanes. Replies, transfer parts, session events, and domain clock frames wait on the control lane, which holds 16 frames. The session ending never waits: the transport writes it after the control frames already queued, and nothing after it. Subscription rows and the notices about them wait on the subscription lane, which holds 16 frames shared by every subscription of the session. The transport always takes a queued control frame first and takes subscription frames only while the control lane is empty. A reply, including the reply to the unsubscribe that stops a subscription, therefore never waits behind rows the client has not read. The lanes never reorder what the transport already took: a frame handed to the transport stays ahead of every frame queued after it. An attached clock’s tick holds one replaceable control slot per domain. Its delivery task can overwrite the slot while the lane is full or after queueing it, until the transport takes it; a changed clock state withdraws a superseded tick still in that slot.
Because every frame is at most the frame limit, the two lanes bound what a session holds for a client that reads slowly. Backpressure reaches the client’s requests too: a rejection or a cancellation outcome waits for room on the control lane before the session reads the next request, so a client that reads nothing eventually stops being read. The server has no idle or slow-client timeout; such a session ends only with its transport.
Cancellation
CancelRequest names the request identity it targets. The server answers with CancelOutcome:
Requested when the target is in flight, or NotInFlight when it is not. The target still receives
its own terminal reply: RequestCancelled, or its ordinary reply if it finished before the
cancellation took hold. The two replies can arrive in either order.
Whether a cancellation stops an effect depends on one atomic decision per request. A request admits
itself immediately before its first effect, and a cancellation that arrives first decides the
request cancelled for good. RequestCancelled reports which came first:
BeforeAdmission. The request was not admitted and never will be. It has no effect.AfterAdmission. The request was admitted. Cancelling ends only the wait for it. A command’s effect continues and is recovered by its execution reference, exactly as after a lost reply; what an admitted cancellation leaves of the other ordered requests is described below.
A submitted batch cannot be cancelled: a CancelRequest naming it is refused with
InvalidRequest, and the batch still receives its outcome, because a batch the ingestor may already
have admitted cannot be withdrawn.
A command admits itself just before its durable admission or first effect. Transaction attach, subscribe, unsubscribe, clock requests, and producer and consumer opens admit themselves when the ordered lane starts serving them, and a concurrent request never admits itself, so cancelling one always stops it. A command cancelled after admission keeps the ordered lane until its work finishes, and an attach cancelled after admission still binds the transaction. A domain clock attach cancelled after admission also keeps the ordered lane until it is served, but then attaches nothing, because the server delivers only an attachment whose reply it queued; a domain clock detach cancelled after admission has still detached, and sending it again reports the session’s state. A cancellation never rolls back admitted work: it is how a client stops waiting, not how it undoes a command, and undoing is a new command.
Rejections
A request that is not served receives RequestRejected with a typed reason, the offending field
when there is one, and a message for display. The session keeps serving after any rejection.
| Rejection | Meaning |
|---|---|
InvalidRequest | A field is missing, malformed, or out of range, such as an execution reference that is not one or a completion cursor inside a character, or a cancellation names a submitted batch. |
UnsupportedRequest | The request kind is not one the server’s schema declares. |
UnsupportedValue | An enum value the server does not declare, including a subscription type it does not implement. |
DuplicateRequestId | The request identity is already in flight in this session. |
TooManyRequestsInFlight | The session already has 64 requests in flight. Nothing was admitted. |
ReplyTooLarge | The complete reply cannot be encoded within the session limits, above all because it exceeds the transfer limit. |
ServerBusy | The server had no memory or worker left to prepare the reply of an inspection. |
The first five refuse a request before anything about it runs. ReplyTooLarge and ServerBusy
replace a reply that could not be prepared. For a read, repeating the request reads again. For a
command, the missing reply says nothing about the command’s effect, which is recovered by its
execution reference like any other lost reply.
Command Dispositions
A command’s reply is a CommandOutcome: the execution reference it answers, whether the outcome was
produced now or recovered from the durable record of an earlier attempt, one typed disposition, a
message for display, diagnostics with optional source spans, and, for a request of several
statements, the outcome of each statement in written order. It also carries the transaction this
session is bound to while the command was served, the operation admission when the command accepted
one operation into the transaction, the typed inspection, WASM state, or resource description a
describing statement produced, and the summary of a backup’s archive or the report of a restore.
The disposition is the one field a client decides from; the message is for a person. Each disposition belongs to one phase of the command and makes one statement about its effect:
Consensus proposal failures reach the session with typed leadership, conflict, storage, and transaction-mutation causes in local reports. The session selects its disposition from those causes and the command’s admission phase, while the display message retains the underlying Raft or I/O reason. Report carriage does not change request correlation, the durable acknowledgement boundary, or the stored transaction response.
| Disposition | Phase | What it establishes | What a client does |
|---|---|---|---|
CommandCompleted | Finalization | The command reached its completion boundary. already_existed says a creating statement found its entity present and changed nothing. | Report success. |
RequestFailed | Admission, execution, or finalization | The command failed definitively. Nothing further will happen under its reference. | Report the failure with its diagnostics. |
LeaderRedirect | Before admission | The serving node is not the leader, and nothing was admitted there. The leader’s endpoints are absent while no leader is known or discovery cannot reach it. | Reconnect to the leader, or wait for one, and repeat the command under the same reference. |
TransactionDetached | Before admission | The leader holds no binding for the session’s transaction, typically after a reconnect or leader change. | Attach the transaction again and repeat the command under the same reference. |
TransactionTakenOver | Before admission | Another session attached this session’s transaction, and nothing was admitted for this request. | Report it. Attaching the transaction again would take it back, which is the user’s decision rather than a retry. |
OutcomeUnknown | Admission, execution, or finalization | The command may have been admitted and its outcome is not known yet: leadership moved while it was admitted or applied (LeadershipLost), it is still applying (StillApplying), or it finished but its outcome is not yet authoritative on every live node (NotYetAuthoritative). | Repeat the command under the same reference until the outcome is known. Never report success or failure from this disposition. |
ExecutionReferenceConflict | Before admission | The reference already identifies a different command, which differs in its owner, domain, transaction position, or content. Nothing was admitted for this request. | Treat it as a defect in the client’s use of references. |
ExecutionReferenceExpired | Before admission | The reference aged out of execution history. Its outcome can no longer be recovered, and the command was not executed again. | Report that the outcome is unknown; issuing the work again is a new command. |
PreviewStale | Commit admission | The commit named a preview that no longer describes the transaction. Nothing was applied, and the transaction stays open. The reply names both the expected and the current preview. | Inspect the transaction again and decide whether to commit against the current preview. |
OutcomeOrigin distinguishes an outcome produced by this request, Executed, from one read back
from the durable record of an earlier attempt with the same reference, Recovered. A recovered
failure is the same failure: its message and diagnostics are the ones retained when it happened.
A request of several statements reports each statement’s own disposition, which is one of
CommandCompleted, RequestFailed, or LeaderRedirect, in written order, and the command’s
disposition is that of the statement that ended it. Several statements in one request always belong
to a transaction: the request begins one and appends to it, possibly committing it too, or appends
several statements to the transaction the session holds. Outside a transaction a request carries one
statement. DESCRIBE TRANSACTION and SHOW TRANSACTIONS read beside the transaction and are always
sent alone.
The leader’s reference check and replicated admission return the same typed dispositions. A
reference the history no longer holds returns ExecutionReferenceExpired; a conflicting request
detected during replicated admission returns ExecutionReferenceConflict with the conflict kind.
Neither refusal admits the request. Exact Recovery explains the retry fence.
Four Boundaries
The dispositions exist because a client can lose sight of a command at four different points, and each point means something different:
sequenceDiagram
participant C as Client
participant S as Session on the leader
participant R as Replicated execution record
participant E as Effect owners
C->>S: CommandRequest(request 7, reference X)
Note over C,S: Bytes transferred: nothing about X follows yet
S->>R: admit X with its semantic command
Note over S,R: Work admitted: X continues without this session
R->>E: apply, activate, release
E-->>R: completion boundary reached, outcome retained under X
Note over R,E: Effects completed
S--xC: Reply(request 7) lost with the session
C->>S: CommandRequest(request 1 of a new session, reference X)
S->>R: look up X
R-->>S: retained outcome
S-->>C: Reply(request 1): CommandCompleted, origin Recovered
A cancelled waiter is the fourth point: a CancelRequest that wins before admission leaves no
record of the reference at all, and one that arrives after admission ends only the wait. In every
case the same reference, sent again, finds the truth: nothing recorded admits the command now, an
applying record joins it, and a terminal record returns it.
Exact Recovery
A client recovers a command it lost sight of by sending the same command again under the same execution reference. The protocol makes that safe by keeping one durable record per reference and answering every repetition from it. Command Completion owns the record’s lifecycle, its retention, and the retry fence; this section describes what a client can rely on.
What Is Recorded
Every persistent command is recorded under its reference before its first effect: model creation,
alteration, and removal, REBIND RESOURCE, CREATE DOMAIN, ALTER DOMAIN, CREATE USER,
CREATE RESOURCE, START, STOP, DROP NODE, CORDON NODE, UNCORDON NODE, DRAIN NODE,
RELOCATE, RESET WASM PROCESSOR ... STATE, and BACKUP. So is every transaction request: a request that
begins, commits, or reverts a transaction, one sent while the session is bound to a transaction, and
one that carries an expected transaction position. An ordinary request carries at most one
persistent statement; several statements in one request always form a transaction request.
Reads record nothing. SHOW, DESCRIBE, LOOKUP, DESCRIBE TRANSACTION, and SHOW TRANSACTIONS
accept any well-formed reference, and repeating one reads again. Uploads have an identity of their
own and are covered in Resource Uploads.
The record binds the reference to one semantic request: the authenticated owner, the request’s domain, the expected transaction position, and a digest of what the request asks for. For an ordinary command the digest covers the parsed statement, so a retry that differs only in whitespace or keyword case is the same request. For a transaction request it covers the exact request text. A new reference is admitted only when it is a UUIDv7 whose creation time lies after the cluster’s retry fence and no more than five minutes ahead of the leader’s clock; a reference that fails either check is refused before any effect.
How A Repetition Is Answered
The leader serializes the requests that carry one reference, then consults its record:
- No record. The request is admitted as new, subject to the checks above.
- Applying. The request joins the admitted execution and waits for it. After a leader change, the new leader resumes every applying record on its own in its next reconciliation pass, which runs every 250 milliseconds, so the work completes whether or not anyone repeats it.
- Finished. The request returns the retained outcome once that outcome is authoritative on each
node in the leader’s current completion participant set. The leader can retire an unreachable
node from that set; the node must catch up before its local reads reflect the completed effect.
If the wait fails, the reply is
OutcomeUnknownwith causeNotYetAuthoritative, and a later repetition returns the outcome.
The first reply is itself rebuilt from the durable record, so the reply to the original attempt and the reply to a repetition are identical except for their origin. The record keeps the disposition, message, diagnostics, per-statement outcomes, transaction status, operation admission, and the archive summary of a completed backup. It does not keep the typed inspection, WASM state, or resource description that a describing statement returns, because those statements are reads and record nothing.
An execution whose attempt returns OutcomeUnknown, a leader redirect, or TransactionDetached
stays applying. The finalization boundary returns that disposition unchanged and leaves recovery
under the original reference to determine the terminal outcome. Reconciliation follows the same
rule when an attempt is interrupted after admission.
The same rule covers every transaction control:
BEGIN. The new transaction’s identity is fixed when the request is admitted. Repeating aBEGINwhose reply was lost returns the same transaction and binds the repeating session to it, instead of opening a second one.- Appends. Each statement of a request is recorded under an identity derived from the request’s reference and the statement’s position in it. Repeating an append with the same reference, position, and text returns the admission recorded for it, without preflighting it again or renewing the transaction’s activity; the transaction is not appended to twice.
COMMIT. Repeating a commit joins the committing execution or returns its recorded outcome.REVERT. Repeating a revert of a transaction its owner already reverted succeeds again.- Multi-statement requests. A request of several statements is one execution with one outcome per statement, and a repetition after a leader change or a full restart returns those outcomes without repeating any effect.
Conflicting Reuse
A reference names one request forever. Repeating it with a different owner, domain, expected
position, or content, checked in that order, is refused as ExecutionReferenceConflict naming what
differed, and nothing about the new request is admitted. A session bound to a transaction other than
the one the reference was recorded against is refused as a position conflict. When two leaders race
and the replicated state machine detects the conflict after the leader’s local check, the refusal
carries the same typed ExecutionReferenceConflict and kind.
Bounded History And Expired Identities
The history is bounded by count, 65,536 records by default, counting applying records, finished
records, and tombstones together. When it is full a new reference is refused with an explicit
capacity failure, and admitted work is never evicted to make room. A finished record is retained for
the retry validity after the command finished, 15 minutes by default, and then reclaimed; an
applying record is never reclaimed. Reclamation advances a durable, monotonic retry fence first, so
a reclaimed reference, repeated at any later time, fails the admission check and never starts its
effect again. The refusal is ExecutionReferenceExpired whether the history still holds a
tombstone or only the retry fence. The outcome of the original attempt can no longer be recovered.
Why Aggregate State Proves Nothing
A client that lost the reply to an append, or to a commit, might be tempted to look at the
transaction instead: if it is COMMITTED and its operation count moved, the append must have been
in it. That inference is false. Another session can attach the transaction, commit it without the
outstanding append, and leave the same counts. Finishing a transaction clears its queued statements,
so an append that was never admitted cannot match anything afterwards, and the only witness of what
happened to a particular request is the record of its own reference. A client therefore reports an
append or a commit only from the outcome recorded for that exact request.
sequenceDiagram
participant A as Session A
participant L as Leader
participant B as Session B
A->>L: append S (reference R, expected position 0)
Note over A,L: R is not admitted yet
B->>L: attach transaction T, taking it over
B->>L: COMMIT
L-->>B: CommandCompleted: T is COMMITTED
L-->>A: R refused: S was never admitted, and T holds no S
Note over A: T being COMMITTED says nothing about S
Domain Mutation Ownership And Plan Fencing
Exact recovery answers “did this command happen”; ownership and fencing answer “could anything else have changed the domain under it”. Both are control-plane contracts, and a client observes them only through the outcomes they produce.
One Owner Per Domain
A command that changes a domain acquires the domain’s mutation lease when it is admitted, and a transaction acquires it when its commit is admitted. The lease is replicated state. It names its owner, a command’s execution reference or a transaction’s identity, and carries a recovery fence: the log position of the entry that acquired it. Every write to the domain’s replicated state, its definition, schedule, and lifecycle, must present exactly the lease currently held. A write that presents none while a lease is held, a different one, or one from a superseded acquisition is refused, and so are automatic schedule changes and clock-authority updates while any lease is held. The command or transaction releases the lease when it finishes, whatever its outcome.
Because the lease lives in replicated state rather than in a leader’s memory, it survives leadership changes and restarts. A new leader resumes the applying record, or the committing transaction, that holds the lease and continues under it; a deposed leader can no longer propose, and a stale acquisition fails on its fence. A second mutation of the same domain is therefore refused at once, with a failure that names the owner, rather than queued behind the first, while mutations of other domains proceed in parallel. A client that receives such a refusal decides whether to send the command again once the owner has finished.
Plans Apply Only To What They Planned
A command or commit is planned from one captured set of inputs: the domain’s state, its resources
and completed versions, its schedule, and cluster membership, voters, and cordons. The replicated
state machine applies the plan only if those inputs are still equal to what was captured, and
refuses it otherwise, naming the input that changed. Eligibility that lives outside consensus,
liveness and incarnations, is checked again by the leader immediately before it publishes. A plan
can therefore never publish a candidate derived from one schedule over a newer one: a RELOCATE
planned before a schedule revision fails rather than overwrite that revision.
What happens after such a refusal depends on who owns the plan. An ordinary command applies through
a frozen internal attempt; when its captured inputs change before its effect is recorded, it keeps
the failed attempt and starts a new attempt derived from the current revision under the same
execution reference, so its caller sees one outcome. An explicit transaction freezes its plan when
COMMIT is admitted: stale inputs at admission leave the transaction OPEN, and a step whose
inputs changed after admission fails the transaction with the applied prefix preserved, rather than
being replanned against state the client never reviewed. Transaction Quiescence And Impact
Inspection defines the frozen plan
and the preview it is checked against.
Transactions Over The Protocol
A transaction is replicated control-plane state; its binding to a session is not. Control Plane owns the lifecycle, the eligible statements, and the limits, and Transaction Quiescence And Impact Inspection owns planning, previews, and reports. This section describes how a transaction travels over the protocol.
Opening. BEGIN is an ordinary CommandRequest for the selected domain, which must already
exist, and every later request of the transaction names that domain. The outcome’s
TransactionStatus carries the transaction’s identity, domain, state, and accepted and applied
operation counts; the identity is the durable handle a client keeps. One request may also begin the
transaction, append to it, and commit it at once.
Appending. Each append carries the position it expects: the number of operations the transaction
had accepted, as the client last learned it. A statement accepted into the transaction returns
TransactionOperationAdmission with its one-based operation number and the whole-transaction
preview identity after it. An append that is refused leaves the transaction and its position
unchanged, so the client corrects it and appends again. Other sessions keep seeing committed
configuration until the commit.
Binding, attach, and takeover. The session that begins a transaction is bound to it. The binding
is soft state in the leader’s memory: it routes commands, and it is gone after a leader change or
when the session ends. A session binds an existing transaction with AttachTransactionRequest,
which the leader grants only to the transaction’s owner; the reply is TransactionAttached with the
current status, TransactionAlreadyFinished with the final status and aggregate outcome while its
tombstone is retained, or a failure for an identity that is unknown or retained no longer. A leader
that holds no binding for the session’s transaction answers the session’s next transaction request
with TransactionDetached, and the client attaches again and repeats the request under the same
reference. A finished or failed attach does not resolve commands whose replies were lost: the
client repeats each under its own execution reference and expected position. The leader returns a
recorded outcome before planning a new request; a command it never admitted receives a failure.
Attaching from a second session takes the binding over, and the displaced session’s next
transaction request receives TransactionTakenOver before anything is admitted. While a session
holds a transaction, the server refuses the requests that belong to the session rather than the
transaction, subscribe and unsubscribe and domain clock attach and detach, and the Rust client and
the console refuse USE and LIST DOMAINS themselves.
Committing. COMMIT may name the preview identity the client reviewed: the transaction
identity, its accepted position, and the fingerprint of its planning basis, taken from the last
append’s admission or from inspecting the attached transaction. If that preview no longer describes
the transaction, the commit is refused with PreviewStale naming the expected and the current
identity, nothing applies, and the transaction stays open and bound. A preview fences only a commit
of a transaction that the same request did not itself begin or append to, because such a request has
already moved the transaction past what the client reviewed. A commit that is admitted runs its
frozen plan step by step; each step is atomic, and a failed step leaves the applied prefix in place
rather than rolling back the transaction. A commit’s outcome arrives only when the final step is
usable on every live node, and a client waits through COMMITTING for the outcome recorded under
the commit’s own reference.
Inactivity and retention. An OPEN transaction expires once it has been inactive for its idle
timeout, 15 minutes by default, whether or not a session is bound to it. Attaching it, appending to
it, and a commit’s admission or failure renew that deadline; an open connection, a bound session,
and reads of the transaction do not. The deadline is a durable UTC instant, so time the cluster
spends stopped counts toward it, and an overdue transaction expires once the cluster runs again. A
COMMITTING transaction never expires. A finished transaction remains as a tombstone, 15 minutes by
default, during which attach and inspection report its outcome; afterwards its identity is unknown.
Ending a session. A session that ends uncleanly, through a transport failure, a node stop, or
the server ending it, only releases its binding; the transaction stays open for attach until it
expires. A session that the client closes cleanly, with no request in flight, reverts the open
transaction it is bound to on the leader, which is how an interactive client that exits discards its
unfinished work. Neither ending affects admitted work: an append already admitted stays admitted,
and a COMMITTING transaction continues without its client.
Read consistency. A read, such as SHOW, DESCRIBE, or LOOKUP, is served by the node the
session is on from its locally applied replicated state; it is not a linearizable read. Once a
command completes, its effect is applied on every node in the leader’s completion participant set,
so a later read through one of those nodes observes it. A node the leader has retired can still
serve an older local state until it catches up. Completion for a session bound to a transaction
resolves against the committed configuration with the session’s own queued statements applied,
while every other session sees committed configuration alone. Inspecting a transaction reads it
without changing its binding, domain, activity, or queue position.
Leader Discovery, Redirect, And Reconnect
What A Session Is Told
Every session receives LeadershipObserved first and DomainsObserved second, and each again
whenever it changes; the serving node checks leadership every 250 milliseconds. Leadership is one of
three states: the serving node leads, a remote node leads, with whichever of its endpoints discovery
has established, or no leader is known. Selecting a domain with SelectDomainRequest opts the
session into that domain’s observations as well: a ClusterObserved summary and a
DomainSnapshotObserved carrying the domain’s live graph as a JSON document and its declared
entities, at once and then every 500 milliseconds. A session that selects no domain receives
neither. Server notices report runtime and control-plane errors and cluster events as text for
display. A session that falls behind the node’s notice bus skips the notices it missed; the node
logs how many. The Rust client holds at most 128 notices and 1 MiB its caller has not read. A
notice that does not fit drops the ones held, and the caller’s next read reports that gap before
the notices that follow it. Notices end with the session that delivered them, and the client’s
notice stream continues with the next session it opens.
There is no separate discovery request. A client learns where the leader is from these observations and from the redirects in its replies. Endpoints come from the cluster’s discovery, which carries each node’s advertised gRPC and console URLs. A redirect never guesses: while no leader is known it names none, and while discovery has not established a leader’s endpoint the redirect names the leader without it.
Which Requests Need The Leader
The node a session is on serves completion and choice lookups, listing and selecting domains,
subscriptions, domain clock attachments, producers, and the reads its locally applied state can
answer: SHOW CLUSTER STATUS, SHOW TRANSACTIONS, SHOW CREATE, SHOW UDFS, SHOW INGESTORS,
SHOW PLACEMENTS,
SHOW RELAY MATERIALIZED STATE, LOOKUP, and the DESCRIBE statements of domains, resources,
endpoints, lookups, placements, UDFs, relocations, relays, ingestors, junctions, deduplicators,
reingestors, correlators, reorderers, window processors, WASM processors, and emitters. Every other
statement needs the leader, and so do every transaction request, transaction attach and inspection,
and uploads. A follower answers them with LeaderRedirect before anything is admitted, and the
client reconnects to the leader and repeats the request. Because persistent commands and
transactions only ever run on the leader, a follower holds no execution state a client could lose.
The console session is the exception: the web console is served only by the leader. A console
session on any other node, and a console session whose node stops leading, is ended with a
SessionEnding that names the leader, and the console reconnects to the leader’s console endpoint.
Deadlines And Cancellation
The server imposes no deadline on a request’s wait and no idle timeout on a session, and neither transport sends keepalives; a connection is judged dead by its transport or by the client’s own deadline. A client deadline bounds only the client’s wait. It never cancels admitted work, and Command Completion owns what the work does meanwhile. A client that gives up on an uncertain command keeps its execution reference and can recover the outcome later, within the retry validity.
The Rust client bounds each connection attempt by connect_timeout (10 seconds by default), each
request by request_timeout (120 seconds), and each public call, including all of its retries and
redirects, by retry_timeout (120 seconds). Without keepalives, a request that outlives
request_timeout is the client’s only sign that its session may be dead, so it ends every request
still waiting on that session, and the next request opens a new one. While an election converges it
retries with a delay that starts at 100 milliseconds and doubles up to one second. When a call’s
deadline passes before a command’s outcome is known, and the command may have been admitted, the
call fails as uncertain and names the execution reference. The Rust client, the CLI, and the console
never send CancelRequest: a caller that stops waiting only drops its waiter, and the reply, when
it comes, is discarded.
nervix-cli backup --timeout extends its request and retry budgets by the named quiesce timeout,
leaving the ordinary request budget for command admission, capture and the reply.
For native Rust sessions, the client loads a Hickory resolver during setup or uses one its owner provided. It resolves the hostname of each selected server, seed, and redirect on a new connection attempt, preserving the advertised URI’s authority and TLS name. Tonic’s connection timeout spans DNS, address attempts, and TLS. A lookup takes at most 30 seconds when no shorter connection deadline cancels it. Failed resolution is a connection failure under the same bounded reconnect policy. Server-internal sessions to a peer’s session service reuse the node’s loaded resolver. Browser WebSocket and fetch resolution remains owned by the browser. Name Resolution describes the resolver, its bounds and its failures.
Reconnecting A Session
A lost session takes everything session-scoped with it. As soon as its frames stop, the Rust client reports an interruption for every subscription and clock it held, and it rebuilds the session in a fixed order. Domain clock attachments and subscriptions come before the transaction, because a session that holds a transaction refuses both, and the transaction comes before any command that belongs to it:
- It opens a new session against the servers it knows: its configured seeds, then at most 32 endpoints it learned from redirects and leadership observations, oldest first, then the server it was using, with the same backoff as an election.
- It attaches every domain clock it followed.
- It publishes the new session to its callers and ends the old one. When the old session was still open, as after a leader redirect, ending it reports an interruption for every subscription and clock it still held. Its frames stop before the interruption, so nothing it delivers afterwards can make an attachment look held.
- It attaches every clock the old session began to follow while the new one was opening, opens again every subscription the server had acknowledged, each as a new generation, and opens its native producers and consumers again.
- It attaches its transaction before it repeats any command that belongs to it.
The requests of steps 2 and 4 are sent before step 5 begins, not merely started, so the transaction can never overtake them on the ordered lane. Only then does the client repeat outstanding commands, each under its original execution reference and, for an append, its original expected position.
The new session can still refuse a restoration: a relay may no longer exist or not be running yet,
the session may already hold as many requests as it admits, or a transaction a caller attached
meanwhile may have made it refuse session-local requests. The client then reports a restoration
failure for that subscription or clock, carrying the server’s message and the wait before the next
attempt, and sends the request again on the same session. A restoration the session leaves
unanswered past the request timeout ends that session instead, as any request that outlives the
timeout does, and the next session restores it again. The wait starts at one
second and doubles after each refusal up to thirty seconds. It keeps trying for as long as the
session stays open and the client still wants the subscription or follows the clock; the next
session starts again from one second. An attach answered DomainClockAlreadyAttached means the
session already holds the attachment, so the client follows the clock on that session and reads its
frames from then on.
The client’s event streams outlive the session. A caller reading subscription events, clock events, or notices keeps reading across the replacement: the interruptions mark the gap, and events of the new session follow them. Reading subscription or clock events opens a new session while one waits to be restored; reading notices never does, and waits for the session something else opens. A failed attempt to open one is returned to the reader, and its next read tries again.
The web console follows the same order on every connection: it selects its domain, opens again the subscription of every tab the server had acknowledged, and attaches its transaction before any ordered request it holds goes out. A restoration belongs to the connection that sent it; when that connection ends before the reply, the next connection restores the tab again. A transaction that has finished, or can no longer be attached, ends only the commands issued in it: restorations, subscription changes, and commands issued outside the transaction keep their place.
stateDiagram-v2
[*] --> Connecting
Connecting --> Open: a server accepted the session
Connecting --> Failed: retry deadline passed
Open --> Open: reply routed by request identity
Open --> Redirecting: LeaderRedirect names the leader's endpoint
Open --> AwaitingLeader: LeaderRedirect names no endpoint, or OutcomeUnknown
AwaitingLeader --> Open: back off, then repeat under the same reference
Redirecting --> Restoring: new session on the leader
Open --> Lost: transport failure, session ending, or protocol violation
Lost --> Restoring: a known server accepted a new session
Lost --> Failed: retry deadline passed
Restoring --> Open: clocks, subscriptions, and transaction attached again
Failed --> [*]
A command moves through its own states within that session state machine:
stateDiagram-v2
[*] --> Prepared: one execution reference, domain, expected position, and preview captured
Prepared --> Sent: new request identity
Sent --> Completed: CommandCompleted
Sent --> Failed: RequestFailed, ExecutionReferenceConflict, ExecutionReferenceExpired, or TransactionTakenOver
Sent --> Stale: PreviewStale
Sent --> Sent: LeaderRedirect or OutcomeUnknown, repeated under the same reference
Sent --> Reattaching: TransactionDetached
Reattaching --> Sent: transaction attached again
Sent --> Uncertain: session lost after sending
Uncertain --> Sent: new session, same reference
Uncertain --> GaveUp: caller deadline passed
GaveUp --> Sent: caller repeats the same prepared execution
Completed --> [*]
Failed --> [*]
Stale --> [*]
Row Subscriptions
A subscription is a read-only view of one relay, owned by the session that opened it. Its rows travel from the relay owner to the subscriber’s node as Arrow batches, and from there to the client as typed Row frames. Sessions owns the NSPL form and what a user sees; this section explains the protocol and data path behind it.
Opening A Subscription
SubscribeRequest names the domain, carries exactly one CREATE SUBSCRIPTION statement, and
selects the subscription type. The statement carries the subscription’s name, relay, BLOCKING or
DROPPING delivery, sample rate, and WHERE predicate; the server parses it once and compiles the
predicate Model directly. The type is required, and Row, the only type, must be named: a request
that omits it is refused rather than defaulted, and a value the server does not support is refused
as UnsupportedValue rather than replaced by another encoding. A subscription’s type never changes.
Opening runs on the session’s ordered lane:
sequenceDiagram
participant C as Client
participant S as Session on node N
participant G as Every other live node
participant O as Relay fan-out on N
C->>S: SubscribeRequest(domain, CREATE SUBSCRIPTION, Row)
S->>S: validate against the scheduled relay definition, compile the predicate
S->>S: take the next generation, wait for N to apply the current runtime revision
S->>S: take N's interest lease on the relay and advertise it
S->>G: wait until each publishes N's interest at the leased version
S->>O: attach under an equal relay definition
S-->>C: SubscriptionOpened(name, generation, Row, schema)
Note over S,O: rows flow only after this reply is queued
O-->>S: Arrow batches
S-->>C: SubscriptionRows(name, generation, frames)
A refusal at any step releases everything taken before it, and the name stays free. The generation
is a per-session counter that starts at 1, so a handle, the name together with its generation, is
unique within its session only; a generation taken by a request that then failed is simply skipped.
The visibility wait is what makes the reply meaningful: by the time a client reads
SubscriptionOpened, every live node already routes the relay’s batches to the subscriber’s node,
and an advertisement left over from before an earlier withdrawal cannot satisfy it.
The reply carries the schema and precedes every row. Delivery is held back until the reply is queued on the control lane, which the transport always serves first. When the reply cannot be queued, because the request was cancelled, the session ended, or the reply did not fit the session limits, the generation is abandoned before it delivers anything, so a client never receives rows it has no schema for. Batches the relay published during the opening are not delivered.
Encoding From Arrow Columns
The subscriber’s node encodes Row frames directly from the Arrow batch it received. It validates the
batch against the announced definition once, then writes each selected cell from its typed column
into the FlatBuffers builder; no JSON object, map of fields, or intermediate row is built. Every
NSPL type has exactly one cell type, DATETIME travels as signed nanoseconds, floats keep their
exact bits, and BYTES travels as raw octets. A sensitive field is written as RedactedCell in
every row, whether or not it held a value, so the frame reveals neither the value nor its nullness.
A null in a nullable field is NullCell; a list element is never null or redacted.
A frame holds rows of one concrete branch. Its branch key is written once per frame, with sensitive key fields redacted, and is absent exactly when the relay is unbranched. The encoder groups the selected rows of a relay batch into runs of adjacent rows with the same branch key, so interleaved branches produce one frame per run.
A frame holds at most 256 rows and at most the frame limit in bytes. When a row does not fit the
frame being built, that frame is finished and the row starts the next one. A row that does not fit
an empty frame cannot be delivered at all; the encoder then reports every selected row of that relay
batch as skipped with cause EncodingFailed, and the subscription stays open. Each subscription
encodes its own frames, so two subscriptions to one relay never share a frame.
Selection, Sampling, And Delivery
Each row passes the subscription’s predicate, then its sampling, then its delivery mode:
The delivery owner supplies its node’s bounded executor to predicate evaluation together with the batch’s domain-time snapshot. The selector borrows that executor for the call.
- Predicate. The
WHEREexpression is an ordinaryBOOLexpression over the relay record. A row is selected only where it is true, so a null result does not select it and is not reported. When a predicate is present, each relay batch takes one domain-time snapshot on the subscriber’s node, so every row and every volatile function of the batch sees the same instant. Rows whose evaluation fails are counted and reported once per batch as skipped with causeFilterFailed, and a batch whose domain time cannot be read is skipped whole with causeDomainTimeUnavailable. - Sampling.
BATCH SAMPLE RATEis applied to each selected row after the predicate, with a pseudo-random draw shared by every subscription on the node. A rate of 1 passes every row and a rate of 0 passes none. BLOCKING. A frame waits for room on the session’s subscription lane. While it waits, the subscription stops taking batches, and the relay’s fan-out waits for it: on the relay owner that holds back the relay itself, including its runtime consumers, and on another node it holds back that node’s ordered delivery from the owner, which waits within its relay admission bounds.DROPPING. A frame that finds the lane full is discarded, and its rows are counted. Before the subscription’s next rows, the session sendsSubscriptionDeliveryLostwith the count, if that report itself finds room; otherwise the next rows are discarded too and counted with the rest.
The subscription lane holds 16 frames shared by every subscription of the session. One slow
BLOCKING subscription therefore also holds back the session’s other BLOCKING subscriptions and
makes its DROPPING ones drop, while replies and session events keep flowing on the control lane.
Fan-Out Across Nodes
The relay owner is the only fan-out source. It delivers each admitted batch to its local
subscriptions, to every node that advertises interest in the relay, and to its runtime consumers. It
encodes one Arrow body for the whole subscription fan-out and shares it across nodes; a node that
also hosts a runtime consumer of the relay receives the batch once, for that consumer, and its
subscriptions piggyback on that delivery. Each batch reaches a subscription at most once. Delivery
to a node is ordered per branch; batches of different branches may arrive in a different order than
the owner published them. The subscription copy carries no acknowledgements, so a subscriber never
holds back a source’s acknowledgement beyond the time its BLOCKING backpressure takes.
Generations, Endings, And Cleanup
Every frame about a subscription carries its handle. A name reused after deletion, or after the
server ended its generation, opens a new generation, and the protocol never lets a frame about one
generation be taken for another. That fence matters: the SubscriptionEnded of an earlier
generation can still be queued when the same name is opened again, and because replies travel ahead
of queued subscription frames, it can reach the client after the new generation’s opening reply.
The server ends a generation with SubscriptionEnded, its last frame, in two cases. RelayChanged
means the relay was redefined, so the schema the subscription announced no longer describes its
rows: a payload field’s name, position, type, nullability, or sensitivity changed, the relay became
branched or unbranched, or its branch or branch key schema changed. RelayRemoved means the relay,
or its domain, no longer exists. The relay fan-out ends every subscriber before a batch of another
definition can reach it, so no row of a redefined relay is ever read against the earlier schema.
Stopping and starting the domain, a rebuild that keeps the definition, a capacity or
materialized-state change, and an ownership move keep the subscription delivering.
Deleting a subscription, or ending its session, withdraws it at once, even while its client reads nothing: the frames it still has queued are discarded, it stops waiting on the lane, it releases its relay receiver and its interest lease, and only then is the reply to the deletion queued. Nothing about the deleted generation follows that reply. A node advertises interest in a relay exactly while at least one of its subscriptions holds a lease on it, and every lease is released exactly once.
Gaps
A subscription is a live view. It keeps no offsets, is not persisted, and replays nothing. The protocol reports the losses the server knows of and states which ones it cannot report:
| Loss | Reported to the client |
|---|---|
Rows a DROPPING subscription discarded | SubscriptionDeliveryLost before its next rows. A count still outstanding when the generation ends, or when no further rows follow, is not reported. |
| Rows whose predicate failed | SubscriptionRowsSkipped with FilterFailed |
| A batch without domain time | SubscriptionRowsSkipped with DomainTimeUnavailable |
| A batch with a row too large for a frame | SubscriptionRowsSkipped with EncodingFailed for every selected row of the batch |
| Batches published while the subscription was opening | Not reported; they precede the subscription |
| Batches lost in transit while relay ownership moves or a node-to-node delivery fails | Not reported; the nodes log them |
| The session itself | Not reported by the server, whose session is gone; the Rust client reports an interruption of each subscription the server had not ended |
| A restoration the new session refused | Not a server event; the Rust client reports each refused attempt, and the gap lasts until an attempt succeeds |
Restoration And Bounded Consumers
Nothing on the server restores a subscription. A client that wants one to outlive its session opens it again on the next session, which is a new generation with a new schema announcement, and must treat the time between as a gap. The Rust client does this for every subscription it holds that the server acknowledged and did not end, and fences its restoration by generation: a late reply for an attempt the caller has since cancelled is followed by a deletion before the name can be reused, and rows of a generation the client no longer holds are ignored. A restoration the new session refuses is reported as a restoration failure and sent again on that session after a growing wait, as Reconnecting A Session describes. Each interruption and refused attempt reaches the caller’s next read as soon as the client records it, without waiting for another change to the subscriptions it holds.
A generation the server ended is never restored, because a new session would not change why it
ended: its relay was redefined, so the announced schema no longer describes its rows, or the relay
no longer exists. The Rust client applies SubscriptionEnded as the frame arrives, before it queues
the end for its caller, so the subscription reads Ended from then on, even to a caller that has
not read the end yet. It stays Ended across later sessions until its caller subscribes again under
the same name, which opens a new generation that announces the relay’s current definition, or
deletes it. The end reaches the caller exactly once, after the generation’s other events: when the
session ends before the caller reads it, the client discards that session’s unread events as it
does for every subscription, reports no interruption, and reports the end itself. The one exception
is a subscription whose events overflowed the client’s queue, for which the consumer overflow stays
the last event of its generation.
A deletion asks the server only while an open session may hold the subscription. A subscription
whose session ended, including one whose delivery had already failed, is deleted without a request,
and so is an interrupted subscription the current session refused to open again: no session holds
either, so the client simply stops wanting it and releases the name. A subscription the server ended
is deleted without a request too: the server keeps an ended name only until it is reused or its
session ends, and nothing of the ended generation remains to release. A deletion whose session ends
before it is answered is complete, because the subscription ended with that session. A deletion that
waited for an opening or a restoration still in flight asks the server only if that request opened
the subscription. Only a name the client never held is always asked about, on a new session if the
current one ended, and a refusal leaves that name free. Each subscription moves through Creating,
Active, Interrupted, Restoring, DeliveryFailed, Ended, Closing, and DeletionFailed:
stateDiagram-v2
[*] --> Creating: subscribe
Creating --> Active: opening reply
Creating --> [*]: refused
Creating --> Interrupted: session lost after the server opened it
Active --> Interrupted: session lost
Interrupted --> Restoring: next session
Restoring --> Active: opening reply for a new generation
Restoring --> Interrupted: refused; reported, then retried on the same session
Restoring --> Interrupted: session lost; retried on the next session
Active --> DeliveryFailed: the client's event queue overflowed
Active --> Ended: SubscriptionEnded
DeliveryFailed --> Ended: SubscriptionEnded
Ended --> Creating: subscribe under the same name
Active --> Closing: unsubscribe
Creating --> Closing: unsubscribe
Restoring --> Closing: unsubscribe
DeliveryFailed --> Closing: unsubscribe while its session is open
Interrupted --> [*]: unsubscribe, which no session is left to answer
DeliveryFailed --> [*]: unsubscribe after its session ended
Ended --> [*]: unsubscribe, which needs no request
Closing --> [*]: deleted, the session ended, or the opening it waited for was refused
Closing --> DeletionFailed: deletion refused
DeletionFailed --> Closing: unsubscribe again
DeletionFailed --> [*]: the session ended
A consumer must stay bounded however fast rows arrive, and must never let an unread event hold back
a reply. The Rust client charges each subscription event for the whole frame its rows keep alive and
for the event itself. One subscription holds at most 32 events and one frame of the frame limit,
4 MiB, with the event that carries it; all subscriptions together hold four times as much, 128
events and 16 MiB of frames with their events. An event that does not fit drops that subscription’s
queued events, reports a consumer overflow, and marks the subscription DeliveryFailed; the reader
that routes replies never waits. An event stops counting once the application reads it, so a
subscription whose events are read before the next one arrives stays within its own allowance
however wide the server’s frames are, and overflows only when other subscriptions have filled the
total. The gRPC receiver shares its receive buffer with a frame of 64 KiB or more rather than
copying it, so a retained frame costs about its own size: four subscriptions each holding one
4.17 MB frame kept 16.78 MB live for 16.69 MB of frames, and the receiver keeps the allocation of the
last large frame it read as its receive buffer after that frame is released. The CLI bounds its
terminal output to 128 lines and 1 MiB, cuts a line above 8 KiB, and reports how many lines it
omitted. It keeps reading every stream across reconnects, and when the client cannot reopen a
session to restore subscriptions or clocks it prints why while the client keeps trying. The web
console keeps at most 256 lines and 256 KiB per REPL and per subscription
tab, and marks where it omitted earlier lines. It keeps the latest 256 commands and 256 KiB of its
command history and the snapshot of the one domain it observes. It holds at most 64 requests of its
controls waiting for a connection, carrying at most 4 MiB of text, and at most 256 held or awaiting
their reply, carrying at most 16 MiB, and sends the ordered ones only while the server has room for
them in flight; a request past either bound is not sent, and the control that issued it reports
why. It drops the parts of a reply nobody awaits, such as a superseded
completion.
A tab whose generation the server ended with SubscriptionEnded turns ended in the web console. It
keeps its rows and the reason, and it is not restored on a later connection, which would not change
why the server ended it. The operator resubscribes it under the same name, which opens a new
generation announcing the relay’s current schema, or closes it. Closing an ended tab sends no
deletion: the server keeps an ended name only until it is reused or the session ends, and nothing
of the ended generation remains to release.
Domain Clock Attachment
A session can follow the clock of a domain. ATTACH DOMAIN CLOCK; and DETACH DOMAIN CLOCK; reach
the server as AttachDomainClockRequest and DetachDomainClockRequest naming the active domain,
and the reply to an attach carries the START generation and the clock as the serving node has it
installed. Sessions owns the public contract and Domain
Clock owns how an installation and accepted progress become
observations; this section places the attachment in the protocol.
An attachment is not a subscription, and the protocol keeps the two apart end to end. It reads no
relay, takes no interest lease, and has no name or generation of its own: it is keyed by its domain,
so a session follows each domain clock at most once, and every frame about it names the domain. Its
frames travel on the control lane rather than the subscription lane. Attach and detach run on the
ordered lane, in order with the session’s commands. The attach reply is queued before delivery
starts, so its state precedes every frame. If the installation has not changed since the reply and
the node already holds a tick of that generation, its newest tick is the first frame after the
reply. Each changed installation arrives
as DomainClockObserved before any tick of its generation. A committed unassigned authority
produces uninstalled and then the same mapping on reassignment; a direct authority move retains the
mapping and produces no state frame.
DomainClockTicked carries the domain, generation, nonzero tick id, logical boundary, the
authority’s UTC observation, and the serving node’s logical reading from a clock snapshot taken
when the frame is built. The three timestamps use signed Unix nanoseconds. The serving reading lets
a client anchor itself even when its local UTC differs from the cluster’s. The runtime publishes
only accepted progress, fenced by generation and authority; a late attachment reads the newest
accepted tick without waiting for another one. Tick ids can skip when the authority coalesces
missed periods or a slow client’s pending tick is replaced.
A state frame waits for room on the control lane, and changes published meanwhile collapse into
the newest installation. A tick occupies one replaceable slot per attached domain, including while
the control lane is full, so a slow client never accumulates a tick backlog. Delivery withdraws a
pending tick when state changes and rechecks installation before selecting another. Detach stops
delivery before its reply is queued, so nothing about the domain follows that reply. When the
serving node no longer has the domain, DomainClockAttachmentEnded with reason DomainRemoved is
the last frame about the attachment.
Every node serves attachments from its own installation, which it derives from the same committed
revision as every other node, and from progress it already accepted, so an attachment adds no
interconnect traffic and survives nothing: it ends silently with its session. The Rust client
reports the gap as an interruption when the session ends, clears its previous tick, and attaches
every clock it followed again on its next session. An attach that session refuses is reported as a
restoration failure and sent again on the same session after a growing wait; an attach it leaves
unanswered past the request timeout ends that session instead, and the next session attaches the
clock again, which a caller already waiting for clock events opens at once. A reply that the
session already follows the clock moves the attachment to that session: the caller keeps the clock
the previous session last reported, without a tick, until that session’s later state or tick
frames arrive. When the new session’s attach reply arrives before the old session ends, as after a
leader redirect, the attachment moves without an interruption: the client reports the clock only
if it changed, keeps the newest tick of a clock still paced in the same generation, and reports a
tick of the new session only when it is newer. A detach the client sends while a clock waits to be
restored reaches the current session: a closed session is opened again first, which attaches the
clock before the detach releases it, and an open session that has not restored it answers
DomainClockNotAttached, after which the client no longer follows the clock. The client sends no
restoration attach of a clock behind a detach of it: a detach waits until every restoration attach
already decided for the clock is sent, so each reaches its session first, and a restoration that
would attach the clock while the detach is in flight waits for the detach to end and attaches it
only if the client still follows it. After
DomainClockAttachmentEnded, a detach is answered DomainClockNotAttached and an attach is served
as a new attachment. The shared binding exposes this event stream through
nx_session_next_clock_event and a dedicated retained nx_clock_event handle, and the clock the
client holds for each followed domain, starting with the one the attach reply carried, through
nx_session_domain_clock and a retained nx_domain_clock handle. The web console
attaches the selected domain clock once per session, detaches it on selection changes, and restores
it on reconnect; its REPL sends the same typed requests for explicit attach and detach statements.
These requests enter the console’s bounded session hand-off; a local refusal is shown to the
operator and sends no request. Both requests are refused while the session holds a transaction,
like every other session-local request.
A node answers an attach only once it has installed the cluster’s committed domains since it
started. Before that it holds no domain, so it cannot tell one the cluster lacks from one it has not
installed yet, and an attach that reaches a restarting node waits on the ordered lane rather than
hearing that a live domain was not found. The server bounds that wait only by the session, as it
bounds every wait, so a client that needs an answer sooner applies its own deadline. Ordered
requests the session sends after the attach wait behind it. DomainNotFound therefore always means
that the committed state the node installed holds no such domain, and the Rust client ends an
attachment it was restoring when a new session answers it so, as it does after
DomainClockAttachmentEnded.
The CLI’s domain-clock subcommand uses the Rust client’s typed attach reply and clock event
stream. It prints the reply and then the same state, tick, interruption, and end lines as its REPL,
including the fresh state after the client restores an attachment. Ctrl-C sends a detach request
before the process exits; an attach refusal exits nonzero with its typed reason.
Producers
A session can open producers that submit typed batches to a client ingestor. Ingestors owns what an ingestor admits and how an outcome is decided, and Sessions owns the public contract of a producer; this section places producers in the protocol.
Opening, Submitting, And Closing
OpenIngestorRequest names a domain explicitly, the ingestor, the fields the producer expects, and
the credit it asks for: how many batches and how many bytes it may have outstanding. The open runs
on the ordered lane. The serving node checks the session’s transaction binding, the requested
limits, the committed domain state and schedule, and the ingestor’s kind, reserves room in the
session’s budget of 32 producers and 32 MiB and in its own node budget of 128 MiB, and attaches the
producer through the node that executes the ingestor, which checks the expected fields against the
execution it runs. The reply is ProducerOpened or a typed ClientProducerRefusal with a message;
a refused open leaves nothing attached and reserves nothing. ProducerOpened carries the domain,
the ingestor, and a ClientProducerDescription: the attachment identity, the input schema, the
domain’s START generation, the endpoint contract digest, the ingestor’s window, ACK timeout and
retry backoff, the granted credit with the largest batch one submission may carry, and whether
admission is open.
The request identity of the open names the producer: every later request and frame about it carries
that identity as its ProducerId, so a client needs no second identity space. The producer accepts
batches from the moment its reply is queued. It belongs to the session: USE does not retarget it,
and it cannot be opened while the session holds a transaction.
SubmitBatchRequest carries the producer and one canonical Arrow IPC stream as opaque bytes. The
receive loop checks the batch against the producer’s credit, takes its share, and hands it to the
producer’s task without waiting for anything; the batch’s reply, a SubmissionOutcome, follows
whenever its outcome is decided. The session never decodes a batch: the node that executes the
ingestor validates and admits it. A batch beyond the credit is answered at once as not admitted
with CreditExceeded, and the producer is closed and later ended as ProtocolViolated; the batches
it already submitted still receive their outcomes.
CloseIngestorRequest stops admission for the producer: its queued batches are refused, its
admitted batches are answered as they resolve, and the close is answered Closed once every batch
has its outcome, or NotOpen when the session holds no producer by that identity.
Outcomes, Admission, And Ends
SubmissionOutcome carries one terminal ClientSubmissionOutcome and a bounded, non-sensitive
message. The four outcomes keep the boundary’s four facts apart:
NotAdmitted. No row entered the graph. The cause is anInvalidBatchdefect,Suspended,Busy,Draining,ProducerEnded, orCreditExceeded. OnlySuspendedandBusyare temporary.Completed. The batch’s source acknowledgement root resolved successfully.ProcessingFailed. The batch was admitted and its acknowledgement failed, withAckTimedOutorRejected. Some effects may have happened.OutcomeUnknown. The batch may have been admitted, and no terminal result can be established:Interruptedwhen the ingestor’s execution stopped or the producer ended with the batch unresolved,OwnerLostwhen the node that executes it, or the connection to that node, was lost.
A transport receipt is never an outcome, and a reply is never sent before the outcome is decided. The session never replays a batch: the client decides, and only a temporary refusal is safe to send again unchanged.
ProducerAdmissionChanged tells the client that admission is Suspended or Open again. Changes
are coalesced, so a client that reads late receives the newest state. ProducerEnded carries a
ClientProducerEndReason and is the last frame about the producer; every batch it held has its
outcome before it: EndpointChanged, EndpointRemoved, DomainStopped, Relocated,
ShuttingDown, OwnerLost, or ProtocolViolated. Ingestors says
which change causes each. A producer closed by its client ends with the close reply and no
ProducerEnded.
During an ingestor alteration, admitted buffered batches keep their original outcome and credit
while admission is suspended. The server drains them before publishing the replacement contract,
including partial branch batches that must first pass through a shared relay. An intake hold and
the full entity hold overlap, so no intermediate Open admits work between those phases. A failed
drain keeps the committed contract and producer attachments; it does not replay an unresolved
submission. A successful contract change delivers the admitted outcomes before EndpointChanged.
Ordering And Backpressure
Each producer has one task on the serving node that is the only writer of its replies and frames, so a batch’s outcome, the producer’s admission changes, and its end reach the client in the order the ingestor produced them. Outcomes, admission changes, and ends travel on the control lane. A batch keeps its share of the credit until its reply is queued, so a client that sends a batch only once an earlier reply freed room never exceeds its credit, and a producer whose client stops reading outcomes stops receiving room for new batches. The credit therefore bounds what a producer holds on the serving node, whatever the graph does, while commands, subscriptions, and clock frames keep moving beside it.
The ingestor admits a producer’s batches in the order they reach it. A batch refused as Busy or
Suspended reaches it again only when the client sends it again after the backoff, behind every
batch the client sent in the meantime, so batches outstanding at the same time can be admitted,
and their output delivered, in another order than they were first sent. A client that needs
submission order keeps one batch outstanding.
Forwarding To The Executing Node
A client may open a producer through any live node. When the serving node does not execute the
ingestor, it opens a producer link to the node that does over the interconnect, reserves the
producer’s bytes in its own budget, and relays the batches, outcomes, admission changes, and end;
the executing node reserves the same bytes again for the batches it retains. Cluster
Interconnect owns the link. The client sees the same
protocol either way. The executing node admits a forwarded batch only after the serving node cleared
it, so when the link is lost, whether the executing node crashed or stopped answering, the serving
node still knows which batches may have entered the graph. Every batch it never cleared is answered
NotAdmitted with ProducerEnded, which the client may submit again on a new producer without
duplicating its effects; every batch it cleared has an OutcomeUnknown with cause OwnerLost; and
the producer then ends as OwnerLost. When the serving node itself is lost, its sessions end with
it, as When The Session Ends describes.
When The Session Ends
When the session ends, including because the node that serves it crashed, its producers detach:
the executing node stops accepting their batches, drops the ones it held queued or awaiting their
clearance without admitting them, answers nobody, and lets admitted batches finish in the graph.
No server attachment survives the session. The Rust client retains the application’s desired
producer and its submission ledger. It reports every batch sent without an outcome as
OutcomeUnknown(SessionLost) and never resends it. On a new exchange it sends a fresh
OpenIngestorRequest, compares the returned START generation, endpoint contract, schema, policy
and grant with the first open, then publishes the new attachment to the same producer handle. A
different generation or contract, a removed or stopped endpoint, or an incompatible schema makes
the handle require an explicit new open. Temporary owner and capacity refusals are retried with
bounded physical backoff while the new exchange lives. Closing or dropping the handle fences a
late open and releases any attachment it created. The shared binding opens producers through these
same Rust client handles, so its hosts restore them the same way; the web console does not open
producers.
A serving node whose runtime has not yet passed its process-start linearizable catch-up barrier
refuses producer and consumer opens as EndpointUnavailable. Its local domain snapshot may still
show the state before a committed START, so it does not classify a missing or stopped endpoint as
terminal until that barrier passes. The client retries this temporary refusal with its bounded
restoration backoff.
Emitter Consumers
OpenEmitterRequest names the domain and TO CLIENT emitter, an exact field list, and requested
outstanding batch and byte limits. The serving node validates the committed schedule and schema,
reserves the requested bytes against the session’s 32 MiB and node’s separate 128 MiB consumer
budgets, and attaches to the executing node before sending OpenEmitterOutcome.Opened. It
refuses an open that cannot hold the emitter’s maximum IPC batch. At most 32 consumer handles
belong to one session. The request identity of the open is its ConsumerId. The protocol and its
FlatBuffers shape are identical over native gRPC and the console WebSocket. An opened reply also
carries the domain START generation and the emitter endpoint contract fingerprint. The fingerprint
is BLAKE3 of canonical emitter NSPL with flush cadence normalized to immediate, a 0xff separator,
and the ordered output fields serialized as JSON. A flush-only change therefore keeps the live
endpoint contract while changes to construction, publishing or ACK behavior do not.
ReadEmitterBatchRequest is a concurrent, potentially long read. It occupies one of the 64
ordinary in-flight request places but does not hold the ordered lane or receive loop. Its reply
is a ReadEmitterBatchOutcome: Batch carries one canonical Arrow IPC stream, a stable delivery
identity, a fresh attempt reference, source relay, opaque branch fingerprint, member count and
execution-time snapshot; Ended says the attachment is gone. It does not distinguish relocation
from removal or a changed contract. A client retaining the desired consumer reports the
interruption, then checks a fresh open on the current exchange against its pinned generation and
contract. A large reply uses the normal bounded transfer parts. Reading the reply never
acknowledges it. The Rust client decodes a batch only when its stream declares exactly the Arrow
schema of the fields the emitter declared at the open and holds one record batch of the member
count, and treats any other stream as an unexpected reply. The registered
client-emitter-deliveries property holds that decoder to the stream a client emitter’s sink
writes: the declared schema, every delivered row in order, and every value and null.
SettleEmitterBatchRequest carries the consumer, attempt reference, and Ack, Retry, or
Reject with a bounded non-sensitive reason. It runs beside the ordered lane and answers with
Confirmed, StaleReference, WrongConsumer, InvalidReason, or ConsumerEnded. An ACK waits
for that confirmation. ACK is idempotent while its bounded result remains; a reference revoked by
retry, timeout, detach, or reassignment cannot settle a newer attempt. The emitter’s physical
backoff controls retry. CloseEmitterRequest detaches and answers Closed or NotOpen.
A read the server answers with a batch assigns that attempt to its consumer, so a reply nobody waits for would strand the attempt until its ACK timeout. A caller of the Rust client that stops waiting for a read therefore leaves the read with its consumer, and the next read of the same attachment receives its reply instead of sending a read of its own; a read of an attachment that has since ended is dropped with it. Closing a consumer, like closing a producer, sends the close from a task of its own, so a caller that stops waiting still releases the attachment, and a second close, or a close while the handle waits to be restored, finds nothing attached and returns at once. The close also ends every read left with the consumer, including one left while the close ran, so no read outlives the close of its consumer.
Consumer and producer operations can share one session. A read awaiting output runs beside commands, producer submissions and their outcomes, clock observations, and consumer settlement. Each server attachment is bound to its session exchange. The Rust client keeps a desired consumer across reconnects and emitter relocation, reports one interruption before the next delivery for each exchange it lost, whether the loss or a reply racing it is observed first, and opens a new attachment only while the generation, contract, schema and granted behavior still match its original open. Removed or changed endpoints require an explicit new consumer. A delivery reference remains bound to the exchange that delivered it; the client never sends its ACK on the replacement exchange, and a settlement whose answer was lost is uncertain. The serving node forwards remote consumers on an authenticated relay-class duplex stream and reserves their granted bytes on both the serving and executing nodes. Loss of the stream or either endpoint revokes outstanding attempts. There is no durable cursor or consumer result history across an owner loss; upstream replay may be needed. Emitters owns the source acknowledgement guarantee and Cluster Interconnect owns the forwarding form.
The executing node exports active consumer and forwarding credit gauges, retained IPC work
gauges, and retry, application ACK, and rejection counters. DESCRIBE EMITTER reads those values
from the scheduled owner, including when the command enters through another node.
Resource Uploads
A resource archive travels on its own gRPC call, UploadResource, beside the session rather than
inside it. The client streams one UploadStart frame, then the archive as UploadChunk frames in
order, and half-closes the stream; the server answers with exactly one UploadReply. The start
names the domain, the declared resource, the upload identity, and the exact size of the archive, and
each chunk is non-empty and fits one frame. There is no manifest, digest, or finish frame: the
leader hashes the archive as it stages it and verifies everything else when it installs it. The
console WebSocket carries no upload stream.
The Rust client builds one deterministic archive in a temporary file before opening an upload
call. Archive construction uses bounded 1 MiB read and write buffers. Only a successfully
terminated and flushed archive becomes an upload body. An I/O failure abandons the partial file
and returns BuildUploadArchive above its originating I/O error; cancellation abandons it without
finalizing in a destructor or spawning cleanup work. The command deadline may cancel construction
and retains its typed deadline outcome. Once construction succeeds, every transport attempt reads
the same complete archive and keeps the same upload identity.
An upload is identified by its upload identity, not by an execution reference. The identity is
chosen by the client, at most 128 bytes of ASCII letters, digits, -, _, and ., and is durable
under the key of the authenticated user, the domain, and the resource. The first complete body
admitted under a key binds it to that archive’s digest and takes the next version number of the
resource. Repeating the same identity with the same archive joins the installation or reports its
recorded outcome, with the same version, and the reply’s origin says whether this call installed it
(Executed) or found it recorded (Recovered). The same identity with another archive fails as
InstallationFailed with a message naming the conflict and, when the leader finds the identity
recorded, the version it already holds. Upload identities never expire. Because the digest is
computed from the body, recovering an uncertain upload means sending the whole archive again; the
protocol has no request that asks for an upload’s outcome without it.
Uploads are leader operations. A follower reads the start frame, answers LeaderRedirect, and reads
no chunk. The leader refuses what a stream gets wrong with a typed failure:
| Failure | Meaning |
|---|---|
InvalidStream | The stream did not begin with exactly one valid start followed only by chunks. |
ResourceNotDeclared | The domain declares no resource of that name. |
SizeMismatch | The chunks add up to more or fewer bytes than the start declared. |
QuotaExceeded | The declared or received size exceeds the archive quota. |
InstallationFailed | The archive could not be staged or installed, its identity is bound to another archive, or the completed version’s outcome could not be confirmed on every live node. It carries the assigned version when one was assigned, and repeating the same identity and archive returns the recorded outcome. |
A body that ends early or overruns its declaration admits nothing and leaves its identity unused, so
it can be sent again. Once the complete body is admitted, installation belongs to the cluster: it
continues when the uploading connection closes, a new leader resumes it, and ResourceInstalled
arrives only after every live node incarnation has verified and installed the same digest. If
leadership moves while the upload installs, the reply is a LeaderRedirect, and repeating the same
identity and archive on the new leader recovers the recorded outcome. Resource Versions And
Bindings owns what admission, installation, publication,
and completion mean, and Command Completion
owns the completion boundary.
Authentication gives an upload its owner and nothing more: any authenticated user may upload a version of any resource declared in a domain, and the user scopes only the identity, so two users who choose the same identity string upload two versions.
Backup Downloads
A backup is a persistent command that leaves an artifact behind: its archive, which can be far
larger than any reply. BACKUP therefore travels as an ordinary CommandRequest and is recorded
under its execution reference like every other persistent command, while its archive travels on a
call of its own, DownloadBackup, keyed by that reference. Backup And
Restore owns what an archive holds and how it is laid out.
The leader runs the backup. Each domain is captured at its own applied revision and records whether
its cut was quiesced, live, stopped, or configuration-only. The leader assembles the archive in its
staging area, and only then completes the command. The CommandCompleted outcome carries the
archive’s summary: its size and BLAKE3 digest, the capture time, the instant the node stops
retaining the archive, whether resource bytes are included, the number of users, and each domain’s
revision, cut kind and quiesce counters, section count, and bytes. The summary is part of the recorded outcome, so repeating the
command under its reference returns the same summary and never assembles a second archive.
A download call carries exactly one BackupDownloadRequest, which names the backup’s execution
reference, and the node authenticates the call from its metadata before it reads the request. A
node that retains the archive answers with a BackupArchiveStart naming the archive’s size and
digest, then the archive in order as non-empty BackupArchiveChunk frames of at most 256 KiB, then
a BackupArchiveComplete. Every other answer is one frame:
| Answer | Meaning |
|---|---|
BackupDownloadFailed with InvalidRequest | The request did not decode. |
BackupDownloadFailed with Expired | The retry validity of the reference has ended. |
BackupDownloadFailed with NotOwner | Another user ran the backup. |
LeaderRedirect | The node does not retain the archive and does not lead. |
BackupDownloadFailed with NotRetained | The leader does not retain the archive: a download already collected it, the node that assembled it restarted, or the reference names no backup. |
BackupDownloadFailed with ReadFailed | The node could not read the archive it retains; the archive stays retained. |
An archive is retained on the node that assembled it until the first download whose completion frame was queued collects it, or until the retry validity of the backup’s reference ends. Any node reads that end from the reference’s UUIDv7 time, so an expired reference is refused the same way before and after its execution record is reclaimed. A node that does not retain the archive sends the client to the leader, and the leader, which then does not retain it either, answers from the recorded outcome. If leadership moves while a backup runs, the new leader resumes the applying command and assembles an archive of its own, and the archive the former leader assembled is never offered again.
Each running domain has its own quiesce timeout; the serial capture does not imply a total
command timeout. Native clients bound the overall backup command wait with
ConnectOptions::backup_wait_timeout (ten minutes by default, one millisecond through 24 hours),
including every reply, redirect and reconnect. An exhausted wait returns UncertainCommand with
the original reference and leaves admitted work recoverable. A new wait attempt retains that
reference and its original selected domain, scope, resource inclusion and capture options; the
client’s local output path is excluded from the server’s semantic request binding. A changed
capture request conflicts, and an expired reference cannot admit a replacement execution.
Waiting again does not extend server retry validity or the archive’s retention. Downloading
begins only after the summary arrives and has separate per-frame stall bounds.
The node reads the archive only as fast as the client takes frames, at most four frames ahead of it, so a slow client holds back reads instead of growing what the node buffers for it. The call is not bounded by the request deadline, because a transfer can take far longer than a command; the Rust client bounds the wait for each frame instead. A download that ends early releases only its own hold on the archive, which stays retained for the client to download again. The protocol has no resume offset: a client starts a failed download again from the first byte, and accepts the archive only when the completion frame has arrived and every byte matches the summary’s size and digest.
The web console sends BACKUP on its session and downloads the archive over a console WebSocket of
its own, from the console of the node whose session reported the backup. It verifies the archive
exactly as the Rust client does, and hands it to the browser as a download only once it is
complete and matches the summary. The browser tab records the backup’s execution reference, query
and domain until the archive is downloaded, so a reloaded page repeats the command under the same
reference, recovers the summary, and downloads the archive again while it is retained.
DESCRIBE BACKUP never reaches a node at all: a client reads the archive file on its own machine,
and a node answers the statement sent as a command with RequestFailed.
Restore Streams
A restore reads an archive that exists only on the client’s machine, so the archive has to reach
the leader before the restore can even be planned. RESTORE therefore never travels as a
CommandRequest: the client sends the statement together with the archive on a call of its own,
RestoreBackup, and the call’s one reply carries the restore’s outcome. A node answers RESTORE
sent as a command with RequestFailed. Backup And Restore
owns what a restore recreates and the order of its steps.
The client streams one RestoreStart, then the archive in order as non-empty RestoreChunk frames
of at most 256 KiB, and half-closes the stream. The start carries the request identity of the
reply, the restore’s execution reference, the statement as canonical NSPL, and the archive’s exact
size and BLAKE3 digest. The reference is a persistent command’s execution reference, and the
request it is bound to is the statement together with the archive’s size and digest: a restore is
admitted once under it, repeating the same statement and archive under it joins that restore, and
another statement or archive under it is answered ExecutionReferenceConflict, like any persistent
command.
The reply is a RestoreReply holding either a CommandOutcome, exactly as a command’s reply would
hold it, or a RestoreUploadFailed that refuses the stream itself and leaves nothing changed:
| Failure | Meaning |
|---|---|
InvalidStream | The stream did not begin with exactly one valid start followed only by chunks. |
InvalidStatement | The start does not name exactly one RESTORE statement. |
SizeMismatch | The chunks add up to more or fewer bytes than the start declared. |
DigestMismatch | The archive’s bytes do not have the digest the start declared. |
QuotaExceeded | The archive is larger than one staged artifact may be, or the leader’s staging area cannot hold it now. |
StagingFailed | The leader could not write the archive to its staging area. |
A refusal of the stream is not recorded; sending the stream again stages the archive again. Every
other answer is a CommandOutcome under the start’s execution reference:
- A follower reads the start, answers
LeaderRedirect, and reads no chunk. - A leader that finds the restore finished answers with its recorded outcome, whose origin is
Recovered, and reads no chunk. - A leader that holds the archive of the restore while it applies answers
OutcomeUnknownwith theStillApplyingcause, and reads no chunk. - Otherwise the leader stages the whole archive, verifies it, and plans the restore. A restore it
refuses there is answered
RequestFailedand is not admitted. A restore it admits, or finds admitted without its archive, runs to its outcome, which carries the typedRestoreReport: the mode, the archive’s size and digest, the capture time, what the users step did, each restored domain with its counts, required lifecycle status and required archived start generation and, for a dry run, its model run’s transaction impact report, and each step with its outcome.
A restore that failed at a step is RequestFailed, and its report names the step as Failed, the
steps before it as Applied, and those after it as NotAttempted. A dry run reports every step as
Planned, and is never admitted or recorded.
A domain whose restore has not published its complete runtime-state generation refuses START
with a definitive command failure naming the incomplete installation. Planning rejects it before
admitting a lifecycle step, and consensus checks the same gate at activation. Failure of the
restore, release of its mutation lease, and node restart do not release this gate. Only completion
of the whole installation does.
With RESUME, complete publication, gate release and the transition to Running at the archived
generation and clock mapping precede the successful outcome. A client can immediately inspect
that lifecycle or attach its clock. A dry run reports the planned lifecycle without activation.
DomainInfo in DomainList and DomainsChanged carries a required start_version beside its
status and pace. Wire verification rejects missing required lifecycle and generation fields;
the decoder never invents a default generation.
The call is not bounded by the request deadline, because an archive can take far longer to send than a command takes to run; the Rust client bounds each frame by it, and then the wait for the reply once the last frame was sent. Once the leader has received the whole archive, the restore is no longer tied to the call: a client that goes away does not stop it, and repeating the stream under the same reference returns its outcome. A call that ends before the whole archive arrived changes nothing and releases what the leader staged for it.
Only the leader the archive was streamed to holds it, until the restore finishes or the retry
validity of its reference ends. If leadership moves, or that leader restarts, while the restore
applies, the reply is a LeaderRedirect or the call fails, and repeating the stream on the new
leader hands it the archive, from which it resumes the restore at its first step not recorded. The
protocol has no resume offset: every repetition sends the archive from its first byte.
The web console streams a restore over a console WebSocket of its own, to the console of the leader its session follows. It reads the archive file the operator chose once to measure and digest it before the start, and again as it streams. The stream ends with the chunk that completes the declared size instead of a half-close, and the console watches for a reply that arrives before every chunk was sent. It repeats a redirected, interrupted, or still-applying restore under the same reference, statement and archive, and reports the outcome as unknown, naming the reference, when ten minutes of repetitions leave it unknown.
Node Stop And Restart As A Client Observes Them
Shutdown And Recovery owns the phases of a node stop. This section describes what a client sees at each ending and what it recovers.
Graceful stop. When a node begins to stop, it closes admission on its session gRPC and console
listeners and ends the sessions they carry. Every request a session had not yet admitted is decided
cancelled before admission and never begins an effect. Work that was admitted keeps running and no
reply follows for it: a transaction commit continues, and the node waits for it until its shutdown
deadline. Subscriptions and domain clock attachments stop with their session, and an upload stream
still waiting for its client ends at once. A console session receives SessionEnding with reason
ServerShuttingDown after the control frames it had already queued, then a normal close. A backup
download or a restore stream on a console WebSocket of its own ends at once with close code 1001,
and a restore admitted with its whole archive goes on without its call. A gRPC
connection is cut when admission closes, so a native client sees its stream fail, as after any
transport loss, rather than the ending frame. Either way the client reconnects to another node and
repeats each outstanding command under its execution reference, which joins the admitted work or
returns its retained outcome.
Forced ending. A repeated signal, an expired shutdown deadline, SIGKILL, or a crash ends the
connection without warning. A command whose reply was not received may or may not have been
admitted, and the client cannot tell which from the connection. It repeats the command under the
same execution reference on whichever node now leads. If the command was admitted, the new leader
resumes it from its durable progress, and the repetition joins it or returns its retained outcome
with origin Recovered; if it was not, the repetition admits it for the first time.
Leadership change. A gRPC session on the former leader receives LeadershipObserved naming the
new leader, or no leader during an election, and its next leader operation is redirected. A command
that leadership moved under returns OutcomeUnknown with cause LeadershipLost, and repeating it
on the new leader recovers its outcome. A console session on the former leader ends with a
SessionEnding that names the new leader, and the console reconnects there.
Full restart. When every node stops and restarts from its own storage, the cluster reopens its
committed state, including every retained execution record, transaction, and upload record.
Repeating an execution reference admitted before the restart returns its retained outcome without
repeating its effect, a COMMITTING transaction resumes from its recorded step, and an open
transaction whose inactivity deadline passed while the cluster was down expires once it runs again.
Nothing a session held survives.
| Fact | Where it lives | After a failover or a full restart |
|---|---|---|
| Execution record: admitted command, progress, terminal outcome, retry fence | Consensus | Retained; repeating the reference recovers it |
| Transaction: owner, domain, state, operations, previews, commit progress, inactivity deadline, tombstone | Consensus | Retained; attaching the identity recovers it |
| Domain mutation lease | Consensus | Retained by the command or transaction that holds it |
| Upload record: identity, digest, version, outcome | Consensus | Retained without expiry; repeating the identity recovers it |
| Session, request identities, admission decisions, queued frames | The serving process | Gone; a new session starts with new request identities |
| Transaction binding | The leader’s memory | Gone; the client attaches the transaction again |
| Selected domain and its observations | The session | Gone; the client selects the domain again |
| Subscriptions, generations, interest leases, queued rows | The session and its node | Gone; rows in transit are lost, and a restored subscription is a new generation |
| Domain clock attachments | The session | Gone; the client attaches again and receives the clock as it is then |
| Producer and consumer attachments, their credit, and the replies they owe | The session and the executing node | Gone; unresolved producer batches are of unknown outcome, admitted ones finish in the graph, and matching client handles acquire fresh attachments |
| Waiters, transfer reassembly, previews, desired subscriptions, followed clocks, desired producers and consumers | The client | Kept by the Rust client across reconnects; lost if the client process ends |
A subscription therefore always has a gap across the loss of its session. The server cannot report that gap, because the session that would carry the report is the one that was lost. The Rust client reports it instead, as an interruption of every subscription and clock the lost session held, before it restores them.
Bindings For Other Hosts
A host language reaches the protocol in one of two ways. It loads the shared Rust binding,
nervix-client-ffi, whose C ABI is crates/client-ffi/include/nervix_client.h, or it implements
the protocol itself from the schema. The binding is the supported path for C, C++, Python, JVM, and
Ruby hosts. It drives the Rust client’s own state machine, so request correlation, leader redirects,
reconnection, execution identity across retries, and desired-subscription restoration are decided
once, in the library, and a host never retries, reroutes, or reinterprets an outcome on its own. A
separate SDK per language would have to reimplement each of those decisions and be qualified again.
The binding’s lifecycle follows from that choice:
- Handles. A host opens a session with
nx_session_connectand releases it withnx_session_free; outcomes, events and batches read from a session stay valid after it is freed, and its producers, consumers and deliveries keep it running until they are released too. Every object a function hands out is owned by the caller and has exactly one release function, and a borrowed pointer stays valid only until the object it was read from is released. - Execution identity.
nx_session_preparecaptures one command and its durable execution reference before anything is sent.nx_session_executeruns it, and running the same prepared execution again after an uncertain, cancelled, or timed-out call recovers the command’s outcome instead of running it twice. Every failure ofnx_session_executenames that reference. - Threads. Each session runs on a runtime of its own, with two threads the library owns. The library never calls host code, so no host function ever runs on a library thread. Every blocking call runs on the calling thread until it completes, fails, or is cancelled, and a session may be used from several threads at once; commands of one session still run one at a time. A call made from a thread that is itself driving a Tokio runtime cannot block on the library’s runtime and aborts the process, so a Rust host calls the binding from a blocking thread.
- Cancellation and deadlines. Every blocking call takes an optional
nx_cancel. A token triggered from any thread makes the call returnNX_ERROR_CANCELLED, and a token created with a deadline makes it returnNX_ERROR_DEADLINEonce the deadline passes. Cancelling a wait never rolls back admitted work. - Bulk access. A Row event keeps the verified frame it was decoded from, after checking the
batch against its subscription’s schema.
nx_event_frameborrows that frame without copying it, a column accessor copies one whole column into caller-provided memory in one call, andnx_event_cell_varlenborrows one string or bytes value. Events are reference counted:nx_event_retainadds a reference, and the event and its frame are freed with the lastnx_event_release, from any thread. - Clock events.
nx_session_next_clock_eventuses the same cancellation and deadline rules as the subscription wait, but returns a separatenx_clock_eventreference. Its kind, domain, generation, state, paced mapping, tick and end-reason accessors expose the fields each event carries. An accessor for a field its event lacks returnsNX_ERROR_TYPE. Retain and release preserve the event and its borrowed domain name across threads until the last reference ends. - Attached clocks.
nx_session_domain_clockreads, without blocking, the clock the Rust client holds for a followed domain, or NULL for a domain it does not follow. After an attach completes it is the clock the attach reply carried, so a host reads the generation and committed mapping of a running clock before its first tick, and every later observation and tick replaces it in the order the session received them, so it is never older than an event the host has taken. Its accessors expose the domain, generation, state, paced mapping and newest tick, and its projections answer logical time, physical waits and admission with the Rust client’s arithmetic. A read never changes; retain and release share it across threads. - Producers and consumers.
nx_session_open_ingestorandnx_session_subscribe_emitteropen the Rust client’s producer and consumer with the host’s exact fields, built withnx_fields, and the credit it asks for; a refusal carries its typednx_open_refusal. A producer submits a batch and waits for credit, and its outcome is taken withnx_producer_rejoin, listed withnx_producer_pendingand let go withnx_producer_release, each with the Rust producer’s semantics: a cancelled wait leaves the submission and its outcome with the producer. A consumer hands out reference-countednx_deliveryhandles; releasing one settles nothing, andnx_delivery_ack,nx_delivery_retryandnx_delivery_rejectreturn the server’s typed settlement. Both keep the Rust client’s restoration: a consumer reportsNX_ERROR_INTERRUPTEDonce per gap, and a handle whose endpoint changed reportsNX_ERROR_REOPEN_REQUIREDwith its typed reason. The binding keeps no acknowledgement, credit or reconnect state of its own. - Batches. A producer’s batch is built with
nx_batch_builderfrom the host’s columns, one level of a column’s type at a time, and every buffer is copied before the call returns. A delivered batch is decoded the first time it is read, held to the consumer’s schema and member count, and read the same way. Every batch is its canonical Arrow IPC stream, whichnx_batch_ipcandnx_delivery_ipcborrow andnx_producer_submit_ipcaccepts from a host’s own Arrow tooling, so the binding encodes nothing a second way and the observation API stays free of Arrow. - Typed failures. A failing call returns an
nx_errorwhose kind separates an invalid argument, a failed connection, a failed session, an uncertain outcome that carries the execution reference or leaves a settlement unconfirmed, a server refusal, a deadline, a cancellation, a protocol violation, a type mismatch, a session or handle that ended, an interrupted consumer, and an endpoint that has to be opened again. The binding classifies the Rust client’serror_stack::Report<ClientError>by its current context, and a failed backup download by theBackupDownloadErrorbeneath that context, never by text, andnx_error_messagereturns the report’s whole chain.
The binding connects with the Rust client’s default options. It exposes no seeds, timeouts, or
certificate authority, so it reaches a node over plaintext and connects to it directly. It exposes
commands, completion, subscriptions and their events, domain clock events and attached clocks,
bulk row access, and producers and consumers of client endpoints with their batches, but not the
typed transaction status, inspection, choice lookups, notices, or leadership. A host executes
ATTACH DOMAIN CLOCK;, reads the clock the reply carried with nx_session_domain_clock, and
reads later observations and ticks through the clock-event wait; the Rust client restores the
attachment after reconnect. An attach reports a refusal as a failed
disposition carrying the server’s reason, not as a typed refusal. Its Row column accessors cover
scalar, string, and bytes fields; a list field of a subscription reports its levels, and its values
are read from the borrowed frame with generated code, while an endpoint batch’s lists are read one
level at a time.
Independent implementations exist as qualification clients rather than supported SDKs. The Go client
speaks native gRPC with flatc --go output and google.golang.org/grpc, and the TypeScript client
speaks the console WebSocket with flatc --ts output, unchanged under Node.js and Bun. They
correlate replies by request identity, follow a leader redirect with the same execution reference,
and fail rather than report success when an outcome is unknown. Neither restores subscriptions or
domain clock attachments after reconnection; that recovery belongs to the Rust client and, through
it, to every binding host. The FlatBuffers runtimes for Go and TypeScript have no verifier, so those
clients check each frame’s identifier and every union discriminant and required value they read, and
the Rust verifier remains the only full structural verification. Conformance
Evidence lists the probes, the runtimes
they cover, and how to run them.
Measured Costs
The protocol’s costs were measured against the accepted FlatBuffers Row form and recorded, with
their raw results and reproduction commands, in tests/client-wire-16-performance.md and
tests/evidence/client-wire-16. The figures below summarize that record. They were taken on a
shared host with a release server and a debug measurement harness, so they show where the cost of
each stage lies and bound regressions. They are not throughput guarantees, and the record does not
attribute a difference between two of its runs to the protocol unless it says so.
Construction and verification. The server writes each Arrow column value directly into FlatBuffers tables, with no record-shaped JSON object, map of fields, or intermediate row. A frame is verified once, and every later read borrows from it:
| Workload | Rows / frames / wire bytes | Arrow to wire p50 | Verify p50 | Borrowed full scan p50 | Owning every cell p50 |
|---|---|---|---|---|---|
| One row per frame, alternating branches | 100 / 100 / 131,192 | 112 µs | 52 µs | 7 µs | 15 µs |
| Narrow rows in one batch | 1,024 / 4 / 135,896 | 415 µs | 250 µs | 47 µs | 91 µs |
| Wide rows with strings, bytes, nulls, and redaction | 100 / 1 / 124,576 | 271 µs | 132 µs | 28 µs | 50 µs |
| Large batch | 4,096 / 16 / 4,672,376 | 2,441 µs | 1,263 µs | 393 µs | 873 µs |
| Rows of 16 KiB, limited by frame bytes | 256 / 2 / 4,224,416 | 332 µs | 134 µs | 25 µs | 160 µs |
| Every fourth row selected | 256 / 1 / 33,968 | 100 µs | 58 µs | 25 µs | 20 µs |
Verification and borrowed reads allocate nothing; reading one cell per frame takes about 0.2 µs. Owning every cell of 1,024 narrow rows allocates about 172 KB, and rendering them as the JSON text the CLI and console display allocates about 724 KB, with escaped field names computed once per batch rather than once per row.
Copies and retained buffers. Variable-length values are copied once, into the builder, and the finished buffer becomes the queued frame without another copy. The gRPC sender copies a frame into its output buffer, about 77 µs for the 4.67 MB large batch. The gRPC receiver copies a frame below 64 KiB into an allocation of its own and shares the receive buffer of a larger one. A WebSocket message must be owned, so the console transport copies a frame once more, about 79 µs for the same batch. The 256-row and frame-byte limits keep a frame from growing into an unbounded buffer: the large batch held 8.38 MB of live allocation for 4.67 MB of frames, the byte-limited batch 4.28 MB for 4.22 MB, and live allocation returned to its baseline once the frames were released.
End to end. The record repeats the workload of the original Protobuf baseline. In its last run, on a clean commit, the release server answered a native command in 2.9 ms at p50 and 3.9 ms at p99, delivered a typed HTTP input to a Row subscriber in 23.6 ms and 28.9 ms, served a live graph snapshot on the console WebSocket in 176 µs and 397 µs, and completed a one-file upload in 15.0 ms and 15.6 ms. Of the command time, about 2.1 ms at p50 is preparation in the debug client, dominated by its statement parser, and about 1.0 ms is the prepared request and its reply. Control commands sent while a subscriber was paused or draining slowly completed in 4.5 ms and 4.9 ms at p50, because replies take the control lane ahead of queued rows. The median command request and response were 176 and 1,640 bytes, and a one-row subscription frame 1,320 bytes.
Transport and bindings. Opening a session on one node took about 2 to 4 ms over plaintext and 4
to 5 ms over TLS; the per-command difference between the two did not stay stable across runs.
Through the C ABI, borrowing a verified frame takes about 60 to 70 ns, retaining and releasing an
event 80 to 90 ns, and copying the states, fixed-width values, or variable-length values of a
100-row column into caller buffers 64 to 122 µs. Through CPython and ctypes, each call costs about
0.6 to 6 µs, which is why a host reads a batch column by column rather than value by value.
The record also sets regression alarms derived from the Protobuf baseline: a p99 of 6.5 ms and a 1,900-byte response for a command, 90 ms and 1,500 bytes for a typed Row delivery, 3.6 ms and 4,600 bytes for a graph snapshot, and 75 ms and 200 bytes for an upload acknowledgement. They are qualification thresholds, not promises.
Guarantees And Non-Guarantees
The protocol guarantees:
- Correlation. A reply completes exactly the request whose identity it names, in whatever order replies arrive, and never a request of another session.
- Bounded, verified frames. Every frame fits the session limits and is verified before it is read. A reply larger than a frame arrives whole or not at all.
- One outcome per identity. Repeating a command under the execution reference that admitted it joins the admitted execution or returns its retained outcome. It never applies the effect a second time, and a reference whose record was reclaimed is refused rather than run again.
- Exact proof. The outcome of an append or a commit comes only from the record of that exact request, never from the state of the transaction as a whole.
- Written order. The requests that change a session run in the order the client wrote them.
- Responsive control. Replies and session events are never queued behind subscription rows, and a command waiting for its completion boundary delays no concurrent request.
- Typed rows. A Row frame carries every value with its declared type and nullability, withholds every sensitive value, and names its concrete branch. Its schema precedes it, and no row of a redefined relay reaches a subscription announced under the earlier definition.
- Named losses. A subscription reports the rows the server skipped, the rows a
DROPPINGsubscription discarded before its next rows, and its end. The Rust client and its bindings also report the loss of the session a subscription lived on, and every attempt to restore it that a new session refused. - One outcome per batch. Every submitted batch receives exactly one terminal outcome, never completed before its acknowledgement root resolved, and a producer’s end follows every outcome it owed.
- Verifiable archives. A backup’s archive arrives with the size and digest its recorded outcome names, and a download that ends early leaves it retained for another attempt.
It does not provide:
- Exactly-once submission. A batch whose outcome is unknown or failed may have had effects, and nothing replays or deduplicates it. The application keeps its source data and decides.
- Exactly-once delivery. A subscription is a live view with at-most-once delivery per admitted batch. Rows in transit can be lost without a report while relay ownership moves or a node-to-node delivery fails, and a source may redeliver a record that the graph then passes again.
- Automatic rollback. A failed transaction keeps the applied prefix of its execution steps, and a cancelled waiter keeps its admitted effect. Undoing either is a new command.
- Durable subscription replay. Subscriptions keep no offsets and are not persisted. A restored subscription starts with the rows that arrive after it opens again.
- Guaranteed restoration. The Rust client opens a subscription or endpoint again, or attaches a clock again, only when the serving exchange accepts it. A subscription whose relay no longer exists stays interrupted until its caller deletes it. A producer or consumer whose endpoint contract or domain generation changed requires a fresh application open. There is no durable consumer cursor.
- A required Arrow client library. Rows arrive as typed FlatBuffers cells; a client needs only code generated from the schema.
- Columnar subscriptions.
SubscriptionTypehas one value,Row. A server refuses a type it does not declare asUnsupportedValueand never substitutes another encoding. - A durable session. A session, its request identities, its selected domain, its subscriptions, its domain clock attachments, its producers, and its transaction binding end with the connection. Only execution records, transactions, and upload records outlive it.
- Liveness detection. Neither transport sends keepalives, and the server has no idle timeout. A client that needs to detect a half-open connection bounds its own waits.
- A durable archive. A backup’s archive lives in the staging area of the node that assembled it and is lost when that node stops, or once its reference’s retry validity ends; the recorded outcome outlives it. Only the first complete download receives it.
- Authorization. Every authenticated user may run every command in every domain.
Observability
The protocol makes a client’s view of its own work explicit rather than inferred:
- Correlation. Every reply names the request identity it answers, and every command outcome names its execution reference. A client that logs both can pair a reply with the request it sent and with every later attempt of the same command. The CLI prints the execution reference of a command whose outcome is unknown, so an operator can see what a later retry will recover.
- Dispositions and origin. A command outcome states its disposition, and whether it was executed
now or recovered from the durable record.
OutcomeUnknownnames its cause, a conflict names what differed, and a stale preview names both identities. - Transactions.
SHOW TRANSACTIONSlists live transactions and retained tombstones with their owner, domain, state, pending count, progress, age, and idle time, from any node.DESCRIBE TRANSACTIONreads one transaction’s full impact report; see Transaction Quiescence And Impact Inspection. - Subscriptions. A client learns of its own losses from
SubscriptionDeliveryLost,SubscriptionRowsSkipped, andSubscriptionEnded, and the Rust client reports a lost session as an interruption of each clock and of each subscription the server had not ended, and each refused attempt to restore one as a restoration failure with the server’s message.Client::subscription_lifecyclereads each subscription’s state, includingEndedfor one the server ended. The web console shows each tab’s state on the tab: pending, active, interrupted, restoring, ended, resubscribing, or closing. Each node exportsnervix_session_subscriptions, the number of subscription leases it holds per relay, andnervix_session_subscription_dropped_rows_total, the rows itsDROPPINGsubscriptions discarded, both labeled bydomainandrelay; see Metrics And Observability. Skipped rows are reported only to the client. - Leadership.
LeadershipObservedtells every session which node leads and where to reach it, andSHOW CLUSTER STATUSshows each node’s availability. - Producers. A client learns every batch’s outcome and every admission change and end of its
producers.
SHOW INGESTORSandDESCRIBE INGESTORshow each client ingestor’s attached and forwarded producers, its outstanding batches and bytes, and the batches holding its window, and the node that executes it exports them as metrics with the answered batches by outcome and cause; see Metrics And Observability. - Backups. A completed backup’s outcome carries its archive’s summary. The node that assembled
the archive logs its assembly and its collection by a complete download at
info, naming the execution reference, and every other end of a download atdebug.
The server keeps its own records at the levels the logging contract assigns. Listener startup is
logged at info. A reply replaced by a rejection, a reply the session ended before it could queue,
a subscription notice or loss report that does not fit a frame, domain clock attach, detach, and
end, and producer opens, refusals, closes, and ends are logged at debug. A session that falls behind the notice bus, an event or snapshot that
does not fit a frame, a failed revert at a clean close, and a failed remote subscription dispatch
are logged at warn. Session opening and closing, individual requests, and rows are not logged, and
no log line carries a payload value. The server exports no metric for sessions, requests,
rejections, or authentication attempts; a client that needs them measures them itself. Producer
counts are exported by the node that executes the client ingestor, not per session.
Retained Subscription And Producer Dependencies
A subscription generation retains its domain lifecycle when delivery starts. Filtered batches read the currently installed clock through that capability, including a domain started after the subscription opened. Unavailable-clock skipped-row outcomes remain unchanged. It also retains the node’s executor and passes it to predicate evaluation for bounded execution. Its dropped-row counter is resolved with the generation. A native ingestor endpoint resolves every public batch-outcome metric child at startup and retains its execution’s acknowledgement trackers. These internal ownership rules change neither session framing nor client recovery behavior.
Control Plane
The control plane is where Nervix applies strong consistency.
It is responsible for:
- storing NSPL models
- validating references, compatibility, and placement claims
- computing domain schedules
- tracking domain lifecycle
- handling cluster coordination
- exposing control operations like
SHOW CREATE,DESCRIBE INGESTOR, andSHOW CLUSTER STATUS
The most important property is that control-plane state is authoritative. A runtime node only exists because the control plane says it exists.
Execution graph configuration is part of this control-plane state. NSPL models, domain schedules, and lifecycle transitions are persisted with strong consistency guarantees before runtime nodes execute them. The committed schedule becomes an in-memory typed execution revision at the application boundary; Execution Plans describes its installation and recovery.
Schedule planning captures live voter incarnations separately from destination candidates, which exclude both terminating incarnations and the captured consensus cordons. Revalidation uses the same composed candidate set. A cordoned former owner beginning to terminate leaves that set unchanged, while its loss or replacement incarnation still invalidates the plan. The consensus publication fence independently checks the captured membership and cordons.
In practice, the control plane covers:
- domain creation and selection
- model creation and deletion
- scheduling decisions, including domain defaults and named placement rules
- explicit node removal with
DROP NODE <node_id> - node cordon and uncordon with
CORDON NODE <node_id>andUNCORDON NODE <node_id> - node drain with
DRAIN NODE <node_id>, which cordons the node and moves scheduled graph nodes away one at a time - explicit relocation with
RELOCATE <selection> ONTO NODE <node_id>, which moves a selected subgraph onto a named cluster node, andDESCRIBE RELOCATION, which shows the plan without executing it - primary and replica assignment
- Kafka
OFFSET BY DOMAINpartition-to-instance assignment and rebalance - domain
STARTandSTOP
This is the part of Nervix where Raft-backed consistency matters. It keeps cluster-wide definitions coherent.
Durability And Recovery
A control-plane mutation returns its semantic result only after Raft has committed it and the leader has durably applied it. The applied write stores the changed semantic records together with its final log position, membership, transaction progress, and revision. Recovery therefore finds a complete applied range or replays its committed entries; it never reconstructs an acknowledged command from a partial state-machine update.
Errors And Diagnostics owns how validation and planning failures accumulate context and become client diagnostics. This chapter owns the command’s durability and transaction outcome.
Consensus uses a dedicated database and journal under <db-path>/consensus. Registry and runtime
state remain in the node database at <db-path>, so consensus synchronization does not flush or
wait behind data-plane journal writes. The full durability boundary, append-stream pacing, log
reader bounds, retention policy, snapshot lifecycle, storage layout, and operator settings are
defined in Consensus Storage And Replication.
Observers see a coherent state revision only after durable success. Change notifications identify committed revisions and may coalesce intermediate revisions; readers retrieve a coherent current view. A storage or transport error can arrive after the durable write completed, so it does not prove that the command was uncommitted. Persistent administrative requests keep the same execution reference across an uncertain result and join the admitted execution or retrieve its retained terminal result.
Each node applies a published cluster revision once. A revision at or below the one it has already applied carries nothing newer and is ignored, and the first revision a node sees always applies. No revision value is reserved to mean that a node has applied nothing yet, so every revision a cluster can publish, including the largest one, still suppresses the stale revisions that follow it. A revision whose application fails is not recorded as applied, so the node applies that revision again instead of treating the failed attempt as its current state.
Replicated NSPL Transactions
NSPL command grouping is explicit. BEGIN creates a Raft-replicated control-plane transaction and
returns its id. Following eligible statements are preflighted and then appended to that transaction
in written order; COMMIT applies them and REVERT discards them. BEGIN inside an active
transaction is rejected, as are COMMIT and REVERT without one. A request containing multiple
statements outside an explicit transaction is rejected instead of becoming an implicit batch.
A transaction belongs to exactly one domain. BEGIN binds the transaction to the session’s
selected domain, which must already exist; without a selected domain, or with one that does not
exist, BEGIN fails and no transaction is opened. Every statement queued afterwards must select
that same domain, and a statement submitted for another domain is rejected without changing the
pending count. A transaction therefore cannot create the domain it configures, and cannot span
domains.
The cluster owns the transaction, not the TCP or WebSocket connection. Its owner, timestamps,
state, structured semantic statements, and commit progress are replicated. The original statement
source is retained for display, but execution never reparses that text. BEGIN, queueing,
COMMIT, and REVERT are leader operations; clients transparently follow the normal leader
redirect, including for the initial BEGIN. Transactions Over The
Protocol describes how a transaction
travels over the client session protocol and how clients recover its requests exactly.
A transaction is OPEN, COMMITTING, or finished as COMMITTED, FAILED, REVERTED, or
EXPIRED. A client retains the transaction id and attaches it after reconnecting. Attach is
restricted to the authenticated owner. Attaching from a second live session takes over the
transaction, so the displaced session’s next transaction operation reports that it was taken over.
The transaction reports the domain it is bound to, and an attaching or reconnecting session adopts
that domain as its selected domain. An unclean transport loss or leadership change leaves an open
transaction available for attach. Binding is leader-local soft state, so a leader that does not
hold it reports the session as detached; clients treat that as a routing condition, attach the
transaction again, and replay the command. A session that its client closes cleanly, with no
request in flight, reverts the open transaction bound to it on the leader.
Only the bound domain’s replicated configuration effects may be queued:
- model
CREATE, supported modelALTER, and modelDROPstatements; REBIND RESOURCE, as one atomic model-mutation batch;ALTER DOMAIN,START, andSTOP;CREATE RESOURCEandRESET WASM PROCESSOR ... STATE.
Completion on the bound session resolves identifiers against the configuration the queued
statements produce, applied in written order, so a client is offered the models and resources its
own transaction defines and is no longer offered a model whose DROP it has queued. Only the
create and drop sequence decides a name, so an intermediate configuration that does not yet resolve
still completes. Sessions that are not bound to the transaction, including other sessions of the
same user, are offered committed configuration alone.
For a REBIND RESOURCE ... FOR list, completion further limits each kind to queued-result models
that bind the named resource.
Read-only SHOW, DESCRIBE, and LOOKUP statements are rejected at queue time. CREATE DOMAIN
and CREATE USER are rejected too: neither belongs to a domain, so neither is transaction content.
Session subscriptions, UPLOAD RESOURCE, BACKUP, RESTORE, and node scheduling or membership
operations (CORDON, UNCORDON, DRAIN, DROP NODE, and RELOCATE) are also immediate,
non-transaction content. Run those statements outside BEGIN/COMMIT.
Queue admission is not a blind append. The leader replays the replicated transaction prefix into a
side-effect-free ordered plan, then checks the new statement against that plan. The planner uses one
captured set of domain, model, resource, schedule, placement, membership, and liveness inputs. It
simulates lifecycle and placement changes, resource declarations, and model candidates in written
order, with the same execution-step boundaries COMMIT uses. This catches such errors as duplicate
configuration, a missing ALTER target or field, invalid domain lifecycle, invalid external
bindings, and invalid UDF or schedule inputs before the statement is replicated. A successfully
queued model mutation reports the effective quiesce level of its complete consecutive model run at
that prefix. Extending the run can raise that level or make cancelling changes a no-op.
REBIND RESOURCE resolves its target and usages from that same prefix. LATEST and its impact are
provisional at admission and are planned again from the captured commit basis. All selected models
pass ordinary creation and external-resource validation before the one model step can commit.
Resource Versions And Bindings defines LATEST
resolution and the rebinding contract.
A rejected statement does not change the pending count or the transaction’s activity time, so the
client can correct it and continue the same transaction. The admitted result is stored with the
statement. The same consensus update also stores its stable operation number and the report revision
for the resulting prefix. A report identity contains the transaction id, accepted queue position,
and a fingerprint of the coherent planning basis. An exact retry with the same request reference,
source, semantic statement, and expected position returns that admission and identity without
rerunning preflight or extending activity. A reused reference with different content is rejected.
Limits are checked before preflight.
Side-effect-free preview planning always reads the replicated semantic transaction content and one
coherent set of authoritative inputs. COMMIT requires a complete identified preview. Admission
validates its position and captured inputs, then stores the report, exact execution plan, and domain
mutation fence in the same transition to COMMITTING. A stale preview returns the typed refreshed
identity while the transaction remains OPEN; no gate or external effect has begun. Once admitted,
each step uses the stored decisions and captured inputs. A restart or new leader does not replan it
from newer configuration.
A COMMIT request may name the preview it expects to apply. The expected identity covers the
transaction id, its accepted queue position, and the whole planning basis, whether the client read
that identity from an accepted append or from an inspection. When the preview no longer describes
the transaction, the commit is refused with both the expected and the current identity, nothing
applies, and the transaction stays OPEN with its session binding intact. A client that decides to
proceed anyway commits again against the identity the refusal reported. A COMMIT that names no
expected preview applies whatever the transaction’s own latest preview describes. Naming an expected
preview fences only a commit of a transaction the same request did not change, because an append in
that request moves the transaction past the preview the client is holding.
The plan records affected topology on both sides of every execution step. The before side retains nodes and edges that the step drops or rewires; the after side records the graph that activation will install. Edges distinguish configuration dependencies, normal delivery, message-error and correlation-timeout routes, and materialized-state reads. Downstream pause traversal follows only relations that can carry records or state effects, while the reported topology also retains the configuration dependencies needed to explain those nodes. Parallel relations between the same two nodes remain separate, and every node and edge retains all operations that contributed to it.
An entity pause names its concrete branch coverage and the admission relays from the current schedule. Shared relay gates and every member of an affected hard placement group are included in that scope. Schedule entity swaps raise the effective scope to an entity pause, while a schedule rebuild raises it to a domain pause before activation begins. A domain pause covers every execution node in the before and after graphs. Changed, paused, force-flushed, ownership-moved, activated, rebuilt, and state-reset nodes remain separate effects in the report; because engaging any entity gate requests a domain-wide force flush, that flush effect covers the full current execution graph. A VHOST TLS version change is reported as an HTTPS listener refresh activation of that VHOST, with no paused subgraph and no rebuilt node. Commit uses the gate plan and schedule delta captured for this report, so execution cannot silently widen the planned scope with a second decision.
Transaction Quiescence And Impact Inspection is the full reference for the operation, execution-step, and transaction report levels; typed pause scopes; topology and attribution; preview freshness; actual engagement; and retained inspection.
An accumulated model run that already forms a complete graph receives the full registry, binding,
UDF, and scheduling preflight. Cross-model completeness may remain provisional only for the
unfinished final model run. An intermediate schema/codec mismatch or temporarily referenced model
may be repaired by a later statement in that same run. Once a lifecycle, domain, or resource step
ends the run, a later model run cannot repair it. Statement-local mutations must still be valid
against the prefix, and COMMIT requires every run to pass every check. This keeps coordinated
multi-model migrations possible without allowing a later execution step to make an earlier step
valid retroactively.
Within a transaction, each consecutive run of model mutations can mix CREATE,
ALTER SCHEMA, ALTER WIRE ... SCHEMA, ALTER RELAY, ALTER JUNCTION, ALTER DEDUPLICATOR,
ALTER REORDERER, ALTER EMITTER, ALTER <CONNECTOR> <ENTITY> INGESTOR, ALTER REINGESTOR,
ALTER GENERATOR, ALTER PLACEMENT, and DROP. Nervix applies that run as one registry
mutation: all operations are evaluated in written order against one candidate model map, the
complete domain graph is revalidated, and one atomic storage batch persists the base-to-final
result. Drop/recreate and multi-ALTER sequences are therefore classified jointly; cancelling
changes can produce a no-op.
A failure writes nothing and does
not swap the active registry state. This supports coordinated wire-schema, internal-schema, codec,
relay, processor, emitter, ingestor, generator, placement, and dependent-node migrations without
exposing an invalid intermediate graph.
Other eligible statements apply individually. COMMIT records authoritative effect progress and
completed application separately and stops at the first definitive failure. A transaction remains
COMMITTING after a step’s authoritative state is durable while runtime activation, source
readiness, remote stopping, ownership handoff, or gate release is outstanding. It becomes
COMMITTED only after the final step is usable on the current live-node set and its outcome is
authoritatively visible. Its successful output is only the highest quiesce level actually executed
across the transaction; it does not repeat the individual command outputs.
The retained report starts with every frozen step unattempted. The authoritative effect transition records that step as applying with its planned and actual impact, and application completion records it as applied or failed. Operation summaries and before/after topology are independent of the executable statement payload, so clearing statements at a terminal outcome does not erase the facts an inspection reads. Reports use keyed header, operation, and step records. Topology graphs are content-addressed, stored as bounded node and edge records, and shared by every report revision that names the same content instead of being copied into one growing transaction value.
Those storage bounds do not narrow the public report. Raft log readers and snapshot transfer read large stored ranges in bounded chunks, while inspection reconstructs every referenced operation, step, node, and edge before returning one typed result. Text and JSON responses are not paginated or silently truncated, including when an expanded report is larger than one Raft replication batch. A client therefore budgets one response proportional to the accepted operation count and affected graph; an encoding or transport failure yields no successful partial inspection.
Actual quiescence is an ordered engagement history, distinct from the frozen plan. Each attempt
records its request and then whether engagement was confirmed, definitively failed, or remained
uncertain; a confirmed cleanup records release. A request timeout is uncertain when the remote node
may already have engaged its gate, so it contributes its possible scope to the executed aggregate.
A definitive failure before any engagement leaves the actual level DYNAMIC. If runtime activation
cannot perform the planned entity swap and rebuilds the domain to recover, the report retains the
entity attempt and appends the wider domain pause and recovery rebuild effects. Later validation,
publication, activation, or leadership failure never erases engagement already observed. The
transaction result derives its aggregate from these actual histories rather than the planned scopes.
Inspecting A Transaction
Inspection reads one transaction’s impact report and changes nothing about that transaction. It reads either the transaction bound to the calling session or one named by identity, and may name a positive one-based operation to read about. Inspecting by identity never attaches or takes over the transaction, adopts its domain, touches its activity time, appends content, or moves its next queue position, and it never overwrites the commit basis cached for a different attached transaction. Ownership is the same check attach uses: a transaction belonging to another user is refused.
Which report an inspection reads follows the transaction’s state. An OPEN transaction has no
frozen revision yet, so it is planned from a coherent read of the control plane and reports the
planning basis and completeness of that read. A repeated inspection plans again rather than joining
durable mutation admission, so two reads of an unchanged transaction agree because the inputs agree.
Inspecting a COMMITTING transaction reads the frozen revision its commit admitted, with the
actual outcomes its steps have recorded so far, including the step that is still applying. A
committed, failed, reverted, or expired transaction answers with the same frozen revision and its
recorded outcomes for as long as its tombstone is retained.
An inspection returns the whole report or an explicit refusal; it never truncates topology and never mixes revisions from different reads. Naming an operation selects what a reader leads with and does not narrow the report that operation belongs to. A refusal says which of these it is: the session has no attached transaction, the identity is unknown or already reclaimed, the transaction belongs to another user, the operation number is past the operations the transaction accepted, or the transaction has no readable report, which is the state of one that ended before any operation was planned. The inspected report travels in its own typed envelope; the transaction status a command result carries continues to describe the caller’s own session binding.
NSPL reaches inspection through DESCRIBE TRANSACTION [ '<id>' ] [ OPERATION <n> ] [ FORMAT TEXT | JSON ]. The session answers it before transaction queueing: sent while a transaction is attached,
it neither becomes queued content nor consumes an operation number, and it takes no queue position,
durable admission, or execution-reference replay, so repeating it reads the transaction again. It
must be the only statement of its request, because sharing a request with an append would make it
part of that append’s durable admission. A request that pairs it with any other statement is
refused before anything in it runs. Other read-only statements remain rejected inside a transaction.
TEXT renders identity and state, progress, completeness and planning basis, the transaction-wide
quiesce level and pause scope, each operation with its execution step and reasons, and each
execution step with its planned scope, effects, actual quiescence, and outcome; naming an operation
moves that operation, its own contribution, and its step to the front. FORMAT JSON renders the
same inspection as one JSON document holding the status, the selected operation or null, and the
whole report. Every format carries the same typed envelope in the command result.
A new leader automatically resumes every COMMITTING transaction from its recorded applying step.
Completed effects are not repeated, and a failed remaining step records its statement number and
error while preserving the applied prefix. A model step that did not pause and whose VHOSTs an HTTPS
listener could not install is the one step whose failure removes its own effect: the record of that
failure also restores the schedule the step replaced, so the applied prefix ends before it. Both
the first attempt and a resuming leader apply this rule. Repeating the outstanding COMMIT joins
this execution and waits for the retained terminal result. Atomicity still does not span the whole
transaction.
Finished transactions remain as small tombstones containing the outcome, step progress, errors,
and executed quiesce levels. Their final report revision remains available for committed, failed,
reverted, and expired outcomes even after later graph changes. Removing the tombstone removes its
plan and report records and any topology content no retained report references. During retention,
attach reports the exact outcome and aggregate commit output; after removal the id is unknown.
SHOW TRANSACTIONS;
can be served by any node from locally applied replicated state and lists the id, owner, domain,
state, pending count, progress, age, and idle time for live transactions and retained tombstones.
It also runs on its own beside an attached transaction without entering its queue or changing its
session binding.
An OPEN transaction expires once it has been inactive for its idle timeout, whether or not a
session is bound to it. Attaching it, appending to it, and a commit’s admission or failure renew
that deadline; an open connection, a session binding, and reads of the transaction do not. The
deadline is a durable UTC instant, so time the cluster spends stopped counts toward it. A
COMMITTING transaction never expires. Defaults and server settings are:
| Setting | Environment variable | Default |
|---|---|---|
--transaction-idle-timeout | NERVIX_TRANSACTION_IDLE_TIMEOUT | 15m |
--transaction-tombstone-retention | NERVIX_TRANSACTION_TOMBSTONE_RETENTION | 15m |
--transaction-max-statements | NERVIX_TRANSACTION_MAX_STATEMENTS | 256 |
--transaction-max-source-bytes | NERVIX_TRANSACTION_MAX_SOURCE_BYTES | 1048576 |
--transaction-max-open | NERVIX_TRANSACTION_MAX_OPEN | 1024 |
These limits are enforced by replicated state, so every leader observes the same admission result. Transaction state changes do not force schedule publication or a runtime barrier.
Data-plane records remain outside this control-plane atomicity.
Node Health, Membership, And Scheduling
Each node publishes application-health observations independently as its probes complete. The scheduler reads the current observation snapshot and applies the configured node-unavailability policy. A peer leaves the live scheduling set only after a current sequence of application probe failures has lasted for that policy’s interval. A stale observation is unknown, and probe-capacity exhaustion remains distinct from a peer failure, so neither condition alone makes a healthy node unavailable.
Automatic scheduling runs independently of the health-probe sweep, resource downloads, and Raft learner catch-up. It does not wait for any of them to finish. Slow health responses, learner admission, and bulk progress can therefore overlap scheduling and unrelated administrative work. Scheduling uses only observations that are current when it computes and publishes a candidate; a changed topology or effective-health revision causes the candidate to be recomputed. A newer observation with the same effective health does not invalidate an otherwise current candidate.
The leader’s effective availability view also defines the required process incarnations for authoritative visibility, runtime preparation and readiness, and HTTPS listener installation. Connected followers fetch that view with the leader’s Raft term before completing a command. If the leader has retired an unreachable node, a follower’s independent healthy observation of that node does not keep its runtime barrier open. The local incarnation must still finish its own work.
The current leader owns membership changes and serializes them one at a time. Learner admission or catch-up and voting-membership changes each have a ten-second wait deadline. When that deadline expires, Nervix stops waiting and observes effective committed membership again before a later retry. It does not infer that the change was rolled back: an admitted learner may continue catching up after the timed wait ends.
An automatic schedule candidate carries the leader identity and term under which it was computed, plus the full applied control-plane revision of its inputs. Publication succeeds only during the same leader tenure and while that applied revision and the expected current domain schedule still match. Any intervening membership, cordon, domain, configuration, or schedule command therefore invalidates the candidate and makes the scheduler compute again from a coherent current view.
Placement Activation
Placement coverage is derived from the complete candidate execution graph rather than stored as a
fixed runtime-node list. During every graph activation, the control plane recomputes path-gated
rule claims, applies rank resolution, rejects equal-rank policy conflicts, and forms the effective
REQUIRE COLOCATION groups before publishing the schedule. A rejected candidate writes nothing
and leaves the prior models and schedule active.
Schedule publication preserves the existing primary and replicas of every single-owner ingestor,
client ingestors included, while all cluster nodes in that assignment are live.
WEBSOCKETS CONNECTION ingestors follow this rule along with the other client sources, so
unrelated graph changes, cluster-node joins or uncordons, and soft placement changes do not restart
their external sessions. HTTP ENDPOINT, WEBSOCKETS ENDPOINT, and SYSLOG LISTENER ingestors
are the only ingestors whose assignments follow live membership, because their listeners execute
on every cluster node.
Every relay is also scheduled with one primary owner. Ordinary relays have no replicas. Materialized relays use the same relay schedule entry: additional assigned nodes are replicas of materialized state only, not relay buffers, branch presence, fan-out, or metrics.
Hard colocation groups constrain every scheduler. A newly effective require group is consolidated
through the normal runtime-node handoff path, and failover or drain moves the group as one unit.
Soft policies affect only future placement decisions and do not relocate existing assignments.
ALTER DOMAIN SET PLACEMENT changes the active domain’s fallback through the same schedule
activation boundary. See Placement Policies for corridor coverage, precedence,
carve-outs, lifecycle commands, and introspection.
ALTER Lock And Quiesce Classification
Every model-mutation batch acquires one exclusive leader-local ALTER lock for its domain before validation. The lock remains held through candidate planning, quiescing, persistence, schedule publication, rollback when required, and resume. Validation covers every candidate binding that reaches outside the registry: domain pace, VHOST TLS material, hash-map content, inferencer tensor metadata, and WASM module compilation. A binding that fails there rejects the batch while the models it would have replaced, and the node-owned state keyed by them, are still the current ones. A concurrent mutation is rejected instead of queued. Raft still serializes the durable domain lifecycle and schedule, while the registry’s base-model comparison remains a final consistency check.
Nervix classifies the validated base-to-candidate model diff, not the spelling of the statements that produced it. The batch uses the highest level contributed by any changed entity:
Quiesce level and ingestor quiesce mode are separate contracts. DYNAMIC, ENTITY_PAUSE, and
DOMAIN_PAUSE determine which graph work must pause before a change commits. The required ON QUIESCE clause on each ingestor determines what that ingestor’s external source experiences while
an entity or domain pause is active. Memory-pressure shedding consults the same mode. There is no
operator PAUSE or RESUME statement.
DYNAMICchanges do not pause ingestion. Relay capacity; processor filters, source predicates, collection, route construction, route flush, and same-target message-error policies; deduplicator/reordererMAX TIME; emitter flush policy; placement definitions; and a VHOST moving to another version of the TLS resource it already binds are hot-applied while retaining buffered and branch-local state when ownership stays fixed. A placement definition is a dynamic model change, but its effective command level rises toENTITY_PAUSEwhen the resulting schedule moves a running runtime node. A VHOST TLS version change is applied as an HTTPS listener refresh on every live node, including for a stopped domain, and no execution node pauses or restarts. If the listener of any node cannot install the change, the batch fails and restores the previous models. TheDYNAMICTLS Refresh defines what every listener presents and when.ENTITY_PAUSEchanges gate only the affected relays on every live node, force-flush affected work, and wait for the owner buffers, fixed dispatch slots, and target-node work counters to drain before commit. Other domain traffic continues. A processor topology change then swaps only the affected node tasks and hands pending materialized-state work to their replacements. Deduplicator key changes also purge the old keyspace before the replacement starts; reorderer ordering changes flush the old ordering buffers before swapping. Relay materialized-state changes update membership in place. Emitter source-predicate, sink, publishing-mode (including any confirmation window, timeout, or retry-policy variable), client, codec, input-collection, and attachment changes drain and replace only the affected emitter task. Every ingestor alteration quiesces and drains only the affected ingestor instances under their declared source mode, then starts their desired source configuration from the published schedule. ASET QUIESCEoperation still uses this level; the mode active when the hold began governs that hold. A client ingestor suspends admission for the hold, and its producers stay attached across the swap unless the alteration changed their endpoint contract; see Client Ingestors. Reingestor alterations replace their relay consumers and branch-entrypoint wiring; generator alterations quiesce and replace their timed task after flushing pending route output. Correlator, window-processor, inferencer, and WASM-processor structural changes use this level as well. A WASM processor participates like every other stateful node: the host gates its input relays, asks the guest to release what it buffers, checkpoints it, and restores that checkpoint into the replacement instance. A change of the module a processor binds instead starts a new guest-state generation for every branch, so its replacement starts without guest state; see Resource Versions And Bindings.- A schedule change that only adjusts replica roles is
DYNAMIC. A planned primary-owner change usesENTITY_PAUSE, even when no model changed.RELOCATEis classified this way: it reportsENTITY_PAUSEwhen it moves at least one runtime node in a running domain, andDYNAMICwhen it moves nothing or the domain is stopped. Both reach every live node as narrow activations: a runtime node whose primary owner and replica set stay fixed does not stop, restart, restore a snapshot, or reopen an external session. Its in-flight batches, retainedREQUIRED WAITwork, branch instances, branch-local state, and ingestor session continue. Producers and materialized state readers rebind to a moved node at the published revision. A cluster node that gains or loses only a replica role starts or stops replication without restarting the primary. A hard colocation group moves in one narrow activation, and a model batch applies its model and schedule changes together. DOMAIN_PAUSEchanges stop ingestion and generators across the domain and fully drain attached work before commit. Relay schema or branching changes and schema or wire-schema definition changes use this level. Changing the membership of an emitter’sFROMrelay list also uses this level because it changes graph topology. Configuration entities use this level too: codec, client, endpoint, signaling-protocol, hash-map, and UDF definitions, vhost hostnames, adding or removing a vhost’s TLS, binding a vhost to another TLS resource, and branch schema, TTL, and eviction settings. Their consumers read that configuration when they are built, so the domain rebuilds around the new models rather than reconfiguring in place.
For an HTTP emitter, the method and path expressions are part of its sink definition. Replacing
them, the client reference, body selection, or retry mode takes ENTITY_PAUSE; a FLUSH-only
alteration remains DYNAMIC. The gate keeps the admitted request with its original destination
and prepared bytes until the old task drains. If it cannot drain, the mutation remains unapplied.
A transaction that drops and recreates the same emitter evaluates its complete candidate as one
model change and still drains the old emitter before replacement. Changes to a client’s own
definition use the configuration-entity DOMAIN_PAUSE above.
An entity-paused model change also gates everything downstream of the affected model, so a dependent node cannot observe a half-applied change through its input relay.
Coordinated WASM Guest-State Reset
The control plane owns one reset operation for a WASM processor. Its target is always one of three typed scopes: the explicit unbranched instance, one concrete branch, or every concrete branch. A request for an unbranched instance is rejected for a branched processor, and a concrete or all-branches request is rejected for an unbranched processor. The operation takes the domain’s exclusive alteration lock, so reset, lifecycle, model, placement, ownership-handoff, and resource rebinding decisions cannot publish competing schedules.
The coordinator engages a branch-selective gate on every live node for each input relay of the processor. A one-branch gate waits for dispatches already admitted to that branch and leaves sibling branches open; the unbranched and all-branches forms select their complete declared scope. The owner then drains accepted input and stops each selected branch task at its serialized callback boundary. Completed callbacks and their buffered output are finalized once through the ordinary output, checkpoint, and acknowledgement paths. Work still suspended on a materialized dependency is discarded with the old task and negatively acknowledged, and old timeout handles are cancelled.
Before changing durable state, the owner creates a fresh guest for every selected live branch, runs initialization without restoring saved state, and captures its initial save in memory. A failure here restores the stopped branch tasks and releases the gate; the schedule, generation, stored checkpoints, and branch lifetimes remain unchanged.
Publication is a two-phase schedule transition identified by the administrative command’s stable execution reference:
Publishingadvances the selected branch generation in the Raft-backed schedule. Every live replica installs the new state identity before the owner while the selected relay scope remains gated. This ordered local activation does not wait on the ordinary cluster runtime-revision barrier, because a failed owner checkpoint leaves that same barrier incomplete. This is the point of no return: checkpoints and replica state of the replaced generation can no longer address current state.- The owner installs the selected fresh branch tasks, durably publishes their branch lifecycle,
writes each initial guest checkpoint, and waits for every assigned replica. Only then does the
coordinator publish
Readyand release the gates.
A failure before Publishing preserves the previous lifetime. A failure after it reports that the
reset is committed but not usable and leaves the scope fenced. Repeating the same execution
reference resumes the same generation and missing durability work; it does not start another
lifetime. Restart, leadership change, and owner recovery read the published phase and follow the
same path. Offline replicas catch up under the new generation, and a stale former owner, replica, or
handoff preparation cannot reinstall bytes from the generation that was replaced. This one
operation serves every trigger: the RESET WASM PROCESSOR ... STATE statement, which a transaction
records as an ordered effect rather than a model mutation, a guest’s request for a new lifetime of
its own branch, and ON REJECTED STATE RESET. See
Coordinated Reset.
Backing Up A Domain
A normal domain backup acquires a replicated mutation lease under its persistent command
execution. The leader also takes its node-local alteration guard, pauses and drains the domain,
reads the configuration at the cut, and asks state owners to stage their checkpoints. It resumes
the domain and explicitly releases that domain’s lease before transferring its sections into the
archive. A cluster backup repeats this process per domain, so one domain’s archive transfer does
not hold another domain paused or mutation locked. A stopped domain is confirmed under its lease
and has no intake to pause;
WITHOUT STATE takes no mutation lease, and WITHOUT PAUSE reads a live checkpoint without
quiescing. A retry still uses the command execution’s stable identity and outcome.
The backup drain reads admitted work on every live node and uses a separate confirming force-flush
round after the cluster first appears quiet. A parked materialized-state wait is reported but does
not keep the cut open, and neither do the rows a window retains, which the cut captures as window
state. If work reappears, the leader drains and confirms again. Each drain request
is bound to the authenticated coordinator process; a successor cannot reuse its cut identity.
The cut captures each materialized relay’s fresh Arrow views and scalar identities under its
assignment barrier. Paused generators release their admitted-work guard after flushing, so the
drain can reach zero while their branch tasks remain paused.
Restoring A Backup
Before admission, restore verification measures the manifest and obtains a separate retained
restore_metadata reservation for archive descriptions, parsing, planning copies and native
conversion scratch. The default per-node ceiling is 2 GiB; raw resources and guest saves stay on
disk. The verified description, parsed Models and reservation share one owner that encoding jobs
retain until they finish, including cancellation. Parsing and the pure restore planner run on
admitted bulk CPU workers. Native checkpoints stream into quota-owned files and use the same
bounded installation path as guest saves. The ordinary bulk budget stays at 32 MiB, with fixed
2 MiB conversion I/O and installation jobs. Backup And Restore
owns the preparation estimate and its allocation limits.
A restore is a persistent administrative command whose progress is replicated state; the operator
view is in Backup And Restore. The leader admits a restore
under its execution reference only after the archive verified and the whole restore planned, and
admission takes the mutation lease of every domain the restore creates. The admitted execution
records the RESTORE statement, the size and digest of its archive, every step the restore has
applied, and what its users step did. The terminal result stores the typed restore report beside
the outcome, so a retry after the restore finished returns the same report.
Three consensus commands change the cluster on a restore’s behalf. Each is refused unless the execution it names is an applying restore:
- Apply restore step applies one step’s effect and records the step in the restore’s execution, in one command. The users step imports every archived user with its password hash under the user policy, and checks every user before it writes any. A domain step creates the domain stopped, with its declared resources and their version sequences, only under the restore’s lease and only where neither the domain nor a catalog of those resources exists. Creating the domain also records a durable pending installation. The resource step records completion of its imports. The model step carries the exact installation authority and releases the start gate only when the currently admitted generation has published its complete state set everywhere. A step already recorded changes nothing, and a step whose prerequisite is not recorded is refused: a cluster restore imports its users before it creates a domain, and each domain is created, then given its resource versions, then its models.
- Begin restore state installation admits a fresh generation for an applying restore after resources complete and before models complete. It requires the stopped domain and authoritative mutation lease and binds the generation to the committed entry, execution and leader tenure. Local and remote storage mutations validate this authority while holding the applied-state read guard through publication and handle clearing.
- Import resource version publishes one completed version under its archived number, as Restored Versions describes. It is refused before the domain is created, and, for a version not imported yet, once the domain’s resource step is recorded.
A domain’s models are applied as one direct model batch under the restore’s lease, with full graph
validation and the leader’s content checks, and without the statement and source-byte limits of a
transaction. The lease keeps every other command from changing the domain while the restore holds
it, so a leader that finds exactly the archived models already committed knows that an earlier
attempt applied them. It stages all compatible state before any node replaces its published set,
then publishes one durable batch per node. Only after every publication succeeds does it record
the model step and release the start gate. For RESUME, this same consensus effect sets Running
and installs the archived clock mapping, preserving the archived start generation and start point.
State installation includes materialized checkpoints bound to that generation before any branch
or generator activates. Default restoration remains stopped. Terminal failure releases mutation leases but retains
the gate, so an incomplete restored domain cannot start after failure or restart.
A step’s effect and its record commit together, and an import or a model batch that committed before its step was recorded is recognized rather than repeated, so a leader that resumes a restore never applies a step’s effect twice, whether or not an earlier leader’s last proposal committed. Only the node a client streamed the archive to holds it. Reconciliation therefore leaves an applying restore alone on a leader without its archive, and the restore resumes from its first step not recorded when a retry streams the archive to that leader. Once the retry validity of the execution reference ends without one, the leader finishes the restore as failed, and the steps it recorded stay applied.
Planned Ownership Handoffs And Failover
Node drain, the ownership move of a graceful-shutdown drain, placement consolidation, and RELOCATE
are planned ownership handoffs.
Nervix computes the complete target schedule before it engages a hold. It then fences dispatch at
the affected subgraph boundary on every live node, stops new intake for each moved ingestor, and
drains work already admitted to the moved unit. Ownership-handoff intake does not consult ON QUIESCE: an already admitted payload continues through its routes, while polling and endpoint
admission stop. The drain includes relay rings, processor work, moved-ingestor ACK roots, emitter
buffers and active publishing, and an Iceberg emitter’s staged commit. Internal relays whose every
producer moves with the same hard group remain open so admitted work can reach the group’s output
boundary.
Before publishing the ownership freeze, each participant requests a force flush and drains its local affected work with the intake and boundary gates already closed. Routes and processors can therefore finish the obligations that make their state ready to capture. A held handoff denotes that completed drain and the installed freeze; frozen owners retain their state until capture and activation finish. Preparation retains its original deadline, distinct from the gate lease that protects activation. Repeated engagement requests use the same coordination identity and scope; a bounded reply timeout does not create another flush or restart the lease.
Nervix writes the new schedule only after that drain succeeds. A drain writes and activates one hard group or independent node at a time. Placement consolidation gates all of its moved groups together and publishes its model and assignments in one schedule update. Only moved runtime nodes restart; unaffected nodes and branches keep their sessions, buffers, state, and in-flight work. The gate is released after the destination owners activate the published runtime revision. A fence or drain timeout releases the old graph without writing the candidate schedule. If activation fails after a schedule commit, the gate stays closed until its lease deadline rather than opening before the destination is ready.
Pending REQUIRED WAIT materialized records cannot finish a planned drain because the dependency is
absent. They do not block the handoff; destroying the old task negatively acknowledges their
attached work. Replicated state is available immediately when the destination was already a replica.
Otherwise the state kind starts from its normal empty or local recovery boundary. When the schedule
has a replica slot, the live former owner is the first replica candidate after the new primary.
RELOCATE differs from the others in one respect: it holds and commits its whole unit at once,
because the unit is the plan the operator inspected and approved. A hold that cannot complete
leaves the domain on its previous schedule and moves nothing. It holds the domain’s exclusive
alteration lock from planning through release, so it and a concurrent model change, placement
change, or DRAIN NODE of the same domain are mutually exclusive. RELOCATE is immediate,
non-transaction content, like CORDON, UNCORDON, DRAIN, and DROP NODE, because its plan
depends on live cluster state that a queued transaction cannot pin. DESCRIBE RELOCATION is
read-only content served by any cluster node. See
Placement Policies for the statements, the selection
forms, and the plan output.
DRAIN NODE cordons first, visits domains and schedule units in canonical order, and continues with
independent units after one times out. Its result lists every successful move and failure. Any failed
unit makes the command unsuccessful, while a later DRAIN NODE retries the units still owned by the
cordoned node. Endpoint and Syslog listeners bind on every live node and are not schedule units.
Shutdown And Recovery is the complete account of stopping a node and recovering from a forced ending. This section states how a graceful shutdown uses the planned handoff above.
A server process begins graceful shutdown when it receives its first SIGINT or SIGTERM. It
registers both signals before it starts any other work and supervises them until it exits, so a
signal received during startup takes effect once startup completes. Every later SIGINT or
SIGTERM abandons graceful shutdown: the process logs the phase it had reached and exits at once
with status 128 plus the number of the signal that forced it, without running its remaining
phases. The rest of the cluster observes that exit exactly as it observes a crash.
Graceful shutdown has one deadline, set by --shutdown-timeout (NERVIX_SHUTDOWN_TIMEOUT, default
50s) and measured on the process monotonic clock from the first stop request: the first SIGINT
or SIGTERM, or a public listener that fails. A later request never restarts or extends it, and
domain pacing never changes its physical length. Every shutdown step described below waits at most
until the deadline. When it passes, the process logs the phase it had reached, reports
shutdown deadline expired; abandoning graceful shutdown, and exits at once with status 1 without
running its remaining phases, so the rest of the cluster again observes a crash. Whichever comes
first, the deadline or a repeated signal, decides how the process exits.
When graceful shutdown begins, the process advertises that its current incarnation is terminating. The incarnation remains live for Raft and for ownership handoffs already in progress, while placement and explicit relocation exclude it as a new destination. The advertisement is transient state of that process incarnation, so a restarted incarnation is eligible again unless the stable node name is cordoned in Raft. The process then stops accepting on its public gRPC, connector, observability, and console listeners and closes the client connections they had accepted. Closing a connection cancels the requests it carries, such as a session stream or a resource upload that is waiting for its client, so no client can delay the drain or the process exit. Work that a session command had already started, such as a transaction commit, keeps running, and terminal teardown waits for it until the shutdown deadline.
Graceful shutdown records whether that stable node name was already cordoned before it invokes the drain. Its cleanup clears the drain cordon only when shutdown began with an uncordoned node, and it runs after a successful, failed, timed-out, or unanswered drain attempt. A pre-existing operator cordon therefore remains set across shutdown and restart. When the drain timeout or the shutdown deadline passes before the node observes a leader to request its drain from, nothing was cordoned and no cleanup runs. The cleanup runs beside the completion of admitted work in place; Releasing The Drain Cordon owns its bound and what a cleanup that cannot complete leaves behind. A follower requests both the drain and the cleanup from the leader over the cluster interconnect, authenticated by its node certificate rather than by a user credential; see Topology Cases.
A graceful-shutdown drain has two parts that share one drain timeout, and the drain also ends when
the shutdown deadline passes first. When another live, schedulable Raft voter exists, the node
first moves its scheduled work there through the planned handoff above. It then completes the work
it has already admitted in place. That second part is the whole drain when no replacement exists,
such as on a single node or on the last schedulable node of a cluster, and it also covers listener
ingestors, which bind on every node, and any unit whose move failed. The
terminating node stops new intake on all of its ingestors, whatever their ON QUIESCE mode, and its
generators stop producing. Work already admitted keeps flowing while the node’s relays, processors,
emitters, and acknowledgement paths stay alive: Nervix repeatedly force-flushes ingestor routes,
processor collections and route buffers, message-error routes, reingestors, and emitters, whatever
their FLUSH EACH cadence, until no relay batch, node work item, emitter buffer or publish, or
admitted acknowledgement root remains. One more force flush then confirms that nothing is still
moving, so work an upstream flush publishes after a downstream node finished its own flush is not
left behind.
Source acknowledgements and commits, such as Kafka consumer-group offsets, complete before the source
session stops.
Terminal teardown stops the node’s tasks only after that drain completes or its timeout passes. It
gives each background task at most two seconds to stop, waits for work that sessions and commands
started until the shutdown deadline and cancels whatever still runs then, and finally stops the
runtime, consensus, cluster membership, and the interconnect and releases the node’s storage. A
timeout reports the drain as abandoned, and teardown negatively acknowledges the remaining work: a
source with external acknowledgements redelivers it after restart, and a MODE NO ACK source loses
it. A sink that stays unavailable therefore holds the drain until its timeout. Pending
REQUIRED WAIT records do not hold the drain open; every force flush retries them against the
state that is present, and teardown negatively acknowledges whatever still waits. Payloads that
other live nodes publish into this node’s relays are admitted work as well, so an owner that keeps
publishing extends the drain until its timeout. When every node terminates at once, each node
completes its own admitted work within its own drain timeout, and work that reaches a peer after
that peer finished its drain is negatively acknowledged.
DROP NODE records the stopped process incarnation before removing its Raft membership. It reads
the newest identity from both live gossip and the failure detector’s dead process identities, so a
node can still be removed after Chitchat stops reporting it as live. Delayed gossip cannot admit
that process again. Starting the node again creates a newer incarnation, which can join the cluster
normally.
Unexpected owner loss remains a termination and uses the failover path. The failed task and its volatile buffers disappear immediately, attached work is negatively acknowledged, and the scheduler promotes a live replica or chooses a fresh owner. Failover does not wait for the planned handoff gate. If a former owner disappears while a planned hold is active, that hold aborts without publishing its candidate; ordinary failover then relocates from the last committed schedule.
Failover acts on a voter that gossip no longer reports live. A leader whose reconciliation has just started, as the first node to lead after a whole cluster restarts does, can reach a quorum before gossip has observed the other voters live. Each process’s reconciliation task retains its live observations, including those made while following. For its first ten seconds, it makes no automatic scheduling decision while any current voter has never been observed live by that task. Chitchat can classify a voter’s first relayed heartbeat as dead because it has too few heartbeat intervals to estimate liveness; that initial verdict does not satisfy the live-observation requirement.
A voter that returns within the grace keeps its work and the state that node holds. A voter already observed live can undergo ordinary failover if it subsequently becomes unavailable once every current voter has been observed. The grace expires after ten seconds even if a voter never returns, so genuine owner loss still makes progress. Leadership changes retain the process’s observations and its original deadline. A process restart starts a fresh observation history; after the grace the task discards that history.
Forced recovery stages the destination’s checkpoint inventory under the destination process incarnation and the complete target-schedule fingerprint. Applying staged checkpoints accepts only that exact preparation. A missing or mismatched preparation does not imply a reset; without an exhaustive reset outcome in the schedule, activation fails and leaves every saved checkpoint unchanged. After activation, the durable completion belongs to the ownership transition itself, so reapplying its retained schedule after a destination restart or a later domain rebuild preserves any newer checkpoints the destination has published. A state reset occurs only when the accepted recovery decision either stages the recreated checkpoint inventory or reports a reset outcome for every state component owned by the entity.
A forced recovery of a WASM processor also starts a new guest-state generation for every branch, in the schedule publication that names the new owner, and that schedule is the one the recovery preparation is fingerprinted against. The destination stages the checkpoints of the generation being replaced and activation publishes them in the new generation. A snapshot of any earlier generation, whether it is held by the lost owner, a replica that was offline, or an older preparation, is never selected, installed, or restored again, even when its revision is higher than every current one. A planned handoff keeps the generation. Generation transitions are published only by a committed schedule, so they are serialized with every other mutation of the domain through the same lease or automatic-decision fence as the schedule itself.
For a planned handoff, each prepare destination becomes a tracked participant before the side-effecting request is sent. A lost response and cancellation of the coordinating future are therefore cleaned up like acknowledged preparations. Cleanup retries an exact discard and never uses gate release as evidence that the durable preparation was removed.
The surviving leader reconciles preparations after it orders all inherited schedule proposals with a committed consensus barrier. A destination preserves a preparation when its exact transition is the committed owner change, regardless of coordinator or participant restart, so normal schedule application can activate it. It also temporarily preserves uncommitted work from the same leader process while the bound source and destination incarnations remain live, the base schedule still owns the entity on the source, and the exact handoff gate remains held. It durably removes every other preparation. Replacement, discard, activation, and reconciliation compare the complete operation identity, so duplicate or reordered work is idempotent and cannot affect another operation’s preparation.
Entity-gate leases are deadline-bound. They release their relay fences and ingestor holds at the configured entity-gate deadline even if the coordinator disappears. A node that joins during a hold applies the published schedule through its normal revision path.
An unchanged candidate contributes no aspect. An all-no-op batch therefore performs no storage
write or schedule publication and reports DYNAMIC, even when the running domain has work that
could not currently drain. A DROP followed by CREATE of the same key in one batch is compared as
one modification, so recreating a relay with a different schema cannot bypass domain quiescing.
An immediate model command reports the level it executed. A queued model command reports its own
preflighted model level before execution. COMMIT reports the maximum effective level actually
executed and the total planned relocations when the transaction moved owners. Nervix recalculates
the effective level and target schedule from the complete candidate at commit time.
For an immediate model alteration, local registry persistence and schedule publication are separate steps. If schedule publication fails, Nervix restores the previous models and republishes the previous schedule at every quiesce level; a domain-paused batch additionally resumes the domain. A model batch that creates, changes, or drops a VHOST also waits until the HTTPS listener of every live node has installed the resulting certificates, and fails with the node and reason when one cannot. A batch that did not pause is then rolled back, and the command waits until every listener presents the restored certificates before it reports the failure. A paused batch has already resumed by then, so it keeps its committed models like any other failure after activation. During a replicated transaction commit, the new schedule and transaction progress become visible in one Raft operation. The leader rolls back an unpublished local registry candidate, and every node synchronizes its registry cache from the committed schedule across leadership changes.
What it does not do is provide transactional semantics for the actual records flowing through the graph. Message batches and ACK state are data-plane hot-path state and are never persisted by the control plane.
Execution Plans
An execution plan is the in-memory boundary between a committed domain schedule and the tasks that run it. NSPL becomes a semantic Model, registry validation establishes its contracts, and scheduling commits the graph and its placement. The decision layer then converts the committed schedule into one complete typed execution revision. The runtime binds node-local resources and executes that revision. No production data-plane task reads a semantic Model, a scheduled node’s Model configuration, or the active registry graph.
This chapter follows that boundary through construction, installation, changes, and recovery. Control Plane owns schedule publication and transaction quiescence; Data Plane owns record movement and state. The chapters linked below own the detailed contracts of their respective engines and integrations.
Ownership And Persistence
| Layer | Owns | Passes to the next layer |
|---|---|---|
| Language and vocabulary | Structured semantic Models, typed names, branch declarations, resource versions, schedules, and state identities | A Model whose expressions are structured rather than executable text |
| Registry and decisions | Reference, type, sensitivity, capability, branch, and placement validation; pure schedule classification; construction of typed node and domain plans | A complete execution revision and a typed change decision |
| Control plane | Committing models, domain state and schedules through consensus; ordering activation, quiescence, ownership handoff, and local installation | The committed schedule and domain state to the planner; the resulting revision to the runtime |
| Engines and infrastructure | VM lowering and compilation, schema and codec engines, connector contracts, resource storage, WASM preparation, and state storage | Prepared programs or local resources when the runtime binds a plan |
| Data plane | Binding a revision to local capabilities, publishing routing state, starting and refreshing tasks, branch-local execution, and ACK tracking | Arrow batches and external source or sink operations |
Models, domain lifecycle, and schedules are strongly persisted control-plane state. Execution revisions and their compiled programs are reconstructed in memory from the committed schedule; they are never persisted as a second graph definition. Selected node state has its own snapshot, replication, or checkpoint contract. In-flight batches, payload attempts, ACK guards, tokens, and maps remain volatile. Typed States And Validation Boundaries explains why absence, identity, and conversion failures are decided at their owning boundary.
From A Schedule To One Revision
The application reads a coherent committed cluster state. Its planning boundary compares the
new cluster schedule with the last schedule successfully applied on this node. For every domain
in the desired schedule, it builds a complete ExecutionRevision before asking the runtime to
apply anything. The revision holds:
- the domain and one source digest derived from the complete scheduled domain;
- typed nodes with identity, schema fingerprint, resolved branch contract, placement, ownership transition, gate relays, and applicable state generations;
- activation, resource, entrypoint, emitter, processor, and message-error plans from that same schedule; and
- the ownership-handoff fingerprint computed from the committed schedule’s established bytes.
The source digest identifies which schedule produced the installed revision. A planned change also carries its predecessor digest, so an incremental application is attempted only when the currently installed revision is the predecessor it was planned against. The handoff fingerprint has a separate purpose: ownership preparation and activation must agree on the exact committed schedule, including its assignments and state generations. Planning preserves its established encoding and does not substitute the source digest for it.
Registry validation rejects invalid candidate Models before they become an active graph. Execution-revision construction resolves references and lowers the remaining schedule-specific contracts before local installation. A missing relay, schema, codec, branch, source, client, or error destination fails at that planning boundary with the owning node or route identified. The runtime never repairs a missing decision with a Model lookup or an implicit default. Only a committed schedule supplies the placement and state authority used for installed execution.
Process startup has an earlier registry path: RuntimeChanges carries each recovered active graph
inside the registry and application boundary. Before runtime application, it projects that graph
into an unplaced typed revision; the runtime receives only the revision, never the graph or its
Models. This prepares the local runtime boundary before consensus admission. The subsequently
admitted committed cluster state supplies the schedule, placement, and ownership authority for
normal running or passive execution. The cluster-state listener and local WASM-reset application
both use the same schedule-to-revision planning boundary.
Typed contents
| Revision part | Decision made before installation | Node-local binding or execution |
|---|---|---|
| Domain activation | Compiled schemas; wire formats and codecs; relay schema, branch retention, capacity, and materialized-state presence; VHOST, endpoint, and signaling references | Load pinned descriptors, compile codecs and signaling protocols, install relay services and endpoint routes |
| Entrypoints | Ingestor source, client, codec, ACK and quiesce contracts; ordered routes, filters, branch construction, and reingestor input edges | Bind VM programs and connector resources; open sources and attach relay consumers only after preparation succeeds |
| Processor specifications | Node topology, exact branch policy, ordered outputs, materialized dependencies, and schedule residue that affects a revision | Bind prepared VM, window, inferencer, and WASM artifacts into a published processor plan; instantiate one task per concrete branch |
| Resources | Pinned lookup file and codec, UDF program, generator source and ordered routes, WASM module file and scheduled assignment | Load the named local version, build lookup indexes, prepare modules, and start branch-local generator or processor work |
| Emitters | Typed sink and optional connector client or codec, ordered source relays and predicates, construction, request and ordering expressions, flush, batching, ACK, and error policies | Resolve local mounts and schemas, bind programs, register local or remote consumers, and open the sink |
| Message errors | Source and optional partial-output schemas, target relay and branch, route flush contract, and ordered SET program | Bind once for the installed revision; execute it for failed records using their captured state |
The tables describe distinct decisions in one revision, not independently selectable runtime configurations. Every node, including one placed elsewhere, is planned from the same domain schedule. This lets a local owner, its replicas, a remote relay consumer, and a future owner agree on the same route and state identities.
Expression lowering produces engine-level programs in the decision layer. Node installation compiles those programs against the exact installed schemas, sensitivity, lookups, and UDFs and retains the prepared result. Validation may compile an expression earlier to reject an invalid statement; that validation result is not a persisted executable program. Branch tasks share their installed processor plan and its prepared artifacts. A newly appearing branch and an existing branch refreshing between batches take the same published revision. VM Functions owns compilation lifetime, selected-row execution, and VM failure behavior.
Installation And Publication
Application serializes local installations. It plans against the last successfully applied schedule, then passes the typed cluster revision, domain lifecycle state, and clock authorities to the runtime. The runtime records a cluster revision as applied only after its schedule application succeeds. An unsuccessful attempt leaves that revision unapplied and keeps the same predecessor for a retry, including after a local WASM reset operation. A node starting after a forced ending first establishes its committed-state catch-up fence before installing execution; an earlier owner’s local state cannot make it act under an obsolete assignment.
A full running build binds the domain clock, pinned resources, codecs, relay boundaries, state
placements, processor and error plans, connectors, and node tasks from the revision. Its
DomainExecution retains the revision and node-local task ownership. The domain routing snapshot
holds the coherent relay services, schemas, branch declarations, materialized-state ownership,
lookups, UDFs, codecs, signaling protocols, and processor plans that record and batch paths need.
Lifecycle code stages changes privately and replaces the published snapshot with one pointer
operation. A reader sees one complete routing snapshot; long-lived tasks cache its handle and
refresh their typed plan identity between batches. The endpoint routing index and other lifecycle
resources are installed at their own cutover points under the application sequence. This is a
node-local publication guarantee, not a distributed atomic transaction across cluster nodes.
The routing snapshot also retains each materialized relay’s installed-state publication and the domain’s relay-state epoch. Assignments are registered before these handles are staged; a missing required publication is an installation invariant failure. A materialized origination capability moves once into its concrete branch runtime or scheduled relay-state task. That task owns the mutable selections, while dependency readers, generators and captures retain immutable row views. Lazy branch installation binds every materialized relay independently within an unchanged routing epoch; having installed one relay does not suppress another. Eviction withdraws the branch’s materialized record before releasing its writer. Read and replica-installation capabilities remain independently retainable.
A stopped domain uses a passive build from the same activation and resource decisions. It keeps schemas, codecs, relay descriptions, materialized-state identities, lookups, bound error routes, and endpoint definitions available for inspection and recovery, while routing is marked passive and its graph intake and node execution tasks do not run. Configured server listeners remain bound on every live node regardless of domain status, leadership, or placement; a stopped domain does not admit its graph’s traffic. Resuming or changing the domain start version builds the appropriate running revision rather than treating passive state as an active task graph.
Schedule planning assigns endpoint and Syslog sources from live voter membership, independently
of the eligible placement set. A cordoned restore coordinator therefore still serves its local
endpoint intake after RESUME, while placed relays and processors execute on eligible nodes.
Removing a voter recomputes listener assignments from the remaining live voters before publication.
WASM ownership preparation follows the installed revision’s execution mode. A passive revision
retains and transfers the validated checkpoint inventory without instantiating guests or reading
its stopped clock. Guest restoration is deferred until START installs a running revision.
Server endpoint installation replaces that domain’s complete set of host-and-path definitions in the node’s immutable intake table. Source starts bind prepared intake lifetimes to the same table; source ending and domain teardown end those lifetimes before withdrawing them. Other domains’ definitions and intakes survive the replacement. Passive execution keeps endpoint definitions for inspection but publishes none for request routing. HTTP resolves one route per request and a WebSocket retains the route and signaling protocol selected at upgrade. The detailed admission and publication contract remains in Data-Plane Concurrency.
Data-Plane Concurrency owns the publication and branch refresh protocols. Domain Clock owns the execution-time capability bound during installation. Client Session Protocol owns how a client observes an installed domain and recovers a command outcome.
Incremental Changes And Rebuilds
The decision layer compares the previous and desired schedules while both Model-bearing
schedules are still available there. It translates the result into an ExecutionDelta that
contains only typed runtime instructions:
| Change | Runtime application |
|---|---|
| Unchanged | Keep the installed tasks and state when the predecessor, lifecycle, and start version still match. |
| Dynamic | Apply the typed capacity, processor, WASM reset, emitter flush, or VHOST TLS update; bind and publish the desired processor and error plans. |
| Entity swap | Fence affected input relays, force-flush as required, rebind reassigned placement and selected node tasks, then publish the desired revision and release the gates. Unaffected nodes and their branch state stay in place. |
| Rebuild | Stop the preceding domain execution and install the complete desired running or passive revision. Domain-wide changes, a missing predecessor, and lifecycle changes can require this path. |
The schedule classifier retains Model-based change-aspect and quiescence decisions in the registry. The runtime receives typed dynamic values, affected node identities, state purges, and gate relays. It does not reclassify the new schedule. An entity swap that fails locally falls back to a full rebuild of the desired revision; the actual wider engagement is reported as a recovery expansion. If that rebuild also fails, application fails and the committed revision remains eligible for retry. A plan is complete even for a narrow change, so fallback uses the same desired revision rather than reconstructing one from live tasks.
The swap sequence fences delivery before stopping an owner, prepares new local placement and programs, replaces relay owner and remote-consumer edges, and publishes the routing snapshot before waking materialized-state waiters. An owner that still executes locally across a placement change keeps its task; a moved owner rebinds only its affected runtime and state placement.
The affected relay gates remain closed through publication. An already buffered owner batch takes a nonwaiting permit before fan-out: it finishes under the prior consumer set if admitted before the fence, or fails its record ACKs so its source retries under the published consumer set.
Dynamic processor revisions reuse unchanged prepared plans, while a changed node gets a new typed plan identity. Transaction Quiescence And Impact Inspection owns planned versus actual pause scope; Control Plane owns schedule publication and relocation.
Sources, Sinks, And Error Routes
An ingestor start uses the scheduled source class, client configuration, codec, ACK boundary, and bound ordered routes from its entrypoint plan. A reingestor binds its source predicates and incoming-branch materialized dependencies before attaching relay inputs. Initial build, entity swap, reassignment, memory-pressure resume, and Kafka domain-offset placement all use those typed contracts. Route plans decide whether output is unbranched, preserves an incoming key, or constructs a new key; the actual branch state remains local to each concrete branch. Transport headers remain connector-owned and enter a relay only when copied into schema-backed fields.
The emitter plan pairs its ordered relay inputs with one typed sink and its applicable client,
codec, expression, flush, batching, and error contracts. A native TO CLIENT sink has no
external client or codec. Its plan carries the exact output schema, acknowledging sequential or
parallel window, physical ACK timeout and retry policy, maximum rows and IPC bytes, construction
program, and attachment boundary. Registry validation rejects an incompatible schema or missing
batch contract before the plan can run. A flush-only dynamic change keeps that delivery contract;
a contract change replaces the endpoint and ends existing consumers. The host binds mounts and
programs before opening a sink; reconnects and retries reuse the typed configuration and retained
prepared write. Swaps install consumer edges from the new plan. The connector receives source or
sink settings and per-record outcomes, never graph placement authority, Models, ACK maps, or
runtime state. Connector Crates And The Connector Contract owns each
external transport’s header, retry, ACK, and commit behavior. Client Session Protocol
owns native consumer delivery and settlement.
An error route is keyed by its domain, owning node, optional source route, and destination relay. The planner resolves the eligible original input, the optional all-nullable partial output, captured materialized-state shape, destination schema and exact branch, flush policy, and ordered assignments. Installation binds its program against local capabilities once. A failed record uses that bound plan with a structured, non-sensitive error reference, code, operation, fields, and timestamp. A handler failure cannot invoke the same handler recursively; an unavailable route is reported and does not acknowledge the source record. A route change replaces its bound plan with the installed revision. Buffered plans retain their prepared delivery queue and exact worker handle. Binding starts no worker and retires no active route. Successful running publication activates each prepared worker once, retires unselected/replaced workers and publishes the complete routing snapshot. Failed and passive builds cannot replace running delivery. Subsequent failed records send through the bound handle, selecting retirement against bounded queue admission; accepted deliveries drain through the worker that already owns them. Errors And Diagnostics owns the error taxonomy and public reporting contract.
Placement, Resources, And Recovery
Each typed node carries the scheduled primary, assigned nodes, branch and schema fingerprint, ownership transition, and the state components it may own. State placement and restoration use that identity, including a WASM processor’s branch generations. Relays have one owner; only their optional materialized state has scheduler-selected replicas. A configured listener is different: it runs on every live server node even when its graph node is not the local primary. Remote relay-consumer edges are derived from the same planned nodes and inputs as local tasks.
Planned handoff prepares state against the exact committed-schedule fingerprint. Reassignment then activates prepared state under the desired plan, fences the previous owner, and starts or retains only the tasks that should execute here. A crash or disconnected owner follows the control plane’s failover and state-recovery rules; in-flight batches and ACKs do not survive it. Restart reconstructs the plan from committed control-plane state and loads only state whose published identity and generation match the desired placement. A processor task restores its branches from its own plan: the plan carries the schema a window processor’s retained rows are read under, so the restore never reads the domain’s routing snapshot, which installation publishes only after it starts the tasks. The restore installs every branch its lifecycle names or none, and the task takes no input until it has; Restoring Processor Branches owns its retry and its failure semantics. A stopped domain reconstructs passive surfaces; a running or paused domain reconstructs the applicable execution and intake state. Shutdown And Recovery and WASM State And Recovery own the detailed handoff, checkpoint, duplicate-window, and restart guarantees.
An archive restore first publishes the restored models as a stopped domain and recomputes its
schedule. Archived node assignments are never installation authority. The coordinator requires
each archived entity’s schema fingerprint to match the new schedule and stages its checkpoints on
the scheduled owner and replicas. Branch lifecycle is staged before WASM guest saves, and each
save uses the state generation from the published schedule. Each node atomically publishes its
complete replacement set through a small durable pointer to its verified generation namespace,
including an empty set on unassigned nodes, then clears passive handles. Restart and passive-state
loading select pointer, headers and checkpoint chunks from one database view. Checkpoint jobs
queued for a different namespace cannot cross into the new installation.
The replicated installation gate prevents START until all nodes finish. A retry of an unfinished
model step admits a new installation generation bound to its leader tenure, execution and lease;
a delayed preceding attempt cannot mutate the state or its handles after completion.
Materialized restore resolves the scheduled Arrow schema once, verifies bounded archive groups,
and installs the native checkpoint under the scheduled schema fingerprint combined with the
archived domain start generation. Native loading retains one database snapshot while reading
bounded sections. A RESUME completion changes the domain to running at that same generation
and mapping only after publication and passive-handle clearing on every target node. The new
running revision therefore binds generators and branch tasks to installed materialized rows.
Backup And Restore owns archive cut modes, state selection, skipped
sections, and the operator-visible restore guarantees.
Resource plans carry concrete pinned version numbers from the committed Models. Node binding
loads exactly those versions from its local resource store; it never resolves LATEST or reads
the catalog during execution. A rebinding is a validated model mutation that produces another
schedule and plan. Resource Versions And Bindings owns upload completion,
pinning, rebinding, TLS refresh, and load failures.
Failures, Guarantees, And Limits
Validation and schedule planning fail before an invalid candidate activates. A typed planning
report identifies the domain and failing part of the revision, with node, route, or reference
detail beneath it. Local binding can still fail when a pinned file is unavailable, a VM program
cannot compile against installed capabilities, a declared route cadence cannot be parsed for its
task, a module cannot prepare, or a connector cannot open. Materialized-state dependencies also
bind to the local relay state at this boundary. A local failure to build or change a domain’s
execution is reported as RuntimeError::BuildDomainExecution, naming the domain, with the
ExecutionBuildError step that failed and that step’s own report beneath it, as
Errors And Diagnostics describes.
The runtime records the whole chain as the domain’s instantiation error, and an ingestor’s start
report as its transient error. It does not mark a failed cluster revision applied. Source and sink
delivery failures retain their existing ACK,
retry, and message-error classification. Diagnostics identify operations and fields without
including sensitive payload values.
The principal guarantees are one current plan shape, one complete typed revision per committed domain schedule, no Model interpretation by production data-plane tasks, coherent node-local routing publication, exact branch and schema identities, and retries of unsuccessful revision applications. These do not make all cluster nodes switch simultaneously, persist in-flight messages, guarantee an external source or sink is available, or turn a connector’s delivery contract into exactly-once end-to-end delivery. Engine-specific resource, batch, expression, and concurrency limits remain in their owning chapters.
Operators observe the committed schedule, domain lifecycle, applied node state, and typed diagnostics through the existing control-plane and session surfaces. Runtime metrics and events report node or connector failures without exposing payloads. A recovery expansion reports when local entity replacement needed a wider rebuild. The execution plan itself is an internal, reconstructible artifact, so it is not an independently persisted or user-editable graph.
Task Dependency Binding
Installation publishes bound message-error plans inside the domain routing snapshot. A failed record retains that snapshot while executing its prepared route. Entity state identity and checkpoint primary/executors/replicas publish through one stable assignment slot before reset callbacks; WASM states retain that slot across schedule replacements. Task startup also binds connector status, accounting, metric, freeze and domain-clock dependencies. Their runtime registries serve registration, teardown and observers; recurring operations use the retained capabilities. See Data-Plane Concurrency.
Replication Routing
State construction publishes resolved replication routes to the installed state and that existing assignment slot. Schedule replacement, branch eviction and entity or domain teardown retire those routes before removing registry entries. Frame handlers read the immutable route publication; announcers and entity catch-up tasks retain their route or lifecycle and never revisit execution or identity registries. See Data-Plane Concurrency.
Transaction Quiescence And Impact Inspection
Nervix plans the effect of a transaction before it changes the running graph. One typed impact report explains what each accepted operation contributes, what each atomic execution step requires, and what the transaction requires as a whole. The same report follows the transaction through commit, application, failure, and retained inspection. A planned pause and a pause actually engaged are separate facts.
This chapter owns impact planning, scoped quiescence, actual engagement reporting, inspection, and retention. Control Plane owns the broader transaction lifecycle and registry mutation rules; Command Completion owns when a command is usable and acknowledged. Domain Clock, Cluster Interconnect, Consensus Storage And Replication, and Shutdown And Recovery own their respective time, transport, durability, and process recovery boundaries.
From Accepted Operations To Execution Steps
BEGIN opens one transaction for an existing selected domain. Every queued statement must belong
to that domain. A successful append receives a stable, one-based operation number in written order
and advances the accepted position. Transaction controls, inspection, and rejected admission consume
no number. An exact retry of an admitted append returns its original number and identified preview;
it does not append again or extend transaction activity. The original source is retained for
display, while execution uses the structured statement.
The planner and executor partition that ordered sequence the same way:
| Content | Execution boundary |
|---|---|
Consecutive model CREATE, supported ALTER, DROP, and REBIND RESOURCE operations | One atomic model run, evaluated in written order against one candidate graph. |
ALTER DOMAIN, START, STOP, CREATE RESOURCE, or RESET WASM PROCESSOR ... STATE | One step each; each ends a preceding model run. |
A model run’s configuration and schedule are published in one authoritative step. Its base-to-final
diff determines the effective impact: an intermediate change can be cancelled, a drop followed by
recreation can change the schema or wiring, and several individually valid operations can jointly
require a wider pause. The run is revalidated as a complete graph before publication. A failure
before that publication writes none of its candidate models. Other steps retain their own atomic
effect boundaries; the transaction as a whole is not atomic. Commit stops at the first
definitive failure and retains its applied prefix. A step can have an authoritative effect while
runtime activation is still APPLYING; completion is recorded separately.
Queue admission replays the accepted prefix in this exact order. An unfinished final model run
may be admitted with an INCOMPLETE candidate and planning diagnostics when a later operation in
that same run can make it valid. An intervening lifecycle, domain, resource-catalog, or reset step
ends the run, so a later run cannot repair the earlier one. An incomplete candidate is never a
complete empty scope, and COMMIT requires every step to plan completely.
Planning captures the restored domain’s installation gate with its authoritative control inputs.
An incomplete restore refuses a queued or standalone START before lifecycle admission, naming
the restore execution responsible for the gate. Consensus revalidates the gate at activation;
releasing a failed restore’s mutation lease does not make that domain startable.
stateDiagram-v2
[*] --> OPEN: BEGIN for one domain
OPEN --> OPEN: admit numbered operation / inspect coherent candidate
OPEN --> OPEN: reject incomplete or stale commit / no effect
OPEN --> COMMITTING: validate complete identified preview / freeze plan
COMMITTING --> COMMITTING: record authoritative effect, then application completion per step
COMMITTING --> COMMITTED: every step completed and terminal result visible
COMMITTING --> FAILED: first definitive failure / retain applied prefix
OPEN --> REVERTED: REVERT or clean session close
OPEN --> EXPIRED: unbound idle timeout
COMMITTED --> [*]: tombstone retention expires
FAILED --> [*]: tombstone retention expires
REVERTED --> [*]: tombstone retention expires
EXPIRED --> [*]: tombstone retention expires
Three Levels Of Impact
An operation contribution records its typed operation, ordered reasons, and effects attributable
to that statement. It has no independent effective quiesce level or execution outcome. In
particular, the ordered clauses of one ALTER remain ordered reasons, while shared affected items
can name several contributing operation numbers.
An execution step records its inclusive operation range, completeness, required pause, planned
effects, actual engagement history, actual effects, and UNATTEMPTED, APPLYING, APPLIED, or
FAILED outcome. A range’s last operation never comes before its first: a stored or transmitted
range that puts it there is refused as it is read, with OperationRangeReversed. The effective requirement comes from the whole model run or the single
non-model step, including schedule changes. The transaction summary combines the effective
requirements of all steps: no pause contributes nothing, entity subgraphs combine their nodes and
gates, and any domain pause makes the summary domain-wide. This is the planned maximum. A commit
result reports the highest level actually engaged by executed steps, which can be lower after a
pre-engagement failure or higher after recorded recovery expansion.
| Required level | Typed scope | What it requires before the step’s effect |
|---|---|---|
DYNAMIC | NO_PAUSE | No execution gate or domain drain. Configuration, lifecycle, listener, or activation work may still be required. |
ENTITY_PAUSE | One named subgraph, its concrete branch coverage, and admission relay gates | Hold new admission at those boundaries and drain the affected work while unrelated graph traffic continues. |
DOMAIN_PAUSE | The owning domain | Stop domain intake and generators and drain all attached execution work before publication. |
The pause requirement is a typed NO_PAUSE, SUBGRAPH, or DOMAIN value. The displayed level is
derived from that value, so a domain level cannot carry an entity-only scope. An entity gate is
installed on every live node for the affected relay and branch coverage. The planner includes
downstream consumers and every member of an affected hard placement group. Engaging any entity
gate also requests a force flush of the full current execution graph; force-flushed work is not
the set of paused entities. A domain pause covers all execution nodes on both sides of the
change. Data Plane explains the runtime work these gates protect;
Control Plane lists the model changes
that normally contribute each level. An ingestor’s ON QUIESCE mode controls its external-source
behavior under a hold; it is distinct from the step’s quiesce level.
A client ingestor under a hold suspends admission: its producers
are told admission is suspended, batches that were queued but not admitted are refused as
suspended, and batches that arrive during the hold are refused the same way. The drain waits for
the acknowledgement roots of the batches admitted before the hold, which the admission fence
guarantees it counts: a batch either has its root tracked before the hold is observed, or is refused
with nothing dispatched. Whether producers survive the step is decided by the ingestor’s endpoint
contract, not by the quiesce level: an alteration that keeps the contract reopens admission with
every producer attached once the step is applied, one that changes it ends them as
endpoint changed, and a failed drain releases the hold and reopens admission with the previous
execution and every producer still attached.
When a model alteration pauses an ingestor and gates a shared downstream relay, the coordinator first engages an ingestor-only admission hold on every node. It force-flushes the current graph and waits for those ingestors’ route work and admitted ACK roots while the relay gates remain open. A partial branch batch can therefore reach its relay and acknowledging sink. The full subgraph hold is engaged before the intake hold is released, so admission never reopens between the two phases. Both phases share the alteration’s physical deadline and retain separate exact coordination identities. Failure releases the attempted holds and keeps the committed contract; dropping the coordinator leaves their existing owned release and lease cleanup responsible. For a planned subgraph pause, transaction inspection records the intake-only scope separately from the full subgraph scope, with each scope’s actual engagement, drain failure, and release outcomes. An already engaged domain pause covers its ingestors throughout these phases.
A native client emitter keeps its prepared IPC bytes, source members and application ACK wait as emitter work. Force flush releases rows into that same wait; a read or network delivery does not finish the drain. A consumer settlement runs beside the session’s ordered command lane, so an engaged transaction can receive the ACK that allows its own drain to finish. A flush-only ALTER changes only the running task’s cadence. A contract-changing replacement or failed drain follows the same retained-work and rollback boundaries as other emitters: the previous execution remains the owner until the change commits, and a canceled attempt cannot resolve a new owner’s ACK.
The report keeps separate effect sets for configuration creation/change/drop, resource catalog and
version bindings, domain lifecycle, ownership moves, activations and deactivations, rebuilds,
state resets, force flushes, and affected topology. An HTTPS VHOST version refresh can therefore
be DYNAMIC while every live node still installs a new listener certificate. A running-domain
primary move raises an otherwise dynamic model or placement change to ENTITY_PAUSE; replica-role
changes alone do not. A schedule rebuild can raise a narrower model change to DOMAIN_PAUSE. In
a stopped domain, a model or resource rebinding has no running work to pause and reports
DYNAMIC, although the changed configuration still matters at the next start. Explicit START
and STOP are individual NO_PAUSE steps with lifecycle effects; they determine whether later
model steps plan against a running or stopped domain. Resource Versions And Bindings
owns what each pinned resource version loads and which rebindings reset state.
An automatic schedule change that moves work away from a node the leader has marked unavailable retains its planned and actual impact. Completion waits for the leader’s current required runtime participants, including the connected local node, and does not wait for preparation by that unavailable node. If the node rejoins, its later catch-up is a separate application of the current revision; it does not change the committed step’s impact report.
Concrete joint and mixed-scope examples
| Accepted sequence in one domain | Effective plan and interpretation |
|---|---|
| On a running graph, change a relay’s capacity, then add an optional field to its schema in the same consecutive model run. | Capacity alone is dynamic, but the run changes a schema. Its single step is DOMAIN_PAUSE, with both operations attributed to the affected result. |
| Add an optional schema field, then drop that field in the same model run. | The base and final graph match. The run is a DYNAMIC no-op even though the operations keep their individual contributions and ordered reasons. |
| Change the keys of two deduplicators on independent relay paths in one run. | Both contribute ENTITY_PAUSE; the step’s subgraph is the union of the two path scopes. If both paths share an admission relay, that gate appears once with both operation numbers. Unrelated paths stay outside the paused subgraph. |
| Reset one concrete branch of a WASM processor, then change an unrelated deduplicator key across its declared branch. | These are separate ENTITY_PAUSE steps. The reset retains selected-key coverage while the model step covers every key of its own branch; the transaction summary unions their two subgraphs without broadening the reset to all branches. |
STOP, change a schema, START, then change that schema again. | Four boundaries: stop, a stopped-domain dynamic model step, start, and a running-domain model step requiring DOMAIN_PAUSE. The summary is domain-wide even though the earlier schema step needed no pause. |
The qualification scenarios in tests/features/runtime/nspl_transactions.feature and the
planner checks in src/registry/transaction/tests.rs exercise the first, second, and lifecycle
patterns. The scoped topology and shared-gate checks also exercise branch and relation identity.
Affected Topology And Attribution
Each step carries affected topology before and after its effect. These are snapshots of the relevant graph at that step, not a lookup into today’s live graph. The before side retains dropped nodes and disconnected or rewired relations; the after side shows the graph activation installs. A transaction view composes steps in order, so an entity created and dropped in different steps can be transient even if absent from the transaction’s final graph.
Nodes have typed model-kind and name identities. Execution nodes additionally state whether their coverage is all executions, the unbranched execution, every key of one declared branch, or selected concrete keys. Selected key values are represented by fingerprints; the report does not expose raw, potentially sensitive branch fields. Configuration-only nodes have no execution coverage. Relations distinguish configuration dependency, dataflow, message-error, correlation- timeout, and materialized-state edges. Parallel relations between the same two nodes remain distinct. A configuration dependency explains a model relationship; downstream pause traversal follows record or state effects, so it is not simply a traversal of every dependency in the displayed graph.
The topology includes shared admission gates, branch coverage, hard-group members, moved owners, and the source and destination of each move. Every affected node, edge, gate, and effect retains the operation numbers that contributed to it. This is why a change to an emitter sink, for example, can show a changed emitter, upstream gate, force-flushed work, and replacement activation as different roles rather than as one ambiguous list of “affected nodes.”
Coherent Preview And Guarded Commit
The planner uses one captured view of the domain, models, resource versions, schedule, placement,
membership, and liveness inputs for an ordered prefix. Its planning-basis fingerprint identifies
the inputs relevant to that report. The report also carries the accepted-operation position and
COMPLETE or INCOMPLETE with non-sensitive diagnostics. An identified preview combines the
transaction id, accepted position, and planning basis. It names the whole transaction, even
when inspection focuses on one operation. A coherent preview is current for the inputs it read;
it does not promise that no other command will change those inputs after inspection.
The captured schedule eligibility records every live voter incarnation and the destination candidates after applying termination and consensus cordons. A cordoned voter’s terminating advertisement changes neither set; its loss or restart changes the live identity set and makes the plan stale. The committed topology fence separately rejects changed membership or cordons.
COMMIT can name the expected preview. Before engaging a gate or applying an effect, admission
checks its position and captured inputs. A stale expected preview is refused with both expected
and current identities; the transaction remains OPEN with the same binding and no effect. The
caller inspects the attached transaction again and decides whether to retry. A commit with no
expected identity applies the transaction’s current complete preview. Once admitted, consensus
stores the report, exact execution plan, captured inputs, and domain mutation fence together with
the transition to COMMITTING. Every step then executes that frozen plan; leader replacement or
restart does not replan it against later configuration. A conflict discovered after admission is
reported as a step failure, not a silent widening or substitution of the frozen plan.
sequenceDiagram
participant C as Client
participant S as Session and planner
participant R as Replicated control plane
participant G as Runtime gates and owners
C->>S: append statement with request reference and expected position
S->>S: plan coherent ordered prefix
S->>R: durably admit numbered operation and preview identity
R-->>C: operation number and whole-transaction identity
C->>S: DESCRIBE TRANSACTION or OPERATION n
S->>S: read and plan without mutating transaction
S-->>C: one typed report, text or JSON projection
C->>S: COMMIT with reviewed identity
alt identity stale or report incomplete
S-->>C: refuse before effects; retain OPEN transaction
else complete and current
S->>R: freeze report, plan, inputs and mutation fence
loop each ordered execution step
S->>R: record requested engagement
S->>G: engage exact gate or domain pause
G-->>S: confirmed, failed or uncertain
S->>R: record engagement and authoritative effect progress
S->>G: activate or rebuild, then release held scope
S->>R: record application complete or failed
end
R-->>C: terminal outcome and actual aggregate
end
C->>S: inspect retained transaction by id
S-->>C: frozen plan plus recorded actual outcomes
Actual Engagement And Application
The actual history records each pause attempt in order: REQUESTED, then CONFIRMED, a
diagnostic FAILED, or UNCERTAIN when a remote response cannot prove whether the gate engaged;
cleanup records RELEASED. A request followed by a definitive failure before engagement
contributes only DYNAMIC to the actual aggregate. A confirmed or uncertain engagement contributes
its possible scope even if the step subsequently fails or the gate is released. The report never
rewrites the planned requirement to make a later outcome appear expected.
Ownership-handoff engagement confirms the closed admission hold and requested flush. The coordinator observes each exact operation’s pending drain before capture; a drained response publishes the participant’s capture freeze. A drain timeout keeps the pending node and work counts and releases attempted holds before independent schedule units continue. Retrying a two-second engagement reply timeout waits on the same receiver-owned identity, adds no pause attempt and extends no lease. Preparation retains its original deadline, and cleanup releases every attempted participant.
The step changes from UNATTEMPTED to APPLYING when its authoritative effect transition is
recorded. It becomes APPLIED only after required activation, source readiness, handoff, and
gate release complete. A failure records a diagnostic and the transaction’s completed prefix;
it does not erase a pause already observed. A committed model effect can remain visible despite a
later activation failure. The special case is a model step that took no pause and whose HTTPS
listener installation fails: its failure record restores the preceding models and schedule before
the command reports failure. Command Completion owns the
durable completion boundary; Errors And Diagnostics owns typed
failure propagation and public diagnostic rendering.
A new leader resumes a COMMITTING transaction from its recorded applying step and frozen plan.
Completed effects are not repeated. If an owner timed out, inspection can show UNCERTAIN rather
than falsely declaring that no gate engaged. Recovery may have to rebuild a whole domain after an
entity swap cannot complete; the actual history then appends a DOMAIN_PAUSE attempt and recovery
rebuild effects beside the original entity plan. The runtime retries the same complete typed
schedule revision during that rebuild, and reports its wider node scope to the transaction. The
actual aggregate rises accordingly. Node
shutdown and restart use their own intake, drain, and former-owner fences, described in
Shutdown And Recovery; the transaction report does not redefine them.
| What inspection shows | Operator interpretation |
|---|---|
INCOMPLETE on an OPEN final model run | The candidate is stageable but does not yet supply a complete commit scope. Inspect its planning diagnostics, finish the same run, and inspect again. |
| Stale-preview refusal | Nothing engaged or applied. The transaction remains open; refresh the attached preview before another identified commit. |
Planned ENTITY_PAUSE, actual DYNAMIC, REQUESTED, FAILED | Gate engagement failed definitively before pausing work. The planned scope remains useful for diagnosing the attempted change. |
Planned DOMAIN_PAUSE, actual DOMAIN_PAUSE, REQUESTED, CONFIRMED, FAILED, RELEASED | A domain drain failed after the pause engaged; the hold was then released. |
UNCERTAIN remote engagement | The remote node may have gated admission. Include that scope in actual impact and inspect the retained result after leadership or connection recovery. |
A failed later step after earlier APPLIED steps | The earlier steps are the applied prefix. Inspect both their historical topology and the failing step; do not infer transaction-wide rollback. |
Persistence, Retention, And Delivery
The control plane persists report identity, operation summaries, step plans, applying and terminal outcomes, and the topology needed to reconstruct before and after views. Header, operation, and step records are keyed; topology is content-addressed into bounded node and edge records shared across report revisions. A finished transaction retains its frozen report with its tombstone for the configured retention period, 15 minutes by default, including after subsequent graph changes. Retention cleanup removes the transaction’s report records and topology content no other retained report references. Once its tombstone is reclaimed, inspection by id returns unknown.
Transaction positions and operation numbers, commit-plan step counts, accepted and applied operation counts, report operation and execution-step counts, and topology node and edge counts use fixed-width 64-bit archives with checked native decoding. Durable record replay and snapshots retain every bit of those counts. Their representation is owned by Archived Counts, and their current storage namespace is defined in Consensus Storage And Replication.
These are control-plane facts. Arrow record batches, payload attempts, relay or connector buffers,
handoff data, ACK guards, tokens, and maps remain volatile data-plane state. Inspection cannot
recover or reconstruct any of those payloads. Bounded Raft log reading and snapshot transfer carry
large stored reports without imposing a 2 MiB semantic cutoff; an inspection reconstructs the
complete referenced report before returning one result. Text, JSON, typed API, and console
responses do not paginate or silently truncate nodes, edges, operations, or steps. Encoding or
transport failure yields an error rather than a successful partial report. Consumers should budget
response memory and rendering time in proportion to the accepted operations and affected graph.
The multi-mebibyte replay, restart, and response evidence is recorded in
tests/transaction-quiesce-qualification-ledger.md.
Inspection Through NSPL And Clients
The public form is:
DESCRIBE TRANSACTION [ '<id>' ] [ OPERATION <positive-number> ] [ FORMAT TEXT | JSON ];
An omitted id reads the transaction attached to the caller’s session. An explicit id can read another transaction owned by the same authenticated user, including one in another domain, without attaching or taking it over. The owner check is the same one attach uses. Inspection never changes the inspected transaction’s activity time, queue position, or content. Explicit-id inspection also cannot change the caller’s binding or selected domain, or the commit basis for the caller’s attached transaction. The statement runs on its own beside an open transaction, before queue admission; it consumes no operation number or durable execution reference. Combining it with another statement in one request is refused before either runs.
OPERATION n selects an accepted number for presentation, leading text with that operation’s
contribution and containing step. The typed result and JSON still contain the whole report.
The server renders TEXT (the default) and JSON from the same typed inspection. That envelope
holds the inspected transaction’s status, optional selected operation, and report; it is distinct
from the command outcome’s transaction-status field, which continues to describe the caller’s own
session binding. The session protocol carries the envelope as typed FlatBuffers fields, including
in the command outcome; text and JSON are server-rendered projections, not a second semantic wire
report. Subscription Row frames and ordinary live-graph snapshots have separate contracts. A
refusal reports no attached transaction, unknown or expired id, wrong owner,
unknown operation number, or unavailable report, including a transaction that ended before any
operation was planned. A report is complete or the read fails; no refusal carries a partial graph.
The Rust client’s inspected attached report refreshes its cached whole-transaction preview only at the attached transaction’s current accepted position. An explicit inspection of another id, an older position, or a stale-commit refusal cannot replace that basis. Request correlation also prevents delayed or out-of-order responses from updating the wrong waiter. Reconnect and leader redirect recover the same admitted command by execution reference; a side-effect-free inspection can instead be read again. Rust Client Library, Command Line Client, and NSPL Overview own usage; Client Session Protocol owns client reconnection, correlated response handling, and the exact recovery of transaction requests.
The web console uses the typed envelope directly. Its outline selects the whole transaction, an effective execution step, or one operation’s contribution. The graph combines each step’s before and after topology at stable positions, then offers Before, Changes, and After and Planned or Actual views. Details show reasons, operation attribution, branch coverage, parallel relations, shared gates, ownership moves, rebuilds, and state resets. A domain-wide pause gets an explicit domain outline; a force flush outside the pictured subgraph remains visible in the domain summary. An incomplete or stale preview is marked, and a refresh obtains a new basis. The console reads a report again only when the inspector opens or changes its target, when the attached transaction’s position, state, or applied count changes, or when the operator refreshes; a delivered report never requests itself again. Historical retained topology is drawn from the report rather than a live graph snapshot. Selecting one operation still sends the whole-transaction preview identity on commit. See Web Console for the controls.
Observing A Transaction
For an open transaction, inspect its accepted position, planning basis, completeness, effective
step scopes, and transaction summary before deciding to commit. For a committing transaction,
compare the frozen planned effects with the applying step’s engagement history and application
outcome. For a finished transaction, use its retained report to distinguish the applied prefix
from a failed or uncertain later step. SHOW TRANSACTIONS gives a compact state and progress
listing; DESCRIBE TRANSACTION gives the full historical explanation. The report’s diagnostics
name planning, topology, quiescence, ownership, activation, application, or recovery failures
without carrying sensitive payload values. Per-record details and hot-path buffers are outside
this inspection contract. Ingestor quiesce buffer metrics and current live observations remain in
Metrics And Observability; retained impact is the source for
what this transaction required and actually engaged.
Consensus Storage And Replication
Nervix uses Raft to replicate the control-plane state described in the Control Plane. Each node keeps its own durable log, applied state, votes, membership, and snapshots. This chapter defines when those records are durable, how a leader paces replication, how storage and memory stay bounded during catch-up, and what recovery may observe.
Consensus traffic uses the authenticated pools and admission classes described in Cluster Interconnect. Consensus storage is node-local: a quorum makes a command committed, while every node independently persists and applies the committed log.
The replicated transaction report and its content-addressed topology use this durable path; Transaction Quiescence And Impact Inspection defines which planned and actual facts that report retains. This chapter owns log reading, snapshot transfer, and the storage bounds that carry large reports.
Consensus request and append-stream responses carry the fixed ConsensusRequestError wire
outcome, classified at its source owner for the compiler failure-contract gate. Local storage and
Raft failures retain their contextual reports before this projection. Opening the node database
also preserves the typed storage cause below startup context; a consensus keyspace in that
database returns the separate layout context. Errors And Diagnostics
owns the compiler and propagation rules.
Durable Write Path
Every consensus mutation uses a full synchronization barrier that asks the filesystem to persist both data and filesystem metadata. The device and filesystem must honor that request for the host-power-loss guarantees in this chapter to hold.
The path from a proposal to its reply has four distinct boundaries:
client leader log follower logs leader state machine
| | | |
|-- propose --->| | |
| |-- SyncAll append ---->|-- SyncAll append |
| |<----- quorum has the committed entry ----------|
| |------------------------------------------------>|
| | SyncAll applied range |
|<------------------------- applied response ---------------------|
| Boundary | What it proves |
|---|---|
| Appended | This node synchronized the complete log batch. It does not by itself mean that a quorum committed the entry. |
| Committed | A Raft quorum durably appended the entry. A node that has not applied it can replay it from the log or receive it again from the leader. |
| Applied | This node atomically synchronized the semantic changes, final applied position, membership, transaction progress, and revision for the applied range. |
| Client acknowledgement | The leader durably applied the command and produced its semantic response. Nothing acknowledged at this boundary is lost by process or host failure on storage that honors synchronization. |
Appended Batches
Entries already ready for storage are encoded into the fewest reservation-bounded atomic writes. One write performs one synchronization. A larger submission is split only when the next complete entry would exceed the write’s admitted memory; an entry is never split between writes.
The append call returns to Raft after the storage job has been admitted to the single ordered consensus worker. Its I/O-completion signal is delivered later, after the write and synchronization finish. Raft can keep its core responsive while storage works, but it cannot count the append as durable before that signal. A vote, truncation, purge, or later append queued behind it observes the same worker order.
A storage failure marks that node’s consensus store failed. Later writes return
consensus storage stopped after a failed write; restart the node to recover. The error can arrive
after the device completed the write, so a failed or disconnected request is an uncertain outcome,
not proof that the command was absent. Recovery reads the durable state to decide.
Consensus startup, proposal, and membership operations carry contextual error reports locally. Database open failures retain their I/O cause; Raft configuration, startup, and handler registration failures retain the failure beneath the startup context. A rejected proposal keeps its Raft cause while classifying a known redirect as leadership loss, a fatal storage failure as storage, and other Raft write failures separately. A state-machine refusal remains a typed conflict, distinct from a storage or leadership failure. These classifications determine whether the control plane redirects, retries, or reports an uncertain failure; it does not parse the error message to decide.
Transaction mutation checks also create local reports. Raft’s applied response retains its
existing serialized TransactionMutationError outcome, so report frames are local to the state
machine evaluation. The proposer creates a new report from the exact typed outcome received over
Raft and adds transaction context when the control plane takes ownership. The response encoding,
client acknowledgement boundary, and recovery rules in this chapter are unchanged.
Applied Ranges And Client Replies
Committed entries are applied in order. Entries already available together share an atomic write until adding the next entry’s changes would exceed the reservation. That write contains the final semantic records and metadata for the whole range. After synchronization succeeds, the node publishes the resulting coherent revision and then answers every entry in the range.
One command may expand when its archive becomes independently keyed transaction report, commit-plan, and topology records. An applied command therefore reserves 16 MiB from the Commands class: up to 8 MiB for the normalized records and keys, with the other half retained for Fjall’s journal encoding and moving batch descriptors. The reservation is released after synchronization. This keeps a multi-mebibyte report atomic without making storage memory proportional to retained history.
A crash after a log write and before application does not lose an acknowledged command. If the entry was committed, recovery streams it from the log and applies it. If it was not committed, a later leader may discard it. In either case, the client had not yet received an applied response. A crash or lost connection after durable application but before delivery of the response can leave the client uncertain even though the command took effect; persistent administrative requests use their stable execution reference to join or retrieve that result.
Transaction application completion persists the actual quiescence history and actual effects in the same applied state-machine boundary that changes the step from applying to applied or failed. Recovery therefore sees ordered requested, confirmed, failed or uncertain, and released outcomes without inferring them from a client timeout. These records contain control-plane scope, attribution, and non-sensitive diagnostics only. Runtime payloads, acknowledgement maps, connector buffers, and handoff bytes remain volatile data-plane state.
Restore Checkpoint Reclamation
Restore checkpoint reclamation borrows the applied state through with_restore_state_reclamation
and retains its read guard across node-local bounded storage deletion. Its caller takes the
checkpoint installation barrier after this guard, in the same order as staging and publication.
The retention view keeps the current generation of every applying restore and any generation
ahead of the applied log; without an applied log it keeps all generations. Leadership and lease
expiry alone do not end an applying execution’s retry lifetime. Terminal, expired, missing or
superseded executions no longer retain an applied unpublished generation. The runtime store
protects its selected durable publication independently. Within the selected initial or restored namespace, the store
retains only chunk sets referenced by the same view’s segmented header and exact checkpoint
revision; segmented or inline replacement, header purge and incomplete unpublished revisions
permit bounded chunk deletion. Snapshot retention
preserves preexisting readers, while the installation barrier serializes ordinary, replica and
ownership writes with cleanup. This adds no consensus record or log
mutation and never removes a replicated incomplete-installation gate. See
Backup And Restore for the
quota, metrics and node-local cleanup boundary.
Coordinated WASM Reset Publications
An NSPL reset first records a typed ordered transaction step with its captured planning inputs,
statement identity, and reset scope. That record changes no processor Model or schedule by itself;
the coordinator uses its retained execution reference for the Publishing and Ready mutations.
If the leader stops after recording the step, transaction recovery resumes that same reference.
An expired outer command reference is refused at admission rather than starting a new step.
A coordinated WASM state reset stores its command execution reference, exact branch scope, phase,
and advanced guest-state generation in the domain schedule. The first applied schedule phase,
Publishing, is the irreversible authority boundary: after that applied response, restart and
leader failover continue the new generation even when its fresh initial checkpoint is not usable
yet. Repeating the same request rejoins that publication rather than appending another generation.
The initial guest checkpoint is runtime state and follows its separate local and replica
synchronization path. Live replicas install the published generation before the owner starts its
initial checkpoint. Reset preparation never publishes the temporary absence of a selected branch
through the branch-lifecycle checkpoint, and reinserting the branch advances that checkpoint’s
revision before the new guest checkpoint is offered. Once the guest checkpoint is durable on the
owner and every assigned replica, a second consensus mutation changes the same reset to Ready.
Success is returned only after every live node applies that phase while the selected relay scope
remains fenced. A follower or offline replica that skips the intermediate runtime activation still
catches up through the ordered schedule records and cannot accept a checkpoint from the replaced
generation. Thus a pre-publication failure leaves the old schedule authoritative, while a
post-publication failure is recovered as committed but not yet usable rather than rolled back or
acknowledged early. See Coordinated Reset.
Replication Pacing
The leader keeps one ordered append stream to each follower. That stream uses the replication pool; heartbeats and leadership probes use a separate management connection so a slow or flow-controlled log transfer cannot consume the path that maintains the leader lease.
Before opening an append stream, the network adapter consumes the caller’s first request. OpenRaft applies its retry delay and cancellation while producing that input; even an immediate local admission rejection therefore passes through the caller’s gate. An ended input opens no transport. Leading empty requests, including finite catch-up probes, use the management heartbeat operation and its deadline. Each answer confirms that request’s previous-log boundary; a conflict, higher vote or incomplete acceptance ends that input before any data stream opens. The first request that carries entries remains charged while stream setup runs and joins the remaining requests exactly once after setup succeeds. Subsequent empty commit updates stay on that ordered stream alongside data batches. Setup retains its five-second deadline; waiting for the caller’s next request belongs to the caller’s lifecycle, rather than to connection setup.
One replication batch targets 1 MiB and stops at 1,024 entries. Commands remain whole, so the first command may take a batch past the target; the encoded replication operation is bounded at 2 MiB. Submissions and answers stay in log order. A conflict, higher vote, partial acceptance, stream failure, or liveness failure ends that stream generation after its first result. Raft resumes from the last progress the follower confirmed.
The leader permits at most 16 unacknowledged batches for one follower. It also waits whenever its unacknowledged bytes plus the next 1 MiB target would exceed 16 MiB. Because one complete command may cross the target, measured outstanding bytes may exceed that target budget by the excess of one encoded batch; the 2 MiB operation limit remains the hard bound on that batch. The node’s Commands memory budget separately bounds the aggregate encoded and decoded copies across all peers.
Progress-Based Liveness
Opening an append stream has a five-second deadline. Once open, the stream has no age limit. With a batch outstanding, it is considered stalled only after five continuous seconds in which both of these are true:
- no answer arrived;
- the follower accepted none of the leader’s bytes.
An answer or transport flow-control progress restarts the idle window. A stream with no outstanding batch can remain idle indefinitely. The time required for a follower to synchronize one batch is therefore not treated as a failure while the transfer is still moving. The interconnect frame writer uses the same progress contract: every nonzero flow-control grant restarts its write idle bound, including grants within one large append frame. Catch-up at a limited byte rate can therefore preserve its append stream across many five-second intervals. The native writer checks both a moving and a stalled receiver. The public rate-limited follower scenario sends opaque TLS bytes through a bounded test proxy at 32768 bytes per second and checks the applied domain backlog within its assertion bound and the bytes carried by the paced path. Append stream openings observe actual data-stream setup; initial empty probes do not open those streams. Native transport regressions cover finite probes, the transition to data, ordered empty commit updates and rejected probes. The public catch-up scenarios retain their stream-count and time assertions and change no product deadline.
Heartbeats keep the shorter deadline derived from the configured heartbeat interval, capped at five seconds. With the default 250 ms heartbeat, the current soft deadline is 187.5 ms. Replication answers also prove leader contact, so sustained writes suppress redundant heartbeats for an automatically derived window. The window is no longer than the heartbeat interval or half the gap between that interval and the minimum election timeout. This keeps the heartbeat interval plus the suppression window below the minimum election timeout.
The independent paths matter when a follower has slow storage: its append answer may take much longer than a heartbeat deadline, while management traffic still prevents an election. Changing the heartbeat interval does not change the append stream’s five-second progress bound.
A Quorum Over A Lossy Link
When only a bare quorum of voters is reachable, such as two of three while the third is down, every commit needs all of them and the leader’s link to each. When such a link loses packets but stays up, TCP recovers them by retransmission under the host kernel’s retransmission timeout, which grows exponentially while the losses continue; Nervix does not tune it. An exchange whose deadline passes before its retransmission arrives fails and is retried: a heartbeat at its soft deadline, and an append stream at its five-second setup or progress bound. Commits can therefore stall, leadership can move between the reachable voters, and a control command, which needs several commits in sequence, can time out.
Nervix sets no bound on such a stall. It lasts until the loss ends or another voter becomes reachable, and it loses no committed entry.
Follower Memory And Backpressure
A follower takes a Commands-budget reservation before it decodes an append batch. It retains that reservation until Raft has durably appended the batch and produced its answer. The default resident window is four decoded batches per live stream. Reaching the window stops frame reads before another batch is decoded, closes the HTTP/2 flow-control window, and makes the leader wait.
The default 24 MiB Commands budget is validated to hold the complete resident window beside one maximum-size 2 MiB batch being encoded. Other command and replication work shares that aggregate budget, so concurrent or superseded streams cannot grow memory without admission. Ending a stream releases its queued batches and reservations; batches from a generation the leader abandoned do not remain uncharged in the Raft core.
Follower Catch-Up And Learner Promotion
A follower whose required log suffix is still retained receives bounded append batches over its stream. If the leader has already purged part of that suffix, the follower receives the current snapshot and then the remaining log. Client proposals and management heartbeats continue while the catch-up runs.
A discovered node joins as a learner. Each membership reconciliation gives a blocking learner
catch-up attempt ten seconds. Promotion to voter is proposed only after that attempt reports the
learner caught up; the membership change has its own ten-second bound. A timeout leaves the node a
learner, records raft membership reconciliation failed, and the one-second reconciliation loop
tries again while ordinary replication continues.
When discovery reports a different interconnect endpoint for an existing member, reconciliation replaces that member’s recorded Raft address. A returning voter keeps its voter role; it is not re-added as a learner. Once the committed membership contains the advertised address, subsequent reconciliation passes propose no further address change. The stored address supplies recovery contact hints; Raft traffic resolves the authenticated node identity through the interconnect.
The consensus event stream and info log record raft add learner, raft wait for learner,
raft update address, raft promote voters, and raft membership updated transitions.
SHOW CLUSTER STATUS reports the local raft.last_log_index, raft.last_applied, and each member’s
learner or voter role and recorded address.
Bounded Log Reading
Committed-range application, startup replay, and leader-bounded replication streams read the log in successive admitted chunks. A chunk targets 1 MiB or 1,024 entries, whichever is reached first, and one oversized first entry travels alone. The reader releases the ordered storage worker between chunks and retains the chunk’s memory charge until its entries have been handed to the consumer.
There is no range-size failure at 2 MiB or at any other aggregate backlog size. A committed range and a startup backlog may span any number of chunks. The state-machine worker applies the entries already read before requesting another chunk, so a large backlog does not have to exist as one allocation. A replication read also revalidates the leader’s durable vote at every chunk boundary; it stops rather than reading through a truncation performed under another leader.
Log Retention And Admission
retained_bytes is the sum of the encoded values for the log entries the node currently holds. It
includes entries still in Fjall’s memtable and excludes purged entries, tombstones, stale segment
space, snapshots, and other keyspaces. Append adds encoded entry bytes; purge and truncation subtract
the removed entries in the same ordered storage operation. Opening the database rebuilds the counter
by scanning the current log, so a process failure between a durable mutation and its in-memory
counter update does not skew the recovered value.
A node starts one snapshot after either of these thresholds is crossed:
- 10,000 committed entries since the completed snapshot;
- 64 MiB of appended entry bytes since the completed snapshot.
Only a completed snapshot permits covered log entries to be purged. By default the node retains the newest 1,000 covered entries or 64 MiB of covered suffix, whichever bound is reached first. Entries newer than the snapshot remain regardless of those settings. A failed or unfinished build therefore does not remove the log needed to recover.
When retained_bytes is already above the 1 GiB admission cap, a new control-plane mutation waits
up to 30 seconds for snapshotting and purge to reclaim space. If it remains above the cap, the write
fails with:
the retained raft log holds <retained> bytes against a <cap>-byte cap and did not reclaim within 30s
The check occurs before admitting the next mutation, so the mutation that crosses the cap may complete. Reads, health checks, replication, snapshotting, and recovery remain available while new mutations wait.
Snapshot Lifecycle
Sealing And Publication
A snapshot generation is a sequence of sections containing keyed state-machine records plus a manifest that names the generation, applied log position, membership, section count, and total bytes. The builder opens one consistent database view and seals it one section at a time. The default section limit is 8 MiB. Each section acquires the Bulk budget for one consensus-storage turn, is encoded and synchronized, and releases that reservation before the next section starts. Concurrent state-machine writes can proceed between sections without changing the pinned view. The section builder charges each key and value together with its archived record envelope, root, and alignment before accepting the record. The encoded section therefore stays within 8 MiB even when it contains many small keyed report records; the complete generation grows by adding sections.
The manifest is synchronized only after every section is durable. Publishing that one record makes the generation active atomically with its applied position and membership. A build interrupted before manifest publication leaves the previous generation active.
Generations And Pins
The newest published generation is always retained. An outgoing transfer pins the generation it is reading. Multiple readers can share one pin, and at most one older generation may remain pinned beside the active generation. If publication would leave a second older generation pinned, the oldest transfer is made obsolete; its next read fails and Raft restarts it from the newest snapshot.
A superseded generation with no reader becomes unreferenced. Its sections are removed as part of a later durable consensus batch. Startup discovers sections that no published manifest names and marks those generations for the same deletion path. This keeps deletion ordered and durable without treating an unfinished build or abandoned transfer as active state.
Transfer And Installation
One complete snapshot transfer has a 30-second deadline. The sender reads one section at a time and sends it in 64 KiB chunks through the Bulk pool. The receiver holds at most one section in memory, validates its declared size and offsets, and synchronizes each completed section into a newly claimed generation. A restarted transfer abandons the earlier staged generation.
The receiver stages at most one transfer from each sending node, and a new transfer from that node supersedes it. Every transfer carries an identity its sender allocates, and the receiver refuses a chunk or a finish whose identity is not that of the transfer it is staging, so a late chunk of a superseded transfer never lands in its successor. Because the receiver only compares identities from one sender, an identity is unique among the transfers one node sends: every Raft connection of the node draws from the node’s own sequence, which starts again when the node starts.
After every declared section arrives, Raft revalidates the vote and whether the snapshot still applies. Installation then synchronizes the new manifest together with an installation marker. That write is the recovery boundary: before it, the old snapshot and state machine remain authoritative; after it, startup must finish installing the marked generation.
The installer clears the prior state-machine records, writes each staged section in order, and finally removes the marker while publishing the new generation. Every step is idempotent. A process that stops after the marker was published redoes the complete replacement on its next start, regardless of how many clear or section writes had finished. It exposes no recovered state until the replacement and current-shape validation succeed.
Storage Layout And Compatibility
| Path | Contents and ownership |
|---|---|
<db-path> | Registry records and runtime-state checkpoints in the node database. |
<db-path>/consensus | The dedicated consensus database and journal: log entries, votes and log metadata, applied state-machine records, snapshot manifests, installation markers, and snapshot sections. |
The databases have independent journals, memtables, rotation, and synchronization. A consensus
SyncAll neither flushes runtime journal bytes nor waits behind a runtime journal write. Both use
the bounded storage executor, but consensus has its own single ordered worker.
The dedicated database owns exactly four current keyspaces: raft_count_logs, raft_count_meta,
raft_count_state, and raft_count_snapshots. Opening it validates the complete keyspace namespace
before reading any records. Its state metadata requires the current RestoredLifecycle encoding,
including the durable restore installation gate. Native counts in commands, queued transactions, progress, outcomes, and plan, report and topology headers
are archived as fixed-width 64-bit values with checked native decoding. Log replay, state recovery
and snapshot installation therefore retain their complete magnitudes. See
Archived Counts.
This layout has one current shape. A consensus database containing an unknown keyspace, a malformed
current archive, or incomplete or inconsistent current state fails with invalid consensus record storage; recreate the node's stored state. Recreate the node’s stored state and let it rejoin from
the cluster.
Archive shape and nesting are validated before a durable batch or state record is read back. Vocabulary values inside a valid shape are checked as each value is read. If one is refused after earlier list or fixed-array elements or a boxed or shared value have been read, the archive reader drops those values and releases their allocation. The whole record fails; no partial record is installed or retained during replay, catch-up, or snapshot installation.
Recovery accepts exactly the records the state writer stores. Each state record sits under the canonical encoding of its key, so a key spelled another way, or one with bytes after its encoding, is refused instead of being read as a neighbouring record. A record whose value names its own identity must name the key it is stored under: a domain, a schedule’s domain and each of its nodes, a user, a resource version, node status or upload, a transaction, its commit plan header or impact report header, and a command execution. The state keyspace holds those records and the metadata and nothing else, so a record no family owns is refused as well. Recovered resource records hold at most one upload for each resource version, and an expired command execution’s reference carries the issue time its expiry is ordered by. A transaction is in a state its transitions reach: an open or committing transaction queues exactly the statements and source bytes it accepted, a finished one queues none, a commit’s results apply consecutive statements from the first and its progress stays inside the queue, and a failure names a statement the transaction accepted. Every log read checks that a log record is stored under its own entry’s index.
Shutdown And Forced Endings
Consensus remains available throughout stop admission and graph drain. During terminal teardown, the node stops Raft, which ends its append stream generations, then submits an idle barrier to the ordered storage worker. Reaching that barrier proves every earlier submitted durable write returned and released the consensus store before its database handle is dropped. Dropping it closes the dedicated database, which finishes even when one of its background workers failed during the close; see Shutdown And Recovery. The interconnect is stopped after consensus, so peers first observe the append protocol ending and later the transport closing.
A repeated signal, an expired shutdown deadline, or SIGKILL skips that barrier. Recovery then sees
the last complete atomic storage boundaries: an append batch is present or absent; an applied range
contains its records and final metadata together or not at all; and a marked snapshot installation
is redone in full. See Shutdown And Recovery for the exact
SIGTERM, SIGKILL, stream-teardown, and restart behavior.
The recovered applied state includes the last stored membership and its peer endpoints. Startup discovery reads that membership before Raft has published its live topology metrics, so a former bootstrap leader can use surviving peers as authenticated gossip seeds. The stored endpoints are contact hints; current peer identity, incarnation, and endpoint still come from discovery. See Cluster Interconnect.
Observability
| Signal | What catch-up or retention looks like |
|---|---|
SHOW CLUSTER STATUS | On each node, raft.last_log_index advances as entries arrive and raft.last_applied follows durable application. Membership lines show learner or voter state. |
nervix_consensus_log_last_index | The highest entry held locally. Compare nodes to see a log follower converge. |
nervix_consensus_log_snapshot_index and nervix_consensus_log_purged_index | The snapshot coverage and removed prefix. A follower behind the purged index must use a snapshot. |
nervix_consensus_log_retained_bytes | The exact encoded bytes in current log entries and the value used by admission. |
nervix_execution_memory_reserved_bytes{class="commands"} | Includes decoded follower batches held until durable answers and replication payload admission. It stays bounded by the Commands capacity. |
nervix_interconnect_pending_operations{operation="append"} | Live append-stream operations on the exporting node. |
nervix_interconnect_stream_resets_total{class="replication"} | Abnormal replication stream endings, separated by deadline, capacity, peer, shutdown, or malformed reason. |
nervix_interconnect_requests_total{operation="snapshot"} and nervix_interconnect_request_seconds_total{operation="snapshot"} | On the sending node, the snapshot transfer requests a lagging follower answered or failed: the transfer’s start, its 64 KiB chunks, and its finish. A transfer moves as these bounded requests in the Bulk pool rather than as a streamed body, so nervix_interconnect_bulk_bytes_total does not count it. |
nervix_consensus_snapshot_pinned_generations, nervix_consensus_snapshot_pinned_readers, nervix_consensus_snapshot_unreferenced_generations | Generations retained for outgoing transfers and generations awaiting durable deletion. |
Normal membership changes appear at info. Append generation endings and invalid or foreign batches
appear at debug; a stalled generation reports that the stream neither answered nor accepted bytes
for five seconds. Snapshot requests caused by the byte threshold appear at info, and byte-retention
purge requests appear at debug.
Tuning
The server exposes heartbeat, election, snapshot, and retention policy as command-line arguments and matching environment variables:
| Argument | Environment | Default | Effect |
|---|---|---|---|
--raft-heartbeat-interval | NERVIX_RAFT_HEARTBEAT_INTERVAL | 250ms | Period for leader liveness probes and basis for their soft deadline. It does not set the append-stream idle bound. |
--raft-election-timeout-min | NERVIX_RAFT_ELECTION_TIMEOUT_MIN | 1500ms | Lower end of the randomized follower election timeout. Must exceed the heartbeat interval plus the derived suppression window. |
--raft-election-timeout-max | NERVIX_RAFT_ELECTION_TIMEOUT_MAX | 3000ms | Exclusive upper end of the randomized election timeout. Must exceed the minimum. |
--raft-snapshot-entry-threshold | NERVIX_RAFT_SNAPSHOT_ENTRY_THRESHOLD | 10000 | Applied entries since the last completed snapshot that request the next build. |
--raft-snapshot-byte-threshold | NERVIX_RAFT_SNAPSHOT_BYTE_THRESHOLD | 64MiB | Appended entry bytes since the last completed snapshot that request the next build. |
--raft-covered-log-entries-retained | NERVIX_RAFT_COVERED_LOG_ENTRIES_RETAINED | 1000 | Newest snapshot-covered entries retained for log catch-up. |
--raft-covered-log-bytes-retained | NERVIX_RAFT_COVERED_LOG_BYTES_RETAINED | 64MiB | Newest snapshot-covered encoded bytes retained; the entry or byte bound reached first decides. |
--raft-retained-log-cap | NERVIX_RAFT_RETAINED_LOG_CAP | 1GiB | Current-log byte level above which later mutations wait up to 30 seconds for reclamation. |
Replication batch and memory limits are fixed by the current server execution policy; the server does not expose flags for them:
| Limit | Current value |
|---|---|
| Replication batch target | 1 MiB or 1,024 entries; whole commands are never split |
| Encoded replication operation | 2 MiB |
| Leader window per follower | 16 batches and a 16 MiB target byte budget |
| Follower decoded resident window | 4 batches per live append stream |
| Commands memory budget | 24 MiB per node |
| Applied command storage | 8 MiB normalized payload in a 16 MiB working reservation |
| Append stream setup / progress idle bound | 5 seconds / 5 seconds |
| Snapshot section / transport chunk | 8 MiB / 64 KiB |
| Complete snapshot transfer | 30 seconds |
Use the timing settings as one policy. Lowering the election minimum without lowering the heartbeat interval can make the configuration invalid at startup. Raising the heartbeat interval does not allow slower append storage because append liveness is progress-based and fixed at five seconds.
Snapshot and covered-log settings trade storage and catch-up method. Smaller thresholds build and transfer snapshots more often; smaller covered suffixes make a lagging follower switch to a snapshot sooner. Larger thresholds and suffixes retain more log and require a retained-log cap large enough for the working set. The cap should leave room for entries newer than the latest completed snapshot, because those entries cannot be purged regardless of the covered-log settings.
Restored Domain Installation Gate
The consensus state machine persists each restored domain’s pending or installing state alongside its restore execution and mutation lease. Creating the domain establishes a pending start gate; admitting state installation records leader tenure, execution, lease revision and the committed log index as its generation. Recording the model step removes the gate only for that exact authority. The record is included in snapshots and restored after restart, independently of lease release or terminal execution failure. Runtime state bytes remain in the node-owned state store. A local storage mutation holds the applied-state read guard through its authority validation, checkpoint mutation and handle clearing; a newer applied authority or start-gate release cannot cross that boundary. The current state encoding requires these records and rejects a database whose encoding does not match, requiring recreation under the alpha persistence contract. The current encoding also requires a restore’s explicit lifecycle policy and its installed-state effect’s clock. RESUME changes the domain to running and removes its complete-set installation gate in that single applied effect; it preserves the archived generation and mapping.
Command Completion
Nervix treats a successful NSPL administrative response as a completion boundary. When a command
returns OK, its declared effect is durable, applied, and usable through every current live node
that participates in that effect. A caller can issue the dependent command immediately through
another session or node. Runtime data flow, subscription delivery, connector acknowledgements, and
timer-driven work continue asynchronously under their own contracts.
A command can therefore remain pending while Nervix validates configuration, replicates control state, distributes resources, prepares runtimes, starts sources, drains old ownership, releases entity gates, and makes the final outcome visible. Transport loss only detaches the waiter after admission. The cluster continues the work and retains its terminal result for the command retry validity, 15 minutes by default.
Lifecycle and ownership
Every persistent ordinary command carries an execution reference. The reference is a UUIDv7, and its embedded creation time bounds how long the request may be retried. The reference is scoped to its authenticated owner and selected domain and is bound to one semantic request while its record remains present. Reusing it with changed content, credentials, owner, or domain fails. Repeating the same request joins the applying execution or returns the retained terminal result.
A new reference is admitted only when its creation time lies after the cluster’s retry fence and no more than five minutes ahead of the leader’s clock. The fence trails the leader’s clock by the retry validity. It is part of the replicated state and never moves back, so restart, leader change, and snapshot installation all preserve it. A reference without a UUIDv7 creation time, one created at or before the fence, and one created too far ahead are refused before any effect.
An applying execution never expires. A terminal result is retained for the retry validity after the
command finishes. The record then shrinks to a tombstone holding only the reference, and the
tombstone is removed once the fence passes the reference’s creation time. From then on the fence
refuses the reference by itself, so a reclaimed reference returns the typed
ExecutionReferenceExpired disposition and never starts its effect again. A conflicting reference
found during replicated admission returns ExecutionReferenceConflict with the kind that differed.
Report retention is a separate contract: a transaction’s report follows
the transaction tombstone retention, so inspection can still read it after its command reference
has expired, and a report that inspection no longer knows does not make its reference executable.
Finalization accepts a terminal command result. An OutcomeUnknown, leader redirect, or detached
transaction leaves the execution applying and preserves the disposition and diagnostics for its
current waiter. Reconciliation and repetition under the same reference resume that execution;
an interrupted attempt never supplies a definitive failure for the execution ledger.
The retained history is bounded. Once it holds as many applying, finished, and expired executions as its capacity allows, a new reference is refused with an explicit capacity error. Admitted work is never evicted to make room, and repeating an admitted request still joins it or returns its retained result.
| Setting | Environment variable | Default |
|---|---|---|
--command-retry-validity | NERVIX_COMMAND_RETRY_VALIDITY | 15m |
--command-execution-capacity | NERVIX_COMMAND_EXECUTION_CAPACITY | 65536 |
An ordinary configuration command applies through a frozen internal transaction attempt. If its
captured planning inputs change before the effect is recorded, the command retains that failed
attempt and starts a revision-derived attempt under the same execution reference. An explicit
transaction remains frozen after COMMIT admission and reports the conflict instead of replanning.
Resource uploads follow the same replay rule with an upload identity instead. It is scoped to the user, domain, and resource, bound to the verified archive digest, and retained without expiry; see Resource Versions And Bindings. Transaction appends additionally carry their expected queue position. This makes a replay an exact append check rather than a request to append another copy.
stateDiagram-v2
[*] --> Admitted: durable identity and semantic effect
Admitted --> Applying: execution owner starts or recovers
Applying --> Applying: durable effect progress / retryable attempt
Applying --> Completed: all required application and visibility complete
Applying --> Failed: definitive failure recorded
Completed --> Expired: retry validity elapsed
Failed --> Expired: retry validity elapsed
Expired --> Expired: replay reports expired
Expired --> Reclaimed: retry fence passes the creation time
Reclaimed --> Reclaimed: retry fence refuses the replay
The control plane owns command identity, durable effect progress, terminal outcomes, and recovery. The registry and scheduling decisions validate and produce authoritative state. Each runtime owner reports preparation and readiness for the exact revision it applied. Gossip carries acknowledgements with the process incarnation, so a restarted process cannot inherit its predecessor’s readiness. Sessions and clients route requests and wait; they do not decide completion.
flowchart LR
Client[CLI / Rust client / web console] --> Session[Session service]
Session --> Identity[Replicated execution identity]
Identity --> Decision[Registry and scheduling decisions]
Decision --> Raft[Durable authoritative state]
Raft --> Runtime[Per-node runtime application]
Raft --> Resources[Per-node resource installation]
Runtime --> Gossip[Revision + incarnation acknowledgements]
Resources --> Replicas[Replicated per-incarnation replica records]
Gossip --> Barrier[Command-scoped completion barrier]
Replicas --> Barrier
Barrier --> Outcome[Replicated terminal outcome]
Outcome --> Client
Commands in different domains and independent resource uploads have separate execution ownership. Conflicting work in one domain uses that domain’s alteration or handoff ownership. Other sessions, health, subscription delivery, and transport control frames continue while a command waits, and so do the waiting session’s completion, choice, domain, and inspection requests. That session’s later commands wait for it, because a session runs its commands in order; see Requests, Lanes, And Cancellation.
REBIND RESOURCE completes only after its entire selected model set has been validated, committed,
and activated under this same barrier. The successful response is therefore the boundary at which
every selected usage observes the new pinned version. A validation or activation failure cannot
report a partially rebound set. Resource Versions And Bindings
defines the rebinding contract.
BACKUP uses the same durable execution reference and retained outcome. Its success means the
archive was assembled from each domain’s own applied revision, verified, and retained on the leader under that
reference, and its outcome carries the archive’s size, digest, and per-domain revisions. The
execution reference is also the key the client downloads the archive by. Its request binding
includes the original selected domain, scope, resource inclusion and capture options. The client’s
local archive destination is excluded from this binding, so recovery may choose another file or
stdout. A bounded client wait can end while the command remains applying; the original reference
recovers that work without admitting a second backup. Repeating the reference returns the recorded
outcome, and the client downloads the archive again while it is retained,
which lasts until a download collects it or the reference’s retry validity ends. See
Backup And Restore.
RESTORE uses the same durable execution reference and retained outcome, and its request identity
includes the size and BLAKE3 digest of the archive the client streams with it, so the same
reference with another archive is a different request. The leader admits a restore only after the
archive verified and the whole restore planned, and records every step the restore applies in the
restore’s execution as the step’s effect commits. Its success means every step applied: the users,
each domain created stopped, its resource versions completed under their archived numbers on every
live node, its models applied, and its complete compatible state set durably published on every
target node. A failure names the step, and the steps before it stay applied. An unfinished domain
retains a replicated start gate even after failure releases the execution’s mutation lease.
Retrying the reference while the restore applies on the leader returns OutcomeUnknown with the
StillApplying cause at once, and after it finished returns the recorded outcome with the typed
restore report. Only the leader the archive was streamed to holds it, so a new leader resumes an
applying restore from its first step not recorded when a retry streams the archive again, and ends
it as failed once the reference’s retry validity passes without one. See
Backup And Restore.
RESET WASM PROCESSOR ... STATE uses the same durable execution reference and retained outcome.
The ordered transaction records the reset as an effect even though it changes no Model. Success
waits for the selected guest-state generation to be durable and its replacement execution usable.
Retrying an admitted reference resumes or reads the original outcome; an expired reference cannot
start another destructive reset. See Coordinated Reset.
All-live-node barriers
A completion barrier continuously derives the required set from the current leader’s effective application-health view. Followers request that view through an authenticated management operation and accept it only while their Raft leader and term still match the response. If the leader or its view cannot be confirmed, they keep waiting. Every node also requires its own current incarnation to finish locally. The set keys each member by node name and process incarnation. A node joining while the effect is still applying joins the required set. A process restarting under the same name must apply the effect in its new incarnation. A node leaves the set only when the leader’s availability policy retires it; a stale observation or one failed probe does not waive its work. This shared view lets a connected follower finish control commands even when its own health probes still see another follower that the leader has already retired. The retired process catches up on reconnection before it can participate again.
Runtime activation has two explicit phases. First, every required node applies the authoritative
models, schedules, clocks, resource bindings, and stopped or running lifecycle state and reports
prepared for that revision. Only after the preparation barrier may running-domain sources and
listeners start. Each node then reports ready, and success waits for the readiness barrier. This
prevents a source from publishing into a peer that still has the preceding graph.
Revision progress is cumulative. If a newer runtime revision arrives while a node is waiting at either barrier, that node applies the newer coherent state instead of waiting to finish the older revision first. Preparing or becoming ready at the newer revision also completes every earlier revision for that process incarnation. This lets a newly admitted process catch up to the current state without forming a cycle with peers that entered adjacent revision barriers before it joined.
sequenceDiagram
participant C as Client
participant L as Leader
participant R as Raft state
participant A as Node A incarnation
participant B as Node B incarnation
C->>L: command + execution reference
L->>R: admit semantic effect
R-->>L: committed revision
par prepare exact revision
L->>A: apply authoritative state
L->>B: apply authoritative state
end
A-->>L: prepared(revision, incarnation)
B-->>L: prepared(revision, incarnation)
par activate sources/listeners
L->>A: activate
L->>B: activate
end
A-->>L: ready(revision, incarnation)
B-->>L: ready(revision, incarnation)
L->>R: retain terminal outcome
R-->>L: outcome visible on live set
L-->>C: OK + same execution reference
The barrier targets the command’s authoritative revision and required effect. Later changes in an unrelated domain do not move that target. Local observations never repair state as a side effect; recovery and application owners perform repair before declaring readiness.
Transactions
Transaction Quiescence And Impact Inspection defines the frozen step plan, required and actual pause scopes, applying progress, and historical report. This chapter defines when each authoritative effect and its runtime application finish.
BEGIN creates an OPEN transaction for one existing selected domain. A queueable statement is
validated by planning the ordered candidate formed by the existing prefix, then durably appended
with its request reference, expected position, admitted result, stable operation metadata, and
identified preview revision. The plan uses one captured set of relevant control-plane inputs and the
exact execution-step segmentation used at commit. Queue success means validation and staging only.
It does not replace a live graph, start or stop a domain, install a resource, or engage a runtime
gate. Other sessions continue to see the committed configuration. Retrying the exact append returns
the retained admitted result and preview without touching the transaction.
COMMIT first computes a side-effect-free complete preview from replicated content and a new
coherent snapshot. Its admission validates the identified basis and atomically freezes the report,
ordered plan, and captured inputs before changing the transaction to COMMITTING. A commit may name
the identity it expects to apply, which a client obtains from an accepted append or from inspecting
the transaction. A stale basis leaves the transaction OPEN and returns a typed refusal naming both
the expected and the current identity, so the caller can decide again against the transaction as it
is. That refusal is part of the recorded outcome, so recovering the same request by its reference
still names both identities rather than only reporting that the commit failed. Reading a transaction through inspection is itself side-effect-free: it changes no binding,
domain, activity time, or queue position. DESCRIBE TRANSACTION is such a read, so it records no
execution: repeating the request under the same reference reads the transaction again rather than
returning a retained result, and it completes as soon as the report is read. Consecutive model mutations are one frozen step;
lifecycle, domain, and resource statements each end a model run and form their own step. Durable
effect progress changes the retained step from unattempted to applying, while completed application
records applied or failed separately. A step whose authoritative write is committed remains applying
until activation, handoff, drain, source readiness, lifecycle work, and command-owned gate release
are complete. Only then can the next step advance. The transaction becomes COMMITTED after the
final application record and terminal visibility barrier.
For a transaction containing a WASM state reset, the frozen preview describes the planned reset
effect, while the retained actual step separately records quiescence engagement and whether
application completed. DESCRIBE TRANSACTION renders those same typed facts in text or JSON; an
uncertain engagement remains uncertain on inspection. DESCRIBE WASM PROCESSOR is a separate
read-only observation of the current scheduled lifetime and owner checkpoint progress. Its client
outcome carries the typed state inspection beside the text message, and reading it does not advance
checkpoint durability or change a retained command outcome. FORMAT JSON renders that same typed
state inspection as JSON while retaining it in the client outcome.
sequenceDiagram
participant C as Client
participant S as Session
participant R as Raft transaction
participant E as Effect owner
C->>S: statement + reference + expected position
S->>S: validate against staged prefix
S->>R: append semantic statement
R-->>C: staged OK
C->>S: COMMIT + execution reference
S->>R: OPEN -> COMMITTING
loop each ordered step
S->>E: prepare and apply
E->>R: durable effect progress
E->>E: activate, drain, release gates
E->>R: application complete
end
R->>R: record COMMITTED outcome
R-->>C: terminal commit result
A leadership change resumes a COMMITTING transaction from its recorded applying step and frozen
plan. It does not replan, repeat a completed effect, or advance past an incompletely applied effect.
A reconnecting commit waiter attaches to the transaction and continues waiting for that terminal
result. A definitive failure records one FAILED outcome, the failing step, and the committed
prefix. The final operation and topology report remains with the transaction tombstone until the
configured retention boundary.
Effect-specific completion
Domain, user, resource-catalog, model, and lookup creation waits for authoritative visibility.
Stopped domains and empty graphs still apply their definitions on every live node. Hash-map lookup
creation also waits for local resource loading and index construction, so its first LOOKUP may be
issued immediately after success. A malformed source resource fails the creating command.
Model creation, alteration, and removal waits for the classified quiescence and activation work. Dynamic changes preserve live execution, entity changes drain only affected entities, and topology changes pause and drain the domain. Success follows schedule application, ownership handoff, runtime readiness, and gate release. A no-op reapplies the current required state before returning.
START waits until every listener and assigned source has completed its startup boundary. STOP
waits for remote source and listener teardown, including when it stops the cluster’s last running
domain. Paced-domain clock authority is part of the same revision. TLS-changing model effects wait
for every affected listener to install the configuration. Each node installs the TLS VHOSTs of a
runtime revision before it reports that revision prepared, and the command then confirms the
installation on every live process incarnation. A failed installation fails the command. A batch
that did not pause is rolled back by the same record that stores the failure, and the command waits
until every listener presents the restored certificates; a paused batch keeps its committed models
like any other activation failure. See
The DYNAMIC TLS Refresh for what the listener
presents and when.
An upload is complete after the entire declared body is admitted, its archive and manifest verify, and the exact digest is atomically installed on every live node incarnation. A complete admitted upload continues after caller disconnect. An interrupted partial body has not admitted an effect and may be retransmitted with its identity. The final result reports one assigned version and the same upload identity. Resource descriptions expose current per-incarnation installation diagnostics for observation, rather than serving as an extra completion step. Resource Versions And Bindings defines how the live set is derived, how nodes that join while an upload applies are included, and what a completed version may be bound to.
Cordon and uncordon wait for the eligibility change to become authoritative. Drain and relocation wait for ownership transfer, destination activation, source drain, and handoff release. Node removal waits for membership and all resulting schedules to become authoritative and usable.
Disconnects, deadlines, and recovery
The gRPC and WebSocket transport loops keep control frames, server events, subscription delivery, and close detection moving while an ordered command worker waits. Disconnecting either transport ends the session’s waiters. An unclean end releases the session’s transaction binding, and a clean close with no request in flight reverts the open transaction bound to it. Service-owned command, commit, and fully admitted upload tasks keep running. Client Session Protocol defines how a client recovers each of them by its identity.
sequenceDiagram
participant C1 as Original connection
participant S1 as Leader before change
participant R as Replicated progress
participant S2 as Current leader
participant C2 as Reconnected client
C1->>S1: command + stable reference
S1->>R: admit and begin applying
C1-xS1: disconnect / deadline
S1-xR: leadership lost
R-->>S2: applying effect and progress
S2->>S2: resume remaining application
C2->>S2: same command + same reference
S2->>R: join existing execution
S2->>R: record terminal outcome
R-->>C2: exact retained result
A caller deadline limits that caller’s wait. It does not cancel admitted work or create a terminal failure. Clients preserve the same reference through redirects, reconnects, and uncertain transport outcomes. An expired reference produces an explicit result and never causes automatic re-execution under a new identity.
The public behavior is specified in NSPL command completion. The corresponding public scenario inventory is maintained in the repository’s command-completion acceptance ledger.
Resource Versions And Bindings
A resource version is an immutable directory tree that the cluster verifies and installs on every
live node before anything can use it. A binding is the part of a model that selects one of those
versions: a TLS VHOST, a protobuf codec or signaling protocol, an inferencer, a WASM processor, a
hash map, or a client mount. This chapter defines the contract that joins them: who owns each piece
of state, how a version becomes usable, what each consumer loads and when, how VERSION LATEST
resolves, how REBIND RESOURCE moves bindings, and what a failure leaves behind.
The central rule is that a version change is a model change. Every stored model names the concrete completed version it binds, and its consumers load exactly that version. An upload only adds a version to the catalog. A running consumer moves to another version only when a validated, classified, and atomic model mutation rebinds it.
This chapter describes the architecture. Resources owns the NSPL statements, the upload format, the per-version limits, and examples.
Ownership By Layer
| Layer | Owner | What it owns |
|---|---|---|
| Vocabulary | Resource identities | A version is identified by its domain, resource name, and number. A statement writes VERSION <n> or VERSION LATEST; a stored model holds only a number. |
| Control plane | The resource catalog in consensus | For each domain: its declared resources and their version sequences, the metadata of every published version, upload outcomes keyed by upload identity, and one replica record per version and node process incarnation. |
| Control plane | Upload installation and replication | Admitting an upload, assigning its number, installing it on every live node, and recording its terminal outcome. |
| Engines and infrastructure | The resource store on every node | Staging an archive, verifying its digest, quotas, and manifest, and atomically promoting a complete version into its local directory. |
| Engines and infrastructure | The interconnect | Streaming a version’s archive from a node that holds it to a node that does not. |
| Decisions | Registry validation and the transaction planner | Resolving LATEST, rejecting a version that is not completed, rebuilding the models a rebinding selects, and classifying the change into the plan a commit applies. |
| Data plane | Runtime consumers | Loading the version their execution plan names from the local store: descriptors, an inference session, a compiled module, a hash-map index, or a client mount. |
| Edges | The HTTPS listener | Presenting the certificate every TLS VHOST pins, on every live node. |
The catalog is domain-owned like every other entity. The same resource name in two domains is two resources with independent version sequences, stored content, and replica records, and a model resolves a resource only in its own domain.
The resource store keeps each version under <db-path>/resources, in one directory per domain,
resource, and version number. It installs bytes and reports what it holds. It does not know how a
version was uploaded, which node sent it, or which models use it.
Every resource keeps a directory of its own. A resource name may begin with a dot, as . and ..
do, and the store names its own staging directories with a leading dot, so such a name’s directory
writes that dot as %2E: the versions of a resource named .. sit in %2E.. % is outside the
name alphabet, so no other resource’s directory has that name. A domain name holds no dot, and its
directory is its name.
Registry validation and the transaction planner decide from the catalog inputs captured with a plan: the declared resources of the domain and its completed versions. Before a plan commits, the leader also checks three kinds of content against its own copy of a version: a VHOST’s TLS bundle must load, a hash map’s file must exist at its path, and an inferencer’s ONNX model must load and match the tensors the inferencer declares. The rest of a version’s content is proven by the consumers that load it.
Domain activation resolves codec, signaling-protocol, and VHOST bindings into typed specifications with their pinned resource identity before a node installs them. Runtime compilation loads that exact version from the local store; the HTTPS listener does the same for the VHOST certificates. Neither path reads the catalog to choose a version. Running and stopped domains use the same activation decision, so a stop, restart, or reassignment cannot select another version.
The HTTPS listener runs on every live node, independent of Raft leadership and graph placement, like every other entity that binds a configured listening port. It presents the TLS VHOSTs of the runtime revision its node applied.
Visual client-mount and VHOST-TLS forms select a resource and one of its completed versions through
typed session choices. Their drafts may submit LATEST; neither form resolves it locally or
creates resource content. The server applies the same version validation and pinning as the NSPL
command path, so the rendered stored Model and every listener use a concrete version.
Version Lifecycle
Assignment
CREATE RESOURCE declares a resource in the selected domain and starts its version sequence at 1.
Uploads are leader operations. A client-protocol upload declares the exact size of the archive it
streams, and a follower redirects it to the leader. A web-console upload streams the selected files
to the leader, which builds the archive itself; a follower refuses it.
The native client’s directory archive is completed and flushed locally before its upload call opens. A failed or cancelled construction discards its partial temporary archive and assigns no version. Once built, that exact archive is retained for every retry within the upload call.
A number is assigned when the leader admits an upload. Once the complete body has arrived, the leader holds the archive’s digest and records the upload under its identity in one consensus command, which takes the next number of the sequence. A body that ends early or exceeds its declared size is not admitted, assigns nothing, and leaves its identity unused.
A number is never reused. A failed upload keeps the number it was assigned, so the completed versions of a resource can have gaps. A sequence that reaches the largest representable number rejects further uploads rather than wrapping.
An upload identity is scoped to the authenticated user, the domain, and the resource. Repeating an upload with the same identity and the same archive digest continues or reports the upload already recorded under that identity, with the number already assigned; the same identity with a different digest is rejected as a digest conflict. An upload admitted through the client protocol keeps installing when the uploading connection closes. The identity rules and client retries are covered in Resources and Command Completion, and the upload stream, its typed failures, and its recovery in Resource Uploads.
Installing One Copy
The leader’s first copy and every replica go through the same verification in the resource store:
- The archive is staged under the archive quota and hashed with BLAKE3 as it is written, and its digest must match the version’s root checksum, the BLAKE3 digest of the archive. An archive that arrived with a declared size, over the client protocol or from another node, has already been rejected if its length differed.
- Entries are extracted into a staging tree. Only directories and regular files are accepted, every path must stay inside the version, and no path may appear twice. The extracted-byte and file-count quotas are checked from each entry’s tar header before that entry is written.
- The store records a manifest of every directory and file, with each file’s size and checksum. The leader’s manifest defines the version’s metadata: its root and manifest checksums, file count, and extracted and archive byte counts. A replica must reproduce exactly the metadata the leader published.
- The manifest, the extracted tree, and the archive are synchronized to disk, and the staging directory is renamed into place as the version’s directory. An earlier copy of the same version in that place is removed first.
A failure at any step removes the staging tree, so a partially installed version is never visible. Each node keeps the verified archive beside the extracted tree, which is what it serves when another node fetches the version. The quotas and their defaults are listed in Resources; each node enforces them itself.
Publication And Transfer
After the leader installs its own copy, one consensus command publishes the version: it stores the version’s metadata together with the leader’s ready replica record. From then on every node knows the version and can see which nodes hold it.
Every node reconciles its store with the catalog in repeated passes, starting each pass a second after the previous one finished. For each published version its current process incarnation has not recorded as ready, it first reads its own store: a local copy whose manifest matches the published metadata is recorded as ready without another transfer, which is how a restarted node reclaims what it installed before. Otherwise the node records a pending replica, fetches the archive from a live node whose replica is ready with the same digest, installs it through the steps above, and records a ready or failed replica with the reason. A node fetches at most four versions at a time, and when no live node holds a ready copy it records a failed replica. The leader accepts a replica record only from the node that record describes.
Archives travel as streamed bulk transfers with an exact declared length, in bounded chunks under HTTP/2 flow control, so neither end holds a whole archive in memory. An interrupted transfer is not resumed; the next reconciliation fetches the archive again from the beginning. Cluster Interconnect defines the bulk pool, its reserved resource streams, and the resource transfer quota in Traffic And Resource Isolation, and the streamed response and its deadlines in Exchange Forms.
Completion
The leader completes an upload when every current live node process incarnation has recorded a ready replica with the version’s digest. It derives that set from effective live membership each time the catalog or the membership changes, keyed by node name and process incarnation:
- A node that becomes live while the upload is applying joins the set, and the upload waits for its copy.
- A node that restarts under the same name is a new incarnation and must record its own ready replica. Its reconciliation normally does so from the copy already in its store.
- A node leaves the set only when the cluster’s availability policy marks it unavailable. A missed probe or a stale observation does not waive its copy.
The first failed replica recorded by a live incarnation fails the upload at once, with that node’s reason. Otherwise there is no completion deadline: an upload stays applying until every incarnation in the set has recorded a ready replica, and a caller that stops waiting does not stop it.
The leader records the outcome in consensus. A failed upload is reported as soon as that record
commits. A completed upload is reported only after the outcome is visible on every live node, and
after the leader has evaluated the live set once more, so that a node that joined while the outcome
propagated also holds the version before UPLOAD RESOURCE returns uploaded resource version <n>.
If either of those final waits fails, because visibility timed out or a live incarnation recorded a
failed replica, the command reports that failure although the version stays completed. The recorded
outcome is terminal: a failed version never becomes completed, and a completed version never fails.
If an applying upload loses the task that was installing it, because leadership moved or a console
upload’s connection was aborted, the leader resumes it at its next reconciliation. An upload whose
version was never published fails with
the admitted archive is unavailable before durable version installation, because only the node
that admitted it held the archive. A retry of the same upload identity that reaches the leader
before that reconciliation carries the archive again, so it installs the version and completes the
upload instead. A published version keeps waiting for the live set as above.
A node that joins after completion does not change the outcome. Its reconciliation installs every
published version it does not hold from a live node that holds it, and
DESCRIBE RESOURCE <name> VERSION <n> shows its replica converge. Nothing else waits for that
installation. Until the node holds a version, building a domain that needs it fails on that node,
so the node does not report the runtime revision prepared, and commands that wait for every live
node wait for it. The node builds the domain again the next time it applies the cluster’s runtime
state, such as after a later schedule or domain change.
Bindable Versions And Latest
Only a completed version can be bound. An explicit VERSION <n> must name a completed version of
the resource in the statement’s domain. An applying or failed version is rejected as
resource '<name>@<n>' is not a completed version in domain '<domain>', and a number with no
upload there as resource '<name>@<n>' does not exist in domain '<domain>'.
LATEST is the highest completed version. A newer version that is still applying or has failed
never displaces it. A resource with no completed version cannot be bound through LATEST either:
resource '<name>' has no completed versions in domain '<domain>'. DESCRIBE RESOURCE reports the
version LATEST would select at that moment as latest:.
stateDiagram-v2
[*] --> Assigned: leader admits the complete archive and takes the next number
Assigned --> Failed: the leader's own installation fails or its installing task is lost
Assigned --> Published: the leader installed its copy and published the metadata
Published --> Published: a live incarnation installs, restarts, or joins
Published --> Completed: every live incarnation holds the same digest
Published --> Failed: a live incarnation records a failed replica
Completed --> Completed: a later node installs its copy
note right of Completed
Bindable. The highest completed number is LATEST.
end note
note right of Failed
Never bindable. The number stays assigned.
end note
Nervix does not delete or expire versions. Every published version, including one that failed after publication, stays in the catalog, and every node keeps installing each published version it lacks.
Restored Versions
A restore brings a domain’s catalog from a backup archive instead of uploading it; see Backup And Restore. When it creates the domain, it declares every resource the archive declares with the next number of its archived sequence, so the next upload of the resource receives the number the source cluster would have assigned next. It then imports each completed version of the archive under its archived number, one at a time:
- The leader installs the version’s original upload from its section of the restore’s archive, through the same verification as any first copy. The archive must have the version’s archived root checksum, and the manifest the leader records must reproduce every archived checksum and count exactly.
- One consensus command publishes the version under its archived number, with its archived metadata, creation time, and creating node, together with the leader’s ready replica record. It records the import as the upload of an identity derived from the version’s number, owned by the user who ran the restore. It refuses a number at or above the declared sequence, a number already published, and a replica other than the leader’s ready copy with the version’s digest. Importing the same number again under the same identity changes nothing, which is what lets a resumed restore repeat an import whose outcome it did not see.
- The version completes as an upload completes: when every live node process incarnation has recorded a ready replica with its digest. The restore waits for that before it imports the next version, and applies the domain’s models only after every version is completed.
A number the archive records for an upload that failed, or was still installing when the backup
read it, is not imported. It stays below the declared sequence, so it stays a gap that no upload
receives and no binding can name. A restored catalog therefore holds only completed versions, and
DESCRIBE RESOURCE in the restored domain shows the same numbers, gaps, and checksums as in the
source, with the replicas of the restoring cluster’s own nodes.
Bindings
Seven model forms bind a resource version, and each requires a VERSION <n> or VERSION LATEST
clause. Omitting the clause is a parse error.
| Binding | What it consumes from the version | When it loads it |
|---|---|---|
VHOST ... WITH TLS | tls.crt, tls.key, and ca.crt at the version root | When a node installs the TLS VHOSTs of a runtime revision into its HTTPS listener: on every live node, for running and stopped domains |
Protobuf CODEC | The .proto sources its configuration selects, or every .proto file in the version when it selects none, compiled into descriptors | When a node builds the domain’s execution: on every live node, for running and stopped domains |
Protobuf SIGNALING PROTOCOL | The .proto sources it selects the same way, compiled into the descriptors of its send and wait messages | When a node builds the domain’s execution: on every live node, for running and stopped domains |
INFERENCER | The ONNX model file it names | When a branch instance first flushes output; each branch instance holds its own inference session |
WASM PROCESSOR | The module file it names, compiled into machine code | When a node builds the execution of a running domain or replaces the processor; a node keeps the compiled module only while the schedule assigns the processor to it as owner or replica, and each branch instantiates it when it first needs its guest |
HASH MAP | The file it names, decoded line by line through its codec into an in-memory index | When a node builds the domain’s execution: on every live node, whatever the schedule assigns, for running and stopped domains |
CLIENT ... MOUNT | The version’s content directory, linked into a temporary mount root; the connector reads the files it names from there | Whenever the client is instantiated, such as by an ingestor or emitter when it starts, or once per node for a pooled client; each client instance keeps its mount root for its lifetime |
Registry planning derives typed lookup and WASM resource inputs from each committed schedule. The lookup input carries the exact resource identity, version, file path, codec, and key field. The leader rejects a missing file during candidate binding validation. Running and stopped domain builds use the same pinned input; the loader reports invalid decoded content before publishing the new runtime. A WASM input carries the exact module resource identity, version, and file. Running builds and entity swaps prepare that module from the scheduled input before replacing execution. The guest-state generations remain in the same scheduled processor revision and are part of its prepared-plan identity.
An HTTP emitter validates its referenced client’s origin, attempt timeout and paired client
certificate/key settings before its candidate graph activates. A TLS path rendered from CLIENT ... MOUNT still names the pinned version in the client Model; validation does not fetch a newer
version or probe the remote HTTP endpoint. Client instantiation reads the mounted CA or identity
files at the existing load boundary above.
Replacing only the emitter’s client reference takes an entity pause: admitted requests finish through the existing client’s pinned mount before the replacement opens the newly selected client. Changing that client’s definition or rebinding its mounted resource takes the ordinary configuration-entity domain pause. If the drain cannot complete, the candidate definition and its resource binding are not installed.
The Pinning Invariant
Three rules together guarantee that a consumer uses exactly the version its model names:
- The persisted model names the number. Planning resolves the written form before a model is
stored, and a stored model holds only the resulting number. The written
LATESTis kept only in the transaction record of the statement that carried it, which is never planned again once that statement is applied. - The execution plan carries it. The decision layer derives each typed consumer plan from the committed schedule, including the pinned number. Runtime installation binds that plan to the node’s local store without reading a Model; see Execution Plans.
- No consumer consults the catalog. A consumer resolves its number to a directory in the local store and loads it. It never asks which version is newest.
A rebuild, a restart, a failover, and a relocation derive typed consumer plans from the same committed models, so they load the same versions. Only a model mutation can change a number.
LATEST Resolution
LATEST is resolved by the planner when a statement is applied, against the catalog inputs
captured for that plan. It never resolves at runtime.
- A standalone statement runs as a one-statement command transaction and resolves
LATESTwhen it is applied. The result names each resolution:resolved VERSION LATEST of resource '<resource>' to version <n> for <kind> '<name>'. - A statement queued inside
BEGINis planned at admission against the transaction’s prefix so that it can be validated. That resolution is provisional and is reported asprovisionally resolved VERSION LATEST ...; the queued statement keeps the writtenLATEST.COMMITplans the whole transaction again as one preview from a fresh snapshot, and admission freezes that plan, soLATESTbinds the highest version completed when the admitted preview was planned, which may be newer than the provisional one. REBIND RESOURCE ... TO VERSION LATESTfollows the same timing and resolves its target once for the whole statement. Its summary line marks the resolved target with(latest).
The captured inputs include the domain’s declared resources and all of its completed versions, and
they are compared with the current catalog twice: by the leader just before it applies a step, and
again at the replicated apply boundary. An upload that completes in the domain in between, for any
of its resources, makes the plan stale. The step is then rejected as
domain '<domain>' resource inputs changed before any model is committed. The leader’s check runs
before anything pauses; a rejection at the replicated boundary first releases the pause, gates, and
handoff the step had engaged and rolls back the leader’s local registry.
What follows depends on who owns the plan. An ordinary command retains that failed attempt and
starts another attempt, derived from the newer revision, under the same execution reference, so it
plans again and LATEST binds the version that completed. An explicit transaction is frozen from
COMMIT admission onwards: a basis that is already stale at admission leaves the transaction OPEN
with a refreshed identity; the Rust client requires an inspection of the attached transaction
before retrying against that identity. A conflict found after admission ends the
transaction instead of replanning it.
Either way, a plan never commits a LATEST it resolved against an older catalog.
Control Plane defines preview identity,
admission, and the frozen plan.
Rebinding
REBIND RESOURCE is a model mutation. It changes the version the selected models bind and nothing
else: it does not touch the catalog, and every other field of each model stays as written.
Its operation reasons, effective model-run pause, resource-binding effects, and retained
attribution use the shared Transaction Quiescence And Impact Inspection
report. This chapter owns version selection, pinning, activation, and rebinding failure semantics.
Selection And Validation
The planner resolves the statement against the part of the transaction that precedes it, so it sees models that earlier statements of the same transaction create or drop.
- The resource must be declared in the domain, or the statement fails with
resource '<name>' does not exist. - The target resolves to one completed version exactly as a binding does: an explicit number must
be completed, and
LATESTselects the highest completed version. - Without
FOR, the selection is every model in the domain that binds the resource. WithFOR, it is exactly the listed members. Each member must exist (<KIND> '<name>' does not exist in domain '<domain>') and bind the resource (<KIND> '<name>' does not bind resource '<resource>'). A repeated member is a parse error. Written order has no effect. - The planner rebuilds each selected model with the target number. A usage that already binds the
target contributes no change and is reported as
unchanged. - The rebuilt models pass the same checks as
CREATE: registry validation of the complete candidate graph, and the leader’s content checks for each changed VHOST, hash map, inferencer, and WASM processor against the target version. One failing model rejects the whole statement, and the error names it, for exampleINFERENCER '<name>' binding validation failed in domain '<domain>': ....
A rejection at any of these steps happens before any effect: no model is stored, nothing pauses, and every usage keeps its version.
A WASM processor’s module is compiled before the statement can commit, because activating a module binding also replaces the guest state of every branch: a module discovered to be uncompilable afterwards has already invalidated the state its predecessor saved. The compiled module the check produces is the one activation installs, so nothing is compiled twice.
The remaining content that only a consumer can prove is proven when nodes build the consumers from the committed schedule’s typed plans: the protobuf sources a codec or signaling protocol compiles, and every line of a hash-map file. A client mount only needs the version’s directory; the connector reads the files it names when it uses them. A version that fails during activation fails the command after its models were committed, as described in Failure Semantics And Recovery. A stopped domain builds only its codecs and hash maps, so an unusable version bound by one of its other consumers surfaces when the domain starts.
Classification And State Effects
The rebuilt models are classified like any other model change, and the statement runs at the highest level among the usages it changes:
| Usage | Level | State effect |
|---|---|---|
VHOST TLS | DYNAMIC | Every node’s HTTPS listener installs the new certificate. No execution node pauses or restarts. |
Protobuf CODEC, SIGNALING PROTOCOL | DOMAIN_PAUSE | The domain pauses and rebuilds with descriptors compiled from the new version. |
HASH MAP | DOMAIN_PAUSE | The domain pauses and every live node builds the index of the new version before the command succeeds. |
CLIENT mount | DOMAIN_PAUSE | The domain pauses and rebuilds; its ingestors and emitters instantiate their clients with a mount of the new version. |
INFERENCER | ENTITY_PAUSE | The inferencer and its downstream pause. The replacement’s branch instances load new inference sessions from the new model file. |
WASM PROCESSOR | ENTITY_PAUSE | The processor and its downstream pause, and each branch is flushed. Every branch starts a new guest-state generation, so the replacement instances, created from the new module, initialize without guest state. The candidate module is compiled before the statement commits. |
Control Plane defines what each level
pauses and drains. A rebinding in a stopped domain has no running work to pause, so it runs and
reports DYNAMIC. A stopped domain still keeps its codecs compiled and its hash maps loaded on
every node, and its VHOSTs in every listener, so those move to the new version before the command
returns; signaling protocols, inferencers, WASM processors, and client mounts load it when the
domain starts. A WASM processor’s new guest-state generations are published with the rebinding
even while the domain is stopped, so the processor starts without guest state.
The shared impact report records the rebinding as one operation. It contributes one rebinding reason, carrying the previous and the target number, for every selected usage, including unchanged ones, and the changes, pauses, activations, and state resets it causes are attributed to that operation.
Activation, Atomicity, And Rollback
All changed models are committed together with the resulting schedule in one step, under the domain’s alteration lock, after the quiesce their level requires. Activation then follows the ordinary model-change path: each consumer is rebuilt or refreshed from the committed models, and the command completes only when every live node has applied the revision, every live node’s listener has installed the certificates of an affected VHOST, and every gate the step engaged is released. The successful response is the boundary at which every selected usage uses the new version; see Command Completion.
The models of a rebinding are committed all together or not at all. A failure after commit follows the rule for any model step:
- An HTTPS listener failure in a step that did not pause, such as one that moves only VHOSTs, rolls the step back in the same consensus entry that records the failure: the previous models and schedule are restored, and the command waits until every listener presents the restored certificates before it reports the failure.
- Every other activation failure, and a listener failure in a step that paused, keeps the committed models. The step releases any pause it engaged and reports the failure, like any other model change whose activation fails.
Transactions And No-Ops
REBIND RESOURCE is ordinary transaction content. Inside BEGIN, it joins the consecutive model
run it is written in, so a transaction can create a model and rebind it, or rebind several
resources, as one atomic step, and the run is classified as a whole. Its target and selection are
provisional at admission and are planned again in the preview that COMMIT admits and freezes.
A rebinding that changes no usage, because every selected usage already binds the target or the
resource has no usages at all, writes no model. On its own it reports DYNAMIC and lists every
selected usage as unchanged:
rebound 0 of 1 usage(s) of resource 'lookup_bundle' to version 2
quiesce level: DYNAMIC
- kind=hash_map name=lookup_by_id from=2 to=2 unchanged
Certificate Rotation
Rotating a VHOST certificate combines both halves of the contract: an upload that changes nothing, followed by a rebinding that moves the VHOST.
sequenceDiagram
participant C as Client
participant L as Leader
participant R as Raft state
participant N as Other live nodes
C->>L: UPLOAD RESOURCE tls_bundle (complete archive)
L->>R: assign version 2 (applying)
L->>L: verify, install, and promote its copy
L->>R: publish version 2 with the leader's ready replica
N->>L: fetch the archive over the bulk class
N->>N: verify, install, and promote
N->>L: ready replica for this incarnation
L->>R: record the replicas, then version 2 completed
L-->>C: uploaded resource version 2
Note over L,N: every listener still presents version 1
C->>L: REBIND RESOURCE tls_bundle TO VERSION LATEST
L->>L: resolve LATEST to 2, rebuild the VHOST, load its bundle
L->>R: commit the rebuilt VHOST and schedule (DYNAMIC)
R-->>N: runtime revision
L->>L: install listener certificates, report prepared
N->>N: install listener certificates, report prepared
L->>N: https_listener_installation(revision)
N-->>L: installed
L-->>C: rebound 1 of 1 usage(s) ... quiesce level: DYNAMIC
Note over L,N: new handshakes present version 2, established connections continue
Uploads Change Nothing That Runs
An upload is a catalog operation. It adds one version and ends with that version installed on every
live node, and it refreshes nothing. Catalog changes do not advance the runtime revision that nodes
apply, so no listener, compiled descriptor, index, session, module, or mount is rebuilt because a
version completed, and no binding follows an upload. DESCRIBE RESOURCE shows the new latest:,
while every model keeps the number it pins.
This keeps two concerns apart. An upload proves that the bytes arrived intact everywhere; whether
they are usable is a property of the model that binds them. A version whose content no consumer can
use, such as a malformed TLS bundle, completes like any other upload, and every existing binding
keeps its version. The problem is reported only when a statement binds that version: the leader’s
content checks reject it before any effect, for example with
invalid TLS resource for VHOST '<name>' in domain '<domain>' from '<resource>@<version>': no certificates found in TLS CA certificate,
and content that only a consumer proves fails the statement’s activation. TLS failures identify
the certificate, private key, or CA certificate by kind without exposing its filesystem path or
material in the command result or server log.
The DYNAMIC TLS Refresh
Every node’s HTTPS listener presents the TLS VHOSTs of every domain in the runtime revision that
node applied, including domains that are stopped, because a stopped domain keeps its schedule. For
each revision the decision layer supplies the listener with VHOST specifications whose TLS resource
identities already include the pinned version. Endpoint host and path decisions use those same
VHOST hostnames, while stopped domains keep their routes inactive until they start. For
each TLS VHOST the listener loads the pinned version’s certificate chain and private key and
presents them to clients whose SNI names one of the VHOST’s hostnames. The certificate must be valid
for every one of those hostnames; the leader’s content check does not compare them, so a
certificate that does not name a hostname fails at installation. ca.crt must parse, but the
listener neither presents it nor asks clients for a certificate. Until a node has installed its
first revision after it starts, and whenever no TLS VHOST exists, its listener has no certificates
and refuses TLS connections.
A node installs the listener certificates of a runtime revision before it reports that revision prepared. The installation replaces, in one step, the configuration that new connections handshake with:
- Established connections keep the session they negotiated and are not interrupted.
- New handshakes present the new certificates.
- A revision whose set of TLS VHOSTs is unchanged keeps the configuration already presented.
- The configuration is built from every TLS VHOST at once, so a bundle that the node cannot load, in any domain, fails the whole installation on that node.
- Each revision is attempted once. A failed installation leaves the previous certificates in place, is recorded with its reason for that revision, and is attempted again at the next revision.
A command that creates, changes, or drops a VHOST confirms the installation on every live process
incarnation through the https_listener_installation management request described in
Cluster Interconnect. A failed installation on
any node fails the command with
failed to install the HTTPS listener TLS configuration on node '<node>'. When the step did not
pause, as for a version-only change, the creation or removal of a VHOST, or any VHOST change in a
stopped domain, that failure rolls the step back as described above: the error states that the model
batch was rolled back, and every listener presents the restored certificates before the command
returns.
Of the changes to an existing VHOST, only a move to another version of the resource it already
binds is DYNAMIC; creating or dropping a VHOST is DYNAMIC as well. Endpoint routing reads a
VHOST’s hostnames but not its certificate, so a version move is applied as a dynamic schedule
update: a running domain’s buffered work is force-flushed, but no execution node pauses, restarts,
or is rebuilt. Changing hostnames, adding or removing WITH TLS, and binding another resource
change the VHOST’s configuration rather than only its certificate. They stay DOMAIN_PAUSE, like
the other configuration entities whose consumers read them when the domain is built, and the domain
rebuilds around the new model. Every listener still installs the resulting certificates before such
a command succeeds.
Guarantees And Limits
The contract provides six guarantees:
- A running consumer uses exactly the version its model names. The number is stored in the model, carried in the execution plan, and loaded from the local store.
- A version change is a validated, classified, atomic model change. It is planned from one
captured snapshot, validated like a
CREATEof the rebuilt models, classified at the level of the usages it changes, and committed for every selected usage or for none. - An upload changes nothing that is running. It adds a version to the catalog and installs it, and no consumer reloads because of it.
- Only completed versions bind.
CREATEandREBIND RESOURCEaccept a version only after its upload completed on every live node incarnation, andLATESTmeans the highest completed version. - Rebuild, restart, failover, and relocation cannot move a binding. They rebuild consumers from the stored models, which name the same numbers.
- Rebinding never crosses domains. A rebinding resolves the resource, its versions, and its usages in the selected domain only.
Nervix does not provide:
- Guest state carried across WASM module versions. A generation of guest state belongs to the module binding it was published for. A model change that binds a processor to another resource, version, or module file, such as a rebinding, starts a new generation for every branch in the same schedule publication, so no checkpoint saved under the previous module can attach to the new one and every branch starts without guest state. A rebinding that leaves the processor’s version unchanged keeps its state. See State Generations and Rebinding And Rollback.
- Version deletion or retention. Versions accumulate in the catalog and in every node’s store. Plan storage for every version ever uploaded.
Failure Semantics And Recovery
| Failure | Outcome |
|---|---|
A statement binds a missing, applying, or failed version, uses LATEST with no completed version, names an unknown resource, or lists a missing or non-binding FOR member | The statement is rejected before any effect. No model is stored, nothing pauses, and every usage keeps its version. |
| A VHOST’s TLS bundle does not load, a hash map’s file is missing, an ONNX model does not match its inferencer, or a WASM module does not compile | The leader’s content checks reject the statement before any effect. |
| An upload completes in the domain between capture and apply | The step is rejected as stale before any model is committed, and whatever it engaged is released. An ordinary command plans again as another attempt under the same execution reference; an explicit transaction reports the conflict instead of replanning. |
| An HTTPS listener cannot install a VHOST change in a step that did not pause | The failure record restores the previous models and schedule, and every listener presents the restored certificates before the command reports the failure. |
| A consumer cannot load the version during activation, such as protobuf sources that do not compile or a hash-map line that does not decode | The step releases any pause it engaged, the committed models remain, and the command or transaction reports the activation failure. Nothing returns the models to their previous versions; a later model change, such as a rebinding to a usable version, repairs the binding. |
| One node cannot install a version during its upload, including a transfer that is interrupted | The upload fails with that node’s reason. The number stays assigned and is never bindable. |
| A node lacks a published version | Its reconciliation fetches the version from a live node that holds it, from the beginning of the archive. Until it holds the version, that node cannot build a domain that needs it. |
Pinned bindings are what make recovery reproduce the same versions. After a process restart, a leader failover, a cluster restart, or a relocation, every consumer is rebuilt from the stored models and loads the number they name from the local store. The catalog is committed control-plane state, and a restarted node reclaims the versions already in its store by checking their manifests against the published metadata. Its HTTPS listener presents certificates again once the node has been admitted back into runtime execution and has installed the revision it applies.
Startup removes staging trees abandoned by an interrupted process, so an interrupted installation leaves no partial version, and closing a connection cancels an upload that is still waiting for its client. Shutdown And Recovery and its Stopping Intake section define both, together with what survives each kind of ending.
Observability
| Signal | What it shows |
|---|---|
DESCRIBE RESOURCE <name> | latest: as the highest completed version or (none); every published version, including one still applying or failed after publication, with its checksums, sizes, and the entries of the answering node’s copy; and usages:, one line per model that binds the resource with its pinned version. Session clients receive the same facts as a typed resource description beside the text: an absent latest version stays absent, each entry keeps its exact path, and an unreadable manifest is reported as unavailable rather than as an empty version. |
DESCRIBE RESOURCE <name> VERSION <n> | The version’s checksums, file count, size, and entries; one line per node with its topology, replica state, incarnation, checksum, verification time, source node, and error, taken from its live incarnation or, for a node that is not live, from its latest one; and the models pinned to that version, or none. |
SHOW CREATE | The stored number for every binding. A statement written with LATEST renders the number it resolved to. |
DESCRIBE HASH MAP | resource: <name>@<n> for the loaded version, together with the path, codec, placement, key field, and entry count. |
DESCRIBE WASM PROCESSOR | The module’s resource: and pinned resource version: beside the current guest-state generation and checkpoint status. A completed reset of the previous binding is not presented as a reset of the newly pinned module. |
| Command results | uploaded resource version <n>, every resolved VERSION LATEST ... line, and the rebinding summary with one from/to line per selected usage. |
| Web console | The highest completed version of each resource in the sidebar and, in the resource dialog, every version’s entries and the models bound to it, read from the typed resource description. See Web Console. |
| Server log | rebound resource usages at info once the leader has applied a rebinding’s step, before the step’s outcome is recorded, with the domain, resource, target version, and the counts of selected and changed usages; installed HTTPS listener TLS configuration at info whenever a node’s listener starts presenting a changed set of certificates. |
| Interconnect metrics | Archive fetches in flight and fetches refused for lack of capacity appear in nervix_interconnect_pending_operations and nervix_interconnect_quota_failures_total with operation="resource". Their bytes count toward nervix_interconnect_bulk_bytes_total{class="bulk"}. Consensus snapshot transfers share the Bulk pool but move as requests, which nervix_interconnect_requests_total{operation="snapshot"} counts instead. See Metrics And Observability. |
A version’s upload outcome is not a line of its own. latest: names the highest completed version,
and the per-node lines show which incarnations installed a version and why one failed; replica
installation writes no log lines of its own. Descriptions are observations: an upload or a
rebinding completes on its own completion boundary, and a caller never polls a description to
finish one.
Data Plane
The data plane is the runtime execution engine.
It executes the typed revision of a committed schedule. Execution Plans describes the decisions, local binding, and publication that prepare its tasks.
Connector Crates And The Connector Contract defines how the host admits external source messages and publishes through external sinks, including their ACK and commit boundaries. This chapter follows the Arrow batches those boundaries hand to the graph.
It is responsible for:
- receiving records from ingestors
- decoding payloads through codecs
- evaluating structured filters, construction expressions, and side-effect invocations
- grouping records into isolated execution branches
- batching rows into Arrow record batches at node boundaries
- moving Arrow batches across processors and relays
- materializing selected state
- encoding and emitting outbound payloads
The data plane is intentionally non-transactional.
Transaction commit can gate admission, drain or force-flush graph work, and activate a new schedule. Transaction Quiescence And Impact Inspection explains which work a planned scope covers and how engagement is reported; in-flight batches and ACK state remain volatile here.
Errors And Diagnostics defines how branch-local failures, materialized-state outcomes, message errors, and recovery classes reach their reporting boundary.
Decoded messages are processed in memory as Apache Arrow batches, including batches with one row. That gives the runtime a columnar format suitable for vectorized processing and batch transfer.
SIMD Kernels describes the typed buffer operations used by JSON boundaries, delivery-latency recording, domain and window admission, failure and selection bitmaps, integer arithmetic, and text codecs. Kernel dispatch changes how those operations execute; schema checks, branch ownership, acknowledgement attribution, and persistence remain the runtime’s contracts.
Nervix has three separate persistence boundaries:
- Execution graph configuration is control-plane state. NSPL models, domain lifecycle, and schedules are persisted with strong consistency guarantees before runtime nodes execute them. The control plane turns each committed schedule into a complete typed execution revision; the runtime retains its planned nodes, placement, and executable plans together and does not reread Models during application or recovery.
- Execution node state is runtime state. Selected state such as domain offsets, deduplicator history, materialized relay entries, window accumulators, and metric summaries is persisted through periodic snapshot/replication mechanisms, and WASM guest state through a durable checkpoint at the end of every guest callback. Each persisted state is keyed by the identity the committed schedule publishes for its node. Domain offsets and metric summaries depend on no schema and are keyed by their node alone, so they survive schema changes. Every other state is also keyed by the fingerprint of the schemas it is laid out by, so a schema change starts it anew, and a node without a published fingerprint for an entity cannot place that entity’s schema-bound state at all. A materialized relay’s snapshot is columnar: it carries the relay’s records as Arrow sections under the relay’s exact schema, with each record’s concrete branch key, watermarks, and the state revision, ownership assignment, and branch lifecycle it was captured at described beside them. A snapshot holds exactly the branches of the branch lifecycle it names and at least the committed revision it names: an update to an existing branch’s record that lands while the records are read may already be present, and the next revision carries it again. Updates to existing records continue while a snapshot is captured and written out; a branch that arrives or is evicted waits only while the records are read, and a snapshot taken before a branch was evicted never restores that branch. A snapshot larger than the transfer budget crosses the bulk pool in bounded chunks and lands on the receiving node’s staging disk, where its length and digest are checked before anything reads it. A cancelled, truncated, or corrupted transfer leaves the state it would have replaced untouched. A deduplicator’s keys and a window processor’s window belong to the branch task that processes the branch, which changes them without waiting on snapshots or replicas. While that state keeps changing, the task publishes an immutable copy of it at least once per replication poll interval, and it publishes again before an ownership handoff checkpoint and when the branch stops. Snapshots, replica synchronization, and the next task for the same branch read only the published copy, so a replica holds a branch’s state as of its latest publication. A branch task that is aborted after exceeding its shutdown grace period still has its latest publication persisted, but loses the changes it made after that publication. A published window carries the rows it retains together with each row’s aggregate arguments; restoring it re-admits those rows in order, which rebuilds every aggregate structure, and only a histogram percentile’s removals that are still waiting out their delay are carried beside the rows. A row’s acknowledgement completes when the window steps past it; until then its shares are parked, so neither a drain, an ownership handoff nor a backup cut waits for them. A quiesced backup’s lifecycle checkpoint also makes every deduplicator and window branch task publish, and the backup archives that publication as public Arrow sections: the deduplicator’s keys with the time each was first seen, and the window’s retained rows with their aggregate arguments and delayed removals; a restore rebuilds the native state from them, see Backup And Restore. A WASM processor checkpoints its guest state at the end of every guest callback and keeps the bytes the guest returned instead of copying them. Unlike the periodic snapshots above, a guest-state checkpoint is written to the node’s stable storage and synchronized, and when the schedule assigns the processor replicas, each of them writes and synchronizes it too before acknowledging it. The inputs a callback decided are acknowledged only after its checkpoint completes, and a checkpoint that fails leaves the previous one committed, negatively acknowledges those inputs, and recreates the branch’s guest from the committed checkpoint; see WASM Processor Guests and WASM State And Recovery. A guest saves only its computation state there, never the input it buffers or that input’s ACK tokens. Each branch’s guest state is keyed by its state generation, the lifetime the committed schedule names for it, so a checkpoint, a replica installation, or a recovered checkpoint of an earlier generation can never address the current state; the node’s state store writes guest state on its storage workers rather than on the async worker that runs the branch.
- Message streaming is the hot path. In-flight records, relay batches, processor handoff, outbound emitter attempts, ACK guards, ACK tokens, and ACK maps stay in memory and are never persisted as runtime state.
The Data-Plane Concurrency chapter defines how this hot path avoids shared lock acquisition, publishes reconfigurable state, owns per-row mutation, and retains only the ordering fences required by delivery and ownership contracts.
Every relay has one scheduled owner. Producers on other cluster nodes use one fixed dispatch slot per relay and serialize each batch once for the owner. The owner alone maintains the bounded relay buffer, concrete branch presence, metrics, subscriptions, and fan-out. It sends at most one serialized copy to each remote consuming cluster node, where all runtime consumers and any local subscription share that delivery. Only a relay’s optional materialized records have scheduler-selected state replicas; the relay’s hot-path runtime is never replicated.
Nervix is not a durable event log for every in-flight row. If hot-path message or ACK state is lost, sources and ingestors react according to their delivery mode, offsets, and retry policy.
Branch grouping is native runtime isolation based on explicit CREATE BRANCH declarations. A
branch declares the branch-key schema shape with SCHEMA <schema>, TTL, and optional eviction
policy. The branch name is part of its identity: differently named branches remain incompatible
even when they reference the same schema. Ingestor routes construct keys with BRANCHED BY <branch> SET ...; reingestor routes preserve the input key, construct another named branch, or
become unbranched. Relays and branch-preserving processors use that exact named branch or declare
UNBRANCHED. Relay presence, processor buffers, deduplicator state, window state, and materialized
entries remain scoped to one concrete branch; batches for those branches share the declared
relay’s owner buffer.
Structured Model expressions are compiled into typed VM programs. The leader validates them when a statement is applied, so invalid scopes, construction, types, nullability, sensitivity, or branch relationships fail at command time. Runtime nodes never reparse stored NSPL. A program executes over the Arrow batch its runtime node projects for it, reusing input columns for inherited or still-current values and constructing new columns only for rewritten or newly initialized output fields. VM Functions defines the compilation pipeline, columnar execution, row and batch errors, kernels, every function family, and branch-local window aggregates, and Expression Functions owns the public function contracts.
ACK Composition
Relay fan-out gives each attached runtime consumer a descendant of the incoming ACK state. Detached consumers receive the batch without an upstream ACK dependency. The source ACK succeeds only when all attached descendants succeed. Any attached failure fails the shared source attempt, even when another descendant has already completed an external side effect.
The relay owner holds a dispatch permit while it selects and fans out to attached consumers. If a schedule swap has already closed that gate before a buffered batch begins fan-out, the owner fails the batch’s ACKs and lets the source retry under the new schedule. This prevents a sibling attached consumer on the old node from completing the source ACK while a moving consumer is absent from the owner’s routes.
A batch that a relay owner routes to another node for its attached consumers carries record acknowledgements for them. If no attached consumer of the relay runs on that node when the batch reaches its runtime, because the consumer moved away after the owner routed the batch, the batch fails those acknowledgements instead of completing them, and the source redelivers the record along the current routes. See Consumers That Leave The Receiver.
A node that sent a record’s acknowledgement to another node waits for it only while that node keeps reporting it. When the other node reports nothing about it for fifteen seconds after admitting the batch, because its outcome was lost on the way back or its process ended, the acknowledgement fails and the source redelivers the record. An acknowledgement lost between two nodes on a record’s path therefore fails the source attempt instead of keeping it waiting. See Record Acknowledgements The Receiver Stops Reporting.
ACK guards, tokens, and maps remain in memory. They do not record a transactional per-sink commit ledger. After source redelivery, every attached path processes the record again. This is why an already successful non-idempotent sink can receive a duplicate after a sibling path fails. See ACK Semantics And Effective Delivery for the sink consequences and mitigations.
Every internal schema type has one Arrow representation, which relays, processors, and the VM share; Typed Batches And Registers lists them. The Expression Functions reference lists every operator and function.
Examples of replicated runtime state:
- Kafka offsets when using
OFFSET BY DOMAIN - deduplicator state
- materialized relay state
- metric summaries used by
DESCRIBEoutput - WASM guest state
Kafka partition scheduling for OFFSET BY DOMAIN is control-plane state instead. The leader observes Kafka topology, commits the partition-to-instance assignment into the Raft-backed domain schedule, and the data plane executes only that committed assignment.
Examples of state that is not treated as a durable commit log:
- normal in-flight relay batches
- ACK guards, tokens, and maps
- outbound emitter operations
- intermediate processor handoff
For relay movement between nodes, Nervix uses Arrow IPC batch serialization on the interconnect path. A producer or owner dispatch slot is scoped to one concrete branch and remains held until the receiving runtime atomically admits or rejects that batch. Other branches continue concurrently, while the attached ACK chain reports downstream processing after admission. A body receipt alone does not mean the runtime admitted the batch. Control traffic such as lookups and state-sync RPCs still uses separate control-envelope formats.
Runtime graph metrics are maintained alongside the data plane. Prometheus export uses branch-aggregated series to keep label cardinality bounded, while DESCRIBE can report branch-local metrics where a concrete relay branch is being inspected. See Metrics And Observability.
The runtime ownership above produces a per-branch resource cost. See Capacity Planning For Branched Graphs for the operator-facing cost structure and the current signal gaps.
This design keeps latency low and avoids turning the runtime into a transactional storage engine.
Execution Primitives
This chapter owns the execution primitive boundary: its layer, governed API families, backend selection, target capabilities, permissions, and build contracts. Data-Plane Concurrency owns the protocols callers build from those primitives, source-owned synchronization contracts, production-owner Shuttle and Loom checks, and diagnostic deadlock detection. Deterministic Interconnect Simulation owns the simulated network and its evidence.
Ownership And Capabilities
Every execution-sensitive primitive Nervix uses comes from the nervix-primitives crate, which sits
below the vocabulary and selects each family for the build’s execution mode: ordinary execution,
Shuttle, Loom, Turmoil or the Deloxide diagnostic mode. A build uses one mode across its whole
dependency graph. Every package that owns a shuttle, loom, turmoil or deloxide feature
forwards it to the primitive crate, and Cargo unifies
that crate’s features, so a vocabulary type, an engine and the server compiled into one test binary
all use the same backend. Selection depends only on features, never on cfg(test). Enabling two
modes fails to compile with a diagnostic that names both, including when two different dependencies
each enable one.
The primitive crate depends on the standard library and the backend libraries it selects. It
names no Nervix vocabulary, graph, policy or scenario. nervix-model-harness owns the exploration
of caller models and their invariant identities, separately from backend selection. The server
remains the composition root; enabling a mode changes how its dependencies execute without moving
a decision or a protocol into the primitive layer.
Ordinary execution re-exports the governed library items directly, with no boundary wrapper,
allocation, dispatch or scheduling point. Explicit mechanisms such as detached-thread creation
perform their named operation through ordinary primitives. The atomic and shared-ownership
families are portable and build for the
browser console. Everything else is the explicit native capability, and asking for a capability or
a mode the target cannot provide is a compile error rather than another implementation: a mode or
the native capability requested for the browser’s target fails with the boundary’s own diagnostic
as its first error, because the libraries of every mode and of the native capability exist only for
native targets and none of them is compiled there.
Backend Matrix
| Family | Path | Ordinary, Turmoil and Deloxide | Shuttle | Loom |
|---|---|---|---|---|
Atomic values, Ordering, fence | sync::atomic | The standard library’s | Modeled: every operation is a scheduling point, and every ordering behaves as SeqCst | Modeled: explored under the C11 orderings Loom supports |
| Shared ownership: Nervix-owned references, and the standard strong and weak references a weak reference or an external API such as Arrow or a Tokio semaphore requires | sync::Arc, sync::StdArc, sync::StdWeak | triomphe’s Arc, and the standard library’s Arc and Weak | Real, with no scheduling point: no reference count is modeled | Real, outside every model |
Async locks, semaphores and one-time cells; the mpsc, oneshot and broadcast channels | sync | Tokio’s | Modeled: Shuttle’s Tokio | Real: Tokio’s, outside every model |
Notify and the watch channel | sync | Tokio’s | Modeled: the boundary’s own, with Tokio’s semantics and a scheduling point before every registration, notification and deciding read | Real: Tokio’s, outside every model |
| The waker registration of one waiting task | sync::AtomicWaker | The futures crate’s | Opaque, with a scheduling point before and after each registration, wake and take | Real: the futures crate’s, outside every model |
| Cancellation tokens and their guards | sync | Tokio Util’s | Modeled: Shuttle’s token, wrapped for Tokio Util’s clone identity and owned operations | Real: Tokio Util’s, outside every model |
| Blocking locks and their condition variable | sync::blocking | parking_lot’s; under Deloxide, the boundary’s tracked adapters over Deloxide’s locks, described in Diagnostic deadlock detection | Modeled: Shuttle’s parking_lot locks, and the boundary’s condition variable over them | Real: parking_lot’s, outside every model |
Barriers, Once and the synchronous channel | sync::blocking | The standard library’s | Modeled: Shuttle’s | Real: the standard library’s, outside every model |
OnceLock | sync::blocking | The standard library’s | Opaque, with a scheduling point before and after each read and write; no initializing read | Real: the standard library’s, outside every model |
LazyLock | sync::blocking | The standard library’s | Unavailable | Real: the standard library’s, outside every model |
| Task spawning, joining, yielding, aborting, tracking and the cooperative budget | task | Tokio’s and Tokio Util’s | Modeled: Shuttle’s Tokio, with the boundary’s abort-on-drop handle | Real: Tokio’s and Tokio Util’s, outside every model |
| Blocking the runtime worker thread that calls it: the declared owners outside the executor | task::block_in_place | Tokio’s | Unavailable | Real: Tokio’s, outside every model |
| Running a CPU job the bounded executor admitted | task::spawn_cpu | Tokio’s blocking pool; under Turmoil, one task of the simulated host’s scheduler | Modeled: Shuttle’s spawn_blocking | Real: Tokio’s blocking pool, outside every model |
| Running a job on the blocking pool itself: the executor’s storage jobs, and the declared owners outside the executor | task::spawn_blocking | Tokio’s blocking pool | Modeled: Shuttle’s spawn_blocking | Real: Tokio’s blocking pool, outside every model |
| Timers and the monotonic clock: sleeps, deadlines, timeouts, intervals and instants | time | Tokio’s, following the clock of the runtime that polls them: the operating system’s, a test’s paused clock, or the simulated host’s under Turmoil | Shuttle’s: a sleep or an interval’s tick is one scheduling point that takes no time, and a timeout expires only when a check triggers it; Instant::now reads the operating system’s clock | Real: Tokio’s, outside every model |
Controls of a paused clock: pause, advance and resume | time, with the test-util capability | Tokio’s | Shuttle’s, which take no time | Real: Tokio’s, outside every model |
| Sockets: TCP listeners and streams with their owned halves, UDP and local sockets | net | Tokio’s over the operating system’s network; under Turmoil, Turmoil’s simulated sockets and lookup_host, which answers from the simulated DNS table, and no local socket or socket configured before it connects | Real: Tokio’s, outside every model. A Shuttle execution has no Tokio reactor, so a socket created inside a check panics and fails it | Real: Tokio’s, outside every model |
The runtime, #[nervix_primitives::test], #[nervix_primitives::main], select! | runtime, crate root | Tokio’s | Shuttle’s runtime and select!; the attributes build Shuttle’s runtime | Real: Tokio’s, outside every model |
| Streams over channels | stream | Tokio Stream’s | Shuttle’s Tokio Stream | Real: Tokio Stream’s, outside every model |
| Atomic reference publication and its cache | publication | ArcSwap’s | Opaque, with a yield before and after each load, store, compare-and-swap and read-copy-update | Real: ArcSwap’s, outside every model |
| Concurrent maps | collections | DashMap’s | Modeled: Shuttle’s DashMap | Real: DashMap’s, outside every model |
| Lock-free queues | collections | Concurrent Queue’s | Opaque, with a scheduling point before and after each operation | Real: Concurrent Queue’s, outside every model |
| Threads: spawning, joining, building, scopes, parking, sleeping, yielding | thread | The operating system’s | Modeled threads; sleeping and parking with a timeout are scheduling points that take no time | Modeled: Loom’s threads to spawn, build, join, park and yield. Real: the operating system’s sleeping, timed parking and scopes, outside every model |
| A thread nothing joins | thread::spawn_detached | A named operating-system thread | A detached Shuttle task, abandoned when the model’s main thread returns | Real: a named operating-system thread, outside every model |
| Thread-local storage | thread_local! | The standard library’s | Shuttle’s, one per task | Modeled: Loom’s, one per model thread |
| The host’s parallelism | thread::available_parallelism | The host’s | The host’s | The host’s |
| Deadlock findings, and the detector that reports them | deadlock | The findings in every mode; the detector only under Deloxide | The findings; no detector | The findings; no detector |
| Real primitives outside every model | unmodeled | Real | Real | Real |
Every exposed operation has one of three classifications in each mode. A modeled operation is
observed by the mode’s engine, and a check may make claims within that engine’s limits. An
opaque operation runs a real primitive between explicit scheduling points: the scheduler can
order an owner’s use of it against other tasks, but the primitive’s own instructions and memory
ordering stay unmodeled, so a check claims nothing about them. An operation outside the modeled
execution uses a real primitive and supplies no evidence about a checked protocol. The matrix
identifies real families retained by a selected backend; an explicit use of
nervix_primitives::unmodeled needs a named permission. An operation a mode cannot provide is
unavailable in that mode and fails to compile; it never falls back to a real primitive. Shared
ownership is real in every mode and on every target: no model checker counts references, so a check
claims nothing about an ordering a reference count establishes, such as the one get_mut or
try_unwrap relies on. Loom models isolated synchronous owners: atomics, the threads a model
spawns, joins, parks and yields, and thread-local storage. It models no async family, lock,
collection or publication, so a Loom build, in which the server and consensus compile for the models
of their owners, takes the ordinary library for each of those, outside every model.
just validate-primitive-boundary rejects every such family in Loom model code, an inline module or
a module file whose declaration compiles it only for Loom, so a model never names a real primitive
silently; what an owner a model drives uses internally is excluded from that model’s claim. The check
reads each cfg of a module through its all, any and not: a module is Loom model code when one
of them is false in every build without the loom feature, and a module whose cfg the check
cannot read is rejected. Model
code may name shared ownership and a permitted unmodeled primitive, because each says it is real.
just test-primitives shows each mode’s selection.
Turmoil replaces the network and the clock, not synchronization. A Turmoil build runs the ordinary
primitives on the simulated host whose task uses them: net selects Turmoil’s sockets, and
net::lookup_host, which only that mode has, answers from the simulated DNS table; the timers and
instants of time follow the host’s simulated clock; and task::spawn_cpu runs an admitted CPU job
as one task of the host’s scheduler, so its synchronous body is one scheduling step. What the
simulated hosts run, and what the simulation establishes, belong to
Deterministic interconnect simulation.
Deloxide models nothing and replaces only the thread-blocking locks and their condition variable. A
deloxide build runs every other family as ordinary execution does, and its tracked locks are real
locks whose acquisitions a deadlock detector also observes, so it runs as a product does and claims
no interleaving. What it reports, and what it cannot, belong to
Diagnostic deadlock detection.
Policy Boundaries
The boundary supplies mechanisms and decides no policy. net never decides what a name means: a
node resolves through its own resolver in nervix-dns, no mode but Turmoil’s offers a lookup, and
the boundary check rejects tokio::net::lookup_host and the ToSocketAddrs traits, which resolve
through the operating system. time grants no clock permission: actual UTC and physical deadlines
keep the owners scripts/check_clock_boundaries.py declares, and a logical deadline stays in the
domain clock’s coordinate, as Domain Clock describes. task::spawn_cpu belongs
to the bounded executor, which admits, charges and cancels every job; the boundary check rejects it
in any file but the executor’s worker pools, so it is no way around admission. task::spawn_blocking
is the runtime’s blocking pool, which the executor’s storage workers run on, and
task::block_in_place blocks the runtime worker thread that calls it, which no file owns by
default. Work a node runs off its async workers goes through the executor instead, so the check
rejects either in any other file unless a permission in crates/primitives/blocking-permissions.toml
lists it for that file, with the owner, why the owner stays outside the executor, and what bounds
its work instead, as Blocking work outside the executor
lists. pause, advance and resume exist only with the test-util capability, which ordinary
tests of elapsed-time behavior enable and no production graph has, because a clock that can be
paused is checked on every read.
The runtime attributes build the selected runtime through a crate path fixed to the boundary: Tokio’s
attribute builds whatever runtime its crate path names, and an attribute that named tokio at the
call site would construct whichever crate a package happened to call tokio. Shuttle’s own test
attribute finds its runtime by reading the calling package’s manifest for a dependency of a fixed
name, so the boundary does not use it; in a Shuttle build a test attribute builds Shuttle’s runtime,
which runs only inside a Shuttle execution.
Every async loop whose body performs async work calls nervix_primitives::task::consume_budget
once per iteration near its beginning. The selected scheduler owns that yield opportunity;
callers do not add their own backend choice. std::time::Duration remains a value in every mode.
The bounded executor still owns job admission, class isolation, memory charges and cooperative
cancellation; choosing a task primitive grants none of those permissions by itself.
Unmodeled Primitives
A modeled primitive exists only inside a run of its model. Using one outside that run is a test
configuration failure the backend reports, and nothing falls back to a real primitive. A real
primitive that must stay outside every model is reached through the unmodeled path, and each use
needs a permission in crates/primitives/unmodeled-permissions.toml that names the file, the items
by their paths below unmodeled, the owner, why a real primitive is required, and what that leaves
unverified:
| Owner | Why the primitive is real | What stays outside every check |
|---|---|---|
The Shuttle and Loom runners of nervix-model-harness | Their statistics span every model execution they start and are read after the last one | Nothing a check claims; they are runner bookkeeping |
| The WASM runtime’s epoch driver and guest-callback time | The epoch driver is an operating-system thread no model runs, which sleeps for real and is stopped by a flag; a guest callback reads its domain time from Tokio’s task-local storage, which no model isolates | When the epoch thread observes shutdown, and a callback’s time scope, which Shuttle tasks would share while polled |
| The C binding’s session | The binding owns the runtime its host’s threads enter and shuts it down in the background, which is its contract with the host and which Shuttle’s runtime does not provide | Everything the binding runs; the Rust client it drives has its own checks |
| The cluster membership transport | Chitchat hands out Tokio’s own lock and watch receiver and runs on Tokio’s runtime, so the transport tasks it drives wait, sleep and select on Tokio’s primitives | Cluster membership gossip |
| Process-wide one-time state: the SIMD instruction level, the interconnect’s cryptography provider, and the test and benchmark schema and certificate fixtures | Initialized once per process and read by every later model execution in it | Detection, installation and fixture construction |
The browser console: its executor installation, and the futures channel, select! and abort handles its tasks use on the browser’s event loop | The browser target runs in no execution mode and has no native capability, so the console takes those families from the futures crate, which a native build takes from Tokio | Everything the console runs |
| The VM benchmarks’ allocation probe | A global allocator counts allocations made on every thread | Nothing; the benchmark claims nothing about synchronization |
| The application unit-test fixtures | Test databases and node ports must differ across every unit test the process runs in parallel, so their identities outlive each test | Nothing a check claims; no model builds the fixtures |
| The records of the relay gate and fan-out, entity gate, emitter record-write, durability barrier, WASM checkpoint, source host-loop, stream-slot, retained-archive and client ingestor Shuttle checks | A record changes in the same scheduling step as the operation it records, so recording adds no scheduling point | Nothing the owner does: records observe and never synchronize, and the owners’ own primitives are modeled |
| The Turmoil runner’s real-time bound and the scenario driver’s attempt deadline | A run’s bound must expire even when a host blocks the scheduler thread and simulated time stops advancing, and the driver kills an attempt process that outlived its bound; both read the operating system’s monotonic clock | Nothing a scenario claims: they decide only when a run fails, never how a simulated request proceeds |
| The integration-test harness’s port pool and the benchmark driver | They reserve free ports from the operating system for real node processes and containers, synchronously and before anything binds them, with the standard library’s blocking listener | Nothing a check claims; the ports carry the traffic of real processes |
A real primitive never carries the protocol under test, chooses its branches, supplies its wakeups or establishes an ordering an assertion relies on. A permission covers one Rust file the boundary governs and lists real items of the unmodeled path; a permission for a directory, a glob, a file outside the governed sources, or an item the path does not have is rejected, and so is a permission or a listed item nothing uses.
A selected atomic belongs to the model execution that constructs it, so it never lives in a
static. A static is constructed once per process and would carry its state from one execution into
the next, and Loom’s atomics have no const constructor, so a crate that declares one does not build
with Loom at all. Process-wide state lives on the owner whose lifetime it has instead: a node’s
session service owns the draws its subscriptions sample with, and a node’s Raft network owns the
identities of the snapshot transfers it sends. A count a unit test reads is kept per thread, and
state that must outlive every test and model is a real atomic under a permission, which may live in
a static.
The rule binds the boundary itself. The boundary names every backend, so the import rules do not
read its sources, but a static it declares holds only real atomics, named through
crate::unmodeled::sync::atomic or the standard library, and the state it builds lazily for the
life of the process is real too. The deadlock detector’s process-wide state is that kind: whether
this process claimed its installation, whether order checking is on, and the order history its
registry keeps are real atomics. One mechanism the detector reaches is shared rather than owned: its
report queue is the ReportHandoff that Shuttle checks, whose refusal counter must stay a selected
atomic for the check to explore it. The detector holds that queue in a static only in a deloxide
build, which selects the standard library’s atomics and compiles no model, so no model execution
ever constructs or outlives it.
Authored Source And Manifest Scope
just validate-primitive-boundary rejects every other path to a governed family however it is
spelled: a direct, renamed, grouped or glob import, a fully qualified path, an attribute, a renamed
crate, an imported sync, time or net module, or a path in a macro body or an inactive cfg
branch. That covers shared ownership through triomphe or the standard library’s sync, the
futures crates’ channels, locks, executors, waker registration, select! and abort handles,
Tokio’s time and net modules, Turmoil’s net, Deloxide’s locks and detector, and the standard
library’s Instant and sockets. Each rejection names the approved path; tokio::net::lookup_host and the ToSocketAddrs traits
name the node’s resolver instead. It also rejects an unmodeled use without its permission, a stale
or misplaced permission, a dependency on a library whose family the boundary selects from any
package but the boundary, a manifest entry that renames a governed crate, task::spawn_cpu in
any file but the executor’s worker pools, task::spawn_blocking in any file but those pools and
the owners a blocking permission declares, task::block_in_place in any file but the owners a
blocking permission declares, and a blocking permission that lists an item its file no longer
names. The source and manifest rules read every tracked file and every new file Git does not
ignore, whether or not the workspace lists its package: the isolated analysis workspace under
tools/nervix-lint, with its driver, fixtures and fixture macros, is authored source like any
crate. What a build wrote is not: Cargo tags each build directory it creates with a CACHEDIR.TAG,
and the check reads nothing below a tagged directory, wherever it is nested. Turmoil is a runner as
well as the network the boundary selects, so beside the boundary, a package whose harness drives a
simulation may depend on it, only as an optional dependency its own turmoil feature enables.
Deloxide is the boundary’s alone: no other package depends on it, and the boundary depends on it only
optionally, so no ordinary graph contains it.
An execution mode is a feature of the boundary, never a global cfg: --cfg loom would reach every
crate of a build, and Tokio and other dependencies read the same names. The check rejects a bare
loom, shuttle, turmoil or deloxide in a cfg predicate, and --cfg with any of those
names in any justfile recipe, Cargo configuration, workflow or build script. Tokio’s
unstable runtime controls belong to the Turmoil build: the check rejects --cfg tokio_unstable in
any justfile recipe but a Turmoil one, and in Cargo configuration, a workflow or a build script.
The analysis cfg of the source contracts, nervix_lint, is tooling only and
selects nothing. Only the analysis driver sets it, for the crates it analyzes, and the analysis must
read the code a product build compiles. The check rejects --cfg nervix_lint in any justfile
recipe, Cargo configuration, workflow or build script, a cfg or cfg! predicate that names it,
and a cfg_attr under it that holds anything but nervix:: contracts and lint levels, such as a
path, a derive or a nested cfg. An annotation hides nothing from the other rules: a governed
path is rejected in the code an annotation gates as it is anywhere else.
The check rejects a static, including one a thread_local! declares, whose declared type names a
selected atomic, directly or through a wrapper, an array, a reference, a module path or a local type
alias, and a static or const initializer, const fn or const block that constructs one. It
applies this rule, and only this one, to the boundary’s own sources as well, where
crate::sync::atomic names a selected atomic and crate::unmodeled::sync::atomic and the standard
library’s and core’s atomics name real ones. It reads declared types and constructions, so a
struct holding an atomic that a static builds lazily is left to review.
Guest code is outside the source rules: the WASM guest SDK and every guest library built on it are
compiled into a user’s WASM guest, a single-threaded program in the host’s sandbox where no mode
exists and no Nervix process runs, and a user’s guest takes Arrow’s standard references without the
boundary. The rules still read their manifests. Tokio’s I/O traits, filesystem, process and signal
modules are real in every mode, and so are the pure polling combinators of Tokio, pin! and
join!, and of the futures crates, which poll futures without synchronizing anything of their
own. The browser’s event loop, its executor reached through wasm-bindgen-futures and Leptos and
its timers through web-sys, exists only in a target no mode runs. The cells of std::cell are
confined to one thread. None of these is a family the boundary selects.
Builds, Modes And Product Binaries
Each mode is its own build invocation, with explicit features, because Cargo unifies the features of one invocation:
| Mode | Lint | Tests | Coverage |
|---|---|---|---|
| Ordinary | just lint: every package in ordinary mode, the server, the client, the formatter, the browser console and the wire crate for the browser | just test, just test-primitives-ordinary | just test-coverage; ordinary extras and test-primitives-ordinary through just coverage-native-extras |
| Shuttle | just cargo-clippy-shuttle: every Shuttle library, and each package just test-shuttle explores in test mode, where its checks are compiled | just test-shuttle [filter], just test-shuttle-replay-check, just test-primitives-shuttle | just coverage-native-extras test-shuttle test-primitives-shuttle; just coverage-shuttle <output> [filter] |
| Loom | just cargo-clippy-loom | just test-loom [filter], just test-loom-qualification, just test-primitives-loom | just coverage-native-extras test-loom test-primitives-loom; just coverage-loom <output> [filter] |
| Turmoil | The Turmoil targets of just cargo-clippy | just test-turmoil, just test-turmoil-replay-check, just test-primitives-turmoil | The canonical suite through just coverage-native-extras test-turmoil, with separate CI artifacts and advisory Turmoil patch feedback; just coverage-turmoil <output> copies a complete report; native conformance through just coverage-native-extras test-primitives-turmoil |
| Deloxide | just cargo-clippy-deloxide: every diagnostic selection of the boundary, reporting owner, paced driver and server, including probes and scenarios | just test-deloxide, just test-deloxide-order, just test-deloxide-stress, just test-deloxide-stress-restore, just test-primitives-deloxide and diagnostic compile checks in just test-primitives-compile | Native conformance through just coverage-native-extras test-primitives-deloxide; just coverage-deadlock collects the test-deadlock-evidence-order and ordinary test-deadlock-report producers in separate builds with canonical completion records and separately flagged diagnostic-evidence coverage; full diagnostic workloads retain separate evidence |
Model coverage uses the canonical inventories and runners, including per-test process isolation,
Shuttle random/PCT exploration and nondeterminism checking, and Loom InvariantIds and exhaustive
completion. models.json retains discovered/selected/executed/completed counts and exact check
identities; the coverage collector requires matching complete evidence before exporting LCOV.
Loom weakening qualification runs independently outside instrumentation. Coverage bookkeeping
runs in the command processes and supplies no ordering, wakeups or branches inside a model.
The per-mode build directories and fresh attempt directories prevent one mode from replacing
another’s executable or counters. Native source-line execution complements each model’s claim;
it does not broaden the ordering or interleaving guarantee. Mode reports remain separate from
ordinary coverage and its advisory CRAP report. See native extra coverage for
collection, artifacts, filtering and replay commands.
The integrated ordinary cost probe is just bench-primitives. It measures the boundary’s atomic
access, ArcSwap publication, an uncontended blocking mutex, and shared-reference clone/drop
without a model or diagnostic build’s scheduling points. The production relay and admitted-work
benches measure their owners, and just bench-task-handles
retains timing and allocation samples. Compare uninstrumented builds on one host with the same
profile and load; a model run or coverage build cannot serve as a throughput baseline. The
integrated qualification procedure
keeps those measurements beside revision-bound model and external fault evidence.
just validate-execution-mode-dependencies resolves the normal dependency graph of the workspace
and of every package on its own, the way a consumer builds it, with default features and without
them, and rejects Loom, Shuttle, a Shuttle wrapper, Turmoil or Deloxide in any of them, and an
execution mode, the diagnostic one included, or the test-util capability enabled on the boundary. It also keeps the portable graphs portable:
the vocabulary crate, and the browser console and the wire crate for the browser’s target, contain
no async runtime or network library and never enable the boundary’s native capability.
just validate-execution-mode-conflicts requires the combined-mode diagnostic, including for modes
two dependencies select separately, the boundary’s own diagnostic for a mode or the native
capability requested for the browser’s target and for the diagnostic mode requested without the
native capability it tracks, and the product-binary diagnostics.
A build that selects a modeled mode is a test artifact. Every binary Nervix ships, the server, the
CLI and the Plexus formatter, declares itself with nervix_primitives::product_binary!, which fails to
compile in a build that selects loom, shuttle or turmoil and names the binary and the mode, so
no modeled binary can be built, let alone published. A deloxide build is a diagnostic artifact
instead, which runs but is never released: it compiles only a binary that declares a diagnostic form,
product_binary!("nervix-server", diagnostic), and such a binary installs the deadlock detector at
start-up. The server is the one binary that declares it; the CLI and the formatter fail to compile in
that build, naming themselves. just validate-primitive-boundary requires the declaration, in either
form, in the crate root of every binary the release image’s build compiles, and the release image
builds the ordinary server.
Blocking Work Outside The Executor
A node runs its variable-size encoding, decoding, validation, hashing, compilation, program
execution and synchronous filesystem or database work through the bounded executor, which admits
each job into a CPU or storage class, charges its memory, and gives it the cancellation it checks
between its bounded units. Operator-supplied code the node cannot bound, a Roto UDF call or a JAQ
transformation, takes the extension class, so a program that never returns holds only that class’s
workers. Password hashing takes the credentials class and its own budget, so anyone who can reach a
listener spends only those, and saturated data, extension or bulk work never delays a login. The
executor’s storage workers are the one built-in owner of the runtime’s blocking pool, and no file
owns task::block_in_place, which blocks the runtime worker thread that calls it.
Everything else that blocks a thread is a declared owner in
crates/primitives/blocking-permissions.toml, which names the file, the blocking items the owner
names there by their paths below nervix_primitives, the owner, why it stays outside the executor,
and what bounds its work instead. A permission declares its file for exactly the items it lists, so
a file whose owners block in different ways, such as the CLI’s, has a permission for each:
| Owner | Blocks through | Why it stays outside the executor | What bounds it |
|---|---|---|---|
The resolver’s configuration load in nervix-dns | task::spawn_blocking | A node reads its resolver configuration before it builds its executor, and the Rust client and the CLI build the same resolver without one | Two small files, read once for each resolver built |
| The Kafka sink’s final producer-queue drain and the Kafka source’s partition inspection | task::spawn_blocking | librdkafka parks its calling thread until the broker answers; the call waits on the network and computes nothing, so it would hold a CPU or storage worker idle | The host’s physical flush deadline, and a five-second metadata timeout per request |
| The Rust client’s backup download and restore upload, and the CLI’s event printer | task::spawn_blocking | A client tool is not a node and has no executor | One rename of a written archive, one sequential read of an archive, and one printer thread for the life of the command |
| The CLI’s completion prompt | task::block_in_place | A client tool is not a node and has no executor; its line editor asks for completions synchronously on the thread that runs the CLI’s main task, so the prompt waits there for the completion while the runtime’s workers keep the session running | One completion at a time, only while the operator waits at the prompt; each page of suggestions ends by the client’s retry deadline, and a repeated continuation ends the completion |
| The benchmark driver and the scenario harness | task::spawn_blocking | A harness is not a node; it waits on brokers, databases, probe threads and the C binding from its own process | Each call’s own timeout, or the probe or thread it joins |
The boundary check rejects either item in a file no permission lists it for, and a listed item its file no longer names.
Thread Creation Outside The Executor
Starting a thread can bypass the executor’s admission, memory charges and cancellation just as
blocking work can. Every caller of thread::spawn, thread::Builder, thread::scope,
thread::spawn_detached or unmodeled::thread::Builder therefore declares its exact file and
items in the same crates/primitives/blocking-permissions.toml. The declaration states its owner,
why the work stays outside the executor, and what bounds the work, thread count and lifetime.
No caller outside the primitive crate has a built-in permission. Naming the builder includes its
ordinary and scoped spawn methods; a glob names every confined item it brings into scope.
Reimported module aliases retain the same confinement regardless of import order. A glob over
the boundary root is rejected; name its modules and items explicitly so confined and unmodeled
paths remain visible.
| Owner | Thread items | Work, count and lifetime bound |
|---|---|---|
| Process termination supervision | thread::spawn_detached | Two supervisors per process, parked between signals or deadline changes, plus at most one forced-exit watchdog after the single exit claim; supervisors end with the process, and the watchdog waits only the forced-exit report budget |
| WASM epoch driver | unmodeled::thread::Builder | One thread per WASM runtime; one sleep and one engine epoch increment per configured tick; it ends on its next wake after the stop flag is set or the weak stop reference can no longer upgrade |
| Diagnostic finding recorder | thread::spawn_detached | At most one watchdog for the process’s one finding; it exits the process after the ten-second recording budget |
| Shared Loom participant launcher | thread::Builder | Loom’s five slots, including coordinator and body, the branch limit and runner watchdog; participants end within one model execution |
| Benchmark load driver | thread::Builder, thread::scope | One joined summary poller per drain owner, and one scoped watermark query per configured partition, each with the request timeout within the driver deadline |
| Turmoil scenario runner | thread::Builder | One scheduler per disposable attempt; physical run and cleanup deadlines, a normal join, and a failed process when either deadline is missed |
| Plexus completion walk | thread::Builder, thread::scope | At most the configured jobs per finite frontier; every worker is joined before the next level |
| Public C binding probes and external Kafka member | thread::spawn, thread::scope, thread::Builder as listed for each file | Fixed probe participants joined within their bounded call and cancellation waits; one Kafka poller per member, stopped and joined when the member drops |
Unit tests and Loom and Shuttle checks declare every file and its exact thread items too. Their
permissions describe finite participants for each case, joins or model termination, and the
registered exploration bounds where applicable. Diagnostic cycle probes instead use disposable
processes and a parent watchdog, since their tested cycle intentionally cannot join. These
declarations do not exempt a directory or evaluate away cfg(test), a model-only module, an
inactive branch or a macro body. A new item needs its declaration, and a listed item its file no
longer names fails as stale. A file whose product owner and tests use different items names both.
The real epoch builder also keeps its independent unmodeled permission, with its reason and verification limit. That permission does not supply the work and lifetime bound and cannot alone authorize thread creation. The ordinary re-exports and every modeled thread operation retain their existing execution semantics; this confinement changes source validation only.
Verification Contracts
The boundary and its callers have separate proof obligations. Choose evidence for the fact being claimed; a successful build, an executed test, a completed model search and a source coverage report establish different things.
| Question | Owner and entry point | What a passing result establishes |
|---|---|---|
| Did authored code obtain a governed family through the boundary, with the required permission? | just validate-primitive-boundary | Source and manifest provenance, including inactive configurations, aliases and authored macro fragments, within the check’s documented structural scope |
| Do ordinary and portable dependency graphs contain only their permitted capabilities? | just validate-execution-mode-dependencies | The resolved Cargo graphs inspected for the workspace, individual packages and browser targets |
| Do incompatible modes, unsupported targets and product binaries fail with the intended diagnostic? | just validate-execution-mode-conflicts | The actual feature/target combinations built by each rejection probe, including transitive mode selection |
| Does one current Rust API accept its supported use and reject a forbidden operation? | Paired compiling and compile_fail Rustdoc examples beside that API; just test-primitives-compile and just test-capability-docs | Those snippets compile or fail in the configuration used for the doctest |
| Do resolved architecture diagnostics recognize their semantic facts and narrow exceptions? | just test-typed-ratchet, with compiler contracts | Allowed/rejected fixtures, precise diagnostics and spans, and invalid, unfulfilled and widened expectations in the declared compiler matrix |
| Does an adapter retain its ordinary backend’s contract? | just test-primitives, once per execution mode | The selected conformance and race scripts actually complete under each backend |
| Is a caller’s interleaving or memory-ordering invariant exercised? | Registered production-owner Shuttle checks or Loom models through just test-shuttle or just test-loom | The reported completed search and bounds for that invariant, with the model’s documented visibility limits |
| Does an integrated operation behave correctly? | Public Cucumber, the supported Turmoil suite, or immutable-image Chaos under their owning chapters | The public operations and fault cases actually exercised, within that harness’s scope |
| Did tracked real blocking locks deadlock in a diagnostic workload? | The four canonical Deloxide selections | Qualifying detector evidence for the acquired locks and processes that completed; untracked paths remain outside the claim |
A compile_fail example belongs beside the API that owns the restriction and is paired with a
compiling example using the current imports and supported signature. An example that fails because
its imports no longer resolve does not establish the intended rejection. Doctests use the
configuration of their containing build; they cannot choose a different Cargo dependency graph for
each snippet. Graph isolation, feature conflicts and browser capability rejection therefore keep
their dedicated Cargo probes. Compiler fixtures keep their semantic diagnostic assertions. Actual
model execution keeps its own inventory and completion records. None is replaced by a collection
of compile failures.
Caller Models And Failure Evidence
A caller’s Shuttle check drives its production owner with scheduler-visible coordination and
registers in crates/model-harness/shuttle-inventory.toml. Registration happens before the deciding
read when waiting for notification. A memory-ordering claim uses the synchronous production owner
under Loom, names its InvariantId, registers in crates/model-harness/loom-inventory.toml, and
has a registered weakening that must fail and replay. Shared references, opaque dependencies,
async I/O and real primitives never silently expand either model’s claim. Deterministic
Concurrency Verification
owns exact exploration, registration and replay requirements.
The canonical runners distinguish discovery, selection, execution and completion. A missing, ignored, unregistered or incomplete required check fails; selecting no check fails. Preserve the failure’s actual source, inputs, schedule or checkpoint, bounds, toolchain and output before changing its owner. A later passing run retains its own source and does not explain an earlier failure. Shuttle replay reproduces a recorded schedule; Loom qualification demonstrates dependence on the registered ordering; Turmoil replay reproduces its recorded simulation; external Chaos replays its action plan without claiming deterministic operating-system scheduling.
Deloxide installs once per diagnostic process before any tracked lock or runtime worker exists. Its applicability and evidence contract requires active, order, stress and stress-restore selections when tracked synchronization or a lifecycle path using it changes. The owner inventory records which workloads reach each lock site and which paths remain unexercised. Async synchronization, atomic publication and network waits need their own applicable evidence. A clean detector run does not establish their safety.
CI, Coverage And Toolchains
just validate includes primitive provenance, dependency and conflict checks beside the resolved
architecture diagnostics. just test includes ordinary tests, public scenarios and paired API
doctests. Primitive conformance and the complete model selections also have dedicated recipes;
the development guide owns the full command catalog. A change affecting
Shuttle, Loom or Turmoil carries the corresponding PR label so its dedicated CI jobs run. A change
covered by the Deloxide applicability rule carries deloxide; membership, failover, recovery or
delivery behavior exercised by external Chaos carries chaos. Labels enable evidence and do not
replace the local checks or source-owned applicability review.
Registered Bolero properties use the same production path and complete assertion in ordinary
randomized/corpus execution and sanitizer fuzzing. Ordinary execution is required when the Bolero
workflow runs. Sanitizer CI requires a pull request carrying fuzz; an unlabeled, scheduled or
manually dispatched run expects a sanitizer skip. Check may exclude the entire reusable Bolero
call only after verifying its prose-only selection without fuzz. A skip contributes no fuzz
execution or coverage. Property Testing And Fuzzing owns this
policy, its inventory and failure accounting.
Use the toolchain files’ declared llvm-tools: stable instrumentation and exports use the stable
compiler’s tools, and the isolated architecture analysis and sanitizer campaigns use their own
pinned toolchains. Never combine incompatible raw profiles or substitute system LLVM tools for
the producing compiler’s tools. Every Rust build, probe, doctest, lint, benchmark and coverage
invocation retains the configured kache wrapper, including isolated compiler and qualification
builds. A cache or compiler failure is reported at its owner; disabling the wrapper changes neither
the requirement nor the validity of the evidence.
Native coverage collection records source-line execution only after its canonical producer
completed, beside its completion and model or lane records. Keep ordinary coverage, model coverage,
diagnostic coverage and sanitizer campaign evidence distinct. A source line being covered does not
prove all interleavings, sufficient memory orderings, or absence of an unobserved deadlock.
The CRAP report has advisory scores; its input validity and
the independent just ratchet complexity verdict remain mandatory checks.
Integrated primitive qualification
binds ordinary costs and public/model/fault results to their actual revision and immutable images.
Run records, audit registers and measurements belong on the owning task. Architecture documentation
states durable guarantees and limits, without turning one passing snapshot into a permanent claim
about later source.
Data-Plane Concurrency
Nervix keeps the paths that carry records, batches, remote relay frames, and acknowledgements free from shared coordination that is unrelated to the record being processed. This is the contentionless data-plane rule. It applies after a runtime task has started and its graph, routing, state, metric, and connector handles have been resolved.
Execution Plans describes the revision that installs and publishes those handles before record and batch work begins.
SIMD Kernels defines the pure buffer boundary that folds batch latency and computes admission, arithmetic, and byte masks. Its cached CPU level is process-wide detection under an unmodeled permission; it carries no protocol state. Batch-local reductions preserve this chapter’s ownership and bounded synchronization rules, including one accumulator lock and one wall-clock read per delivery-latency series per batch.
The hot paths are:
- accepting one record from an ingestor
- admitting and fanning out one relay batch
- executing one processor batch for a concrete branch
- receiving and admitting one remote relay frame
- creating, sharing, and resolving one acknowledgement
These paths may apply bounded backpressure and preserve required ordering. Their steady-state work
must not acquire a Mutex or RwLock, including a read lock, merely to discover configuration,
ownership, topology, a service, or a metric series. A read lock still changes shared lock state,
competes with writers, and can join an unrelated reader to a writer’s delay.
A shared concurrent map synchronizes on every access, not only on entry(). DashMap::get,
contains_key, len and iteration take a shard’s read side, and get_mut, entry, insert,
remove, retain and alter take its write side, whether or not the key is present; a borrowed
Ref holds its shard until it is dropped. A steady hot path therefore does not look an established
value up in a shared map at all, whether by borrowed lookup or by entry(). It retains the handle
it resolved when its task, branch, channel, or attempt was created. A shared registry serves the
cold paths around that handle: registration and first installation, where entry() gives racing
installers a single winner, replacement, teardown, and observers.
Remote ACK And Admission Owners
just bench-remote-owners measures registration, admission, Alive, ordered parking/resume and
terminal resolution with actual ACK roots at one and 64 rows per delivery, reports delivery tail
latency and relay-memory reclamation, and measures an empty sweep of the positions its deliveries
claimed beside the fixed position count. Its
authenticated frame probe sends three current Arrow rows, admits them, returns the exact terminal
reply and checks retired attempt counts. A separate 5,000-frame peer-owner probe completes while
another peer’s protocol guard is continuously held and checks returned permits and memory. These
native probes also run through the existing benchmark smoke producer; local debug timings describe
their measured build and host load, rather than a production throughput guarantee.
Remote record correlation uses an immutable array of delivery and admission routing positions. Each delivery generation owns its mutable rows under one short synchronous guard; a row’s outcome is independent of the other rows. Each side claims unused positions on demand and returns retired positions to its own bounded free queue, preserving admission room when delivery positions fill. Shutdown seals first claims before closing the queues and visits only positions that were claimed. The silence sweep, which runs once a second on every node, has the same bound: it reads each side’s first-claim count and takes a guard only for the positions below it, so a node that forwards nothing takes none and a sweep’s cost follows the positions its deliveries have used, not the 16,384 positions a node is built with. The counts bound the scan and order nothing: a position’s guard publishes its correlation, and nothing relies on a sweep having observed a claim. A share whose first claim races a sweep is counted from that sweep or from the first one after its registration, so it still fails between the silence timeout and one sweep interval later. Runtime shutdown cancels the sweep task and waits for it. The task returns to its executor after every sweep, because a sweep that overran its interval finds the next tick already due and that tick does not suspend the task, so it observes the cancellation between sweeps and a sweep’s guard count also bounds how long a stopping node waits for it. A diagnostic build pays for every one of those guards: the acquisition-order selection enters Deloxide’s process-wide detector on each tracked acquisition and release, and the nodes of every scenario in one process share that detector, which is why a recurring pass takes guards in proportion to its live work and never to a fixed capacity. Record allocation and receiver batch watcher tasks reserve relay memory before creating their retained rows. A batch task multiplexes row progress and keepalive polls every 100 milliseconds; each row and the single task have fixed charges. The cadence remains shorter than a one-second source ACK timeout while task cardinality stays bounded by admitted batches. A receiver whose relay budget has no room for a batch’s watches holds the unadmitted batch on its own channel lane and reads the budget again every 10 milliseconds; it registers for no release notification and never queues in the budget’s semaphore, whose released permits would otherwise go to the held charge ahead of the relay work that frees it. The batch task returns each watch’s share as that watch ends, through its exclusively owned reservation, so the hold involves no shared state beyond the budget’s own atomic permit count. The registered wire number selects the position, exact generation and row without a concurrent map lookup. The process identity is checked before routing. Generation exhaustion seals the position, and cancellation, timeout, terminal resolution and shutdown compete to take each row once. The ACK tree resolves after the routing guard is released.
Relay services retain the domain drain tracker resolved during construction. Received rows create ACK roots through that handle; they never register or discover the tracker per row. Watchers read the membership writer’s immutable incarnation publication, so registrar retirement ends their charged task lifetime without a gossip lock.
One authenticated peer transport owns its ordinary grant, attempt, channel, watermark and outbound correlation collections. Connections and operation leases retain that owner. Its guard serializes identity and ordering decisions for this peer; there is no node-wide actor or map guard in the recurring protocol. The peer guard precedes the exact record guard, neither crosses an await, and permit release remains possible while runtime borrows an admitted record. Ordered attempt and grant retirement keeps cancellation effects deterministic. Cold binding and membership changes publish routing through CAS. Cluster Interconnect owns the capacities, grace periods and reconciliation deadlines.
Production-owner Shuttle checks cover delayed events against a reused position, registration
against shutdown, admission waiter drop against a reply, final silence sweeps against terminal and
Alive reports, a first claim against a sweep, a sweep that completes while an unclaimed position’s
guard is held, body claim against cancellation, admission against peer ending, and another peer’s
progress while one peer holds its guard. The production admission choice has a Loom model for one
irreversible verdict; replacing its compare-and-exchange with an overwriting swap must fail and
replay. ArcSwap, queues and external semaphore internals remain outside that atomic claim.
Registered bounded Bolero sequences check complete verdicts, capacity and generation outcomes.
The remote-correlation-lifetimes sequence uses an independent sequential model to predict
registration identities and FIFO reuse before calling the production owner. It varies both pools’
capacity, receiver identities, attached and detached batch rows, admission receiver closure,
single and bulk admission, Alive and sequenced parking/resume, terminal replies, clear, relay-memory
refusal and shutdown. Every operation and every individual silence sweep checks exact root outcomes
and progress, drain and handoff counts, admission updates, per-receiver expiry counts, retained
memory and the positions ever first-claimed. Stale generations and non-record admission row
numbers are also checked through the public routing operations. This is sequential representation
evidence; the registered Shuttle checks above own races, and the runtime scenarios own the timed
sweep loop and its public delivery behavior.
Turmoil covers transport loss, retry, cancellation, reconnect and process restart within its
documented scope; public one/three-node branch delivery and producer-restart scenarios cover host
ACKs. External immutable-image faults remain separate evidence.
Deloxide applies to the new delivery, peer and record blocking guards and their acquisition order. Both diagnostic selections run the remote ACK scenarios alongside their existing workloads. It does not model publication, async channels, network waits or the atomic verdict; a clean run proves only the tracked acquisitions the diagnostic processes executed. Generated measurements and fault evidence belong on the task, and a failed required attempt remains recorded.
Leading empty consensus append probes use management capacity. One task owns their replies and the transition to an ordered data stream; subsequent empty commit updates stay on that stream. Rejected probes end the input before data admission. This transition adds no shared map, lock or memory-ordering claim. Native real-transport and public catch-up checks cover it; Deloxide does not track these async network waits.
Consensus append setup consumes its caller’s first request before opening the transport. The caller owns the async retry and cancellation gate; the adapter retains and submits that request once, without adding a lock or retry timer. Native admission and ended-input regressions exercise this boundary, and the public three-node scenario checks paced failed setup while the surviving quorum commits and the returning voter catches up. The test probe counts attempts and successful opens in its existing blocking map. Deloxide does not track the request-stream or network waits; the applicable Turmoil suite and real-process Chaos faults retain their separate claims.
Relay, Transport And Source Lifetimes
A concrete producer retains its branch channel handle. The relay publishes branch membership in a
persistent immutable table; concurrent first binding selects one allocation with CAS. Each branch
publishes an owner/consumer routing generation, whose cancellation parent fences ingress and
outbound slots, including candidates created during replacement. Eviction cancels the exact branch
before withdrawing its allocation. An independently expiring receiver branch permits a still-live
producer to bind a fresh channel after invalidation. The producer’s own ending moves it to Ended
and prevents rebinding. Domain terminal teardown cancels the relay publication itself.
Channel gates serialize encode, sequence assignment and dispatch for that channel only. Gate waits select cancellation, and transport admission retains its existing physical deadline. They do not serialize unrelated branches or peers. A subscription snapshot selects only live advertised peers, fenced by incarnation and interest version. Equal advertisements retain channels; changed selection cancels the preceding subscription generation. Branch and destination registration copy only a persistent changed-key path. Tables are never rebuilt for an established batch.
Transport targets publish immutable fixed pool arrays. A slot atomically claims one reconnect
worker for its lifetime and publishes its authenticated connection through ArcSwapOption. Leasing
reads these publications, scans the fixed class slots and retains the selected connection. The
connection registry serves registration, exact retirement and observers. Endpoint, peer or TLS
replacement cancels slots before publishing replacement handles; a changed DNS answer affects only
the next dial. Cluster Interconnect owns quotas, deadlines and delivery rules.
A fresh intake selects its domain publisher from an immutable persistent table. Executing tasks retain the selected publisher and observe complete snapshots. Domain removal withdraws its table entry; terminal shutdown clears the table after ending executions. Buffered error routes similarly send through the worker retained by the bound plan, with no failure-path discovery acquisition.
Source polling writes a retained instance readiness scalar. Starting and ready may alternate; retired is final, so a late poll or drop cannot change a replacement instance’s readiness. These scalar transitions and worker claims use relaxed atomic operations and publish no other data. Shuttle checks production binding, refresh, retirement and polling races. ArcSwap internals remain opaque; this delivery adds no cross-location memory-ordering claim requiring a new Loom weakening. Existing memory-ordering claims remain with their owning models. Deloxide tracks the existing sequence and route-task blocking locks; it does not track ArcSwap, scalar atomics or async channel ordering gates.
just bench-retained-channels measures published versus retained branch selection, established
stream leasing and bounded branch churn. Its ordinary native bodies also run in bench-smoke,
whose shared native coverage producer prepares browser, guest and runtime dependencies before
instrumentation. The measured bodies retain the usual allocation assertions; their timing output
is workload evidence, not a portable latency threshold.
The resulting design uses four forms of ownership:
- Reconfigurable read-mostly state is built privately and published by atomically replacing one immutable reference.
- A task resolves stable services, state authorities, slots, and metric series when it starts and retains those handles.
- Scalar progress and counts use atomics with an ordering chosen for the contract they enforce.
- State that changes for every row or batch belongs exclusively to one task, branch, delivery
channel, or source attempt. That owner mutates ordinary collections directly: a task-local
HashMap,BTreeMap,IndexMapor JSON map and itsentryAPI are not synchronization, and exclusive ownership does not pin the task to an operating-system thread. When other tasks need to observe such state, the owner publishes an immutable value of it; observers never reach into the owner’s collection.
A shared concurrent map is justified only for state that several owners register into or that observers read across owners, and only while its accesses stay on those cold paths. Hot-path synchronization that remains is an ordering fence or bounded protocol this chapter names, with the exact key that scopes it and the capacity or deadline that bounds its wait. The concurrent map inventory records every concurrent map on the data plane and its neighbours, how often each is reached, and the disposition of every map that is still reached on a record, batch, remote-frame, acknowledgement or steady-poll path. Until its named repair lands, such a site is debt, not accepted design.
The Data Plane chapter defines payload, persistence, branch, and ACK semantics. This chapter owns the concurrency contract for executing them. The Cluster Interconnect, Domain Clock, and Shutdown And Recovery chapters remain authoritative for their respective distributed and lifecycle protocols.
Publication Boundaries
An atomic publication is a complete immutable value. A writer derives the next value without changing the published one, then replaces the reference once. A reader keeps the reference it loaded for as long as it needs it and therefore observes either the complete preceding value or the complete replacement. It never observes fields being changed in place.
Publication is a node-local visibility mechanism, not a durability boundary. The control plane, runtime-state store, or gossip protocol still owns the source of truth. Runtime publication makes one already-decided revision available to the data plane without putting its readers behind the writer.
| State | Publication owner and scope | Reader contract |
|---|---|---|
| Domain routing snapshot | Each domain retains one stable publication handle across execution rebuilds. Schedule application stages and replaces the relay services, schemas, branch declarations, materialized-state ownership, lookups, UDFs, codecs, signaling protocols, and the complete map of bound processor plans together. Each plan carries a typed identity and prepared VM and WASM artifacts for one installed node revision. | A task sees the old routing revision or the new routing revision, never a mixture of their fields. Long-lived tasks use a local pointer cache instead of returning to the domain execution registry per batch. Existing processor branches compare typed plan identities between batches; new branches resolve their template from the same published map. |
| Ingestor and reingestor programs | Schedule application installs the domain’s decision-layer entrypoint plans inside its complete typed execution revision. An ingestor start binds its node filter and routes against the staged routing revision into one immutable route set before its source opens. A reingestor start prepares the branched entrypoints of its routes and binds each input’s source filter, its node filter and its routes before any entrypoint or input task starts. A running relay registers the consumer of a swapped-in input only after that. | Every source instance, endpoint request and ingest group of one ingestor shares the same bound route allocation, and a reingestor input task owns its bound programs. Neither compiles a program on the hot path nor returns to the domain execution registry per batch. A reingestor start that fails leaves no entrypoint, task or registered consumer behind, so a relay never refuses attached delivery for a consumer that has no receiver. |
| Emitter execution plans | The decision layer prepares the domain’s typed sink and ordered source edges with lowered expressions from one schedule revision; schedule application installs them in that revision. Startup, swaps and relocation bind a selected plan to local mounts, schemas and UDFs; relay inputs are registered during those transitions. Remote consumer edges come from the same plans. | An emitter task owns its bound programs and sink configuration for its lifetime. Per-batch work does not read the schedule or client Models; sink retries reuse the typed configuration. A dynamic flush change sends only its new flush policy to the running task. |
| Message-error route plans | The registry selects each DLQ route from the committed schedule. Domain installation binds its schemas, branch declaration, flush contract, relay target and SET program before tasks start. Schedule application publishes the complete bound route map with the routing state of one typed revision. | A failed record looks up its prepared route in the task’s retained immutable routing snapshot, which publishes the bound plans together with the routing revision, before running its VM program or delivery. A buffered route compares the bound plan allocation with its running delivery task; a replacement drains the preceding task and starts a task with the new target and cadence. |
| Node identity and remote dispatcher | The node runtime publishes this once after cluster join, when the authenticated interconnect and process incarnation are known. Relay boundaries created afterwards retain the same dispatcher handle. | Readers borrow the stable node identity, incarnation, transport, admission service, and ACK registry without a write-once lock or repeated name allocation. |
| Relay owner state | Each relay boundary publishes its scheduled owner, installed owner buffer, remote runtime-consumer set, and immutable branch-reset gate set. Schedule and relay lifecycle operations replace these values at their cutover points. | A batch borrows the current owner and buffer, then takes permits only from reset gates whose typed scope selects its branch. Multi-step ownership changes use the whole-relay dispatch gate described below so teardown cannot race an admitted dispatch. |
| Relay branch presence | The relay’s owner task publishes the complete membership of the concrete branches it holds, and whether it admitted unbranched work, when that membership changes: a branch appears, is evicted or expires, or the owner starts or stops. The relay’s state placement keeps the same presence across execution rebuilds. See Relay branch presence. | DESCRIBE RELAY ... WHERE, materialized reads, and the console’s dataflow graph load one membership without a lock. A batch for a branch the owner already holds publishes nothing. |
| Subscription interest | The cluster live-state watcher rebuilds an immutable index from domain and relay to interested node incarnations and advertisement versions whenever gossip changes. | A relay owner performs borrowed lookups in one published index. It neither formats gossip keys nor waits on the gossip mutex per batch. Subscription creation waits until every live node has observed the exact subscriber incarnation and at least the current advertisement version before reporting success. |
| Clock installation | Each domain-clock lifecycle on each node publishes the complete missing, stopped, uninstalled, unpaced, or paced installation. | A read validates its bound lifecycle generation against one installation, then advances that installation’s nondecreasing timestamp watermark atomically. A same-generation replacement retains the watermark; a different generation cannot be clamped by a stale reader. |
| Accepted clock progress | Each runtime domain publishes its newest accepted generation, tick id, logical boundary, and authority UTC observation through a watch. | The watch serializes comparison and replacement, so concurrent progress deliveries cannot regress the id or its authority UTC observation. Generation changes and stops publish absence. A session observer subscribes before reading, retains a sender for as long as its attachment lives, so removing the domain wakes it instead of closing its wait, and uses an execution snapshot only to add its serving node’s logical reading to a tick frame. |
| Committed domain installation | Each node’s runtime counts its installations of the committed domain states through a watch. An installation advances the count only after it has inserted, updated, or removed every domain. | A session attach waits for the first count before it looks up a domain, so a node that is still starting never refuses a domain the cluster has. Because the count advances after the domains are in place, the wait never releases before they are observable. |
| Runtime-state assignment | Each state placement publishes one packed atomic binding containing its generation and capability. Replication roles are a separate immutable published snapshot. | A per-message operation admits itself, compares the exact binding it was granted, and proceeds only while that generation still grants the required capability. It never takes the assignment barrier. |
| Ingestor quiesce decision | Each ingestor publishes the declared and pending modes, active causes, source support, and derived intake decision as one value. Concurrent lifecycle changes derive their replacement from the current publication. | Polling and per-message intake make one load to decide whether to dispatch, suspend, skip, buffer, drop, or reject. A source host retains its last observed publication across dispatch awaits; its change wait registers before comparing that publication with the current one, so an engagement or release in the gap wakes it. The retained-payload lock is reached only after the published decision selects buffering. |
| Backup state snapshot | Branch lifecycle publications, Kafka offset commits and WASM checkpoints register on a per-domain atomic generation before changing their published state. After the domain is paused and drained, the owner closes that generation with a Release RMW and waits with Acquire loads for registered publishers to leave. It then serializes Kafka positions and branch lifecycle into the runtime state store and opens one database snapshot for those records and durable WASM saves. The closing RMW is in the release sequence that a publisher entering the next generation acquires. | A publisher that entered before the cut completes into the captured view; a publisher that enters after it waits for the next generation. An older periodic storage write cannot replace a newer forced publication. The backup reads the database snapshot off the record path and stages owned section bytes. A live backup does not close the generation and reports its weaker cut explicitly. The server’s Shuttle and Loom checks drive this production fence. |
| Restore state publication | The control plane stages checkpoints under a replicated authority, then admits one fixed-size storage job, validates a complete generation namespace and durably publishes its active pointer. It holds the consensus applied-state read guard through authority validation, synchronous storage mutation and runtime-handle clearing. The state store reuses its existing latest-snapshot installation mutex for staging, pointer publication and bounded cleanup. Checkpoint jobs retain their selected namespace and validate it under that barrier; entity replacement and purge also select the active namespace while holding the barrier. | A newer applied generation and the release of the start gate require the state-machine write guard. Every node therefore replaces a whole state set before START is available, and stale coordinators cannot mutate a running restored domain. The store retains authority and inventory for exact retry and monotonic rejection. Database readers select pointer, header and chunk values from one snapshot, which retains its complete set across bounded deletion of obsolete namespaces and unreferenced active chunk sets. Publication runs on stopped-domain lifecycle paths. The admitted maintenance loop also follows running checkpoint replacement and purge under the installation barrier; record delivery adds no lock. |
Restore storage qualification includes the store’s cancellation, corruption, snapshot, queued
writer, active chunk liveness and many-key cleanup regressions in just test-deloxide,
just test-deloxide-order and just test-deloxide-stress, plus the public one-node and three-node @restore_installation
workloads, which just test-deloxide-stress-restore also runs under the bounded scheduling
disturbance, for large saves, many checkpoints, native lifecycle and Kafka records above the default
32 MiB bulk budget, active chunk replacement and purge, durable publication failure, restart and
delayed coordinators. The many-checkpoint three-node case includes one replica per state; its
one-MiB saves fit the replication operation’s limit. Deloxide tracks the installation barrier and other
boundary blocking locks; Fjall’s internal locks, async authority ordering and memory ordering keep
their separate ordinary, Shuttle, Loom and Turmoil checks. Measurements and diagnostic artifacts
are delivered to the owning task.
The registered large-WASM-handoff diagnostic scenario reaches replica installation and planned
handoff with two branch saves above that message limit. It exercises the state store’s tracked
installation locks in both Deloxide selections; the bulk stream’s async waits and transport faults
remain outside the detector and have their own Turmoil and Cucumber evidence.
Materialized capture shares row carriers under the existing assignment barrier, together with the revision, ownership fence and branch generation. Membership insertion and eviction use that same barrier; ordinary updates of existing branches keep their existing admission protocol. Fresh backup capture admits its bounded row-view metadata before allocation. Archive conversion stages one identity/Arrow group at a time. Native loading independently bounds typed-key and row-view metadata to 8 MiB, charges retained decoded columns to relay memory through installation, and uses bulk memory for one section’s decoding scratch. A cumulative relay admission refusal ends loading instead of waiting while retaining earlier groups. A pinned database reader retains one snapshot and one stored chunk through all section reads. Replica installation checks monotonic revision under the existing exclusive assignment barrier before replacing any entry.
RESUME changes the domain to running at its archived lifecycle in the consensus effect that
records complete publication and releases the installation gate. No atomic field or memory
ordering in the capture epoch, assignment protocol or relay revision changed. Existing Loom
models continue to qualify their ordering; new Shuttle checks qualify branch membership capture,
replica revision races and publication before activation. Paused generators release their
admitted-work guard after flushing and reacquire it before producing more output.
Native restore conversion retains the immutable verified-description owner and its preparation reservation while an admitted storage job streams the current checkpoint encoding into a quota-owned file. The job owns the disk permit until its file closes or becomes a completed artifact, including cancellation. Entry serialization and 64 KiB writes check cancellation between bounded units. Each bounded artifact read also retains its file and quota in the storage job until it exits. Local and remote installation retain their exact file source and use the existing authority and installation barriers; conversion adds no lock to record paths.
Stored branch capture also retains one immutable database selection, opens only the scheduled checkpoint, and streams it in cancellable 64 KiB units into a quota-owned file. Its admitted read-only mapping and size index remain owned through each bounded conversion job; cancellation does not release their memory or disk permits before that job exits. Keyspace and window codecs convert only the selected archive group. This adds no lock or publication protocol. Existing capture and restore lifecycle locks remain tracked by Deloxide: the stored-branch scenario runs in active and order selections and in the restore stress selection. Deloxide does not observe the mapping, CPU jobs or async admission; ordinary cancellation/refusal owner tests and complete Bolero byte comparisons cover those boundaries. The capture fence’s Shuttle and Loom models retain ownership of its unchanged publication and ordering invariants.
The backup coordinator waits for each node’s admitted-work counters to reach zero, then orders one
confirming force-flush generation across the cluster. The generation’s obligations use the same
atomic counters as shutdown; a parked REQUIRED WAIT batch stays visible separately and cannot
hold the cut open. Rows a window retains park their shares too, and the cut captures them as
window state.
| Metric series handles | Each relay, node, ingestor, emitter, or concrete branch resolves its label set, internal series, and Prometheus child when its owning task or branch is created. | Recording uses the retained series directly. Counters update atomically; a histogram records through its already-resolved per-series accumulator without a registry lookup, key construction, or map guard. A node input folds a whole batch’s delivery latencies in one kernel pass over the batch’s high-watermark column before recording, so each latency series locks its accumulator and reads the wall clock once per batch rather than once per row, and its Prometheus child takes the batch in one flush. Whether a series was ever observed is an atomic flag set under that lock and read without it. Registration and removal stay on lifecycle paths. |
| MQTT broker packet limit | Each MQTT sink’s event-loop task stores the Maximum Packet Size of every CONNACK its client receives, or the broker’s silence about one, as one atomic scalar. A reconnect replaces it. | A publish loads the scalar once per record to reject a packet the broker would refuse before handing it to the client. The value stands alone, so relaxed ordering is its whole contract, and a record published before the first CONNACK is measured only against the protocol’s largest packet. |
An atomic replacement gives consistency for the value it publishes. A transition involving several owners still needs a protocol. Graph and schedule changes use quiescence and relay gates; clock and state operations carry generations; subscription creation uses a visibility handshake. Publication removes read-side contention without weakening those transition contracts.
The control plane classifies each committed schedule change and builds a complete typed execution revision before handing it to the runtime. The revision keeps placement, state identity, handoff fingerprint, and all node plans together. Runtime application reads that revision for incremental swaps, relocation, remote consumer edges, and full or passive rebuilds. A failed application keeps the last successfully applied schedule as the predecessor for its retry; a stale revision cannot replace that predecessor. Each planned domain also carries that predecessor’s source identity. If an earlier attempt left a domain partly or wholly installed at another revision, the runtime rebuilds it instead of applying a delta against the wrong predecessor.
Schedule application prepares every changed local processor plan before publication. A preparation failure leaves the preceding routing revision observable. Publication swaps the complete typed routing snapshot with its installed execution revision. Unchanged processor specifications, schema fingerprints and resolved branch contracts keep their exact plan allocation, including prepared programs and compiled WASM modules.
Retained Task Dependencies
A source or sink retains one failure publication containing both its safe error and optional retry. Success reads that slot and clears it only when it holds a failure. Identical failure reports keep the same immutable observation. Reporting an error without a new retry keeps the active retry, which remains visible as publishing work to drains even when the emitter buffer is empty. DESCRIBE loads error, backoff and remaining wait from one observation. Emitter confirmation guards retain one counter resolved at startup, and task teardown removes its registry interest.
Every pooled Redis or SQL sink registers a wait slot once. The connector serializes its connection borrows. A ready borrow writes nothing; the first Pending poll publishes its wait and owns a guard that clears it on success, error or cancellation. Sink destruction removes only its own registry slot, so a replacement sink’s slot survives an earlier sink’s teardown.
Generators retain the domain acknowledgement tracker. Ingest executions, groups and endpoint bindings retain the same domain/ingestor tracker pair. Task and branch metric handles retain the exact placement’s dirty mark. Client batch outcome counters, quiesced payload counters/gauges and subscription drop counters retain their Prometheus children, including every declared outcome label combination, so recording resolves no label set.
Subscription generations also retain the node’s executor and pass it directly to predicate evaluation. Executor admission continues to own worker and memory bounds for extension calls. The admission benchmark also constructs the current emitter context, resolving its routing, metrics mark, status publication and confirmation counter before the timed encoding runs.
Each handoff entity has one immutable coordination-owner set and notification owner. Tasks retain that entity slot across repeated freezes. A watch registers before reading the publication; engagement and release replace it before notifying. Concurrent owners derive their replacement with the primitive publication boundary’s reference update. Domain removal drops registry interest.
Domain-clock publication contains lifecycle pause, generation and start-point observations alongside its installation. Ingestion reads admission and time from that one publication; Kafka offset polls read its generation; generators, processors, materialized relay tasks and subscriptions use the clock or lifecycle handle bound at startup. Each WASM state retains its entity assignment slot, which publishes state identity and checkpoint execution/replica owners together. A generation replacement or removal therefore fences a callback without a runtime registry lookup.
Clock authority reconciliation binds a producer to its locally installed mapping, generation and
authority fence before starting it. Each node’s domain clock owner installs a committed
authority-only reassignment ahead of graph installation. Each installed domain records the
committed revision of its lifecycle, which keeps an older graph installation from restoring a
superseded fence. A graph installation held on a departed node therefore stalls neither the
replacement authority nor a receiver. Each remote delivery loop starts after its receiver
advertises installation of that revision’s domain clocks. An obsolete producer still retires
before its successor starts, and a leader proposes an authority that did not commit again on a
fixed interval instead of waiting for the next state change. The takeover Cucumber scenarios in
domain_clock_contract.feature exercise this ownership path in the active, order and lifecycle
stress Deloxide selections; their async installation, watch and transport waits remain outside
tracked-lock detection.
Force-flush participants retain a private idle, available or closed scalar. Idle and already-claimed polls acquire no coordinator mutex. An available indication enters the mutex, which remains the sole authority for generations and claims. The coordinator visits participants in participant-ID order when publishing readiness, so deterministic replay sees the same sequence of scheduling points. Publication of a request precedes the watch wakeup; claim release makes the obligation available again. The relaxed hint publishes no data from another location. Watch synchronization and ArcSwap ordering belong to opaque primitive dependencies; this change introduces no Nervix cross-location memory-ordering claim. Production Shuttle checks explore generation publication, claims, release and close, and the idle regression counts actual coordinator acquisitions.
The status and freeze transition contracts have registered Bolero sequence properties. Status
observers and freeze registration/release have production-owner Shuttle checks. just bench-task-handles records raw same-host timing and allocated-byte samples for the repaired paths.
The retained task handle measurements record the measured
operations, host, samples and limits.
The concurrent map inventory records their lifecycle registration, observation and teardown sites.
Server endpoint intake
EndpointIntakeRoutes owns one immutable host-and-path table containing each domain’s configured
endpoint definitions and the source lifetimes bound to them. Domain install or teardown and source
bind or unbind derive a replacement from the current publication with ArcSwap::rcu. Concurrent
writers retry against the latest complete table, preserving unrelated domains and sources. A
passive installation publishes no endpoint definitions. The listening socket remains present on
every node independently of this table.
An HTTP request resolves one route using borrowed host and path keys; only uppercase hosts need a normalization allocation. A WebSocket retains the resolved route and its signaling protocol from upgrade through all later frames, including signaling data frames. Readers share the route allocation and borrow its prepared intakes; they do not clone route vectors, programs, codecs, or sender maps and do not return to a concurrent registry for each frame.
Each source lifetime publishes its optional intake through ArcSwapOption. Close, drop, domain
replacement, domain teardown, and runtime clear publish absence before withdrawing that lifetime.
Retained routes therefore refuse later admission even after a replacement source starts. A request
that already loaded a present intake keeps its lease until dispatch finishes. Unbind removes the
exact source allocation, so a late close cannot remove a replacement with the same typed identity.
Configuration withdrawal removes the route; source withdrawal alone leaves configured metadata
available to report unavailable intake. These publications remain volatile data-plane state.
Unit regressions and the registered Bolero operation sequence exercise the production owner and retained lifetimes. Shuttle races whole-domain publication with resolution, unbind with admitted requests and replacement, and domain teardown with retained routes. These checks treat publication operations as opaque scheduling points. Both publications reuse the primitive boundary’s existing implementation: no new memory-ordering protocol is introduced, and Loom does not model their internals. Listener ownership and network primitives retain their existing contracts; Turmoil is outside this owner’s scope. Public scenarios cover one and three nodes; the endpoint Chaos workload belongs to Typed Ratchet 10. The inventory ledger records the routing measurement and verification evidence.
Mutable Execution State
Per-row mutation is kept close to the lane that orders it. A global concurrent map is an index into those lanes, not the owner of their inner state. Once a task has found its lane, later mutation does not repeatedly acquire the registry guard.
Ownership handoff drain before freeze
The runtime closes intake and the affected boundary gates, then requests a force flush and confirms the admission hold. The coordinator can observe pending work and its timeout diagnostics while the installed ingestor routes and processors finish that flush. Each participant’s exact operation drain-status query publishes its ownership freeze only when the local affected scope is drained. Capture requires both the held operation and its own coordination identity’s freeze. The status query retains the operation’s registry guard through publication, so release cannot withdraw its hold between observing drain and publishing capture readiness.
This is lifecycle work: the polls read existing quiesce and acknowledgement registries, and hot
paths continue to retain their counters and freeze publications. The existing force-flush and
quiesce checks cover completion visibility; freeze checks cover publication and wakeup order.
The ownership drain reads force-flush obligations before admitted-work counts. Acquiring a
completed flush therefore makes a parked message that the flush resumed visible to that drain.
loom_an_ownership_handoff_drain_sees_work_resumed_before_flush_completion exercises the production
counter method; its qualification weakens that method’s existing acquire. The handoff publication
check races the production freeze decision against the actual participant’s flush completion.
The public shutdown regression holds an admitted Kafka route until handoff publishes its flush
request, then verifies completed drain and exactly one acknowledged output. The diagnostic lane
registers that path in all three selections; async wakeups, transport and deadlines remain outside
tracked blocking-lock detection.
Emitter payload assembly
One emitter task owns the released carriers and their flush order. Its batch packer borrows each
carrier’s source relay, concrete branch key and ordered metadata while it prepares selected Arrow
rows. It clones the Arc-backed Arrow batch once per carrier passed to the packer; no row
acquires a lock or increments an Arc reference count for source identity. The open candidate holds
at most BATCH MAX MESSAGES prepared members and is sealed on metadata or source changes, a failed
member, or the message-count limit. Each encoding attempt uses the codec’s bounded writer under
MAX SIZE. The emitter retains original batch and row positions for acknowledgements and errors.
A payload offered to the sink stays in the same task’s buffer until the sink answers for it. The task marks each member row prepared, and the one answer for the payload resolves every member: a row resolves once, so neither a repeated answer nor a later attempt resolves its acknowledgement share again. An HTTP emitter’s prepared request is such a payload with exactly one member, retained by the same owner with its request fields and body bytes, so its answers follow the same rules, and so is an OTEL emitter’s Export request, retained with the protobuf bytes its connector prepared and the rows they carry. No lock guards this state; only the emitter task touches its buffer, and resolving a member is one operation on the lock-free ACK tree. A confirmed payload’s members are acknowledged in the same step that releases the payload, while a rejected payload’s members return to pending until their message errors are delivered, so an attempt its stop deadline cuts short leaves every member either resolved or still owned by the buffer.
Terminal teardown races the whole emitter task against its domain shutdown signal. It can cancel an in-flight connector await after the graph drain budget expires, dropping the task’s volatile payloads and ACK guards together. That cancellation cannot resolve a member as delivered; an acknowledged source retains its own redelivery boundary. Entity-pause swaps do not use this terminal cancellation: the old task must complete its deadline-bounded stop before the new task starts. The Shuttle check over the emitter’s prepared payload and ACK owner races a connector await with the terminal shutdown signal and asserts that dropping an unanswered request leaves its attached source unacknowledged.
Branch processor state
Each concrete branch has one dispatch lane and one mutable processor runtime. The lane admits one batch for that branch and queues later batches, which preserves processor order. Different branches have different runtimes and execute independently. Expiry and eviction first detach a branch from the instance registry and then take ownership of its runtime, so registry access is not nested over branch execution.
Deduplicator keys
A deduplicator keyspace belongs to the task for one concrete branch. That task prunes, reserves, and releases keys directly. When the keyspace changes, the task marks it dirty and periodically publishes an immutable generation whose key allocations are shared with the live keyspace.
Snapshot persistence, replication, ownership handoff, and a replacement branch task read the last published generation. They never read or lock the live keyspace. Publication occurs during the replication cadence, before a handoff checkpoint, and when the branch stops, so snapshot encoding can proceed while the current task continues processing rows.
Window and WASM state
A window and its aggregate accumulators belong to one branch task. The task mutates the live window directly and publishes a complete immutable window generation for persistence, replication, handoff, and restoration. Snapshot encoding reads that generation instead of holding the branch runtime while it serializes. The publication shares the retained input and aggregate-argument Arrow columns through row views. The snapshot task seals those views as bounded Arrow sections on the bulk executor, while a separate bounded typed section carries each group of histogram delayed removals. Encoding never materializes a scalar-field copy of the retained payload. A window whose rows exceed one snapshot section is sealed group by group into quota-owned pieces and published as a segmented checkpoint under the state store’s installation barrier, as a materialized relay’s periodic checkpoint is; the snapshot task holds no lock while it seals, and the barrier is held only by the storage job that writes the segments and the header. Restore opens the sections on the bulk executor and reuses their columns to rebuild exact accumulators and sketch panes.
Evicting a concrete branch resets its retained window rows and aggregate structures before the branch task’s final publication. The published generation is empty, so a later appearance of the same branch key cannot inherit the evicted window or its sketch panes. After the final checkpoint, the owner releases the evicted branch’s in-memory publication. A branch that appears without a restored lifecycle entry also publishes an empty initial window, even if a previous lifetime of its key left a checkpoint behind. A processor task therefore installs every branch its lifecycle names before it dequeues any input, and a restore the bulk executor refuses installs none of them and is attempted again; see Restoring Processor Branches. Stopping a branch for an ownership handoff follows the normal finalization path and publishes its retained window instead. The branch lifecycle and window checkpoint carry the same incarnation, assigned when the concrete branch appears. A restore whose incarnations differ begins with empty window state and marks it for publication, so a delayed checkpoint from the preceding lifetime cannot restore its panes.
A WASM guest instance likewise belongs to one branch task. At the end of every guest callback the task asks the guest to save its computation state and checkpoints the returned buffer: it writes it to stable storage, waits for the replicas the checkpoint names, and only then publishes it as the committed checkpoint a recreated instance restores. The branch runs no further callback until that checkpoint completes or fails. The publications retain the saved buffer rather than copying it for every persistence or replication reader. Input buffered by the guest host and ACK tokens remain execution state and are never included in a guest save. See WASM State And Recovery for the checkpoint’s stages.
Each branch publishes checkpoint revision, boundary, and stage together through one immutable observation. An inspection read samples that publication and replica progress without taking the branch task’s execution lane or advancing durability. A failure before guest state was captured is an explicit observation without a new revision; any earlier committed checkpoint remains the restore source. The published observation contains no guest bytes.
Every branch save is addressed by the guest-state generation in the committed schedule. Forced recovery publishes a new generation with the replacement schedule, so a late save, replica installation, or recovered checkpoint from the generation it replaced cannot address current state. The state store performs durable guest-state writes on its storage workers; the async worker that owns the branch never performs that storage operation synchronously.
A coordinated reset enters the selected branch task through its existing supervisor command lane. That lane is the serialization point with input callbacks, timeout callbacks, branch eviction, and task replacement. Preparation takes ownership of the selected task, reaches its stop boundary, and keeps the handoff only until either pre-publication abort restores it or generation publication makes it obsolete. Sibling branch tasks keep their own lanes and continue running. After publication, the supervisor creates fresh tasks and waits for their initial checkpoints before it drops the retained old tasks and accepts completion.
Checkpoint replication
Every replicated runtime state owns its replication: the domain offsets of a Kafka ingestor, the state of a deduplicator, window or WASM processor branch, a materialized relay, an entity’s branch-aggregated metrics, and the branch lifecycle of a branch-keyed entity. On the node that owns a placement, the replication records the highest revision each replica reported holding on its stable storage, and offers the placement’s newest checkpoint to the replicas that do not hold it yet. On a replica, it carries the owner’s announcements to the task that keeps the replica’s copy current. It lives and ends with the state it replicates, so a WASM checkpoint or a Kafka offset commit offers its revision through the state it already holds, and no checkpoint, commit, offer enters a node-wide announcement or progress map. State installation publishes a resolved route containing the actual state handle and the entity’s existing assignment slot. Frames select that route from one immutable publication, never from the state, execution or identity registries. An announcer retains its selected route for its whole lifetime and reads the current primary and replica set from the assignment slot.
Branch-aggregate byte capture keeps the narrow lifecycle_call expectation for scanning the
metrics inventory at an explicit snapshot boundary. Each placement retains one encoded snapshot
under its own blocking mutex. Capture reads the dirty revision first and chooses that revision’s
bytes once; subsequent descriptors, streams and persistence reuse them, including elapsed metric
fields. Initial, replica and persisted recovery install the received bytes with their revision.
The guard serializes capture and installation, covers only that placement’s snapshot work and
never crosses an await. Metric recording advances the dirty revision without taking this guard;
the next capture includes those measurements. The guard belongs to cold checkpoint work, not to
record or batch execution. The two interleaved-tenant window drain and relocation scenarios run
in both diagnostic selections and the stressed restore selection. State-route selection uses the
installed state handle and retained assignment slot.
Checkpoint synchronization sends a bounded revision, length and digest description through that
selected route. The replica fetches the exact revision through the bulk pool, admits the whole
allocation and verifies every byte before installation. A handoff destination follows the same
sequence from the source’s captured checkpoint while its entity gate remains held. Neither
path adds a shared hot-path lock or a new
wait-and-notify protocol; Deloxide sees the existing state-store installation lock but cannot see
stream scheduling or async memory admission.
A replica’s progress only rises. Acknowledgements travel independently, and a replica acknowledges what it holds again whenever it is offered a checkpoint, so an acknowledgement of an older revision can arrive after a newer one; it leaves the newer one in place. Each wait for replicas registers for the next report before it reads the progress: a WASM checkpoint waiting for the replicas its boundary names, a Kafka offset commit waiting for its replica quorum, and a WASM state reset waiting for the branch lifecycle that authorizes its first checkpoint. The reset wait retains its route, follows the current replica assignment and rejects loss of identity or primary ownership. A checkpoint promised to replicas never confirms with fewer replicas, using the same typed boundary as a guest checkpoint. A 100-millisecond recheck observes assignment changes even without a replica report. A report that lands between the read and the wait therefore wakes the wait instead of leaving it to its deadline.
One announcer at a time offers a placement’s revision. An offer raises the revision the running announcer offers or, with none running, starts one. The announcer sends the revision to the replicas that lag behind it, again every 100 milliseconds, and ends in the same step that finds every replica the committed schedule assigns holding it, so a revision offered while it ends is either sent by that step or starts the next announcer. It also ends when this node stops being the placement’s primary, when the replicated state goes away, and when the runtime stops. An announcer whose task ends before it finished hands its announcement back to the next offer.
The runtime’s checkpoint announcement task owner cancels both dispatch and retry waits when it closes after domain drain. It joins every cancelled task before withdrawing replication routes, so shutdown releases the task’s exact retained state without waiting for a remote dispatch timeout.
The snapshot task of a deduplicator or window branch has the runtime as its owner too, because it outlives its branch task: once that task is gone, stopped or ended at its stop grace, the snapshot task still writes what the branch published last. A stopping runtime closes this owner after domain drain, which tells every task still waiting for its branch to make that write, and joins every one of them before it closes the announcement owner. Nothing is cancelled: the join is what keeps the node’s storage from being released under a write, so it lasts as long as the slowest of those writes. The owner is a task tracker and a cancellation token, with no lock.
The announcement, the progress and the announcer’s handover change together under a short lock that belongs to the one placement and is never held across an await. The placement’s originator, its one announcer and its replicas’ acknowledgements are the only participants; unrelated placements never share it. An acknowledgement or an announcement names its placement, and the node that receives it uses the handle resolved when that state was installed. The assignment and the placement must still name the same schema and guest-state generation. Cold replacement or teardown ends the route before withdrawing it, so a retained route never attaches to its successor, including when the successor has the same placement key. A frame already admitted through a borrowed state may finish on that exact state. On a replica, announcements of a lifecycle or one of its branches use the published entity lifecycle, which the entity’s replica task retains. An announcement of an entity a node holds no state for wakes nothing and creates no state.
A replica installs a branch checkpoint only while the branch lifecycle it holds names the branch. Each lifecycle checkpoint a node holds is decoded once into the set of branches it names, so installing a branch checkpoint looks its branch up in that set, through the lifecycle handle the replica task retains, instead of copying and decoding the lifecycle each time. Holding a replica’s copy of a branch checkpoint compares revisions and moves the newer payload into an immutable publication owned by the entity lifecycle. Lifecycle pruning visits only that entity’s passive copies without acquiring a registry shard. Catch-up steps and installation read the assignment slot the lifecycle retains. Cold assignment cleanup also discards superseded passive generations, while an already borrowed checkpoint can finish. Cold promotion consumes the passive copy; non-branch handoff snapshots remain in a cold recovery registry.
Replica catch-up
A replica keeps each branch-keyed entity it replicates current through one replica task: the entity’s branch lifecycle and, for a deduplicator, window or WASM processor, the state of every branch. The task retains the entity’s lifecycle handle and alone owns what it learned: the owner’s catalog as far as it read it, the revision this node holds of each branch it looked at, and the branches it still has to fetch or acknowledge. Nothing else reads or changes that record, so it takes no lock.
The owner keeps, with each entity’s lifecycle, a catalog of the newest replicable revision of every branch state it owns. A branch state registers when it is created, records every revision it offers its replicas, each generation a deduplicator or window branch publishes and each WASM checkpoint on the owner’s stable storage, and leaves the catalog when it goes away. A branch state that another one of the same branch replaced neither changes nor removes its successor’s entry. The catalog is one immutable value that each change replaces by a read-copy-update, so recording a revision never waits for a reader and a reader never waits for a branch. Every change is numbered, and a replica asks for the changes after the cursor its previous read returned. A cursor of another catalog, a cursor older than the oldest removal the catalog kept, or no cursor at all restarts the listing from the catalog’s beginning. A listing is paged, 256 changes at a time.
A round runs when the task starts, when the owner announces a checkpoint, and once every replication poll interval. It synchronizes the lifecycle, reads the catalog’s changes, and looks only at the branches that changed, that the lifecycle names for the first time, that the owner announced, or whose earlier step failed. The task reads what this node holds of a branch once, from its own copy, and keeps that current itself. A round in which no branch changed therefore sends two requests to the owner, reaches no shared map for any branch on either node, and does no work per branch, however many branches the entity has. The branches that changed are fetched and installed at most 16 at a time.
The owner’s announcements of the entity’s lifecycle and branch checkpoints are left with the entity’s lifecycle handle on the replica, the newest one of each, under a short lock that belongs to that one entity and is never held across an await, and each wakes the task. The task takes them all at the start of its next round, so an announcement that lands while a round runs is taken by the next one, and one of a revision the replica already holds is acknowledged again. An announcement that is lost delays a checkpoint by at most one poll interval.
Relay branch presence
A relay’s owner task owns its concrete branch instances: their activity order, the TTL scan that expires them, and the LRU eviction that bounds them. It keeps them in an ordinary indexed map beside a persistent set of their keys, and admits, evicts and expires through one owner type that changes both in the same step. A step that changes the membership publishes the set, sharing its structure with the membership published before it, so creating or releasing one branch copies only the path to that branch, never every branch the owner holds. A batch that creates one branch and evicts another publishes once, so an observer never sees the relay above its capacity. A batch for an established branch refreshes that branch’s activity in the owner’s map and publishes nothing; it takes no lock and writes no shared timestamp.
Each owner lifetime claims the presence when its task is spawned. The claim publishes an empty
membership that replaces whatever an earlier owner left behind, including the branches of an owner
aborted before its teardown ran, and from then on only the claiming owner changes what the presence
publishes: a replaced owner that is still ending compares its lifetime with the published one and
publishes nothing. Dropping the owner state, whether the task finishes its drain or is aborted,
releases the presence by publishing an empty membership unless a successor has claimed it.
Publication and claims are one ArcSwap compare-and-swap or read-copy-update each; the protocol
adds no atomic of its own.
The owner publishes a new branch before it fans out the batch that created it and an eviction before it invalidates the evicted branch’s delivery slots, so a consumer that has processed a batch reads a membership that holds its branch.
Materialized relay entries
One assigned materialized-relay originator owns updates. The state contains at most one latest record for each concrete branch. Its origination capability moves into the branch runtime or the scheduled relay-state task and cannot be cloned. That task owns an ordinary map of mutable branch records. An established branch retains its row publication: timestamp comparison uses the owner’s record, then publishes one immutable Arrow-backed row view. It acquires no shared-map shard and does not republish or scan branch membership. A larger high watermark wins, then a larger low watermark; equal watermarks retain the first record and advance no revision.
Replacing an existing branch’s record is admitted under the originator’s assignment generation. Adding the first record for a branch or evicting a branch also changes the branch lifecycle, so that narrower operation uses the assignment barrier and advances a branch generation. The persistent membership publication changes only at those boundaries or a snapshot installation. Eviction first clears the exact row publication, then withdraws that member. Recreation allocates a new publication; a retained ended member never attaches to it. Rebinding waits for operations admitted under the preceding assignment before rebuilding the new task’s mutable selections from the published views. Previous writer tokens refuse further mutations.
Domain routing retains one materialized publication per relay and its relay-state epoch. The installed state-replication routes supply the relay’s immutable branch index. Dependency reads and generator ticks load that retained relay index and the installed row views, rather than looking up or iterating node-wide materialized-state, presence or epoch registries. Presence comes from the retained relay services; placement identity comes from the shared immutable assignment publication. Live state owns absence: an empty concrete branch does not fall back to the relay snapshot, and a missing live row does not fall back to a pre-eviction stored snapshot. Cold installation and public observation retain their lifecycle registries.
A snapshot capture holds the assignment boundary only long enough to take immutable row views, their revision, their ownership fence, and their branch generation. The Arrow columns stay shared, and sealing and encoding happen after the boundary is released. A snapshot from an earlier assignment or branch generation cannot restore state that a newer owner or eviction superseded. Installation also refuses a lower revision within the same fence and branch generation. A cached sealed snapshot answers a current read only when its revision and assignment fence still match. One sealed generation is cached and one build is admitted per placement; outstanding readers and captures keep only their selected row views and shared Arrow carriers. Publication never copies a row payload or a whole relay for an established update. Snapshot archives and storage ownership retain the current container and backup contract.
A stopped backup selects materialized checkpoint readers alongside its other state from one immutable database view. Those readers keep the selected namespace, metadata and chunks through bounded group conversion, even if a later publication replaces or reclaims the generation. They retain one stored chunk and one charged group at a time and publish no runtime state. Running and paused backup capture continues to borrow current row views under the assignment barrier.
The required-wait observation creates its notification before reading dependencies. The primitive
boundary registers a notify_waiters observer at future creation, so an update after the read and
before its first poll still wakes it. Broadcast publication occurs once after each changed batch
and after a deletion. Periodic rechecks also cover remote changes; they do not establish the local
wake invariant.
The registered materialized-publication-lifetimes property exercises bounded updates, captures,
deletes and rebinding, then restores every captured schema, logical value, watermark and branch.
Production-owner Shuttle checks cover update/capture, eviction/recreation, assignment replacement
and required-wait registration without a polling timer. The Loom invariant
server.state-assignment.drained-publication proves the synchronous admission drain observes
preceding writes; its relaxed completion weakening must fail. ArcSwap internals are opaque to
these models. Supported Turmoil transfer checks establish network framing and replacement scope,
not disk durability or whole-cluster recovery. Typed Ratchet 10 owns mandatory immutable-image
materialized smoke/soak qualification. The same-host just bench-materialized-state measures row
and batch cost for 1, 256 and 4096 branches, allocation and captured-carrier retention; instrumented
benchmark smoke execution supplies coverage, not performance evidence. Assignment/capture
barriers and sealed-cache locks are tracked by Deloxide; async notifications and opaque publication
internals remain outside its detector.
just test-deloxide, just test-deloxide-order and just test-deloxide-stress include the materialized-publication diagnostic
process, which starts the detector and exercises timestamp replacement, capture, sealing,
installation lifetimes, rebinding, lazy relay installation, branch eviction and bounded complete
snapshot properties. The latter also checks potential acquisition-order cycles among tracked lock
instances. Each selection retains its own evidence alongside the other diagnostic processes under
target/deloxide/test-deloxide; these instrumented executions supply correctness evidence, while
the ordinary native benchmark supplies the row-publication cost measurements.
Sealing retains bounded identity and Arrow pieces on quota-owned staging disk and concatenates through one 64 KiB read buffer. Row projection and its IPC result share one bulk admission and one CPU job; projected columns never wait for a second grant while holding the first. Typed identity conversion is charged before it allocates. The sealed cache holds the immutable file and descriptor, so its memory does not grow with container size. Periodic writers select their namespace before executor admission, write one 64 KiB database chunk per batch, synchronize the chunks, then synchronize the replacement checkpoint header. A queued writer whose namespace was replaced is inert, and a pinned reader continues through the same database snapshot. Replica installation rejects a lower revision before clearing its current entries. An incoming materialized snapshot releases its response stream at EOF, before local verification or Arrow decoding. Those local operations retain their own staging and memory admission; they do not keep Snapshot request or connection-stream permits while waiting for executor work.
Kafka offset catch-up checks the revision before retaining or encoding a checkpoint. Capture holds the assignment barrier only to select a conservative revision and immutable partition topology; encoding retains that topology and reads shared offset slots after the barrier returns. A commit included ahead of the captured revision is offered again by the following revision. The native encoder writes into quota-owned staging disk with a fixed 64 KiB I/O grant and separately admitted serializer scratch. Bulk transfer carries bounded chunks rather than a whole resident checkpoint. At EOF the receiver releases its Snapshot stream, verifies the staged file, and admits native decoding separately. It builds a replacement table directly from archived entries, checks cancellation between entries, and publishes only under the replica’s existing assignment token. A failed conversion changes no installed offsets; rebinding rejects a delayed installer. No new concurrent map, per-record lock, or atomic ordering is introduced by this transfer path. An unchanged poll repeats the held revision through the existing replica assignment admission. This recovers a lost acknowledgement without encoding another checkpoint, while promotion or replacement prevents a retained poll from reporting progress through its former token. The runtime passes the native bulk operation’s thirty-second budget into the quorum wait for both committed and reset Kafka offsets. The offset-state owner supplies the register-before-read wait mechanism; it no longer chooses a separate response budget that can expire while an admitted native checkpoint is still progressing. A missing replica acknowledgement still fails at this bounded deadline.
Acknowledgement state
Each source attempt owns one in-memory acknowledgement tree. Fan-out reserves shares in an atomic
pending count, and each completion resolves one share with a compare-and-swap. A second atomic word
encodes either a typed tracking state with its active-share count or the completed state; its
reserved raw representation stays inside the encoding’s owner. That state records whether shares
still count against ownership handoff; a message parked on materialized REQUIRED WAIT does not
keep a handoff blocked, and neither does a row a window retains until the window steps past it.
Both owners hold one park guard per retained message and drop it before they acknowledge, so the
acknowledgement removes the share the holder owns.
Remote ACK progress publishes a monotonic sequence with the root’s parked handoff state. An
upstream remote share follows that state, and its root publishes the resulting transition farther
upstream. A delayed progress event cannot reactivate a newer parked state. Terminal ACK resolution
still owns the pending share and releases the parked guard with it.
A handle owns an active share only when its attachment reserved a pending share. An attachment that finds the root complete may observe that root, but cannot park or remove a share owned by another handle. Resolution removes the owned pending share before changing active-share tracking, so a terminal transition cannot make an unsuccessful attachment look like active work.
Root trackers count attempts rather than individual shares. Their transition ordering may briefly overcount but cannot let handoff miss active work. Exactly one terminal transition takes the one-shot sender from its slot and releases that guard before notifying the waiter. ACK trees, shares, and trackers are volatile hot-path state and are never persisted.
A WASM branch holds back the success of the inputs a guest callback decided with one more attached share per input. The branch task owns those shares for the length of the callback’s checkpoint and resolves them itself: it releases them when the checkpoint completes and negatively acknowledges them when it fails. Holding them needs no lock and no shared registry.
A share a node forwarded to another node with a relay delivery waits in the node’s registry of forwarded acknowledgements under its registration number, beside two atomics: whether the delivery was admitted, and how many sweeps found no report from the receiver since its last one. A report, the admission, and the once-a-second sweep change them through a borrowed lookup, under the shard’s shared lock. The sweep fails a share only through an exclusive removal whose predicate rechecks the count, so a report that took the shared lock first keeps the share, and a report after the removal finds no entry. A terminal outcome, the delivery’s own failure, and the sweep each take the share out of the registry with one removal, so exactly one of them resolves it. The atomics establish no ordering across locations; the shard lock orders the recheck against every earlier report.
Client ingestor endpoints
Each client ingestor a node executes has one endpoint task that owns every producer attached to it: the batches each queued, the round-robin order among them, the batches handed to the execution’s admission worker or admitted and awaiting acknowledgement, and whether admission is open. Producers, the admission worker, the quiesce watch, and lifecycle changes reach that state only as commands on the task’s channel, applied in the order they were sent, so nothing locks it. An execution is installed before its admission worker and quiesce watch start, so the watch’s first report, which opens admission, is applied after the installation.
The admission worker takes one batch at a time over a channel of one. A worker that stops refuses the batch it was handed and never took, and closes the channel so that the endpoint refuses any later batch itself. The worker’s task is joined before the endpoint learns that the execution stopped, so a batch the endpoint still holds then was taken by a worker that was aborted when its stop outlasted the grace period, possibly while dispatching it: its outcome is unknown, never refused. The endpoint task awaits each admitted batch’s acknowledgement itself, among its commands and before further ones once it resolves, so an ending endpoint leaves no task behind, and a resolution for an attachment that already ended finds nothing to answer.
A producer’s turn gives its next queued batch a slot of the window whether or not the worker is free, so a busy worker never passes a producer over. A local producer’s batch is then ready for the worker. A batch that another node forwards first waits among the producer’s batches awaiting clearance while the endpoint asks the serving node over the producer’s own clearance channel. The clearance returns as a command, and clearances arrive in the order they were requested, so the one that arrives names the oldest batch awaiting it, or a batch the endpoint has already refused, which it ignores; the cleared batch is then ready for the worker too. A free worker takes a ready batch, which already holds its slot. A refusal, a close, an end, or a detach answers or drops the batches still awaiting clearance or the worker, which never reached it, and releases their slots in the same command, so a lost serving node never leaks a slot of the window.
After each command the task publishes its producer, outstanding, and window counts into plain
atomics that DESCRIBE reads, and into metric gauges it resolved once. Each count is exact when
written, and a reader may see one count of a change before another.
The node’s producer byte budget is one atomic counter. A reservation adds its bytes with a compare-and-swap that refuses to pass the budget, and dropping the reservation subtracts exactly those bytes, so concurrent opens through different sessions and links never overcommit it.
A session’s producer credit is held under a short mutex, taken by the receive loop when a batch arrives and returned by the producer’s task before it queues that batch’s reply, so a client that sends only after reading a reply always finds room. The mutex never crosses an await.
Client emitter attempts
Each running native client emitter has one actor for its volatile output. Its channel serializes consumer attach/detach, prepared-payload publication, assignment, settlement, timeout and cancellation. An attempt has exactly one owner and ACK reference. Revocation returns its worker’s batch and byte credit and marks that reference stale before the payload can be assigned again. Sequential dispatch checks every earlier unresolved delivery of the same source relay and concrete branch; parallel dispatch counts assigned attempts against that stream’s shared window. The actor keeps only a bounded queue of completed references for duplicate ACK recognition.
The node’s consumer grant and retained-output byte counters are separate atomics. A session’s
consumer count and credit use one short mutex; neither that mutex nor a runtime map guard crosses
an await. A retained payload reserves actual bytes until application settlement or cancellation,
and its prepared Arrow bytes and source members remain owned by the emitter host until the
result is applied. A consumer read uses an asynchronous receiver lock only for that consumer; it
cannot hold the session receive loop or the ordered command lane. Settlement is a concurrent
request, so a quiescing command cannot prevent the application ACK it waits for.
The shuttle_competing_consumer_grants_never_exceed_the_node_budget check explores competing
session grants against the production atomic budget. The
shuttle_consumer_loss_and_ack_race_release_one_retained_delivery check runs the production
delivery owner with deterministic attempt references and no timer wakeups to explore ACK versus
consumer detachment. The shuttle_publish_cancellation_and_ack_race_release_one_reservation
check also races publisher cancellation against application ACK and verifies retained byte credit
returns once. Timeout revocation is covered by the ordinary owner and public scenarios.
Rust client attachment recovery
The client retains desired producer and consumer handles independently of their wire attachments. Their weak registries are reached for attachment registration, admission changes, termination and reconnect snapshots, not for discovering an attachment on each submission or delivery. Producer wire entries include the exchange generation beside the attachment ID. A snapshot upgrades live weak handles and releases its map guard before any handle transition or network await. Close/drop removes desired entries, exchange termination removes its producer wire entries, and snapshots prune expired weak handles. The concurrent map inventory records each map’s access frequency and disposal.
Each desired handle has one lifecycle owner, with a short selected mutex over its typed attachment phase and a watch notification for changes. Beginning or installing a restore checks that phase and its exchange generation under the same mutex as close. Close is terminal: a restore reply that loses that transition cannot install its attachment and releases the grant it obtained. Another exchange loss can interrupt only the restore or active attachment of that exchange. No lifecycle guard crosses an await. Submissions and deliveries keep their originating attachment, so replacing it cannot retarget an uncertain submission or an application ACK.
The four production-owner Shuttle checks listed below race close with starting restoration and with another loss during restoration. They establish that the handle stays closed and cannot begin a subsequent restore. Public scenarios cover exchange I/O, late open replies, reservation cleanup and delivery interruption; those network effects are outside these in-process checks. Client Session Protocol and its implementation manual own the restoration and application-outcome contract.
Node quiesce accounting
Every entity on a node keeps one set of quiesce counts. A drain reads them to decide whether the node still holds work, and the hot paths that take a message in, collect it, park it on required materialized state, buffer its output, or finish it adjust them.
Each total a drain reads is a counter of its own: everything the node holds, and the admitted
subset of it that a local drain waits for. A total summed across several counters at read time can
miss an item that is between two of them, because the reader loads the count the item has already
left and then the count it has not yet joined. A drain that misses the last item reports a node
holding no work while it still holds one, and an entity gate that believes that alters the entity
under the message. Messages parked on REQUIRED WAIT and outstanding force-flush obligations are
counted on their own for the drain’s report, and neither exchanges items with the admitted count.
An adjustment that moves work between the counts raises every count that rises before it lowers any count that falls, so a drain reading during the move sees at least the work the node holds. A processor publishes its collected inputs, parked messages, and buffered outputs together for the same reason: a batch that left an input collector for an output buffer was admitted work throughout the move. Overcounting only delays a drain, while undercounting lets one conclude early, so the ordering is chosen in that direction. The counts are hot-path scalars and are never persisted.
A local drain reads these counts together with the domain’s relays, ingestors, generators,
acknowledgement roots and emitters, and work keeps moving between them while it reads. No single
order of reads follows every move. A node that takes a batch from a relay counts it before the relay
lets it go, so the relay has to be read first, while a relay counts a batch a node publishes before
the node lets it go, so the node has to be read first. Each relay therefore keeps two totals on
each node that only rise: the batches it admitted, from its owner’s publishers or from deliveries
another node routed to its consumers here, and the batches it handed on. It holds their difference,
and the admitted total is also its admission sequence. A drain observation reads every relay’s
sequence before and after everything else, and a relay whose sequence changed in between counts as
work still moving. A batch the observation missed reached the place where it rests only after the
read that would have counted it there, so it entered some relay between that relay’s two readings.
The observation reads force-flush obligations first: a participant completes its obligation after
everything it did for the flush, including resuming a message parked on REQUIRED WAIT, so an
observation that sees the obligation complete sees that work as well. The totals cost a relay’s
owner path the same two read-modify-writes per batch as the pending count they replace, a routed
delivery two more, and a drain reads them only on its own poll.
A broker source’s batch keeps its acknowledgement roots counted beyond their resolution. The
source host creates confirmed roots, and a confirmed root that resolves hands its domain and
ingestor counts to the completion that observes it instead of releasing them. The batch
acknowledgement drops the completion of every root that resolves while another is still
unresolved and keeps the completion of the last, which the source loop drops once acknowledge or
reject has returned. The hand-over is part of the root’s one terminal transition, so a drain that
reads the counts by read-modify-write never finds them at zero between a batch’s last resolution
and its transport’s confirmation. An unresolved root keeps holding them itself, unless it waits only
on REQUIRED WAIT state, which a drain does not wait for, and a completion that dropped before its
root resolved has the counts released as the root resolves. See
Confirmed acknowledgements.
An ingestor route’s force-flush participant releases its partial batches into branch execution;
the admitted client submission’s tracked ACK root stays outstanding until its downstream work
settles. For a model alteration with a shared relay gate, the control plane suspends the affected
ingestors on every node and drains those roots before closing the relay gate. Otherwise a flushed
batch could wait behind the gate whose drain waits for that same root. Intake and full entity
holds overlap on the same quiesce control: releasing one EntityHold removes only its share, so
the remaining hold keeps admission suspended. The client admission Shuttle check exercises that
overlap with racing batch admission; the public buffered alteration scenarios exercise delivery
through local and remote relay owners.
A task that must not publish while an ownership handoff has frozen its entity observes that freeze
through one watch, which registers for the next freeze change before it reads the freeze. A release
landing between a read and a registration wakes nothing, because the waiter does not exist yet, and
a task that missed one keeps a stale freeze that disables exactly the force-flush and deadline arms
that would have woken it again. The release lifts the freeze before it wakes anyone, so a waiter
that rereads it sees the entity thawed rather than parking on a change that has already happened.
The ingestor route task, ingestor branch maintenance loop, and processor branch task all take this
observation before deciding which force-flush and deadline arms to enable. Their wake contract is
therefore the watch owner’s registration order, checked by
shuttle_an_ownership_handoff_freeze_observation_registers_before_its_read against the production
release path. Other data-plane waits on watch subscribe before their deciding read, or use an
existing receiver whose borrow leaves a subsequent changed able to see an intervening send.
Ordering Fences
Three fences remain because they define externally observable order or ownership. Each is scoped to the smallest identity that carries the guarantee. They do not provide general-purpose mutual exclusion for unrelated batches.
Per-slot delivery gate
A remote delivery slot is scoped to one destination, relay, payload role, and concrete branch. Its asynchronous gate permits one encode-and-send operation at a time for that logical channel. The gate preserves FIFO order from serialization through admission and ensures the channel sequence is allocated in the same order. Other destinations, relays, roles, and branches use different slots and continue independently.
Established slots are found with a borrowed lookup. entry() is used only when concurrent first
deliveries must agree on a single slot; allowing two racing installations would create two sequence
owners and could interleave the channel. The small sequence state is updated only while the slot
gate is held, and its synchronous guard never crosses an await.
Dispatch-gate engagement
The relay dispatch gate separates ordinary publication from a model change, ownership handoff, or shutdown operation that must know every earlier dispatch has left. On the open path, a publisher raises an atomic in-flight count with a read-modify-write and then checks an atomic closed flag. An engagement closes the gate and then reads the count with a read-modify-write as well. Read-modify-writes of one count are totally ordered, so either the publisher’s comes first and the engagement counts it, or the engagement’s comes first and the publisher’s acquires it, observes the gate closed, rolls its count back, and waits. A plain load of the count would not do: it may return a count from before an increment that preceded it, while that publisher’s own load of the flag still finds the gate open. Leaving releases what a dispatch did under its permit to the engagement’s next read, and the last dispatch to leave a closed gate wakes the engagements waiting for it.
The engagement state is consulted only while the gate is closed. Once all earlier permits leave, the engagement owns a lease and the protected mutation can proceed. The acquisition deadline bounds waiting for that fence; a successfully acquired lease remains engaged until its owner releases or drops it.
An owner buffer admits a batch before its fan-out begins. Fan-out therefore takes a nonwaiting dispatch permit before it reads any local or remote runtime-consumer route and holds the permit until fan-out has attached the consumer ACK shares and resolved the owner’s share. A schedule swap cannot replace the routes while that permit is live. If the gate has closed first, fan-out fails the record ACKs and returns the batch for source retry. Waiting for the gate here would deadlock the swap, because the swap’s drain counts the buffered batch. The same gate protocol orders the nonwaiting attempt and the swap engagement.
The three-node attached-emitter move scenario injects a stale zero buffered-batch drain report while an owner batch is paused. That puts fan-out beside the local swap even when the coordinator’s usual drain would wait for the batch, and verifies that the local gate independently prevents an ACK for a batch the moved consumer missed.
A WASM state reset adds a narrower gate without putting a lock on relay dispatch. Under a short whole-relay publication fence, the relay atomically replaces an immutable list of scoped reset gates. Dispatch loads that list once, compares the batch’s branch fingerprint with each typed scope, and waits only on matching gates. Removing a lease atomically republishes the list and releases its gate. This gives publication and dispatch one total order while unrelated branches neither acquire shared locks nor wait for the reset.
Client batch admission fence
A client batch is validated before it is dispatched, and a quiesce can engage in between. The admission worker therefore tracks the batch’s ACK root with the ingestor’s drain accounting first, and only then reads the quiesce publication. Either the read comes before the engagement, so the drain that follows counts the root and waits for it, or the read observes the engagement and the batch is refused with its root resolved before anything was dispatched under it. No batch can be dispatched after a drain concluded that the ingestor held no admitted work. The admission tracks the root with a read-modify-write, and the drain reads each root count with a read-modify-write after the engagement published, so one side observes the other in the C11 memory model, not only on processors whose read-modify-writes are full barriers.
Assignment generations
A runtime-state assignment packs its generation and capability into one atomic word. A hot operation increments the admission counter for that generation before comparing its token with the published binding. A rebind serializes with other rebinds, publishes the successor binding, and waits only for operations admitted under the generation it superseded. It reads that generation’s admission count with a read-modify-write, so an operation whose admission came first is waited for, and one whose admission came later acquires the rebind’s read and observes the successor binding. An operation that finishes releases what it did to the rebind’s read. Even and odd generation counters let work admitted under the successor proceed without extending that wait.
This fence prevents a former owner from mutating or describing state after reassignment. Snapshot capture holds the assignment barrier so its observed binding stays current for the whole capture. Whole-state installation is authorized exclusively under its exact binding: it cannot overlap a capture or an admitted origination. Ordinary record updates use atomic admission, continue while a capture merely holds the barrier, and never take that barrier.
Bounded Synchronization Outside The Open Path
Some synchronization remains after a hot operation has selected a state that explicitly requires it:
- a quiesced ingestor, and one draining what it retained, locks only the bounded retained-payload
buffer selected by its declared
MAX SIZEpolicy: to retain a payload, to take the oldest out for delivery, and to end that delivery. No guard crosses an await. - a retained payload whose unfolding finds the extension class’s wait queue full waits for a place in that queue. The executor’s semaphore hands a freed place to waiting jobs in the order they asked, ahead of any job asking afterwards. The wait is scoped to the one class, holds no lock, and ends when a place frees or when the ingestor’s shutdown or a new quiesce interrupts it
- a live payload a paced source or a source whose loop may be held handed over without an acknowledgement waits for a place the same way. The source host registers for the next quiesce publication before it compares it with the decision the payload was taken in under, so a new decision always ends the wait, and the host then decides on the payload again; the ingestor’s stop ends it as well. The race holds no lock and no guard across an await
- one concrete processor branch serializes its own batches, while other branches remain independent
- one already-resolved histogram series serializes its bounded accumulator update, once per recording: a batch’s delivery latencies arrive as one folded recording, so the lock is taken once per batch; counter series are atomic
- one inbound relay attempt serializes its small receipt, admission, rejection, and cancellation transition within the interconnect’s quotas and deadline
- snapshot construction and state installation serialize per placement, off the row path
- a WASM branch waits for its own checkpoint after each guest callback, bounded by the checkpoint deadline. The state store’s durability barrier lets one writer at a time run a storage synchronization for every writer waiting, through one atomic runner slot and a ticket watermark; it holds no lock across that wait, and the synchronization runs on the storage workers. The round of a synchronization belongs to the storage job that flushes: it frees the runner slot and records its outcome when that job ends, so a writer that stops waiting neither frees the slot while the flush still runs nor loses a failed flush. Replica progress reaches the waiting branch through the replication of its own state.
- one placement’s replication changes its announcement, its replicas’ reported progress and the handover between its announcers under a lock scoped to that placement, taken by its originator, its one announcer and its replicas’ acknowledgements, and never held across an await; see Checkpoint replication
- a replica’s copy of one branch checkpoint is held under the registry’s guard for one revision comparison and a move, never a copy of its payload
- the owner’s announcements pending for one branch-keyed entity on a replica are added to under a lock scoped to that entity, one newest announcement per branch, and taken all at once by the entity’s one replica task, never across an await; see Replica catch-up
- an ACK root locks only its single terminal sender transition
These sites are accepted for the contract and bound named above. A lock that merely makes shared state convenient, protects immutable configuration, or repeats registry discovery on every batch does not belong in this category.
The simulated relay fault checks drive the production receipt, admission, and cancellation APIs through authenticated connections. They synchronize on the received Arrow batch and verify the same attempt cannot be admitted twice after a lost reply. Cancellation before grant and while receipt or reconnection is unresolved must win the attempt’s existing admission fence before the runtime can admit it. A cancellation after admission returns the admitted outcome.
Relay fan-out itself uses one bounded queue per consumer. Publishers share no fan-out lock; capacity and receiver counts are atomic, and a publisher registers for notification only when a consumer queue is full. A waiting publisher raises a waiting count before it reads a consumer’s admission count, and a consumer lowers its admission count before it reads the waiting count. Both reach the admission count by read-modify-write, so either the publisher reads the room the consumer freed or the consumer reads the waiting count raised and wakes the publisher. Removing one consumer does not stop the others, and changing capacity does not discard batches already queued.
A publication counts the consumers it delivers to from the same snapshot of the registered consumers that it delivers through. A consumer that registers meanwhile receives later publications only, so the acknowledgement shares a publication reserves for its attached consumers always match its deliveries, and a routed delivery, which holds no dispatch permit, cannot resolve a share it never reserved.
Ratchet And Review
just ratchet, just validate and just validate-ci require the pinned compiler’s Nervix
source diagnostics to pass across the complete declared matrix. Finite API recognition lives in
tools/nervix-lint/report/src/rules.rs. Architectural contracts live with their source owners;
generated inventories under target/ are evidence, never approval inputs. Other structural
ratchets retain their own checked-in counts.
The compiler recognizes resolved Mutex, RwLock and DashMap acquisitions through aliases, re-exports, dereference adjustments, UFCS and selected trait implementations. Borrowed reads, mutating and try operations, and iteration remain visible. Ordinary collection entries, held-guard entries and I/O do not acquire a recognized lock. Borrowed DashMap iteration acquires lazily; consuming iteration owns its storage. Authored arguments remain checked through external macros, including operation expectation macros.
Source contracts
Gate annotations with cfg_attr(nervix_lint, ...). Only the isolated analysis driver registers the
nervix tool and enables that cfg; ordinary stable and browser source needs no unstable feature.
Contracts use the narrowest meaningful function, trait, type, impl, module or crate boundary:
#[cfg_attr(nervix_lint, nervix::context(recurring, reason = "one callback per admitted batch"))]
fn process_batch() { /* retained execution state */ }
#[cfg_attr(nervix_lint, nervix::context(bounded,
reason = "the retained grant serializes its terminal transition",
key = "one admitted relay grant", bound = "one synchronous transition; no guard across await"))]
struct GrantState { /* retained bounded state */ }
recurring identifies record, batch, frame, acknowledgement and steady poll execution.
lifecycle identifies installation, replacement, snapshot cadence and teardown. observer
identifies observation outside execution, and outside names an edge or harness outside this
rule’s data plane. bounded remains recurring but permits the explicitly described retained
protocol; its reason, identity key and capacity or deadline are required. These are reviewed
architectural assertions, not proofs of exclusive ownership, bounded waiting or runtime cadence.
An explicit callable contract wins over defaults. An implementation inherits its trait method’s contract before its impl/type or lexical defaults; a recurring trait method’s override remains recurring or names a bounded protocol. A type contract applies to its implementations. Duplicate, conflicting, malformed and misplaced annotations fail. A binding whose initializer owns exactly one anonymous body can supply that body’s context: use it when a constructor installs a recurring task. Nested task bodies still require their actual entry contract.
The compiler re-evaluates supported local call edges and closure/callback bodies to a fixed point.
A recurring caller makes an unannotated local helper recurring, including one in a lifecycle module.
An explicit lifecycle callable is an installation boundary: entering it from recurring execution
emits nervix::lifecycle_call. Callee and trait contracts survive cross-crate metadata and renamed
imports. During recurring execution, unresolved generic, dynamic trait and function-pointer
application calls require a callable/trait contract or nervix::dispatch(reason = "...") on their
owning callable; a cold module default is insufficient. A dispatch contract states the external
driver or callback boundary; it does not exempt compiler-visible local callback bodies or acquisitions.
Local iteration and polling implementations remain call edges. Generated await polling is the
Rust scheduling protocol, rather than a separate application callback diagnostic. Synchronization
inside external libraries, opaque external future implementations and unsupported whole-program
relationships remain outside the recognizer’s claim. The checker supplies no universal ownership
or effect proof.
Resolved architecture boundaries
nervix::layer(kind, reason = "...") states the exact documented crate/module, type, trait, impl
or callable owner: primitives, vocabulary, language, engine, decisions, data_plane, control_plane,
edge or harness. The narrowest contract wins; associated implementations inherit explicit trait metadata
and impl-self contracts. Contracts survive compiler metadata and move with their source owner.
An unrelated trait module default cannot replace an implementation body’s lexical owner.
An engine that receives validated plans also declares nervix::input(plans, ...); general
infrastructure is not presumed to execute graph plans.
nervix::value(model|name|cluster_access, ...) classifies exact semantic ADTs or traits. The current
configuration Model union, shared name vocabulary and cluster observation capability carry those
roles. nervix::model_in_data_plane rejects the configuration union in data-plane and plan-consuming
interfaces or bodies, including normalized generic containers, aliases, constructors and patterns.
An ordinary user type called Model has its own meaning. These checks do not treat every vocabulary
field or schema descriptor as a configuration Model.
nervix::parser_dependency diagnoses resolved language definitions outside the language, edges and
harnesses, including imports, type/trait references, qualified calls and methods. A separate
source/manifest validator retains the owning crate’s import path before cfg selection, including
renamed dependencies, inactive module imports and authored macro tokens. It derives language
packages and consumer ownership from source contracts and Cargo/module declarations. Approved plain
primitive and vocabulary re-exports keep their actual semantic owner; importing from a language
crate still requires an allowed provenance owner even if resolution reaches a plain value.
The complete architecture rule matrix separates these resolved contracts from compiler-independent source properties. Integer/Duration clamping and Option/Result control-flow diagnostics use the same source-local exception machinery and legal configuration coverage; they do not classify a custom method by its name. Their serial analysis does not supply concurrency execution evidence. Explicit Result branches preserve the same reports and delivery outcomes when a caller expands an adapter sequence.
nervix::cluster_await diagnoses future-returning access on the classified cluster capability or
trait when awaited from recurring data-plane execution. It follows the existing supported local
helper/callback graph, selected concrete methods, UFCS, async closures, awaited expression wrappers
and simple local future-binding chains. A local I/O await has no cluster role. A lifecycle cluster
read remains lifecycle work. Dynamic/generic application dispatch needs its existing source effect
contract; opaque external future implementations, arbitrary stored-future relationships and
whole-program effects are not inferred. Execution graphs and recurring reachability are conservative
within the declared local graph; annotations are reviewed architectural assertions.
nervix::node_identity, nervix::installed_state and nervix::state_contract implement the exact
identity and representation contracts in Typed States.
All six architecture diagnostics use the same normal lint levels, exact-operation expectations and
complete current configuration evidence as synchronization and failure rules. No count, product
owner catalog or source-review hash grants permission.
Diagnostics and reviewed exceptions
nervix::sync_acquisition diagnoses an acquisition on ordinary recurring execution.
nervix::unknown_effect diagnoses an acquisition without a context or an unresolved application
call reached from recurring execution. nervix::invalid_contract rejects contract and exception
errors. The lints use ordinary Rust warn/deny/expect levels; the required gate also rejects unresolved Nervix warnings.
The driver also emits the resolved failure-contract, outcome-discard and panic diagnostics
described in Errors And Diagnostics. Their exact error/return
classifications use nervix::error_boundary; execution-context inheritance does not classify
an error or exempt a failure. All diagnostics share the same one-operation expectation checks
and complete configuration evidence.
Retained repair debt names its owning task at the exact operation:
let state = nervix_primitives::expect_lint!(nervix::sync_acquisition,
"Typed Ratchet 15 (86bca1web): retain the replica entity before frame handling",
states.get(&entity));
expect_lint! puts a normal reason-bearing Rust expectation on one binding, evaluates the
operation once and returns its value. It supplies stable syntax and introduces no runtime policy,
allocation or lock. A direct gated expectation on one operation binding works too. A lifecycle
call admitted only during first installation or terminal teardown explains that phase and its
concrete lifetime in the same narrow form. Blanket allow, undocumented or broad expectations,
lint caps and unfulfilled expectations fail. The checker counts distinct HIR operations against
Rust’s effective expectation identity, including macro expansion; widening a binding to cover a
second operation fails even when Rust would regard the expectation as fulfilled.
Task handles remove recurring status, freeze, metric and checkpoint lookups. Source readiness, relay channels, buffered error delivery, domain selection and transport leasing use retained handles or immutable publications. Materialized writers own their mutable branch records and readers retain per-relay publications. Remote acknowledgement rows and admission waiters use fixed routing positions with exact generations; authenticated connections retain bounded peer protocol owners. Replica catch-up retains the entity’s lifecycle handle, assignment slot and its own record of each branch. Replication frames, synchronization and listing requests select published state handles; announcers retain their route and assignment slot. Their runtime registries handle installation, replacement and teardown. A session’s clock delivery retains the observer its attach resolved and reads only that observer’s lifecycle publication and progress watch. Accepted progress reaches the runtime’s domain registry once per report and then compares and replaces inside the watch, and an attach waits on the committed-domain installation count rather than returning to the registry. Bounded retained placement progress remains explicitly documented. Testing fault selectors name their exact emitter, ingestor, domain/branch, checkpoint window or acknowledgement link and finite read/removal steps; they release map guards before a scenario-controlled pause. These contracts describe test selection, not a product wait deadline. The executor-saturation lookup belongs to testing fault control. The concurrent map inventory records the runtime ownership review; source contracts and expectations are the executable policy.
Complete analysis and qualification
The isolated tooling workspace uses nightly-2026-09-17; product builds use stable. The matrix
analyzes ordinary workspace libraries/binaries, the browser’s wasm32 library and binary, the server’s testing capability,
server/interconnect/primitives under Shuttle, server/consensus/primitives under Loom,
interconnect/primitives under Turmoil, and the server’s library and binary, the deadlock
diagnostics and primitives under Deloxide, where every blocking acquisition resolves to a tracked
adapter that the rules recognize as the lock it wraps. Turmoil’s runtime cfg comes only from its just recipe,
which preserves the parent’s diagnostic mode. Compilation supplies no concurrency execution
evidence. Test targets and undeclared feature combinations are not claimed.
Mandatory source/manifest checks retain import provenance, inactive cfg and authored macro checks.
Cargo artifacts, complete side reports and declared roots must agree, including when there are
zero sites. Reports retain every owner, configuration and expansion instance; generated target
and external source sites are exclusions. Completion identities cover current source and
annotations, Rust rules, compiler, driver, validator, locks, dependency declarations, Cargo
configuration, diagnostic mode and relevant flags. Source changes during a run fail. The workspace
wrapper nests beneath configured kache. Missing reports establish a new supported cache namespace
and rebuild only isolated authored artifacts; the gate preserves RUSTC_WRAPPER.
just test-typed-ratchet qualifies APIs and source diagnostics in the legal modes, ordinary Bolero
properties, stable gated syntax and paired Rust API doctests. just qualify-typed-ratchet-cache
qualifies fresh/Cargo-fresh runs, an actual kache dependency hit, changed inputs, missing and
interrupted output, and two worktrees. Native coverage uses the matching LLVM tools and records
the driver executions that ran. Sanitizer campaigns follow the shared Bolero label policy.
Calibration, measured cost and complete generated evidence belong on
Typed Ratchet 02A.
Use the generated listing during review:
just typed-ratchet --show
just typed-ratchet --configuration shuttle --inventory
Partial selections and warning-mode inventories identify their diagnostic/matrix scope explicitly; they cannot satisfy the required full diagnostic gate.
For every new or moved site and every shared-map access, the reviewer establishes all of the following:
- Frequency. Trace its callers and decide whether it can run per record, row, batch, remote frame, ACK share, or steady poll iteration. A site on one of those paths is rejected unless it is an ordering fence or bounded protocol described by this chapter. Registration, first installation, replacement, teardown and observer reads are cold and are classified as such, separately from the recurring path.
- Owner. Identify the one component that changes the state. Immutable reconfiguration uses a
whole-value publication, a scalar uses an atomic, a stable dependency is bound when the task
starts, and branch-local state belongs directly to the branch task. An ordinary collection that
one owner holds, and its
entryAPI, are not synchronization and need no justification. - Lookup behavior. A steady path does not look an established value up in a shared map, with
entry()or with a borrowedget,contains_keyor iteration, all of which take a shard lock. It retains the handle resolved when its task, branch, channel or attempt was created.entry()belongs to a cold or racing installation whose single-winner property is part of the contract. - Fence contract. An allowed ordering fence or bounded protocol names the order it preserves, the exact key that limits its scope, the capacity or deadline that bounds waiting, and whether any guard crosses an await. Unrelated branches, relays, destinations, or assignments must still progress.
- Lifecycle separation. Startup, registration, schedule application, snapshot sealing, and teardown may synchronize with their peers, but their guards must not leak into a hot callback or be held while awaiting data-plane work.
- Mechanical result. Run
just ratchet; inspect the site list when the count changes; update the baseline only when the count fell. A lower aggregate count does not make a newly introduced hot-path lock acceptable, and renaming a method or moving a file is not a repair. - Inventory. Record a new concurrent map, or a changed frequency or disposition of an existing one, in the concurrent map inventory in the same change.
Installation-only fields declare their lifetime at the owner. The resolved installed-state rule rejects replaceable read-write locks in that representation, while an optional name/shared handle with an unstated lifetime requires explicit review. The current publication or one-time primitive must express the actual owner; type shape alone cannot establish a write-once lifetime.
Primitive Selection
Execution Primitives owns the shared primitive surface, its complete per-mode matrix, feature and target selection, authored-source scope, and unmodeled, blocking-work and thread-creation permissions. Every synchronization owner in this chapter obtains its primitives there. The boundary selects mechanisms; the source contracts and production-owner checks below establish what a caller may do with them.
Diagnostic Deadlock Detection
A diagnostic node selects the deloxide mode. The deloxide-order feature adds historical order
instrumentation to that same mode, and the deloxide-stress feature adds Deloxide’s bounded
scheduling disturbance to its active-only selection; neither is another execution mode, and the two
cannot be enabled together. Ordinary builds keep their primitive reexports and contain no detector.
Build the diagnostic node with just build-diagnostic-server [deloxide|deloxide-order|deloxide-stress];
its target directory is separate from the ordinary server.
| Selection recorded in evidence | Compiled order instrumentation | Runtime order checking |
|---|---|---|
ActiveOnly (deloxide) | absent | disabled |
OrderAnalysis (deloxide-order) | present | enabled |
OrderInstrumentedActiveOnly (deloxide-order --deadlock-active-only) | present | disabled |
StressedActiveOnly (deloxide-stress), with its stress configuration | absent | disabled |
Runtime disabling does not recover an ordinary or active-only fast path. The pinned Deloxide 1.1.0 graph build enters its global detector even on uncontended mutex and write acquisitions. Reads enter that detector in either diagnostic build. The order build also tracks actual guard lifetimes locally, including when runtime checking is disabled, so active self waits remain observable. No timing from these builds is ordinary product performance evidence.
Scheduling stress. A deloxide-stress build compiles Deloxide’s random preemption into its
tracked locks. A thread that already holds a tracked lock and asks for another sleeps first with a
fixed probability, for a delay drawn between two bounds, and a configuration may also yield a thread
after every release.
An active cycle needs two threads that each hold what the other wants at the same moment; widening
the window between one nested acquisition and the next makes the lifecycle paths that take nested
locks meet that moment far more often than an idle schedule does. Disturbance never decides a
finding: the detector reports the same cycles it would report without it.
StressConfiguration is the typed configuration, recorded with the selection in every process’s
evidence. It is bounded by construction: a probability in millionths of at most one, delays of whole
microseconds between 1µs and 2ms, the shortest no longer than the longest. The lane’s configuration,
StressConfiguration::LANE, preempts one in twenty nested acquisitions for 20µs to 200µs and does
not yield after releases. The longest delay holds one runtime worker for a small fraction of the
shortest physical product deadline, and the product’s deadlines are not widened for it. A first
configuration, a quarter of nested acquisitions for 50µs to 1ms with a yield after every release,
made the stressed nodes miss their startup and request deadlines; so did one in twenty without the
yield while four scenarios shared one process, which a plain diagnostic build of the same scenarios
met. The cost is not the delays alone: a stress build enters Deloxide’s process-wide detector twice
on every mutex and write acquisition, once to decide the delay and once to acquire, so every node of
every scenario in one process contends on that detector. The stressed scenarios therefore run one
at a time. Deloxide
draws its preemptions and delays from entropy it seeds itself, so a configuration reproduces the
distribution of the disturbance, not one schedule; a replay repeats the workload and the
configuration.
Deloxide applies the disturbance only inside its blocking acquisitions, and its stress build disables its own optimistic mutex and writer fast paths. The tracked adapters therefore skip their own immediate try in this build and enter Deloxide’s blocking acquisition directly, registering the waiting site first. Deloxide keeps its reader fast path, so a shared acquisition that finds no writer is not delayed. Because every mutex and write acquisition then reaches Deloxide’s global detector, its held set is populated as in an order build, and the adapters keep the caller’s live guards so a self wait is still reported. Deloxide’s component-based strategy is not offered: it appends every nested acquisition to a list it never trims and scans that list on each one, so its memory and its cost per acquisition grow with the life of the process. The random strategy keeps one preemption counter per lock it delayed, which grows with the locks a process constructs and is bounded by the process’s life within its invocation bound.
The stress selections run every active-only probe, conformance check and owner test again, and
between them every scenario of the active-only selection but the report tool’s triage, which drives
no lifecycle path. deloxide-stress runs the lifecycle scenarios tagged @deloxide_stress and the
paced-driver scenarios with the stressed Rust driver; deloxide-stress-restore compiles the same
build under its own name and runs its probes and the capture, restore and checkpoint-transfer
scenarios tagged @deloxide_stress_restore, in a lane of its own because the stressed scenarios of
both, one at a time, would take most of one lane’s budget. The probe.stress-disturbance probe proves the
disturbance is applied in each: twenty thousand nested acquisitions must wait at least the shortest
delay for half the preemptions the configuration expects, and record the lane’s configuration. Its first run found that the adapters’
immediate try bypassed the disturbance entirely, and that a self wait went unreported once
Deloxide’s held set was populated; both are fixed in the adapters, and the probe and the stress
selection’s self-wait probes are their regressions.
The tracked surface. Every sync::blocking mutex, read-write lock and condition variable is an
adapter over Deloxide. The adapters retain new, lock, try_lock, read, write, try_read,
try_write, get_mut, into_inner, Default, From and guard dereferencing. Locks are not
poisoned; Debug tries the lock and never waits. Timed, upgradable, mapped, fair and reentrant
acquisitions are unavailable because this backend cannot track their contract. Constructors and
successful guards retain caller locations; an acquisition that waits separately registers its
waiting site and requested access. The compiler synchronization gate classifies these as the
acquisitions they wrap.
A condition variable keeps the Shuttle adapter’s wait and notify_all interface and an exact
count of the waiters in each notification generation. Waiting removes the caller guard’s held
lease while the lock is released, and reinstalls it on reacquisition. Its internal state guard’s
lease also leaves while the vendor condition variable releases that lock. A notified waiter
reacquires the caller’s lock through its tracked adapter, so it can participate in an active cycle.
Installation and active findings. The process installs once, after its signal registration and
before any tracked lock or runtime worker, through nervix-deadlock::DiagnosticRun. The server’s
main, the diagnostic scenario binary and the Rust paced driver own that placement. A tracked constructor before
installation panics with a configuration error; a second installation fails. Installation keeps
the vendor banner off standard output by temporarily redirecting that descriptor to /dev/null.
A WaitForGraph finding describes threads currently waiting in a cycle. A self wait is also an
active cycle: the mutex adapter checks a failed acquisition against its calling thread’s actual
live exclusive guard in an order-instrumented or stressed build and sends that one-thread cycle
through the same bounded handoff. This establishes a real incompatible owner/wait pair; it does not infer an
active cycle from historical order. Deloxide 1.1.0’s common-held-lock filter otherwise discards this
case once instrumentation or stress populates its held set. Recursive mutex acquisition and an
upgrade from one’s own read guard remain disposable-process regressions in every selection.
Historical order and source evidence. A LockOrderViolation is a PotentialCycle, never an
active outage. For example, a thread can successfully take A then B, release both, and successfully
take B then A. The run retains the potential cycle and continues the workload. The pinned upstream
graph adds requested mutex and write acquisitions; it does not add requested read edges. Opposite
read-only orders complete without an order finding. A read guard held before a requested mutex or
write still contributes a historical edge, with its shared mode and multiplicity retained. This
limit also excludes some dangerous mixed orders: absence of a finding is not coverage of every
reader/writer combination.
The boundary retains directed edges between run-local lock instances, not between construction
lines. Each edge carries construction sites, Live, Ended or Unrecorded lock lifetime at
correlation, and source witnesses with run-local thread identity, optional name, held and requested
sites and modes, attempt count, and the number of identical live guards at the held site. Thread
names and construction sites copied into history survive registry removal. Lifetime observations
are not an atomic snapshot of all locks. Missing context is explicit. Source attempts include
unsuccessful nonblocking calls; only upstream successful acquisitions decide that a historical
cycle exists. Attempts are not claimed as successful-acquisition counts.
A complete cycle has no repeated closing node and rotates to its smallest lock identity. Witness sets sort by source context and reject duplicates. Deduplication keys on the directed instance cycle within one process, merges distinct source/mode witnesses, takes the maximum cumulative attempt count for each context, and counts delivered callbacks separately. It preserves direction, lock instances, known construction, ended lifetimes and guard multiplicity. New source or lifetime context invalidates an existing review. It never equates instances merely because a constructor line was reused.
Bounds and handoff. The live registry owns the live lock, waiting attempt and thread records; none of its shard guards cross a tracked acquisition. Order instrumentation adds a run-bounded history of at most 16,384 directed instance edges, 1,024 source witnesses per edge, and 64 guard leases per thread. Ending a lock does not reclaim the historical edge budget. Refused history or count overflow becomes an explicit overload finding. The witness budget retains distinct storage worker identities across checkpoint staging. Its process regression retains all 512 worker contexts in one historical edge; a separate 1,025-context workload must record order-history overload and fail qualification. The independent edge and held-guard limits remain fatal too.
The vendor dispatcher has an unbounded channel and swallows callback panics. The boundary callback
therefore only retains bounded identity vectors, submits to its lock-free ReportHandoff of 64
reports, counts refusals, and unparks the findings thread. Each queued active report retains at
most 64 threads/waits; an order report retains at most 65 identities to describe a 64-edge prefix.
The findings thread correlates source context and calls the sink serially. Missing order payloads
are loss, not a clean run. Sink panic aborts. Installation failure closes the handoff. A periodic
wake also delivers history overload that produced no upstream cycle.
The history, handoff, output and artifact limits are Nervix retention limits. They do not bound the vendor graph, its cache, its pre-callback allocation or its dispatcher backlog. No exact upstream flush barrier is available: evidence is an observation at the time it is read, and a process can end with upstream callbacks pending. Deloxide 07 owns dependency and upstream graph/dispatcher hardening; full diagnostic workloads retain the actual observed bounds and gaps.
Recording and exit policy. --deadlock-evidence (NERVIX_DEADLOCK_EVIDENCE) selects an existing
local directory; without it the run uses standard error. The run first records empty evidence and
atomically replaces its own file after each finding. The current header is NVXDLEVD, evidence
kind 1, format version 3; unsupported versions fail clearly. The artifact holds at most 16 records,
with one slot reserved for retention loss after at most 15 distinct potential cycles. Each cycle
retains at most 64 edges and each text at most 512 UTF-8 bytes, remembering truncation. An artifact
is at most 128 MiB. Descriptions have a 128 MiB per-process budget; repetitions with unchanged source
context update the artifact without repeating the description. Changed context stays visible.
Exceeding a bound retains loss and cannot qualify.
An active cycle ends the process with status 3 after recording. Potential order continues with
unreviewed evidence. Handoff/history/retention overload, failed recording or output, or a recording
operation exceeding its ten-second budget ends with status 4. The deadline is cancelled after a
successful potential recording; no detached deadline later kills a continuing workload. On expiry
it exits directly: a diagnostic write to a blocked descriptor must not delay the deadline itself.
Exit bypasses handlers, as for a forced exit. Ordinary logging,
visualization and browser startup are unnecessary. Nothing uploads evidence, logs protected values,
or uses source/lock identities as metric labels.
Local inspection and triage. The ordinary nervix-deadlock-report tool needs no detector or
running node. Select and export complete current values, inspect the original indexes, and write a
reviewed copy of the original process evidence:
just deadlock-report inspect /var/tmp/nervix-deadlocks/deadlock-PID-TIME.rkyv --source potential --export /var/tmp/potential.rkyv
just deadlock-report triage /var/tmp/nervix-deadlocks/deadlock-PID-TIME.rkyv --finding 0 --basis non-overlap --reason 'The owning operation serializes both paths before either lock is acquired.' --regression 'owner::tests::serialized_operations; retained run artifact' --output /var/tmp/reviewed.rkyv
just deadlock-report qualify /var/tmp/reviewed.rkyv
The placeholder artifact and regression names must be replaced with the observed run and a real
retained regression. Review uses correction, non-overlap, shared-readers or lifecycle and
requires a nonempty, untruncated reason and regression reference. A reader proof checks every
recorded mode is shared; other proofs are explicit engineering assertions, not machine verification
of a referenced test. A correction cites the fixed owner and the subsequent regression run. A
non-overlap proof names the enforced serialization; a lifecycle proof establishes disjoint
instance/operation lifetimes. A source line alone proves none of them. Triage keeps the complete
original cycle. Missing/truncated context or active findings cannot receive a potential proof.
Qualification of whole-process evidence fails on any active, lost, incomplete or unreviewed
finding. A selected export retains its selection scope and cannot qualify its source process,
even when the filter yielded no findings. Loss always remains visible through a selection. The
qualify command exits 5 for nonqualifying evidence and 4 for an invalid artifact or operation.
It qualifies recorded evidence; the engineer also verifies workload completion and successful
checks, the run selection, pending-callback limit, revision and declared coverage. Keep reviewed
copies separate from files a live recorder replaces. The operator’s workload is not ended merely
because an unreviewed historical cycle exists.
The diagnostic lane. just test-deloxide, just test-deloxide-order,
just test-deloxide-stress and just test-deloxide-stress-restore run the lane of each selection,
and CI’s deloxide job runs all four side by side for every pull request labeled deloxide, the
label each change the Deloxide rule applies to carries. A selection is named after the diagnostic
build feature it compiles, or extends that name and declares the feature when it runs workloads of
the same build in a lane, budget and CI runner of their own; a
build records one diagnostic selection in its evidence whichever selection runs it. An invocation
runs in every selection unless the inventory names the selections that run it. The stress selection
runs every probe, conformance check and owner test of the active-only selection, its
stress-scenarios invocation of the one- and three-node lifecycle scenarios tagged
@deloxide_stress, which drive client contract transactions and their rejected rollbacks, the
drain of buffered branches before an alteration, interleaved branches with remote acknowledgement
owners, a lost acknowledgement and a producer restart, session restarts that resume a producer and
consumer, a domain restart that replaces its consumer, a memory-pressure pause of scheduled
ingestors, a Roto function’s column builder, TLS rebinding of every HTTPS listener, batched
inference over interleaved branches, a restarted voter’s ownership, and graceful shutdown with a
slow cordon release and diagnostic server processes that stop gracefully, and its
stress-paced-simulation invocation of the paced-driver scenarios with the stressed Rust driver.
The restore selection runs the build’s probes and its stress-restore-scenarios invocation of the
scenarios tagged @deloxide_stress_restore: backup capture and restore of WASM branches, Kafka
offsets, deduplicator keys and windows, materialized relays and native metadata, complete restore
generations, staging quota and reclamation, delayed restore coordinators, bounded multi-domain
backup waits and their recovery, and the drain of a large-state WASM owner through bulk checkpoint
streams. Every stressed scenario runs one at a time under its own stress. identity, with the
scenario, examples and invariant of its active-only counterpart and the owners that counterpart
reaches.
tests/deloxide-inventory.toml is the lane’s bounded inventory. It registers every workload a
selection runs, each with a stable identity, the invariant it owns, the selections that must run it
and the coverage it declares: the disposable-process probes of nervix-deadlock, the tracked
locks’ conformance checks of nervix-primitives, the diagnostic owner tests of the server library,
and the tagged scenarios with the number of example runs each must make. The three
@remote_ack_owners scenarios add four example runs across interleaved branches, a lost
acknowledgement and producer restart; every selection exercises the tracked correlation and peer
admission owner locks. The @shutdown_kafka_handoff scenario runs in every selection and reaches
the ingestor quiesce control’s tracked locks while a stopping node closes intake, waits for its
attached emitter’s handoff, and finishes admitted Kafka acknowledgements. The async gate wait,
remote delivery and scheduling order remain outside the detector’s observation and have the
Cucumber and chaos checks in Shutdown And Recovery. The
@shutdown_leader_change scenario also runs in every selection and reaches the tracked entity-gate
state while a stopping follower repeats the remaining handoff through a new leader. Its leadership
observation, transport waits and shared shutdown deadline remain outside the detector’s observation.
Both scenarios carry @deloxide_stress, with separate stress workload registrations that reach
their owners under bounded scheduling disturbance. The inventory also bounds the lane: one real-time
budget for a selection’s
diagnostic compilation and execution after its prerequisites, a bound for every invocation, a stop
grace period, the reserve the scenario binary keeps for its own teardown inside its invocation’s
bound, and how many scenarios run at once, which is the same on every machine.
The fault harness shares its observation and release barriers through
src/fault_injection/pause.rs. The barrier owns watch-backed reached, released and delivered
state and the one-shot atomic claim; fault selection remains in the harness’s owning modules.
These async observations introduce no tracked blocking-lock owner. Deloxide continues to measure
the registered product locks, while the public scenarios and model checks exercise pause ordering
at their applicable boundaries.
The registered @restarted_voter_observation workload reaches whole-cluster restart and automatic scheduling when the leader first hears a voter through a relayed heartbeat. Its test-only watch channels and concurrent fault map are untracked. The registered native metadata restore workload exercises distributed Kafka checkpoint replication, acknowledgement, restart and exact restoration above the resident replication message budget; its stream waits and assignment atomics retain their complementary checks.
For each invocation the lane builds the selection’s executable, lists what the build holds or reads
the tagged scenarios from the feature files, and refuses a registered workload that the build no
longer holds or holds ignored, a discovered probe, conformance check, owner test or tagged scenario
that is not registered, a scenario whose example runs changed, and an invocation that selects
nothing. It reports how many workloads it discovered, selected, executed and saw complete. Global
detector configurations never share a process: each probe workload is a disposable child, each
conformance check and each owner test installs its detector in a fresh process, and each scenario
binary process installs once in main. Active detection runs its tagged scenarios together; order
analysis starts a fresh process per feature and gives the six large materialized restore and four
complete-generation restore examples their own processes. These restores each start several nodes
and carry large state payloads, so per-example order histories stay within the detector and host
budgets. Their order_tags in the inventory select and account for every example. Every diagnostic
server and paced driver process installs its own detector. Each selection compiles one diagnostic
build, deloxide-stress-restore the stress selection’s under its own name, and the diagnostic
builds stay apart from ordinary, fuzz, Loom, Shuttle and Turmoil builds.
The conformance checks are the tracked adapters’ own: which locks a diagnostic build selects, that
Debug formatting tries a lock and never waits for it, that a value moves in and out of a lock, the
exact count of waiters a notification wakes, the refusal of a second installation, the registry’s
record of a thread across its exit, and a tracked wait that proceeds once its lock is released.
nervix-primitives sits below nervix-deadlock, which owns evidence files, so a conformance check
records none. Its detector’s sink panics on any finding and a panicking sink aborts the process, so
the lane ends such a check as signaled, with the finding in its retained output.
just test-primitives-deloxide runs the same checks in one process for each selection, beside the
other modes’ conformance.
Stress widens timing windows; it does not make untracked waits visible. A Tokio mutex, channel or
Notify wait, a DashMap shard, dependency locks, an atomic publication and a wait on another node
are invisible to the detector with or without disturbance, and a workload that stalls on one of them
ends as timed-out, not as an active cycle. Those owners keep their Shuttle, Loom, Turmoil and
Chaos evidence. A dependency’s own channels are invisible in the same way: closing a database waits
for the storage engine’s background workers through the engine’s worker channel and active-worker
count, with no tracked lock involved, so that close is held by the pinned engine’s own worker-stop
tests and the store-close regression beside ApplicationStartup::close_stores, not by the lane. A
stressed workload that fails is reproduced first as a focused failing test against its owner before
the owner changes.
Every process the lane starts runs in a session of its own. A process that outlives its bound
receives SIGTERM and, after the stop grace period, SIGKILL with its whole group. The lane is a
child subreaper, so a process that left its group is found too, and anything still running after an
invocation ends is killed and fails the lane. The lane names each ending:
| Ending | Lane class | Lane status |
|---|---|---|
| A process reported an active cycle, or a failed invocation’s evidence records one | active-deadlock | 3 |
| Recording or output failed, findings or context were lost, or the recording budget expired | diagnostic-failure | 4 |
| A process outlived its bound, or the lane’s budget expired | timed-out, budget-expired | 124 |
| A signal ended a process | signaled | 1 |
| A test or step failed | failed | 1 |
| A process was left running | leftover-processes | 1 |
| A registered workload did not run or ran ignored, or the scenario count differs | incomplete | 1 |
| Evidence is missing, partly written or does not qualify | evidence-missing, evidence-partial, evidence-unqualified | 1 |
| The inventory, a build or a prerequisite is wrong | inventory, build-failed, prerequisite-missing | 1 |
A failed invocation’s evidence is qualified before the lane reports it, so a node that deadlocked
inside a scenario is reported as the active cycle it recorded rather than as the step it failed.
After each invocation that records evidence, every process observation must be present, completely
written and qualify. nervix-deadlock-report qualify prints one evidence summary: line of counts
per file, and the lane reports repeated deliveries of a retained potential cycle apart from findings
lost to overload. The first failure ends the lane.
A potential cycle fails the lane. The lane qualifies only the current run’s whole-process evidence, and it keeps no ledger that approves a recurring cycle: lock identities are run-local, and a source line never stands in for a lock instance. A correction removes the order from the exercised workload, and the lane is its regression. A reviewed infeasibility proof qualifies the retained evidence of the run it reviews, for the owner’s investigation and record; it never qualifies a later run.
Each run keeps a fresh attempt directory below <build target>/test-deloxide/<selection>/, which no
later run reuses or removes: every log, the probes’ artifacts, every process’s evidence, the
description of every finding with its source sites, and lane.json. The record holds the revision
and whether the tree was modified, the toolchain, Cargo and Deloxide’s locked version and source, the
inventory’s digest, the selection, the features of every build and the bounds, and for every launch
its exact command, working directory, environment additions, bound, ending, duration, discovery and
accounting, with the qualification of every evidence file. just test-deloxide-replay <lane.json> <launch> runs one recorded launch again, exactly, in a fresh attempt. OS schedules and stress timing
are not recorded, so a replay can take another interleaving; deterministic reproduction belongs to
Shuttle and Loom.
just test-deloxide-qualification proves the supervision on the probe binary’s real processes,
through the lane’s own launch, evidence and classification code. A two-thread deadlock, an evidence
directory that cannot be written, an untracked wait that never ends, an aborted process and, in the
order selection, an unreviewed serial inversion and retention overload must each fail the lane with
its own class and status, keep their output and the evidence they could write, and leave no process
behind. A consistent-order control must pass, and the recorded deadlock must replay to the same
class.
Under just coverage-native-extras test-deloxide, test-deloxide-order, test-deloxide-stress or
test-deloxide-stress-restore, the lane builds in the collector’s instrumented target of the
feature its selection compiles, with instrumentation on workspace crates only, so dependencies such
as wasmtime’s compiler keep the speed the product’s deadlines assume. It takes the report tool, CLI
and shared client binding from the target its prerequisites built, starts every test executable
through Cargo’s configured runner, and hands lane.json to the collector, which exports nothing
unless the lane completed. CI’s
deloxide job runs each selection this way, then the qualification, and uploads the completion
records, the diagnostic LCOV reports and every attempt directory whatever the verdict. Diagnostic
coverage stays out of ordinary coverage and its CRAP report. The Integration Test
Lifecycle chapter owns the job’s bounds and
reserves.
Applicability. [[owner]] records in the same inventory cover every source file in which the
compiler finds a tracked blocking acquisition in a diagnostic configuration. Each names the
registered workloads whose processes reach its tracked locks, the path the lane does not reach and
why, or both. just validate-deloxide-applicability, part of just validate and CI’s checks
job, reads the acquisition catalog just ratchet writes and fails on an owner without a record and
on a record whose file no longer acquires a tracked lock. It reports how many owners registered
workloads reach and how many record a path the lane does not reach. The catalog is the compiler’s
resolution of the Deloxide adapters’ operations, not a search for lock names. Every diagnostic
library and binary configuration enables testing, as the lane’s server builds do, so acquisitions
in testing-only fault controls require owner records too. This includes the server’s scheduling,
drain and executor-occupancy controls and the consensus storage fault controls. Ordinary and
modeled configurations retain their own feature selections. A record is a reviewed declaration:
the lane’s coverage report is evidence about it, not a proof of ownership. Format
equality, malformed and out-of-bounds rejection, normalized-cycle deduplication and the summary’s
counts are registered Bolero properties with the same assertion in ordinary and sanitizer execution.
The native backup-wait workloads reach the client’s pending-reply locks during multi-domain capture, bounded wait expiry and recovery under the same execution reference. They run on one- and three-node clusters in both selections and name the client request and exchange owners in the inventory. The detector observes their blocking reply-registration and cancellation locks. Async command and reconnect mutexes, physical deadlines and network scheduling remain outside its observation; the public scenarios and client-session regressions assert their wait, expiry, leader-change and recovery outcomes directly. The bounded native-client case runs inside the diagnostic harness and reaches expiry and recovery with tracked client locks. The CLI scenarios exercise diagnostic nodes around ordinary subprocesses, whose own locks remain untracked.
The lane’s state-store owner test starts its detector in a fresh process before constructing store
or executor locks and runs the current generation regressions: cancellation before and after pointer publication, snapshot
readers, queued checkpoint writers, corrupt and incomplete chunks, exact retry after reopen,
stale authority, bounded cleanup across 600 checkpoints and a 40 MiB guest save admitted with a
2 MiB reservation. Its evidence is retained beside the probe and scenario evidence. The lane
also exercises quota refusal and exact staging retry, partial chunks without receipts across
reopen, reclamation with applying and future generations retained, published and initial state
preservation, snapshot readers across deletion, and interrupted bounded reclamation resumed after
restart. Active-generation owner checks also exercise ordinary and same-revision inline writes,
replica installation, handoff, forced recovery, entity and stale-identity purge, queued writers,
exact publication retries, referenced-revision cursors and cancelled active deletion across restart.
Current-shape storage fixtures also qualify segmented replacement and incomplete unpublished
revisions in both initial and restored namespaces, with retained readers and reopen.
Retained readers keep complete deleted payloads; SST observations distinguish logical deletion
from snapshot release and explicit compaction. Header copies stay bounded, while Fjall’s value
size queries and database caches retain their separate allocation owner. The node-local sweeper
takes the applied consensus read guard before the checkpoint
installation barrier, matching staging and publication; both remain held through bounded deletion.
The public quota scenarios retain active staging through completed sweeps and prove failed
targets remain gated after reclamation and restart on one and three nodes. The selected
scenarios run without retries in a binary built for the mode: in-process diagnostic nodes running
a workload, real diagnostic
server processes that stop gracefully with evidence that records a running detector and no
findings, each on one and three nodes, the buffered client alterations with overlapping holds and
failed drains or full-hold engagement, and the restore installation workloads. The latter exercise
the blocking applied-state authority guard through staging failure, complete publication, runtime
handle clearing and a delayed coordinator across leadership transfer and a successor’s START.
The large-generation scenarios cover two 20 MiB saves and forty 1 MiB saves on one and three nodes,
failure after durable publication, cluster restart, branch isolation, saved source offsets and
positive reclamation after resumed checkpoints and the state purge from module rebinding.
The three-node many-save case retains one replica per state and observes reclamation of both
owner and replica chunks.
The large branch-state scenario restores a 40 MiB deduplicator keyspace and a 40 MiB window beside
24 small branches on one node, through a failure after durable publication, a stopped restore and a
resumed one. Once the resumed domain runs, the window persists as a segmented checkpoint under the
installation barrier and reopens from it after a cluster restart. Its three-node example runs in
the ordinary suite only.
Restore workload setup sends durable mutations and activation through the Rust client, which
recovers the same execution reference when leadership moves; the raw subscription session keeps
its own row stream. Large-generation and staging archive captures use a two-minute command budget
so positive capture waits accommodate concurrent diagnostic workloads.
The three-node copy restore cordons its coordinator before planning, so every guest save uses the
remote chunk transfer and the receiver’s quota-owned completed file.
Those restore scenarios also run in the ordinary public suite. The memory-pressure scenarios take
the node’s pause lock as the pause collects every registered quiesce control and as each starting
ingestor reads the pause, on one and three nodes. The client consumer scenarios take the Rust
client consumer’s state and parked-read locks in the scenario process as a consumer is restored
after a session restart and closed by a domain restart. The Roto scenario takes the bool column
builder’s lock in every push, push_null and finish of an admitted function call, for a
selected, a dropped and an absent value. The TLS rebinding scenario replaces the HTTPS listener’s
TLS configuration on every node as a TLS VHOST is created and rebound, while each connection the
background HTTPS posts open reads it. The inferencer scenario takes a branch’s ONNX session lock
for each batch of two interleaved branches. Each of the three runs on one and three nodes. On three
nodes with a replica, the deduplicator, window and guest-save restore scenarios leave the owner’s
lifecycle and branch checkpoint announcements with the replica’s lifecycle handle, where the
replica task takes them under the same lock.
The ONNX inspection admission scenario saturates the leader’s bulk workers and wait queue, observes the typed CREATE INFERENCER refusal, releases the occupancy and creates the same inferencer on one and three nodes. Its paired stress registration reaches the occupancy holders mutex under bounded scheduling disturbance. Metadata inspection keeps ONNX’s operator concurrency at one, so loading and optimization run on the bulk worker the executor admitted. Debug phase events distinguish worker queue delay, session initialization and release while the thirty-second product bound stays in place. The dependency’s own locks and threads remain outside Deloxide; the isolated native owner regression measures the live temporary session’s process thread count, and the public branch-batch scenario retains its exact interleaved-branch output oracle.
Shuttle checks the production handoff’s delivery/refusal and close races, including counter drain,
through the registered replay-capable harness, in the Shuttle build where no detector is installed.
The queue internals and OS wakeups are opaque; the relaxed refusal counter publishes no other
location. OnceLock publishes the installation port; no new independent cross-location atomic
protocol is introduced. Existing primitive Loom conformance remains separate. The lane’s supervisor
runs outside every diagnostic process: it adds no synchronization to a process it observes and
reads only the processes’ exits and the evidence files they wrote. Deloxide models no async or network scheduling. Tokio locks,
channels, Notify, DashMap shards, dependency locks, atomic protocols, missing notifications and
cross-node waits remain outside its detection claims and retain Shuttle/Loom/Turmoil/Chaos checks.
No diagnostic check proves safety for an untracked path or an order the workload did not take.
Diagnostic images under external chaos. The lane runs diagnostic nodes in a scenario binary or
as local processes; the external chaos suite runs them as packaged containers under real process,
pause and network faults. The diagnostic target of Dockerfile.debian builds the server of one
selection, NERVIX_DIAGNOSTIC_SELECTION, with the ordinary CLI and formatter and the
nervix-deadlock-report tool, on the release image’s CPU runtime. It declares the selection in its
io.nervix.diagnostic.selection label, and every image, release or diagnostic, declares the revision
it was built from in org.opencontainers.image.revision. The release targets copy their binaries
from the builder’s release artifacts and the diagnostic target from its diagnostic ones, so a build
of one kind cannot produce an image of the other, and the diagnostic image never enters a published
manifest. just docker-build-diagnostic builds it; CI’s build-diagnostic job builds the
deloxide-order image beside the release images whenever a chaos suite runs, and the suites run
against both. The chaos runner reads the label: every node records its evidence in a directory of
the run, the run freezes the nodes once its Docker event recording is closed, and the image’s own
report tool qualifies every file before cleanup. A run fails unless each node holds one complete,
qualifying evidence file of the declared selection for every process start the recording shows,
and no node ended with status 3 or 4; an unreviewed potential cycle fails it as in the lane.
The suite refuses an image whose labels name another kind or revision than its caller asks for.
scripts/chaos/README.md owns the runner’s contract and artifacts. The evidence observes each
process up to its kill, pause, stop or the freeze, without an upstream flush barrier, and only its
tracked thread-blocking locks; recovery, ledger and timing verdicts stay the scenarios’ own, with
the product’s deadlines and the suite’s budgets unchanged.
Deterministic Concurrency Verification
The ordering contracts above are checked against the production owners under Shuttle. A check runs several tasks or threads through one scheduler, which chooses an interleaving at visible synchronization points. It can expose a lost notification, an early drain, a double completion, or a stale generation without depending on which OS thread happened to run first. A deadlock is a failed check. The model is the surrounding schedule and test data; the protocol under test is the same type used by the data plane, not a copied implementation of it.
Shuttle does not model elapsed time. Under Shuttle the boundary’s time family is Shuttle’s: a
sleep yields once, and a timeout does not measure its deadline and fires only when a check triggers
it by task label through nervix_primitives::time::trigger_timeouts. Instant::now still reads the
operating system’s monotonic clock, while paused-time controls do not advance a simulated clock.
Checks of deadline ordering therefore use an already-passed or far-future instant and explicitly
choose whether the timeout wins. Ordinary tests on a paused Tokio clock retain responsibility for
actual timer behavior. No concurrency check uses a wall-clock bound, sleep poll, or recv_timeout
to establish progress.
Only scheduler-visible operations create interleavings. In a Shuttle build the primitive
boundary selects every family from Shuttle or from its own adapters, as the backend matrix shows, and
every package that owns the feature forwards it to the boundary, so the synchronization inside
vocabulary types such as AtomicTimestamp, inside dependencies such as the execution crate’s
cancellation, and inside every connector is Shuttle’s too, and so are the timers of every crate in
the graph. The feature changes the test execution environment, not the public protocol. Edge I/O,
sockets, filesystem access, and signals remain real and are outside a Shuttle schedule. A Shuttle
execution has no Tokio reactor, so a socket created inside a check panics and fails it rather than
reaching the network unobserved. A check’s own records use unmodeled atomics on purpose, under the
unmodeled permissions, so that recording an operation adds no scheduling point to it.
Some primitives are opaque to Shuttle: arc-swap, concurrent-queue and the futures crate’s
atomic waker have no modeled implementation. The boundary runs each ArcSwap and ArcSwapOption
load and store, each ArcSwap compare-and-swap and read-copy-update, each operation of a lock-free
queue, each registration, wake and take of an atomic waker, and each read and write of a OnceLock
between two scheduling points, and calls the primitive directly in ordinary execution. Shared
ownership through triomphe or the standard library takes no scheduling point: a reference count is
real in every mode. The boundary’s thread module supplies the scheduler-visible synchronous yield
that admission spin waits use. A check may claim an ordering around an opaque primitive only when
its relevant calls have visible scheduling points. Shuttle cannot interrupt an arbitrary instruction
inside it, and a check never claims the primitive’s own memory safety.
A lost wakeup lives between a waiter’s read of some state and its registration for the
notification a publisher sends after changing that state. Shuttle’s own Notify keeps its waiter
registration behind a real lock and its watch channel reads its version without a scheduling
point, so no schedule could place a publication in that window, and a read followed by a
registration looked safe in a check even though a release between the two is lost in production.
The boundary’s Notify and watch channel keep Tokio’s semantics and take a scheduling point
immediately before each registration, each notification and each read of the version that
registers or decides, and the atomic waker’s adapter takes one before and after each registration
and wake. Tokio’s Notify registers a future for notify_waiters when the future is
created, and for notify_one only when it is first polled or enabled; notify_one without a
registered waiter stores one permit; a waiter a single notification chose passes it on when it is
dropped before it observed it. A watch receiver marks the current version as seen when it
subscribes. The conformance checks of crates/primitives/src/tests run the same scripts against
Tokio and against the adapters, and the Shuttle checks there show that a publication between a read
and the registration is reached: each broken order deadlocks in some explored schedule and the
correct one never does. The ownership-handoff checks separately exercise release-before-wake and
register-before-read on the production freeze owner. Reversing the latter order deadlocks under
both random and PCT schedules, and the failing schedule replays.
Shuttle explores sequentially consistent schedules. It cannot prove that a chosen Relaxed,
Acquire, or Release ordering is sufficient on weak-memory hardware; a claim that depends on one
is a Loom model, described under Memory-ordering models. Cucumber
remains the public behavior test: when a scheduling defect affects an NSPL operation, runtime
output, or process outcome, its Shuttle regression is paired with a scenario through that
interface. A scenario cannot exhaust the interleavings of an in-process protocol, and neither a
Shuttle check nor a Loom model can verify the whole cluster, socket, disk, or browser path.
Check contract and runner
Each check names the invariant in its test name, drives competing operations on the production owner, and asserts the state at meaningful transitions or after all participants have joined. Use an explicit handshake or scheduler-visible yield to position a race. When both the decision read and waiter registration are scheduler-visible, a missed notification leaves the waiter pending and Shuttle reports the resulting deadlock. Name a bounded number of participants so the search remains reviewable; use bounded depth-first search for small races and random plus probabilistic concurrency testing (PCT) for larger ones.
Every check runs through the one Shuttle runner, nervix_model_harness::shuttle: 100 random and
100 PCT schedules of depth three, 1,000 of each with a depth-first search bounded at 1,000
schedules, or one scheduler with the check’s own bounds. Every schedule may take 10,000 steps; one
that needs more fails its check, because a step cap is an exploration bound, not a product timeout.
A random or PCT search that ran fewer schedules than it declares fails too, and a search that
finishes prints a record naming what it explored. SHUTTLE_REPORT_STEPS=1 reports the highest
observed step count when tuning a check.
The primitive boundary’s own conformance and race checks run in just test-primitives, which
builds the boundary once per mode; its Shuttle checks explore their small models exhaustively with
depth-first search, and each deliberately broken order must deadlock in some schedule.
crates/model-harness/shuttle-inventory.toml registers every check of nervix-execution,
nervix-interconnect, nervix-client-core, nervix-connector, nervix-consensus,
nervix-server and nervix-deadlock by its test’s full name.
just test-shuttle lists the library tests whose full names contain shuttle_ in each registered
package, built with its shuttle feature, and a run over the whole inventory fails when a
registered check is missing or ignored, or when a discovered check is unregistered. Each selected
check runs twice, each time in its own process: under the exploration it declares, then under
Shuttle’s uncontrolled-nondeterminism detector, which runs 100 random schedules twice each and
fails when the second run diverges. A run counts only when its test passed and the runner printed
its record, and the command reports how many checks it discovered, selected, executed and saw
complete. just test-shuttle <filter> selects the checks whose full name contains the filter in
every package; a package with no match is fine, and a filter that selects nothing at all fails. The
recipe uses the repository’s kache-backed build and prepares the server’s test dependencies; just test continues to run the ordinary suite.
just cargo-clippy-shuttle, whose package checks also run in just lint, lints every Shuttle
build with warnings denied, including every inventory package in test mode, where its checks are
compiled, so a check that compiles with a warning fails validation.
A failed check leaves target/shuttle-failures/<package>/<fully-qualified-test-name>/: the schedule
Shuttle persisted for the failing execution, the run’s output, and metadata naming the check, the
run, the revision, the toolchain, Shuttle’s version and the replay command. just test-shuttle-replay <schedule> replays exactly that check in a fresh process with the schedule,
which ends where its execution failed and so replays that failure. just test-shuttle-replay-check
proves the path end to end: it fails one check deliberately after its invariant held, requires one
persisted schedule, and requires it to reproduce the failure in a fresh process. CI’s dedicated
shuttle job runs both, only for a pull request labeled shuttle, and uploads
the failure directory in a tar archive as the shuttle-failures artifact when a check fails;
fully qualified test names contain colons, which the artifact uploader rejects as paths. See the
command recipes in Developing Nervix.
Protocols and their checks
The checks below hold the scheduler-visible parts of each protocol to their invariants. Test
names are given relative to their owning module; the shuttle feature selects the modeled build.
A family of names means each member runs independently through the recipe.
| Protocol | Invariant and check |
|---|---|
Waiter registration and cancellation (crates/primitives/src/tests/shuttle_races.rs) | shuttle_reaches_a_notify_waiters_published_between_a_read_and_the_registration and shuttle_reaches_a_send_between_a_read_and_the_subscription require a waiter that reads before it registers to deadlock in some schedule, which shows the scheduler reaches a publication between the read and the registration; shuttle_registering_before_reading_never_misses_a_notify_waiters and shuttle_subscribing_before_reading_never_misses_a_send require the correct order never to lose one. shuttle_reaches_a_wake_between_a_read_and_the_waker_registration and shuttle_registering_the_waker_before_reading_never_misses_a_wake hold an atomic waker to the same pair. shuttle_a_notify_one_between_a_read_and_the_registration_is_kept_as_the_permit shows a single notification’s permit makes the other order correct for notify_one, and shuttle_a_waiter_cancelled_as_it_is_notified_passes_the_notification_on requires the surviving waiter to complete however a cancellation and a notification interleave. |
Execution budgets and storage jobs (crates/execution/src/tests.rs) | shuttle_saturated_class_keeps_live_reservations_within_each_class_capacity keeps live reservations within each class capacity; shuttle_occupied_bulk_execution_leaves_control_execution_untouched keeps bulk saturation from charging control; shuttle_queued_job_drop_releases_its_reservation_and_exact_queue_slot and shuttle_running_job_observes_cancellation_and_keeps_its_charge_until_exit balance queue slots and permits across drop and cancellation; shuttle_full_wait_queue_is_exact_typed_backpressure checks a full wait queue’s typed rejection; shuttle_a_job_waiting_for_a_place_takes_the_first_freed_place requires a job that waits for a place to take the first one freed and run before a job refused when full that asks after it; shuttle_a_dropped_job_waiting_for_a_place_gives_up_its_place_and_charge returns a dropped waiter’s charge and hands its place to the job waiting after it; shuttle_consensus_storage_preserves_admission_order_and_returns_every_permit checks storage admission order and permit return. |
Force-flush obligations (src/runtime/force_flush.rs) | shuttle_two_participant_generation_waits_for_every_obligation prevents completion before all participants live at publication complete and redelivers a dropped, unhandled completion; shuttle_stale_completions_never_clear_a_newer_generation prevents an old completion from clearing new work; shuttle_published_generation_wakes_a_waiting_participant catches a lost publication wakeup; shuttle_participant_lifecycle_balances_obligations_through_close balances pending() across subscribe, request, participant drop, and close. |
Ingestor intake (src/runtime/ingestors/source_shuttle_tests.rs; src/runtime/ingestor_quiesce.rs) | shuttle_broker_source_observes_engagement_during_dispatch, shuttle_paced_source_observes_engagement_during_dispatch, and shuttle_request_source_observes_engagement_during_dispatch exercise host-loop engagement for memory pressure, entity gate, handoff, and shutdown: after engagement returns, no further payload dispatches, and a change during dispatch is observed. shuttle_an_open_control_answers_intake_without_waiting_on_retained_payloads keeps the open decision independent of a retained-payload lock. shuttle_a_new_quiesce_ends_a_delivery_waiting_for_extension_room and shuttle_a_shutdown_ends_a_delivery_waiting_for_extension_room race the engagement or the stop against a retained payload’s delivery waiting for a place the extension class never frees: the waiter registers before it reads, so the delivery always ends, and its payload is back at the front of the buffer counted as it was. shuttle_a_new_quiesce_decides_again_on_a_live_payload_waiting_for_extension_room and shuttle_a_stop_ends_a_live_payload_waiting_for_extension_room race a BUFFER engagement or the stop against a live payload waiting for such a place: the wait always ends, a new decision becomes the one the payload is decided under next and its buffer retains it, counted with its bytes, and a stop leaves nothing retained. |
Broker source confirmation (src/runtime/ingestors/source_shuttle_tests.rs; src/runtime/ingestors/source.rs, src/runtime/ingestors/source_acknowledgement.rs, src/runtime_ack.rs) | shuttle_a_drain_waits_for_the_transport_to_confirm_a_resolved_batch lets a drain read the domain’s ownership-handoff count at any point while the graph resolves the two confirmed roots of one batch in either order and the source’s transport takes a round trip to confirm the batch. Whenever the drain reads no outstanding work, every batch whose roots resolved has been confirmed, and the count returns to zero once it is. The check fails under a batch acknowledgement that releases the last root before the loop drops it, and under a root released as it resolves. |
Broker source reconnect (src/runtime/ingestors/source_shuttle_tests.rs; src/runtime/ingestors/source.rs) | shuttle_a_reconnect_discards_rejections_the_broker_redelivers loses the connection of a broker_redelivered source at any point, once or twice, while the host loop acknowledges two messages one at a time. The host never asks a connection to acknowledge or reject a delivery of a lost one. Every message is acknowledged exactly once, on the connection that delivered it, and each rejection a lost connection failed is discarded and counted exactly once. The check fails under a loop that retries the failed rejection after the reconnect. |
Relay dispatch gate and fan-out (src/runtime/relay_channel_shuttle_tests.rs) | shuttle_dispatch_permits_never_overlap_a_quiescent_lease_and_release_frees_every_waiter, shuttle_overlapping_gate_leases_all_release_before_dispatch_resumes, shuttle_expired_gate_fence_frees_every_waiter_without_reporting_quiescence, shuttle_canceled_dispatch_returns_its_permit_to_the_gate_fence, and shuttle_dispatches_parked_behind_a_lease_wake_only_on_its_release hold the fence, lease, expiry, cancellation, and waiter contract; in-flight dispatches drain before quiescence. shuttle_capacity_shrink_keeps_buffered_batches_and_wakes_publishers_after_the_drain, shuttle_capacity_growth_admits_waiting_publishers_without_a_take, shuttle_publishers_wait_for_the_slowest_consumer_and_skip_consumers_that_leave, and shuttle_losing_every_consumer_delivers_or_returns_the_waiting_batch keep queued batches across capacity changes, release waiting publishers, and return or deliver each batch when receivers leave. |
Relay owner fan-out (src/runtime/relay_boundary_shuttle_tests.rs) | shuttle_owner_fanout_fails_its_ack_while_an_attached_consumer_moves keeps a live sibling from completing a source ACK while another attached consumer leaves under a schedule fence; after release, a retry reaches the live consumer. shuttle_a_routed_batch_racing_a_joining_consumer_reserves_a_share_for_every_delivery races a batch another node routed here against an attached consumer registering here: every consumer the batch reaches holds a share of its own, so one that fails the batch fails the source ACK. |
Local drain observation (src/runtime/local_drain_shuttle_tests.rs) | shuttle_a_batch_published_while_a_drain_observes_is_never_missing_from_it, shuttle_a_batch_a_consumer_takes_while_a_drain_observes_is_never_missing_from_it, and shuttle_a_batch_its_owner_hands_on_while_a_drain_observes_is_never_missing_from_it race a batch moving from a node into a relay, from a relay into a consumer’s node, and from a relay’s transit into its consumer’s queue against one drain observation of the production read order: the observation shows the batch, or the relay’s admission when the move went against the order of its reads. shuttle_a_message_a_flush_resumed_is_seen_by_a_drain_that_sees_the_flush_complete races a force flush that resumes a parked message and completes its obligation against the observation: one that sees the obligation complete sees the message as admitted work. |
Relay subscription definitions (src/runtime/relay_subscription.rs) | shuttle_an_attachment_racing_a_redefinition_is_refused_or_closed_by_it races a subscriber’s attachment against a redeclaration of the relay with a field that became sensitive: the attachment is refused, or the redeclaration closes the receiver it attached, so no subscriber describes rows by a replaced definition. |
Materialized dependency waits (src/runtime/materialized_read.rs) | shuttle_a_parked_message_is_woken_by_the_materialized_state_it_waits_for races the arrival of the state a REQUIRED WAIT message is parked on against the processor branch reading it and parking: the wait registers before the read, and again before every retry, so the arrival always wakes it. |
Client emitter budget (src/runtime/client_emitter_shuttle_tests.rs) | shuttle_competing_consumer_grants_never_exceed_the_node_budget, shuttle_consumer_loss_and_ack_race_release_one_retained_delivery and shuttle_publish_cancellation_and_ack_race_release_one_reservation keep grants within the node budget and release each retained delivery and reservation once. shuttle_a_reservation_waiting_for_a_full_budget_takes_the_bytes_a_release_returns races a release against a reservation that finds the budget full: the reservation registers for the change before it reads the budget, so the release wakes it and it takes the bytes. |
Assignment authority and state updates (src/runtime/state_store_shuttle_tests.rs, materialized_state.rs, kafka_offset_state.rs) | shuttle_a_rebind_yields_until_the_operation_admitted_under_its_replaced_binding_finishes and shuttle_no_operation_admitted_under_a_superseded_binding_outlives_its_superseding_rebind fence admitted work even when generations reuse an even or odd counter. shuttle_snapshot_installation_never_overlaps_origination_or_a_capture keeps exclusive installation apart from originators and captures. shuttle_an_originator_update_proceeds_while_the_assignment_barrier_is_held and shuttle_a_committed_offset_proceeds_while_the_assignment_barrier_is_held keep ordinary admitted updates independent of a capture’s barrier. |
ACK tree (src/runtime_ack.rs) | The shuttle_tests checks concurrent_attachment_and_final_ack_leave_exact_tracking, concurrent_wait_and_active_ack_exempt_the_remaining_root, concurrent_wait_release_and_completion_leave_no_tracking, attachment_losing_its_reservation_to_completion_resolves_no_share, attachment_losing_its_reservation_to_completion_parks_no_share, wait_release_racing_the_last_active_ack_holds_domain_and_ingestor_handoff_once, attachment_racing_the_last_active_share_into_wait_publishes_one_active_share, and parked_remote_progress_survives_a_racing_heartbeat_and_resumes_once keep pending, active, and handoff counts exact across attachment, REQUIRED WAIT, and remote progress. concurrent_success_and_failure_choose_one_terminal_transition, fan_out_across_ingestors_with_a_failing_root_resolves_each_root_once_with_exact_counts, and parked_and_fanned_out_roots_hold_exact_counts_at_every_quiescent_point require one terminal result per root, one observed completion, and zero outstanding counts after resolution. a_confirmed_root_stays_counted_until_its_owner_confirms_and_releases_once races a confirmed root’s resolution with its owner taking the resolution and dropping its completion: a resolution the owner holds keeps the root counted, and the counts are released exactly once, after both. |
Forwarded acknowledgement silence (src/runtime/remote_dispatch_shuttle_tests.rs) | shuttle_a_terminal_outcome_racing_the_final_sweep_resolves_the_share_once races the receiver’s terminal outcome against the sweep that would fail a forwarded share: exactly one of them removes it, and the root delivers that one’s outcome. shuttle_a_report_racing_the_final_sweep_keeps_the_share_it_reached races a report against the same sweep: a report that reached the share keeps it pending with its root unresolved, and only a report that found it removed lets the sweep fail it. |
Entity gate and node quiesce (src/runtime/entity_gate_shuttle_tests.rs) | shuttle_an_entity_gate_hold_fences_every_relay_and_admits_no_work_until_it_is_released requires admitted work to drain before quiescence and prevents new admission while closed. shuttle_a_work_item_parked_for_materialized_state_is_never_missing_from_a_drain and shuttle_every_node_quiesce_gauge_withdraws_exactly_what_it_contributed keep parked, buffered, and branch work counted without underflow and back to zero. shuttle_every_engagement_waiter_wakes_and_exactly_one_release_takes_the_hold, shuttle_a_hold_dropped_before_its_fence_completes_reopens_every_relay_it_engaged, and shuttle_a_failed_engagement_wakes_every_waiter_with_its_failure cover release, drop, and failure. shuttle_releasing_an_ownership_handoff_wakes_every_waiter_frozen_by_it holds release-before-wake; shuttle_an_ownership_handoff_freeze_observation_registers_before_its_read holds register-before-read against that release. |
Interconnect slots and membership (crates/interconnect/src/connection/stream_slots/shuttle_checks.rs, request/shuttle_checks.rs) | management_drain_stops_leasing_and_waits_for_every_leased_slot, replication_drain_stops_leasing_and_waits_for_every_leased_slot, bulk_drain_stops_leasing_and_waits_for_every_leased_slot, and relay_drain_stops_leasing_and_waits_for_every_leased_slot keep partition and subquota reservations isolated, forbid leases after drain starts, and wait for every lease to return. racing_registrations_lose_no_handler_and_publish_each_name_once prevents a lost handler registration and duplicate name. a_membership_change_between_a_callers_check_and_its_wait_is_never_lost prevents a missed discovery wakeup. |
Shutdown and signals (src/application/shutdown.rs, termination_signals.rs) | shuttle_racing_stop_requests_accept_exactly_one_and_keep_its_deadline retains the first stop request and its deadline. shuttle_phases_only_advance_and_every_completion_waiter_observes_the_one_outcome keeps phase order and one completion. shuttle_an_expired_deadline_and_a_repeated_signal_let_exactly_one_forced_exit_end_the_process and shuttle_a_repeated_signal_before_the_deadline_ends_the_process_with_the_status_of_that_signal give one forced-exit claimant and the exit status of the cause that won. |
Brokered sink confirmation window (crates/connector/src/confirmation_window_shuttle_tests.rs) | Each check hands records to the production ConfirmationWindow while broker tasks answer them concurrently through channels of their own, each recording when its answer could be read, and the check notes which answers were given before the publish began to end. shuttle_a_failure_settles_every_answer_its_broker_gave_before_it ends the publish with the driver’s failure: every record whose answer was given before it is delivered or rejected as answered, and a failed answer leaves its record unresolved. shuttle_answers_racing_a_full_window_settle_each_record_exactly_once hands five records through a window of two whose answers arrive in any order: the window holds fewer than two answers after each hand-over, and every record is settled exactly once as its answer says. shuttle_an_expired_oldest_settles_the_answers_given_before_it lets a check-triggered timeout expire the publish’s deadlines at any point among the answers, which a Shuttle timeout does before it polls the answer: a missed deadline still settles every answer given before it, the oldest’s own included. shuttle_a_failed_oldest_answer_settles_the_younger_answers_given_before_it ends the publish with the oldest’s failed answer and still settles a younger answer given before the end. |
Emitter batch payloads (src/runtime/emitter_record_writes_shuttle_tests.rs) | shuttle_a_retried_payload_acknowledges_each_fanned_in_member_once_after_every_emitter and shuttle_a_sibling_failure_resolves_each_fanned_in_member_once_despite_a_retry fan two source messages out to a batching emitter and a sibling: each source acknowledgement completes once, successfully only after both emitters confirmed it, and the retry writes the retained payload’s first bytes. shuttle_a_cancelled_attempt_leaves_each_member_to_resolve_once cuts an attempt short at any point and requires the next one to write only unanswered payloads and deliver each rejected member’s message error once. shuttle_a_drain_never_finds_the_emitter_empty_while_a_member_is_retained races a drain’s reads against a stalled write and the force flush that repeats it. |
Client ingestors (src/runtime/client_ingestor_shuttle_tests.rs) | shuttle_racing_reservations_never_exceed_the_node_budget_and_return_every_byte races opens that each need more than half the node’s producer budget: at most one holds it at a time and every reservation returns its bytes. shuttle_a_batch_racing_a_quiesce_is_either_counted_by_its_drain_or_refused_undispatched races the admission fence against an engagement and its drain: no batch is dispatched after the drain concluded. shuttle_a_closing_producer_answers_every_admitted_batch_once_before_its_release and shuttle_an_ending_endpoint_answers_every_batch_once_and_ends_its_producer_last race a close or an endpoint end against the worker’s admission reports and the batches’ acknowledgements: every batch is answered exactly once, a close answers each with its real outcome before the release, and an end reports no admitted batch as not admitted and comes last. shuttle_a_detach_racing_a_clearance_admits_only_a_cleared_batch_and_returns_its_slot races a forwarded producer’s detach against the clearance of its batch while a local producer waits for the window’s one slot: the forwarded batch reaches the worker only after its clearance was recorded, and the local batch is admitted whichever comes first, so no slot leaks. shuttle_an_end_racing_clearances_reports_a_batch_not_admitted_exactly_when_the_worker_never_took_it races an endpoint end against the clearance of two batches while the worker holds the first without reporting it: each batch is answered once, not admitted exactly when the worker never took it, whether it was still being cleared or cleared and waiting for the worker, and of unknown outcome when it did. |
Rust client submission slots (crates/client-core/src/producer/slots_shuttle_tests.rs) | shuttle_a_wait_racing_its_resolution_takes_the_outcome_once_and_returns_the_credit, shuttle_a_cancelled_wait_loses_neither_the_outcome_nor_the_credit, and shuttle_a_release_racing_its_resolution_returns_the_credit_exactly_once race a submission’s resolution against the application’s wait, an aborted wait followed by a new one, and a release: the outcome is taken at most once, a cancelled wait leaves it retrievable, and the credit comes back exactly once. |
Rust client attachment recovery (crates/client-core/src/producer.rs, consumer.rs) | shuttle_close_fences_a_producer_restore_started_on_the_same_exchange and shuttle_close_fences_a_consumer_restore_started_on_the_same_exchange race close against beginning restoration. shuttle_close_fences_a_producer_restore_interrupted_by_another_loss and shuttle_close_fences_a_consumer_restore_interrupted_by_another_loss race close against another loss while restoring. Each check uses the production lifecycle owner and requires the final phase to remain closed, with subsequent restoration refused. shuttle_a_reply_racing_the_end_of_its_exchange_reports_the_gap_once races a consumer’s reply against the end of its exchange: the interruption is reported once, whichever observes the end first. shuttle_a_read_parked_while_its_consumer_closes_does_not_outlive_the_close races a read that parks for delivery against the close: the read ends with the close. shuttle_a_submission_racing_its_producers_end_observes_the_end races a submission’s wait against its producer’s end, and shuttle_a_reader_racing_a_reported_gap_takes_it_without_another_change (subscriptions.rs) races the application’s next read against a subscription event the follower reports: each waiter subscribes before it reads, so neither waits for a later change. |
Rust client domain clock restoration (crates/client-core/src/domain_clock.rs) | shuttle_a_detach_racing_a_restoration_leaves_no_attachment_of_its_clock races a detach of an interrupted clock against the production restoration decision, with a modeled session that serves both in the order it receives them and applies each reply through the production owner: once the detach is answered the session holds no attachment of the clock and the client does not follow it, whether the restoration attached first, waited for the detach, or found the clock released. |
Relay branch presence (src/runtime/relay_branch_presence_shuttle_tests.rs) | shuttle_an_observer_sees_every_owner_step_whole_and_never_an_older_one races an owner at capacity one through admission, eviction, recreation and release against an observer that registers and reads throughout: every read is a membership the owner published whole and never older than the step the owner had finished. shuttle_capacity_and_expiry_publish_whole_memberships keeps every read within the owner’s capacity and drops an expired branch from every read after the expiry. shuttle_a_replaced_owner_never_publishes_over_its_successor races a predecessor’s admissions, expiry and release against its successor’s claim: once the claim is visible no read holds a branch only the predecessor admitted, and the successor’s branch survives the predecessor’s release. |
Checkpoint replication (src/runtime/kafka_offset_state.rs, src/runtime/state_replication/checkpoint_announcement_shuttle_tests.rs) | shuttle_a_replica_acknowledgement_racing_the_quorum_wait_is_never_missed races a Kafka offset commit’s replica quorum wait against its replica’s acknowledgement: the wait registers before it reads, so it completes without its deadline, which a Shuttle timeout only reaches when a check triggers it. shuttle_an_offer_racing_the_end_of_an_announcement_is_always_announced races a second offer against the announcer of the first finding its replica caught up: the second revision is always announced and acknowledged. shuttle_a_retired_replication_ends_its_announcer ends an announcer whose replicated state goes away while its replica never acknowledges. shuttle_an_announcement_racing_the_replica_wait_is_never_missed races an owner’s announcement against the replica task’s synchronization and wait: an announcement that lands before the wait is kept as its permit. shuttle_checkpoint_announcement_close_cancels_pending_dispatch exercises the production task owner with close racing the first poll and close after dispatch starts: both cancel a dispatch that never becomes ready and release its retained announcer. |
Branch snapshot task ownership (src/runtime/state_replication/tasks_shuttle_tests.rs) | shuttle_a_closed_snapshot_task_owner_is_waited_for_until_every_task_has_written races a runtime closing the production task owner against three branches: one that stops in time, one ended at its stop grace that drops what it held, and one that never tells its snapshot task anything. The wait returns only once every task has made its last write, and a task that waited for its branch alone would hang it, which Shuttle reports as a deadlock. The task body stands in for the write; the tracker and token are opaque dependencies. |
Resolved state replication (src/runtime/state_replication/routing_shuttle_tests.rs) | shuttle_state_replacement_and_retirement_fence_frames_in_flight exercises the production routing owner: a frame can complete only on its selected lifetime, never change a successor, and cannot enter a route after retirement. ArcSwap internals remain opaque; this check covers owner use and scheduling, with no new Nervix-owned memory-ordering claim. |
Replica catch-up announcements (src/runtime/branch_lifecycle_state_shuttle_tests.rs) | shuttle_an_announced_branch_racing_the_replica_round_is_never_missed races an owner’s announcement of a branch checkpoint against the replica task taking the pending announcements and waiting for the next: the task takes it whether it lands before the take, between the take and the wait, or during the wait. shuttle_announcements_of_one_branch_keep_the_newest_pending delivers two announcements of one branch in either order while the task takes them: an older one never replaces a newer one still pending. |
Backup capture, restore publication and reclamation (src/runtime/backup_capture_fence.rs, src/runtime/branch_state_capture_shuttle_tests.rs, crates/consensus/src/restore.rs, materialized_state.rs) | shuttle_backup_cut_includes_pre_cut_branch_publication includes every registered publication before a cut. shuttle_a_forced_deduplicator_publication_holds_every_key_acknowledged_before_the_cut and shuttle_a_forced_window_publication_holds_every_step_acknowledged_before_the_cut race a branch task’s admissions or window steps against the cut’s checkpoint request over the branch task’s own command and input channels: once the task answers, the keyspace or window the capture reads holds every change acknowledged before the cut asked. Restored deduplicator and window checkpoints install through the same staged publisher and domain handle clear as the other kinds, so the restore checks below hold their installation order too. shuttle_restore_publication_and_handle_clear_precede_resume_and_fence_delayed_publishers drives the applied-state authority guard against a successor generation and RESUME, requiring complete publication and cleared handles before activation. shuttle_materialized_capture_names_exactly_its_branch_generation races capture with an update and eviction. shuttle_replica_synchronization_preserves_the_restored_materialized_revision races delayed and current owner snapshots. shuttle_restore_reclamation_holds_the_revision_through_queued_installation races the reclamation guard with a successor generation and queued installer, requiring the borrowed revision to stay fixed through deletion and preserving the current installation. They register in the shared Shuttle inventory and use its exploration, nondeterminism and replay contract. |
Domain clock (src/runtime/domain_clock.rs) | shuttle_lifecycle_tests::concurrent_reads_of_one_installed_generation_never_decrease checks the nondecreasing watermark; a_clock_bound_to_a_replaced_generation_is_refused_by_revalidation rejects a superseded generation; readers_never_observe_an_installation_older_than_one_they_observed prevents publication regression. shuttle_delivery_sends_state_before_ticks_without_regressing_progress explores the production observer and attachment delivery order across accepted ticks, same-generation unassignment and reassignment, and a generation change. shuttle_an_attach_waiting_for_the_first_installation_observes_its_domains races an attach’s wait and lookup against the node’s first installation of the committed domains and requires the lookup to find the domain and its clock. a_logical_waiter_wakes_when_its_generation_stops, a_logical_waiter_wakes_when_its_generation_is_replaced, a_logical_waiter_wakes_when_its_domain_is_removed, and a_logical_waiter_wakes_when_a_replacement_mapping_reaches_its_deadline cover each lifecycle wakeup. |
The checks of WASM checkpoint holds and the durability barrier use the same runner and replay
contract. Among them, shuttle_an_abandoned_writers_synchronization_keeps_the_barrier_until_it_ends
and shuttle_an_abandoned_writers_failed_synchronization_still_refuses_what_it_covered hold a
synchronization round to the storage job that runs it: a writer that stops waiting frees neither the
barrier nor the round’s failure. Their state semantics live in the WASM state documentation; they do not turn Shuttle
into a disk or replica simulator. Deterministic interconnect simulation
and Cucumber cover the network and process behavior outside this in-process scheduling boundary.
shuttle_a_vocabulary_atomic_is_a_scheduling_point_in_the_server_build
(src/shuttle_selection_tests.rs) holds the selection itself to account: in the server’s Shuttle
build, an operation on the vocabulary crate’s AtomicTimestamp must advance Shuttle’s
scheduling-point count, which it can only do when the feature reached that dependency’s atomics.
Memory-ordering models
A claim that one thread’s writes are visible to another because of an ordering, rather than
because both threads were scheduled in some order, is checked under Loom. Loom runs a model once for
every schedule and every reordering the C11 memory model allows that it can distinguish, so a
Release weakened to Relaxed shows up as an execution in which a reader sees the flag but not the
write it was meant to publish. A model drives the actual synchronous production owner from Loom
threads with Loom’s atomics, both selected through the primitive boundary by the package’s loom
feature. When a protocol is embedded in asynchronous orchestration, the synchronous protocol is
made independently testable and the runtime uses that same owner; a copied algorithm, a witness
that a join or an extra lock publishes, or a real atomic does not make a model.
The execution, interconnect, consensus and server crates own a loom feature, and each forwards it to the
primitive crate and to every dependency that owns one, so the whole library graph of each builds
with Loom’s primitives. just cargo-clippy-loom, whose package checks also run in just lint,
lints every Loom build:
the models and their harness, the primitive boundary, and the server and consensus libraries both
as they ship and in test mode, where models of their owners are compiled.
Each model names its invariant with an InvariantId and runs through
nervix_model_harness::loom::explore, which explores it to exhaustion: no preemption bound, no
permutation or time budget, a branch limit of 1,000 thread switches per execution that fails the
model rather than ending the search, and Loom’s full thread count. A Loom setting in the environment
that would change that search is refused. A completed search prints a record naming its invariant,
its execution count and its bounds, and just test-loom accepts nothing else as a completed model.
Discovery, model execution, qualification and checkpoint replay use the workspace’s loom build
profile. It inherits dev, retains debug assertions, overflow checks, debug information and the
server’s allocation instrumentation, and compiles with optimization level one to reduce the
allocation frames on Loom’s fixed coordinator stack. Qualification cleans only this profile’s
package artifacts before and after each weakening, so a weakened artifact cannot become an
ordinary build input or satisfy another qualification.
The coordinator starts and joins the model body without touching protocol state. The body and
participants started through nervix_model_harness::loom::spawn each request a 1 MiB coroutine
stack for instrumented allocators and debug frames. The coordinator retains Loom’s fixed default
stack; its initial allocation and spawn path must fit before the larger body stack can run.
Loom forwards that request to its coroutine implementation, whose allocation units need not be
bytes. Its five thread slots include the coordinator, body and at most three other participants.
The harness stack model executes a 64 KiB frame on both the body and a participant; reducing the
stack request must reproduce the overflow and replay it from its checkpoint. Scheduling and
memory-ordering checks still explore to exhaustion.
crates/model-harness/loom-inventory.toml registers every model by invariant. just test-loom
lists the loom_* library tests of every registered package built with its loom feature, and a
run over the whole inventory fails when a registered invariant’s test is missing, ignored or did not
complete, or when a discovered model is unregistered. Each model runs in its own process, and the
command reports how many models it discovered, selected, executed and saw complete; a filter that
selects none fails. A failed model leaves target/loom-failures/<package>/<invariant>/: the Loom
checkpoint of the failed execution, the run’s output, and metadata naming the invariant, revision,
toolchain, Loom version and exploration bounds. just test-loom-replay resumes Loom from that
checkpoint with location tracking and tracing, so the failed execution runs first. The artifacts
hold model output only, never payloads or secrets. For a pull request labeled loom, CI runs the
models in its dedicated loom job, which uploads the failure directory, and their qualification
in the loom-qualification jobs. Invariant IDs use ASCII letters, digits, dots and hyphens, so
these paths are valid artifact names; metadata retains the exact Rust test name used for replay.
Every model also registers a weakening that must make it fail. just test-loom-qualification
applies each to a copy of the working tree, requires the model to fail with the registered message,
and requires the checkpoint of that failure to replay it. This is what shows a model depends on the
ordering it claims, rather than passing because something else synchronized its threads. A
weakening whose original text no longer appears exactly once fails as well, so changing an owner’s
ordering means revisiting its qualification.
Successful qualifications retain target/loom-qualification/<qualification-id>/ as well: the
counterexample checkpoint, the checkpoint advanced during replay, both outputs and metadata with
the revision, toolchain, exploration bounds and complete registered weakening. CI uploads these
records whether the qualification job passes or fails. To replay a retained counterexample, restore
its recorded revision, apply its recorded weakening in an isolated worktree and run
just test-loom-replay <directory>; replay against the unweakened owner is a different check.
The isolated build copy is still removed after qualification, and an unsuccessful control retains
its output and any checkpoint it produced.
Loom models a SeqCst access as an acquire or a release, and models only fence(SeqCst) exactly. A
protocol in which each side writes one location and then reads the other, such as a dispatch that
raises the in-flight count before it reads the closed flag while a fence closes the gate before it
reads the count, is therefore written so that its correctness follows from acquire and release
alone. The side off the hot path reads the other side’s count with a read-modify-write, which reads
the newest value in that count’s modification order: either the hot side’s read-modify-write came
first and the cold side’s read sees it, or the cold side’s came first and the hot side’s
read-modify-write acquires it, and with it the write the cold side made before. That is exact in the
C11 model and checkable by Loom, and it costs the hot path nothing. The qualification of such a
model weakens the cold side’s read-modify-write to a load, which is the store-buffering fault the
protocol rules out.
Loom’s own limits bound every claim. It does not model every relaxed behavior the C11 model
permits, and an operation inside a third-party dependency, such as a triomphe reference count or
an arc-swap publication, is invisible to it and excluded from the claim rather than given a
fictional model. The client batch admission fence reads its quiesce decision from such a
publication, so its model checks the root counts the admission and the drain reach by
read-modify-write, and the publication’s own ordering stays outside the claim. A relay’s
subscription definition is another: an attachment and a redefinition each put a sequentially
consistent fence between their own write and the read of the other’s, but the attachment’s write is
its registration in the fan-out’s consumer list, an arc-swap publication, so the protocol has no
Loom model and its Shuttle check orders the two. The state store’s durability barrier orders each
writer’s ticket after the write it covers and each synchronization’s start before its flush, but
those writes and the flush are the database’s, inside its own synchronization, so it has no Loom
model either; its Shuttle checks hold which tickets a synchronization covers. A standalone counter
carries no cross-location claim, whatever its ordering. Relay
branch presence is such a case: its owner lifetimes and publications are arc-swap compare-and-swap
and read-copy-update operations with no Nervix-owned atomic beside them, so it has no Loom model;
its Shuttle checks order its publications against observers and successors. Checkpoint
replication is another: its announcement and progress change under one placement’s lock, and its
wake-ups are the boundary’s Notify, with no Nervix-owned atomic beside them, so it has no Loom
model either; its Shuttle checks order an announcer’s end against offers, and a wait’s registration
against reports and announcements.
| Invariant | Claim | Model and qualification |
|---|---|---|
execution.cancellation.publication | A job that observes its cancellation also observes every write its awaiting caller made before the cancellation: raising the flag releases and observing it acquires. The witness is read the moment the job observes the cancellation, before any join could order the two threads | loom_a_job_that_observes_cancellation_observes_every_write_made_before_it (crates/execution/src/cancellation.rs); fails when either the raising store or the observing load is weakened to Relaxed |
execution.cancellation.cancel-on-drop | Dropping an armed obligation cancels its job: once the drop is ordered before a check, every clone of the job’s signal reports it, and a check never loses a cancellation an earlier check observed | loom_dropping_the_obligation_cancels_every_later_check_of_the_job |
execution.cancellation.disarm | A disarmed obligation never cancels its job, whether the job checks while it is disarmed or after it is dropped. Disarming writes nothing, so the model has a single schedule and fails if disarming or the drop after it ever raises the flag | loom_a_disarmed_obligation_never_cancels_its_job |
runtime.relay-dispatch-gate.entry-fence | A relay dispatch fence counts every dispatch that finds the gate open: a dispatch raises the in-flight count before it reads the closed flag, an engagement closes the gate before it reads the count, and both reach the count by read-modify-write, so one observes the other | loom_a_fence_counts_every_dispatch_that_finds_the_gate_open (src/runtime/relay_channel_loom_models.rs); fails when let in_flight_dispatches = self.in_flight_dispatches.fetch_add(0, Ordering::AcqRel); weakened to let in_flight_dispatches = self.in_flight_dispatches.load(Ordering::Acquire); |
runtime.relay-dispatch-gate.lease-publication | A fence that takes its lease observes everything each dispatch it counted did under its permit: leaving releases and the fence’s read of the count acquires | loom_a_lease_follows_what_every_counted_dispatch_did_under_its_permit (src/runtime/relay_channel_loom_models.rs); fails when a dispatch’s leaving is weakened from AcqRel to Relaxed |
runtime.relay-dispatch-gate.drain-wakeup | The last dispatch to leave a closed gate wakes the fence waiting for it: either the fence’s read of the count sees the dispatch gone, or the dispatch’s leaving acquires that read and observes the gate closed | loom_the_last_dispatch_to_leave_a_closed_gate_wakes_its_fence (src/runtime/relay_channel_loom_models.rs); fails when let in_flight_dispatches = self.in_flight_dispatches.fetch_add(0, Ordering::AcqRel); weakened to let in_flight_dispatches = self.in_flight_dispatches.load(Ordering::Acquire); |
runtime.relay-dispatch-gate.reopen-publication | A dispatch that finds the gate reopened observes what the protected mutation changed while it was closed: reopening releases and the dispatch’s read of the flag acquires | loom_a_dispatch_that_finds_the_gate_reopened_observes_the_protected_change (src/runtime/relay_channel_loom_models.rs); fails when .store(!state.engagements.is_empty(), Ordering::Release); weakened to .store(!state.engagements.is_empty(), Ordering::Relaxed); |
runtime.relay-fanout.admission-wakeup | A consumer that frees room a waiting publisher needs either admits it or wakes it: the publisher raises the waiting count before it reads the admission count, the consumer lowers the admission count before it reads the waiting count, and both reach the admission count by read-modify-write, so one observes the other | loom_a_consumer_freeing_room_admits_or_wakes_the_publisher_waiting_for_it (src/runtime/relay_channel_loom_models.rs); fails when let admitted = self.admitted.fetch_add(0, Ordering::AcqRel); weakened to let admitted = self.admitted.load(Ordering::Acquire); |
runtime.state-assignment.admission-fence | An operation admitted under a runtime-state binding that a rebind supersedes either observes the new binding and is refused, or finishes before the rebind returns with everything it did visible to the rebinding side: the operation counts itself in before it reads the binding, the rebind publishes the binding before it reads the count, both reach the count by read-modify-write, and finishing releases what the read acquires | loom_an_operation_admitted_under_a_superseded_binding_never_outlives_its_rebind (src/runtime/state_store_loom_models.rs); fails when while admitted.fetch_add(0, Ordering::AcqRel) != 0 { weakened to while admitted.load(Ordering::Acquire) != 0 { or .fetch_sub(1, Ordering::Release) weakened to .fetch_sub(1, Ordering::Relaxed) |
runtime.client-ingestor.admission-fence | A client batch whose admission races a quiesce is either counted by the drain that follows the quiesce or refused before anything is dispatched under it: the admission tracks its root by read-modify-write before it reads the quiesce publication, the quiesce publishes before the drain reads the root counts, and the drain reads each count by read-modify-write, so one side observes the other | loom_a_client_batch_racing_a_quiesce_is_counted_by_its_drain_or_refused (src/runtime/client_ingestor_loom_models.rs); fails when self.outstanding.fetch_add(0, Ordering::AcqRel) weakened to self.outstanding.load(Ordering::Acquire) or self.ownership_handoff_outstanding.fetch_add(0, Ordering::AcqRel) weakened to self.ownership_handoff_outstanding.load(Ordering::Acquire) |
runtime.local-drain.moving-batch | A batch a node publishes into a relay while a drain observes the domain is never missing from the observation: the relay admits the batch before the node lets it go, the observation reads every relay’s admission sequence before and after the node’s count, and acquiring the node’s release makes the admission visible to the closing read | loom_a_batch_published_while_a_drain_observes_is_never_missing_from_it (src/runtime/local_drain_loom_models.rs); fails when self.admitted.load(Ordering::Acquire) weakened to self.admitted.load(Ordering::Relaxed) |
runtime.local-drain.taken-batch | A batch a consumer takes from a relay while a drain observes the domain is never missing from the observation: the consumer’s node counts the batch before the consumer returns the relay’s admission, the observation reads the relay before the node, and returning the admission releases the node’s count to the observation that acquires it | loom_a_batch_a_consumer_takes_while_a_drain_observes_is_never_missing_from_it (src/runtime/local_drain_loom_models.rs); fails when the consumer queue’s return of an admission is weakened from AcqRel to Relaxed |
runtime.local-drain.handed-on-batch | A batch a relay’s owner hands on to its consumer while a drain observes the domain is never missing from the observation: the consumer admits the batch before the owner hands it on, the observation reads the relay’s transit before its consumers’ queues, and handing on releases the consumer’s admission to the observation that acquires it | loom_a_batch_its_owner_hands_on_while_a_drain_observes_is_never_missing_from_it (src/runtime/local_drain_loom_models.rs); fails when self.handed_on.fetch_add(1, Ordering::Release); weakened to self.handed_on.fetch_add(1, Ordering::Relaxed); |
runtime.local-drain.flush-completion | A message a force flush resumed is admitted work to a drain observation that sees the flush complete: the participant resumes the message before it completes its obligation, the observation reads the obligations before the node’s work, and acquiring the completion makes the resumed message visible | loom_a_message_a_flush_resumed_is_seen_by_a_drain_that_sees_the_flush_complete (src/runtime/local_drain_loom_models.rs); fails when self.force_flushes.load(Ordering::Acquire) weakened to self.force_flushes.load(Ordering::Relaxed) |
runtime.kafka-offset-state.snapshot-revision | A Kafka offset snapshot stamped with a commit’s revision encodes that commit’s offset: the commit stores the partition’s offset before it advances the revision, the snapshot reads the revision before the offsets, and advancing releases what reading the revision acquires | loom_a_snapshot_carrying_a_commits_revision_carries_its_offset (src/runtime/kafka_offset_state.rs); fails when LsmSequence::advance is weakened from SeqCst to Relaxed |
server.backup.cut-includes-publication | A quiesced backup cut waits for every branch state publication that registered before the cut closed, then reads every write those publications completed: closing and registering are read-modify-writes of one word, a publication leaves with a release and the cut’s wait acquires | loom_a_cut_includes_every_registered_publication (src/runtime/backup_capture_fence.rs); fails when a publication’s leaving is weakened from Release to Relaxed |
server.backup.cut-generation | A branch state publisher that enters after a backup cut acquires the cut generation and sees the coordinator’s writes before it: reopening extends the closing release sequence, and a publisher’s registration acquires it | loom_a_publisher_acquires_the_cut_generation (src/runtime/backup_capture_fence.rs); fails when the registration is weakened from AcqRel to Release |
harness.loom.coroutine-stack | A model body and the participants it spawns run debug frames with allocation instrumentation on the coroutine stacks they request, while Loom’s coordinator only starts and joins the body | loom_model_coroutines_support_instrumented_debug_frames (crates/model-harness/src/loom.rs); fails when the stack request is cut to 4_096 |
The cancellation protocol these models check is the bounded executor’s. Cancellation::armed
creates both ends of one job’s cancellation: the executor keeps the obligation while its caller
awaits the job and moves the signal into the job, which checks it between its bounded units.
Dropping the awaiting caller drops the obligation and cancels the job, which keeps its memory charge
until it returns; observing the job’s value disarms the obligation first, so an ordinary completion
never reports itself as cancelled. The executor keeps admission, charges and cancellation policy;
the protocol it runs is the one the models explore.
SIMD Kernels
Nervix applies explicit SIMD to contiguous typed buffers where generated-instruction inspection and measurement justify it. The optimization includes the caller: decoding into Arrow builders, resolving a column once, retaining bitmaps, or folding a whole run removes the per-row structural work that would otherwise hide a faster arithmetic loop.
This chapter owns the kernel boundary, dispatch, result guarantees, caller catalog, qualification, and measured evidence. Data Plane owns batches, branches, persistence, and ACKs; VM Functions owns expression execution and window structures; Domain Clock owns time and admission; Data-Plane Concurrency owns synchronization and execution modes. Schemas And Codecs, Syslog, and Connector Crates And The Connector Contract retain their wire contracts.
Ownership And The Admission Rule
nervix-simd-kernels, in crates/simd-kernels, belongs to the primitives layer, below the
vocabulary. It owns portable operations on slices, offsets, validity bytes, and bitmask words.
It depends on fearless_simd, simdutf8, pointer-width conversions, and self-contained error
values. It must not know Arrow, Models, schemas, domain clocks, codecs, metric recorders,
connectors, branches, or execution graphs. A nanosecond buffer is an integer buffer here; the
caller gives those integers their meaning.
A new explicit kernel needs all three:
- Inspection of the current caller’s optimized code shows scalar or lane-extracting work. A source loop, a batch API, or an operation’s name is insufficient evidence.
- Every supported level and the forced fallback reproduce the complete scalar result, including failure bits, null masking at the caller, and unused tail bits. Integer, bitmap, and byte operations are exact. Window floating reductions follow the run contract below.
- The caller supplies a typed contiguous slice or bitmap. Per-row boxing, field lookup, virtual type selection, or reconstruction from values already held in columns must be resolved first.
Kernel ownership does not grant clock, scheduling, synchronization, or allocation policy. The caller retains its bounded-executor admission, cancellation between bounded units, memory charges, and branch-local state. A kernel neither reads a clock nor starts a task. Its output is transient values and masks, never a new persisted execution representation.
The repository’s cast denial, checked arithmetic, typed errors, ownership headers, and debt ratchets apply here. Source-owned compiler diagnostics and narrow reason-bearing expectations govern architecture rules; a generated report or an approval count grants no exception.
Dispatch And Build Targets
The crate caches one fearless_simd::Level::new() result in a process-wide OnceLock from
nervix_primitives::unmodeled::sync. The exact-file permission explains why CPU capability
detection may outlive individual model executions. It carries no protocol state or ordering that
a concurrency check relies on. Later calls reuse that level; short byte searches can return before
resolving it at all.
dispatch! enters the selected target-feature context. Small #[inline(always)] functions
generic over S: Simd perform the buffer loop there, using simd.vectorize where required.
The dispatch expression returns values or masks. Fallible validation and contextual errors stay
outside it; a dispatch body does not use ? or return from its enclosing caller. Callers name
operations and types, and do not choose an instruction set or use std::arch themselves.
| Level | Where it is available | Selection contract |
|---|---|---|
| SSE2 | x86 and x86-64 | The supported x86 baseline where no higher level is selected |
| SSE4.2 | x86 and x86-64 | Requires the level’s complete feature set, including its auxiliary features |
| AVX2 | x86 and x86-64 | Includes the associated features required by fearless_simd, rather than testing AVX2 alone |
| AVX-512 | x86 and x86-64 | The complete Ice Lake class feature set; AVX-512F alone is insufficient |
| NEON | AArch64 | Selected from the target’s NEON capability |
| WASM SIMD | wasm32 built with simd128 | A build capability; browser feature detection does not dynamically enable it inside an already compiled module |
| Fallback | Targets without a supported SIMD level, and explicit tests | Portable scalar implementation; LLVM may still auto-vectorize it for the ambient build target |
The workspace enables force_support_fallback so tests can explicitly request
Level::fallback() even on SIMD hosts. This makes the fallback implementation reachable; it
does not make an AVX2 binary runnable on a CPU below that binary’s baseline.
| Build | What the payload guarantees | What runtime selection adds |
|---|---|---|
| Debian x86-64 release image | Dockerfile.debian defaults cargo-sonic payloads to x86-64-v3, including AVX2 | The same payload can select the Ice Lake class AVX-512 arm on a supporting host |
| AArch64 release image | The AArch64 target and its NEON capability | NEON execution; x86 levels do not exist in this build |
Local target-cpu=native build | Features of the machine used to compile it | Selection cannot lower that ambient baseline; a native AVX-512 build can dispatch lower x86 tokens through its AVX-512 backend |
| Untuned local build | The compiler target’s baseline | Host-supported higher levels through multiversion dispatch |
| WASM build | SIMD only when simd128 is enabled | Selection within that module’s compiled capabilities; no claim that the browser console invokes these server kernels |
The effective compiler flags matter. A native build’s nominal SSE or AVX2 test token does not
prove that its lower backend executed. The production-target recipes build separate
x86-64-v3 artifacts, allowing an AVX-512 host to exercise AVX2 as well as its higher runtime arm.
SSE and non-x86 qualification require builds and hosts that expose those backends. Current
instruction and timing evidence below is x86 evidence; it establishes no AArch64 or WASM timing.
Results, Failures, And Bounds
| Result family | Required guarantee |
|---|---|
| Integer arithmetic and reductions | Exact values and failure classification; integer window sums accumulate in i128 and check the declared result width at emission |
| Bitmaps | Bit zero names the first lane or byte; unused high bits in the last word or byte are clear |
| Byte classification and encoding | Exact member positions and masks, and byte-identical wire output under the owning codec’s contract |
| Timestamp admission and latency | Exact inclusive admission and histogram bucket decisions over Arrow nanoseconds, including the full signed timestamp range |
| Window float sums and moments | Per-lane compensated sums and centered moments merged per run; run partitioning and addition order may affect rounding |
| VM float arithmetic | Existing scalar/compiler-vectorized arithmetic and non-finite rules; packing failure bytes does not change the arithmetic result |
Checked arithmetic returns the wrapped lane value together with its failure word, matching
overflowing_*. The VM intersects failures with operand validity, zeroes failed values, makes
them null, and constructs errors only for set bits. Null lanes cannot produce a message error,
including when their underlying value buffers contain overflowing operands. Scalar-null operands
produce an all-null result. Sliced arrays retain their value span and bitmap offset.
Integer sums, counts, comparisons, byte classes, and masks require exact scalar equality.
Floating window admission uses eight compensated accumulation lanes, two-pass centered moments,
and one Chan merge per run. This is a typed run algorithm, not a claim that every float loop has
an explicit fearless_simd backend. Different batch/run partitions can round differently under
the public window contract; changing VM float association is outside this boundary. Retraction
refolds survivors rather than subtracting floating statistics. Window ownership, snapshot restore,
and branch isolation remain defined by
Window Aggregates And Sketches.
When a sum of finite operands overflows, the mean is computed from compensated operands divided
by their largest contributing magnitude, then rescaled. Clamping the normalized mean to [-1, 1]
keeps it finite at the representable limit. Constant finite runs retain their exact mean and zero
centered moments in this path; ordinary finite sums keep the existing compensated division.
Output storage scales with the input run, or with a validated fixed histogram layout. Local
padding, lane reductions, and block masks have bounded sizes. Tails are masked before publication;
a padding value is never an extra admitted row or failure. Byte scanning uses overlapping loads
inside the existing buffer rather than allocating padded tails. Unsafe conversion is confined to
XmlChars::into_string: the spans accepted by simdutf8 partition the complete buffer, so joining
them preserves UTF-8 and permits String::from_utf8_unchecked. Character rejection remains a
typed result, and invalid UTF-8 takes precedence over an excluded XML character.
Kernel And Caller Catalog
The catalog includes library-dispatched operations and typed run algorithms so their contribution is not mistaken for an explicit vector kernel. The callers retain schema validation, sensitivity, route errors, ACK attribution, and branch ownership.
| Operation | Buffer API or library | Caller and retained work |
|---|---|---|
| Live schemaful JSON decode | simd-json borrowed values, reusable Buffers, pre-resolved KnownKey | The open ingest group owns writable payload scratch and parser buffers; connector, endpoint, ingest-group, and lookup-file decoding append directly to typed Arrow builders |
| Columnar JSON encode | JsonEscapeClassification over string values and Arrow-style offsets | nervix-columnar-json prepares typed readers per batch and pre-escaped keys per schema; runtime wire codecs and ClickHouse JSONEachRow share the writer |
| Delivery latency and latest watermark | ElapsedHistogram, ElapsedLayout, latest_instant, elapsed_nanos over &[i64] | Relay delivery observations feed retained metrics handles at processor, reingestor, and emitter inputs; HDR bucket scatter and Prometheus local recording remain scalar |
| Paced-domain admission | AdmissionKernel::admit returns packed bytes | DomainAdmissionWindow::admit_column supplies reached centers and period; ingest groups select Arrow rows, timestamp metadata, and ACK sidecars together |
| Window admission and retraction | RunValidity, signed/unsigned sums, boolean counts, non-finite masks, compensated sums, moments, co-moments, bucket indexes, typed reverse visits | ArgumentColumn resolves type and validity once per run; branch-owned accumulators admit, retract, and rebuild retained rows; deque updates, scatter, and sketches remain irregular |
| VM failure packing | FlagPacker::pack, WORD_LANES, lane_mask | numeric::Lanes computes blocks of up to 1,024 lanes and packs one failure byte per lane; explicit arithmetic kernels already return failure words |
| Selection bitmaps | Arrow BooleanBuffer operations, collect_bool, set_indices, filter/take | VM predicates exclude null and failed rows; runtime selection and ingest branch masks retain packed form; required-output validation ORs inverted validity buffers |
| Checked integer arithmetic | CheckedArithmetic, LaneOperands, CheckedLanes | VM integer operators pass two runs or a shared operand; every signed/unsigned width has checked sums and differences, with widened products below 64 bits |
| Constant integer division | UnsignedDivisor, SignedDivisor, ConstantDivision | VM column-by-scalar / and % and fixed-unit datetime operations prepare the divisor once; measured 64-bit operations retain scalar reciprocal paths |
| SYSLOG header and structured-data scanning | ByteClass::first_in, ByteScanner | Compiled SYSLOG fields retain names and Arrow types; byte classes find header refusals and structured-data syntax before scalar parsing at those positions |
| Syslog TCP framing | memchr, retained read cursor, typed FrameProgress | The connector retains parsed octet counts and searches newly read bytes of split lines, with one compaction per read |
| SQS XML character admission | XmlChars::into_string and XmlChars::admits, with simdutf8 | The SQS sink validates bodies and string attributes; its integration owns size limits and reporting |
| OTEL trace/span identifiers | faster-hex | The OTEL connector decodes fixed-length nonzero hexadecimal IDs; otel-hex-ids compares every byte and each typed refusal with a per-digit scalar reference in ordinary and sanitizer execution |
JSON Boundaries
Decode copies a borrowed payload into reusable writable scratch because simd-json mutates its
input. It builds no intermediate serde JSON tree or materialized row map. Schema compilation
resolves field keys and Arrow types; datetime text is parsed once and base64 chunks append to the
binary builder. Exact integer shape/range, strict unknown-key rejection, nullability, invalid
UTF-8, and document limits remain codec decisions. Workspace serde_json::preserve_order remains
for JAQ’s public object-member-order contract, independently of schemaful decoding.
Encode classifies quotes, backslashes, and controls below 0x20 over each string values buffer
in 64-byte blocks, then summarizes offset pairs into row masks. Non-ASCII UTF-8 remains verbatim.
Clean strings copy directly; marked rows enter escaping. Numbers use itoa/ryu; datetime and base64
text write without another escape scan. Sensitivity redaction, nested values, ClickHouse’s widened
F32 rendering, bounded-write refusal, and frozen retry bytes remain the owning encoder’s contract.
The shared byte block classifier also implements the JSON escape class.
Latency And Domain Admission
Relay metadata carries low/high ingestion watermarks as Arrow-compatible nanosecond buffers. The delivery fold computes the latest watermark, excludes timestamps after the acceptance instant, rounds elapsed time to milliseconds with halves up, clamps at 30 seconds, and counts HDR buckets. The histogram layout validates the bounds under which floating lane division reproduces the integer rounding exactly. Scalar bucket scatter is intentional. Each retained series merges the fold into its four rolling windows under one lock with one wall-clock read, then flushes its local Prometheus histogram once. The domain acceptance instant and wall-clock window maintenance remain separate clock responsibilities.
Admission uses the first and last reached centers with inclusive skew. Ordered signed timestamp
differences become exact unsigned distances, including across the signed extrema. Interior rows
test their distance to the nearest period boundary. A window with no gaps needs no remainder;
powers of two use masks; other periods prepare a floor reciprocal and correct its estimate once.
The single-timestamp vocabulary check has the same semantics. A rejected or missing timestamp
affects its row alone, with the existing validation/admit error and ACK attribution.
The kernel does not establish which centers are eligible; Admission Windows
defines that clock contract.
Window Runs And Bitmap Work
Validity readers carry a bitmap and its bit offset and count present rows by popcount. Boolean
counts combine value and validity words. Signed/unsigned 64-bit sums accumulate low and high
32-bit halves in vector lanes, periodically reducing them to exact i128 before lane overflow.
Smaller integer sums and generic min/max use typed scalar folds. Non-finite F32/F64 inputs
produce column bitmaps. Bucket-index calculation and subsequent histogram scatter remain typed
scalar work. Sketch admission reuses a run key buffer; BLAKE3, t-digest insertion, and candidate
updates are not turned into lane-wise kernels.
Failure packing compares failure bytes with zero and extracts masks per register. Valid-lane-only VM operations read validity by 64-lane words: empty words do no work, full words use the ordinary loop, and mixed words evaluate only present lanes. One packing call covers the block, with clear bits past its end. Explicit checked arithmetic bypasses the intermediate failure bytes entirely. Selections use bitmap operations and Arrow filtering; required output columns with no nulls take a fast path. These operations remove repeated per-row mask reconstruction, without changing which row is selected or reported as failed.
Checked Arithmetic And Constant Division
Sums and differences wrap at their own width. Signed operand/result signs determine overflow; unsigned carry and borrow comparisons determine it. Products of 8-, 16-, and 32-bit operands widen to twice the width, compare against the original bounds, and narrow by truncation. A 64-bit checked product remains scalar because no wider exact lane is provided.
For a nonzero non-power-of-two divisor d, preparation computes m = floor(2^64 / d) once.
Discarding the low 64-w bits yields floor(2^w / d) for width w. The high product gives a
quotient at most one below the exact quotient, and one remainder comparison corrects it.
Powers of two use a shift and mask. Signed division operates on unsigned magnitudes, including
MIN, before sign restoration. Truncating numeric division and Euclidean datetime division keep
their distinct rounding. Zero fails valid lanes; MIN / -1 fails, while the numeric remainder
contract keeps MIN % -1 equal to zero.
| Constant operation | Retained execution |
|---|---|
| Signed/unsigned 8-, 16-, 32-bit quotient and remainder | Explicit SIMD reciprocal lanes |
I64 quotient | Explicit SIMD multiply-high from four 32-bit partial products |
I64 remainder, U64 quotient and remainder | Scalar multiply-high and correction, selected by measurement |
Fixed-unit truncation/binning and to_unix | Prepared signed reciprocal in the datetime caller; scalar/compiler-vectorized loops |
Fixed-unit date_diff | Checked i64 subtraction and prepared truncating division; exact i128 subtraction/division when the difference exceeds i64, followed by a checked i64 result |
date_add, from_unix | Checked i128 multiplication/addition; from_unix has no division |
| Column divisor, including scalar-left/column-right | Existing checked scalar lane loop |
Binning normalizes the value and origin, corrects their phase difference by at most one stride,
and checks subtraction of the distance to the bin start. Calendar and zoned operations retain
their calendar rules. Divisor preparation may execute wide division once; selected reciprocal
lane bodies contain no integer division instruction. The exact wide date_diff fallback still
divides in i128.
Byte Classes And Text Codecs
A ByteClass supplies an ordinary inclusive byte range, excluded bytes outside it, and listed
bytes inside it. A byte is a member when it lies outside the range without being excluded, or
is listed. Constants become splatted vector comparisons. Each excluded/listed list is bounded
by MAX_NAMED_BYTES at compile time; supported examples and a paired compile_fail doctest
exercise that current API restriction.
first_in stops at the first block holding a member. ByteScanner retains the current block’s
mask for successive forward positions, classifying each block at most once. A final partial block
uses the last 64 bytes of a sufficiently long buffer and shifts away the overlap. Buffers shorter
than a block use 16-byte registers and an overlapping final load. first_in below 16 bytes uses the
scalar loop before resolving the level. No padded-tail copy is needed.
XmlChars checks the XML 1.0 Char ranges: tab, LF, CR, U+0020–U+D7FF, U+E000–U+FFFD, and
U+10000–U+10FFFF. The owned-byte path validates UTF-8 and classifies bounded 4 KiB spans,
carrying sequences across classification boundaries and adjusting validation boundaries to
character starts. Once a character is excluded it still validates the remaining bytes, because
invalid UTF-8 outranks character rejection. An already valid &str needs only character
classification. Syslog framing’s memchr, UTF-8 validation’s simdutf8, and OTEL’s faster-hex choose
their own SIMD; the host adds no competing dispatch layer for their internals.
Qualification
Tests compare every level available to their build/host, the baseline, and the forced fallback with a complete scalar oracle. They cover empty runs, short inputs, every tail length, word/block boundaries, sliced validity, nulls, shared operands, signed extrema, and unused bits. Byte arithmetic and division include exhaustive operand pairs. Float-window tests check the documented run algorithm and numerical bounds; they do not assert bit equality across different partitions.
Use the repository recipes and retain its kache compiler wrapper:
just test-simd-kernels
just test-vm
just test-bolero simd-
just build-simd-kernels-x86-64-v3
just build-vm-bench-x86-64-v3
Registered Bolero targets use the same production path and complete assertion in ordinary
randomized/corpus runs and sanitizer-backed libFuzzer. Current targets include
simd-checked-lanes, simd-constant-division, simd-byte-classes, simd-xml-chars,
simd-json-escapes, simd-paced-admission, simd-elapsed-histograms, simd-flag-packing, and
simd-window-runs and simd-window-moments; syslog-stream-framing, syslog-structured-data, and columnar-json-rows
cover the connector/codec consumers. Elapsed buckets are compared with HdrHistogram’s lowest
equivalent values, and float window sums with an exact sum within the compensated-summation bound.
The moment reference uses exact addition partials and fused-product residuals for runs of at most
128 finite values with exponents from −200 through 200. Counts and histogram placement are exact.
For ε = f64::EPSILON, n = max(count, 1), input absolute sum S, and reference mean μ, its
mean error bound is B = 4ε|μ| + [2(n + 16)ε]² S/n. The M2 bound is
16(n + 16)ε M2 + 4n B²; the cross-moment bound is 32(n + 16)ε Σ|centered products| + 4n B₁B₂.
Independent sliced null masks select paired contributions. Separate constant runs at 1e308,
-1e308, and f64::MAX check that overflow of an intermediate sum preserves the exact constant
mean and zero centered moments. Histogram cases check each edge, both clamps and null positions.
Every applicable property must remain registered in tests/bolero-targets.toml. Ordinary runs
remain required on PRs; sanitizer CI runs only for PRs labeled fuzz. Scheduled,
workflow_dispatch, and unlabeled PR runs expect a sanitizer skip. A skip is neither execution
nor coverage evidence. Explicit qualification uses just fuzz <target> <seconds> or
just fuzz-all <seconds> and records the actual inventory executed. See
Property Testing And Fuzzing.
Current Rust API restrictions use source-adjacent compile_fail doctests paired with compiling
examples of supported imports and signatures, run in normal tests and CI. Retain compiler-resolved
lint fixtures, feature/dependency/target checks, public scenarios, and model tests under their
owners. Use LLVM tools matching the producing compiler, as declared by its toolchain.
Public behavior retains Cucumber evidence on one and three nodes: exact JSON ingestion/emission, batch latency metrics, reached admission-window edges, interleaved branch window aggregates, sparse/dense/empty filters with row errors, checked numeric and datetime errors, split Syslog TCP/TLS frames, SQS noncharacters, and OTEL ingestion. Kernel equivalence does not establish ACK, branch, transport, or recovery semantics. Synchronization changes additionally need the evidence required by Data-Plane Concurrency; a pure buffer kernel introduces no claim about concurrent ordering.
Measurement And Recorded Evidence
Inspect optimized instructions for the actual payload target, using nm -C and objdump -d -C
on the binaries the production-target recipes build. Check every operand shape and width,
including tails and fallback. A source abstraction’s scalar implementation can be folded back
into vector instructions by LLVM; conversely, an early-exit loop can remain scalar even when
the target supports AVX2. Instruction evidence must name the revision, toolchain, flags, and host.
Criterion compares a kernel with the loop it replaces on identical operands in one process. Include scalar/shared operands, null/error density, short inputs, and divisor preparation and allocation where the caller pays them. Pass reference operations as inlinable closures: a function pointer per lane measures indirect-call overhead. A byte scan’s reference must preserve its early exit; removing that exit can let LLVM vectorize a different algorithm.
just bench-json-encode
just bench-relay-interaction
just bench-window-admission
just bench-checked-lanes-x86-64-v3
just bench-constant-division-x86-64-v3
just bench-byte-classes-x86-64-v3
just bench-syslog-framing
just bench-vm numeric_kernels
just bench-vm datetime_kernels
just benchmark-ab <baseline-ref> 3 <workload>
An application-throughput claim requires serialized same-host just benchmark-ab, exact output
parity, and equivalent configuration. Pin timing runs to one core on mixed-core hosts and
interleave baseline/candidate rounds. Report intervals and interference; a bounded backlog rate
does not establish maximum sustainable throughput. Store new raw logs, benchmark outputs,
disassembly, and validation reports on the corresponding ClickUp task, rather than committing
transient artifacts. The summaries below are architectural evidence, not fresh measurements.
| Delivery | Recorded evidence | What it establishes and limits |
|---|---|---|
| 01: live JSON decode | Decode measurements, 2026-09-26, i9-14900HX/AVX2 | Direct builders and library dispatch; same-host Kafka means changed +0.0% and −0.8%, within spread and at backlog caps, so no throughput gain is established |
| 02: columnar JSON encode | Encode measurements, 2026-09-28, i9-14900HX | AVX2 vpcmpeqb/vpmovmskb and AVX-512 mask instructions; 1,024-row writer 77.8 µs versus a cheaper direct-serde reference 74.0 µs; bounded Kafka mean +3.9% does not isolate the writer’s contribution |
| 03: batch latency | Latency measurements, 2026-09-28 | Vector timestamp arithmetic with scalar bucket scatter; final 1,024-row spread-watermark recording 322.9 → 30.66 µs, shared-watermark 415.5 → 7.896 µs; bounded processor A/B −0.7% establishes no application gain |
| 04: domain admission | Admission measurements, 2026-09-29, i9-14900HX | Exact scalar/level equivalence and public paced-window edges; noisy +8.0% mean came from an unpaced workload, measuring surrounding metadata/selection changes rather than paced SIMD arithmetic |
| 05: window runs | Window measurements, 2026-09-29 | AVX-512 integer sum adds/shifts; combined four-kernel 4,096-row fixture 30.696 µs, not isolated kernel throughput; bounded window A/B mean −0.04% is within spread |
| 06: packed failures and selections | Bitmap measurements, 2026-09-29, Ryzen AI 9 HX 370 | Packing’s per-lane shifts removed; AVX2 compare/movemask and AVX-512 mask extraction; 65,536-row arithmetic-filter program 465.08 → 423.05 µs; native and production-target checks distinguish dispatch arms |
| 07: checked integers | Arithmetic measurements, 2026-09-30, Ryzen AI 9 HX 370 | Signed sums 2.2–6.0× and narrow products 1.4–4.1× faster in same-process kernel comparisons; unsigned 8/16-bit sums at parity; VM rounds varied by up to 2.3× under shared load |
| 08: constant division | Division measurements, 2026-09-30, i9-14900HX/AVX2 | Exact reciprocal loops without per-lane idiv; measured vector/scalar selection, kernel ratios, and VM timings are distinct observations, with no end-to-end throughput measurement |
| 09: text scanning | Implementation evidence on that task, 2026-10-03, PR #600, rustc 1.99/LLVM 22, production target | Replaced early-exit loops were scalar; AVX2 byte compares/movemasks and AVX-512 masks, with no kernel tail memset/memcpy; short headers reach parity at 8 bytes, about 3× at 48 and 15× at 255; no Kafka A/B workload represents Syslog/SQS/OTEL |
The arithmetic inspection corrected the initial assumption about I64: every signed sum and
difference was a scalar flag-per-lane loop in the inspected x86-64-v3 VM, while unsigned sums
and differences were already vectorized. Own-width overflow checks also measured faster than
widening sums (native i8: 93 versus 161 ns; i32: 171 versus 247 ns per 1,024 lanes).
Unsigned operations now share the checked-arithmetic owner, with parity or a measured improvement;
their presence is not evidence that LLVM previously left them scalar.
Constant-division production-target experiments measured I64 quotient at 1,302 ns in vector
lanes versus 1,745 ns with scalar reciprocal, but U64 quotient at 856 versus 788 ns and
U64 remainder at 877 versus 797 ns. A repeated I64 remainder comparison measured 1,685 versus
1,534 ns. These 1,024-lane comparisons explain the retained scalar operations. Separately, VM
truncation/binning measured 20.005 → 14.348 µs, fixed elapsed arithmetic 9.546 → 7.329 µs, and
numeric constant programs about 26–68% less time. Combined to_unix/from_unix remained unchanged;
the latter is multiplication. Unchanged controls also moved under shared-host load, so these are
kernel/VM observations, not application-throughput guarantees.
Text-scanning qualification found that a padded-tail copy compiled into memset/memcpy and
made an 8-byte header 4.5× slower. The shipped overlapping-load and below-16-byte scalar paths
remove that cost. This is why every byte-kernel benchmark includes short inputs. Framer A/B used
one MiB of frames read eight KiB at a time, with interleaved pinned rounds against the prior
decoder; it remains connector-level evidence rather than a Kafka pipeline measurement.
Adding A Kernel And Scope Limits
- Resolve the typed caller and ownership first. Reuse Arrow or an existing dispatched library when it supplies the exact semantics. Inspect the current optimized loop before adding code.
- Write the full scalar contract, including bounds, signed arithmetic, failures, bitmap offsets, empty input, and tails. Establish a failing focused check; observable behavior also needs its public Cucumber scenario before product changes.
- Put the operation at the primitives boundary. Validate fallible inputs before dispatch, resolve the cached level, and return values/masks from the generic buffer body. Keep allocation proportional and local padding bounded; keep irregular scatter in its owning caller.
- Qualify every available backend and forced fallback, with appropriate baseline builds. Register differential properties in both Bolero modes and pair API-rejection doctests with supported examples. Record the limits of host/target coverage.
- Measure the complete caller and inspect generated instructions. Keep a scalar operation where it wins. Any end-to-end claim additionally needs same-host A/B. Publish raw evidence on the task and update this chapter and the caller’s authoritative chapter in the same change.
- Run focused checks and
just validate; lower ratchet baselines that fall. Keep public contracts and NSPL skill routing current when their surfaces change, then runjust book dev.
Do not add explicit kernels for operations LLVM already vectorizes without a measured caller benefit, or duplicate Arrow and library dispatch. Division by a column has no constant reciprocal and stays scalar. Checked 64-bit multiplication lacks a wider exact lane. VM floating arithmetic and platform-libm transcendental calls keep their current contracts. Branch keys, deduplicator and reorderer keys, lookups, correlator keys, and subscription sampling still contain irregular string/map/hash work: typed keys and a demonstrated buffer algorithm must precede SIMD there. No kernel may combine state across concrete branches, weaken schema/sensitivity checks, change clock authority, hide an error, or persist hot-path payloads.
VM Functions
Every NSPL expression runs through one expression VM. That covers a route’s SET, WHERE, and
INVOKE, a source or processor filter, a key or ordering expression, an emitter mapping, and a
window aggregate’s arguments. This chapter is the implementation reference for that VM and its
function catalog. It covers:
- how an expression Model becomes a compiled program
- how a program executes over an Arrow batch
- which kernels compute each function family, and how far each one vectorizes
- how window aggregates and sketches keep branch-local state
- how to add a function without weakening any of these contracts
Expression Functions owns every contract a user relies on: signatures, exact types, nulls, errors, limits, and measured costs. This chapter explains how the implementation delivers those contracts, and links to the reference instead of restating them. Four other chapters own neighbouring contracts:
- Data Plane: payload, persistence, and acknowledgement semantics
- Data-Plane Concurrency: the concurrency of branch tasks and published state
- Domain Clock: the execution snapshot that supplies every expression’s time
- Shutdown And Recovery: what a stopping node does with windows it has not emitted
Five rules hold throughout:
- Arrow batches are the only payload. A program reads Arrow columns and writes Arrow columns. No row is ever held as a map of fields. A value every row shares is held once, not copied into each row.
- Types are exact. The compiler never inserts a conversion, and every operation’s result type is decided when the program is compiled.
- A failure belongs to a row whenever it can. A kernel records which rows failed and why. Only a failure of the batch as a whole, such as a UDF trap, fails the batch.
- Time is an input. No kernel reads a clock. The current time arrives in the execution context from the caller’s domain execution snapshot.
- The VM decides nothing about the graph. It runs one program against the bindings its caller hands it, and knows nothing of relays, branches, schedules, or connectors.
Ownership By Layer
The predicate capability and its execution entry point keep paired supported and rejection
Rustdoc examples beside their APIs. just test-capability-docs runs them in normal tests and CI,
separately from execution and concurrency evidence.
| Layer | Owner | What it owns |
|---|---|---|
| Vocabulary | Expression Models in nervix-models | Expression, RouteConstruction, Assignment, Invocation, and JsonPath. A builtin name is a validated BuiltinFunctionName identifier, not a closed set, and a UDF name is a UdfName. |
| Language | nervix-nspl | Parsing a call as a generic name(args) or udf::name(args) into those Models. The grammar has no function-name table; completion emits a typed builtin or UDF expectation at a call position. |
| Engines and infrastructure | nervix-vm | Lowering Models into VM programs, the semantic catalog of every operator, cast, and builtin, type and sensitivity checking, compilation into instructions over typed registers, every kernel, window aggregate lowering and route compilation, and where an execution runs: inline, or admitted through the node’s bounded executor onto its data or extension workers. |
| Engines and infrastructure | nervix-roto | Compiling a typed UDF program on the node’s bulk workers, and the FunctionInjector that answers the VM’s UDF calls over Arrow arrays under a watchdog. |
| Decisions | Registry validation and resource planning | Compiling every expression it can check with the same compiler the runtime uses when a statement is applied, then selecting scheduled UDF programs and lowering generator and emitter routes, source filters, HTTP fields, ordering groups and sink mappings against their declared scopes. |
| Data plane | Runtime plan binding and hosts | Binding runtime programs against installed schemas once per typed node revision, projecting carrier batches into VM input, supplying the execution context and injectors, turning row errors into structured message errors, and owning branch-local window accumulators. |
| Control plane | Subscriptions | Compiling a session subscription’s WHERE into a read-only predicate when the subscription is created. |
The VM compiler, batch constructors, runtime, and FunctionInjector return error-stack
reports with their semantic CompileError or RuntimeError context. A compile error retains a
typed code, its existing stable code spelling through code(), the operation span, and a safe
message. Registry validation adds the owning model and route while retaining the VM report.
Runtime plan binding likewise adds its operation above the original VM report: a
RuntimeVmCompileError names the program and its node, such as a filter, a FILTER-MAP route, an
output branch construction or a generator output, and its caller names the domain above that. A
lowering, LOOKUP_HASH_MAP or materialized-state binding failure stays beneath the same program
context instead of being rendered into its text. Roto UDF setup
returns UdfError reports; its injected calls return VM runtime reports, retaining an underlying
Arrow failure when one caused the call to fail. Jaq compilation, evaluation, and format conversion
return their own typed reports to the codec or signaling caller. These reports are batch or setup
failures; selected-row execution and SideError values remain the row-failure channel.
The compiler failure-contract gate reads source-owned outcome classifications on
SideErrorReason, CIDR NetworkDefect and JSON JsonTargetDefect. These are row or pure type
conversion outcomes; their callers establish the operation report when a whole batch or setup
fails. Errors And Diagnostics owns those compiler contracts.
For ordinary expression completion, the session resolver asks FunctionName for the VM’s sorted
builtin spellings, including datetime names and accepted aliases. That list excludes injected
calls and write_header; it is a candidate catalog, while VM lowering and registry validation
still decide whether a call is valid for its arguments. The parser carries the source or sink kind
with header-function expectations. The resolver adds read_header and read_headers only for a
source that can read headers, and write_header only for a sink that can write them. UDF candidates
come from the selected domain’s Models after the session’s queued transaction changes.
Registry planning converts the validated scheduled Models into typed processor specifications. Before a node revision is installed, runtime plan binding lowers every processor expression against the installed schemas, branch declarations, lookups and UDF catalog. The complete map of bound processor plans is published with the domain routing snapshot. Processor supervisors and concrete branch tasks consume those plans and never read the active graph or a semantic Model.
The Expression Pipeline
Each representation is produced once, at its boundary, and the next stage consumes it:
flowchart LR
Text["NSPL text"] -->|parsed once| Model["Expression Models"]
Model -->|lowered once| Program["VM Program"]
Program -->|"types, sensitivity, folding, sharing, registers"| Compiled["CompiledProgram"]
Compiled -->|"executed per batch"| Result["ExecutionResult"]
Carrier["Carrier Arrow batch"] -->|projected| Input["TypedBatch"]
Input --> Result
Result -->|"rows, selection, row errors"| Host["Routes, message errors, ACKs"]
Models are persisted; programs are not. A compiled program and everything prepared inside it, such
as a compiled regular expression or an IN set, live in memory beside the runtime node that runs
it. Nothing prepared at compile time ever enters a stored Model, and no executable NSPL text is
stored for a runtime to parse again.
Lowering
crates/nervix-vm/src/frontend.rs converts Models once into the VM’s program vocabulary in
program.rs. The result is a Program with an optional filter, ordered set assignments, and
ordered invoke calls, each built from Expr trees. Each route kind has one lowering, and each
lowering fixes the scopes a bare field name reads and writes:
| Lowering | Used for | Bare reads and writes |
|---|---|---|
lower_transforming_route | Transforming routes, which begin empty and may INHERIT | read and write output |
lower_set_only_route | Set-only constructions, such as a set-only processor route or a materialized-state DEFAULT | write output only |
lower_generated_route | Generator routes | read generated, write output |
lower_branch_construction | BRANCHED BY ... SET | read and write branch |
lower_finalized_output_filter | A route WHERE over finalized output | read output only |
lower_route_construction | A construction under a scope policy its caller chooses, such as a correlator route, an emitter VALUES mapping, or an error record | set by the caller |
lower_expression | One standalone expression, such as a subscription WHERE | set by the caller |
Window routes have their own lowering in window.rs, described under
Window Aggregates And Sketches.
SemanticScopePolicy decides what a bare read or write means. A scope the route kind does not
expose is a typed FrontendError, never a fabricated name that later fails name resolution.
Lowering also removes surface forms the compiler does not need to know about:
| Surface form | Lowered to |
|---|---|
IF | CASE |
NOT IN, NOT BETWEEN | negated membership and range tests |
TRY_CAST | a cast whose failure yields a typed null |
INHERIT | plain assignments; a field inherited with LEAK SENSITIVE is wrapped in leak_sensitive |
udf::name(...) | FunctionName::Udf |
Lowering resolves datetime builtins further than other calls, because their literal arguments
choose the kernel. It resolves literal units, time zones, formats, and bin widths once into a
DatetimeFunction, and the call keeps only its row-valued arguments. Every other name is parsed,
case-insensitively, into the closed FunctionName enum. An alias such as ceiling, power, or
substring maps to the same variant as ceil, pow, or substr, and messages always use the
canonical name. A name that matches nothing becomes FunctionName::Unknown, which the compiler
rejects.
A span in the VM is an operation ordinal, not a source offset: the i-th assignment, the route
WHERE, or the j-th INVOKE. Every node inside one operation shares that operation’s span. The
runtime maps a span back to the node, route, operation, operation index, and fields that a
structured message error reports.
The VM never parses NSPL in production code. nervix-nspl is a development dependency that only
its tests and benchmarks use to build Models from text.
The Semantic Catalog
crates/nervix-vm/src/semantics.rs is the one catalog of every operation’s meaning. Registration
is a set of exhaustive match expressions over closed enums, not a runtime table, so a builtin that
misses a decision does not compile. For each builtin the catalog and its neighbours decide:
| Decision | Where | What it records |
|---|---|---|
| Name | FunctionName::parse and as_str in program.rs | The canonical name and its aliases. |
| Lowering | builtin_descriptor, producing a BuiltinLowering | The operation the runtime executes. Some variants carry values prepared at compile time: a regular-expression call, an IN set, an Aho-Corasick matcher, a constant network, or a resolved datetime function. |
| Semantics | builtin_semantics_for_lowering, producing OperationSemantics | Volatility (Immutable, Stable, Volatile), dependency scope (Constant, ExecutionLocal, RowLocal), side effects, whether it can report a per-row error, and null propagation (NeverNull, Strict, Custom). |
| Signature | builtin_signature and builtin_output_type | Arity, exact argument types, and the result type. |
| Conditional arms | builtin_arm_execution, producing ArmExecution | Whether a conditional arm runs the kernel over the whole batch or only over the rows it selects. |
| Folding | fold_builtin_call in compiler.rs | A folding arm, or an explicit refusal. |
| Execution | execute_builtin in runtime.rs | The kernel call. It has no wildcard arm. |
| Optional result | Compiler::expr_may_be_null | Exceptions to “optional when any argument is”. |
ExpressionSemantics::from_operation combines an operation with its children bottom-up. It takes
the most volatile child, the widest dependency scope, and any child’s side effects or ability to
fail. Some calls have no descriptor: leak_sensitive, LOOKUP_HASH_MAP, the header functions,
window aggregates, and UDFs. The compiler handles those specially. For a tree that contains one,
expr_semantics returns no semantics, so the tree is neither folded nor shared through the builtin
path.
No builtin has side effects. The language’s one side effect, write_header, is an INVOKE
statement rather than an expression.
A value rule that both folding and execution apply is defined once in the catalog:
CaseMapping: full Unicode, locale-independentlowerandupperFloatClass: the floating-point classification behindis_nan,is_finite, andis_infiniteBitwiseOperationandIntegerBits: integer bit operations- the IP address and URL text checks
Execution may reach the same value by a faster route. lower and upper map a column whose
visible bytes are all ASCII in one pass over its buffer, for example, but must produce exactly the
value the contract defines. This is what keeps upper('grüßen') and upper(input.text) equal.
Types, Nullability, And Sensitivity
The compiler in compiler.rs infers every expression’s type from its input bindings and the
catalog. Argument types match by Arrow DataType equality. No numeric widening, parsing,
stringification, or datetime–text interchange happens unless the program spells it out as a cast,
TRY_CAST, or JSON_VALUE. Messages name types by their Arrow names, which the
reference maps back to NSPL names. A result is optional when
any argument is, unless the catalog or expr_may_be_null records an exception. The exceptions are
listed in Function Properties.
A program compiles against CompileBindings. Each binding has a namespace, a schema, the sensitive
fields of that schema, and whether it is readable and writable. With more than one binding, input
fields are qualified by namespace, such as left.id and right.id.
Sensitivity is a compile-time property:
- Propagation.
expr_is_sensitivemarks a result sensitive when any operand is sensitive. That includes aCASEcondition, because the arm a sensitive condition selects reveals it. - The only exemption.
leak_sensitive(...)is the only operation that removes sensitivity. It compiles to its argument and costs nothing at run time. - Where a leak is rejected. A
sensitive_leakerror rejects each of these:- a sensitive value assigned to a non-sensitive output field
- an inherited field that would pass sensitive data through
- a sensitive
write_headerargument
- The bypass.
CompileOptions::allow_sensitive_outputturns these checks off where the output never leaves Nervix. A predicate always compiles with it off. - Unbound sensitivity. A binding compiled without its sensitive fields enforces nothing. A caller whose output can leave Nervix must bind the sensitivity of every input it reads.
For direct emitter VALUES, the registry lowers each mapping through the same route frontend and
infers its type and sensitivity against the declared input schema. The inference result retains
sensitivity for each assigned field, including a field assigned more than once. The registry
rejects a sensitive mapping before activation unless its expression explicitly calls
leak_sensitive(...); the target column or attribute is identified without including its value.
A CompileError carries a stable code, a message, and its operation’s span. The codes fall into
these groups:
- binding and namespace errors, such as
unknown_identifierandwrong_stream - typing errors, such as
type_mismatchandunsupported_cast - function errors, such as
unknown_functionandinvalid_argument IN-set errors- output-contract errors, such as
missing_setandnull_for_required_field - invocation errors
sensitive_leak
Folding, Sharing, And Scalar Values
Three mechanisms avoid repeated work. None of them changes a result.
Constant folding. compile_expr folds an expression only when all of these hold:
- it is not volatile and its dependency scope is
Constant - it cannot fail and has no side effects
- the folder produces a non-null value
A folded value becomes one Literal instruction. The folder is deliberately small. It folds only:
- among builtins,
lower,upper,trim,length,coalesce,is_null,nullif,is_nan,is_finite,is_infinite, the bitwise functions,bit_count,contains,starts_with,ends_with,is_ip_address, andis_url - among binary operators, comparisons,
AND, andOR
Casts, arithmetic, membership, ranges, JSON, and every other builtin are never folded.
Folding applies the catalog’s value contracts rather than calling the runtime kernels, so a folded
call and an executed call agree by construction. trim and length are the exception: each has a
separate folding implementation beside its kernel. A new folding arm must reuse a catalog contract
rather than add a second implementation.
Three kinds of constant argument are prepared while the call is lowered: a regular-expression
pattern, a contains_any pattern list, and an ip_in_network network.
- An invalid constant pattern is kept, and every row that evaluates it reports the error.
- An invalid constant network is a compile error.
Sharing. Identical expressions share one register within one generation of the compiler’s
expression cache. The generation advances each time an assignment rebinds a writable field, and
each time an uninitialized field is materialized. So an expression is shared within one assignment,
and, after the last assignment, among the route WHERE and the invocations. The cache key ignores
spans. An expression is shared only when all of these hold:
- it has no side effects and cannot fail
- it is not volatile
- inside a conditional arm, it contains no injected call,
TRY_CAST, or JSON extraction, because those answer only the rows the arm selects and leave nulls on the others
A failing expression is compiled at each occurrence, so each occurrence reports its own error. A
UDF call follows its own rule: it is shared unless the UDF, or a UDF among its arguments, is
declared VOLATILE. Extractions from one JSON document share one scan for each document register
and selection, as the JSON entry of Function Families describes.
Scalar values. A register holds either a column or a scalar, which is a one-row array standing for every row:
- What is scalar. Literals are scalar. An instruction whose operands are all scalar and that
is not volatile is computed once, over one row. That covers an all-literal call that was not
folded, such as
clamp(5, 1, 3), andnow(), which has no operands. - Errors. When a scalar computation fails, as
1 / 0does, its row error is copied to every row that evaluates it. - Expansion. A scalar becomes a column only when a kernel or an output field needs one. It is expanded at most once per register and cached.
- Kernels that read scalars.
Operandimplements Arrow’sDatum, so Arrow comparison kernels, the checked numeric lanes, and most string and selection kernels take a scalar as it is.
Compiled Programs And Their Lifetime
CompiledProgram in ir.rs holds:
- its input and output schemas
- the bindings that load input columns into registers and read outputs from them
- the invocation bindings
- the instruction list
- the register layout of each register space
- the filter register
- an optional injector
Registers are typed. The types are:
- the thirteen scalar types: eight integer widths, two float widths,
Boolean,Utf8, andBinary Datetime, a nanosecond UTC timestampGeneric, for lists
The register spaces are Input, Temp, Condition, and Output. An Instruction carries its
kind, its span, and a selection register. It carries a selection only when its result depends on
which rows it runs for: an operation that can fail, an injected call, a JSON scan, or a cast that
reads or writes text.
Two optimization passes run after compilation:
- Dead instructions and moves. Instructions whose results are never read are removed, except volatile builtins and injected calls. Redundant moves are removed as well.
- Temporary registers. Temporaries are renumbered to reuse slots.
Input registers are never pruned: every readable field is bound, and binding one costs an Arc clone.
There is no separate constant pool. Literals are instructions, and prepared artifacts live inside
their BuiltinLowering behind shared pointers. Cloning a program therefore shares its prepared
patterns, sets, matchers, and pattern caches. CompiledPredicate wraps a program privately, so a
general construction program cannot be passed where only a read-only filter is allowed.
The VM has no notion of plan activation. The host holds each program as a shared
Arc<CompiledProgram>, the primitive boundary’s nervix_primitives::sync::Arc, inside a published
typed processor plan. A prepared artifact lives exactly as long as that plan. An unchanged scheduled
node reuses the exact plan allocation across a domain revision. A change to its topology, schema
fingerprint, resolved branch contract or processor specification binds a fresh plan before
publication. Existing branches adopt it by its typed revision identity between batches, while a
newly appearing branch starts from the same published allocation.
Where Programs Are Compiled
Registry validation compiles or type-infers expressions when a statement is applied and discards the result. The exceptions, which are first checked when their domain’s execution is built or a subscription is created, are listed in Where Expressions Run. The decision layer lowers the installed revision’s expressions into typed plans; node installation binds and compiles those programs against runtime schemas. Execution Plans describes the complete revision and its publication.
| Program | Compiled for execution |
|---|---|
Routes of junctions, deduplicators, reorderers, inferencers, correlators, window processors and WASM processors, and processor FROM ... WHERE and FILTER WHERE | Once when the installed typed processor revision is bound, before its domain routing snapshot is published. Every concrete branch shares the plan’s programs and prepared artifacts. A valid route that needs no VM program records that prepared absence and uses direct Arrow projection. |
DEDUPLICATE ON, reorderer BY, and CORRELATE WHERE | Once when the installed typed processor revision is bound, then shared by every concrete branch |
Ingestor FILTER WHERE, routes, and BRANCHED BY ... SET | Lowered once by the domain’s entrypoint plans, then bound when the ingestor starts, before its source opens. Every source instance and ingest group shares the bound programs. |
Reingestor FROM ... WHERE, FILTER WHERE, routes, and BRANCHED BY ... SET | Lowered once by the domain’s entrypoint plans, then bound for each input relay before that input’s task starts |
Emitter FROM ... WHERE, routes, HTTP request fields, SQS FIFO GROUP, VALUES, and OpenTelemetry mappings | Lowered once into the domain’s typed emitter execution plans, then bound against installed schemas and UDFs when the task starts or swaps. Reconnects reuse the bound programs. |
Window aggregate argument and output programs, inferencer INPUTS, and inferencer output routes | Once when the installed typed processor revision is bound, then shared by every concrete branch |
| Generator routes | Lowered into typed ordered route plans from the committed schedule, then compiled once when the domain’s execution is built or the generator is swapped; each concrete branch task retains those compiled programs and the exact materialized source branch. |
Materialized-state DEFAULT | Compiled and executed in one step when the default binds |
ON MESSAGE ERROR SEND TO ... SET | The registry lowers the ordered assignments before installation. The domain execution or its replacement binds the lowered program once. It reads the original eligible input, the exact captured state, the optional partial output and structured error fields for each failed record. A route update installs a new prepared program; delivery never compiles one. |
Subscription WHERE | When the subscription is created |
An HTTP emitter’s route compiles without its write_header invocations, which are request fields.
The emitter compiles its METHOD, PATH and header writes as one more program: METHOD and PATH
are assignments into an internal, write-only request namespace, so neither is an input of the
program, and the header writes are its invocations. It reads input as the original source record
and, with a codec, output and message as the finalized record; without a body message is the
source record. It runs once for each admitted batch over only the rows the route kept, with the
batch’s execution snapshot and materialized state, so a filtered record evaluates no request field,
and a volatile call such as uuid_v4() yields the value the retained request resends on every
retry.
The Runtime Bridge
The host builds a program’s input with project_vm_input_batch in src/runtime/vm_input.rs:
- Carrier columns. Each is an Arc clone of the carrier batch’s column, checked for its exact type. No buffer is copied.
- Built columns. Branch key fields, broadcast materialized-state values, lookup results, and
selected ingest metadata are built as new columns.
SharedVmInputColumnsbuilds each of them once per dispatched batch and shares it among every output route that reads it. - Uninitialized outputs. These stay
TypedArray::Uninitializeduntil finalization materializes them.
The host also builds an ExecutionContext. It carries the domain execution snapshot’s timestamp,
and optionally an injector for this execution alone, such as the header reader or a window’s
aggregate results.
execute_program_with_selection_in_context returns an ExecutionResult. It holds the output
batch, the selection that survived the program’s WHERE, and any write_header invocations. The
VM applies the WHERE itself, and always keeps a row that carries an error in the selection, so
the caller sees every failure. A caller must therefore check batch.errors() for each selected row
before treating that row as a result.
The kept rows are bitmap operations over the batch: the predicate’s values under its validity, where a null keeps no row, ORed with the bitmap of rows that carry an error when any row does. The selection is read from the set bits of that bitmap, and the same bitmap filters every output column and invocation argument.
src/runtime/filter_map.rs shows the complete handling:
- It acknowledges each row the
WHEREdropped. The kept rows are set in a bitmap directly from the selection, and the dropped rows are its clear bits. - It turns each row that carries an error into a structured message error. The error holds the stable reference, the code, the operation and its index, the fields, and the execution’s domain time, together with the partial output when the error route reads it.
- It rejects each row on which a required output field is uninitialized or null. The rows are found once per batch, as the OR of the inverted validity bitmaps of the required columns, and a batch whose required columns hold no null skips the bitmaps on their null counts. Only a rejected row names its fields.
- It passes every remaining row on as output. The rows it exports are set in a filter bitmap directly from their indices.
- A returned
RuntimeErrorfails the whole batch through the node’s general error handling. The host keeps its report beneath aPlannedGeneralErrorthat names the program by its clause and the step that failed, and hands back the acknowledgements of every message of the batch with it; the report is rendered only when the error policy reports the failure.
A branch construction program keeps the same split: a row whose branch SET recorded an error keeps
that SideError as its own typed outcome, which its route’s error handling formats only if it
reports the row, while a RuntimeError fails the whole batch.
Implementation Map
All paths are relative to the repository root.
| Path | Owns |
|---|---|
crates/nervix-vm/src/lib.rs | The public surface and the with_typed_registers! expansion over the scalar register types |
crates/nervix-vm/src/frontend.rs | Lowering Models into programs, scope policies, and frontend errors |
crates/nervix-vm/src/program.rs | Program, Expr, FunctionName, and resolved datetime calls |
crates/nervix-vm/src/semantics.rs | The semantic catalog: lowerings, semantics, signatures, arm execution, and shared value contracts |
crates/nervix-vm/src/compiler.rs | Type and sensitivity checking, folding, sharing, register allocation, instruction emission, and optimization |
crates/nervix-vm/src/ir.rs | CompiledProgram, CompiledPredicate, instructions, registers, and bindings |
crates/nervix-vm/src/runtime.rs | Execution, register banks, scalar sharing, conditional arms, the builtin dispatch, and the list, cast, and most string kernels |
crates/nervix-vm/src/batch.rs, operand.rs | Typed batches and arrays; column versus scalar operands and broadcasting |
crates/nervix-vm/src/error.rs | CompileError, RuntimeError, SideError, error codes, and sparse RowErrors |
crates/nervix-vm/src/numeric.rs and numeric/ | Checked numeric lanes, comparisons, math, bit operations, and decimal rounding |
crates/simd-kernels/src/flags.rs | Packing the checked lanes’ per-lane failure bytes into bitmap words at the selected SIMD level |
crates/simd-kernels/src/checked.rs, benches/checked_lanes.rs | Checked integer addition, subtraction and multiplication in vector registers at the selected SIMD level, returning each lane’s value together with the failure words, and their measurement beside the lane loop they replaced |
crates/simd-kernels/src/division.rs, division/lanes.rs, benches/constant_division.rs | Constant integer divisors prepared once per call, exact reciprocal quotient and remainder kernels, and comparisons with scalar reciprocal and checked division loops |
crates/nervix-vm/src/datetime.rs and datetime/ | Fixed-unit datetime kernels, calendar arithmetic, time zones, and formats |
crates/nervix-vm/src/text_column.rs | The bounded builder for STRING and BYTES values whose length an argument chooses |
crates/nervix-vm/src/text_search.rs, regexp.rs | Splitting, joining, LIKE, contains_any, NFC normalization, and regular expressions with their caches |
crates/nervix-vm/src/membership.rs, extremum.rs, count.rs | IN sets, greatest, least, and clamp, and integer counts read without narrowing |
crates/nervix-vm/src/bytes.rs | Encodings, hashes, and UTF-8 conversion of BYTES |
crates/nervix-vm/src/json.rs | Typed JSON extraction over one parse per document |
crates/nervix-vm/src/ip_address.rs, url_component.rs | IP addresses, CIDR networks, and URL components |
crates/nervix-vm/src/window.rs, window/route.rs | Lowering window assignments into aggregate demands, and compiling each route’s argument and output programs |
crates/nervix-vm/benches/ | The Criterion harness, workload shapes, and allocation probe |
crates/nervix-roto/src/lib.rs | The UDF injector and its watchdog |
src/registry/validation/ | Apply-time compilation, including window_route.rs and the sketch budget in processor/sketch.rs |
src/registry/entrypoint_plan.rs | Lowering ingestor and reingestor filters, routes, and branch constructions before a node binds them |
src/registry/message_error_plan.rs | Resolving the schemas, branch contracts and flush policies of DLQ routes and lowering their ordered assignments from scheduled Models |
src/runtime/entrypoint_routes.rs | Binding lowered ingestor and reingestor programs to a node’s schemas, state, lookups, and UDFs |
src/runtime/message_error_plan.rs | Binding each error-route VM program once to installed relay services and node-local capabilities |
src/runtime/vm_compile.rs | Runtime compilation and message-error sites |
src/runtime/vm_input.rs | Input projection, lookup key execution, and exporting selected output rows |
src/runtime/filter_map.rs | Program execution and result handling for routes and filters |
src/runtime/ingest_metadata.rs, lookup_hash_map.rs | The header injector and hash-map lookup calls |
src/runtime/message_error.rs | Structured message errors, the rows whose required outputs are missing, and execution of prepared error-record programs |
src/runtime/window_processor.rs, window_accumulator/, window_state.rs | Branch-local windows, their aggregate structures, and their snapshots |
benches/window_sketches.rs | The cost of admitting retained rows into pane sketches, from a new pane for every row to a thousand rows per pane |
src/runtime/window_archive.rs | A captured window as bounded archive Arrow groups, and the window snapshot rebuilt from them |
src/runtime/subscription_predicate.rs | Session subscription filters |
Window checkpoints nest two Arrow containers of the shared snapshot codec, the retained input rows and their argument columns, behind a header that counts the rows and both containers’ bytes. A window whose retained rows fit one 8 MiB snapshot section is encoded in memory under the window’s complete-payload admission; replica synchronization and ownership handoff use that resident encoding, which refuses a window whose rows exceed the whole bulk budget before it encodes a section. A larger window persists through the same container sealed in bounded pieces: each group of rows is sealed and staged on quota-owned disk as soon as it is encoded, the header is placed first once the groups are counted, and the pieces are concatenated one 64 KiB chunk at a time and published as a segmented checkpoint. Each bounded typed section of delayed histogram removals is encoded, staged and released before the next, so the removals never hold more than one section of the bulk budget. The two encodings are byte-identical for the same window. A backup restore seals an archived window the same way, one archived section at a time and a group’s input rows before its argument columns, keeping each archived Arrow section whenever its rows’ identities fit one identity record. A section whose sealing fails stages nothing and leaves the checkpoint as it was, so a restore the node refused only for room seals the same section again.
Retained input keys may repeat, and argument rows are unbranched. The relay restore boundary applies one-record-per-concrete-branch validation after this shared decoder; that relay invariant does not restrict retained window rows. A relay’s restored row views stay within its 8 MiB metadata ceiling, while a branch reopening its window charges the row views of every retained row to the relay memory class, so a window retaining more rows than that ceiling reopens after a restart or a restore. Relay snapshot transfers use quota-owned files independently of the window’s encoding.
Worked Example: clamp
This example traces one assignment from its declaration to its execution:
SET bounded = clamp(input.latency_ms, 0, input.budget_ms)
Here input.latency_ms and input.budget_ms are I64 fields and bounded is an I64 output
field.
-
Parse. The parser produces
Expression::Callwith the nameclampand three argument Models. It knows nothing aboutclamp. -
Lower.
clampis not a datetime name, soFunctionName::parseyieldsFunctionName::Clamp. The assignment is the route’s first operation, and every node in it carries that operation’s span. -
Catalog.
builtin_descriptormaps it toBuiltinLowering::Clamp. Its semantics:- immutable, with a constant dependency scope
- strict nulls
- it can fail, because a row’s low bound can exceed its high bound or be NaN
Its arm execution is
WholeBatch, because two comparison bitmaps and two selections cost less than narrowing a batch to the selected rows. -
Type check.
builtin_output_typerequires three arguments of one identical, ordered type (numeric,STRING, orDATETIME) and returns that type:- an
I32bound beside anI64value is atype_mismatch - a
BOOLvalue isunsupported_function
The result is optional when any argument is, and sensitive when any argument is.
- an
-
Compile. Because
clampcan fail, it is neither folded nor shared. The compiler emitsInstructionKind::Builtin { lowering: Clamp, inputs }. It writes the assignment with a fallback, so a row that fails leavesboundednull. Inside aCASEarm, the instruction also carries the arm’s selection register. The literal0stays a scalar register. -
Execute.
execute_builtincallsextremum::clamp:- Comparisons. For integers and floats it compares the value with each bound in one pass
over the value buffers, with IEEE 754 rules, so a NaN value is neither below nor above
anything. For
STRINGandDATETIMEit calls Arrow’sltandgt. - Selection. Two
zipselections pick the bound or the value for each row.nullifthen nulls every row that has a null argument or invalid bounds. - Errors. It returns bitmaps of the rows whose bounds are invalid. The runtime builds a
SideErrorwithInvalidClampBoundsonly for the set bits of those bitmaps, so a clean batch allocates no error at all.
- Comparisons. For integers and floats it compares the value with each bound in one pass
over the value buffers, with IEEE 754 rules, so a NaN value is neither below nor above
anything. For
-
Report. A failed row reaches the route’s
ON MESSAGE ERRORpolicy. Its error has the codeevaluation, the kindinvalid_argument, the operationset, and the operation index0. The runtime built its field set when it compiled the route: the assigned field and every field the expression reads, sorted asinput.budget_ms,input.latency_ms, andoutput.bounded.
The public contract is in Extrema. Unit tests are in
extremum_tests.rs, and the public qualification is the Greatest, least and clamp outline in
tests/features/runtime/membership_ranges_extrema.feature.
Batch Execution
Typed Batches And Registers
TypedBatch carries:
- its schema, held rather than rebuilt for each batch
- one
TypedArrayper field - its row count
- the row errors it arrived with
A TypedArray has one variant per scalar register type, plus these:
Datetime, a nanosecond UTC timestamp arrayGeneric, for listsUninitialized, for an output no assignment has written yet
Each NSPL type has one Arrow representation, and the VM executes over all of them:
| NSPL type | Arrow type |
|---|---|
U8, I8, U16, I16, U32, I32, U64, I64 | The matching unsigned or signed integer type |
F32, F64 | Float32, Float64 |
BOOL | Boolean |
STRING | Utf8 |
BYTES | Binary. Its values may be empty or hold any octets, and are never read as UTF-8 without an explicit conversion. |
DATETIME | Timestamp(Nanosecond, "+00:00"). RFC 3339 is only a wire representation of it. |
ARRAY<T, n>, VEC<T> | FixedSizeList and List of the element type |
Execution checks the schema once and then works through these steps:
- It allocates one register bank for the batch.
- It loads each input register with an Arc clone of its column. A sliced array keeps its offset, and kernels read only its visible span of values.
- It carries the batch’s incoming row errors forward.
- It runs the instructions in order.
- It reads each output register, expanding a scalar into a column at this point.
- For a program with a
WHERE, it filters the output columns, the invocation arguments, and the row errors together.
Route construction is one ordered program, and it executes as follows:
- Inherited fields. An inherited field that no assignment changes is bound straight to its input register, so the output column is the input column itself.
- Assignments. Assignments run in written order. A repeated target rebinds the field to the later value’s register, so it replaces the current output column. A later assignment that reads the field reads the value it holds at that point.
- Finalization. Each field is then finalized:
- Uninitialized optional outputs materialize as typed nulls.
- A required output that is still uninitialized, or holds a null, fails. The compiler rejects both cases statically wherever it can prove them.
- Route
WHERE. The route’sWHEREthen reads the finalizedoutput.
This is the implementation of The Working Message, which owns the scopes and their edge cases.
Conditional Arms And Selected Rows
The compiler compiles each CASE arm under a selection:
- An arm’s condition is compiled as
coalesce(condition, false), over the rows that no earlier arm answered. - Its result is compiled over the rows its condition selected.
- A nested arm narrows its parent’s selection with
AND.
An instruction observes its arm’s selection only when its result on a selected row depends on which rows it runs for:
- an operation that can report a per-row error
- an injected call
- a JSON scan
- a cast that reads or writes text
Every other instruction runs over the whole batch, even inside an arm. Its value on an unselected
row is discarded, and nothing else it does is observable. This covers every function that cannot
fail, such as lower, replace, date_part, or format_datetime.
For an instruction that does observe the selection, an ArmSelection reads the selection mask
once. It classifies the mask as all rows, no rows, or some rows, and every instruction under the
same mask reuses that classification. The runtime then chooses how to execute each instruction:
| Case | Plan |
|---|---|
| The arm selects no row | Write a typed null without running the kernel. |
| Every operand is scalar and the instruction is not volatile | Compute one row, and copy its errors to the selected rows. |
WholeBatch | Run the kernel over the whole batch, then discard the errors it reported for unselected rows by truncating each row’s error list back to its earlier length. |
SelectedRows | Narrow each column operand to the selected rows with one Arrow filter, run the kernel over those rows only, and scatter the result back with take. Unselected rows get nulls, and errors are re-indexed onto the batch rows. |
execute_select then merges the arms from last to first with Arrow zip. It skips an arm that
selected no row and takes an arm that selected every row as it is.
The catalog’s ArmExecution makes the cost decision for each fallible builtin. Narrowing costs a
few nanoseconds for each row of the batch, so it pays only for kernels that cost more per row:
-
SelectedRows:- regular expressions,
LIKE,contains_any,split,join, andconcat_ws - NFC normalization
- the encodings and decodings,
sha256, and UTF-8 conversion - the transcendental functions
uuid_v7- reading and writing IP address text, and URL parsing
date_trunc,date_add,date_diff, andparse_datetime- casts that read or write text
repeat,lpad, andrpadmust useSelectedRows, and not only for cost. Every row’s text shares one column’s size limit, so text built for an unselected row could exhaust that limit and fail a selected row. - regular expressions,
-
WholeBatch: checked arithmetic,clamp, bit shifts,date_bin,from_unix, and non-text casts, which are vectorized kernels.
An injected call, whether a header read, a window aggregate, or a UDF, receives only the narrowed arguments, the batch indices of the selected rows, and the errors those rows already carry. It is not called at all when its arm selects no row. A JSON scan parses only the selected documents.
AND, OR, and coalesce compile every operand under the same selection. They run Arrow’s
Kleene boolean and zip kernels over whole columns. Only conditional arms narrow evaluation.
Row Errors And Batch Errors
A per-row failure is a SideError: a typed SideErrorReason and the operation’s span. Each reason
maps to one stable ErrorCode:
division_by_zerooverflowcast_failedinvalid_argument
The reason stays typed until the host reports it, so its text is formatted only for a row that
actually failed. RowErrors is sparse. A batch without failures allocates nothing, and
push_failures builds reasons only for the set bits of a kernel’s failure bitmap. Reasons name the
operation, the target type, or a position or length, never the value.
There are two exceptions:
- A regular-expression syntax error carries the regex crate’s message, which quotes the pattern.
A pattern read from a field can therefore appear in
error.message, as Regular Expressions states. - An injected call’s error text is chosen by the injector.
A failure that belongs to the batch is a RuntimeError, returned as Err:
- a schema that does not match the program
- an Arrow kernel error
- a collection larger than Arrow can address
- an execution the bounded executor did not admit, or that panicked on its worker
- a formatted datetime column larger than its offsets allow
- an invalid injected result, such as the wrong type or row count
Not every caller of the VM has an error route. The Errors section of the reference says what each context does with a row error.
Prepared Patterns, Sets, And Matchers
| Artifact | Prepared | Held | Bound |
|---|---|---|---|
| Constant regular expression | Once, when the call is lowered (ConstantPattern) | Inside the program; one search cache is borrowed from the regex’s pool for each batch, so no row takes a lock | 10 MiB compiled size, 2 MiB lazy-DFA search cache |
| Pattern read from a field | On a miss, on the executing thread | DynamicPatterns, one cache per call site per program, shared by clones and across batches. Each batch loads one ArcSwap snapshot and publishes a miss copy-on-write. | 64 patterns, oldest compiled evicted first; rows that repeat the previous row’s pattern skip the lookup |
Constant contains_any list | Once, as an Aho-Corasick matcher | Inside the program | 128 patterns and 64 KiB; over that, each non-null row fails |
Per-row contains_any set | On a miss, for each batch | A per-call cache for one batch; a hit borrows the Arrow strings without allocating | 64 sets per batch |
IN set | Once, from evaluated constant elements | MembershipSet | Up to 8 fixed-width values compared in turn; larger fixed-width sets and every STRING set hashed |
| Constant CIDR network | Once, when the call is lowered | Inside the program | A per-row network is reparsed only when its text differs from the previous row’s |
| Datetime zone and format | Once, when the call is lowered | Inside the resolved call | A 256-byte longest formatted value, checked at compile time |
LOOKUP_HASH_MAP is not a VM function at run time. The runtime rewrites each lookup into an
internal input field, and runs the key expression as its own program before the route’s program.
Identical lookups of one program are answered once, and the answer enters the program as an input
column. A key expression that fails fails the batch. The map itself is the resource version the
graph pins; see Lookups and Resource Versions And
Bindings.
Allocation And Result Bounds
Registers are allocated for each execution and are not reused across batches. Kernels reserve their output from the input where the size is known:
- string builders sized from the input’s offsets and values
- 32 bytes per row for
md5 - numeric lanes sized up front
- one scratch string reused across the rows of a batch
The per-row allocations that remain belong to functions whose output is irregular:
- the Unicode path of
lowerandupper repeatuuid_v4’s text- list
containsandoverlap, which slice each row - the error copied to each row of a failed shared value
The limits users see are enforced in these places:
- Argument-sized text. A value whose length an argument chooses is built through
ByteColumnBuilderintext_column.rs. It sizes each value before allocating it and refuses one that would take the column past 2,147,483,647 bytes. A shared value is charged once for each row it stands for. A refused value fails its row withoverflow. - Text built without that check.
concat,replace,regexp_replace,translate,lower,upper, andinitcapuse Arrow’s ordinary builders, so no row reportsoverflowfor them. Arrow’s builder does not return an error when its 32-bit offsets overflow: it panics. The reference’s Result Size asks users to keep these results within the column limit for that reason. - Other bounds.
splitstops at 65,536 parts,LIKEpatterns at 4 KiB, JSON documents at 16 MiB and 128 levels, andformat_datetimeat its preallocated column. Lists that Arrow cannot address fail the batch withCollectionTooLarge.
Scheduling And Execution Bounds
The VM’s entry point alone decides where a program runs:
| Condition | Where it runs |
|---|---|
At most INLINE_ROW_LIMIT (1,024) rows, and no injected function whose FunctionExecutionPolicy is Extension | Inline, on the caller’s task |
| More than 1,024 rows, and no such function | On the node’s data workers, admitted through the bounded executor |
Any Inject instruction whose injector’s FunctionExecutionPolicy is Extension | On the node’s extension workers, admitted through the bounded executor, whatever the batch size. Every UDF call does this. |
A caller hands the entry point the node’s Executor and awaits the result; it chooses neither
the class nor the charge. An admitted execution is charged to the relay memory class for the bytes
the batch’s columns hold, the usual order of what the program builds from them, and never more
than one relay batch may decode into. A batch Arrow cannot measure is charged that bound. A UDF
runs operator-supplied native code the node cannot bound, so its executions take the extension
class: one that never returns holds an extension worker and leaves the data workers, which relay
bodies are encoded and decoded on, their whole capacity.
Admission can refuse. A class whose wait queue is full refuses the execution with
RuntimeError::ExecutionNotAdmitted, and a job that panics on its worker returns
RuntimeError::ExecutionPanicked. Both are batch errors, which reach the node’s error policy as
the report of the planned step that ran the program, like any other batch failure.
An inline execution never yields and has no cancellation point: it runs every instruction over
its batch to completion, and the batch’s size and the limits above bound that work. An admitted
execution checks between its instructions whether its caller stopped waiting, and returns
RuntimeError::ExecutionCancelled at the next instruction if it did, so it costs at most the
instruction already running. It keeps its relay charge until it actually exits. Stopping a node
or a processor otherwise takes effect between batches. Host loops call consume_budget once per
batch iteration, not the VM. A UDF adds its own watchdog, described
below.
Kernels
SIMD Kernels owns the primitives boundary, dispatch and build targets, caller catalog, exact buffer results, scalar/level qualification, and measured evidence. This chapter owns how the VM selects those operations, masks nulls and failures, and reports errors.
Kernel Classes
Every kernel falls into one of five classes. The class decides how its cost scales and which claim about vector instructions it supports.
| Class | Examples | Claim |
|---|---|---|
| Arrow compute kernel | Boolean logic, STRING/BYTES/DATETIME comparisons, LIKE/ILIKE, contains/starts_with/ends_with, casts, CASE/coalesce/nullif selection, filter/take/zip/interleave, UTC date_part, bitwise_and/or/xor, list sum per row | Whatever Arrow 58.4’s kernels do; Nervix adds none of its own |
| Explicit SIMD lanes | Checked + and - over I8, U8, I16, U16, I32, U32, I64, and U64, checked * below 64 bits, integer column-by-scalar / and % below 64 bits, and I64 column-by-scalar / | nervix-simd-kernels selects the vector instructions once per process, with a scalar fallback, and every level computes the same lanes; the SIMD kernels 07 report and 08 report inspect the generated code |
| One pass over value buffers | Integer division by a column, U64 column-by-scalar / and % and I64 column-by-scalar % with a prepared reciprocal, checked * over I64 and U64, checked float arithmetic, numeric comparisons, fixed-width IN, abs/sign/negation, ceil/floor/round/trunc, sqrt, shifts, classification, fixed-unit date_trunc/date_bin/date_add/date_diff, to_unix/from_unix, length/octet_length/bit_length, ASCII lower/upper, list count | Written so LLVM may auto-vectorize the loop for the target CPU; no claim that it does |
| Library with runtime SIMD dispatch | JSON structure (simd-json), base64 (base64-simd), hexadecimal (faster-hex), sha256 (sha2 with SHA-NI detection) | The library selects instructions at run time; xxh3_64 selects them when the binary is built |
| Irregular, per row | Transcendental math, round(value, digits), zoned and calendar datetimes, datetime formatting and parsing, Unicode case mapping and NFC, regular expressions, Aho-Corasick, substring and padding functions, md5, IP and URL parsing, JSON path walk and conversion, most list functions, UUIDs | Batch API with optimized substeps; scalar work per row |
Arrow Kernel Reuse
The VM reuses Arrow kernels from arrow-arith, arrow-ord, arrow-select, arrow-string, and
arrow-cast wherever their semantics are exactly NSPL’s. It deliberately avoids them in these
cases:
- Numeric comparisons. Arrow’s comparison kernels order floats by the IEEE 754 total order.
NSPL comparison is IEEE 754 equality and ordering, in which NaN equals nothing and the two zeros
are equal.
numeric::Comparisonevaluates every numeric comparison, integers included, over the value buffers.IS DISTINCT FROM,nullif, listcontains,IN,greatest,least, andclampapply the same float rules. - Arithmetic. Arrow’s wrapping or erroring arithmetic cannot report which rows failed. The checked lanes described below compute arithmetic instead.
- List
sumand fixed-width floatdot. These keep ordered accumulation, checked integer overflow, and a finiteness check, rather than Arrow’s wrapping sum. - Three casts. A float-to-
STRINGcast uses Rust formatting. ASTRING-to-DATETIMEcast parses RFC 3339 through chrono.BOOL–DATETIMEcasts always yield null. - UTC
date_part. It reads the column as a zoneless timestamp, so Arrow does not convert every lane through an offset.
List min and max are the one place that intentionally keeps Arrow’s total order. The reference
documents how that differs from greatest,
least, and the window aggregates.
Checked Buffer Kernels
numeric.rs computes every lane of a checked operation in one branch-free loop:
- Failure bitmap. Each lane returns its value and whether it failed. A block of up to 1,024
lanes runs in one loop that stores each lane’s failure as one byte, and one call to
nervix-simd-kernelspacks the block’s bytes into its failure words with vector compares and bitmask extraction at the SIMD level the process selected. No lane shifts its failure into a word, so packing does not decide whether the lane loop vectorizes. - Explicit integer lanes. Integer
+and-, and*below 64 bits, run no lane loop of their own.CheckedIntegerhands the operands’ value buffers, and a scalar operand as the value every lane shares, toCheckedArithmeticinnervix-simd-kernels, which computes a register of lanes at a time and returns each lane’s value together with the failure words, so no failure byte is stored or packed. A sum or a difference wraps in its operands’ own lanes, and the signs of its operands and result, or the carry out of unsigned lanes, give its failure bit at every width. An 8-, 16-, or 32-bit product widens into two registers of lanes twice as wide, where every product is exact: comparing it against the narrow type’s bounds gives the failure bit, and narrowing it back by truncation gives the lane’s value. Every lane holds the wrapped value thatoverflowing_add,overflowing_sub, oroverflowing_mulreturns. A 64-bit product has no wider lane to be exact in, so it stays on the lane loop. - Failed lanes. A failed lane becomes null and its value is zeroed, so a wrapped result never escapes.
- Validity. A result lane is null wherever any operand lane is null.
- No rerun. The kernel never reruns a batch. The failure bitmap, restricted to lanes whose operands are valid, is the only record of a failure.
- Scalar operands. A scalar operand is folded into the lane function, or handed to an explicit kernel as the value every lane shares.
- Inlining. Each operator passes its own lane function or kernel operation, so the call inlines.
The datetime kernels share these lanes. Fixed-unit truncation and binning and to_unix prepare
one reciprocal divisor at entry. For a non-power-of-two divisor d, the prepared unsigned reciprocal is
floor(2^64 / d). Discarding its lower 64-w bits gives floor(2^w / d) at a narrower
operand width w; the high half of its product with a dividend gives a
quotient at most one below the exact quotient. One remainder comparison corrects it. Powers of
two use a shift and mask. Signs are restored after division of unsigned magnitudes, which keeps
MIN representable and distinguishes truncation toward zero from Euclidean rounding. Binning
normalizes both its value and origin, then corrects their phase difference by at most one stride.
It fails a lane if subtracting the distance to the bin start overflows.
Fixed-unit date_diff subtracts in i64 and uses its prepared truncating divisor when the
difference fits. Otherwise it computes the difference and division in i128, then checks the
result against i64. Calendar differences retain their calendar rules. date_add and
from_unix use checked i128 multiplication and addition; from_unix performs no division.
Integer column-by-scalar / and % use the same reciprocal primitive. Division by a column
retains the checked scalar lane loop. A quotient by zero or MIN / -1 fails; a remainder fails
only for zero, so MIN % -1 remains zero. Operand validity still masks failures, and a null
scalar produces an all-null column without running division. The constant-lane benchmark
compares each width’s selected kernel, scalar reciprocal and checked lane loop on identical runs;
U64 and I64 remainder retain scalar reciprocal loops because their vector forms measured slower
on x86-64-v3. The
retained paths and generated instructions are recorded in the
SIMD kernels 08 report.
Floating-point operations fail a lane that produces NaN or infinity. The transcendental functions
come from the platform math library as opaque calls per lane that no loop vectorizes, so those
kernels compute valid lanes only, as the decimal rounding and calendar kernels do. They read
validity one 64-lane word at a time: a word without a valid lane computes nothing and fails no
lane, a fully valid word runs the ordinary lane loop over its 64 lanes, and any other word computes
its valid lanes alone with the failure bytes of its null lanes cleared. The words’ failure bytes
fill the same blocks the other kernels pack, so a null lane never fails, no lane writes a failure
word, and a word with few valid lanes pays for no packing call of its own. round(value, digits) rounds exactly in integer arithmetic
on the value’s significand, and a digit count beyond ±400 rounds as ±400 does. A shift reads its
count from any integer type and fails a negative one. A count at or beyond the value’s width moves
every bit out, and the lanes stay branch-free.
Batch Execution, Compiler Vectorization, And Explicit SIMD
These are three different claims, and the implementation makes them separately:
- Batch execution. Every function computes a whole column in one call, and the cost of dispatch, type checks, and registers is paid once per batch. Every function in the catalog executes this way, including the irregular ones.
- Compiler vectorization. A buffer loop is written so that LLVM can widen it to the vector
instructions of the CPU the binary targets, and its result is the same whether or not it does.
The effective compiler flags determine the baseline: local
target-cpu=nativebuilds use the compiling host’s features, while untuned builds use the compiler target’s baseline. The Debian image builds its x86-64 payloads forx86-64-v3through cargo-sonic. A compiler-vectorized loop needs inspection of the generated instructions for the particular build before claiming a specific instruction set. - Explicit SIMD. The VM hands typed buffers to
nervix-simd-kernelsfor failure packing and checked integer operations. The caller catalog also covers JSON, delivery latency, domain/window admission, selections, and text codecs. Library-dispatched functions retain the library’s selection; xxhash chooses when the binary is built. - Dispatch. The kernel crate caches one
fearless_simdlevel per process and enters it once per buffer operation. The build-target matrix defines SSE2, SSE4.2, AVX2, Ice Lake class AVX-512, NEON, WASM SIMD, and forced fallback, including the ambient baseline that can coalesce lower test tokens into a higher backend. Every integer lane and failure bit is exact. The VM names no instruction set and uses nostd::archortarget_featureof its own.
The VM functions measurement report records what the measurements establish, and Measured Performance turns them into user guidance:
- Fixed-width work. Checked arithmetic ran at about 127 million rows per second on a clean 1,024-row batch.
- Variable-length work. Ragged lists, text search, and JSON ran at single-digit millions.
- Failures. Dense failures made checked arithmetic about six times slower, because each failed row builds an error.
- The executor hop. Crossing the 1,024-row threshold admits the execution onto the data workers, which costs more than executing a small batch.
- Conditional arms. A regular expression in an arm that selects half the batch costs about twelve times one that selects none.
- JSON sharing. Four extractions from one document cost less than a third of four extractions from four documents.
The timings were taken on one development machine with other builds running, so they are diagnostic. Without hardware counters or generated-instruction inspection, they do not establish a SIMD speedup. The SIMD kernels 07 report records the generated instructions and interleaved Criterion rounds of the explicit integer lanes.
Nulls, NaN, Overflow, And Unicode
- Nulls. A strict kernel’s result is null wherever any operand is null. Membership
and length results reuse the input’s null buffer as it is.
IS [NOT] DISTINCT FROMcombines both validity bitmaps into a never-null result. - Cast failures. A strict cast runs Arrow’s safe cast, which nulls a value it cannot convert. It
then reports the rows that became null without being null in the input, and skips that check
when the null counts are equal.
TRY_CASTkeeps those nulls silently. - NaN. Comparisons are IEEE 754 everywhere except list
minandmax. AnINset stores floats by bit pattern after canonicalizing-0.0, and never matches NaN. - Overflow. Integer overflow, division by zero, and
MIN / -1fail their lane. Datetime results outside the nanosecond range fail their lane. A window’s integerSUMaccumulates in i128 and is checked only when the window emits. - Unicode.
STRINGvalues are valid UTF-8 by Arrow’s contract. Case mapping is full Unicode without a locale.lengthcounts code points by skipping continuation bytes.STRINGordering is bytewise, which for UTF-8 equals code-point order. Nervix has no collation.
Operations That Do Not Reduce To SIMD
Several widely used operations cannot be expressed as a fixed-width loop over value buffers. For the functions this catalog delivers, the implementation optimizes the substeps that can be batched and leaves the irregular remainder per row, still inside one batch call:
| Operation | Optimized substeps | Remaining scalar or irregular work |
|---|---|---|
| Unicode case mapping and NFC normalization | A column whose visible text is all ASCII is case-mapped in one pass over its value buffer, reusing its offsets and nulls. NFC writes into one bounded builder. | A column with any non-ASCII byte is mapped row by row with allocation. Normalization composes each row. Nervix has no collation to optimize: strings compare bytewise. |
| Regular expressions and captures | Constant patterns compile once. A batch borrows one search cache per pattern. Field-supplied patterns are cached across batches and deduplicated within one. The regex engine runs its own vectorized literal prefilters. Inside a conditional arm, only selected rows are searched. | Each row runs its own search. Captures allocate their slots once per pattern per batch, then each match is extracted and interpolated per row. |
| JSON extraction | simd-json finds a document’s structure with instructions chosen at run time. Every extraction from one document column shares one parse. A literal document is parsed once per batch. | The path walk over each tape, conversion to the declared type, and the depth check are per row. |
| Formatted datetimes | A format compiles once. Writing lays the format out into fixed-width templates and fills one preallocated buffer. | Parsing is a strict per-row reader. |
| Calendar and time-zone logic | Fixed-length units in UTC or at a fixed offset use the vectorizable fixed-unit lanes. A time zone resolves once from the bundled database. Consecutive lanes inside one offset span reuse one zone lookup. | Months, quarters, years, and local days or weeks under an IANA zone compute per row through Jiff’s civil calendar, including disambiguation and month-end clamping. |
contains_any and IN | Constant pattern lists build one Aho-Corasick matcher. Small fixed-width sets compare in turn and larger ones hash, with the threshold chosen by benchmark. | Each row is scanned or hashed individually. Per-row pattern sets build a matcher per distinct set per batch. |
| Lists | Counts read offsets. First, last, and nth are index arithmetic plus one take. min and max over fixed-width lists without null elements compare whole columns one element position at a time. | Ragged-list extrema, mean, distance, dot, contains, and overlap loop over each row’s elements. |
| Sketches and window structures | Aggregate arguments are evaluated once per batch as Arrow columns. Typed runs fold validity by popcount, exact integer sums by split SIMD lanes, floating sums and centered moments by per-lane folds, and histogram indexes before scattering. Non-finite arguments are classified by column bitmaps. | Candidate deque updates, bucket scatter, BLAKE3 per distinct input, t-digest insertion, and Misra-Gries updates remain irregular. Sketch keys reuse a run buffer. |
| Encodings and hashes | Base64, hex, and SHA-256 use library SIMD dispatch per value. Output lengths are checked before encoding. | Each value is encoded or hashed separately. md5 is scalar. |
| IP addresses and URLs | Constant networks parse once. A containment test is one mask and compare on fixed-width integers. A scalar URL parses once per batch. | Address text and URLs parse per row under the URL Standard. |
| Roto UDFs | One vectorized call per batch or selection, with column methods over Arrow arrays. | Whatever the UDF body does. Its get and builder methods are an explicit per-row path. |
Function Families
The reference owns each family’s contract. These notes describe its implementation.
- Numeric. Checked lanes cover arithmetic, comparison,
abs,sign, and rounding.round(value, digits)rounds exactly, with halves away from zero, using integer arithmetic on the significand. Integer rounding to negative digits computes in 128 bits.- Bit operations reuse Arrow’s
binaryandunaryforand,or,xor, andnot, and branch-free lanes for shifts. - Classification never fails.
- Temporal. A
DATETIMEis signed nanoseconds since the epoch in an Arrow UTC timestamp.- Fixed units compute in checked lanes.
- Calendar units and zones use Jiff with the IANA database it bundles, and never read the host’s zone or locale.
- Time zones, formats, units, and widths resolve once when the call is lowered.
now()and every execution-local instant come from the execution context.
- Strings.
length,bit_length,octet_length, andtrimread or copy contiguous slices.LIKEandILIKEare Arrow kernels behind a pattern-length bound.splitreturns an Arrow list, bounded in parts and bytes.- Search, capture, and normalization are described above.
- Only NFC normalization exists.
- Arrays and vectors.
ListColumnreads a variable list’s offsets or a fixed list’s width.- Construction and
concatuse Arrowinterleave.slicebuilds indices for onetake. ListElementsin the catalog decides, when the call is compiled, which functions reject nested elements, so no batch reaches a kernel with an element type it cannot read.
- Construction and
- Bytes, encodings, and hashes.
BYTESis an ArrowBinarycolumn.- Encoders check each output’s length against the column before encoding.
bytes_from_utf8reuses the text’s buffers without copying.- Hashing preserves sensitivity like any other function.
- JSON. One
JsonScaninstruction per document register and selection parses each document once, into a tape. EveryJsonScanOutputthen walks its path over that tape and appends directly to a typed column of its declared type, truncating back to a mark when a row fails.- A document is never held as an untyped value, and no row becomes a map.
JSON_VALUEandTRY_JSON_VALUEare one extraction with differentCastFailurechoices. Even the tolerant form still reports a result too large for its column.
- Network. An IP address is a 4- or 16-byte
BYTESvalue, held internally as a 32- or 128-bit integer.- A network is a network and mask pair, so containment is one mask and compare.
- URLs parse with the
urlcrate, and the functions never resolve a host or fetch anything.
- Conversions.
ASandTRY_CASTshare one cast path through Arrow’s safe cast. ASTRINGread as a number follows Arrow’s parser.
Context, Determinism, And Extension Boundaries
Domain Time
The one timestamp an execution sees is ExecutionContext::now. The host takes it from the
domain execution snapshot of the unit of work. All of
these use it:
now(), and every datetime function applied to ituuid_v7(), whose timestamp field it encodeserror.occurred_atin a structured message error
now() is Stable with an ExecutionLocal dependency scope. It is therefore never folded, which
requires a constant scope, but it is shared and computed once per execution. A paced domain’s time
before the epoch reaches uuid_v7() as it is, and each row that evaluates the call fails rather
than encoding a different instant. No VM or datetime code calls SystemTime or Instant.
Processor and materialized relay tasks retain the installed domain clock that supplies each unit’s
snapshot. Filtered subscriptions retain its lifecycle owner so a subscription opened before
START DOMAIN follows the subsequently installed generation. Each subscription generation also
retains the node’s executor and supplies it directly to predicate evaluation, preserving bounded
admission for extension calls. Batch evaluation reads these publications directly; it does not
discover its clock through the runtime registry.
Deterministic And Volatile Functions
Only uuid_v4 and uuid_v7 are Volatile. A volatile builtin:
- is never shared by the compiler
- is never computed once for all rows
- is never removed as dead code
Window aggregates and header reads are Stable, because they answer from their context. A UDF is
deterministic unless it is declared VOLATILE. Only a volatile UDF can call now, rand_f64, or
uuid_v4, and UDF code never runs during constant folding.
Extension Boundaries
The FunctionInjector trait is the VM’s only extension point. The compiler emits an Inject
instruction for three kinds of call: read_header and read_headers, window aggregates, and UDFs.
At run time the VM looks for an injector in two places: the injector in the execution context first, then the one compiled into the program. Every answer is validated. A result of the wrong type or row count, or a side error that names a row outside the call, is a batch error.
The injectors:
- Header reads.
IngestHeaderFunctionInjectoranswers header reads from the ingest metadata of one execution. - Window aggregates.
WindowAggregateResultsanswers window aggregates from the accumulators at emission. - UDFs.
nervix-roto’sUdfExecutoranswers UDF calls:- It receives one call per batch or selection, and masks rows whose required arguments are null or already failed.
- Every execution that calls a UDF runs on the node’s extension workers, and a panic there is a batch error.
- It reports per-row errors as side errors, and fails the batch for a trap, a wrong type or row count, an unexplained null, or a call that returns after its 5-second watchdog.
- The watchdog is checked only after the call returns. It cannot reclaim a worker from native code that never returns, as User-Defined Functions explains.
WASM processors are not VM functions. nervix-wasm does not depend on the VM. A WASM processor’s
routes are ordinary set-only VM programs over the guest’s output, and the host gathers guest
columns with Arrow identity, slice, take, and concatenate. Guest execution, isolation, and state
belong to WASM Processor Guests and
WASM State And Recovery.
FunctionInjector and retained window accumulator traits declare recurring compiler contracts,
so their implementations and dynamic callers retain the VM callback frequency across crate
metadata. UDF result builders document the retained column and admitted execution bound; external
Arrow, formatting and operator callback effects are declared at their owning callable. Paired
Rust API doctests verify that injection accepts the domain timestamp with its selected-row error
mask and rejects an unrelated scalar time. The annotations do not replace executor admission,
selected-row validation or the concurrency checks.
Data-Plane Concurrency owns compiler contract syntax
and diagnostics.
Window Aggregates And Sketches
Planning
window.rs lowers a window route’s assignments:
- What is rejected. It rejects
INHERITandINVOKE, and targets other than a bare field oroutput.<field>.inputmay be read only inside an aggregate argument, and an aggregate argument may read onlyinput. Nested aggregates, and configuration arguments that are not constants, are rejected too. - Demands. It rewrites each aggregate call into a demand. A demand names its function, its
shared storage kind, and its per-row arguments. Two demands merge when their storage, arguments,
histogram configuration, and sketch configuration all match. That merging is what lets
AVGandSTDDEV_POPof one argument share onemomentsstructure.
window/route.rs then compiles two kinds of program for each route:
- The argument program. One program evaluates every demand’s per-row arguments over an input
batch, writing internal
window_argument.demand_<id>_<position>fields. - The output programs. For each assignment, a one-row output program turns aggregate results
into the output field. Each aggregate call becomes an
Injectinstruction. Aggregate calls may therefore sit inside arithmetic, a conditional, or a list, because they compile like any other scalar operand.
Registry validation compiles the same route with the same compiler when the processor is applied.
It also rejects BYTES arguments and checks the sketch budget.
Branch Ownership And Accumulation
A window and every accumulator in it are fields of the live window of exactly one concrete branch.
Only that branch’s task changes them, through &mut self, without a lock. What leaves the task is
an immutable publication. See Window and WASM
state.
For each batch, the task works through these steps:
- It evaluates the argument program once. A whole-batch failure fails every message in the batch.
- It refuses a row whose argument failed, or whose float argument is not finite where the structure requires finite values. Each floating argument column supplies a non-finite bitmap.
- It checks the state budget, then admits the rest in runs of consecutive rows. A run ends where the window’s width fills.
- For each run, every structure reads one typed value slice and validity bitmap from the shared argument columns. Counts use bitmap popcounts; integer sums use exact high and low lane halves; float sums use per-lane TwoSum; moments use a compensated mean pass and a centered second pass, normalizing by the largest contributing magnitude when summing finite inputs overflows before division, then one Chan merge into the branch’s aggregate. Histogram indexes are computed for the run and scattered into bucket counts. Each retained row holds its sequence, its timestamp, and a row view of the Arc’d input and argument batches. The normalized mean is clamped to its operand range before rescaling; a constant finite run keeps its exact mean and zero M2 and cross moment even when its sum overflows.
- It emits while the width is met, and then steps the window. Stepped rows are acknowledged.
Tumbling and sliding windows use this same path, with different WIDTH and STEP.
| Structure | Functions | Algorithm | Retraction when the window steps |
|---|---|---|---|
counter | COUNT | Exact 64-bit count | Subtract |
truth_counter | COUNT_IF, BOOL_AND, BOOL_OR | Exact true and false counts | Subtract |
sum over integers | SUM | Exact 128-bit sum from high and low 32-bit SIMD halves, checked against the argument type at emission | Subtract the same run kernel’s result |
sum over floats | SUM | Per-lane Knuth two-sum in a two-stack window; F32 accumulates in F64 | Refold typed surviving runs, never subtract |
moments | AVG, variances, standard deviations | Two-pass centered count, mean, and M2 with one Chan merge per run, in a two-stack window | Refold typed surviving runs |
co_moments | Covariances, CORR | Two-pass centered co-moments with one Chan merge per run; CORR clamped to [-1, 1] | Refold typed surviving runs |
extremes, sequence, arg_extremes | MIN/MAX, FIRST/LAST, ARG_MIN/ARG_MAX | Monotonic candidate deque ordered by value, arrival, or key; ties keep the earliest row | Drop front candidates older than the first survivor |
linear_histogram | PERCENTILE_LINEAR_HISTOGRAM | Fixed-range buckets, answered with a bucket midpoint | Immediately, or after the configured delay on the domain clock |
hll | APPROX_COUNT_DISTINCT | HyperLogLog over the first 8 bytes of a BLAKE3 hash of a type-tagged key, with linear counting at small cardinalities | Rebuilt from survivors |
quantile_sketch | APPROX_QUANTILE | t-digest capped at its capacity, merging the least-cost adjacent centroids; exact while it holds every value | Rebuilt from survivors |
frequent_items | APPROX_TOP_K | Misra-Gries candidates, ordered by count and then key bytes | Rebuilt from survivors |
The two-stack window keeps its floating-point statistics exact under sliding:
- Front. A stack whose entries aggregate the oldest retained rows. Its top entry covers the oldest front row together with every newer front row, so dropping that row pops one entry.
- Back. One aggregate of every retained row after the front, extended as rows are admitted.
- Refold. A retraction that reaches past the front groups survivors by typed argument run and folds each run from newest to oldest into a fresh front.
Nothing is ever subtracted, so a value that left the window leaves no rounding behind, at amortized constant cost per row.
Run boundaries and SIMD lane order determine the association of floating-point additions and Chan merges. Their results are associative up to floating-point rounding and can differ slightly when batching changes. Integer sums and counts remain bit-identical regardless of run boundaries.
Sketch Panes, Merge, And Expiry
A sketch window keeps one sketch per pane. A pane is a span of the greatest common divisor of
WIDTH and STEP, aligned to the Unix epoch, and a window spans at most WIDTH / pane + 2 panes.
At emission the window merges every live pane into a fresh sketch:
- HyperLogLog takes the register-wise maximum.
- The t-digest inserts every centroid and compresses.
- Misra-Gries adds weighted counts.
Each top-k value is sliced from the earliest retained row with that key.
The first row admitted into a pane creates its sketch, found or inserted by one search of the
window’s ordered pane map; every later row of the pane updates that sketch in place. A row whose
argument is absent admits nothing, so a pane holding only such rows keeps no sketch.
just bench-window-sketches measures admission at one, 16 and 1,024 rows per pane for each
sketch.
A sketch cannot subtract a row. When a window steps, every pane is cleared, and every surviving row is admitted again. This rebuilds the whole window rather than one pane, so a row that left the window never contributes to a later estimate. Mergeability is what makes the pane merge cheap; it never implies that a sketch can retract a row.
Bounded State
A sketch’s reservation per pane is fixed by its configuration:
2^precision + 128bytes for HyperLogLog32 * capacity + 128bytes for the t-digest160 * capacity + 128bytes for Misra-Gries
When the processor is applied, processor/sketch.rs requires these declarations:
MAX STATE SIZE- duration
WIDTHandSTEP - for a branched window, a branch that declares
MAX INSTANCES
It rejects a route when 1024 + reservations * (maximum panes + 1) bytes exceed that size. The
extra pane is the merged sketch built at emission.
At run time, check_admission charges a branch before any state changes. The charge covers:
- a fixed base
- per-row bookkeeping
- each shared input and argument batch, charged once at the greater of its actual and its estimated size
- the branch key
- every sketch in every active pane, plus the merged sketch
A run that does not fit is retried one row at a time. A single row that still does not fit is
refused through the node’s message-error policy, which logs it and leaves it unacknowledged. More
active panes than the maximum is refused the same way. Restoring a snapshot passes the same check.
Only a window that declares MAX STATE SIZE is charged, and every sketch window must declare it.
Histogram buckets are not charged, as the reference
states.
Publication, Handoff, And Recovery
A branch task marks its window dirty after every admission and every emission that changes it. It
publishes an immutable generation through an ArcSwap on these occasions:
- at each replication poll, while the window is dirty
- before an ownership-handoff checkpoint
- when the branch stops
The snapshot task persists publications on its own interval. See Data Plane for the persistence guarantees.
A published window carries its retained rows, each with its sequence, timestamp, key, and the Arrow row views of its input and argument columns. It does not carry the accumulators themselves. The snapshot codec seals those views as bounded Arrow sections on the bulk executor. Only the histogram’s delayed removals ride beside them in a typed section, because the retained rows cannot reproduce them.
Each delayed removal archives its bucket index as a fixed-width 64-bit count, with checked native
decoding. The current snapshot frame begins with NVXWIN64, and window state uses runtime-state
kind tag 8. These identify the current stored and transferred shape before decoding; an
unrecognized frame fails validation, and stored state outside the current namespace must be
recreated. The count contract is defined in Archived Counts.
A snapshot from an earlier incarnation of the branch restores an empty window and marks it for publication, so a late checkpoint of a previous lifetime cannot restore its panes. Otherwise, restoring checks that the snapshot’s sequences are consecutive, that its input schema matches, and that every row belongs to this branch. It then re-admits every row in order. That rebuilds every exact structure and every sketch pane from the rows themselves, then reapplies any delayed removals.
The argument columns of a snapshot have one schema, which the window compiler derives from the route’s aggregate demands: one nullable column per argument of every demand, in demand order. Native snapshots and backup archives both use it, and a reader opens a sealed snapshot only against that schema and the window’s input relay schema.
A backup archives the same publication in a public form. Each window
branch becomes an rkyv descriptor holding the retained rows’ admission sequences and watermarks,
the branch incarnation, the window model digest and every histogram’s delayed removals, together
with bounded groups of the retained input rows and their argument columns as Arrow IPC. A restore
converts those groups back into a native snapshot, which the branch task restores as it restores
any other: every row is re-admitted in order, and the histograms take their delayed removals back.
The window model digest is the BLAKE3 hash of the window processor’s canonical NSPL, so a window
whose WIDTH, STEP, state limit or aggregates changed starts empty instead.
How each ending treats the window:
| Ending | Effect on the window |
|---|---|
| Handoff or detach | Flushes a window whose width is met, then publishes what remains. |
| Eviction | Resets the window first, so its final publication is empty and a later branch with the same key starts fresh. |
| Quiesced backup cut | Publishes any change since the window’s last publication when the cut’s lifecycle checkpoint asks. The retained rows’ acknowledgement shares stay parked, so they do not hold the cut open; the cut captures them as window state. |
| A task aborted after its shutdown grace period | Loses the changes made after its last publication. |
Snapshot transfer between nodes follows the bulk transfer rules of the Cluster Interconnect. Drain and forced flush at shutdown follow Shutdown And Recovery.
A result that does not fit its type fails the window when it emits:
- a
SUMoutside its argument type - a statistic that overflows
F64 - a sketch estimate outside
I64
That failure fails every retained row’s acknowledgement and clears the window. The routes the window emitted before the failing one keep their output.
Adding A Function
Treat this as the checklist for a new builtin, or for a new family.
-
Decide the contract first. Write down these, and check that the function respects the reference’s Function Properties:
- exact argument and result types, and which results are optional
- null handling, and every error kind and message
- volatility and dependency scope
- sensitivity and eligible contexts
A new type shape, such as a general decimal or a map payload, is a product change that needs its own contract, not a scalar function name.
-
Register it. Add the following:
- a
FunctionNamevariant with itsparseandas_strarms, plus aliases only when they are part of the contract - a
BuiltinLoweringvariant - its arms in
builtin_descriptor,builtin_semantics_for_lowering,builtin_output_type, andbuiltin_arm_execution - any
expr_may_be_nullexception
A datetime function resolves its literal arguments at lowering instead.
- a
-
Decide folding. Either add a folding arm that applies a shared catalog contract, or list the function in
fold_builtin_call’s refusals. Never fold anything that can fail, is volatile, or reads execution-local state. -
Decide
can_errorhonestly. A builtin that pushes a row error must declarecan_error. Otherwise the compiler shares it and gives it no selection, so the errors of unselectedCASEarms would leak into error handling. -
Choose the kernel.
- Reuse an Arrow kernel when its semantics are exactly NSPL’s, including float ordering, overflow, and null rules.
- Otherwise write one pass over value buffers with the checked lanes, pushing failures from a bitmap.
- Keep a dependency with runtime SIMD dispatch only when it preserves the semantic contract.
- Irregular work stays a batch API with per-row substeps.
Build text or bytes whose length an argument chooses through the bounded column builder. Read integer counts through
count.rs. Take scalars asOperands instead of expanding them. -
Choose arm execution. Use
SelectedRowsfor work costing well above a few nanoseconds per row, and for any text sized by an argument. UseWholeBatchfor vectorized kernels. -
Keep errors typed. Add a
SideErrorReasonvariant with typed fields and itsErrorCode. Format nothing per row, and never include an argument’s value in a reason. A failure of the batch as a whole extendsRuntimeErrorand propagates througherror-stack. -
Preserve the invariants.
- no implicit conversion
- sensitivity from every operand
- no clock read
- no row-oriented carrier
- no lock per row
- no prepared object outside the compiled program
-
Test it.
- kernel and compiler unit tests next to the code, through
just test-vm, covering nulls, NaN, overflow, sliced arrays, scalar operands, and conditional arms - a public Cucumber scenario outline on one and three nodes that proves values and failures
through a graph and a session subscription, run with
just test-scenarios --input <feature> - a row in the qualification ledger
- for a kernel with an independent reference, a registered Bolero property over its column
kernel, as
round(value, digits)has invm-decimal-rounding-floatsandvm-decimal-rounding-integers; see Property Testing And Fuzzing - window structures also need interleaved branches, eviction, and snapshot restore
- kernel and compiler unit tests next to the code, through
-
Measure it. Add a Criterion case to
crates/nervix-vm/benches/vm.rsor its workload shapes, and run it withjust bench-vm. Usejust bench-vm-allocfor allocation evidence, and the serializedjust benchmark-abfor any end-to-end claim.- Claim vectorization only after inspecting the generated instructions for the target.
- Claim a speedup only from a same-host A/B.
- Record the result beside the existing report.
-
Document it.
- Add its contract to Expression Functions, including a catalog row.
- Update the NSPL skill only where its routing or checks change, then regenerate the book.
- Update this chapter when the function changes a mechanism rather than adding an instance of one.
Guarantees And Limits
Guarantees:
- An expression’s value never depends on the other rows in its batch, on the batch’s size, or on whether a kernel was vectorized.
- A folded, shared, or scalar computation produces the value that per-row evaluation would.
- Only a conditional expression evaluates an operand for some rows and not others. An unselected arm never reports an error.
- Every execution reads one domain timestamp, supplied by its caller.
- Window state belongs to one branch task. It is published only as immutable generations, and after a handoff or recovery it is rebuilt from its retained rows.
Limits:
- Column limits: 2,147,483,647 bytes of text or bytes in one column; 1,024 rows before execution moves to the node’s data workers.
- Regular expressions: 10 MiB compiled and a 2 MiB search cache per pattern, and 64 field-supplied patterns cached per call.
- Pattern sets: 128 patterns and 64 KiB per constant
contains_anylist, and 64 per-row pattern sets per batch. - Text functions: 4 KiB
LIKEpatterns and 65,536splitparts. - JSON: 16 MiB, 128-level documents, and 64-step paths.
- Datetimes: 256-byte formatted values.
- Sketches: precision 4–16, capacity 32–4096, and
kat most the capacity.
The complete user-facing limits are in Limits.
Current boundaries:
- Validation compiles to reject invalid statements, while installation compiles the programs selected by the typed revision against local schemas and capabilities.
- Branched processor routes compile before publication and share their prepared patterns and sets across concrete branch tasks of the same installed node revision.
- A message-error route binds once per installed revision; each failed record executes that bound program with its captured inputs.
- The text functions listed under Allocation And Result Bounds build their columns without the size check.
- A statement that makes a batch’s program slow is bounded only by the input and the limits above. The VM has no instruction budget and no preemption.
Streams And State
RELAY declares a named, schedulable runtime node between producers and consumers. Every relay
has one primary owner. Only that owner instantiates the relay buffer, concrete branch presence,
relay metrics, subscription fan-out, and optional materialized state.
CREATE IF NOT EXISTS RELAY notifications
SCHEMA notification
UNBRANCHED
CAPACITY 1;
Streams, Branch Keys, And Branches
External feeds commonly contain records for many tenants, users, devices, accounts, or other business groups. Nervix branching lets one declared graph process those groups independently.
Branching is defined by a schema name on a named branch:
CREATE BRANCH by_user SCHEMA user_branch TTL 5misolates each userCREATE BRANCH by_tenant SCHEMA tenant_branch TTL 5misolates each tenantCREATE BRANCH by_tenant_user SCHEMA tenant_user_branch TTL 5misolates each tenant/user pairMAX INSTANCES <n> EVICT LRUcan cap active concrete branch instances for that branch
The web console’s Create menu also has a branch form. Its schema picker searches internal
schemas in the captured domain, including schemas staged earlier in the attached transaction; the
form requires a TTL and accepts the optional positive LRU instance limit. Registry validation
remains authoritative for whether the selected schema can serve as a branch key.
SHOW CREATE BRANCH <name> returns the stored branch’s canonical declaration.
The same menu has a relay form. It selects the relay’s schema and branch from server-backed lists
of the captured domain, including models staged earlier in the attached transaction, and requires
an explicit UNBRANCHED or BRANCHED BY choice. Capacity starts from the default relay buffer,
and materialized state is none or LAST BY TIMESTAMP. SHOW CREATE RELAY <name> returns the
stored relay’s canonical declaration, which always states its capacity. See
Web Console.
Relays select an explicit branch or declare unbranched execution:
CREATE RELAY notifications SCHEMA notification BRANCHED BY by_tenant_user;
CREATE RELAY global_notifications SCHEMA notification UNBRANCHED;
Altering Relays
ALTER RELAY accepts one or more comma-separated operations. Operations execute in written order,
so later operations may replace values set earlier in the same statement:
ALTER RELAY notifications
SET CAPACITY 8,
SET SCHEMA notification_v2,
SET BRANCHED BY by_tenant,
SET MATERIALIZED STATE LAST BY TIMESTAMP;
The available operations are SET CAPACITY, SET SCHEMA, SET BRANCHED BY, SET UNBRANCHED,
SET MATERIALIZED STATE LAST BY TIMESTAMP, and DROP MATERIALIZED STATE. Dropping materialized
state when it is not configured is an error. The registry validates the complete resulting graph,
so schema or branching changes must be submitted in the same transaction as every dependent model
change needed to make the candidate graph valid.
Capacity changes are dynamically applied to a running domain without replacing the owner buffer or its buffered batches. Schema and branching changes use domain pause because Arrow schemas and branch identity are compiled into producers and consumers. Materialized-state add/drop uses entity pause: Nervix gates and drains the relay, changes the relay’s state-replica membership without replacing its owner buffer, advances the state epoch observed by readers, and releases the gate. Adding state therefore has no interval in which post-commit records can bypass materialization. Dropping state purges the relay’s in-memory and persisted materialized records before flow resumes.
An ingestor or reingestor uses BRANCHED BY <branch> to compute the branch key for each record. When records for a key arrive, Nervix uses a branch instance for that key. A branch instance is the runtime execution path for one concrete key.
Inside a branch, records retain their key while moving through the relay owner. Each concrete relay branch has:
- the declared
RELAYname it belongs to - a branch identity
- a schema
- buffering behavior
Processing node state also belongs to the branch. That gives each group independent deduplicator history, reorder buffers, window accumulators, and materialized entries. Relay buffering is owned once per declared relay and may contain interleaved batches from several concrete branches.
Runtime branch rules:
- an
INGESTORstarts a branch for one concrete branch key throughBRANCHED BY <branch> - normal downstream processors keep the same named branch and concrete branch key
- output routes and forwarders send records to downstream relay names inside the same branch
- stateful processors keep branch-local state for that group
- a
REINGESTORmay consume across a branch boundary and start new downstream branches throughBRANCHED BY <branch> - an
EMITTERconsumes records across the whole input relay and terminates the branch at an external sink
branch is a reserved namespace, so a relay cannot be named branch. Expressions inside a
concrete branch may read its immutable key with branch.<key>. Bare fields never resolve to branch
fields. Unbranched execution has no branch.<key> values, and successful emitter expressions do
not expose the branch scope.
Internal Payload Model
After schema application, Nervix does not keep an internal per-message document format on relays. The runtime payload on a relay is an Apache Arrow record batch plus the schema, the row-aligned ACK state, and each row’s low and high ingestion watermarks. The watermarks travel beside the batch in two Arrow buffers of Unix-nanosecond timestamps, which watermark-based logic and delivery latency read without converting each row.
Apache Arrow is used here for two practical reasons:
- fast vectorized processing over columnar data inside runtime nodes
- fast serialization and deserialization when batches move between nodes
Operationally that means:
- ingestors and reingestors batch decoded rows before writing into a relay
- deduplicators still apply row-level state semantics inside the node, while junctions stay Arrow-native and concatenate compatible branch-local batches before forwarding
- window processors keep branch-local online aggregate state and construct each route through
aggregate expressions in ordered
SETassignments - batches remain branch-local until a
REINGESTORorEMITTERboundary changes the routing behavior - a producer on a nonowner cluster node serializes a batch once and holds one fixed ingress slot until the owner admits it into the relay buffer
- the owner serializes an admitted batch at most once for its remote runtime consumers and once for remote session subscriptions, and shares each serialized body across the destination nodes; every local runtime consumer on a node shares that node’s delivery, and a session subscription on a node that also hosts a runtime consumer piggybacks on it
- a delivery that reaches a node after its attached consumer moved away fails its attached acknowledgements, so the source redelivers the record along the owner’s current routes; see Consumers That Leave The Receiver
- an owner batch that starts fan-out while a schedule swap fences its consumer routes fails its record acknowledgements and is retried by its source after the swap; fan-out already holding a dispatch permit finishes before those routes change
- an acknowledgement a receiver returns after the sending node restarted names that node’s earlier run, so the restarted node rejects it and its own records stay pending until their own acknowledgements resolve; see Acknowledgement Registrations
Lookup and state-replication control paths are separate from this relay payload model. The Arrow batch path applies to relay movement inside the data plane.
Relay batches and their per-row ACK metadata are hot-path runtime data. They are not persisted or replicated, and ACK guards, tokens, and maps are never part of runtime snapshots. Materialized state, when enabled, is the only replicated part of the relay; it is not a second runtime node.
Capacity
CAPACITY <n> controls the single buffer on the relay owner. It is one cluster-wide
backpressure boundary, not a per-producer, per-consumer, or per-branch capacity. If downstream
runtime consumers cannot drain the relay quickly enough, upstream dispatch waits once the owner
buffer and the fixed dispatch slots leading to it are occupied. A producer on another node also
waits while the owner has no relay memory left for the acknowledgement watches of the rows it
sends, until the owner has reported enough earlier rows; see
Capacity Planning.
The capacity is a positive integer, through 18446744073709551615 on native 64-bit targets.
Creation and ALTER RELAY ... SET CAPACITY retain the exact accepted value in storage, replicated
commands, and SHOW CREATE after a node or full-cluster restart.
At most the following batches can be admitted or in dispatch for one relay:
CAPACITYbatches in the owner buffer;- one batch from each producer cluster node to the owner; and
- one batch from the owner to each remote consuming cluster node.
The owner has no additional inbound queue. A producer’s slot is released only after the owner has admitted that batch. A consumer-node slot is released only after that node has admitted its batch. These fixed slots do not scale with the number of runtime consumers or subscriptions on a node.
The capacity can be changed after creation:
ALTER RELAY notifications SET CAPACITY 5;
The updated capacity is persisted in the relay definition and applied in place to the active owner buffer. Existing concrete branches, subscriptions, runtime consumers, and dispatch slots remain attached.
A capacity change applies at once to admission and never discards buffered batches. Increasing capacity admits waiting publishers immediately. When capacity is shrunk below the current buffered depth, every buffered batch is kept and publishers observe relay backpressure until receivers drain the buffer below the new capacity. The one-batch producer and consumer dispatch slots never resize.
Small capacities are useful in tests and tiny examples, but high-throughput graphs should use capacities large enough to absorb several flush intervals of batches. This is especially important for relays written by external ingestors and relays read by reingestors, because a low buffer can multiply short waits across every branch. If omitted, Nervix uses the default relay buffer.
TTL
TTL is a branch contract, not a relay-local setting. CREATE BRANCH declares TTL <duration> after SCHEMA <schema>. UNBRANCHED branch roots do not declare TTL because there are no concrete branch instances to expire.
TTL controls:
- concrete relay-branch presence on the owner
- materialized-state cleanup when the relay is materialized
- downstream processor state cleanup for the same concrete branch
Expiration semantics:
- paced domains use domain logical time
- unpaced domains use wall clock time
- a branch’s idle time is measured from the time at which its last input was accepted. A relay owner or processor that spends a long time waiting for input does not spend that wait out of the branch’s TTL, so every branch receives its complete declared lifetime from its own last record
- every relay owner and processor using the branch applies that branch’s TTL to its own branch-local runtime state
- relay TTL and
MAX INSTANCES ... EVICT LRUare enforced once by the relay owner across all producers in the cluster
TTL is the expiration boundary; releasing the expired branch is a separate maintenance scan that
runs on the physical clock. A branch is never released before its TTL has elapsed in the domain’s
clock, and it is released at the first scan after that boundary. The scan interval is therefore
release latency added on top of the TTL, never early expiry, and accelerating TIME RATE shortens
the TTL in real time without changing the scan interval.
Materialized State
Materialized relay state is enabled with:
CREATE IF NOT EXISTS RELAY notifications
SCHEMA notification
UNBRANCHED
WITH MATERIALIZED STATE LAST BY TIMESTAMP;
Current semantics:
- materialized state is keyed by the branch key
- a branch grouped by nothing has one root entry
- Nervix keeps the latest full record per branch group according to record metadata watermarks
- materialized state is persisted to Fjall
- persisted snapshots are replicated to scheduler-selected state replicas
- when a concrete branch-local relay expires, Nervix deletes the matching materialized entry and replicates that deletion
Because watermark and timestamp metadata travel alongside rows inside relay batches, batching does not change LAST BY TIMESTAMP semantics. Materialized state still compares records using the preserved runtime metadata for each row.
Operational notes:
STOPpreserves persisted materialized stateSTARTclears materialized state for the active domain before new execution proceeds- after a crash, Nervix restores persisted materialized entries from Fjall
- a backup captures fresh materialized rows, exact schemas, typed branch keys and watermarks;
RESTORE ... RESUMEinstalls them on owners and replicas before generators start, retaining the archived domain start generation. A default stopped restore followed bySTARTclears them. See Backup And Restore. - per-group TTL metadata is not yet persisted, so crash recovery does not currently perform a startup sweep of stale materialized entries
Materialized state is also the readable snapshot surface for GENERATOR nodes. A generator declares exactly one materialized relay with USING MATERIALIZED STATE <relay> and reads it through relay_state.<relay>.<field>.
SHOW RELAY <relay> MATERIALIZED STATE reports kind: RELAY, the relay owner, and its
scheduler-selected state replicas before the materialized entries or empty-state message. Each
entry keeps its typed branch key through collection and renders it at this reporting boundary;
unbranched entries render as key=(root). An entry’s payload is written as JSON, which has no
number for a NaN or an infinity, so a float field holding one is written as the string NaN,
Infinity or -Infinity. A record holds one when a payload’s number rounds past its F32 field’s
range, or when an Avro payload or a producer’s Arrow batch carries one.
DESCRIBE RELAY <relay> reports the owner and state replicas immediately after kind: RELAY, then
the logical definition and owner buffer-utilization metrics. An ordinary relay reports
replicas: -. Traffic metrics remain on the producing or consuming runtime-node edge.
DESCRIBE RELAY <relay> WHERE (...) is answered only by the current relay owner and reports
owner-authoritative concrete-branch existence and buffer metrics. A branch exists from the moment
the owner accepts a batch for it until the owner evicts or expires it. Relay presence and metrics
are not replicated. A planned owner move drains admitted work before cutover; an owner failure loses
buffered batches, presence, and relay metrics. A new owner, whether on another node or after the
relay’s execution is rebuilt on the same one, reports a branch only after it accepts a batch for
that branch itself. Materialized records survive when a current state replica can become owner.
Prometheus exports aggregate relay metrics without branch-key labels;
see Metrics And Observability.
Other Replicated Runtime State
Materialized state is only one example of replicated runtime state. Others include:
- Kafka offsets for
OFFSET BY DOMAIN - deduplicator state
- graph metric summaries
Kafka partition assignment for OFFSET BY DOMAIN is not runtime-local replicated state. The leader observes Kafka partition topology, computes instance assignment, and commits that assignment into the Raft-backed domain schedule. That committed schedule is then persisted through the control-plane storage path and applied by runtime nodes.
This is still recovery-oriented state, not transactional exactly-once storage.
WASM State And Recovery
A WASM processor runs one guest instance per concrete branch, and each guest keeps computation state that Nervix makes durable. The host checkpoints that state at the end of every guest callback, confirms it with the processor’s replicas, restores it into every instance it recreates, and replaces it when a command, the guest itself, or the processor’s rejected-state policy asks for a new lifetime. This chapter defines that contract end to end: who owns each piece of state, what is durable and what is not, how a checkpoint becomes the state a recreated instance restores, which identity fences out every earlier lifetime, how a lifetime is replaced, and what each kind of failure leaves behind.
The central rule is that guest state and message delivery are separate guarantees. A checkpoint makes the guest’s computation state durable before the source acknowledgements it covers are released. It does not make the guest, its output, and external sinks one transaction, so a path through a WASM processor is at least once.
Execution Plans explains how the committed schedule carries a pinned module and state-generation identity into node-local WASM preparation and recovery.
This chapter describes the architecture. WASM Processor Guests owns the guest ABI and the user-facing account of checkpoints, resets, and failure diagnostics, the Rust WASM Guest SDK owns the Rust guest surface, and Runtime Nodes owns the NSPL statements.
Ownership By Layer
| Layer | Owner | What it owns |
|---|---|---|
| Vocabulary | WASM state models | The guest-state generation of every branch, a reset’s scope, phase, and reason, the recovery attempts refused lifetimes have spent, and the typed state inspection. The processor’s entry in the committed domain schedule carries all of them. |
| Language | NSPL | ON REJECTED STATE, RESET WASM PROCESSOR ... STATE, and DESCRIBE WASM PROCESSOR, lowered into Models. |
| Engines and infrastructure | The WASM host | Compiling a pinned module, instantiating a branch store, the fuel and memory limits of every guest operation, the domain snapshot each operation reads, and answering a guest’s reset request. |
| Engines and infrastructure | The guest SDK | The guest side of the ABI: the snapshot envelope, strict restore verdicts, and keeping its own execution state out of every save. |
| Engines and infrastructure | The runtime state store | Writing a checkpoint under its placement, and the durability barrier that synchronizes the node’s storage for every writer waiting on it. |
| Engines and infrastructure | The interconnect | Replica synchronization and acknowledgements, and the coordination requests a reset and a recovery send. |
| Decisions | Registry and scheduling | Planning a reset as a transaction step, deciding which schedule publication starts a new generation, and binding the pinned module and generation identity to that scheduled processor revision. |
| Data plane | The branch task | Running guest callbacks one at a time, holding back the acknowledgements each callback decides, and checkpointing the instance the callback leaves behind. |
| Data plane | The processor supervisor | Fencing the scope of an unfinished reset, and preparing, aborting, and committing a reset on the owner. |
| Control plane | The reset coordinator on the leader | Validating a reset, gating its scope, preparing it, and publishing it through its Publishing and Ready phases. |
| Control plane | The recovery coordinator on the leader | Deciding whether a refused lifetime still has its one recovery attempt, recording the decision, and driving the reset it admits. |
| Edges | The session service and clients | Admitting an NSPL reset as a transaction step, and returning the typed state inspection beside the text of DESCRIBE WASM PROCESSOR. |
During normal execution, guest bytes stay in the data plane and state store. A backup is an explicit control-plane export of durable checkpoints to an archive; restore installs those bytes under the generations of the newly published schedule. The data plane executes the lifetime the committed schedule names and never decides one.
Registered representation properties compare complete guest snapshots, SDK restore values, archive state descriptors and stored checkpoint headers. The oracle includes every raw guest byte, typed name, schema, branch field, generation and revision; segmented headers retain their full length and digest. These serial conversions establish value preservation. Callback completion, replica durability and publication ordering retain the scenario and concurrency evidence of their production owners. The WASM representation register records each conversion, its exact oracle and the limits of its claim.
Backup And Restore
A normal backup holds a quiesced cut for each running domain. Once intake and acknowledged work
drain, each owner applies the selected cut revision and its runtime plan, then requests a fresh
branch lifecycle checkpoint from its active supervisors before
reading durable WASM saves together with its other state from one database snapshot.
The archive stores a typed descriptor for each saved branch: processor, schema fingerprint, typed
branch key and its fingerprint, generation, and checkpoint revision. Raw guest bytes occupy a
separate section with a measured length and digest. WITHOUT PAUSE exports the latest published
checkpoints with crash-consistent semantics; WITHOUT STATE omits them. Capture filters the
current scheduled identity and active lifecycle before reconstructing branch keys. Retained
checkpoints for TTL or LRU evicted branches are omitted; they remain available for local branch
reappearance without preventing a backup of the current lifecycle.
The owner checks the committed generation before opening each selected save from the cut’s
database view. It reads and stages one save at a time under an admitted restore_metadata charge
for the inline checkpoint and three fixed 64 KiB bulk buffers, so an accepted guest save can exceed the
node’s bulk budget. The raw section and its descriptor take their bytes and revision from the same
read; a superseded generation is not opened. A failed or interrupted write leaves no published
archive, and retry takes a new cut.
A restore creates the target domain stopped and publishes its models and schedule before installing state. It stages a complete replacement state set on each target node. It accepts a guest save only when its entity and schema fingerprint match the restored schedule, maps the saved branch to the generation that schedule names, and stages the checkpoint on the assigned owner and replicas. Branch lifecycle is staged first. Each node streams saves into bounded chunks in an invisible installation namespace, verifies the complete inventory, synchronizes the namespace and durably selects it with one atomic pointer. Lifecycle, offsets and saves become visible together. Passive handles are cleared only after pointer durability and bounded obsolete-namespace cleanup, under the same installation authority. The installation and publication jobs reserve 2 MiB independently of total guest bytes and checkpoint count; loading a saved guest at START still materializes its bytes under the WASM host’s own limits. Snapshot readers retain one complete selected generation, and queued checkpoint writers cannot cross an installation namespace. The every-node checkpoint maintenance owner also reclaims restored chunks after ordinary, replica, handoff or recovery checkpoints replace their selected header, or after state purge. It retains the exact revision a selected segmented header names, and preexisting snapshot readers keep complete saves across deletion. Backup And Restore owns the sweep’s bounded buffers, accounting, cancellation and separate database allocation limits. The replicated start gate remains closed until every node completes publication, even if the restore fails or its mutation lease is released. Authority binds leader tenure, execution, lease and installation generation; stale local and remote requests cannot republish or clear handles. A checkpoint from a different schema or an entity absent from the schedule is skipped with a diagnostic. The source domain name and owner node are not carried into the restored placement. The guest still validates its saved bytes when the restored domain later starts. The Rust SDK allows the domain name to change when the branch key, domain type, and input and output schemas match; a guest with its own snapshot format may apply stricter identity rules and reject the save.
Branch Ownership
The runtime creates one guest instance per concrete branch. Each instance has its own Wasmtime store, linear memory, guest state, and timeout handles; a node shares only the compiled module between them. See Module Sharing And Branch Memory.
An unbranched processor has exactly one execution, the explicit unbranched instance. It is never a
branch with an empty key: its BranchInit payload carries no branch_key, its state placement
names no branch, and a reset selects it with the explicit UNBRANCHED scope. A branched processor
never has an unbranched instance.
The committed schedule gives each processor one owner and zero or more replicas. The owner runs
every branch’s instance and is the only node that checkpoints guest state. A replica holds durable
copies of the owner’s checkpoints. An ownership move for an active domain runs a guest at the
destination to prove it can restore a checkpoint. A passive revision for a stopped domain carries
the complete validated checkpoint inventory and preserves its guest saves without executing guest
callbacks or reading domain time. The guest validates the save when START installs the running
revision. A stopped clock is not a missing-checkpoint outcome and cannot justify a state reset.
The control plane, the inspection, and the diagnostics and logs of a branch’s guest name a concrete
branch by its fingerprint, a fixed-size digest of the canonical branch-key text, never by its field
values.
A concrete branch that is evicted for its TTL or its instance limit stops without a quiesce flush,
and its live instance and the work it buffered are dropped with it, as for every evicted processor
branch. Its guest-state checkpoint stays. Guest state is addressed by branch key and generation, not
by the incarnation of the branch, so the same key appearing again restores the last checkpoint that
branch committed. Window state differs: an evicted window branch publishes an empty final window.
What Is Durable
A guest saves only the computation state a recreated instance needs to continue: counters, aggregates, open windows, or whatever else it derives from the input it has accepted. Everything an instance uses to execute belongs to that instance.
| State | Where it lives | Survives |
|---|---|---|
Guest computation state, the bytes nervix_dump_state returns | The state store of the owner and of every replica that confirmed it | A restart, and an owner loss when a replica holds it |
| Generations, the latest reset, and the recovery attempts refused lifetimes spent | The committed domain schedule | Everything consensus survives |
| Checkpoint revisions | Stored with each checkpoint; the owner’s next revision is derived from the one it restored | With the checkpoint |
| Input the guest buffers, with its ACK tokens, row sidecars, and input-column references | The instance | Nothing |
| Output groups the guest has not emitted, and output already dispatched to relays | The instance and the relays | Nothing |
| Timeout handles | The instance’s host store | Nothing |
| A guest’s latched error state | The instance | Nothing |
| Acknowledgements held back for a checkpoint | The branch task | Nothing |
| Guest reset requests not yet coordinated, and raised rejected-state recoveries | The owner’s memory | Nothing |
| Checkpoint progress observations | The owner’s memory | Nothing |
| Compiled modules | Each assigned node’s memory | Nothing; recompiled from the pinned version |
A recreated instance therefore starts without buffered input, pending output, pending timeouts, or
error state. The host keeps an instance whose callback failed unless the failure exhausted MAX FUEL or MAX MEMORY, so a Rust SDK guest that latched into error state stays latched, and keeps
saving its computation state, until its instance is recreated. Acknowledgement trees and tokens are
in-memory hot-path state everywhere in Nervix; see Acknowledgement
state.
Guest Lifecycle
The host creates an instance when a branch has none and work arrives for it: the branch’s first input, the first input after a callback exhausted a limit or a checkpoint failed, a node restart, an ownership move, and a replaced lifetime. Creating one runs these operations in order:
- The host instantiates the module into a new branch store.
- It calls
nervix_initwith the branch configuration. - When the branch’s committed checkpoint holds bytes, it calls
nervix_load_statewith them. A branch in a new lifetime has no checkpoint yet, so the instance starts from its initialized state.
The instance then runs callbacks: nervix_process_batch for input, nervix_on_timeout for each due
timeout, and nervix_flush whenever the host force-flushes the branch, which every entity or domain
pause, ownership handoff, reset preparation, and shutdown drain does. After a callback the host
drains nervix_read_emit and saves the instance with nervix_dump_state. A guest asks for a new
lifetime with the nervix_request_state_reset import, which the host accepts only while one of
those three callbacks is running. The ABI also requires a nervix_reset_state export. The host
resolves it when it instantiates a branch and never calls it: every new lifetime starts in a new
instance.
Every guest operation receives its own MAX FUEL budget and one explicit domain execution snapshot.
Instantiation, initialization, restore, each callback, and each save are separate operations; the
emit reads after a callback share that callback’s budget. See Execution
Limits and Execution-Time
Snapshots.
The FlatBuffers protocol decoder, Rust guest SDK callbacks, and Wasmtime host return typed
error-stack reports within their Rust layers. The guest SDK preserves protocol and application
causes through snapshot decoding and restore, then renders a reason at the ABI boundary. The host
keeps the typed call cause beneath the failed guest operation, including its export and execution
limit classification. Rendering does not change the ABI return codes, the global-error latch, or
the rejection verdicts; the same restore reason reaches the host, and a failed callback still
reaches the checkpoint and ACK decisions described below.
A zero-length save means the guest has no state: the next instance is initialized without a
nervix_load_state call. The Rust SDK wraps every save in a GuestSnapshot envelope that also
carries the branch configuration, so empty application state is still restored as state, and a
snapshot taken under another branch configuration is rejected.
For a restore into a differently named domain, the SDK accepts that name change while still
checking the domain type, concrete branch key, and input and output schemas. The saved application
bytes are passed through unchanged.
A restore either succeeds, or the guest rejects the saved state with one of the two reserved verdict
codes, or it fails without a verdict: a trap, an exhausted limit, or another negative code. Only a
verdict classifies the saved bytes as unusable. Each outcome is reported at its own failure
stage, the inputs that were waiting for the
instance go through ON GLOBAL ERROR, and the saved state stays in place, so the next input hands
the same revision to a new instance. Only the processor’s rejected-state
policy acts on a verdict, and only a forced recovery
can discard state after a failed restore.
Placements, Generations, And Revisions
Placements
Every checkpoint is stored, served, replicated, transferred, and restored under a placement: the domain, the processor, the concrete branch or the unbranched instance, the fingerprint of the schemas the processor’s records are laid out by, and the branch’s guest-state generation. A node places guest state only under the identity derived from the committed schedule in its applied typed execution revision, and acts on a placement only while that placement is current. The same revision carries the processor’s reset and pinned module plans through a rebuild or owner change. A replica acknowledgement counts only toward the placement it names. Installed replication routes retain the same entity assignment publication as guest checkpoint execution. Reset lifecycle confirmation retains its route, follows replica reassignment, and refuses replacement or loss of primary ownership before declaring confirmation; neither that wait nor its announcer reads execution maps. See Checkpoint Identity and Membership, Consensus, And Bulk Transfer.
Generations
A generation is one lifetime of a branch’s guest state. The processor’s schedule entry holds a default generation for every branch and a generation of its own for each concrete branch reset separately since the default last advanced. A new generation number is always the successor of the highest one the processor has handed out, so a branch’s generation only moves forward.
| Event | Generation effect |
|---|---|
| The processor is created | Every branch starts in generation 1 |
| A reset of one concrete branch | That branch moves to the next generation; every other branch keeps its own |
| A reset of every branch, or of the unbranched instance | The default moves to the next generation and every per-branch generation is cleared, which also covers branches that exist only as stored checkpoints |
| A forced recovery after owner loss | Every branch moves to the next generation, in the publication that names the new owner |
| A binding change: another resource, version, or module file | Every branch moves to the next generation, in the publication that commits the change |
| A change of the schemas the processor’s records are laid out by | No change; the state moves to a placement under the new fingerprint, which starts without a checkpoint |
| A planned ownership handoff, a limits or policy change, a no-op rebinding | No change |
Only a committed schedule publishes a generation, so every transition is serialized with every other mutation of the domain through the same alteration lease or automatic-decision fence as the schedule itself. Reapplying a committed schedule, after a restart or a domain rebuild, publishes nothing new.
Revisions
The owner stamps each capture with the next revision of its placement. A revision is never reused, including the revision of a checkpoint that failed, because this node’s storage or a replica may still hold it. An owner that restores a placement continues from the revision it restored.
Each branch keeps two checkpoints on its owner. The committed checkpoint is the last one that reached its boundary; a live instance that the owner recreates restores it. The published checkpoint is the newest one on the owner’s stable storage; replicas fetch it, and a restarted owner reopens it. The two differ only after a checkpoint that reached the owner’s storage failed at its replicas.
The Checkpoint
A branch runs one callback at a time, and every callback that leaves an instance behind ends with a checkpoint of that instance:
- an input batch, whether the callback succeeded or failed;
- each due timeout callback, one checkpoint per timeout;
- a quiesce flush that emitted output;
- the checkpoint an ownership handoff takes of an idle instance.
No checkpoint follows a flush that emitted nothing, because it decided no input. No checkpoint follows a callback whose instance exhausted a limit: the instance is discarded, the committed checkpoint already holds the state the next instance restores, and the callback’s decisions are released at once. No checkpoint follows a callback that asked for a new lifetime, because the state it would save is the state the reset discards.
Stages
- Boundary. Before the guest saves, the owner confirms that the placement is current and that
the committed schedule still has this node execute the processor. The replicas the schedule
assigns at this moment are the checkpoint’s boundary; with none, which is always the case with a
replica count of
0, the boundary is the owner’s storage alone. A branch whose generation or ownership has moved on is refused here, before anything is saved, persisted, or replicated. - Captured. The guest saves with
nervix_dump_state, and the bytes are stamped with the next revision. The owner shares the buffer with persistence and replication rather than copying it. - Locally durable. The state store writes the checkpoint on its storage workers, never on the worker running the guest, and returns once a synchronization of the node’s storage covers the write. The owner then announces the revision to its replicas.
- Replica confirmed. Every replica in the boundary has the revision on its own stable storage.
- Committed. The checkpoint becomes the committed checkpoint, and the acknowledgements the callback held back are released. Only then does the branch run its next callback.
The whole checkpoint, from the guest’s save to the last replica’s confirmation, has ninety seconds.
While it is under way, the branch reports every input the callback holds back alive to its source
every 100 milliseconds, as a remote receiver reports an acknowledgement it holds. A checkpoint is
progress of those inputs: a source whose ACK TIMEOUT is shorter than the checkpoint does not time
them out and send them again while the checkpoint can still commit them, whether the source runs on
the owner’s node or on another. Input the guest keeps undecided past its callback is not held for a
checkpoint and is not reported alive.
The replica first receives a small revision, length, and digest description, then fetches that
revision through the bulk pool in 64 KiB chunks. It verifies the complete byte count and digest
before writing the checkpoint to its own storage. A save up to the host’s 64 MiB guest buffer can
therefore cross the 2 MiB replication-message limit. The bulk fetch has a sixty-second progress
deadline inside the checkpoint’s ninety-second completion budget.
Local Durability
The state store’s durability barrier issues a ticket to every writer after its write is applied. At most one writer runs a synchronization at a time; it covers every ticket issued before it started, and the writers still waiting elect the next runner when it did not cover them. Branches that checkpoint at the same time therefore share one synchronization instead of queuing one each. No lock is held across the wait; see Bounded Synchronization Outside The Open Path.
A failed synchronization is not retried. The operating system may have dropped the writes it failed to flush, and the database refuses every later synchronization, so from then on no write on that node is reported durable and every later checkpoint there fails until the node restarts.
Replica Confirmation
A replica acts on an announcement only while the placement is current on it and the announcing node is the owner its schedule names. The announcement wakes the replica task that keeps the processor’s branches current, which fetches the checkpoint in its next round. While idle, the task also starts a round after the one-second replication poll interval, so a lost announcement is recovered by periodic synchronization. Lifecycle, catalog and checkpoint requests each retain their typed five-second response deadline, including time waiting for transport capacity; the poll interval does not cancel a request still within that deadline. A busy or failing replica can take longer than one interval to catch up. The subsequent bulk fetch has a sixty-second deadline, and the owner’s whole checkpoint has ninety seconds to complete. The replica installs a revision newer than the one it holds, synchronizes its storage, and only then acknowledges; a replica that already holds the announced revision or a newer one synchronizes and acknowledges what it holds, so a lost acknowledgement is replaced by the next announcement. Each round synchronizes the owner’s branch lifecycle before it installs a branch checkpoint, and the replica refuses a checkpoint only when that lifecycle does not name the branch, as for an evicted branch. A node without stable storage acknowledges nothing.
While the checkpoint waits, it wakes on each replica report and rereads the schedule at least every 100 milliseconds. A replica the schedule replaces is replaced in the wait. The wait fails when the schedule assigns fewer replicas than the checkpoint was captured for, when this node no longer executes the processor, and when the deadline passes with a replica still missing.
Acknowledgement Holds
A callback’s output is validated as a whole and dispatched before its checkpoint; only the
acknowledgements wait. For every input the callback decided successfully — carried into an output
row, listed as acked, routed through ON MESSAGE ERROR, or accepted by ON GLOBAL ERROR IGNORE
after a failed callback — the branch task attaches one more share to the input’s acknowledgement and
owns it for the length of the checkpoint. The input succeeds once every delivery the callback made
for it succeeded and that share is released. nacked inputs are negatively acknowledged at once.
See Checkpoints And Acknowledgements.
The holds gate the source only on an ATTACHED processor, which is the default. A DETACHED
processor receives its input after relay fan-out has already acknowledged it upstream, so no source
acknowledgement waits for its checkpoints, although its guest state is checkpointed the same way.
When A Checkpoint Fails
| Stage | What failed |
|---|---|
state authority check | The placement is no longer current on this node, or the schedule no longer has this node execute the processor. |
state snapshot | nervix_dump_state returned a negative code, trapped, exhausted a limit, or exceeded the host’s 64 MiB guest buffer. |
local state persistence | The write or its synchronization failed, or did not finish before the deadline. |
state replication | A replica did not confirm before the deadline, or the schedule assigns fewer replicas than the checkpoint was captured for. |
Whatever the processor’s ON GLOBAL ERROR policy, the failure is reported as a runtime error at its
stage. Every input the callback decided, and every input the guest still buffers, is negatively
acknowledged. The committed checkpoint stays where it was. The instance is discarded with its
buffered input, pending output, and timeouts, and the next input for the branch recreates the guest
from the committed checkpoint.
Failure At Each Boundary
A checkpoint’s stages are durability boundaries, and schedule publications are authority boundaries. Where an owner stops relative to them decides which state survives.
| The owner stopped | Its storage holds | A replica holds | A restart of that node continues from | After owner loss, the branch continues from |
|---|---|---|---|---|
| After dispatching output, before the save | The previous checkpoint | The previous checkpoint | The previous checkpoint | The previous checkpoint |
| After capture, before local durability | The previous checkpoint | The previous checkpoint | The previous checkpoint | The previous checkpoint |
| After local durability, before every replica confirmed | The new checkpoint | The previous or the new one | The new checkpoint | Whichever the promoted node selects as newest |
| After every replica confirmed, before release | The new checkpoint | The new checkpoint | The new checkpoint | The new checkpoint |
| After the acknowledgements were released | The new checkpoint | The new checkpoint | The new checkpoint | The new checkpoint |
In every row but the last, the callback’s output may already have left the node and its inputs were not acknowledged, so a source with acknowledgements redelivers them. Without replicas, only the owner’s storage holds a branch’s checkpoints, so recovering without that node resets the branch. Replay, Duplicates, And Exactly-Once describes what a redelivered input does.
| Authority boundary | Failure before it | Failure after it |
|---|---|---|
A reset’s Publishing schedule is applied | The previous lifetime stays authoritative; the owner restores the branch tasks it stopped | The new generation is authoritative and cannot be rolled back; the scope stays fenced until the same request reaches Ready |
A reset’s Ready schedule is applied | The reset is committed but not usable, and input of the scope is refused | The new lifetime is usable and the scope reopens |
| A recovery attempt is recorded | Nothing is spent; the next refusal raises the lifetime again | The attempt is spent whatever the reset achieves |
| A forced recovery schedule is published | Every checkpoint stays where it was, and the next recovery prepares again | The new owner continues in the new generation from the checkpoints it staged, or without state when its preparation failed |
| A binding change is published | The previous binding and its checkpoints stay current | Every branch starts the new binding without guest state |
Ownership Fencing
Recovered And Moved Owners
A restarted node executes nothing until a linearizable read proves it has applied the committed schedule, so a former owner cannot resume a branch the cluster moved while it was away. See Recovered Ownership Is Fenced. During execution, the boundary check at the start of every checkpoint, and the schedule rereads while it waits for replicas, refuse a checkpoint whose placement or ownership moved on while its callback ran.
A planned ownership handoff keeps the generation. Intake and boundary admission close first. Affected branches remain runnable until their admitted work and requested force flush complete; only then does the participant freeze their state for capture. The capture reads the completed checkpoints without asking a frozen guest to flush. The source sends the destination a small revision, length and digest description for each branch. The destination fetches each exact checkpoint that capture retained in the source’s stable storage through the bulk stream and verifies it before restoring the guest. A fetch or restore failure ends the handoff before the schedule changes. The replacement instances then restore the transferred checkpoints. See Planned Ownership Handoffs And Failover.
Forced Recovery
When an owner is lost, the leader publishes the new owner together with a new generation for every
branch. The destination prepares the recovery within five seconds: for each branch it collects the
checkpoints of the generation being replaced from its own storage and from every other node the new
schedule assigns the processor, and stages the newest valid one. Two different checkpoints at the
same highest revision are a conflict, and the branch is reset. A branch with no surviving checkpoint
is reset. The destination restores every staged checkpoint into a guest before the schedule is
published, a promoted replica from the module it compiled while it was a replica, and activation
publishes the staged checkpoints in the new generation. SHOW CLUSTER STATUS reports the transition
with state_recovery=reset and resets=wasm_processor:<cause> when it reset branches.
A preparation that fails for any reason — a guest that refuses or fails to restore a staged
checkpoint, a module the destination cannot compile, or the five seconds running out — makes the
leader publish the recovery with recreated state: every state component of the processor is reset
with the cause missing_checkpoint. ON REJECTED STATE does not govern this path. When the
processor has replicas, a replica holds every checkpoint whose acknowledgements the lost owner
released, so a recovery that prepares successfully continues each branch from at least the state its
acknowledged inputs produced. See When The Former Owner Is
Gone.
A leader that has just started reconciling, as the first leader after a whole-cluster restart is, makes no automatic scheduling decision for ten seconds while any voter is neither reported live nor declared dead, so an owner that returns within that grace keeps its work and restores its own checkpoints instead of being failed over. See Whole-Cluster Restart Keeps Ownership.
Stale-Node Catch-Up
A node that was away installs the committed schedule before it accepts any runtime state, and then synchronizes only the placements that schedule names. Applying the committed schedule of a running domain removes every stored checkpoint whose placement is no longer current: state of a replaced generation, state laid out by a replaced schema fingerprint, and state of a node the schedule dropped. A reset or a forced recovery therefore never sweeps the cluster to delete old bytes: addressing by generation makes them unreachable at once, and each node removes them locally when it applies the schedule.
Why State Does Not Resurrect
A lifetime that a reset, a forced recovery, or a binding change replaced cannot become current again:
- a generation only moves forward, and only a committed schedule publishes one;
- every checkpoint is written, served, installed, transferred, staged, and restored under the generation the committed schedule names for its branch, so a snapshot of an earlier generation is never current, whatever revision it carries;
- a replica acknowledgement counts only toward the placement it names, so an acknowledgement for a replaced generation never satisfies the current one;
- a handoff or forced-recovery preparation is bound to the fingerprint of its complete target schedule, which covers every generation, so a preparation staged before a transition cannot activate after it;
- a restarted former owner is inert until it has applied the committed schedule, and a checkpoint of a branch whose generation moved on is refused before it is saved.
These fences compare generations and schema fingerprints, and they order checkpoints within one placement by revision. A schema change keeps the generation and moves the state to a placement with the new fingerprint, and each node removes the replaced placement’s checkpoints when it applies the committed schedule of the running domain. A node that did not apply that schedule while the domain ran, because it was down or the domain was stopped, keeps them. When the processor’s schemas later return to the earlier fingerprint, that placement is current again, and so is every checkpoint kept under it: an owner that kept one restores it, a replica that kept one with a higher revision than the owner’s acknowledges each new checkpoint with the revision it already holds, and a forced recovery selects the kept checkpoint as the newest.
Quiescence
A WASM processor participates in quiescing like every other stateful node. An ENTITY_PAUSE model
change, RELOCATE, a drain, and a planned handoff gate the processor’s input relays, force-flush
its branches, which calls nervix_flush, checkpoint them, and wait for the acknowledgements those
checkpoints hold. A guest that keeps input past nervix_flush leaves it unacknowledged until the
branch resumes. See ALTER Lock And Quiesce
Classification and Quiesce
Flush.
A reset gates more narrowly: its branch-selective relay gate holds back only dispatches to the selected scope, and sibling branches keep running. See Dispatch-gate engagement.
Coordinated Reset
A coordinated reset replaces the lifetime of one scope of one WASM processor: the explicit unbranched instance, one concrete branch, or every concrete branch. One operation on the leader serves every trigger.
| Trigger | Reason reported | Request reference | Entry |
|---|---|---|---|
RESET WASM PROCESSOR ... STATE | TRANSACTION | The statement’s command execution reference | An ordered transaction step |
| A guest request | GUEST | wasm-guest-reset. followed by a digest of the domain, processor, branch, and generation | The owner hands the request to the leader |
| Rejected-state recovery | REJECTED_SNAPSHOT | wasm-recovery.<generation>. followed by the branch fingerprint or unbranched | The owner raises the refusal to the leader |
OPERATOR names a reset requested of the coordinator directly rather than through a transaction; no
public interface issues one.
Admission
The leader admits a reset only when the domain exists and is running, the processor is scheduled
with an owner, and the scope matches the processor’s declaration: UNBRANCHED only for an
unbranched processor, and a concrete branch or every branch only for a branched one. A concrete
branch is named by exactly the declared branch-key fields with exact typed values, and it must be a
branch the owner is running. The reset takes the domain’s exclusive alteration lock, so a concurrent
reset, lifecycle, model, placement, handoff, or rebinding operation is rejected rather than queued.
While a reset of the processor is Publishing, only the same request can proceed, and reusing a
request reference with a different scope or reason is rejected.
Phases
- Gate. The leader engages the reset’s branch-selective gate on every live node for each input relay of the processor, bound to its coordination identity and scope, and waits for dispatches already admitted to that scope.
- Prepare. The owner stops each selected branch at its callback boundary through the
processor’s command lane. Accepted input drains first, and completed callbacks with their
buffered output are finalized once through the ordinary output, checkpoint, and acknowledgement
paths. Work suspended on a materialized dependency is discarded and negatively acknowledged, and
the old timeout handles are cancelled. The owner then creates a fresh instance for every selected
branch, initializes it without
nervix_load_state, and keeps its first save in memory. A failure here restores the stopped branch tasks and releases the gate; nothing durable has changed. - Publishing. The leader commits the schedule that advances the selected generation and records
the reset with its request, scope, reason, and the
Publishingphase. Once this schedule is applied the old checkpoints are unreachable and the reset cannot roll back. Live replicas install the new placement before the owner, in an activation ordered by the coordinator rather than the ordinary cluster runtime-revision barrier, because an owner whose initial checkpoint fails is the node that would keep that barrier incomplete. - Initial checkpoint. The owner installs the fresh branch tasks, republishes the branch lifecycle that names them and waits for its replicas to hold it, writes each fresh instance’s first save as the initial checkpoint of the new generation, and waits for every assigned replica.
- Ready. The leader commits the same reset as
Ready, every live node applies it while the scope is still gated, and the gate is released.
From Publishing until Ready, the owner negatively acknowledges every input of the selected
scope, so a source with acknowledgements redelivers it into the new lifetime; sibling branches run.
A restart, a leader change, or an owner recovery reads the published phase and keeps the scope
fenced. A reset does not touch the domain clock: it changes no lifecycle generation, mapping,
authority, or frontier, and no deadline armed by the replaced instance fires in the new lifetime.
See Execution-Time Snapshots.
Failure And Retry
A failure before Publishing reports an ordinary reset failure, and the previous lifetime stays
usable. A failure after it reports that the reset was committed but its new lifetime is not usable.
Retrying the same request reference resumes the missing initial checkpoints and Ready publication
without advancing the generation again. An NSPL reset completes only at Ready; its transaction
step is recovered with its original execution reference after a leader change, a lost reply returns
the retained outcome, and an expired reference cannot start another reset. See Coordinated WASM
Reset Publications,
Command Completion, and Coordinated WASM
Guest-State Reset.
Guest-Requested Reset
The host answers nervix_request_state_reset with 0 while nervix_process_batch,
nervix_on_timeout, or nervix_flush runs, and with -9 during every other operation, which
schedules nothing. An accepted request is terminal for the callback that made it, whether the
callback then succeeds or fails:
- output the callback emitted is discarded;
- every input the branch holds, including input the guest still buffers, is negatively acknowledged;
- no checkpoint is taken;
- the instance is dropped with its pending timeouts;
- effects earlier callbacks published stand, because their checkpoints completed.
The branch then fences itself: it refuses, and negatively acknowledges, every input until the reset stops its task, so it can never instantiate the guest again from the lifetime being replaced. The owner holds at most one request per branch in memory and hands it to the leader, and drops it instead when the committed schedule shows the branch has already left the generation it was made from. The request reference is derived from the domain, processor, branch, and generation, so every request the guests of one lifetime make is the same reset, and the first request from the next lifetime is a new one.
A reset that fails is reported as a runtime error naming the processor and domain. If it failed before the owner stopped the branch, the branch stays fenced, and each input it refuses states the request again. If the owner had stopped the branch, it restored it, and the guest continues in the lifetime that could not be replaced and asks again from its next callback. A node that stops before a request is coordinated loses it; the instance recreated from the same committed checkpoint asks again. Acceptance is never proof of a new lifetime: a guest learns that only by being initialized without a restore. See Guest-requested reset.
Rejected-State Recovery
A guest’s verdict on the snapshot it is handed is -7, when it cannot decode the snapshot envelope,
or -8, when it refuses the application state inside it. ON REJECTED STATE PRESERVE, the default,
keeps the refused snapshot: every later instance of the branch is handed the same revision and
reports the same failure until a reset or a binding change replaces the lifetime. ON REJECTED STATE RESET replaces the refused lifetime once. No other failure reaches the policy, so compilation,
initialization, fuel, memory, trap, storage, replication, and authority failures never discard state
through it.
The owner raises a refused lifetime once per placement and holds the raise until the leader answers; at most 32 distinct refused lifetimes wait on one node, and a refusal beyond that is raised when the branch is refused again. A node that is not the leader forwards the raise. The leader then:
- refuses the recovery when the processor’s policy is not
RESETor the domain is not running, and does nothing when the branch has already left the refused generation; - records in the committed schedule that this refused lifetime is spending its one attempt, before anything is reset, or finds the attempt already recorded;
- runs the coordinated reset under the reference derived from the scope and the refused generation;
- records what the attempt achieved.
A refusal or failure is reported as a runtime error naming the processor and domain.
| Recorded outcome | Meaning |
|---|---|
attempted | The attempt is admitted and its reset has not reported yet. A resumed attempt drives the same reset. |
recovered | The reset published a fresh lifetime and the branch resumed on it. A later refusal of that lifetime is a new failure with an attempt of its own. |
failed | The attempt ended without a usable lifetime. The budget is spent; the branch keeps reporting its refusal instead of resetting again on every record. |
A reset that fails after Publishing leaves the scope fenced exactly like any other committed but
unusable reset. The attempts are control-plane state, so a restart, a leader change, and an owner
change read the same budget, and a binding change clears them because it starts every lifetime anew.
Only an instance the owner creates for live work raises a refusal: a planned handoff whose
destination is refused fails, and a refused forced-recovery preparation resets the processor as
described in Forced Recovery. See Recovering A Rejected
Snapshot.
Rebinding And Rollback
A generation belongs to the module binding it was published for: the resource, its pinned version, and the module file. A model change that binds another one starts a new generation for every branch in the publication that commits it, clears the processor’s latest reset and its recovery attempts, and the replacement instances initialize from the new module without guest state. No state is migrated between module versions. A change of the execution limits, the global error policy, or the rejected-state policy, and a rebinding that leaves the version unchanged, keep every generation.
The leader compiles the candidate module before the change commits, and activation prepares the
same pinned module from the committed schedule’s typed resource plan. Running builds and entity
swaps prepare it before replacing execution; a retained compiled module is reused. The prepared
processor revision also includes the schedule’s guest-state generations, so a reset or rebinding
cannot reuse a plan from the previous lifetime. A module that does not compile rejects the whole
statement before any effect,
while the previous binding and its checkpoints are still current. A rebinding is ENTITY_PAUSE for
a running domain: the processor and its downstream pause and each branch is flushed. A rebinding in
a stopped domain publishes the new generations as well, so the processor starts without guest state.
A reset after a rebinding starts another lifetime on the rebound module, and DESCRIBE WASM PROCESSOR never presents a reset of the previous binding as a reset of the new one. See
Classification And State Effects.
Shutdown And Restart
A shutdown drain waits for the acknowledgements a WASM branch holds, as it waits for any outstanding acknowledgement, and the checkpoint deadline bounds that wait. Runtime teardown gives each processor task two seconds to stop, and the processor task gives each of its branch tasks two seconds inside that. A branch task never outlives its processor task: one still in its checkpoint is ended with it, its unreleased acknowledgements are negatively acknowledged, and a restart finds whatever that checkpoint had already written. See Draining Admitted Work.
A restarted node reopens each branch at the newest checkpoint on its own storage under the identity
the committed schedule publishes, which covers every input its acknowledgements released. A reset
interrupted before Publishing leaves the previous generation authoritative; one interrupted after
it resumes the new generation on restart. Guest reset requests and raised recoveries do not survive
a restart; the next callback or refusal raises them again, and a spent recovery attempt stays spent.
See What Survives for the kinds of ending and the state each keeps.
Replay, Duplicates, And Exactly-Once
Snapshot recovery guarantees one thing: once a source has received a successful acknowledgement for an input, the guest state that reflects the input is on the stable storage of the branch’s owner and of every replica the checkpoint was confirmed by. It is not an exactly-once guarantee for the guest or for external delivery:
- output is dispatched before its checkpoint, so a sink can publish output whose input is later negatively acknowledged and redelivered, and the redelivered input produces it again;
- the state a branch continues from can already reflect a redelivered input: a guest counts buffered input as it accepts it, a checkpoint that failed at its replicas stays on the owner’s storage, and a replica can hold a checkpoint whose acknowledgement arrived too late;
- a source that keeps redelivering while no owner accepts the input can deliver it to the recovered branch more than once;
- a source without acknowledgements redelivers nothing, and loses the input in every window before local durability.
A guest therefore applies a redelivered input again unless it recognizes the input itself, and exactly-once external effects need a sink whose writes are idempotent. See Recovery, Replay And Duplicates and ACK Semantics And Effective Delivery.
Observability
| Signal | What it shows |
|---|---|
DESCRIBE WASM PROCESSOR <name> | The pinned resource, resource version, and file, the rejected state policy, state default generation, the latest reset as state reset: <PUBLISHING or READY>, <scope>, generation <n> with its reason and readiness, one rejected state recovery line per recorded attempt, the checkpoint counts, and one checkpoint line per branch with its fingerprint, generation, committed and latest revision, stage, and required and confirmed replica counts |
DESCRIBE WASM PROCESSOR <name> FORMAT JSON | The same typed state inspection, including each reset’s request reference; null for a processor that is not scheduled |
| Client outcome | CommandOutcome::wasm_state carries the typed inspection beside either text rendering |
DESCRIBE TRANSACTION | The planned and actual effect of a transaction’s RESET WASM PROCESSOR step |
SHOW CLUSTER STATUS | A forced recovery’s state_recovery outcome and the wasm_processor component it reset with the cause |
| Runtime errors | Every failure in the diagnostic shape, a reset that could not replace a guest-requested lifetime, and a rejected-state recovery that failed or was refused; delivered to the sessions attached to any node |
| Server log | At info: WASM guest-state reset generation published and WASM guest-state reset became usable on the leader, wasm guest requested a new branch state lifetime and raised a refused WASM guest-state lifetime for recovery on the owner, and WASM rejected-state recovery admitted and WASM rejected-state recovery settled on the leader |
The inspection is read-only. It samples the owner’s published checkpoint observations without taking
a branch’s execution lane, requesting synchronization, or advancing a checkpoint, and it counts only
checkpoints of the generation the committed schedule names. Checkpoint counts cover every branch the
owner holds state for; branch and recovery details are limited to 128 entries each, with the rest
reported as omitted. A stage is EMPTY while the branch has no checkpoint in its lifetime,
CAPTURED before local storage, LOCALLY_DURABLE before its boundary is complete,
REPLICA_CONFIRMED once it is, which without replicas reads 0 of 0, and FAILED when the
latest checkpoint failed, with the previous committed revision still current. A checkpoint the owner
restored rather than took reports LOCALLY_DURABLE with unknown replica counts, because its earlier
boundary cannot be reconstructed. Reset readiness is RESETTING while the new lifetime’s checkpoint
is unfinished, AWAITING_USABLE_EXECUTION when a selected single-scope checkpoint is confirmed but
the reset is still Publishing, and READY once Ready is published; an all-branches reset stays
RESETTING until then, because a sample of the current branches cannot prove every selected branch
completed. Nervix exports no WASM-specific metric.
Checkpoint stage totals and omitted-entry counts use the vocabulary’s fixed-width 64-bit archive adapter, so an interconnect response preserves their complete native magnitude. Decoding refuses a count the receiving target cannot represent. See Archived Counts.
Guarantees And Limits
Nervix guarantees:
- No acknowledgement before durability. An
ATTACHEDWASM processor releases no successful acknowledgement until the checkpoint covering it is synchronized on the owner and on every replica its boundary requires. - Failed checkpoints change nothing committed. A checkpoint that fails leaves the committed checkpoint in place, negatively acknowledges what it covers, and discards the instance.
- Branch isolation. Every branch has its own instance, checkpoints, holds, and lifetime; a reset of one branch leaves every sibling’s generation and task intact.
- One lifetime per request. Retrying, forwarding, or resuming a reset under its request reference never advances the generation twice, and a spent recovery attempt is never spent again.
- No resurrection by transition. A lifetime replaced by a reset, a forced recovery, or a binding change never becomes current again.
- Strict restoration. Only a guest’s own verdict can make
ON REJECTED STATE RESETdiscard a lifetime.
Nervix does not provide, and these limits apply:
- Exactly-once processing or delivery. See Replay, Duplicates, And Exactly-Once.
- Guest state across module versions. A binding change always starts a new lifetime.
- A checkpoint deadline of ninety seconds, from the save to the last replica, and one callback per branch at a time, whose acknowledgements are held for at most that long.
- A save of at most 64 MiB, the host’s guest buffer limit.
- Prepared handoff memory grows with its branch inventory. The destination retains every verified branch checkpoint until activation; the 256 MiB limit applies to each transfer, not the entity’s aggregate state.
- Loss of state without replicas. Recovering a processor without its owner resets every branch whose only checkpoints were on that owner.
- Forced recovery can reset state without a verdict. A recovery preparation that fails or runs past five seconds recreates the processor’s state.
- A failed storage synchronization is permanent until the node restarts.
- A whole-cluster restart keeps ownership only for owners that rejoin within the ten-second voter observation grace.
- Evicted branches keep their state. A branch key that returns after eviction restores its last committed checkpoint.
- A schema that returns to an earlier fingerprint can make a checkpoint kept by a node that missed the intermediate schedule current again; see Why State Does Not Resurrect.
- A
DETACHEDprocessor gates no source acknowledgement on its checkpoints. - Guest requests and recovery raises are volatile, and are raised again by the next callback or refusal.
Qualification Evidence
The WASM state qualification pins each checkpoint window with a harness-only pause and ends the owner there, on one node and on three nodes with one replica, with interleaved records of two branches.
| Guarantee | Evidence |
|---|---|
| Crash windows and replay | wasm_state_qualification.feature: the four checkpoint windows on a single-node restart, owner loss at the windows it can tell apart, a cluster stopped while its owner holds a checkpoint, and a reset committed but not usable across a cluster restart |
| No acknowledgement before durability | wasm_checkpoint_durability.feature, and the Shuttle checks of ReplicatedWasmProcessorState, WasmCheckpointHolds, and DurabilityBarrier under random, PCT, and DFS schedules |
| No replay while a checkpoint is under way | wasm_checkpoint_durability.feature: a checkpoint held past its source’s ACK TIMEOUT on one node and on three nodes with one replica commits its input without the source sending it again, and the WasmCheckpointHolds Shuttle checks race its keepalive with the input’s delivery |
| No resurrection, no double reset | wasm_state_reset.feature: failover, retry, a lost reply across a leader change, an expired identity, and rebinding |
| Recovery budget | wasm_state_recovery.feature: preserve, recover once, and a failed fresh initialization that spends the attempt across a restart |
| Restart | wasm_processor.feature: guest state restored after a cluster restart and not resurrected by a returning former owner |
| Large backup and restore | backup_guest_saves.feature: two 40 MiB branch saves on one and three nodes, bounded bulk capture, interrupted capture cleanup, exact restored bytes and the next guest callback |
The WASM state qualification ledger records the commands, the defects the qualification found and fixed, the validation record, and the measured cost of a durable checkpoint: 3 to 18 milliseconds per round for one branch, and 6 to 37 milliseconds for sixteen concurrent branches that share a synchronization.
Retained Checkpoint Assignment
A branch’s guest-state owner retains its entity assignment publication. Schedule installation publishes state identity and checkpoint executors/replicas together before a reset callback can checkpoint. Each callback and replica-boundary replan reads that retained slot. Replacement lifetimes and removed entities are rejected before guest capture; losing local execution ownership also rejects the checkpoint. The captured replica count and checkpoint deadline keep their existing meaning. Removal publishes absence before dropping registry interest, so a retained reader cannot resurrect an assignment.
Shutdown And Recovery
Shutdown is the ordered transition from a running node to a stopped process. It has one owner, three phases, and one deadline. The owner establishes that the node is terminating, stops new intake, keeps the services that admitted work depends on alive until that work finishes, and only then tears down the node’s own tasks, connections, and storage.
This chapter defines the externally observable contract: what starts a shutdown, what each phase
guarantees, what bounds it, what survives it, and what a restart recovers. It covers both endings.
A graceful shutdown completes admitted work within its deadline. A forced ending — a repeated
signal, an expired deadline, or SIGKILL — abandons the remaining phases, and the rest of the
cluster observes it exactly as it observes a crash.
Nervix is not an exactly-once system, and shutdown does not make it one. The guarantees below stop at durable boundaries that are stated explicitly. See What It Is Not for the persistence boundary and ACK Semantics And Effective Delivery for the delivery consequences at each sink.
Stop Requests
A node begins shutting down for exactly two reasons:
- It receives its first
SIGINTorSIGTERM. - One of its public listeners stops, whether it failed or returned.
There is no NSPL statement, API call, or cluster command that stops a node. CORDON NODE,
DRAIN NODE, RELOCATE, and DROP NODE move work and change eligibility; none of them ends a
process. Stopping a process is an operator or supervisor action delivered as a signal.
Both signals are registered before the node starts any other work, and they stay registered until
the process exits. A node that cannot register them refuses to start, reporting
failed to register termination signal handlers and exiting with status 1. A diagnostic node,
the server built in the deloxide mode, then installs its deadlock detector before it starts a
runtime worker, so the detector’s threads exist only once neither signal can end the process by its
default action; a diagnostic node whose deadlock diagnostics cannot start refuses to start,
reporting failed to start the diagnostic node's deadlock diagnostics and exiting with status 1. A signal that arrives
while the node is still starting is held rather than lost: it takes effect once startup completes,
while its deadline runs from the moment the signal arrived.
Repeated requests are idempotent. A later stop request never restarts, extends, or replaces the first one; it can only end the process sooner.
Phases
Shutdown reports three phases in order. Each logs when it finishes and with what outcome.
| Phase | Log message | What it owns |
|---|---|---|
| Stop admission | shutdown admission phase finished | Advertising the terminating incarnation, then closing the public listeners and the client connections they had accepted |
| Drain support | shutdown drain-support phase finished | Moving scheduled work to a replacement node when one exists, then completing admitted work in place, while the services that work depends on stay alive |
| Terminal teardown | shutdown terminal-teardown phase finished | Stopping the node’s tasks, then the runtime, consensus, cluster membership, and interconnect, and releasing its storage |
stop request exit 0
(first SIGINT or SIGTERM, ^
or a public listener that stopped) |
| |
v |
StopRequested ------> DrainSupport ------> TerminalTeardown ------> Finished
| | |
+---------------------+----------------------+
|
repeated SIGINT or SIGTERM -> exit 130 or 143
shutdown deadline expired -> exit 1
(the remaining phases do not run)
Each phase reports one of three outcomes:
- Completed. The phase finished its work.
- Abandoned. The phase gave up on work it could not finish, such as a drain that timed out or a leader it could not reach, and shutdown continued.
- Forced. The deadline passed while the phase was running.
A process that reaches terminal teardown with any phase Forced exits with status 1 rather than
0, because graceful shutdown did not finish within its deadline. The absence of
shutdown terminal-teardown phase finished in a node’s log is the definitive sign that the process
did not shut down gracefully.
The Shutdown Deadline
One deadline bounds all three phases. It is set by --shutdown-timeout
(NERVIX_SHUTDOWN_TIMEOUT, default 50s) and measured on the process monotonic clock from the
first stop request.
The deadline is absolute. A later signal does not extend it, a client holding a session or an upload open does not extend it, and domain pacing never changes its physical length: a domain running a million times slower or faster than real time reaches the same physical deadline. A separate supervisor enforces it independently of the runtime, so a wedged or saturated node cannot outlive its own deadline by failing to schedule work.
When the deadline passes, the process logs the phase it had reached, reports
shutdown deadline expired; abandoning graceful shutdown, and exits at once with status 1 without
running its remaining phases.
Inside that deadline sits the drain timeout, --drain-timeout (NERVIX_DRAIN_TIMEOUT, default
30s), which bounds the drain-support phase alone. The two defaults are ordered so that a full
drain still leaves time for terminal teardown. Every drain step waits for the smaller of its
remaining drain budget and the remaining shutdown deadline, so shortening the shutdown timeout below
the drain timeout makes the shutdown deadline the effective bound. The one exception is the release
of the cordon the node’s own drain set, which keeps a one-second grace when moving the node’s work
used the whole drain timeout; see Releasing The Drain Cordon.
Two bounds are deliberately outside the deadline. Terminal teardown’s final stops — the runtime,
consensus, cluster membership, and the interconnect — run to completion rather than being cut short,
because stopping them is what releases the node’s tasks, connections, and storage. The interconnect
applies its own ten-second transport drain. Closing the node’s databases synchronizes their journals
and blocks until their background workers have left, so the services that own them are dropped
last, as one job on the node’s filesystem storage workers; a node that cannot take that job reports
teardown Abandoned. A background worker whose flush or compaction failed never holds that close
open; see Terminal Teardown. The deadline supervisor, not those bounds, is what
guarantees the process ends.
Exit Status
| Ending | Status |
|---|---|
Graceful shutdown finished, every phase Completed or Abandoned | 0 |
Graceful shutdown ran to the end but a phase was Forced | 1 |
| Shutdown deadline expired | 1 |
| A public listener, cluster shutdown, or storage release reported an error | 1 |
| Termination signal handlers could not be registered at startup | 1 |
| A diagnostic node’s deadlock diagnostics could not start | 1 |
| A diagnostic node reported an active deadlock among its tracked locks and recorded it | 3 |
| A diagnostic node reported a deadlock it could not record, lost findings, or outlived the budget for recording one | 4 |
| A command-line option or its environment variable holds a value the node cannot read, such as duration text that names no duration | 2 |
Repeated SIGINT | 130 |
Repeated SIGTERM | 143 |
SIGKILL | Terminated by signal, no exit status |
Every SIGINT or SIGTERM after the first abandons graceful shutdown. The process logs
repeated termination signal received; abandoning graceful shutdown with the phase it had reached
and exits immediately with the status a shell reports for that signal, whichever signal it was that
started the shutdown. No destructor, exit handler, or remaining phase runs. Whichever comes first,
the repeated signal or the deadline, decides how the process exits.
A diagnostic node that reports an active deadlock ends the same way, immediately, whatever phase it
is in: its blocked threads hold work it can no longer finish. It describes the cycle on standard
error, records it as evidence, and exits with 3, or with 4 when the recording failed, without
running a destructor, an exit handler or a remaining phase, as
Diagnostic deadlock detection
describes. Its durable state recovers as after any forced exit.
Execution Primitives owns how the
diagnostic binary is selected and why it is a separate artifact from an ordinary release.
Terminating Incarnation And Placement Eligibility
An incarnation is one process run of a named cluster node. A restart produces a new incarnation of the same stable node name.
The first act of shutdown is to advertise that the current incarnation is terminating. That advertisement belongs to the incarnation, not to the node name, and is carried by cluster gossip rather than persisted. A restarted process is a new incarnation and does not inherit it.
Terminating removes the incarnation from consideration as a new placement destination. It does not remove the node from anything else: the incarnation stays a live Raft voter, keeps its interconnect listener and registered handlers, keeps carrying admitted relay work and acknowledgements, and can still complete an ownership handoff that is already in progress. A node that is shutting down does not leave the Raft membership, does not step down, and does not transfer leadership away. It simply stops, and its peers observe it going silent.
An administrative cordon is the other eligibility control, and the two are independent:
| Cordon | Terminating | |
|---|---|---|
| Applies to | The stable node name | One process incarnation |
| Set by | CORDON NODE <node_id>, and by DRAIN NODE | The process itself, at its first stop request |
| Stored in | Replicated consensus state | Cluster gossip |
| Survives restart | Yes | No |
| Cleared by | UNCORDON NODE <node_id> | Process exit |
| Visible as | raft.cordoned_nodes in SHOW CLUSTER STATUS | terminating: true in node status |
Both filters compose when the scheduler chooses an owner: a candidate must be a live Raft voter
that is neither cordoned nor a terminating incarnation. RELOCATE and DESCRIBE RELOCATION apply
the same checks in a fixed order and name the first one that fails — not a Raft member, then not a
live Raft voter, then node '<node_id>' is terminating, then node '<node_id>' is cordoned.
The planning snapshot retains terminating live voters separately from the composed destination
set. A committed cordon cannot classify a live process as terminating, and a process with both
states retains its termination reason.
The planning fence uses those composed destination candidates. After the drain’s cordon commits, observing the former owner’s terminating advertisement does not invalidate its pending move: that owner was already excluded as a destination. Its live incarnation remains a required input, so a restart or loss of that owner still invalidates the captured plan. Changes to other eligible destinations and to the committed topology retain their own fences.
Cordon Preservation
A graceful shutdown that moves work away cordons the node so its own work does not come back to it mid-drain, and clears that cordon afterwards. It records whether the node was already cordoned before it started, and clears only a cordon it set itself.
An operator cordon therefore survives shutdown and restart. A node cordoned by an operator, stopped, and started again comes back cordoned. When the node never requested its drain, because the drain timeout or shutdown deadline passed before it observed a leader, nothing was cordoned and nothing is cleared. A request the leader may have received counts as requested, even when its answer never arrived, so the node still clears the cordon that request may have set.
Releasing The Drain Cordon
Releasing the cordon is one consensus write, made by the leader: by the node itself while it leads,
and otherwise by the leader it asks over the cluster interconnect. The stopping node waits for that
write for what remains of the drain timeout, and for at least one second when moving its work used
the whole drain timeout, so a drain that timed out still clears the cordon its request may have set.
The wait never reaches past the shutdown deadline. A consensus write that takes seconds on a loaded
worker therefore still completes the release, and a node that handed all of its work over reports
the drain-support phase Completed.
That wait applies while the stopping node and the voters that are live and not terminating form a quorum, so the release can commit without voters that are stopping too. When they do not, as when the whole cluster stops together, the release keeps only the one-second grace: only voters that are about to stop could commit it, and waiting longer would only delay the stop of the last node of a cluster that is going away. The node decides this from its own gossip view when the release begins.
The release runs beside the completion of admitted work in place, which waits for what remains of the same drain timeout, so a slow release does not shorten the time admitted work has to finish. Placement still excludes the node once the release commits, because its terminating incarnation is not a placement candidate whether or not it is cordoned.
A stopping leader writes the release itself and waits until a quorum has committed it, so the release survives the node’s stop like any other committed entry. It does not transfer leadership first: the release would still be one consensus write, and every stop of a leader would add a leadership change before its drain. A leader that loses its leadership while it writes the release answers as a node that does not lead, and the stopping node asks the leader it observes next within the same bound; clearing a cordon twice is harmless.
When the release does not complete within its bound, the node logs timed out clearing shutdown drain cordon with the timeout it waited, reports the drain-support phase Abandoned, and
continues to terminal teardown. A leader that refused the release or never answered it is logged as
failed to clear shutdown drain cordon before graceful shutdown or the leader did not answer the release of the shutdown drain cordon, with the same outcome. What the cordon becomes then depends on
the write:
- A leader on another node that received the request finishes it in a service task of its own, so the cordon still clears after the stopping node stops waiting.
- A release the stopping leader appended but had not committed survives only if the node that leads next holds it in its log, and that leader then commits it.
- A release that never commits leaves the cordon in replicated state. The node starts again
cordoned, exactly as after an operator cordon:
raft.cordoned_nodeslists it, it takes no new placements, andUNCORDON NODE <node_id>clears it once the cluster has a leader.
Stopping Intake
Stop admission closes the node’s public surface. It stops accepting on the session gRPC, connector, observability, and console listeners and closes the client connections those listeners had accepted.
Closing a connection cancels the requests it carries. A console session is told that the server
is shutting down, after the replies already queued for its client, and that ending is its last
frame. A native gRPC session’s connection is cut when admission closes, so its client sees the
stream fail as it would after any transport loss. Neither waits for the client to read anything: a
session whose client reads nothing ends just as promptly, and its subscriptions stop and release
the relays they held. Every request the session had not yet admitted is cancelled before admission
and never begins an effect, so the client can send it again, with the same execution reference, to
another node. A session stream or a resource upload waiting on its client ends at once, so no
client can hold the drain or the process open. A backup download or a restore stream that the web
console carries over a WebSocket of its own ends at once as well, with close code 1001, as a
native gRPC download or restore call is cut; a restore admitted with its whole archive goes on
without its call. An authenticated upload held open
at any point of its progress — before its first message, between chunks, or trickling chunks
indefinitely — is cancelled this way and does not delay the exit. Node Stop And Restart As A
Client Observes Them
describes what a client sees at each ending and what it recovers.
Work that a session command had already admitted is not cancelled here. Its session stops waiting
for it, and no reply follows for it, but its effect keeps running. A transaction commit in progress
keeps running, and terminal teardown waits for it until the shutdown deadline. A commit still
running when the deadline passes is cancelled with the process; its replicated progress survives,
and a later leader resumes it from its recorded step, so the transaction reaches COMMITTED after
the node restarts.
The terminating node then stops new intake on every one of its ingestors, including endpoint and Syslog ingestors that serve on every node and ingestors whose scheduled owner did not move. Its generators stop producing. Intake never reopens: there is no resume path out of a shutdown drain. If a broker-style source is still resuming, the host cancels that pending resume when shutdown arrives, drops its DNS, socket and handshake work, and closes the source before exiting. A quiesce change also cancels an in-progress resume so the next loop turn observes the new intake state.
Source-instance readiness retirement is final and names the ending host’s exact handle. Shutdown retires installed readiness before withdrawing the registry. Terminal domain execution teardown cancels its relay channel publication after owned tasks stop; retained producer or channel handles then reject further dispatch and cannot attach to a restarted execution.
This intake stop ignores ON QUIESCE. That clause governs what an external source experiences
during a resumable hold — a model alteration, a domain pause, or memory-pressure shedding — where
the ingestor will run again. Shutdown and ownership handoff are not resumable, so polling and
endpoint admission simply stop, and no SUSPEND, BUFFER, DROP, or REJECT policy is applied on
behalf of the stop. An endpoint refuses new requests outright, without offering a retry delay.
Endpoint source close and terminal table clearing end each exact intake lifetime before withdrawing its binding. HTTP requests and WebSocket sessions that retained a route then see absent intake on later admission; a replacement source does not reopen that retained lifetime. A request already holding an intake lease may finish within the existing shutdown deadline. Endpoint definitions and intake leases are volatile publications and are reconstructed from the installed revision on startup.
Payloads already admitted continue through their routes. Each source host retains the quiesce publication it observed before awaiting dispatch. Its next change wait compares against that publication after registering the waiter, so a shutdown or ownership-handoff engagement during dispatch is observed on the next loop turn even when the notification arrived before the wait began.
A client ingestor stops intake the same way: from the moment
intake stops, a batch that arrives or waits unadmitted is refused as draining, which its producer
must not send again to this execution, while admitted batches continue through their routes. Closing
the node’s sessions detaches the producers they held; their admitted batches still drain. Producers
another node forwards here stay attached through the drain and learn every outcome it decides.
Terminal teardown then ends every producer still attached with shutting down, reporting each batch
whose acknowledgement is still unresolved as of unknown outcome with cause interrupted.
A raw quiesce buffer is not part of the drain. Payloads that a BUFFER mode retained during an
earlier hold are outside runtime graph work: a shutdown does not replay them, and they are discarded
and counted as dropped when the ingestor stops. A retained payload whose unfolding was still waiting
for the extension workers stays in the buffer, so the stop ends that wait at once and discards it
with the rest. A live payload a source handed over without an acknowledgement, such as a paced
poll, whose unfolding was still waiting for the extension workers is dropped when its ingestor
stops; the stop ends that wait at once as well. Only work already admitted into the graph is
drained.
Draining Admitted Work
Drain support has two parts that share the drain timeout. Both are also bounded by the shutdown deadline.
The stopping node closes its local source intake at the start of drain support, before it asks the leader to move any scheduled work. Kafka and other source ingestors stop taking new input, and generators stop producing. A payload already admitted and its acknowledgement chain continue through the installed relays and emitters. This prevents a source from admitting a new ACK root while a downstream emitter is moving. A destination node can start its replacement source after the handoff; intake on that node remains open. The local intake closure is permanent for the stopping process and is reused by the later in-place drain.
Moving scheduled work. When another live, schedulable Raft voter exists, the node first moves
its scheduled work there through the planned ownership handoff described below. This is the same
operation DRAIN NODE performs, requested against the terminating node itself: it visits domains
and schedule units in canonical order and moves one hard colocation group or independent runtime
node at a time. The request gives the leader the time left in the stopping node’s drain budget.
For each unit, the leader bounds its gate, state preparation, and activation waits by the time still
left on that request minus one sixth of the request’s initial budget. Preparation, including the
entity gate’s quiescence wait and state capture, has at most four fifths of that unit bound;
activation uses the same unit deadline. The
ordinary entity-gate limit can shorten either phase. If a unit cannot prepare within its bound,
the gate is released, the leader records that unit and its reason as failed, and it attempts other
units while time remains. If no unit budget remains, the leader reports the unfinished drain. A
stopping node that receives a failed or unanswered drain reports its drain-support phase
Abandoned, then still runs the in-place drain with whatever time remains. The node’s own timeout
and shutdown deadline bound its wait for a leader even when that leader continues cleanup after
the node stops waiting.
Completing admitted work in place. The node then finishes what it has already admitted. This part always runs, whatever happened in the first part. It is the whole drain when no replacement exists, and it also covers listener ingestors, which bind on every node and are never moved, and any unit whose move failed.
While admitted work finishes, the node’s relays, processors, emitters, and acknowledgement paths stay alive, and so do the supporting services that work depends on: the domain clock and its progress delivery, schedule activation, runtime-state replication and checkpoints, ownership-handoff coordination, and the interconnect listener and its handlers. Work admitted before the listeners closed can therefore still take execution snapshots, wait on logical deadlines, and reach its sinks.
Nervix repeatedly force-flushes ingestor route buffers, processor collections and route buffers,
message-error routes, reingestors, and emitters, whatever their FLUSH EACH cadence, until nothing
remains: no relay batch, no runtime-node work item, no emitter buffer or active publish, and no
admitted acknowledgement root. A stopped or extremely slow domain clock cannot hold rows out of the
drain, because the force flush does not wait for a logical cadence to come due.
Window processors are the one exception to that release. A force flush purges timed-out aggregate
state but emits only windows that have met their declared WIDTH. A partially filled window is not
emitted early by a shutdown, so its rows do not reach the sink. A row’s acknowledgement completes
when the window steps past the row; until then its shares are parked, so the drain does not wait
for them. The branch’s final checkpoint keeps the retained rows for the next task of the branch to
restore, and teardown ends their shares unacknowledged: a source with external acknowledgements
redelivers those rows after the restart, and the restored window admits them beside the rows it
restored.
An ownership handoff publishes the remaining window for the destination to restore. Evicting a concrete branch has a different endpoint: it drops the branch’s retained rows and aggregate state before the final checkpoint, so a later branch with the same key begins with an empty window. The lifecycle checkpoint carries each branch incarnation; window restore accepts retained state only from that same incarnation. A restarted owner resumes those incarnations before it accepts any input; see Restoring Processor Branches.
Draining ends with a confirmation pass. After a flush generation observes nothing outstanding, one
more generation must also observe nothing, so work that an upstream node publishes after a
downstream node finished its own flush is not left behind. A domain is quiescent only when that
confirming generation completes with nothing visible.
Quiesced backup applies the same admitted-work view to its selected domain across all live nodes.
It requests a separate cluster-wide confirming generation before capturing checkpoints. Parked
REQUIRED WAIT messages and the rows windows retain do not hold that cut open, and a failed drain
resumes the domain. The cut captures retained window rows as window state.
When a parked message has crossed nodes, remote ACK progress carries its parked state back through
the source’s acknowledgement chain. The drain excludes that chain while the message is parked;
resuming it reactivates the chain, and only a terminal ACK completes the source attempt.
Work keeps moving while the drain looks for it, so “nothing visible” is an exact observation rather than a series of separate reads. Each observation reads the domain’s outstanding force-flush obligations first, so a flush it sees complete has also left everything it released or resumed visible to the reads that follow. It reads every relay’s admission sequence before and after the other counts, and a relay that admitted a batch in between counts as work still moving: a batch that reached a relay or a node only after the read that would have counted it there entered some relay while the observation read. Node quiesce accounting describes the counts and the order of the reads.
Each domain therefore advances independently through three states:
intake closed on a generation requested with nothing
every ingestor visible completes with nothing visible
| |
v v
Draining ------------------------> Confirming ------------------------> Quiescent
^ |
| admitted work became visible |
+-----------------------------------+
drain timeout or shutdown deadline reached, in either state
|
v
Abandoned -> remaining work is negatively acknowledged
A WASM processor branch keeps the acknowledgements of its latest guest callback open until that callback’s checkpoint reaches stable storage and every assigned replica, so a drain waits for the checkpoint as it waits for any other outstanding acknowledgement. The checkpoint has a ninety-second completion deadline, while the drain timeout may end the node’s wait sooner. A checkpoint that cannot complete fails and negatively acknowledges what it held.
Runtime teardown gives each processor task a stop grace, and the processor task gives each of its branch tasks one of its own. A processor task still stopping its branches when its grace ends is ended together with every branch task it holds. A branch still waiting for its checkpoint therefore never outlives its node: it cannot keep the node’s runtime database open past terminal teardown, continue a checkpoint, or settle acknowledgements after the node stopped. Its unreleased acknowledgements are negatively acknowledged, and a restart finds whatever that checkpoint had already written to the node’s storage, exactly as after a forced ending.
A deduplicator or window branch also has a snapshot task, which writes what the branch published last on every snapshot interval and once more as the branch stops. That task belongs to the runtime, not to its branch task, because it goes on after it: a branch ended at a stop grace before its own stop was reached published nothing as it went, so what it published before is the newest state a restart can restore, and the snapshot task still writes it. A branch that stops in time publishes its state first and waits for that write itself. Either way the write is finished before terminal teardown releases the node’s storage, as Terminal Teardown describes.
A coordinated WASM state reset is serialized with domain lifecycle, placement, ownership movement,
resource rebinding, and model mutation by the domain alteration lease and its entity gate. If node
shutdown interrupts a preparation before reset publication, the old generation remains
authoritative; ordinary reset error handling restores the stopped task when it can, while terminal
teardown may discard its volatile handoff and negatively acknowledge what it held. Restart then
restores the old generation. If the reset’s Publishing schedule wins, the old generation is
already fenced permanently. Drain support keeps schedule activation, state storage, replication,
and interconnect handlers alive so the fresh initial checkpoint and Ready publication can finish
within their ordinary bounds. If shutdown ends first, restart observes Publishing and resumes the
new generation rather than restoring the old one.
Read-only WASM state inspection after restart uses the committed schedule’s generation and
retained reset and recovery outcomes. A pre-publication failure leaves the preceding generation
visible; a published but not yet usable reset remains PUBLISHING until its initial checkpoint
and activation finish. Runtime checkpoint observations from a replaced generation are excluded,
and a restored checkpoint reports unknown prior replica confirmation when that boundary cannot
be reconstructed. Inspection never settles an uncertain transaction outcome or resumes a reset.
An admitted NSPL reset is recorded as an ordered transaction effect. If shutdown interrupts the command after its effect is recorded, recovery resumes it with the original execution reference; it does not admit the text as a fresh reset. The client receives success only after the replacement is usable, or a retained failure when that cannot be established.
Resetting one concrete branch does not turn a sibling branch into shutdown work. Its scoped relay gate and processor command lane select only that branch; sibling callbacks, checkpoints, outputs, ACKs, and timers continue until shutdown itself reaches them. Old timeout handles belong to the discarded branch instance and are never transferred to the fresh instance.
Three kinds of work deliberately do not hold the drain open:
- Pending
REQUIRED WAITrecords. A message suspended on absent materialized state cannot finish, because the dependency is not there. Every force flush retries it against the state that is present; whatever still waits at the end is negatively acknowledged. Its source redelivers it after the restart, and the record is processed then against the state that has since arrived. - Rows a window retains. Only further input steps a window past them, and the window’s checkpoint keeps them, as described above.
- Outstanding force-flush obligations. They are the mechanism of the drain, not admitted work waiting inside it.
Payloads that other live nodes publish into this node’s relays are admitted work like any other, so a peer that keeps publishing extends the drain until its timeout. When every node terminates at once, each completes its own admitted work within its own drain timeout, and work that reaches a peer after that peer finished draining is negatively acknowledged.
When The Drain Does Not Finish
A drain that reaches its timeout reports itself abandoned and shutdown continues. Terminal teardown
then negatively acknowledges the remaining work. A source with external acknowledgements redelivers
it after a restart; a source in MODE NO ACK loses it.
This is the behavior an unavailable sink produces. An emitter whose sink will not accept records holds the drain until the timeout, its source offsets are never committed, and the records are redelivered and published after the node restarts and the sink recovers.
The log distinguishes the two ways a drain runs out of time:
local graph drain timed out with admitted work outstanding, with per-domain counts of admitting ingestors, active generators, outstanding acknowledgements, buffered relay batches, runtime-node work items, buffered emitter messages, publishing emitters, pendingREQUIRED WAITrecords, and force-flush obligations.local graph drain timed out before confirming that no admitted work is still moving, where the work finished but the confirming generation did not complete in time.
Ownership Handoff During Shutdown
The ownership move at the start of drain support is the same planned handoff used by DRAIN NODE,
RELOCATE, and placement consolidation. Control Plane
owns the general contract; this section states what shutdown adds.
The complete target schedule is computed before anything is held. The coordinator then fences dispatch at the affected subgraph boundary on every live node, stops intake for each moved ingestor, and drains the work already admitted to the moved unit, including moved-ingestor acknowledgement roots, emitter buffers and active publishing, and an Iceberg emitter’s staged commit. The new schedule is written only after that drain succeeds, and the fence opens only after the destination activates the published revision.
Each participant closes intake and boundary gates, requests a force flush and confirms its admission hold. The coordinator polls the exact operation’s drain status while installed routes and processors finish the admitted work. Returning a drained ownership status publishes that participant’s capture freeze under its retained hold. Capture verifies both the held operation and its exact freeze identity, so a pending flush cannot make mutable state ready for capture. Frozen owners retain state until capture and activation finish. Preparation retains its original deadline, distinct from the activation lease. A pending-drain timeout reports its node and counts, releases every attempted hold, and leaves independent schedule units available for later moves. Repeated engagement requests use the same identity and scope; a bounded reply timeout creates no additional flush and restarts no lease.
Every step of the handoff carries one coordination identity, composed of the coordinating node, its process incarnation, and a sequence. The receiver verifies that identity against the authenticated connection before the request is handled, so a different node cannot use it and a restarted coordinator process cannot replay it. Engagement binds the identity to one domain, relay set, affected-entity set, and purpose; a retry succeeds only for the same identity and the complete scope.
Gate leases are deadline-bound and release themselves if the coordinator disappears. The default lease is sixty seconds and a planned handoff budgets two of them, one for preparation and one for activation. Both are longer than the default shutdown timeout, so a gate a terminating node engaged can outlive that node’s own process; the lease deadline, not the node, is what reopens it.
When activation fails after the schedule is committed, the gate stays shut until its lease expires rather than opening before the destination is ready.
Interrupted Handoffs
Restart or owner replacement for a stopped WASM domain preserves the complete validated lifecycle
and guest checkpoint inventory in its passive revision. Ownership preparation performs no guest
callbacks and does not read the stopped domain clock. START restores those saves under the active
clock generation; stopped time does not authorize discarding valid checkpoints or resetting state.
A preparation written durably at a destination outlives the process that wrote it. The coordinator records every destination as an attempted participant before it sends the side-effecting request, so a timeout, a cancellation, or a lost response after the destination persisted the request remains explicit cleanup work rather than an unknown.
The current leader reconciles durable preparations after a leadership or live-incarnation change. It first commits a consensus barrier and sends its log position with the reconciliation request; each participant applies through that position before consulting its own committed schedule. A preparation survives when its exact transition is the committed owner change, regardless of which processes have since restarted. An uncommitted preparation survives only while the original coordinator process and both bound participant incarnations are live, the base schedule still has the source owning the entity, and the exact gate is still held. Every other preparation is discarded durably. Discard is exact and idempotent over the operation identity, so a delayed discard for one operation cannot remove a replacement prepared by another.
A destination that restarts between preparation and activation invalidates the handoff: the
operation fails with destination node '<node_id>' changed process incarnation during ownership handoff, the schedule is unchanged, and a later relocation onto that node succeeds normally.
When The Former Owner Is Gone
Unexpected owner loss is not a handoff. The failed tasks and their volatile buffers disappear immediately, attached work is negatively acknowledged, and the scheduler promotes a live replica or chooses a fresh owner without waiting for a gate.
The scheduler acts on what application health and gossip report, so a forced recovery can also move work off a node that is still running. That node stops the moved runtime when it applies the published schedule. A batch a relay owner routed to it for a moved consumer, and that reaches its runtime afterwards, finds no attached consumer and fails its attached acknowledgements, so the source redelivers the record to the new owner rather than committing it; see Consumers That Leave The Receiver.
Forced recovery is the path that publishes a schedule when the source cannot participate. It stages the destination’s checkpoint inventory under the destination’s process incarnation and the complete target-schedule fingerprint, and applying staged checkpoints accepts only that exact preparation. A missing or mismatched preparation does not imply a reset: without an exhaustive reset outcome in the schedule, activation fails and leaves every saved checkpoint unchanged. State is recreated only when the accepted decision either stages a recreated inventory or reports a reset for every state component the entity owns.
A forced recovery of a WASM processor starts a new guest-state generation for every branch in the same schedule publication, and activation publishes the staged checkpoints in that generation. The former owner’s saves, and those of any replica that missed the recovery, belong to the generation it replaced, so a node that restarts or rejoins with them never restores, serves, or supplies them to a later recovery. A later owner loss therefore resets a branch whose only surviving checkpoints are of an earlier generation instead of reviving them. When the processor has replicas, a replica holds every checkpoint whose acknowledgements the lost owner released, because the owner released them only after its replicas had synchronized the checkpoint, so forced recovery continues each branch from at least the state its acknowledged inputs produced. The promoted replica restores every staged checkpoint into a guest before the schedule is published, from the module it compiled while it was a replica, so the recovery does not wait for the module to compile. A preparation that fails, including a guest that cannot restore a staged checkpoint, publishes the recovery with recreated state; see Forced Recovery.
Topology Cases
| Case | Behavior |
|---|---|
| Single node | No replacement exists. Nothing is cordoned, no ownership moves, and all admitted work drains in place before the process exits. |
| Follower with a reachable leader | The node asks the leader, over the cluster interconnect, to drain it, then completes what remains in place. |
| Leader | The leader drains itself in process and commits the release of its own cordon before it stops. Leadership is not transferred first; the cluster elects a new leader after it stops. |
| Last schedulable node | Same as a single node: no replacement candidate exists, so everything completes in place, including source offset commits. |
| Only peer is itself terminating | A terminating incarnation is not a placement candidate, so the node takes the no-replacement path and completes its work in place. |
| Every node at once | Each node advertises terminating, finds no eligible destination, and drains locally within its own drain timeout. A node that asked for its drain before it saw the others terminating gives the release of its cordon only the one-second grace once it sees them terminating, because only voters that are stopping too could commit it. |
| Leader unreachable, or no quorum | The node waits for a leader only until its drain budget runs out, reports the drain-support phase abandoned, and still completes its local drain. Nothing is cordoned. |
The no-replacement path is explicit in the log: no live schedulable replacement node remains; admitted work completes in place.
A follower requests its drain, and then the release of the cordon that drain set, with the
stopping_node_drain request over the
cluster interconnect. The request names no
node: the leader acts for the node whose certificate authenticated the connection, so a node can
drain only itself, and no user credential takes part. A follower started without
--init-default-user-password, as every node but the bootstrap node is in the documented
Docker deployments, therefore drains through the leader like any other.
A node that receives the request without leading changes nothing and says so. A leader that loses
leadership before it completes the drain also answers as not leading, including when a participant
fences its coordination identity during a handoff. It logs the interrupted drain report before
answering. The stopping node asks the leader it observes next within the same shrinking drain
budget. Already committed moves remain committed; the next leader reads the current schedule and
moves only the work still owned by the stopping node. A failed drain from a node that still leads
remains a failed outcome. The leader runs the drain in a task of
its own, so a drain that has begun finishes, and releases the gates it engaged, even when the
follower’s drain timeout ends its wait first. The interconnect resolves the leader’s advertised
endpoint through the follower’s loaded resolver, described in Name Resolution,
within the drain and shutdown deadlines.
Connector Contracts
Connector Crates And The Connector Contract defines normal source and sink ownership and completion points. This section describes what a node’s drain can complete before its shared stop deadline.
Shutdown does not change any connector’s delivery contract. It changes only whether a connector reaches its completion point before the process ends.
Sources
A source acknowledges or commits the work that reached its sinks before its session stops. A batch
acknowledged through the graph stays outstanding admitted work until its source has confirmed the
batch’s positions on its transport, so the drain waits for that confirmation as it waits for any
other acknowledgement root, within the drain timeout. What a source’s confirmation means is the
acknowledgement confirmation its entity declares; see
Confirmed acknowledgements. An SQS deletion
is answered by the broker and a RabbitMQ acknowledgement is written to the connection before the
drain moves on, while a Kafka consumer-group commit, a Pulsar acknowledgement and an MQTT PUBACK
are only queued by their clients, so the drain does not wait for their brokers to answer them.
Work whose acknowledgement root is still unresolved when the drain ends is negatively acknowledged,
so the source’s own redelivery contract applies. A source instance that stops while it still waits
on such a batch logs source instance stopped before its transport confirmed positions it read; the transport presents them again at info, with the count of positions. This is why a drain that
times out leaves Kafka offsets uncommitted rather than advancing them.
What that contract is depends on the source, and three groups differ sharply:
| Source | Source acknowledgement | Admitted work when a drain does not finish |
|---|---|---|
Kafka, Pulsar, RabbitMQ, SQS, and MQTT in an ACK mode | The offset is committed, the broker acknowledged, or the message deleted only after the record is acknowledged through the graph | Redelivered after the restart |
| HTTP polling, Prometheus | None; the poller re-reads its source each cadence | Read again by a later poll |
| NATS, Redis Pub/Sub, ZeroMQ, WebSocket clients, HTTP endpoints, Syslog | None exists; these sources offer no acknowledged mode | Lost, with nothing to redeliver it |
| Client ingestors | The producer receives each batch’s outcome once its acknowledgement root resolves | Reported to its producer as of unknown outcome, or not at all when the producer’s session ended first; the application decides whether to submit it again |
The last row is the one to plan around. Those sources have no acknowledged delivery mode at all, so a record admitted from them and not yet emitted is lost both by a drain that runs out of time and by every forced ending. An endpoint rejects new requests as soon as intake stops, so a client that handles the rejection can retry; a payload it already accepted is not redelivered by anything.
A general failure handled by ON GENERAL ERROR IGNORE acknowledges the record, while
ON GENERAL ERROR LOG negatively acknowledges it. During a drain that choice decides whether an
acknowledged source redelivers the record after the restart.
Sinks
An emitter reaches the sink completion point its MODE declares, and the drain waits for it. An
emitter that cannot finish reports emitter '<name>' did not drain before its configured deadline
and holds the drain until the timeout. A batching emitter’s drain also writes every batch payload an
earlier attempt left unanswered, with the bytes and members it was first written with, as an OTEL
emitter’s drain sends every Export request it prepared and did not learn the outcome of, and the
emitter buffer counts those members as work the node still holds until they resolve.
A native client emitter reaches its success boundary only on application ACK. Force flush prepares its bounded Arrow IPC batches, but a waiting consumer read or a batch already sent to a session does not drain them. Closing public sessions during intake stop detaches their consumers and revokes attempts; remaining prepared batches wait for another consumer within the physical drain deadline. On deadline expiry terminal teardown cancels the emitter task and discards volatile attempt history. An attached upstream source may replay after restart, including a batch whose application effect happened but whose ACK was lost. No durable consumer cursor is restored.
An HTTP emitter force-flushes its collected records into one request per eligible record, then waits for complete successful final response headers. Each attempt and retry wait remains bounded by the remaining physical drain deadline, even in a paced domain. A request still unanswered at that deadline is not marked delivered. Its prepared bytes, selected destination, headers, and retry state are volatile and disappear when the process ends. An attached acknowledged source can redeliver the record after restart; an unacknowledged endpoint source cannot. A destination that applied a request whose successful response was lost can receive it again after that redelivery. Once the graph drain budget ends, terminal teardown cancels an emitter task waiting in an HTTP attempt. It does not wait for that client’s full attempt timeout or turn the canceled request into a record-specific success or rejection.
Kafka is the only sink whose client-side queue shutdown drains explicitly: after its buffered batches are published, the emitter host calls the sink contract’s finish hook with the remaining stop deadline. Kafka implements that hook by flushing the producer’s local queue within the same deadline. Other sinks hold no such queue, so their default finish hook completes immediately and the publish itself is the completion point.
A sink that stages what it accepts and publishes it later reaches its completion point at its commit instead. After the buffered batches are written into such a sink, the drain calls the sink contract’s commit hook and forces it, so the commit runs whatever its commit cadence says and without reading the domain clock, which a stopped domain no longer has. Until that commit succeeds the sink holds the acknowledgements of every row it staged, and the drain counts those rows as work the node still owes. A commit that fails is retried on the emitter’s declared backoff, reported as the emitter’s commit retry, and a shutdown that cuts the backoff short fails the drain.
Iceberg is the sink that does this. It commits its staged data through its catalog, forced by the
drain rather than waiting for the COMMIT EACH cadence. Staged rows live as local files that only
a successful commit removes, so a forced ending leaves them behind: the rows are not in the table,
and a later run does not reclaim or commit them. An attached source redelivers that work; a
detached one loses it.
Duplication
A graceful shutdown creates no new duplicate condition, but redelivery after a forced ending or an abandoned drain produces exactly the duplicates the source and sink combination already allows. A sink that already published successfully can receive a record again after a restart, because acknowledgement state is in memory and does not survive the process. See ACK Semantics And Effective Delivery for the per-sink duplicate and loss conditions and the idempotency options each sink offers.
Branch Isolation
Branch isolation is preserved throughout. Draining walks concrete branches one at a time, branch-local state stays branch-local, and interleaved branches complete independently. Branch expiry is suspended while an entity is frozen for an ownership handoff, so a drain cannot silently expire branch state mid-handoff. Nothing about shutdown merges branches or moves work between them.
Terminal Teardown
Terminal teardown begins only after drain support completes or reports itself abandoned.
It stops the node’s background tasks, giving each at most two seconds, then waits for work that sessions and commands started until the shutdown deadline and cancels whatever still runs. It then stops the runtime, consensus, cluster membership, and the interconnect in that order, and finally releases the node’s storage.
Runtime teardown stops one domain at a time. It publishes the domain’s stop to its processors,
emitters, generators, reingestors, and placement tasks at once, and then waits for the domain’s
tasks kind by kind: relay owners and materialized relay state, each of which is told to stop when
its turn comes, then processors, emitters, generators, reingestors, and the placement tasks that
checkpoint and replicate state. A task that has not ended when its stop grace runs out is aborted.
The node logs shutdown task exceeded grace period; aborting at warn with the domain, the task’s
kind, and its name, or relay owner task did not stop cleanly and
relay state task did not stop cleanly for a relay’s tasks. The grace is two seconds, except that
a relay owner and an emitter are given the domain drain timeout first.
Generators and reingestors share their grace. A generator reads its source relay from the node that owns it, and both kinds publish to the node that owns their output relay, so while they work they wait on other nodes. When every node of a cluster stops at once those peers stop answering, and every generator and reingestor caught in such a call outlasts the grace at the same moment. Neither kind owns state that the node persists or replicates, so the waits of one kind run together. Each task still has the whole grace to release what its routes buffered, and a domain’s generators cost its teardown one grace and its reingestors one more, however many of them a stopping peer holds up. The other kinds persist or replicate state they own, or drain a sink, as they stop, and are waited for one after another, each with a grace of its own.
Once every domain’s tasks have ended, runtime teardown tells the snapshot task of every
deduplicator and window branch that is still waiting for its branch to write what that branch
published last, and waits for all of them. A snapshot task holds the runtime and its storage, so
this wait is what keeps the node database from being released under a write: it has no grace and
cancels nothing, and lasts as long as the slowest of those writes. A window whose retained rows
exceed one snapshot section is sealed and published in bounded pieces through the storage and
bulk workers, so its write waits for those workers like any other job. The node logs nothing for
this wait; a write that fails is logged at warn as
failed to flush branch state snapshot during shutdown, and a restart then finds the checkpoint
written before it.
Runtime teardown ends each resolved replication route before clearing its state registry. A retained route cannot attach to replacement state; a request already admitted through a borrowed checkpoint handle may finish on that exact state. After domain drain, closing the replication task owner cancels its announcers, including pending dispatches and retry waits, and waits for their retained handles to be released before these routes are cleared. Teardown does not wait for an announcement’s remote dispatch timeout; synchronization supplies any missed availability hint.
Closing a Kafka offset replica task also cancels its pending revision request or bulk checkpoint stream immediately. A partially received or cancelled conversion never publishes a checkpoint or acknowledges its revision. Native conversion checks cancellation between entries and before publication; the replica’s assignment token still fences installation if ownership changed.
Consensus stops Raft and then waits for its storage to reach an idle barrier, which proves every
earlier durable write has returned and released the store, before its dedicated database handle is
dropped. The registry and runtime database is released separately after the services that hold its
handles. Terminal teardown does not finish until both database locks have been released. A build
with the testing feature is the exception: its fault injection keeps an observer of each running
node’s consensus state, so an in-process test node’s consensus database closes only once the
scenario harness gives that registration back, after the node has stopped; see
Integration Test Lifecycle. See
Consensus Storage And Replication for the write, replay,
retention, and snapshot contracts behind this barrier.
Releasing a database stops its background workers, which flush memtables into tables and compact
them, and waits until none is left. A worker normally leaves when it takes its stop request, but a
flush or compaction that fails, for example on a full or failing disk, ends its worker at once,
without taking one. The close never waits for such a worker to answer. It keeps checking whether
any worker is left, discards the flushes and compactions still queued, and finishes once the last
worker is gone, so a storage failure during the close cannot hold terminal teardown open. Discarding
that work loses nothing: every write it would have moved into a table is in the database’s journal,
which the close synchronizes and the next start replays. The storage engine logs each failed worker
at error as Worker #N crashed with its cause, and a journal it cannot synchronize as
Flush error on drop. Either failure reaches the node’s log, while teardown goes on to release the
lock.
The interconnect rejects new admission, cancels pool and operation waiters, and retires the pool and peer protocol owners. An ending peer owner cancels unadmitted records and releases item and terminal-outcome permits even if the runtime retains an intake already admitted; that intake’s verdict remains admitted. A batch the runtime holds back for room for its acknowledgement watches is unadmitted, so that cancellation ends its hold, and so does the end of the relay intake lane when the node stops; it is never admitted afterwards. Decoded metadata stays charged while a borrower keeps it. Runtime teardown cancels and joins its charged ACK watcher tasks, closes delivery and admission routing queues, and negatively resolves remaining volatile correlations exactly once. The once-a-second silence sweep is one of the joined tasks. A sweep is synchronous, so its task cannot observe the cancellation while one runs, and a sweep that overran the second between sweeps finds the next tick already due, which does not suspend the task. The task therefore returns to its executor after every sweep and observes the cancellation there, whatever the sweep took, so the join waits for the sweep in progress and for no other. The receiver-side acknowledgement watchers the same join waits for have no cadence that can fall behind: each of their steps takes one row’s progress and awaits its delivery to the forwarding node, so they return to the executor within their cooperative budget. A sweep, like the resolution that follows, visits only the correlation positions the node’s deliveries and admissions have claimed, never its fixed capacity, which is what keeps it inside that second. No ACK owner state is stored for restart. The interconnect also retires the pool connections the node opened, giving their leased streams up to ten seconds before closing whatever remains. Connections that peers opened to the node close at once, together with the handlers still serving their streams, so a peer’s request the node has not answered fails instead of completing. A forced ending skips this entirely, so peers observe the connections ending exactly as they do when a process crashes.
The node’s own OTLP trace exporter is a process service. After the application returns, its tracing guard closes resolver installation and asks the SDK to flush before the process drops its Tokio runtime. An installed resolver remains available to queued exports. If startup failed before DNS installation, closing the publication ends a pending collector connection. Export failures remain telemetry diagnostics with no data-plane acknowledgement consequence, and a forced process ending does not guarantee a final export. Node Trace Export owns the resolver lifetime and export budgets.
Consensus Work At The Ending Boundary
Consensus remains live through stop admission and drain support. A terminating node can still append and apply control-plane entries during those phases, answer peer heartbeats, and carry replication. The boundary below begins when terminal teardown stops Raft.
| Work in progress | Graceful SIGINT or SIGTERM | Repeated signal, expired deadline, or SIGKILL |
|---|---|---|
| Append batch | A storage job already admitted to the ordered worker completes before the idle barrier can pass. Its complete atomic batch is synchronized before the database closes. An append alone is not a client acknowledgement. | There is no idle barrier. Recovery finds the last complete synchronized batch or its predecessor; Raft may commit or truncate an uncommitted tail. |
| Applied range | A write already submitted completes atomically with its final applied position. Entries committed but not yet submitted remain in the log and are replayed on restart. | Recovery finds the last complete applied range and streams every later committed entry from the log. A response can be lost after durable application, so client silence remains an uncertain outcome. |
| Snapshot build or transfer | Completed section writes remain durable. A generation with no published manifest stays inactive and is reclaimed later. A published installation marker is finished on restart if teardown did not reach its final publication. | The same durable boundaries decide recovery. Partial section writes are absent; staged unpublished generations stay inactive; a marked installation is redone in full. |
Stopping Raft ends each append-stream generation while the interconnect is still running. The
stream stops accepting new submissions, releases its charged follower batches, and its peer observes
a normal stream end or failure and resumes from its last confirmed progress if another leader path
exists. The terminating node is still a Raft member and does not transfer leadership as part of
shutdown, so peers otherwise react to its silence through the ordinary heartbeat and election
rules. Interconnect shutdown follows and records any remaining reset with reason="shutdown".
A forced ending skips the protocol close; peers see an abrupt stream or connection loss.
What Survives
Three persistence boundaries decide what a restart finds. Control-plane state is replicated and strongly consistent, selected runtime state is checkpointed, and the hot path is memory only.
| State | Graceful shutdown | Forced ending or SIGKILL |
|---|---|---|
| Committed control-plane state: models, schedules, domain lifecycle, cordons, users, resources | Reopens from the committed generation | Reopens from the last committed generation; nothing acknowledged is lost |
| Published consensus snapshots | Reopen from the current manifest; a marked installation is finished before state is exposed | Reopen from the current manifest; a marked installation is redone before state is exposed |
| Durable handoff and forced-recovery preparations | Preserved, then reconciled or activated | Preserved, then reconciled or activated |
| Runtime-state checkpoints: Kafka domain offsets, deduplicator and window state, materialized relay records | Flushed again as runtime tasks stop; a deduplicator or window branch ended at its stop grace has what it published last written before terminal teardown ends; a materialized relay, and a window whose retained rows exceed one 8 MiB snapshot section, are sealed to quota-owned files and written as segments synchronized before their header | Reopen at the last completed periodic checkpoint |
| WASM guest-state checkpoints | Every checkpoint that released an acknowledgement is already synchronized | Reopen at the newest checkpoint on the node’s storage, which covers every acknowledged input |
| Restored backup state: materialized relay rows, WASM guest saves, Kafka domain offsets, branch lifecycle, deduplicator keys and window state | A restore synchronizes a complete chunked namespace and its atomic active-generation pointer before releasing the stopped domain’s START gate or resuming its archived lifecycle | Reopens pointer and checkpoints from one complete database view on each assigned owner and replica; a durable pointer alone does not release an incomplete replicated installation gate. A resumed domain retains every restored kind. A normal START continues saved guest state, source positions, branch incarnations, deduplicator keys and windows, and resets materialized rows |
| External source offsets and sink commits | Complete when the drain succeeds | Only the external connector’s own delivery and transaction guarantee applies |
| Relay batches, queued payload attempts, suspended work, ACK guards, ACK tokens, ACK maps, handoff payloads, gate leases, clock progress | The drain tries to resolve them before its deadline | Volatile; lost |
| Client producers, their credit and queued batches, producer links | Ended as shutting down after the drain, or detached with their sessions | Volatile; lost with the process. Every producer another node forwarded here ends there as owner lost: its batches that node never cleared for admission are refused as producer ended, and only the cleared ones are of unknown outcome. The node’s own sessions end, so their clients report every batch they sent without an outcome as of unknown outcome, and the nodes executing the ingestors detach those producers and finish what they admitted |
A materialized backup captures the current relay generation independently of the periodic
snapshot interval. Restore streams its complete sealed container into the generation store,
including containers larger than the bulk memory budget. RESUME activates the archived domain
generation only after every assigned owner and replica has published its complete set. These
rows reopen from one pinned database snapshot across a node restart; the stored clock mapping
projects downtime under the recovered authority. A failed installation retains its durable
activation gate across restart. A normal START creates a new generation and clears materialized
rows while retaining compatible WASM, offset, branch lifecycle, deduplicator and window state. Periodic materialized
flushes seal to quota-owned files and write bounded database segments, synchronizing their data
before the replacement header, so a large relay has the same shutdown durability boundary. See
Backup And Restore. A deduplicator or
window backup likewise captures what each branch task publishes when the cut’s lifecycle
checkpoint asks it to, independently of the periodic interval, and a restore rebuilds each branch’s
native checkpoint from its archived Arrow groups and streams it through the same publisher, one
bounded group at a time, including a keyspace or a window larger than the bulk memory budget. A
window whose retained rows exceed one 8 MiB snapshot section is persisted the way a materialized
relay is: its periodic and stopping checkpoints seal in bounded pieces to a quota-owned file, and
its segments are synchronized before the header that selects them, so a restored window larger
than the bulk memory budget survives a graceful restart with the rows it admitted afterwards.
Durability is not uniform across those rows, and the difference is operationally visible:
- Consensus acknowledges a vote, an appended batch, or an applied range only after synchronizing both data and filesystem metadata, so committed control-plane state survives host power loss on a device that honors those requests. Synchronization is per reservation-bounded batch or applied range rather than per entry.
- Every handoff and forced-recovery preparation, activation, and discard is synchronized the same way.
- Periodic runtime checkpoints are written to the operating system without forcing a synchronization
on every update. They therefore survive the death of the process, including
SIGKILL, but a host power loss can lose the most recent ones. This is the boundary the crash qualification asserts: exact counts are guaranteed only for checkpoints known durable before the kill. Segmented checkpoints, those of materialized relays and of windows beyond one snapshot section, synchronize their segments and then their header, so a power loss keeps the previous complete checkpoint. - A WASM guest-state checkpoint is synchronized, on the branch’s owner and on every replica the schedule assigns, before the source acknowledgements it covers are released, so every checkpoint that released an acknowledgement survives a host power loss. Checkpoints that branches take at the same time share one synchronization. See Failure At Each Boundary and WASM Processor Guests for what a recovered branch continues from and which inputs its source redelivers.
Branch-local processor state survives only to its latest publication. A branch task aborted after exceeding its grace period keeps what it had published and loses the changes it made afterwards.
Restart And Recovery
Recovered Ownership Is Fenced
A restarting node does not execute the ownership recorded in its own database until it has proven it is caught up with the cluster. On each process start it asks consensus for an admitted runtime state through a linearizable read, which confirms the leader’s authority with a quorum and returns a committed log boundary. The node applies through exactly that boundary before it reads the domain, clock-authority, and schedule state it installs, and it retries every half second until that read succeeds.
After admission, the control plane converts the committed schedule into a complete typed execution revision before runtime installation. Running and stopped domains use that same revision for local placement, state identities, passive recovery, and ownership handoff. The handoff fingerprint keeps the committed schedule’s exact bytes. If an installation fails, the next attempt is planned from the last successfully applied schedule; the failed revision is not recorded as applied.
Until admission succeeds, the node is live but inert with respect to ownership. Its public listeners and its interconnect answer requests, which keeps configured listening entities available on every live node, but no runtime routes exist, so a payload it accepts cannot reach recovered graph execution. A client producer or consumer open is refused as temporarily unavailable until the node has passed the catch-up barrier; the node does not report a stale local missing or stopped domain as a terminal endpoint refusal during that interval. The client can retry the open on its restored session. A former owner restarted while cut off from consensus therefore produces no output, and once connectivity is restored it observes the current schedule and forwards traffic to the node that now owns the work.
The node’s log marks the boundary. When its first read attempt fails, it logs this once per process
at warn:
runtime execution is waiting for linearizable consensus catch-up. When admission succeeds, it logs
runtime execution admitted after linearizable consensus catch-up at info, with the
committed_log_index it applied through.
At startup the node offers the peer endpoints retained in its Raft membership as gossip seeds. A former bootstrap node therefore has a path back to surviving peers even if it was originally configured without a bootstrap host. The endpoints only initiate authenticated contact; gossip establishes each peer’s current incarnation and endpoint before normal peer routing and runtime admission proceed. A recovered endpoint that does not resolve is skipped while other seeds and incoming gossip remain available. A configured bootstrap host that does not resolve is skipped on that restart when recovered peer endpoints are available; a node without them still needs the bootstrap host for first contact.
This is the fence that prevents crash recovery from reviving an obsolete owner. It is a process-start admission proof only: connectivity lost after admission does not revoke execution.
Whole-Cluster Restart Keeps Ownership
When every node restarts, the first node to lead can form a quorum while the others are still starting and gossip has not observed them live yet. Its automatic scheduling therefore waits, for the first ten seconds of its reconciliation, while any current voter has never been observed live by this process’s reconciliation task. A first heartbeat relayed by another peer can initially put the voter in Chitchat’s dead set before sufficient heartbeat intervals establish liveness. That initial dead verdict keeps the voter unobserved during the grace. Each owner that returns in that time keeps its work and restores it from its own storage and replicas. Live observations made while following survive a leadership change, and the original ten-second deadline continues running. Once it expires, a voter that never returned is eligible for ordinary failover. The observation history is private to this process and is discarded after the grace. See Planned Ownership Handoffs And Failover.
Restoring Processor Branches
A processor task restores the branches its branch lifecycle checkpoint names before it takes any input. It reads the checkpoint an ownership transfer left for the node, else the lifecycle the node holds, else the one its storage keeps, and each branch resumes the incarnation the checkpoint records. A window branch therefore reopens the window its retained rows hold, and a deduplicator or WASM branch resumes the state it checkpointed. The restore reads only the node’s state and the processor’s execution plan, which carries the schema a window’s retained rows are read under, so it does not wait for the domain’s routing, which installation publishes after it starts the tasks.
The entity’s branch-aggregate metric checkpoint also retains exact bytes for its revision, including elapsed metric fields. Capture, persistence and bulk transfer agree on those bytes while the handoff gate is held, and the recovering owner retains them until its next dirty metric revision. See checkpoint synchronization.
A restore installs every branch or none. It builds each branch and opens its retained state first,
starts the branch tasks only once all of them are built, and releases a transferred checkpoint only
after that. When a branch cannot be built, because storage cannot be read, a checkpoint does not
decode, or the bounded executor refuses the decode, the task installs nothing and logs failed to restore processor branches; their input waits for the next attempt at warn with the cause. It
tries again after a backoff that starts at a quarter of a second and doubles up to thirty seconds.
Until an attempt succeeds:
- The processor dequeues no input. Its relays keep their records and apply backpressure upstream, so no record starts a new lifetime for a branch the restore resumes and discards its retained state. Input a drain still delivers is negatively acknowledged.
- A lifecycle checkpoint request is refused, so a backup capture or an ownership handoff that needs one fails instead of recording a lifecycle without the processor’s branches. A WASM guest-state reset is refused the same way.
- A task that replaces it receives the branches it never installed, and restores them itself.
A failure that persists, such as a checkpoint written in a shape the node no longer reads, keeps the processor waiting and repeats its warning on every attempt.
Checkpoint Identity
A restart reopens a runtime-state checkpoint only under the identity the committed schedule
publishes for its entity. Kafka domain offsets and the metric summaries behind DESCRIBE output
depend on no schema: they are keyed by their entity alone and survive a restart whatever schemas
changed while the node was down. Every other checkpoint, including deduplicator, window, and
materialized relay state, branch lifecycle records, and WASM guest state, is keyed by the
fingerprint of the schemas its entity lays records out by, and WASM guest state also by its
generation. Window state additionally includes the current window model in its identity, so a
replacement that changes WIDTH, STEP, or aggregate expressions begins with an empty window even
when its schemas are unchanged. A checkpoint written under a replaced fingerprint is never restored
as the new layout, served, replicated, handed over, or selected by a forced recovery. Applying the
committed schedule of a running domain removes it. Until the node has applied a schedule that names
an entity,
it has no fingerprint for that entity’s schema-bound state and does not place that state at all.
Recovery also validates the current representation before decoding its counts. Registry Model
frames and the dedicated consensus database identify their fixed-width 64-bit count shape;
unrecognized stored state fails with an instruction to recreate it. Window checkpoints use the
current runtime-state kind and NVXWIN64 frame signature. Native decoding of an archived count is
checked and cannot truncate it to fit the target. Consensus recovery accepts only the records its
state writer stores, each under its own canonical key and in a state its transitions reach, and
fails on anything else with the same instruction. See
Archived Counts and
Storage Layout And Compatibility.
Interrupted Snapshot Installation
Installing a consensus snapshot publishes the manifest and a marker naming the generation whose records still have to replace the state machine, then replaces those records and clears the marker. A node that stops anywhere between the two finds the marker on its next start and redoes the replacement in full, however far the interrupted attempt had got. It publishes no recovered state until that replacement finishes. Sections staged before any manifest was published never become active; startup marks every generation the current manifest does not name as unreferenced, and a later durable consensus batch removes it. See Transfer And Installation for the section, marker, and final-publication boundaries.
Replayed Forced Recovery
A completed forced recovery is recorded durably against the ownership transition itself, not against the process or schedule that executed it. Reapplying that retained schedule after a destination restart or a later domain rebuild is therefore a no-op rather than a second recovery, and any newer checkpoints the destination has published since are preserved. The WASM guest-state generation the recovery published is part of that committed schedule, so reapplying it names the same generation and starts no further lifetime.
Abandoned Preparations And Staging
A new leader reconciles durable handoff preparations after a coordinator or participant is lost, as
described above. Resource uploads that were staged but never promoted are removed at startup, so an
upload interrupted by a forced ending leaves no partial version behind. Only the store’s own staging
directories begin with a dot: a resource whose name does keeps a directory whose name writes that
dot as %2E, so startup never removes its versions. See
Resource Versions And Bindings.
A backup archive a node retains for download is a temporary file in its staging area and is never durable. Stopping the node, gracefully or not, loses it: a later download is refused, while the backup’s recorded outcome stays retained under its execution reference. See Backup And Restore.
Unpublished restore checkpoint namespaces remain in the runtime database after an interrupted
installation. Every restarted node’s admitted maintenance borrows its applied consensus revision
before taking the checkpoint installation barrier. It retains selected publications, applying
installations and generations ahead of catch-up; without an applied log it retains all generations.
Terminal or superseded applied attempts are reclaimed in bounded deletion batches, including
chunks without receipts. Terminal teardown cancels the maintenance caller, and its storage job
checks cancellation between bounded units. A later startup resumes from the remaining keys.
The same owner reclaims selected-namespace chunks made unreachable by ordinary or replica
checkpoint replacement, ownership recovery or purge, including after restart. It keeps the
selected publication and every currently referenced segmented revision.
Reclamation preserves snapshot readers and never completes an installation or opens its START
gate. See restore checkpoint storage
for quotas, metrics and physical storage limits.
Domain Time
The paced mapping, lifecycle generation, and authority fence recover from consensus state. Tick progress, delivery loops, local read watermarks, and process-monotonic deadlines do not. After a restart, nodes install the committed mapping and the leader reconciles an authority among the current live voter incarnations. Because the authority identity includes the process incarnation, a restarted process with the same node name cannot act as the authority its predecessor was. See Domain Clock.
Observability
Shutdown is observed through its log, not through a dedicated metric family. There is no
shutdown-specific metric; a node that is shutting down is identified by its phase log lines and by
terminating: true in its status.
The phase records are the primary signal:
| Level | Message |
|---|---|
info | termination signal received; requesting graceful shutdown |
info | termination signal received while graceful shutdown is already in progress |
info | advertised terminating process incarnation |
info | shutdown admission phase finished |
info | shutdown drain-support phase finished |
info | shutdown terminal-teardown phase finished |
warn | repeated termination signal received; abandoning graceful shutdown |
warn | shutdown deadline expired; abandoning graceful shutdown |
Drain decisions and failures are logged beside them: stopping-node drain lost leadership; remaining work needs the next leader, preserving operator cordon across graceful shutdown, no live schedulable replacement node remains; admitted work completes in place, timed out reaching the leader before requesting a graceful shutdown drain, drained local node before graceful shutdown, failed to drain local node before graceful shutdown, the leader did not answer the graceful shutdown drain of the local node, timed out moving scheduled work off the local node before graceful shutdown, cleared shutdown drain cordon before graceful shutdown,
failed to clear shutdown drain cordon before graceful shutdown, the leader did not answer the release of the shutdown drain cordon, timed out clearing shutdown drain cordon, and the two
drain-timeout records above. The drain and cordon records name the leader that acted, which is the
node itself when it leads, and carry the leader’s account of each moved unit as message;
timed out clearing shutdown drain cordon carries the timeout the release waited. Each phase
record carries its outcome, so outcome=Completed distinguishes a finished phase from an abandoned
or forced one.
A source instance that stops before its transport confirmed positions it read records
source instance stopped before its transport confirmed positions it read; the transport presents them again at info, with the domain, the ingestor, the instance and the count of
positions.
Runtime teardown records every domain task it had to abort, at warn:
shutdown task exceeded grace period; aborting carries the domain, the task’s entity as
ingestor, its task_kind (scheduled node, scheduled emitter, generator, reingestor, or
scheduled node placement) and the grace_period it outlasted, and
relay owner task did not stop cleanly and relay state task did not stop cleanly carry the
domain, the relay and the reason. The generators and the reingestors of a domain that outlast
their shared grace are recorded at the same moment, one record for each.
Existing metric families move during shutdown without naming it. Interconnect stream resets count
reason="shutdown". The ingestor quiesce families change as intake stops, but they carry no cause
label, so a shutdown hold is not distinguishable there from another hold. Live branch instances fall
without incrementing the eviction counter, because a stopping node is not evicting branches. A
client ingestor’s endpoint logs ended the producers of a client ingestor at info with the count
and reason shutting down, its gauges fall to zero, and the batches it answered while draining are
counted under nervix_client_ingestor_submissions_total as draining refusals or as the outcomes
their roots resolved to. See
Metrics And Observability.
The health endpoints do not describe shutdown. /livez answers while the process is alive, and
/readyz reports whether the node currently knows a leader. Neither turns negative when the node
starts terminating, so an orchestrator that removes endpoints on readiness failure will not remove a
draining node on that signal alone. Closing the public listeners in the stop-admission phase is what
ends client traffic.
Troubleshooting An Incomplete Drain
Start from the outcome on each phase record.
shutdown terminal-teardown phase finishedabsent, process exited130or143. Something sent a second signal. Give the node its full shutdown timeout, or raise the supervisor’s grace period so it does not signal twice.shutdown deadline expired, exit status1. The node could not finish in its shutdown timeout. Read the drain-timeout record for the domain counts, and raise--shutdown-timeouttogether with the deployment grace period if the workload legitimately needs longer.local graph drain timed out with admitted work outstanding. Read the counts. Non-zero publishing emitters or buffered emitter messages mean a sink is not accepting; non-zero outstanding acknowledgements mean an ACK chain has not resolved; non-zero pendingREQUIRED WAITrecords are expected and do not hold the drain.local graph drain timed out before confirming that no admitted work is still moving. A peer is still publishing into this node’s relays. Drain or stop the upstream node first.timed out reaching the leader before requesting a graceful shutdown drain. The node observed no leader before its drain timeout. It still completed its local drain; its scheduled work is reassigned by failover once it is gone.failed to drain local node before graceful shutdown. The leader moved only part of the node’s work. Itsmessagenames each unit and why its move failed; those units fail over once the node is gone.the leader did not answer the graceful shutdown drain of the local node. The request did not come back from theleaderthe record names, anderrorsays why, such as a leader that left the cluster or a connection that could not be opened. The leader may still have moved some of the node’s work; whatever it did not move fails over once the node is gone.timed out moving scheduled work off the local node before graceful shutdown. The drain timeout or the shutdown deadline ended the wait for the leader’s answer. A leader on another node still finishes the moves it began; whatever is not moved fails over once the node is gone.timed out clearing shutdown drain cordon,failed to clear shutdown drain cordon before graceful shutdownorthe leader did not answer the release of the shutdown drain cordon. The release of the cordon the node’s own drain set did not complete, so the drain-support phase reportsAbandonedeven whendrained local node before graceful shutdownshows that every unit was handed over. Readraft.cordoned_nodesthrough another node. When it no longer lists the node, the release committed after all; when it still does once the cluster has a leader, the node takes no new placements, andUNCORDON NODE <node_id>clears the cordon, before or after the node starts again. See Releasing The Drain Cordon.- Work reappears after a restart. That is redelivery, not duplication of committed work: the drain ended before those records were acknowledged, so their source offered them again.
Deployment Grace Periods
A supervisor must allow a node to finish its own shutdown. Every deployment should set its grace period longer than the node’s shutdown timeout, so the node exits on its own terms rather than being killed partway through a drain.
| Deployment | Setting | Value alongside the 50s default |
|---|---|---|
docker run | --stop-timeout | 60 |
| Docker Compose | stop_grace_period | 60s |
| Kubernetes | terminationGracePeriodSeconds | 60 |
Docker’s own default of ten seconds is shorter than the default drain timeout and would kill a node
mid-drain. When you raise --shutdown-timeout, raise the deployment grace period with it; when the
grace period ends first, the supervisor sends SIGKILL and the node loses whatever the drain had
not yet completed.
Raising --drain-timeout above --shutdown-timeout does not extend the drain, because every drain
step is also bounded by the shutdown deadline. Raise both together.
Retained Drain Dependencies
Running source, generator and sink tasks retain their acknowledgement trackers and confirmation counters. A drain observes those same registrations. Emitter retry state remains publishing work even when its buffer is empty. Idle and already-claimed force-flush participants use a retained readiness hint; only an available obligation enters the coordinator to claim its generation. A released claim becomes available again. Task teardown removes its confirmation/status registration, and pooled sink teardown removes its wait registration; cancellation clears a pending borrow’s wait through its guard. Handoff watches register before reading their retained entity publication, and release publishes thaw before waking them.
Integration Test Lifecycle
Paced Interconnect Fixture
The rate-limited follower catch-up scenario starts a TCP byte pacer before restarting a stopped node. Its listener takes the existing advertised endpoint and forwards opaque bytes to a private interconnect listener on the restarted node. The advertised endpoint stays fixed so retained peer routes also reach the pacer. Its port remains reserved for the test run, as for other retired test endpoints. The host, TLS identity, resolver policy and product deadlines stay fixed. Each accepted connection uses a 1024-byte inbound buffer, delays each chunk by its configured byte rate before forwarding, and forwards responses promptly. A scenario-owned task set retains and reaps connections. Cancellation or fixture drop closes the listener and aborts all its connections; the fixture cannot survive its node specification.
The scenario starts the follower once without the harness’s catch-up retries, records append stream opening baselines before that start, and waits for the applied NSPL domain backlog within its explicit assertion bound. The opening count is a diagnostic observation because normal initial catch-up probes can also open streams. An atomic maximum of bytes forwarded on one connection proves that the paced path carried more than one production progress interval. This observation does not inspect TLS or frame bytes and establishes no ordering the product relies on.
The Cucumber scenario suite runs Nervix the way an operator does. It starts clusters, drives them through the session API, brokers, and HTTP endpoints, and asserts on what they do. Many scenarios run at once in one test process, each with nodes of its own, so the harness around them is a concurrent system with failure modes of its own: a node that never becomes ready, a status request that never gets a reply, a node that never stops, a step that never returns.
This chapter defines the harness’s lifecycle contract: how a node, a scenario, and the whole run start, how the harness observes them, how it stops them, and which deadline bounds each of those steps. Three rules hold throughout.
- Every lifecycle operation has one owner and one absolute deadline, fixed when its phase starts. A retry, a poll, or a cleanup inside the phase receives only what is left of it.
- Every ending has a typed outcome. A node task is running or ended in one of four ways, a readiness probe reports what it observed, and a scenario publishes the phase it is in. No harness path reports an unknown reason.
- The run ends itself before the CI job limit and leaves the job time to upload the diagnostics it produced.
The harness is test code outside the layer order: it may name any layer, and no product code names it. It observes product behavior and never redefines it. Product deadlines stay with the chapters that own them: node shutdown with Shutdown And Recovery, node-to-node communication with Cluster Interconnect, client requests, redirects, reconnects, and exact recovery with Client Session Protocol, and command identity and completion with Command Completion.
What Runs Where
Before starting ordinary CI producers, Check verifies its exact tested merge and every changed
path. A verified documentation-only selection excludes scenarios with the other ordinary suites;
validation and title checks remain enabled. This exclusion records a source decision rather than
scenario execution, and cancellation of a required run never supplies successful evidence. Explicit
mode labels retain their own selections. Documentation-only Selection
owns the allowed paths, immutable event snapshot and retained CI selection record.
The suite is the scenarios test target, tests/scenarios.rs, running the features under
tests/features. Its lifecycle owners live in tests/common.
| Element | Where it runs | Owner |
|---|---|---|
| Scenario steps and hooks | One runner task: Cucumber polls every running scenario, and the suite watchdog around them, from the task the binary’s main thread blocks on | tests/scenarios.rs |
| Scenario admission and timing | Feature permits, prioritized run slots, queue reasons, and accumulated attempt times in the runner process | scenario_schedule.rs and scenario_phase.rs |
| In-process nodes | One Tokio task per node on the binary’s multi-threaded runtime, which has one worker thread per CPU | The cluster fixture, tests/common/cluster.rs |
| Server processes | Child processes executing the nervix-server binary | The server-process fixture, tests/common/server_process.rs |
| Real-process cluster | Three server children with separate durable stores, ports and identities under one test certificate authority | tests/common/server_process_cluster.rs, using the server-process fixture |
| Deadlock diagnostics | In a scenario binary built for the deloxide mode only: one deadlock detector for the whole process, installed by the binary’s main, and a diagnostic run for each server child, each recording evidence in a directory of its own | tests/scenarios.rs; the @deadlock_diagnostics steps, tests/deadlock_diagnostics |
| CLI sessions | Child processes executing nervix-cli. One-shot commands and the subscribe and domain-clock subcommands run on pipes; the subscription reader retains at most 256 output lines and the clock reader retains at most 2,048. The interactive REPL runs on a pseudo-terminal, whose fixture retains the newest 256 KiB the terminal displayed | The scenario world, tests/scenarios.rs; the terminal fixture, tests/common/cli_terminal.rs |
| Test dependencies | Containers started on first use and shared by every scenario of the run | nervix-test-environment, through tests/common/dependencies.rs |
| HTTP receivers | Tasks on the binary’s runtime, one listener and one task per connection, owned by the scenario that started them | The HTTP receiver fixture, tests/common/http_receiver.rs |
| Injected holds | One task on the binary’s runtime per sequence of holds a step injects at controlled gates, aborted when the step drops it | tests/common/injected_hold.rs |
| Synchronous step work | One task on the runtime’s blocking pool per job a step hands off the runner task, such as reading, verifying or rewriting a backup archive or inspecting a stopped node’s database; the step awaits the job and fails with its panic | tests/common/runner_offload.rs |
| Runner-lag observer | A tick polled beside the whole run on the runner task, which logs every stall of that task of one second or more | tests/common/runner_lag.rs, wrapped around the run in tests/scenarios.rs |
| gRPC receivers | Tasks on the binary’s runtime, one listener, one task per connection and one per call, owned by the scenario that started them | The gRPC receiver fixture, tests/common/grpc_receiver.rs |
| Broker observers | One task on the binary’s runtime per observer, which reads the broker’s client and hands each message to the step over a bounded channel and is aborted when the scenario drops the observer. A core NATS observer has no task of its own, because its client already buffers what the server delivers | The cluster fixture, tests/common/cluster.rs, and the NATS observer, tests/common/nats_observer.rs |
| Client probes | A child process per probe of another language, or one blocking task for the in-process probe of the shared Rust binding, owned by the scenario that started it | The client probe fixture, tests/common/client_conformance.rs |
A scenario binary built for the deloxide mode, which just test-deloxide, just test-deloxide-order,
just test-deloxide-stress or just test-deloxide-stress-restore builds, starts its
deadlock diagnostics in main, right after it configures the lifecycle of its test dependencies and
before it builds its runtime, so its one detector is installed before any tracked lock or runtime
worker exists and every in-process node shares it; the helper process that holds a dependency
container open starts its own. It records its evidence in NERVIX_DEADLOCK_EVIDENCE when that is
set. A binary whose diagnostics cannot start exits with status 4 before any scenario runs, and the
first active deadlock any of its tracked locks reports ends the whole run with status 3, its
description on standard error. A server child started with the fixture’s deadlock evidence option
records its own evidence under its root, and is a diagnostic node only when the binary beside the
scenario binary was built for the mode. The ordinary suite leaves the @deadlock_diagnostics
scenarios out by default: their steps assert a detector an ordinary build does not have.
The diagnostic lane also selects the ordinary @restore_installation,
@client_ingestor_alter_drain, @memory_pressure_pause, @client_io_03_consumer_restore,
@client_io_03_generation, @udf_column_builder, @vhost_tls_rebinding and
@inferencer_branch_batches scenarios and the local @deadlock_reports
inspection/export/triage workflow. They exercise
the blocking applied-state guard through interrupted checkpoint staging, complete publication and
runtime handle clearing, including a delayed coordinator after leadership transfer and a
successor’s START, the memory-pressure pause of starting and running ingestors, Rust client
consumers restored after a cluster restart and closed by a domain stop, a Roto function’s column
builder, the HTTPS listener’s TLS configuration as a VHOST’s bundle is rebound under HTTPS
ingestion, and a batched inferencer’s ONNX session across interleaved branches. The report tool is
an ordinary local executable, even when the scenario process
selects order analysis. Potential findings remain recorded without ending the process; any
unreviewed workload finding prevents diagnostic qualification. Real server children and the
scenario process record the same compile-time/runtime selection. The ordinary CLI built by the test
dependencies drives these diagnostic nodes through its public protocol; the lane supplies its path
explicitly because the diagnostic binary has a separate build directory. The feature input is a
quoted file glob, and the lane requires every tagged scenario run to execute and pass without
retries. The recorded evidence covers tracked blocking locks reached by those workloads; async
waits, capture atomics, dependency locks and cross-node waits retain their other concurrency
checks.
The @restarted_voter_observation scenario runs in both selections, reaching whole-cluster teardown, reopening each node’s store, and automatic scheduling after the first relayed voter heartbeat. Its watch-channel barrier and concurrent fault map are untracked; the diagnostic evidence covers blocking locks reached by restart and scheduling. The native metadata restore scenario also runs in both selections after its Kafka replication stream and acknowledgement boundaries are repaired, retaining complete primary and replica metadata comparisons and distributed placement checks.
The measured restore and backup steps sample each node’s public bulk and restore-metadata executor gauges every 20 milliseconds while the ordinary CLI runs. They also record the harness process’s jemalloc allocated and resident bytes. The executor measurements separate the fixed checkpoint I/O and publication grant from retained archive descriptions, typed native values and planning allocations. Process measurements include every in-process node, dependency fixture and harness allocation; isolated one-node and three-node runs provide attributable comparisons. Sampled peaks are observations, not continuous maxima, and allocator resident bytes exclude mappings outside jemalloc. The scenarios also compare bulk refusal counters before and after each restore, and a measured backup’s check compares them and every node’s sampled bulk peak with that node’s published bulk budget. The large-native-metadata oracle stops the cluster after public restore and exact replay, then opens each node’s database through the production snapshot reader. It compares every lifecycle key, timestamp, incarnation, revision and Kafka topic/partition/offset on primary and replica copies. Those complete-value inspection allocations occur outside the restore measurement. The runtime’s testing-only inspection API returns decoded current metadata; the harness owns all archive comparisons and assertions. The scenario takes the CLI’s execution reference and replays it through the public session before and after restart, comparing the complete typed restore report. It saves the restored work’s public owners and replicas before restarting, requires distributed placement in the three-node case, and compares every saved placement after START and after receiving isolated output for every tenant. A failover that drops replicas or recreates guest state cannot make this oracle pass by moving all execution onto one node.
just test-native-metadata-restore-repeat runs that scenario’s three-node example ten consecutive
times at concurrency four with zero retries, preserving its normal harness deadlines. It stops
at the first failure or a summary that does not contain exactly one passing scenario. Every attempt
keeps its source revision and patch, each process output and the available node log under
target/native-metadata-restore; its completion record is written only after all ten pass.
The complete-generation fixture allows 120 seconds for every post-restore input to reach its raw relay. Its forty one-mebibyte guest saves exercise sequential source acknowledgements and durable replica checkpoints beside the other diagnostic workloads. The full active diagnostic selection reached 77 of 80 inputs at the former 30-second bound, while the exact same binary reached all inputs and preserved every branch in the focused four-case replay. This condition ends as soon as the exact expected count is observed; it retains the complete output, state and branch assertions.
The large materialized restore fixture prepares its rows through an acknowledged client producer. One round has a 120-second budget and one in-flight batch, including explicit replays of reported processing failures. Each replay replaces the same materialized keys with the same complete values, so partial effects retain the fixture’s value oracle. Every attempt and terminal result is logged; an unknown outcome, final admission refusal or expired round fails preparation. The producer closes after every tenant’s batch has completed, before the scenario checks all generator rows and takes the backup cut. Source admission, input delivery and the final archive and restore assertions remain separate observable steps.
The lane then selects @paced_simulation_reopen from the public paced-driver feature. It builds the
Rust driver in the lane’s selection and supplies its path to the same scenario fixture, so the
driver installs its own detector before entering its runtime. Both drivers exercise endpoint
replacement, pending submission ownership, explicit replay and replacement refusal against
diagnostic nodes on one and three nodes, without retries. Python uses the ordinary shared binding;
its locks and condition variables are outside this detector.
The buffered-route replacement scenario accounts for every reading in the first run’s outcomes and explicitly replays unresolved ledger entries. Ending a producer can refuse a pending or already planned submission even when its replacement opens in the same START generation. The scenario requires every planned reading to have exactly one effect after replay; each invocation’s summary counts only that invocation, including an empty replay when no reading was refused.
The lane’s diagnostic owner tests each start their detector in a fresh process before any store,
publication or executor lock: the state store’s staging, snapshot views, queued writers,
interrupted publication and bounded cleanup, and the materialized publication lifetimes. Each must
pass its libtest accounting and record exactly one qualifying process observation in both
diagnostic selections. The tracked locks’ conformance checks of nervix-primitives run the same
way, each in a fresh process in both selections. They record no evidence file, because the
primitive crate sits below the crate that owns evidence: a finding reaches a sink that aborts the
check, and the lane fails on that ending.
Test dependencies start through one suite-owned environment. If Docker creates a named container but cannot bind its randomly selected host port, that owner removes the failed container and tries up to three more times with a new port selection. It also removes the failed container when those attempts are exhausted, so a scenario retry does not collide with its name. Other startup failures reach the scenario directly.
The OpenTelemetry Collector dependency exposes its stdout and stderr to scenario assertions through one follower that the suite-owned environment starts with the container. The follower streams both outputs from the container’s start into an in-memory transcript of at most 1 GiB per output, and every scenario observes that transcript without the environment’s lock or another read of the container’s logs. A Docker logs read answers with the output as it stood when the read began, and the Collector’s output grows for the whole run, so no assertion re-reads it. The environment stops the follower before it tears the container down. A batching scenario reads the Collector’s debug exporter output to check the number and order of records in each received export request, using unique test markers to distinguish simultaneous scenarios sharing that container.
The debug exporter prints each export as two log entries: a summary that counts the export’s records, and then the export’s body. The Collector prints each entry whole, but an entry of a concurrent export can print between a summary and its body, so a summary does not identify the body it counts. The batching scenario therefore reads each export from its body alone. The body’s first line names its signal, and each log record, span or data point in it starts a line of its own, which is what the scenario counts. The logger prints the entry’s fields after the body’s last line, so a body is read only once its fields have arrived. The suite’s Collector configuration turns off the sampling of the Collector’s own logger. By default that logger prints only the first ten entries that share a message in each ten-second interval, and every hundredth after that, so it drops the summaries of exports that arrive close together.
The raw session fixture records domain-clock replies and frames in the order it reads them. A wait for a detach reply can queue ticks that arrived before the reply; the post-detach assertion checks the recorded wire order, clears those already queued frames, and waits for any new frame. An unread pre-reply tick therefore does not masquerade as delivery after detach.
The generator cadence assertion groups equal logical timestamps across concrete branches and checks increasing timestamps within each branch. It allows records from separate branch tasks to arrive in either order, as their delivery has no shared ordering contract.
tests-deps builds the CLI, the NSPL formatter, the paced simulation’s Rust driver and the shared
C binding its Python driver loads in the normal target directory. The full and focused
client coverage recipes build a standalone instrumented CLI beside their instrumented server binary
and place the normal NSPL formatter there. The scenario runner selects the covered CLI through
NERVIX_TEST_CLI_PATH, so its one-shot completion and command paths contribute to the same LCOV
report as the CLI’s binary unit tests and the server’s public scenarios. The general
coverage-scenarios and coverage-scenarios-append recipes provision the same instrumented CLI
and formatter, so a selection containing CLI scenarios needs no separate binary setup. The focused
CLI process coverage recipe exercises transaction inspection and the clock-following process
scenarios.
test-scenarios-coverage, coverage-scenarios and coverage-scenarios-append also build the paced
simulation’s Rust driver and the shared binding with coverage flags, and the scenario runner selects
them through NERVIX_PACED_SIMULATION_PATH and NERVIX_CLIENT_LIBRARY. A driver process inherits
the profile location of the scenario binary that starts it, so the Rust driver and the binding
loaded by the Python driver write their profiles into the same report when they exit.
The CI scenarios job uses the shared native
CI linker for the server, CLI and scenario harness,
including their instrumented builds. Coverage flags do not replace Wild linker selection.
The suite has one pool of run slots. Its size is the number of CPUs times the concurrency
factor, set by NERVIX_TEST_CONCURRENCY_FACTOR or --concurrency-factor and 1 by default;
--concurrency sets an absolute slot count. The CI scenarios job uses factor 3, so its
16-CPU runner has 48 slots. The instrumented inventory consumed 78,433 slot-seconds with 115
attempts still active, almost the entire 78,720 slot-seconds that 32 slots could supply in
the unchanged 41-minute suite budget. The additional slots use the observed CPU and memory
headroom; their complete-suite qualification retains runner load and the actual margin rather than
assuming that work stays constant as concurrency changes. Cucumber may take up all parsed scenarios
without charging a slot.
Admission grants feature capacity and a run slot in one decision, after both are available. A
scenario holds both through the end of teardown. The active-scenario diagnostic names the feature
limit or run slot a waiting scenario needs. No scenario runs alone or prevents unrelated scenarios
from starting.
The coordinated WASM state-reset feature runs one scenario at a time. The web console REPL,
execution graph, transaction inspector, and domain clock features share a limit of two. Both limits
affect only their named features. The parser takes these limited features up before the bulk of the
suite, and
available run slots favor a queued limited successor over queued ordinary work. The web console
group grants its two slots to the feature with the fewest prior grants, so one feature cannot keep
the other three waiting behind its whole queue. Their chains can therefore make progress throughout
the suite rather than after it. Cucumber retries a failed scenario twice; --retry 0 turns retries
off for a focused run. The before hook reads the attempt kind from its active-scenario registration.
Each feature queue keeps retries ahead of first attempts, and admission selects an eligible retry
before any first attempt, including a limited feature’s first attempt. A retry still needs its
feature capacity and a run slot together: one waiting for a full feature group leaves free slots
available to other work. Ordinary and WASM-reset queues retain FIFO order within an attempt kind;
the web-console group retains its feature fairness. The finite retry count bounds this priority;
first attempts cannot be kept waiting by an unlimited retry stream. Cancelled waiters return both
capacities before dispatch continues.
The CI jobs divide the work at the scenario boundary:
| Job | Work |
|---|---|
tests | Instrumented workspace build and all tests except the scenario target |
scenarios | Instrumented server, CLI and paced simulation drivers, the unsharded scenario suite at factor 3, and scenario logs |
coverage | After tests, scenarios and extra tests, just coverage-merge builds the one repository-relative ordinary workspace report from their artifacts and fails on an incomplete one. just check-coverage then scores the repository’s Rust sources against that report with the pinned cargo-crap, which the job builds through kache, and fails only when the analysis is vacuous: no CRAP score fails the job. The retained artifact and an advisory patch line coverage comment, which carries the CRAP section for the changed files, read the same report |
extra-tests | Native coverage collectors plus capability doctests, Miri, mutation, compiler, and primitive conformance checks |
shuttle | Only for a pull request labeled shuttle: modeled in-process concurrency checks, uncontrolled-nondeterminism rechecks, and failure schedules |
loom | Only for a pull request labeled loom: exhaustive memory-ordering models and failure checkpoints |
loom-qualification | Only for a pull request labeled loom: two shards of the Loom weakening qualifications, one weakened server build for each, and the runs of a failed qualification |
deloxide | Only for a pull request labeled deloxide: the diagnostic lane of each Deloxide selection side by side, the two stress selections included: its diagnostic builds, probes, owner tests and tagged one- and three-node scenarios under the native coverage collector, the supervision qualification, and every attempt’s record and evidence |
The checks, tests, and scenarios jobs use Go 1.26 and check out the source before installing
their pinned Go formatters. This makes the installer and the later cache publication and report
scripts available when a tool setup step fails. The installer retains every attempt in a
go-tool-bootstrap-<job> artifact and retries only a checksum database transport error within its
eight-minute budget. The publication and report steps check for their scripts before calling them,
so a failed checkout does not add a second failure that obscures the original one. The jobs’
existing deadlines and test budgets are unchanged.
The tests and scenarios jobs also sample runner CPU utilization and steal time every five
seconds. Every kache-backed job uses kache 1.2.1, records doctor output without making it a
test failure, publishes a cache report, and diagnoses its five most expensive misses with
why-miss. The shared S3 cache keeps executable and test-binary outputs, with stores sized for
the full builds: every kache-backed job and Docker image build uses a 1 TiB store ceiling. This is
an upper bound, not a disk reservation. The runner’s actual disk capacity remains the practical
limit.
The shared remote is Cloudflare R2 through its S3 API. Kache reads the base artifacts prefix
first. Pull-request jobs also read and publish entries, manifests, and shards under the shared
artifacts-pr prefix, configured in the daemon’s TOML file. This lets successive PR runs reuse
their builds. Kache lets only a protected-branch push publish to artifacts, and no workflow runs
on one; scheduled and manually dispatched native jobs retain kache’s read-only policy. Docker image
builds run kache inside BuildKit, which does not see the GitHub Actions environment, so their
builder publishes to artifacts. Fork PRs still need credentials to access R2. Kache’s doctor
displays the generic read-only CI policy even when the PR prefix is enabled; the job’s daemon
reports its actual write mode, which the start step prints and publication records.
CI configures the cache bucket through the required repository variables KACHE_BUCKET (bucket
name), KACHE_BUCKET_REGION (S3 region), and KACHE_BUCKET_ENDPOINT (S3 service URL), and reads its
credentials from the repository secrets KACHE_BUCKET_ACCESS_KEY_ID and
KACHE_BUCKET_SECRET_ACCESS_KEY. Each kache-backed job passes them, as kache’s KACHE_S3_*
environment variables, to python3 scripts/kache_remote.py start right after the setup action.
Docker image builds pass the same settings to their builder, whose build command runs
scripts/kache_remote.py configure before it starts the daemon. Either way the service URL is
written into the daemon’s TOML configuration before startup.
For Cloudflare R2, set KACHE_BUCKET_REGION to auto and KACHE_BUCKET_ENDPOINT to
https://<account-id>.r2.cloudflarestorage.com, without the bucket name in the URL. The bucket name
belongs in KACHE_BUCKET. scripts/kache_remote.py rejects URLs with a bucket path; supplying
the full bucket URL can make the remote cache appear empty.
Cache Publication
scripts/kache_remote.py owns the remote of every kache-backed job: the check.yaml, Bolero,
benchmark and build-book jobs, and the builder stage of every Docker image build.
kunobi-ninja/kache-action installs kache, sets RUSTC_WRAPPER, the store and a job-scoped
runtime directory, and stops the job’s daemon in its post step. It receives no S3 inputs, so its
post step neither saves a manifest nor pushes. The Start kache remote step that follows it:
- writes the daemon’s configuration,
$RUNNER_TEMP/kache-remote.toml, with the bucket, region, service URL, base prefix and pull-request prefix. The credentials stay in the environment. - exports
KACHE_CONFIG, the credentials, and the job’sKACHE_MANIFEST_KEYandKACHE_NAMESPACEto the job’s later steps. A matrix job’s key includes its matrix value, so every build variant keeps its own manifest and shards. - starts the daemon, and fails the job unless
kache --json daemon statusreports it running with that file andkache --json statsreports that the file namess3://<bucket>/artifacts. A daemon that has not answered yet gets three polls, two seconds apart. The step prints whether the daemon publishes uploads or, under kache’s read-only policy, skips them.
During the build the daemon publishes each entry the build stores: kache records a durable upload intent, uploads the entry in the background, and retries an upload that failed for a retryable reason every five seconds.
Publish kache cache runs scripts/kache_remote.py publish whatever the job’s result: just before
the kache report where the job publishes one, and as the job’s last step otherwise. For a job whose
work succeeded, it saves the build manifest with kache save-manifest, then reads the daemon’s
upload counters from kache telemetry write every two seconds until no upload is queued or in
flight. It waits at most 120 seconds, its kache commands included. The step’s 4-minute
timeout-minutes only backstops that bound. Every kache answer it reads is a versioned kache
document, and an answer outside its schema is reported rather than read. The outcome is one of:
| Outcome | When | What remains |
|---|---|---|
published | The manifest is saved and the daemon reports every upload of the job finished | Nothing |
incomplete | The budget ended with uploads queued or in flight, the manifest failed or did not finish, an upload failed for good, or the daemon stopped answering during the wait | Queued uploads go to the daemon’s shutdown drain, which kache bounds at 30 seconds |
read-only | Kache made the job’s remote read-only, as for a scheduled or manually dispatched run | Nothing: such a job publishes nothing |
not-run | The job’s work failed or was cancelled, so publication returns at once | Queued uploads go to the shutdown drain |
unavailable | The job has no kache command or no manifest key, or its daemon does not answer | Whatever a stopped daemon left queued: publication asks kache --json daemon status first because kache stats would start a new daemon |
cancelled | The step received SIGINT or SIGTERM | The record names the phase the step reached |
Publication never changes the job’s verdict. Every outcome exits 0 except cancelled, whose step
ends with the signal’s status, as the runner asked. incomplete, unavailable and cancelled also
add a workflow warning. A job that failed before its checkout has no script to run, so its step
adds only a notice. The record, publication.json and publication.md, lands in
$RUNNER_TEMP/kache-report, which the kache report artifact uploads where the job has one, and in
the job summary. An image build runs publish --job-status success as the last command of its
build stage, which reaches it only when every artifact has been built.
No job runs kache sync --push. Kache’s push-only sync lists every key under both prefixes before
it decides what to push. On 2026-10-14 and 2026-10-15 that listing grew from 650,859 to 690,780
keys, took six to eight and a half minutes in every kache-backed job, and found nothing the daemon
had not already published.
Product Deadlines And Harness Deadlines
Two kinds of deadline meet in every scenario.
A product deadline is configuration a node enforces on itself: its shutdown and drain timeouts, its command retry validity and transaction idle timeout, its node-unavailability and election timeouts. Test clusters start with test values for some of them. A scenario about one of them configures it, drives the node to it, and asserts the outcome the owning chapter defines. No harness deadline shortens a product deadline while the scenario can still observe it.
A harness deadline bounds how long the harness waits: for a status reply, for a node to become ready, for a cluster to stop, for the whole run. Its expiry changes nothing inside a node. It ends a wait with a typed failure, takes apart a receiver or a child process the harness owns, or ends the run. It never ends a node. A node’s task is only the future the node was started as: the node spawns the tasks that hold its databases, its listeners and its peers, and they would outlive an abort of that future with nothing left to join them. A node that has not stopped when a deadline passes is therefore left to end by its own shutdown, keeps what it holds until it has, and is recorded as still stopping, not as something the harness ended.
The cluster health scenario can fail application-health responses for one ordered probing-node and responding-node pair while other probes remain healthy. It can also hold one node immediately before that node reports a runtime revision prepared, wait until the hold is reached, and release it after asserting command completion. These are per-scenario injected conditions; their waits have harness bounds and do not shorten the product’s completion deadline.
An injected hold is the opposite case: a product deadline races it. The scenario holds a controlled
operation at a gate for a workload duration, counted from the moment it observes the gate, and the
node keeps its own deadline running while the operation waits. The backup wait scenarios hold one
domain’s quiesced cut for 12 seconds and the next one’s for 14 seconds, each inside its own
20-second cut budget. The scenario’s margin is the part of the budget the hold leaves, and a runner
task that another scenario’s synchronous step stalls for longer than that margin would lengthen a
hold timed on the runner task until the product deadline expires. tests/common/injected_hold.rs
therefore waits for each gate, holds it for its duration and releases it on a task of its own, and
the step that injects the holds only awaits that task. A stalled runner delays when the step learns
of a release, never the release itself. The step still watches the controlled operation, and a
command that ends before its gates were held fails the step with the command’s own outcome.
The boundary between them is kept in four places.
- Ordinary commands. The NSPL commands a scenario runs go through the production Rust client, with its execution identity, redirects, and reconnects. This includes the transaction qualification graph’s setup commands, which retain each execution reference if leadership changes while setup is applying. When a command document has server statements after a subscription, the harness sends those statements through the client and opens a raw session for the subscription on the node requested by the step. A standalone subscription stays on that requested node, including after failover. The large materialized backup workload submits each graph statement through the production client so a leadership change during its many relay definitions recovers that statement by reference; its Row subscription opens on a raw session after the graph is ready. A leader setup step whose other commands need one session, because they create a subscription or open a transaction, runs them over a raw wire session; when leadership moves before one of them reached the leader, or after the leader admitted it and before its outcome was known, the step sends that command again with the same execution reference to the current leader, as a client must, and goes on there, within 60 seconds per command and only while the session holds no subscription or transaction yet. The paced simulation’s published graph is loaded through one native client so its BEGIN/COMMIT state and command references survive an interrupted finalization. Raw wire probes retain the protocol’s received dispositions. A paced-driver file fixture writes only the scenario’s ledger or effect file before the driver starts. The published driver reads that file, and the scenario checks the process exit and the occurrence count of its terminal cause. No harness deadline shortens native commands. Only the status path described below is harness-owned, and it is a separate test-only boundary: it opens its own session and never redirects, reconnects, or retries by itself, so it adds no second client policy.
- Cleanup. Scenario cleanup runs after the scenario’s assertions, so it spends the harness cleanup budget rather than waiting for a product shutdown deadline. A scenario whose subject is shutdown, drain, or deadline expiry stops its nodes itself, inside its body, under the product deadlines it configured.
- How nodes stop. The harness stops an in-process node through its shutdown coordinator, with
the same stop request a first
SIGTERMmakes to a server process, and the node runs its shutdown phases under its configured deadline. Registering termination signals, the forced-exit supervisor, and exit statuses belong to the process boundary, which only the server-process fixture crosses. See Shutdown And Recovery. A real-process cluster can deliverSIGKILLto all three voters before waiting for any child to exit. On restart it launches all existing stores before awaiting readiness, so no one voter is required to answer without the persisted quorum. Each node must then report the same leader and all three voters through its public status endpoint. It can also fault one voter while the other two keep the quorum:SIGKILL, after which a step waits for that child to exit and checks the signal, orSIGSTOP, which freezes the process with its connections open, so its peers hear nothing from it and see no reset untilSIGCONTlets it run again. A killed voter restarts from its own database and ports, and the step then waits for the same leader and three voters as a whole restart does. While a voter is killed or frozen, the fixture asks only the running ones which node leads. The restarted-voter observation fixture stops every in-process node before arming its fault. It blocks direct gossip between the first leader and one voter, then holds that voter’s first relayed heartbeat while allowing the other voters’ heartbeats to advance. The leader’s reconciliation starts after that sample arrives, so slow harness startup cannot consume the product’s observation grace. A one-shot barrier captures a scheduling pass inside that grace with the voter marked unavailable; its elapsed-time decision is captured before the barrier waits. The scenario checks the failure-detector verdict and final ownership through public cluster status. Releasing the barrier waits for that pass to finish before restoring heartbeats and application health. While the heartbeat is held, peer startup waits for listeners; Raft catch-up is checked after release, so the fixture never waits for progress its own fault prevents. This is an in-process scheduling regression; immutable-image external Chaos runs retain the real-process crash and restart evidence. The durable follower catch-up fixture creates client writes while the restarted follower applies its backlog and samples commands memory throughout that interval. Once the backlog is applied, stopping the writer finishes its admitted command before counting completed writes: the response can arrive after all voters have already applied that command. The fixture still checks the storage-derived catch-up bound, append-stream limit and commands-memory budget. Its initial burst of domain creation also uses one production client, retaining each command’s execution identity across a leadership change during finalization. A raw session’s recoverable disposition cannot end the fixture before follower catch-up starts. A listener bind failure retries only that listener family: the gRPC pair, HTTP, HTTPS, observability, web console, or interconnect. A readiness timeout may still move all listeners within the same startup budget. A changed interconnect address remains reserved while a peer may dial it. Metrics sampling follows the actual observability endpoint before the next launch. The durable catch-up scenario deliberately occupies the stopped follower’s observability port until cleanup of its first startup attempt, so a metrics bind collision cannot silently turn the measured catch-up into an interconnect address and membership change. The planned Kafka handoff scenario installs its subscription and emitter stall, publishes and flushes both branch records, and then starts intake. Both records are therefore available to its first parallel batch. The source host waits for that batch’s attached ACKs before polling the next batch; separate publications after intake starts do not establish one batch.just coverage-harness-liveness <filter>measures the ordinary startup and other harness owner regressions with counters of their own, as the diagnostic lane section describes. A patch that also changes product code passes the retainedlcov.infotojust coverage-patchas another--report, beside the workspace report. - Test defaults. An in-process node’s shutdown timeout defaults to four minutes rather than the
product’s
50s, which leaves the bounded shutdown phases scenarios configure by default room to finish, so only a scenario about the deadline reaches it. A server process runs with the product defaults unless its scenario passes an option such as--shutdown-timeout.
Deadline Ownership
Every lifecycle operation of the harness is bounded by exactly one of these deadlines. Each budget
is a constant in the module that owns it, under tests/common unless named otherwise; where a
second module runs the operation, it is named after the owner.
| Operation | Owner | Deadline | When it passes |
|---|---|---|---|
| One status request: connect, open a session, send, receive | status_request.rs | 10 seconds, or what is left of the enclosing phase when that is less | The request fails naming the operation that was still pending |
| A status wait: leadership, voters, a consistent leader, interconnect status, applied index, a status fragment | status_request.rs, run by cluster.rs | 40 seconds from the start of the wait | The step fails with the elapsed time, the last status text, and the last typed failure |
| Status snapshots of every node, for teardown or for a failure message | status_request.rs, run by cluster.rs | 10 seconds for the whole cluster | Each node keeps its status, its typed failure, or the operation its request was in |
| Readiness of one startup attempt | node_startup.rs, run by node_liveness.rs | 36 seconds, inside the node’s startup budget | The attempt fails naming the node, attempt, elapsed time, task state, and last readiness outcome |
| Cleanup after a failed startup attempt | node_startup.rs | 5 seconds, inside what the startup budget has left | The node keeps its task, and the startup ends rather than launching over a node that is still stopping |
| One node’s startup, every attempt included | node_startup.rs | 84 seconds | The startup fails with the typed record of every attempt |
| The node startups of one cluster construction | node_startup.rs, run by cluster.rs | 84 seconds per node, shared by the whole construction | The node that ran out ends the construction |
| A node a scenario stops itself | cluster.rs, recorded by node_stop.rs | The longest of five minutes, the configured shutdown timeout, and the configured drain phases | The step fails naming how long the node had been stopping and the shutdown phase it was in, and the node keeps its task and everything it holds |
| Scenario cleanup of a whole cluster | cluster_teardown.rs | 60 seconds for every node together | Nodes still stopping are left to end themselves, keep their storage and ports until they have, and are recorded as still stopping |
| Stopping a scenario’s HTTP and gRPC receivers | http_receiver.rs and grpc_receiver.rs, run by tests/scenarios.rs | 6 seconds for every receiver together: 5 for its connections, 1 for its accept loop | Still-running connections, then the accept loop, are aborted and joined and recorded as forced |
| An HTTP receiver wait: captured requests or a recorded fault | http_receiver.rs, run by tests/scenarios.rs | 60 seconds from the start of the wait | The step fails with the captured count, the fault count, and the latest fault |
| A gRPC receiver wait: captured calls | grpc_receiver.rs, run by tests/scenarios.rs | 60 seconds from the start of the wait | The step fails with the captured count, the fault count, and the latest fault |
| A server process’s readiness, exit, or log line | server_process.rs | 120, 120, and 60 seconds | The step fails, quoting the last 80 lines of the process log |
| Convergence of a started or restarted real-process cluster, or of one restarted member | server_process_cluster.rs | 120 seconds, with each status request capped at 10 seconds | The step fails with the last status or typed request failure from every member |
| A one-shot CLI command or a streaming output assertion | tests/scenarios.rs | 60 seconds for a command, 30 seconds for a subscription line, the duration the step names for a clock line, between 10 and 60 seconds, and 10 seconds for clock-process exit after Ctrl-C | The step fails with the process result or retained output lines |
| Text the interactive CLI is expected to display, its startup banner included | cli_terminal.rs, run by tests/scenarios.rs | 60 seconds, pressing Enter every 250 milliseconds so the REPL draws a prompt and prints the events it queued; a row expected from repeated HTTP posts gets 2 seconds after each post within the same 60 | The step fails quoting the newest 40 lines the terminal displayed, with control sequences removed and repeated lines collapsed |
The interactive CLI’s exit after the scenario types exit | cli_terminal.rs, run by tests/scenarios.rs | 60 seconds | The step fails with the exit status or the elapsed wait, quoting the same transcript |
| One draw from the port pool | port_pool.rs | 65,536 consecutive draws that land on reserved ports | The draw fails with the pool exhausted |
| The whole scenario run | suite_watchdog.rs | 41 minutes, injectable | Every active scenario is reported, live nodes get a 60-second cleanup window, and the process exits 124 |
| Stopping the test dependencies after the run | suite_watchdog.rs | 2 minutes | The containers are left to the runner |
| Dropping the runtime after the run | suite_watchdog.rs, run by tests/scenarios.rs | 60 seconds | Blocking tasks still running are abandoned |
Everything else a scenario’s body does, including its NSPL commands, broker and metric waits, and assertions, keeps whatever bound its step declares. Some of those steps check their bound only between requests, so a request that never returns outlasts it; the suite budget bounds them in every case. It is also the only bound on the time a scenario spends queued for its concurrency permits and on closing the browser during cleanup, neither of which has a budget of its own. Browser cleanup explicitly awaits the Playwright driver’s shutdown before dropping its handle. The driver’s own five-second exit grace is asynchronous, so cleanup keeps the shared scenario runner and its watchdog available rather than waiting in Playwright’s blocking destructor.
Absolute Phase Deadlines
A phase deadline is one instant on the monotonic clock: the moment the phase started plus its budget. Operations inside the phase receive that deadline rather than a fresh timeout, so the phase ends when its budget says it does, however many requests, retries, or polls it made.
- Nesting. An operation inside a phase that may take at most a budget of its own gets whichever comes first: that budget from now, or the phase’s deadline. A status request inside a status wait, a startup attempt inside a node startup, and a node startup inside a cluster construction are all nested this way.
- Bounding. Running an operation within a deadline ends it when the deadline passes. An operation that begins after the deadline has passed does not run at all, so a stream of immediately ready work cannot keep a phase alive.
- Polling. A poll repeats an attempt until one output is accepted or the deadline passes. Every attempt receives the same deadline, the pause between attempts never sleeps past it, and on expiry the poll returns the last output it rejected and the last failure it saw.
- Owned work. A wait for something its owner must keep, such as the join handle of a node task, borrows it for the length of the wait and never takes it. Whether the wait finishes, runs out of time, or is cancelled from outside, the owner still holds the handle, so no path leaves a node’s task running with nothing able to join it.
The deadline keeps its start and its budget rather than their sum, as the product’s shutdown deadline does, so a budget of any length cannot overflow the clock.
Deriving A Budget
A harness budget is derived from named policy inputs rather than written as an independent literal. Each input is a measured duration, the slowest healthy value observed while the whole suite ran at the CI concurrency factor, or a headroom factor that multiplies one. Every derived budget is checked against the budgets around it by assertions evaluated at compile time, so a change that breaks an ordering fails to build rather than producing a harness that outwaits itself.
| Budget | Derivation | Measured input |
|---|---|---|
| Status request, 10 seconds | The 40-second status wait divided by the four requests it must outlast when none of them replies | The slowest healthy reply, 3.7 seconds, times a headroom of 2 fits in one request |
| Status snapshots, 10 seconds | One request timeout, because every node is asked at once | None of its own |
| Startup attempt, 36 seconds | A policy input | The slowest healthy node startup, 24.2 seconds, fits in one attempt; the same 3,465 startups had a 2.0-second median and a 4.3-second 99th percentile |
| Node startup, 84 seconds | Two full attempts, each 36 seconds of readiness, a 5-second cleanup slice, and a 1-second pause | Stays under a 90-second ceiling |
| Cluster cleanup, 60 seconds | The slowest healthy cluster stop, rounded up to 15 seconds, times a headroom of 4 | 12.2 seconds at the slowest over 163 cleanups, with a 0.15-second median and 1.6 seconds at the 90th percentile |
| Suite, 41 minutes | The 60-minute scenario job limit, less 14 minutes allowed for pre-suite work and a 5-minute reserve after it | Split-job runs took 11m37s and 10m08s before the suite, then 21m49s and 20m26s for the suite; the 22-minute ceiling leaves at least 15 minutes of slack |
The assertions keep these orderings, among others:
- A status request is materially shorter than the waits and startup attempts that repeat it, and one attempt outlasts three readiness requests that never reply.
- Status snapshots are shorter than the waits whose failures they explain.
- A startup attempt outlasts the slowest healthy startup, a cleanup slice is short beside the attempt it ends, and the node startup budget stays under its ceiling.
- The cluster cleanup budget is shorter than the watchdog of a node a scenario stops itself, and the test shutdown timeout lies between the default shutdown phases and that watchdog.
- The suite watchdog’s cleanup window fits inside the reserve, and so do the dependency stop and the runtime shutdown together.
The 22-minute observed suite ceiling plus 15 minutes of slack fits the 41-minute budget with four minutes to spare. The second split-job run passed in 20m26s with seven retries and 94.3% run-slot utilization. Increasing either measured input eventually fails the compile-time assertion. Measure both again whenever the suite, its concurrency, or the runner changes.
Status Requests And Status Waits
The harness reads cluster state with SHOW CLUSTER STATUS;. A status request opens a new connection
to the node’s session endpoint, in plaintext or over TLS trusting the test certificate authority
when the node serves its session API over HTTPS. It opens a session authenticated as the default
test user, sends the command with a request identity, no domain, and a fresh execution reference,
and reads the session’s frames until the reply naming that request identity arrives, keeping the
session’s request stream open so the node never sees the session end while it answers.
The request has one deadline: 10 seconds, or what is left of the phase that sent it. Its four operations run in order, each within whatever that deadline has left.
| Operation | What it waits for |
|---|---|
| Endpoint connection | The transport connection, including the TLS handshake when the node serves HTTPS |
| Session establishment | The node accepting the authenticated session |
| Command send | Handing the command to the open session |
| Response receive | The reply naming the command’s request identity, past any unsolicited server events that arrive first |
A node that never completes an operation holds the request in it until the deadline: a listener that never accepts leaves a TLS handshake waiting, a node can accept the connection and never open the session, and a node can take the command and never answer it, or answer only with unrelated events.
A request ends in one typed failure: the deadline passing, naming the operation still pending and the budget; an endpoint or TLS authority that could not be configured; a command frame that could not be encoded; a connection, session, or response-stream error carrying the transport’s status; a session that ended before the result; a frame from the node that does not decode, or a reply to the command that is not a command result; or, when the caller needs the status text, an unsuccessful command result with its disposition, message, and diagnostics. Client diagnostic scenarios inspect the failed disposition and source span from that result, so the assertion uses the delivered diagnostic rather than parsing the displayed message.
A status wait polls one node every 200 milliseconds until its parsed status satisfies a
condition, within 40 seconds from the start of the wait. The waits the harness performs are a
current leader, a specific leader or a leader other than a given node, a Raft state, the expected
voter set, an interconnect status to a peer, an applied index at least the leader’s, and a fragment
of the status text. Two waits read every node at once: a leader the running nodes agree on, and a
consistent leader, which requires every node to name the same leader, that leader to report itself
Leader, and three consecutive polls to agree. Every request of a wait receives the wait’s
deadline, so a wait whose last request never replies still ends at its original deadline rather than
one request timeout later. A wait that expires fails its step with the elapsed time, the last status
text it rejected, and the last typed failure it saw. The meaning of the connected interconnect
status these waits read belongs to Cluster
Interconnect.
The stopped-voter DROP NODE scenario waits for the public status report to show that the voter
is no longer visible in gossip before removing it, then requires the exact remaining voter set.
Application health can report an unavailable voter while gossip still sees it live, so an unavailable
verdict does not establish membership-removal readiness. Scenarios that exercise application-health
failure classification assert that source under their own controlled health fault.
The correlator match-policy scenario observes each left input’s received counter on the correlator owner before posting the next independent input. An HTTP acceptance alone does not order execution across ingestors. Its two-minute correlation window accommodates those bounded readiness waits without expiring the records whose match policy it asserts. The Kafka handoff scenario that pauses after entity-gate engagement uses a test-configured two-minute gate lease so its broker-delivery assertions and pause release fit inside the handoff budget under concurrent suite load.
Collection and route-flush timing assertions measure observed row arrival from the HTTP publication instant. Concurrent branch posts retain one publication origin, and every expected row must match its tenant and sequence and arrive after the declared lower bound, inside the unchanged delivery budget. Starting a new silence window after an HTTP response would reject a valid row whose collection or flush deadline elapsed while the harness awaited that response. The production owner’s timing checks retain the independent branch-deadline assertions.
The MQTT changed-address restart scenario waits for the post-restart marker within its bounded delivery window. Its persistent QoS 1 source may replay the pre-restart marker before that row; broker receipt does not establish that its source acknowledgement reached the broker before the node stopped. The OTEL metric scenario observes one successful export from each concrete emitter owner before checking Collector output. HTTP intake acceptance does not establish sink export, and the per-owner counters retain exact progress assertions for both HTTP and gRPC.
The OTEL Collector content wait keeps its ten-second bound, from the step’s start to the text’s appearance in the followed transcript, counting output printed before the step began. Each look searches only what arrived since the previous look, plus the bytes an occurrence could span across it, and every append wakes the wait, so the text is seen as soon as the follower reads it. No look re-reads the whole output, so a record delivered within the bound cannot be missed by a long read that began before the delivery and ended after the bound. If the bound expires, or the follower stops first, the failure reports whether the text arrived after the wait ended, how far the follower has read each output and how it stopped, the latest 200 Collector lines that name the scenario’s test identifier, and the last command output. A collector timeout alone does not establish that the emitter lost a record or failed to export it; retained diagnostics and a new attempt remain separate evidence.
A scenario step can also read status inside a window of its own, passing that window as the phase deadline. Where the window bounds how long something is watched rather than how long one read may take, every read keeps a full request timeout instead.
The harness session client keeps domain-clock replies and frames in arrival order. When a detach reply ends an attachment, it retires that domain’s unread frames from the pending observation queue while preserving them in the ordered log. Assertions about events after detachment therefore start at the acknowledged boundary, including when ticks arrived while the client awaited the reply.
Node Tasks And Readiness
An in-process node is the server application started as one Tokio task. That task is the node’s root, not the whole node: the application spawns the tasks that run its domains, its consensus, its gossip and its interconnect, and those tasks hold its databases and listeners until the application’s own shutdown stops them. The harness holds the root task through a single owner, which is in one of four states:
- Not started. No task has been spawned.
- Running. The task is live, and the owner holds its join handle.
- Left stopping. The task outlived the wait its owner could give it and was handed to a keeper, which waits for the node to end.
- Terminal. The task ended, and the owner holds how: a clean application exit, an application error carrying the node’s own report, a panic, or a cancellation, which is how a task ends when the runtime that ran it is dropped.
Running and ended are read from the owner’s state and never inferred from whether an error message exists. Inspecting a task that has finished joins it and keeps its terminal outcome, so a diagnostic never takes the handle a later stop needs. A wait receives its deadline and reports that the task was never started, had already ended, ended within the deadline, was still running when the deadline passed, or had already been left stopping.
The owner never aborts the task. An abort would end only the root future and leave the tasks the node spawned running with its databases, its listeners and its connections to its peers, owned by nothing and joined by nothing: a second launch into the same directory would find the database locked, and the orphaned node would keep working beside every scenario that followed it. A node ends by its own shutdown instead. One that is still running when its owner stops waiting is left stopping: the owner hands the task to a keeper that waits for it to end, and the keeper holds what the node still uses, the cluster’s storage, the node’s seven ports, its consensus fault registration and the cluster’s place in the live-cluster registry, and gives them back only then. A node whose handle is dropped while it runs is asked to stop and left stopping the same way. A keeper prints the end it waited for:
node '<node>' ended <elapsed> after it was left stopping: <terminal outcome>
A node that never ends keeps what it holds until the process drops the runtime.
A readiness probe is one status request, and it reports one of three outcomes:
- Ready. The node returned a successful command result, or one saying it is not the leader. Either way it answered an authenticated command.
- Request failed. The status request ended with its typed failure, including the operation a passed deadline interrupted.
- Non-ready response. The node answered with an unsuccessful result, kept with its kind, message, and diagnostics.
The readiness wait probes every 200 milliseconds within the attempt’s deadline and remembers the most recent outcome. The owner inspects the task before each probe and after it. The wait succeeds when a probe is ready and the task still runs. It fails with node task terminated before readiness when the task has ended, and with startup deadline expired when the deadline passed while the task was running. Either failure names everything needed to tell resource pressure from a deterministic failure:
node '<node>' startup attempt <n> failed after <elapsed>: <failure>; task state: <state>; last readiness outcome: <outcome>
The task state is not started, running, or terminal with the terminal outcome, and the last
readiness outcome is the most recent probe or no readiness probe completed.
Node Startup
Starting one node has one advertised worst case, 84 seconds, whatever happens inside it. The budget starts before the first launch, and every readiness probe, every cleanup after a failed attempt, and every pause between attempts receives only what is left of it.
one startup budget: 84 s, fixed before the first launch
|<------------------------------------------------------------------->|
| readiness | cleanup | pause | readiness | cleanup | pause | launch 3 |
| <= 36 s | <= 5 s | 1 s | <= 36 s | <= 5 s | 1 s | remainder |
|<-------- attempt 1 -------->|<-------- attempt 2 -------->|
The budget pays for two attempts at their full length. A third launch is allowed because a launch that fails at once, such as one whose listen address another process took first, leaves most of the budget unspent; that launch receives whatever the earlier ones left.
After an attempt fails, the harness classifies the failure, cleans up, and records the attempt.
| Failure | Classification |
|---|---|
| The harness could not launch the node: an address it could not parse, the test certificate authority, or the node’s database directory | Terminal: the harness’s own configuration, which the next launch repeats |
| Readiness did not arrive before the attempt’s deadline | Transient, selecting all listeners: the resource pressure the budget exists for |
| The node’s task ended with an application error binding its gRPC, HTTP, HTTPS, observability, or web console listen address, or starting the interconnect listener | Transient, selecting that listener family: another process took the port first, and its fresh addresses resolve it |
| The task ended with any other application error, a clean exit, a panic, or a cancellation | Terminal: the node decided it from its configuration, its stored state, or its own code |
Cleanup requests the node’s stop and waits for its task within a 5-second slice of what the budget has left. A node that never became ready has no drain to finish, so the product’s shutdown deadline plays no part here. A node whose task ended inside the slice gives back its consensus fault registration, and after a transient failure it moves the selected listeners to freshly allocated ports. A bind failure preserves unrelated addresses; a readiness timeout selects all seven ports. The harness pauses one second before launching again.
A node still stopping when the slice passes is never launched again. It still holds its database and its listeners, and it will until its own shutdown has finished, so a fresh launch into the same directory could only fail on the lock its predecessor holds. The node keeps its task, its shutdown coordinator and its fault registration, the startup ends, and whatever stops the cluster afterwards waits for the node or leaves it stopping.
The startup ends in one of five ways, each reported with the node, its budget, the time spent, and the record of every attempt:
- the last attempt failed for a terminal reason;
- every launch the attempt limit allows was spent;
- the budget ran out before another launch could start;
- the node the last attempt launched had not stopped when its cleanup slice ended;
- fresh ports for the next launch could not be allocated.
Each attempt’s record names its number out of three, its classification, how much of the budget had been spent once it was cleaned up, its typed failure, and its cleanup: nothing was launched, the task stopped with its terminal outcome, or the node was still stopping at the cleanup deadline. A cleanup that the spent budget left no time for records the node as still stopping too, and that startup ends as the spent budget or the spent attempts rather than as a node that would not stop.
The harness prints every transition to standard error as it happens, so a run heading for exhaustion is legible while it is still going:
node '<node>' startup: ready after <elapsed> on attempt <n>/3; <remaining> of its 84s budget left
node '<node>' startup attempt <n>/3 [<transient or terminal>] failed <elapsed> into the budget: <failure>; cleanup: <cleanup>; <remaining> of its 84s budget left
The budget bounds what the harness waits for, not what the operating system schedules. A runner that stops running the harness’s own tasks for longer than an attempt delays the check that ends it, and the measured startups above include such a runner.
Cluster Construction
A cluster is built one node at a time, bootstrap node first. The construction has one deadline, 84 seconds times the node count, and each node’s startup budget is nested inside it, so no node can outlive the construction. Once a node is ready, the harness asks the other nodes for the current leader and that leader’s applied index within one 40-second lookup, and then waits up to 40 seconds for the new node to apply as far.
Once every node has started, construction waits for a current leader on the bootstrap node. A
cluster of more than one node must also converge, and each check is its own 40-second status wait: a
current leader on every node, the full voter set, a consistent leader on all nodes, and connected
interconnect status from every node to every peer, which is six waits in a three-node cluster. A
construction therefore waits up to 84 seconds per node for startups and then up to 40 seconds for
each status wait that follows.
The start step logs cluster start requested: with the node count, domain, and test id. When
construction fails, the harness stops the nodes it had started the way scenario cleanup does, all of
them together under the one 60-second cleanup budget, because a construction that failed has no
product shutdown deadline left to observe. It logs cluster start failed: with the error, followed
by the cleanup record and every node that was still stopping or panicked when there was one, and
fails the step.
The other ways a scenario starts nodes reuse the same budgets. Restarting a whole cluster stops its nodes and builds it again with a fresh construction deadline. Adding a node, or starting one a scenario stopped, spends one 84-second node startup and the catch-up that follows it; adding a node also repeats the convergence checks. A node started while its consensus connectivity is blocked spends the node startup without the catch-up. A scenario whose subject is a node that cannot apply what the leader committed starts it with one attempt’s 36-second readiness and no retry, because a retry would restart a node that had already consumed the failure the scenario armed.
Scenario Phases
A scenario publishes the phase it is entering, so a reader sees the work in flight rather than the last work that finished.
| Phase | Published when |
|---|---|
queued | The suite has taken the scenario up, and it waits for the permits that let it run beside the scenarios already running |
started | Its steps begin |
body complete | Its steps have ended and their result is known |
teardown started | Cleanup begins by releasing the faults the scenario injected |
teardown diagnostics | Bounded status snapshots and the scenario’s context are recorded |
stopping | Its fixtures and cluster nodes are stopped |
finished | The scenario and its cleanup have both ended |
queued -> started -> body complete -> teardown started -> teardown diagnostics -> stopping -> finished
finished is published only once cleanup has completed. A scenario holding any other phase has not
finished, and a scenario whose log ends at a phase marker is still inside that phase.
Browser assertions that wait for an acknowledged subscription tab to disappear poll for up to 10 seconds. This bounds the server reply and browser update together; on expiry, the step reports the tab text that remains visible.
The active-scenario registry holds every scenario that has started and not yet ended. A scenario registers in its before hook, before it acquires its permits, so a scenario that never gets them is visible while it waits. Its entry holds:
- its identity: the feature, the scenario name, and the line where it begins, which tells apart the example rows of an outline that share one name;
- its attempt, counted from one, which is exact because Cucumber takes up a retry only after the run before it has ended;
- its phase, when it entered that phase, and when it started.
The entry is keyed by a registration the scenario’s world owns rather than by anything the feature
file supplies, and it leaves the registry when that world is dropped, whether the scenario passed,
failed, or panicked. The suite’s report prints scenarios in order and can keep a finished scenario’s
world until earlier scenarios have reported, so a finished entry can still be present when the
registry is read.
Entering a phase also appends a marker to the scenario log:
scenario <phase>: feature="<feature>" scenario="<scenario>" line=<line> age=<age> <detail>
body complete carries result= with the body’s result, which is passed, skipped, before hook
failed, or step failed with its error. finished carries body= with the same result and
teardown= with the cleanup record. Cleanup reports itself separately: a node left stopping and a
node that panicked while stopping are recorded beside the scenario’s result and never turn a passing
body into a failure.
Scenario Teardown
The after hook runs every scenario’s cleanup through the phases above.
body complete
|
teardown started release paused health responses, domain-clock progress pauses, and
| command pauses; stop durable catch-up writers and clear consensus
| commit delays
teardown diagnostics every node's status at once within 10 s; the scenario's context
|
stopping abort CLI output readers and kill their child processes;
| drop HTTP load, held uploads, server processes and process clusters,
| and observers;
| stop HTTP and gRPC receivers within 6 s; close the browser and the
| session;
| stop the cluster within 60 s;
| release proxies, silent peers, permits, and fixture ports
finished
Teardown Diagnostics
The harness asks every node for its status at once, under one 10-second budget for the whole cluster, so a node that never replies delays cleanup by at most that long and cannot hide another node’s status. Each node’s outcome goes to the scenario log on its own line: its status text, the operation its request was still in when the budget passed, or its typed failure.
scenario teardown <identity>: node=<node> status=<status text>
scenario teardown <identity>: node=<node> status_timeout=<operation> pending after 10s
scenario teardown <identity>: node=<node> status_error=<failure>
A scenario context: line follows with the scenario’s domain and test id, its last command error
and output, its last server error, and its last subscription and broker payloads. Diagnostics are
best effort: whatever they observe, cleanup continues into stopping. Steps that explain their own
failures can collect the same snapshots.
Cluster Teardown
A cluster’s nodes stop within one deadline of 60 seconds for all of them together.
- Every node is asked to stop before any node is awaited.
- Every node’s task is awaited concurrently under the one deadline. Each wait borrows its task from the node’s owner, so a node still stopping when the deadline passes is still owned.
- Once the waits are over, each node whose task ended, whatever ended it, gives back what it holds: its seven port leases and its consensus fault registration.
- Each node still stopping is left to end itself. Its task goes to a keeper together with what the node still uses, and the keeper gives that back once the task has ended.
A cluster of three wedged nodes therefore costs one budget rather than three, and no path ends a
node or takes anything from under one that still runs. The cleanup record reads
stopped <n> of <n> node(s) in <elapsed> of a 60s budget, <n> still stopping. Dropping the cluster
afterwards removes the temporary storage its nodes wrote as soon as no node left stopping still
shares it. Two more records appear only when they apply:
scenario cleanup unfinished: node "<node>" was still stopping at the cleanup deadline, in shutdown phase <phase>, <the node's own deadline>, and keeps its storage and ports until it ends
scenario live during unfinished cleanup: <identity> attempt=<n> phase=<phase> phase_age=<age> age=<age>
scenario teardown failed: <node record ending in a panic>
A node left stopping is read through its shutdown coordinator before it goes to its keeper, as Scenario-Driven Stops describes, so the record says which of the node’s own shutdown phases the cleanup deadline found it in.
An unfinished cleanup is usually cleanup that ran beside work heavy enough to starve it, so the
harness names every scenario live at that moment, and the keeper’s own line later says when the
node ended. A node whose task panicked while stopping has no other witness than its record. In the
qualification runs that characterized them, cleanups that ran out their budget were nodes of WASM
scenarios whose setup had already failed with not-a-leader, and those scenarios passed when
retried.
A step that replaces a scenario’s cluster with a fresh one stops the old cluster under the same budget and fails when a node of it was left stopping: that node still runs beside the cluster that would replace it, so the replacement has not happened.
The consensus fault registration keeps an observer of the node’s consensus state, as does every observer a step took from it, so the node’s consensus database stays open after the node’s task has ended until the last of those observers is dropped. That drop closes the database synchronously, on the task that makes it: the close synchronizes the journal and waits until none of the database’s background workers is left. Cleanup and scenario-driven stops give the registration back on the runner task, so every scenario and the suite watchdog wait for that close, while a keeper gives it back on a runtime worker. The close never waits for a worker whose flush or compaction failed; see Terminal Teardown.
What Cleanup Releases
Harness state goes back only after the tasks that used it have ended, so the next scenario never finds a port, a fault, or a proxy taken. What a node left stopping still uses stays with its keeper until that node has ended.
- CLI output readers, the terminal task of an interactive CLI among them, are aborted and their
kill_on_dropchild processes are dropped before node teardown, which also removes the REPL’s working directory. Background HTTP load, held uploads, server processes and real-process clusters are also dropped first. Dropping any server child kills it and returns its ports. - Broker and syslog observers, HTTP and gRPC receivers, the browser, and the session are closed before the cluster stops.
- The TCP proxies and silent interconnect peers a scenario placed in front of its nodes are released once those nodes have ended.
- The ZeroMQ and syslog ports drawn for the scenario return to the pool after their nodes and
observers have ended. The scenario publishes
finishedand then releases its feature permit and run slot, so admission includes all teardown work.
Scenario-Driven Stops
A step that stops a node, stops every node, or restarts the cluster is part of the scenario’s subject, so it runs under the product’s deadlines with a harness watchdog around them rather than under the cleanup budget. The harness requests the stop and waits for the node’s task for the longest of five minutes, the node’s configured shutdown timeout, and its configured drain phases: the application drain when graceful drain is enabled plus the runtime’s branch stop timeout for the configured domain drain. A scenario that configures a longer product deadline raises the watchdog with it, so the harness never cuts a product deadline short. Node-specific stop steps resolve a saved scenario placeholder before selecting the node, including the graceful stop used to exercise ownership handoff during drain.
Once the node’s task has ended, the stop gives back the node’s consensus fault registration, which closes its consensus database as Cluster Teardown describes, and a stop that ended cleanly then checks that the node released its node and consensus database locks. The step fails when the node’s task panicked, when the watchdog passed with the node still stopping, or when a lock outlived the stop. A node still stopping keeps its task, its ports and its fault registration, and the scenario’s cleanup waits for it again or leaves it stopping. Stopping every node requests every stop first and then waits for each node in turn. A scenario can also begin stopping a node without waiting, to observe how the cluster reacts while the node is on its way down; its cleanup waits for it later.
The harness prints every such stop to standard error when its wait ends, as it prints a startup:
node '<node>' stop: ended <elapsed> after its stop request, inside its <watchdog> watchdog: <terminal outcome>; shutdown phases: stop admission <outcome>, drain support <outcome>, terminal teardown <outcome>
node '<node>' stop: still stopping <elapsed> after its stop request, at the end of its <watchdog> watchdog, in shutdown phase <phase>, <the node's own deadline>
The elapsed time runs from the first stop request the harness made of that launch of the node to the moment its task ended. The task records its own ending, so the stops of a whole cluster, which are requested together and awaited in turn, each report how long that node took rather than how long the harness took to reach it. A stop is printed by the wait that observed the ending, and only by that one.
The second line is also the failure of the step. A node that outlived its watchdog is read through
its shutdown coordinator, which changes nothing in the node: the phase is the product’s
StopRequested, DrainSupport or TerminalTeardown, and the node’s own deadline reads
<remaining> before its own shutdown deadline or past its own shutdown deadline. An in-process
node has no deadline supervision to end it, so a node read in TerminalTeardown past its own
deadline is running the final stops the product lets run to completion, while one read in
DrainSupport is still inside a drain its scenario configured. A node that had already been left
stopping under a keeper has handed its coordinator over, and its record says so instead.
Ports
Every port the harness binds is drawn from one pool shared by all scenarios in the process. A draw binds port zero on the loopback interface, reads the port the operating system chose, closes it, and reserves it in the pool. The operating system would let a second scenario bind a closed port, so the reservation is the only thing that keeps two scenarios apart. It cannot see sibling worktrees running the same suite, which is why a node startup retries a lost bind on fresh ports.
| Holder | Ports | Returned |
|---|---|---|
| In-process node | 7: gRPC, gRPC over HTTPS, HTTP, HTTPS, observability, web console, and interconnect | After its task has ended: at cluster cleanup, when a scenario stops every node, and when a failed startup attempt moves to fresh ports |
| Scenario fixtures | 4: ZeroMQ ingest and emit, syslog ingest and emit | At the end of cleanup |
| HTTP receiver | 1 per receiver, drawn with the scenario fixtures | At the end of cleanup, with the scenario fixtures |
| gRPC receiver | 1 per receiver, drawn with the scenario fixtures | At the end of cleanup, with the scenario fixtures |
| DNS authority | 1 UDP port per cluster addressed by names | When the cluster is dropped at the end of cleanup, after its nodes have stopped |
| TCP forwarders | 1 port, shared by every forwarder a scenario stands in front of a dependency | At the end of cleanup, after the cluster has stopped |
| Server process | 6 | When the process is dropped |
| A node moved to a new interconnect address | 1 new interconnect port | The port it gave up stays reserved for the rest of the run |
A port returns only once nothing can still dial it. Every scenario names its nodes node-1,
node-2, and node-3, so a node of an unrelated scenario that binds a port a peer still dials
passes the peer-identity check and is caught only when its introduction fails to verify, which takes
long enough to fail a scenario. Keeping every port reserved is not an option either: the ports a
whole run draws exceed the range the operating system draws from, because Linux hands a port-zero
bind an odd port from the lower half of its ephemeral range, some seven thousand ports.
A draw is synchronous and runs on the runner task. A draw that looped until the operating system offered a port the pool did not hold would stall every scenario and the suite watchdog with it, so a draw that lands on reserved ports 65,536 times in a row fails with the pool exhausted, naming how many ports the process holds, and gives back the ports it had already reserved. A pool with one free port among seven thousand still yields it within that many draws almost every time, and a pool with none ends the draw in about a second.
Server Processes
Scenarios about signals, exit statuses, process startup, command-line values, and open-file limits
run nervix-server as a real child process, because an in-process node cannot show whether the
process boundary delivers a signal to its shutdown coordinator. The fixture gives each process its
own ports, database directory, and interconnect credentials, forms a single-node cluster, and
captures its standard output and error in one log. It removes every NERVIX_* variable and
RUST_LOG from the child’s environment, so the runner’s configuration cannot silently reconfigure
the server. The options and environment variables a scenario gives the process are applied after
that removal, written exactly as the scenario states them, so a scenario can hand the node a value
it must refuse; such a scenario starts the process without waiting for readiness and asserts how it
exits. A claim about a
node process dying while its peers keep running, such as what a client producer is told when the
node that executes its ingestor or serves its session is killed or frozen, runs the three-process
cluster and faults one member, because only a real process death closes, or stops answering on,
every connection the node held at once without running any of its shutdown.
Readiness uses the same probe outcomes as an in-process node, probing every 100 milliseconds within 120 seconds, and fails at once with the exit status when the process exits first. Waiting for an exit is bounded by 120 seconds, and waiting for a log line or an HTTP admission by 60 seconds. Those failures quote the last 80 lines of the process log. Dropping the fixture kills the process, so cleanup never waits for a graceful stop and a failed scenario never leaves a server running. What the process does between a signal and its exit is product behavior; see Shutdown And Recovery.
Test Dependencies
The external Chaos directed-rate probe times the sender’s connection and the receiver’s complete byte count through helper-local kernel uptime. A successful observation requires the same kernel boot identity and boot-time offset at both helpers. Namespace identifiers are diagnostic values; they do not identify the clock coordinate. The ten-millisecond clock precision produces a duration upper bound. Its lower bound subtracts two ticks from that upper bound because both endpoint readings can round down. Rate-effect qualification requires the fault’s lower bound to reach twice the baseline’s upper bound. Docker control time remains in separate helper-lifetime evidence and the unchanged ninety-second total probe budget. Missing, malformed or mismatched clocks fail the observation with raw helper logs retained. Direct Docker controls exercise launch delay, actual connection and receiver stalls, byte-count failures, failed helper operations and the real rate effect; they qualify the harness measurement and establish no product recovery outcome.
Brokers, databases, and the other external systems that scenarios use run as containers, started the
first time a scenario needs one and shared by every scenario of the run. The run removes them when
it ends; NERVIX_TESTCONTAINERS_MODE=reusable, which just test-scenarios-reuse sets, keeps them
for the next run instead. Scenarios still provision the topics, queues, tables, and other entities
they use explicitly.
The attached- and detached-window Kafka acknowledgement scenarios create their input topic before
START and observe the consumer group’s membership and the ingestor’s readiness before injecting
the output failure. Their bounded subscription assertions therefore exercise replay and
acknowledgement isolation after source initialization, without spending those assertions’ budgets
on automatic topic creation and consumer discovery.
The attached-window fixture publishes both rows through one producer and allows a thirty-second partial-batch wait. Collection ends as soon as its two-message ACK batch is full; the window can then produce the output whose failure rejects both records. Separate publication steps can exceed a short collection window, leaving one retained window row waiting for input while the source waits for that partial batch’s ACK. The fixture retains its five-second ACK timeout and exact four-record replay assertion within eight seconds.
The Pulsar broker announces a maxMessageSize of 1 MiB rather than Pulsar’s 5 MiB default, the
same limit the MQTT and NATS brokers keep, so one scenario message can exceed each broker’s limit.
Its admin API serves the topic-level maxMessageSize policy a scenario sets on its own topic; the
step waits up to 60 seconds for the broker to read the policy back, which shows that the policy
applies to the next message.
Harness Redis connections use an explicit ten-second budget for connection setup and each command response to tolerate scheduling delay under parallel load. The driver’s one-second connection and 500ms response defaults are too short under concurrent scenario load: the three-node JAQ transformation scenario reached a running ingestor but its publishing client timed out. The budget belongs to the test client used for individual publishes, bursts, and subscriber-count observations. A request failure still fails the step; a publish with an ambiguous result is never retried. The existing bounded subscriber wait only republishes when Redis confirms that the publish reached zero subscribers. Focused harness regressions delay setup and publish replies by two seconds and verify that a silent broker still reaches the connection deadline.
After the run, the suite stops its dependencies within 2 minutes. A stop that does not finish is abandoned and its containers are left to the runner, because waiting without a bound is how a run that already has its result loses it to the job’s own timeout. Dropping the runtime then waits at most 60 seconds for blocking tasks a scenario left parked in a driver.
A container that fails to stop or to be removed does not keep the others from being torn down. Its
failure keeps a ContainerTeardownError report with the Docker or Testcontainers cause beneath it,
and so does a Ryuk cleanup container the suite fails to remove. The teardown stays typed until the
process prints it, on one line: suite dependency teardown failed: and the whole chain of every
failure, the failures separated by semicolons. A teardown that failed is not clean, so it fails a
run whose scenarios all passed; after a suite timeout it is printed beside the timeout instead.
HTTP Receivers
A scenario about a node sending HTTP requests to an external endpoint starts an in-process HTTP/1.1 receiver in its place, because only a receiver the harness controls can capture exactly what arrived and choose exactly how to answer: a status sequence, a delayed, held, or lost response, a stalled body, or malformed framing. A receiver can serve TLS with a certificate for chosen names and can require the client certificate it issued, whose files a node mounts as a resource. Its response script can generate exact and over-limit header counts and value sizes separately for an interim or final block. It timestamps a request when its complete body arrives, before its scripted response delay begins, so a scenario can assert the measured gap between two requests without racing a window in which nothing should happen.
Three controls keep the remaining HTTP assertions free of timing races too. A Retry-After date
is computed when its response head is written, the scripted delay after the current UTC and
rounded up to a whole second, so the date always asks for at least that delay however late the
request arrives. A response held until released stays unanswered, and so the attempt that sent it
unresolved, until a scenario step releases it with a response of its own choice; only the client’s
own timeout or the receiver’s stop ends it sooner, so a scenario can observe state that must hold
while a request is unresolved without betting on how long a scripted delay lasts. A request for a
target the scenario answered by target takes that answer instead of the next scripted response,
which gives each request of independent branches or source relays its own outcome whatever order
they arrive in. Answering the same target again replaces its answer, so an endpoint that kept
failing one request can recover while the scenario watches the retries.
The receiver also records two facts about how its clients treat their answers, each read once the requests it concerns have arrived. It keeps the most requests that were ever awaiting a response at once: a request awaits from its capture until the receiver begins writing its final head, or until its connection ends without one, so a client that sends each request only after reading the previous final head never has two. And it counts the responses its clients abandoned: a held response, a stalled body, or a body still being written whose connection the client closed first. A response body can be generated at a scripted size and is written chunk by chunk, so a body many times the socket buffers of a loopback connection proves that a client which stopped at the final head never read it. A stop of the receiver itself abandons nothing, and an abandoned response is not a fault.
Everything a receiver holds is bounded, and exceeding a bound is recorded as a fault, not captured.
| Bound | Limit |
|---|---|
| One request head | 256 KiB and 512 header fields, room for a request at every HTTP emitter limit at once |
| One request body | 16 MiB |
| Captured requests | 4,096 per receiver |
| Kept faults | 256 per receiver; later faults are counted but not kept |
| One generated response header value | 128 KiB, enough to test both sides of the emitter’s 64 KiB header limit |
| Generated response headers per block | 512, enough to test both sides of the emitter’s 128-field limit |
| One generated response body | 64 MiB, written in 16 KiB chunks and never held in memory |
A scripted Retry-After date | One day ahead, far beyond any scenario’s wait |
Every await a receiver connection makes also waits for the receiver’s stop, so a held response or
a stalled body ends as soon as cleanup begins. The receivers of a scenario stop together in the
stopping phase, before the cluster, under one 6-second budget: 5 seconds for every connection to
end on its own, after which the rest are aborted and joined, and 1 second to join the accept loop,
after which it is aborted and joined too. The derivation asserts that this budget is shorter than
the cluster’s cleanup budget. Each stop is recorded in the scenario log:
HTTP receiver cleanup: <name>: stopped <n> connection(s) in <elapsed> of a 6s budget, <n> forced, <n> panicked; captured <n> request(s), recorded <n> fault(s)
scenario cleanup forced: HTTP receiver <name>: <the same record>
The second line appears only when a connection or the accept loop had to be aborted, or panicked. A receiver’s port is drawn with the scenario’s fixture ports and goes back with them at the end of cleanup, once the nodes that dialed it have ended.
gRPC Receivers
The node trace-export scenarios start one or three real server processes with --otel-enabled,
separate service names and the scenario’s DNS configuration. A local gRPC collector captures their
OTLP trace requests; the steps decode the current trace schema and require spans from every node
and a query for the collector’s fixture name. This uses the process cluster’s existing readiness
and failure diagnostics. Processes are dropped before the DNS authority, resolver files and
collector, including when a scenario fails. The fixtures use no external collector container.
A scenario about a node calling an external gRPC service, such as an OTLP/gRPC collector, starts an
in-process receiver that serves HTTP/2 without TLS in its place. It reads each unary call to the end
of its request and captures the method, the header fields, the compressed flag, and the one request
message exactly as it arrived. It then answers the call with the next scripted answer: an empty
response message with grpc-status: 0, a trailers-only status, no answer at all while the receiver
closes the whole connection, or no answer until the client resets the call. Once the script is
empty, calls are accepted. The receiver never interprets a message, so a scenario step decodes what
it captured.
An OTLP receiver is the protocol-neutral name a scenario uses for either transport:
Given OTLP receiver "<name>" is running for "<protocol>" starts a gRPC receiver for grpc and an
HTTP receiver for http/protobuf, and one script vocabulary — accept, reject, unavailable,
lose response, hold response — maps to the answer each transport gives for it.
A gRPC receiver has the HTTP receiver’s bounds on its message, its captured calls, and its kept faults. A call whose length prefix already declares more than 16 MiB is refused as a fault before its message is read, and a request that is not exactly one gRPC message is a fault rather than a capture. Every await a connection or call makes also waits for the receiver’s stop. The gRPC receivers of a scenario stop together with its HTTP receivers under the same 6-second budget, and each stop is recorded in the scenario log:
gRPC receiver cleanup: <name>: stopped <n> connection(s) in <elapsed> of a 6s budget, <n> forced, <n> panicked; captured <n> call(s), recorded <n> fault(s)
scenario cleanup forced: gRPC receiver <name>: <the same record>
A call that panics takes its connection with it, so the stop counts it among the panicked connections.
Broker Observers
A scenario about a node that publishes to a broker observes the destination through a client of its own: a Kafka consumer, a subscriber of MQTT, RabbitMQ, Redis, Pulsar or SQS, a ZeroMQ socket, or a core NATS subscription. Its steps then assert on what the broker delivered to that client, in the order the broker delivered it.
Those steps run on the runner task, so how an observer hands a message to its step decides how many polls of that task a burst of messages costs. Most observers run one task on the binary’s runtime that reads the broker’s client and hands each message to the step over a bounded channel, and the scenario aborts that task when it drops the observer. A core NATS observer has no task of its own: the NATS client already reads what the server delivers into the subscription’s own buffer on a task of its own, and the step takes each message straight from that buffer. A step that is behind takes as many buffered messages in one poll of the runner task as the poll’s cooperative budget allows.
Under the suite’s concurrency the runner task comes back to a step only after it has polled every other woken scenario. While the NATS observer handed each message on through a one-message channel, every message waited for a poll of its own, and on a loaded runner the NATS emission scenarios drained their 32,768 messages too slowly for their 90-second bound although the observing client had already received every one of them.
The NATS client buffers at most 65,536 messages for one subscription and, as a slow consumer, drops what arrives beyond them, so a scenario keeps the messages its step has not yet taken below that bound.
A step that waits for a sequence of messages and times out names how many messages the observing client had received from its broker, for a client that counts them, so a message that never reached the observer reads differently from one the step had not yet taken.
DNS Authorities
A scenario about how nodes find one another by name selects its cluster’s peer addressing before the
cluster starts, with Given cluster peers are addressed by "<addressing>". Every other cluster uses
literal IPv4 loopback endpoints, starts no DNS fixture, and lets its nodes load the host’s own
resolver configuration, as a production node does.
| Addressing | Each node listens on | Each node advertises | Its name answers with |
|---|---|---|---|
literal IPv4 endpoints | 127.0.0.1 | 127.0.0.1 | No fixture |
literal IPv6 endpoints | ::1 | ::1 | No fixture |
DNS names | 127.0.2.<n> | node-<n>.nervix.test | Its listen address |
DNS names behind an unreachable address | 127.0.2.<n> | node-<n>.nervix.test | 127.0.4.<n>, where nothing listens, then its listen address |
single-label DNS names | 127.0.2.<n> | node-<n>, completed by the search domain | Its listen address |
hosts file names | 127.0.2.<n> | node-<n>.nervix.test | Nothing: the hosts file lists the name and the fixture never answers it |
A cluster addressed by names starts one in-process DNS authority on a loopback UDP port drawn from
the port pool. The harness writes a resolver configuration with the search domain nervix.test,
ndots:1, and one-second queries with one retry, and a hosts file, into the cluster’s directory,
and starts every node with that configuration and the authority as its only name server. Every
answer carries a one-second TTL, positive or negative, so a zone change reaches a node within
seconds of the cached answer expiring. A scenario can move a stopped node to 127.0.3.<n> behind
the same name, and answer a node’s name as a name that does not exist, a name with no address, or
not at all. A name outside the zone does not exist, with a negative TTL of zero. Every test
certificate names localhost, 127.0.0.1, ::1, node-<n>, and node-<n>.nervix.test, so each
addressing presents the name its peers expect.
A scenario can also publish a dependency it started under a name in the zone, such as
rabbitmq.nervix.test, answered with the dependency’s literal address or with a list of loopback
addresses in order, and later answer that name as a name that does not exist, a name with no
address, or not at all. A dependency’s published port listens on every local address, so an answer
that cannot connect needs TCP forwarders: listeners at chosen loopback addresses such as
127.0.5.<n>, all on one port drawn from the pool, that forward to the dependency and count the
connections each accepted. Stopping a forwarder closes its listener and every connection it
carried, so a client that still holds a cached answer naming it cannot reconnect there. A scenario
can restart that listener on its still-reserved port to let the same client reconnect while its
target node stays up; the CLI clock process scenario uses this to observe reattachment after a
transport loss without changing the node’s clock installation. An address
the scenario names without a forwarder refuses connections on that port. The TLS
certificate the harness gives its containerized dependencies names localhost, 127.0.0.1 and
*.nervix.test, so a dependency reached through a zone name presents a certificate for the name
its client dialled, and a name outside the zone does not match it.
The authority answers from memory and holds nothing else: it counts questions for at most 1,024 names, and a scenario can assert that its nodes asked nothing for their names. It answers until the cluster is dropped at the end of cleanup, after every node has stopped, so no node’s lookup outlives it; dropping it aborts its answer loop, and its port goes back to the pool. How long a node waits for an answer is product behavior, bounded by the budget of the path that asked and described in Bounds, Deadlines And Cancellation; the scenario’s own waits are status waits.
The resolver crate’s focused checks use the same authority, started on an unused port of their own,
to observe DNS messages directly: TTL expiry, negative caching, refusals, silence, the lookup
budget, the concurrency bound, and runtime teardown. just test-dns runs them.
Evidence states what each form of DNS evidence establishes.
Client Probes
The cross-language conformance scenarios in client_conformance.feature run a small client of each
runtime against a scenario’s cluster, or against the checked-in conformance corpus. The in-process
probe drives the shared Rust binding’s C ABI from a blocking task of the binary’s runtime, because
every call of the binding blocks its caller; every other probe is a child process that the fixture
starts with its target in NERVIX_PROBE_* variables and its standard input closed. A probe prints
one report line per observation on standard output, and the fixture keeps every line it read.
A probe’s waits are bounded twice. The step that starts it waits at most 180 seconds for the line that says its subscription is open or its clock attach completed. A later step can wait, for the duration it names, for one more line the probe prints, so a scenario acts between the probe’s observations: the clock probe prints the first tick of a generation before the scenario stops and starts the domain, and the interruption before the scenario restarts the TCP forwarder the probe entered through. Every line read on the way is kept for the report. The step that reads the report waits the duration the step names for the probe to end, 180 seconds against a cluster and 60 against the corpus. Each probe also ends itself: a binding probe gives up on its rows, or on each stage of the clock it follows, after 120 seconds, and the Go and TypeScript probes give up on their whole run after 170. A failure quotes every report line read so far, the exit status, and the probe’s standard error. Dropping the fixture kills a child process, so a failed scenario never leaves a probe running; the in-process probe ends when its session fails against the stopped cluster, or at its own deadline.
Every example of a runtime other than the in-process probe is tagged @client_conformance_toolchain
and one @client_probe_<runtime> tag, and the suite excludes the first tag unless a run selects its
own tags, because those examples need toolchains the suite’s job does not install. just test-client-conformance builds every probe artifact and runs them; its first argument is the tag
expression that selects runtimes. The client-conformance CI job, which runs only for a pull
request labeled client-conformance, runs it on its own runner with a 60-minute limit and a
15-minute suite budget, which leaves the builds before the scenarios up to 40 minutes of the limit
and keeps the same 5-minute reserve.
The Suite Watchdog
The scenario run has one budget, 41 minutes from the moment it starts, which --suite-budget or
NERVIX_TEST_SUITE_BUDGET replaces with a duration such as 4m, read by the same guarded duration
parser as a node’s options. The budget is a clock rather than
a count of failures. Cucumber’s fail-fast stops scheduling scenarios and leaves those already
running where they are, so it cannot end a run whose step, diagnostic, or node stop never returns;
the clock ends such a run at the same instant as one whose work returned at once. Until the budget
expires, the suite keeps running its intended failure and retry coverage.
The watchdog reads two registries: the active-scenario registry, and the live-cluster registry. A cluster registers when a scenario builds it. Each node registers when its task is spawned, through a registration the task itself owns, so the node leaves the registry when its task ends, whether it returned, failed, or panicked, and the watchdog can tell a cluster that stopped from one still running without owning either. A cluster stays registered for as long as its scenario holds it or one of its nodes was left stopping, so a node that outlived its scenario’s cleanup is still named. The registry holds each node’s stop, its shutdown coordinator, rather than the node, so the watchdog never reaches into a scenario that is still using it.
When the budget expires:
- The watchdog reads both registries before changing anything, so the diagnostic is the state the run expired in, and prints it to standard error at once. If the job’s own limit kills the process during what follows, it has still said what it was doing. The scenario log receives the same diagnostic, with the cleanup outcome, once the cleanup window has ended.
- It asks every live node to stop, then waits one 60-second window for all of them together, checking every 50 milliseconds.
- It drops the run. That ends every scenario future still held, which kills the server processes the scenarios started and leaves the nodes they still hold stopping.
- The suite stops its dependencies within 2 minutes and drops the runtime within 60 seconds, which ends every node task that is still running.
- The process prints the dependency teardown and the cleanup outcome, flushes its output, and exits
with status
124.
suite timeout: the <budget> suite budget expired with <n> scenario(s) active
suite timeout active: <identity> attempt=<n> phase=<phase> phase_age=<age> age=<age> nodes=[<nodes>]
suite timeout unclaimed cluster: <identity> nodes=[<nodes>]; its scenario had already left the registry
suite timeout cleanup: asked <n> node(s) to stop and waited <elapsed> of a 60s window; every node ended itself
When a node is still running at the end of the window, the cleanup line names its cluster instead of
reporting that every node ended itself, and the scenario log adds
suite timeout cleanup forced: the run was dropped with nodes still running. A live cluster whose
scenario has already left the registry is a leak, and the unclaimed line is its only record.
The watchdog does not run after hooks. Faults a scenario injected stay in place while its nodes are asked to stop, so a node held by one can still be running at the end of the window, and it ends when the process drops the runtime. The induced timeout during qualification had one such node, held by a consensus commit delay that only its scenario’s cleanup releases.
The CI Reserve
The suite runs inside just test-scenarios-coverage in the CI scenarios job, after that job’s
instrumented server, CLI and paced simulation driver builds. The tests job runs at the same time
on another runner. The scenarios job’s timeout-minutes is 60, and the suite budget is derived
from it so that the job ends on its own.
For focused local coverage, just coverage-scenarios <lcov-path> <scenario-options> starts a
fresh measurement. just coverage-scenarios-append <lcov-path> <scenario-options> retains the
current profiles and reuses unchanged instrumented artifacts for another scenario selection.
When collecting a different source revision, first run just coverage-clean-workspace so
instrumented binaries and line mappings from earlier sources cannot enter the new report.
After collecting unit and scenario profiles, just coverage-report-workspace exports all
workspace packages, including executed test files, as lcov-workspace.info with the checkout path
beside it, and just coverage-merge maps that report onto the repository.
Both CI collectors export lcov-workspace.info from every collected profile, with no package
selection, and with --no-default-ignore-filename-regex, and write the absolute checkout path they
ran in to coverage-source-root.txt beside it. Test lines stay in these reports, so merging native
extra reports cannot make executed library properties appear uncovered because their files were
filtered. Executing a crate’s tests does not include that crate in a report whose package selection
names only the root package. Filtering happens in the merge, not in the exports: the coverage job
runs just coverage-merge over the coverage-tests, coverage-scenarios and
coverage-native-extras artifacts, which maps every record onto the files tracked at the tested
revision, drops dependency, toolchain and generated records by class, leaves out the native
collections of other modes, and fails rather than publish a report that misses an input or a
workspace member. It passes that one report to just coverage-patch, so library properties
contribute to patch coverage alongside server and client tests. Patch coverage, CRAP scores and
comment publication are advisory and never change the build verdict; the CRAP step fails only when
its analysis cannot describe the merged report. The complexity verdict is the just ratchet count
of functions above cyclomatic complexity 30, which the checks job enforces and which reads no
coverage.
The PR comment and workflow summary identify the exact comparison and the coverage-merged
artifact retains the report, its merge record target/coverage-merge/record.json,
target/patch-coverage.md and the CRAP reports target/crap-report/crap.json and
target/crap-report/crap.md.
The scenario collector also starts coverage in each published Python paced driver using the shared
scripts/paced_simulation_coverage startup hook and configuration. It combines those completed
process reports into target/paced-simulation/python.lcov. CI merges this scenario report with the
deterministic Python open-policy report from the extra checks, alongside ordinary Rust coverage.
The STOP/START generation-following outline also carries @paced_simulation_reopen, so both full
Deloxide selections reach its Rust and Python drivers on one-node and three-node clusters.
The merge reads each producer’s checkout path explicitly when it maps LCOV sources from Blacksmith
onto files on its GitHub-hosted runner.
| Part of the job | Budget | Basis |
|---|---|---|
| Work before the scenario binary starts | 14 minutes | The first cold kache 0.28.1 split-job run took 11m37s from job start to the binary, and the next took 10m08s; the ceiling adds 2m23s beyond the slower measurement |
| The scenario run | 41 minutes | What the limit leaves |
| After the budget expires | 5-minute reserve | At most 60 seconds of cleanup window, 2 minutes of dependency stop, and 60 seconds of runtime shutdown, four minutes in all, and then the log upload, measured at 2 to 3 seconds with 8 seconds of steps after it |
A healthy suite finishes inside its budget and exits 0 or reports its failures. A wedged one exits
124 with its diagnostic and leaves the upload its reserve. Either way, a step that runs whatever
the job’s result uploads the whole tests/logs directory as the scenario-logs artifact.
A suite that passes ends before its budget, so the reserve then holds the coverage export, the
uploads and cache publication, which waits at most 120 seconds. Saving the
manifest took 5 to 14 seconds in the scenario jobs measured on 2026-10-14, and the daemon had
already published every entry those jobs built. A suite that fails or runs out of its budget leaves
publication not-run, so the reserve stays with the cleanup.
The first split-job PR run completed its suite in 21m49s with 88.3% run-slot utilization and four retries. One Raft snapshot scenario failed all three attempts because its setup accepted a purge that preceded the backlog under test. The corrected scenario waits for a purge beyond the measured backlog peak. The next run passed in 20m26s with 94.3% utilization and seven retries, so the 22-minute ceiling and 15-minute slack fit both measured runs.
The job’s limit remains the emergency guard outside the budget rather than the mechanism that ends a wedged run. A job the limit cancels is killed wherever its scenarios are: the logs it uploads end mid-scenario, with no summary and no record of what each scenario was doing. The limit and the harness’s copy of it change together.
A same-commit rerun of the scenarios job replaces that job’s four report artifacts: scenario logs, runner load, kache report, and scenario coverage. This lets a rerun publish its own diagnostics under GitHub Actions’ immutable artifact names. When the coverage job is rerun, its advisory reporter updates its marked PR comment for the same PR head instead of adding a comment.
Runner Stalls
The watchdog is polled on the runner task, so it cannot see, let alone end, a step that blocks that task. The run is therefore polled beside a tick on the same task, due every 250 milliseconds. A step that blocks inside its poll delays the tick until it returns, so how late the tick is polled is how long the task was held. A stall of one second or more goes to the scenario log with every scenario live during it:
runner task stalled for <duration>
scenario live during runner stall: <identity> attempt=<n> phase=<phase> phase_age=<age> age=<age>
The log line is a diagnostic, not a failure: a stall delays every wait and bound the other scenarios measure, and the line says when and for how long. Backup steps hand their archive reads, rewrites, digests and fixture generation, and the inspection of stopped nodes’ databases, to blocking tasks; the native metadata restore scenario, whose archive holds lifecycle and offset records above 32 MiB each, held the runner task for about 30 seconds while it inspected three restored nodes before they did.
Diagnostic Lane
just test-deloxide, just test-deloxide-order, just test-deloxide-stress and
just test-deloxide-stress-restore run the Deloxide diagnostic lane: every workload
tests/deloxide-inventory.toml registers, in the build of the diagnostic feature its selection
compiles, supervised by scripts/deloxide_lane.py. Data-Plane Concurrency
owns what the lane proves, its failure classes and its applicability records; this section owns how
it runs and how long it may take.
The lane starts after its prerequisites, just tests-deps, and builds each invocation with Cargo
before it runs it. The prerequisites provision what the workloads load, as they do for the
ordinary suite: the WASM guests, the generated ONNX models and the ONNX runtime library, whose
path the lane passes to every invocation. The probes, the tracked locks’ conformance checks and
the owner tests are libtest executables, run directly so that a signal reaches the lane as a
signal rather than as Cargo’s status. The scenario binary runs the lane’s tagged scenarios
without retries. The active-only and order selections run them in two inventory invocations: the
restore, client, diagnostic, remote ACK, Roto function, TLS rebinding and inferencer scenarios,
then the paced-driver scenarios with the diagnostic Rust driver; the stress selections run the
same scenarios in three invocations of their own, described below. Active mode, with or without
stress, runs each invocation as one process. Order mode starts a fresh
process for each tagged feature in the first invocation; the materialized restore feature runs
each scenario separately and its six large examples each have their own process, as do the four
complete-generation restore examples, selected by the inventory’s order_tags. The lane checks
every chunk’s Cucumber summary against its registered examples, retains its logs and evidence
separately, and stops on the first failed chunk. Each process of the active-only and order
selections runs the inventory’s fixed number of scenarios at once, four, rather than one per CPU: a diagnostic
build pays for its tracked acquisitions on every lock, and a fixed count puts the same load on its
nodes locally and on CI’s 16-vCPU runner.
The lane resolves every feature pattern against its repository root before passing it to Cucumber. A relative pattern would make Cucumber traverse the whole working directory, including generated build artifacts. An exact feature uses an absolute pattern with two identical literal alternatives, so the walker starts at that feature’s parent directory and still selects only that file. This bounds discovery without changing the registered examples or their deadlines.
Under the native coverage collector the lane instruments only workspace crates, the scope the
ordinary coverage build has. When every crate was instrumented, wasmtime’s compiler included, CI’s
WASM restore scenarios ran five times as long as an uninstrumented local run, 338 seconds against
64 for one of them, and its restored runtime revision missed the product’s readiness deadline: the
test cluster’s 10-second node unavailability timeout plus the 30-second propagation bound. It
missed it at four scenarios at once as at eight, because the time went to preparing the restored
WASM processor, not to waiting for a run slot. The product deadline stays as it is; the lane
removes the instrumentation the product’s own work never needed. Each invocation records
its evidence in its own directory of the attempt, and the binary’s tests/logs are copied into the
attempt after it ends, because the next invocation truncates them.
Each scenario invocation passes the binary NERVIX_TEST_SUITE_BUDGET, its bound less the
inventory’s teardown reserve, so a suite that runs out of time ends itself with the suite
timeout diagnostic and its own cleanup before the lane would stop it. The
reserve is six minutes: the four the suite’s own cleanup can take after its budget, with two to
spare. A bound that expires anyway ends the binary with SIGTERM and, after the stop grace period,
SIGKILL for its whole group; Ryuk removes the containers the binary started when its connection
closes. A stall of the runner task itself ends only this way, because the suite watchdog runs on
that task, and the binary then prints no Cucumber summary. Unless a tracked lock takes part in the
stall, the lane records it as timed-out with no finding: a wait inside a dependency, such as the
storage engine’s own worker channel while a database closes, is visible only in a stack of the
binary’s threads taken before the bound expires.
In CI the deloxide job, which runs only for a pull request labeled deloxide, runs each
selection on its own 16-vCPU runner through just coverage-native-extras test-deloxide,
test-deloxide-order, test-deloxide-stress or test-deloxide-stress-restore, then
just test-deloxide-qualification for the same selection, and uploads every attempt whatever the
verdict.
| Part of the job | Bound | Basis |
|---|---|---|
| Setup before the lane | No bound of its own | Toolchain, LLVM, Go, TinyGo and kache installation, as in the other native jobs: 46 seconds for deloxide and 40 for deloxide-order in the measured run |
| Prerequisites, diagnostic builds and workloads, and the coverage export | 95 minutes, timeout in the step | The inventory’s 80-minute budget bounds the diagnostic builds and workloads, and the prerequisites and the export share the rest. The measured run took 25m02s for deloxide and 23m24s for deloxide-order. The prerequisites took 3m05s and 2m46s, the diagnostic builds and workloads 21m28s and 20m11s, and the export 2 seconds in each. Inside the budget, the builds took about 11 and 9.5 minutes, the 55 scenario runs 7m41s and 7m45s at four at once, the 32 paced runs 2m35s in each, and the seven conformance checks less than a second together after a build of 8 and 7 seconds |
| Supervision qualification | 10 minutes, timeout in the step | A small probe build and up to seven bounded cases; only the untracked wait runs to its bound, 15 seconds, and the others end in milliseconds. It took 40 and 41 seconds in the measured run |
| Uploads and the kache report | The rest of the 120-minute job limit | Attempt directories, completion records and LCOV reports, uploaded after either step’s verdict, then cache publication, which waits at most 120 seconds. In the measured run the uploads took 4 and 3 seconds |
The table’s measurements come from the CI run of 2026-10-07 in which both selections passed with
every owner of tracked locks reached: 110 workloads in deloxide and 118 in deloxide-order,
with the lane instrumenting workspace crates only and running 55 scenario examples at four at
once. The first run in which both selections passed, on 2026-10-06 with 97 and 105 workloads and
49 scenario runs, took 30m27s and 23m18s in the lane’s step. The six scenario runs and seven
conformance checks added between those runs fit inside their variation. A deloxide-order run
that included the one-node restore of a deduplicator keyspace and a window above the bulk budget,
together with its three-node example, took 21m47s in the lane’s step with 107 workloads and 51
scenario runs before those six scenario runs and seven conformance checks were added. The table
predates the four remote ACK examples and the order-mode per-example process split, so its
durations are historical measurements rather than a timing claim for the current combined
inventory; the inventory and job bounds remain enforced.
The two stress selections compile one build, the active-only build with the bounded disturbance
of StressConfiguration::LANE, and between them run every scenario of the active-only selection but
the report tool’s triage, one at a time: a stress build enters Deloxide’s process-wide detector
twice on every mutex and write acquisition, and when the selections were introduced, with four
scenarios in one process, their nodes contended on it and missed startup and request deadlines
that a plain diagnostic build of the same scenarios met.
Two Stressed Scenarios In One Process records what made
them miss. deloxide-stress runs the active-only selection’s probes, conformance checks and
owner tests with the disturbance, the probe that proves it, the stress-scenarios invocation of 35
lifecycle scenario runs tagged @deloxide_stress, bounded at 30 minutes, and the
stress-paced-simulation invocation of the 36 paced-driver runs with the stressed Rust driver,
bounded at 40. deloxide-stress-restore runs the build’s probes and the stress-restore-scenarios
invocation of 43 capture, restore and checkpoint-transfer runs tagged @deloxide_stress_restore,
bounded at 70 minutes. Each selection has the inventory’s 80-minute budget and its own CI runner.
The split follows from what the disturbance costs. It barely lengthens a scenario, because the
lifecycle scenarios wait on product cadences, timers and deadlines rather than on the CPU: in the CI
run of 2026-10-09 the 33 runs the stress selection then held took 854 seconds one at a time,
against 885 seconds of run-slot time for the same runs at four at once in the active-only lane. One
at a time, a stressed lane therefore costs the sum of its scenarios’ durations, and the 44 scenario
runs and 36 paced-driver runs not yet stressed took 1,587 and 658 seconds in the same run. One lane
holding all of them one at a time would have needed about 52 minutes of scenarios after 11 minutes
of diagnostic builds, about 63 of its 80 budgeted minutes, before the variation between CI runs,
before the large-state WASM handoff added since, and before any scenario added later. Chunking that lane into a fresh process per
feature keeps the sum and adds a process start-up per chunk. Running more stressed scenarios at once
in one process, or fewer nodes in each of several concurrent scenarios, is the configuration whose
nodes missed product deadlines then.
The restore scenarios are the longest family, so they became their own selection: a budget and a
runner of their own, and the same build, disturbance and probes. In the CI run of 2026-10-10 that
introduced the split, deloxide-stress took 28m41s in the lane’s step and 27 minutes inside its
budget: about 9.5 minutes of diagnostic builds, 6m19s for the 35 stressed lifecycle runs and
10m51s for the 36 paced-driver runs, with 30 qualifying evidence files and no finding.
deloxide-stress-restore took 39m40s in its step and 37 minutes inside its budget: 5.5 minutes of
builds and 31m30s for the 43 restore runs, with no finding. One lane holding both would have needed
58.5 of its 80 minutes, before any variation between runs. The disturbance delays one in
twenty nested mutex and write acquisitions by at most 200µs, and the product’s physical and logical
deadlines are not widened for it. A stressed workload that misses a product deadline is a finding to
reproduce and fix in its owner, not a bound to raise.
The budget is measured on CI’s runner, whose diagnostic builds kache serves warm, and a local run
pays its cold builds out of the same budget. On 2026-10-10 a shared 40-CPU host already at a load
of 36 to 50 spent 33 minutes building the stressed scenario binary and ran the heaviest restore
scenarios two to three times slower than CI, so deloxide-stress-restore expired its budget after
17 of its 43 runs, every one of which passed. Run the stress lanes where the CPUs are not already
saturated, or again once their builds are warm; an expired budget ends the lane with status 124,
never as a clean result.
Two Stressed Scenarios In One Process
At two stressed scenarios in one process, the restore scenarios failed in two ways the
one-at-a-time selections never did: the cluster is restarted found a restored node still stopping
at the end of its 300-second watchdog, and node startups spent their whole 84-second budget.
Neither was a deadlock among tracked locks. Every evidence file of those runs qualified with no
finding, and both failures are a node that could not get through terminal teardown, or through
startup, beside the other nodes of its process.
The retained run of tests/features/cluster/backup_materialized.feature at 6c0d23de holds the
signature. Its 28 stops by the cluster is restarted took 130.5 seconds at the median and 297.7 at
the slowest, 2.3 seconds inside the watchdog, where the plain diagnostic build of the same revision
took 0.9 and 39.6. Of its 129 node stops, 69 never reported the end of terminal teardown. Three
things made a stop that slow, and CPU contention is what turned them into a failure: the run shared
a 40-CPU host whose one-minute load was 47 at the median and between 28 and 75.
- Every node swept its whole acknowledgement registry every second, and a sweep that overran the second could not be cancelled. The remote acknowledgement silence sweep took a guard for each of a node’s 16,384 correlation positions whether or not the node had forwarded anything, and a stress build enters Deloxide’s one detector twice for each of them. On that host one sweep took longer than the second between sweeps. Its task then found the next tick already due and went from one sweep into the next without waiting, and the cancellation that runtime teardown joins the task with is observed only when the task waits. The run’s first cleanups show it: four nodes that had served for under ten seconds logged nothing through 59 seconds of terminal teardown while the process kept three CPUs busy. The sweep now visits only the positions a node’s deliveries and admissions have claimed, which keeps it far inside its second, and its task returns to its executor after every sweep, so teardown’s cancellation ends it after the sweep in progress even when that sweep overruns; see Terminal Teardown.
- Nodes the harness gave up on kept running. A cleanup that reached its deadline aborted the node’s root task, which ended that one future and left every task the node had spawned, its sweep among them, running for the rest of the process. A node aborted this way never reported the end of its teardown. The harness never aborts a node now, as Node Tasks And Readiness describes.
- A stopping domain waited for its generators one at a time. When every node of a cluster stops at once, a generator that is reading its source relay from another node, or publishing to one, waits on a peer that no longer answers, and runtime teardown gave each such generator its own two-second grace in turn. This one is independent of the detector: it is in the plain build’s 39.6 seconds too. The generators of a domain now share one grace; see Terminal Teardown.
The measurements below repeat that run’s configuration on a 32-CPU workstation shared with other
builds: the whole feature in the deloxide-stress build, two scenarios in one process, sixteen
runtime workers on sixteen CPUs, no retries, and the row, startup, stop and cleanup deadlines
unchanged.
| Build | Outcome | the cluster is restarted stops, median and slowest | Slowest node startup |
|---|---|---|---|
6c0d23de, retained from the 40-CPU host | Ended by its 3000-second budget with 17 of 18 complete and one failed | 130.5 s and 297.7 s | 29.5 s |
c39c5388, after the sweep and the harness changed | 18 of 18 passed in 422 s | 0.49 s and 36.9 s, the slowest all generator graces | 2.1 s |
| With the generators’ shared grace | 18 of 18 passed in 376 s | 0.36 s and 10.9 s | 2.6 s |
Without competing load, 6c0d23de itself is as fast here as the current revision: in the
feature’s first four examples its restarted clusters stopped in 0.06 seconds at the median and
0.48 at the slowest. The sweep’s cost shows only once the process has to compete for its CPUs. A
build of c39c5388 with nothing changed but the full sweep restored was run beside an unchanged
one, the process confined to four CPUs that twelve busy loops competed for. The unchanged build
passed 18 of 18 in 443 seconds. The build with the full sweep took 1,301 seconds and 1.7 times
the CPU time, failed both three-node examples with forty relays when deliveries to a peer timed out
during their input rounds, and stopped its restarted clusters in 5.3 seconds at the median against
0.36. 6c0d23de itself, run the same way, matched that build and not the unchanged one: 1,345
seconds, the same two examples failed, one of them with the missing rows of the retained run, and
its restarted clusters stopped in 4.5 seconds at the median.
What a three-node stop still costs after this is bounded by the deadlines of the calls its nodes had
in flight when the stop began, and each wait is named in the node’s log. A relay owner that is
delivering to a peer which has already stopped waits out the five seconds that delivery’s admission
is given and the five its cancellation is given. A checkpoint announcement to a replica that has
stopped is waited for until its five-second dispatch deadline has passed. A replica’s placement task
that is fetching or acknowledging a checkpoint when its owner stops does not observe the stop, and
such tasks are aborted one after another, two seconds each. The node’s generators share their two
seconds. In Every node stopping at once ends its generators together, the slowest node of the
three-node cluster took between 2.0 and 12.0 seconds to stop in ten runs of the ordinary build.
Before the generators shared their grace it took between 22.0 and 30.1 seconds in five of six runs,
and 8.1 in the sixth, where two of the nodes had no generator waiting on a peer.
The stress selections keep running one scenario at a time: their budgets were measured that way, and two per process remains the comparison below rather than a lane.
For a focused comparison, just test-diagnostic-scenarios <selection> <feature> --name <scenario> --concurrency 2 --retry 0 builds and runs the public scenario in deloxide,
deloxide-order or deloxide-stress, with the lane’s prerequisites and ordinary CLI. Its builds
share the diagnostic target directory. The recipe resolves its feature file to an absolute path:
Cucumber’s relative input glob walks the entire working directory, including build artifacts,
before starting any scenario. The absolute input glob matches the exact file in its containing
directory. Argument boundaries, including a scenario name with spaces, are preserved. Each attempt
retains its output under target/deloxide/focused/<selection>/run.* and rejects an empty scenario
selection. It records detector evidence there unless NERVIX_DEADLOCK_EVIDENCE names another
directory. Use a fresh evidence directory for each run and retain the standard output, scenario
log and suite summary together. The complete lane remains the entry point for inventory accounting
and diagnostic evidence qualification.
In this comparison, --concurrency counts scenarios in one process. Their in-process nodes share
one runtime and one detector; the runtime still has one worker per available CPU. Record the
available CPU count, affinity and host load, and keep them the same for the plain and stressed
selections. A failure’s phase matters: missing generator rows immediately after restore and missing
rows after a subsequent restart exercise different installation boundaries. Preserve the first
failure, the missing relay and tenant identities, and any stop or startup deadline outcome before
running a control. A passing control or replay describes that run only.
The oversized materialized-generation assertion preserves its missing relay and tenant pairs,
expected round and value length, domain, and subscription node when its row deadline expires.
Before teardown it queries SHOW RELAY ... MATERIALIZED STATE on at most three nodes for the first
four missing relays. The observations share a ten-second budget, with at most two seconds per
query. They keep the placement, branch, round, value byte length and watermarks, without printing
the fixture’s large values. Query failure or timeout stays a diagnostic observation; the original
exact-row assertion still fails. This observation budget starts after the row deadline and grants
no additional time for a missing row to satisfy the assertion.
just test-diagnostic-harness <selection> <filter> runs focused harness regressions in the chosen
diagnostic build. just coverage-harness-liveness <filter> measures ordinary harness regressions
in a separate target directory and retains its log and lcov.info, including the harness source,
in a fresh target/harness-coverage-reports/run.* directory. Both commands reject a selection
that completed no tests or left selected tests ignored. These counters measure the harness itself
and do not join product coverage or qualify a diagnostic lane.
After replaying an instrumented ordinary scenario binary from that target directory without a
source edit, just coverage-harness-liveness-report <output> exports the retained profiles without
another test invocation.
Public materialized-state inspection can read installed or checkpoint state and can forward an empty non-owner observation to the primary. Record the addressed node and returned placement; rows returned by a replica’s endpoint alone do not prove that replica installed the generation. Correlate the observations with the restore execution report, runtime log and lifecycle snapshot before deciding which publication or delivery owner needs a regression.
A debugger attached to this process suspends every in-process node while it collects thread stacks. Record that suspension separately and retain the attempt as a diagnostic run; its elapsed time and deadline outcomes cannot qualify an uninterrupted throughput comparison. Preserve an unprofiled failure before collecting stacks on a diagnostic replay. Thread stacks can identify workers blocked in a tracked lock or the detector itself, but a suspended async task is not a worker’s call stack. Retain the node’s lifecycle and placement observations alongside the dump when deciding whether the failure belongs to drain, state publication or scheduling contention.
When a step’s timeout expires it sends SIGTERM to the step’s process group. The collector
records its attempt as interrupted and forwards the signal to the lane, which ends the process it is
running with that process’s whole group, records interrupted in lane.json and exits; the upload
steps still run. A failed or interrupted attempt is kept as it is: a later run, or a warm rerun,
creates a new attempt and never qualifies the one that failed.
How Failure Reaches CI Output
The job log carries the scenario binary’s standard output and standard error, and the scenario-logs
artifact carries tests/logs.
| Output | Where | What it holds |
|---|---|---|
| Cucumber report | Standard output | Every scenario and step result in order, the world of a failed step, the summary, and every failed scenario repeated at the end |
| Harness transitions | Standard error | Node startup transitions and outcomes, the stops scenarios waited for, and a suite timeout’s diagnostic and cleanup |
| Scenario log | tests/logs/cucumber.log | The run’s parallelism and suite budget, cluster start requests and failures, the NSPL commands scenarios run, every phase marker, teardown diagnostics and context, unfinished cleanups, and the suite timeout diagnostic |
| Suite summary | tests/logs/suite-summary.md and the CI job summary | Length, budget and remaining margin, run-slot utilization and scenario work, wait time by reason, retries, and the ten longest attempts; captured at watchdog expiry before cleanup on a timed-out run |
| Runner load | runner-load-tests and runner-load-scenarios artifacts | Five-second CPU utilization and steal-time samples and their mean and peak |
| Node traces | tests/logs/scenarios.log | The trace output of every in-process node of the run |
Server processes write their logs into their own temporary directories, and a failure quotes the tail it needs.
| Failure | How it surfaces |
|---|---|
| A step assertion | The step fails with its message; Cucumber retries the scenario and repeats the failure at the end of the report |
| A status wait that expired | The step fails with the elapsed time, the last status text, and the last typed failure |
| A node that never became ready | The step fails with every attempt’s record, and a failed construction logs cluster start failed: |
| A teardown diagnostic that failed or timed out | The node’s status_error= or status_timeout= line, and cleanup continues |
| A node a scenario stopped that outlived its watchdog | The step fails with the node’s stop record: how long it had been stopping, the shutdown phase it was in, and whether its own deadline had passed |
| A cleanup that left nodes stopping, or a node that panicked while stopping | scenario cleanup unfinished: lines naming each node’s shutdown phase, or scenario teardown failed: lines, beside an unchanged scenario result, and the keeper’s ended ... after it was left stopping line on standard error |
| A scenario that never ends | The suite timeout diagnostic names it with its attempt, phase, phase age, and nodes |
| An exhausted port pool | The step that needed a port fails with the pool’s report |
Exit Status
| Ending | Status |
|---|---|
| Every scenario passed and the dependencies stopped cleanly | 0 |
| The run finished with failed steps, parsing errors, or hook errors, reported as a panic naming each count | 101 |
| The run finished but its dependencies failed to stop or were abandoned | 101 |
| The suite budget expired | 124 |
A deloxide build could not start its deadlock diagnostics | 4 |
A deloxide build reported an active deadlock and recorded it | 3 |
A deloxide build failed output/recording, exceeded a diagnostic budget, or lost findings/context | 4 |
A deloxide-order build retained a potential cycle | Workload continues; unreviewed evidence cannot qualify, and the diagnostic lane fails |
124 is the status timeout(1) reports. It differs from 0, from 1, and from the 101 a panic
ends with, so a wedged suite is told apart from a passing or a failing one by its exit status alone.
Adding A Harness Operation
The append-setup regression observes each live source’s test-only setup counter before issuing a burst of public domain creations with one voter stopped. It checks that both survivors apply the burst, that attempts to the departed voter fit OpenRaft’s existing 200 ms retry series plus two boundary attempts per source, and that the returning voter catches up. Its count bound uses the whole observation’s elapsed time, so parallel-suite delay does not tighten the assertion. A zero attempt observation fails rather than claiming that pacing was exercised. The counter shares the probe’s existing map with successful opens and is absent from the ordinary production recorder.
A new operation that starts, observes, or stops something on the harness’s behalf follows the same contract.
- Find its phase. An operation inside a phase, such as a startup attempt, a status wait, or a cleanup, takes that phase’s deadline as a parameter and nests its own budget in it. Only a new phase starts a deadline of its own, and never inside a loop that retries within a phase: a retry that restarts its budget turns a bounded wait into an unbounded one.
- Derive its budget. Name its policy inputs, usually the slowest healthy duration measured with the whole suite at the CI concurrency factor and the headroom that multiplies it. Derive the budget from them with constant arithmetic, and assert its order against the budgets around it: shorter than the phase that repeats it, and inside the cleanup budget or the reserve when it runs during cleanup. Record the measurement beside the input.
- Bound it by what it owns. An operation that owns nothing runs within the deadline, or is polled until it, keeping its last output and last failure. A wait for something its owner must keep borrows it and leaves it with that owner when the deadline passes. A task that spawned nothing of its own may be aborted and then joined; a node is never aborted, because the abort would end only its root task. A loop yields on every iteration and pauses only until the deadline.
- Keep its failure typed. Return a report of a semantic error enum the module owns, name the operation a passed deadline interrupted, and read state from typed variants. Never reduce an outcome to a boolean, infer a state from whether a message exists, or report an unknown reason. Failures carry no credentials and no payload values.
- Never block the runner task. Every scenario and the suite watchdog share it. A synchronous
loop needs a bound of its own, as the port draw has, and blocking work belongs on a blocking or
spawned task: a step hands its file, encoding, digest or database work to
off_the_runnerintests/common/runner_offload.rsand awaits it, and the runner-lag observer logs a step that still holds the task. A duration a scenario injects for a product deadline to race is timed on a task of its own, as injected holds are, because another scenario can still stall the runner task. - Make cancellation release it. Whatever a scenario starts belongs to the scenario’s world or
its cluster, so dropping the run releases it: spawned work behind handles that abort on drop,
child processes killed on drop, and node tasks through the single task owner, which asks a node
that still runs to stop and leaves it stopping under a keeper. A new kind of node publishes its
stop to the live-cluster registry through a registration its task owns, and its cleanup belongs
to the
stoppingphase. Ports, faults, proxies, and storage go back only after the tasks that used them have ended. - Report it. Transitions a reader needs while a run is still going go to standard error or to the scenario log with the scenario’s identity. A failure fails its step with the typed error’s message, and diagnostics gathered on a failure path stay within the diagnostic budget.
- Test it. Add a focused regression to
tests/harness_liveness.rsthat drives a stand-in endpoint or task, on a paused clock where the budget is long, so the bound is proven without production-scale waits, and runjust test-harness-liveness. Run the affected features withjust test-scenarios --input <feature>at the CI concurrency factor,NERVIX_TEST_CONCURRENCY_FACTOR=2. - Keep this chapter current in the same change.
Guarantees And Limits
The harness guarantees:
- Every status request, status wait, readiness wait, node startup, teardown diagnostic, cluster cleanup, and post-run teardown has one absolute deadline, and no retry or poll restarts it.
- No diagnostic, timeout, or cleanup path aborts a node task or leaves one unowned. A node that outlasts a wait stays with its owner or with the keeper it was handed to, and keeps its storage, its ports and its registrations until its task has ended.
- No node is launched over one that is still stopping, so a launch never meets a database lock its own predecessor holds.
- A cluster’s cleanup costs one budget whatever its size, and releases what a node held only after that node’s task has ended.
finishedis published only after cleanup has completed.- A gate a step holds is released its injected duration after the hold observed it, however long the runner task is stalled.
- The run ends within its budget, the cleanup window, and the bounded teardown, with a status that tells a timeout from a failure.
Its limits:
- Budgets bound what the harness waits for, not what the operating system schedules. A starved runner delays the checks that end a wait.
- The watchdog is polled on the runner task, so it cannot end a synchronous stall of that task. The job’s limit is the guard that remains.
- Provisioning a Kafka topic’s partitions and waiting for a consumer group’s members query the broker synchronously on the runner task, for up to 5 seconds per query, and every scenario of the run waits while one of those queries runs.
- Cleanup and stops give back a stopped node’s consensus fault registration on the runner task, so when no step still holds an observer of the node’s consensus state, the node’s consensus database closes there, and every scenario of the run waits until the close has synchronized the journal and the database’s last background worker has left.
- Kafka committed-offset assertions query on a blocking worker with a one-second request timeout. A query error retries within the assertion’s original deadline; persistent errors fail rather than count as an offset observation. An offset that crosses a forbidden threshold fails immediately, and a below-threshold assertion completes only after a successful observation at the end of its window.
- The watchdog runs no after hooks, so a node held by a fault its scenario injected can outlast the cleanup window and ends with the runtime.
- Nothing in the process can end a node that does not end itself: in-process nodes share one runtime with the harness. A node left stopping that never finishes its shutdown keeps running, with its storage and ports, until the process drops the runtime.
- The time a scenario spends queued for permits and closing the browser have no budget of their own, and some ordinary step waits check their bound only between requests. The suite budget bounds all of them.
- A construction’s convergence checks are separate 40-second waits after its startup deadline.
- The suite budget’s derivation holds with no margin.
Qualification Evidence
just test-harness-liveness runs the focused regressions that hold this contract. They drive
stand-in session services and a stand-in NATS server on real loopback sockets, and stand-in node
tasks, most of them on a paused clock. The tests job runs them beside the separate scenario job.
| Contract | Regressions |
|---|---|
| A status request ends at its deadline in the operation that stalled, and every probe outcome keeps its cause | status_request_ends_at_its_deadline_while_the_connection_is_pending, status_request_ends_at_its_deadline_while_session_establishment_is_pending, status_request_ends_at_its_deadline_while_the_response_is_pending, status_request_ends_at_its_deadline_while_unrelated_responses_remain_ready, readiness_and_status_outcomes_retain_their_typed_cause |
| A wait ends at its original deadline | status_wait_ends_at_its_original_deadline_when_the_final_request_never_replies, nested_deadline_never_outlives_its_phase, startup_readiness_failure_reports_the_timed_out_status_operation |
| Node task outcomes are distinct, the task is joined once, and its owner never aborts it | running_task_reports_the_last_failed_probe_at_the_deadline, clean_application_exit_before_readiness_is_terminal, application_error_before_readiness_is_retained, panic_and_cancellation_have_distinct_terminal_outcomes, stop_and_keeper_paths_use_the_task_inspected_for_diagnostics, a_wait_past_its_deadline_leaves_the_task_running_with_its_owner |
| One startup budget with classified retries | repeated_readiness_failure_spends_one_budget_across_every_attempt, a_node_that_does_not_stop_ends_the_startup_instead_of_being_launched_over, exhaustion_reports_every_attempt_with_its_typed_cause, ports_that_cannot_be_reallocated_end_the_startup, a_bound_address_is_retried_until_a_launch_becomes_ready, a_last_attempt_still_becomes_ready_with_what_the_budget_left, an_application_error_ends_the_startup_without_another_launch, a_panicking_node_ends_the_startup_without_another_launch, a_launch_failure_ends_the_startup_before_anything_is_cleaned_up, sequential_cluster_construction_stays_inside_its_derived_budget |
| Diagnostics are concurrent and never keep cleanup from starting | status_snapshots_keep_a_healthy_node_while_another_node_stalls, failed_and_stalled_diagnostics_end_by_their_deadline_so_cleanup_starts, a_stalled_diagnostic_still_reaches_every_node_stop_in_a_cluster_of_one_and_of_three |
| One cleanup budget per cluster, and truthful phases | stuck_nodes_spend_one_cleanup_budget_in_a_cluster_of_one_and_of_three, a_node_left_stopping_keeps_what_it_holds_until_its_own_task_ends, a_single_node_cleanup_keeps_how_its_task_ended, a_panicking_node_is_the_only_cleanup_failure_a_three_node_cluster_reports, the_finished_phase_is_published_only_once_cleanup_has_completed, an_active_scenario_publishes_its_phase_and_the_age_of_that_phase |
| A stop a scenario waits for is timed by the node’s own ending, and a node that outlives a wait is named with its shutdown phase | a_stop_is_timed_from_its_first_request_to_the_ending_of_the_node_task, a_node_that_had_ended_before_it_was_asked_to_stop_took_no_time_to_stop, a_stopping_node_is_read_with_its_shutdown_phase_and_its_own_deadline, a_stop_record_reads_as_the_transition_the_harness_prints, a_node_left_stopping_is_recorded_with_where_its_own_shutdown_stood |
| Feature waits do not occupy run slots, and limited chains start before and progress beside bulk work | a_queued_web_console_scenario_does_not_hold_a_run_slot, limited_features_are_taken_up_before_the_bulk, the_next_limited_scenario_gets_a_slot_beside_bulk_work, releasing_a_limited_scenario_hands_its_slot_to_the_next_in_its_chain, each_web_console_feature_starts_before_one_feature_consumes_the_group |
| The port pool is bounded and gives ports back | a_draw_that_keeps_landing_on_reserved_ports_ends_at_the_draw_limit, an_exhausted_draw_gives_back_the_ports_it_had_reserved, a_draw_the_operating_system_refuses_is_reported_as_its_own_failure, ports_drawn_from_the_operating_system_are_distinct_and_reserved, a_released_port_can_be_drawn_again |
| An HTTP receiver answers as scripted, records what it cannot capture, and stops within its budget | the_receiver_captures_requests_and_answers_its_script_in_order, a_lost_response_is_captured_and_the_connection_closes_without_an_answer, chunked_bodies_interim_responses_and_raw_bytes_are_served_as_scripted, held_responses_and_stalled_bodies_end_within_the_stop_budget, requests_awaiting_a_response_are_counted_until_their_final_head_begins, a_client_that_leaves_an_unfinished_response_abandons_it, a_wait_for_a_request_line_ends_once_that_request_is_captured, requests_beyond_the_receiver_bounds_are_faults_not_captures, a_tls_receiver_accepts_the_client_certificate_it_issued_and_refuses_others, a_tls_receiver_is_refused_by_a_client_that_dials_a_name_its_certificate_lacks, every_documented_script_form_parses_and_unknown_forms_are_refused |
| A gRPC receiver answers as scripted, records what it cannot capture, and stops within its budget | the_grpc_receiver_captures_calls_and_answers_its_script_in_order, a_lost_grpc_answer_is_captured_and_its_connection_closes_without_one, held_grpc_calls_end_when_the_client_resets_them_or_the_receiver_stops, a_request_that_is_not_one_bounded_message_is_a_fault_not_a_capture |
| Synchronous step work leaves the runner task polling, and a stall of that task is measured | a_step_job_leaves_the_runner_task_polling_other_scenarios, a_step_job_returns_its_result_to_the_step, a_step_job_fails_its_step_with_its_own_panic, a_step_that_blocks_the_runner_task_is_reported_with_its_length |
| A step takes the messages a NATS observer’s client already holds without a poll of the runner task for each one, in the order the server delivered them | a_step_takes_buffered_deliveries_without_a_poll_per_message |
| The suite watchdog names what was running, publishes timing before cleanup, and ends the run | a_run_that_finishes_inside_its_budget_keeps_what_it_produced, a_suite_timeout_reports_before_it_drops_the_run, a_stalled_scenario_body_is_named_with_its_attempt_phase_and_nodes, a_stalled_teardown_diagnostic_is_named_by_the_phase_it_is_in, a_node_that_never_stops_is_named_at_the_end_of_the_cleanup_window, a_cluster_that_outlives_its_scenario_is_named_as_unclaimed, a_retried_scenario_publishes_which_attempt_is_running, the_suite_budget_is_injectable_and_defaults_to_the_suite_policy, a_timed_out_suite_is_reported_apart_from_a_passing_and_a_failing_one, a_failing_suite_ends_the_process_by_unwinding, a_dependency_stop_that_never_returns_is_abandoned_at_its_budget, a_dependency_stop_that_finishes_keeps_what_it_reported |
The high-parallelism qualification was recorded on 23 September 2026 for the change that landed as
cec5f764, at the CI concurrency factor of two scenarios per CPU with Cucumber’s two retries unless
a run says otherwise. The local runs used a 24-CPU workstation shared with other builds, whose load
average stayed between 7 and 40, so 48 scenarios ran at once; CI used its 16-CPU runner, so 32 did.
| Run | Result |
|---|---|
just test-harness-liveness, 20 repetitions | Every repetition passed, 48 regressions in 4.0 seconds each |
WASM processor restores guest state after cluster restart, all three example rows, three runs with --retry 0 | Every run passed. Every node became ready on its first attempt within 2.1 seconds, and the slowest cleanup took 8.7 of its 60 seconds, so none reached its budget |
tests/features/cluster/*.feature | 206 scenarios passed in 500 seconds with 16 retries. 553 node startups: median 2.04, 99th percentile 22.0, and slowest 41.3 seconds; two became ready on their second attempt, after a 36-second readiness deadline whose last probe was a typed Unauthenticated reply. 200 cleanups, the slowest 45.1 seconds, so none reached its budget |
tests/features/runtime/wasm_*.feature | 95 scenarios passed in 583 seconds with 18 retries. 288 startups, the slowest 17.4 seconds. Two cleanups reached the 60-second budget with a node still running, each recorded with that node and the scenarios live beside it, and both scenarios passed when retried |
The whole suite with --suite-budget 4m | Exit 124 after 313 seconds: the 240-second budget, the 60-second cleanup window, and 13 seconds of dependency and runtime teardown. The diagnostic named all 49 registered scenarios with their attempt, phase, phase age, and nodes, and the cleanup named the one node still running at the end of its window |
| The whole suite with a deliberately failing scenario added | Exit 101 reporting 3 step(s) failed after 1,676 seconds. The failing scenario failed all three attempts while the rest of the suite ran beside it, and each attempt reached finished within 0.32 seconds of its body ending. 3,926 startups, 99th percentile 4.14 and slowest 21.4 seconds; eight transient bind failures were relaunched on fresh ports and became ready. Six cleanups reached their budget with a node still running and were recorded |
| The whole suite | Exit 101 after 1,490 seconds, with 1,749 of 1,752 scenarios passing. The three that did not are throughput and deadline assertions that failed only under the shared machine’s load and pass in isolation and in CI. 3,937 startups, the slowest 20.7 seconds; four cleanups reached their budget with a node still running and were recorded |
CI tests job with NERVIX_TEST_SUITE_BUDGET=4m | The job ended itself in 16m30s. The suite started 11m54s into the job, and its budget expired with 32 scenarios active, 29 in their body and 3 stopping, each named. All 53 live nodes stopped within 9.96 seconds of the cleanup window, the process exited 124, and the test-logs artifact was uploaded 2 seconds later |
CI tests job of the merged revision | Passed in 34m15s. The suite started 12m01s into the job and ran 1,754 scenarios and 19,384 steps in 19m26s with 2 retries. 3,832 node startups, all on their first attempt: median 1.77, 99th percentile 4.91, and slowest 6.37 seconds. No cleanup reached its budget |
After every run, no nervix-server process, scenario binary, or dependency container remained. The
harness liveness qualification
ledger
records each run in full, with its artifacts, the executable evidence for every item of the
qualification matrix, and the comparison with the run the job limit cancelled.
Deterministic Interconnect Simulation
The interconnect has a seeded network simulation built on Turmoil. Each Nervix transport runs on a simulated host with its own Tokio runtime, virtual clock, TCP stack, and DNS name. The hosts talk over simulated links that the test can delay, partition, hold, and repair. The code above the socket is the production code: TLS 1.3 mutual authentication, HTTP/2 pools, typed rkyv envelopes, Arrow IPC payloads, relay admission and reconciliation, connection lifecycle, and quota accounting all run unchanged. The same seed and inputs make the same decisions in the same simulated order, so a network failure that once exposed a defect can be run again exactly.
This chapter owns that simulation: where its code lives, how it builds, what a simulated host controls, how a run is supervised, the fault model, how runs are compared and replayed, the scenarios and what each one proves, the commands, the CI budget, the qualification evidence, and the limits of every claim. Three rules hold throughout.
- The simulation tests interconnect contracts. It never claims that a whole Nervix node, a consensus group, gossip, the domain clock, a store, or an external system was simulated.
- A replay compares decisions, not bytes. A trace records identities, admission decisions, outcomes, and the simulated time at which they happened. It never records key material, certificate bytes, ciphertext, or payload values.
- A simulated host crash is not a process crash. It tears a runtime down with ordinary destructors, so durability and SIGKILL behavior keep their real-process evidence.
The harness is test code outside the layer order: it may name any layer, and product code never names it. Product contracts stay with the chapters that own them: node-to-node transport with Cluster Interconnect, in-process ordering under Shuttle with Data-Plane Concurrency, node stops and crash recovery with Shutdown And Recovery, domain time with Domain Clock, and the Cucumber cluster harness with Integration Test Lifecycle.
What Each Suite Establishes
Four suites exercise the interconnect. Each establishes something the others cannot, and none of them substitutes for another.
| Suite | Command | Scheduler and time | What it establishes | What it cannot establish |
|---|---|---|---|---|
| Ordinary unit tests | just test-interconnect, just test-execution | Real Tokio runtime, paused or real time | Local logic of one owner: framing, codecs, quotas, identities | Behavior across a lossy network or between real processes |
| Shuttle checks | just test-shuttle | Shuttle’s scheduler over wrapped primitives; no elapsed time | That in-process protocols hold their invariant under every explored interleaving | Sockets, elapsed deadlines, or anything across hosts. See Deterministic Concurrency Verification |
| Turmoil simulation | just test-turmoil | One Turmoil scheduler owns every host; each host has a paused Tokio clock that advances with simulated time | That the production transport keeps its deadlines, reconciliation, fencing, and resource bounds when links are delayed, partitioned, held, repaired, or a host restarts, and that the same seed replays the same decisions | Instruction-level races inside one job, kernel TCP behavior, durability, and whole-node behavior |
| Cucumber scenarios | just test-scenarios --input <feature> | Real nodes, real sockets, real time | Public behavior through NSPL, HTTP, the CLI, and cluster state, including real-process SIGKILL recovery | Exhaustive fault timing; failures are observed, not scheduled |
Code Ownership
| Path | Kind | Owns |
|---|---|---|
crates/primitives/src/net.rs, time.rs and task.rs | Product seam | The primitive boundary’s selection for the Turmoil build: Turmoil’s sockets and the simulated lookup_host in net, Tokio’s timers and instants in time, which follow each host’s clock, and task::spawn_cpu, which runs an admitted CPU job as a scheduler task. Production takes Tokio’s sockets and runs the job on the blocking pool |
crates/interconnect/src/peer_resolver.rs | Product seam | Selects the PeerResolver: the node’s Hickory resolver in production, the simulated host’s DNS table through nervix_primitives::net::lookup_host in the Turmoil build |
crates/interconnect/src/authentication.rs | Product seam | TransportClock, the one UTC clock each TLS bundle judges certificates by. TransportClock::system in production, TransportClock::from_provider for a controlled environment |
crates/interconnect/src/entropy.rs | Product seam | TransportEntropy, the source of the process epoch and relay grant identifiers. TransportEntropy::operating_system in production, TransportEntropy::from_source for a controlled environment |
crates/execution/src/workers.rs | Product owner | Admission, charges and cancellation of every job; it submits CPU jobs through task::spawn_cpu, the only caller the boundary check allows, and storage jobs through task::spawn_blocking |
crates/primitives/src/lib.rs | Product seam | The compile error when two execution modes are selected together |
crates/interconnect/tests/simulation/runner.rs | Harness | Run configuration, the scheduler thread and its real-time bound, host supervision, first-panic capture, the simulated UTC clock, seeded entropy, and the semantic trace. The library’s own simulation tests include it as simulation_runner |
crates/interconnect/tests/simulation/scenario.rs | Harness | Scenario identity, committed seeds, the seed sweep, fresh-process attempts, failure records, replay, and failure injection |
crates/interconnect/tests/simulation.rs | Harness | The simulation test target: runner checks, the DNS and CPU scenarios, and the fresh-process record and replay checks |
crates/interconnect/tests/simulation/transport.rs | Harness | The certificate authority fixture, typed Arrow requests, host synchronization, and the exchange and link-fault scenarios |
crates/interconnect/tests/simulation/relay.rs | Harness | Relay reply loss, cancellation, receiver restart, and sender replacement with a retained intake |
crates/interconnect/tests/simulation/backup.rs | Harness | Authenticated backup capture and section fetch across a partition, repair, and owner restart during transfer |
crates/interconnect/tests/simulation/isolation.rs | Harness | The three-host stalled-peer scenario and the capacity bounds it checks |
crates/interconnect/tests/simulation/materialized.rs | Harness | Typed branch descriptions and 3 MiB Arrow payloads in 64 KiB bulk chunks across a partition, repair and serving transport replacement |
crates/interconnect/src/authentication/simulation_tests.rs | Harness | Certificate validity, expiry drain, handshake deadlines, and trace replay under the simulated clock |
crates/interconnect/src/wire.rs, module simulation_checks | Harness | Seeded rkyv round trips through the execution owner |
crates/execution/src/workers.rs, module simulation_checks | Harness | Queue slots, admission order, and charge ownership of the Turmoil CPU strategy |
crates/primitives/src/tests/simulated_host.rs | Harness | The boundary’s own check that sockets, name lookup, timers, and admitted CPU jobs belong to the simulated host whose task uses them |
justfile and the turmoil job in .github/workflows/check.yaml | Tooling | The recipes, budgets, validation, and CI artifact |
Every product seam has exactly one production behavior, and the production value is the default a node uses. A seam exists so that a controlled environment can supply a value; it is not a switch between two production behaviors. The harness never reaches into product state: it constructs a transport through its public API, supplies a clock and an entropy source, and observes the result through requests, snapshots, and outcomes.
Build Modes
Execution Primitives owns backend and target selection, permission contracts and the difference between compile-time probes and actual execution evidence. This chapter owns what the Turmoil hosts simulate, the faults and the completed scenario evidence.
The simulation is its own build. Its scheduler replaces the real network, so it can never share a compilation with the production or Shuttle builds.
| Build | Selected by | Sockets and DNS | CPU jobs | Synchronization primitives | Tokio configuration |
|---|---|---|---|---|---|
| Normal | Default features | Tokio’s operating-system sockets, which nervix_primitives::net selects, and the node’s Hickory resolver | Tokio’s blocking pool, through task::spawn_cpu | The ordinary primitives nervix-primitives re-exports: Tokio, Tokio Util, parking_lot, DashMap and the standard library’s | Stable |
| Shuttle | The shuttle feature of each owning package, forwarded to nervix-primitives | Tokio’s, outside every model and never driven by the checks: a socket created inside a check fails it | spawn_blocking of Shuttle’s modeled Tokio | The modeled primitives nervix-primitives selects: Shuttle’s Tokio, timers, Tokio Util, parking_lot, DashMap and atomics, and its own scheduler-visible Notify and watch | Stable |
| Turmoil | The turmoil feature of nervix-interconnect, which enables nervix-execution/turmoil and nervix-primitives/turmoil | Turmoil’s simulated sockets and DNS table, which nervix_primitives::net selects | A task on the simulated host’s scheduler, through task::spawn_cpu; storage jobs stay on the blocking pool | The ordinary primitives, on the simulated host that runs the caller, with timers that follow its clock | --cfg tokio_unstable |
| Turmoil with Shuttle, Loom or Deloxide | Both modes in one dependency graph, from one package or two | Fails to compile in nervix-primitives with a diagnostic naming both modes, so the deadlock detector never runs inside a simulation | |||
| Turmoil for the browser’s target | The turmoil feature with --target wasm32-unknown-unknown | Fails to compile with the boundary’s diagnostic that execution modes run on native targets only, before any simulator dependency is built |
Only the Turmoil recipes pass --cfg tokio_unstable, and just validate-primitive-boundary rejects
the flag in any other recipe, in Cargo configuration, in a workflow and in a build script. It
rejects --cfg turmoil everywhere, and a bare turmoil in a cfg predicate: the mode is the
turmoil feature, never a cfg every crate of the build would read. With it,
Turmoil seeds each host runtime’s
scheduling from the simulation seed and configures an unhandled task panic to shut that host’s
runtime down, which fails the run. Without it, scheduling would not follow the seed and a panicking
task would only print, so the runner refuses to start. The workspace declares the cfg in its
check-cfg list so ordinary builds accept the attribute without enabling it. Turmoil 0.7.2, locked
in Cargo.lock, is used without its unstable filesystem feature.
Validation keeps the modes apart:
just validate-execution-mode-dependenciesfails when the normal dependency graph of the workspace, or of any package built on its own, with or without default features, contains Turmoil, Loom, Shuttle, a Shuttle wrapper or Deloxide, or enables an execution mode on the primitive boundary.just validate-primitive-boundaryrejects every path to Tokio’s or Turmoil’s sockets, Tokio’s timers, or the standard library’s monotonic clock and sockets outsidenervix-primitives, and aturmoildependency outside the boundary unless it is optional behind the package’s ownturmoilfeature, as the interconnect’s harness takes it. The simulated path therefore cannot name an operating-system socket or clock that would escape its host.just validate-execution-mode-conflictsbuildsnervix-primitiveswith every pair of theloom,shuttle,turmoilanddeloxidemodes and with all four, andnervix-interconnectwith Shuttle while its execution dependency selects Turmoil, and requires the diagnostic naming the modes in each. It builds each mode for the browser’s target and requires the boundary’s own diagnostic first, and builds the NSPL formatter, a product binary, with each mode and requires the diagnostic that a modeled build is a test artifact.just lintruns Clippy over every target of both packages with theturmoilfeature, beside the production and Shuttle lints.just testruns the workspace with all features while excluding every package that offers a scheduler mode, runs those packages with default features, and then runsjust test-turmoil.
The Simulated Host
A simulated host is a named Turmoil host running one production transport. Everything the transport owns is constructed inside the host: its credential bundle, executor, peer resolver, transport, and peer targets. Live sockets and transports are never shared between hosts. Hosts share only bounded readiness and completion signals with each other and with the observing client.
Sockets And DNS
In the Turmoil build the primitive boundary’s nervix_primitives::net selects Turmoil’s
TcpListener, TcpStream and UdpSocket, and every transport is built with
PeerResolver::simulated, which answers from the simulated host’s DNS table through
nervix_primitives::net::lookup_host, a lookup the boundary offers in that mode alone. The listener,
every outbound dial, and peer resolution therefore use simulated TCP and DNS. A peer endpoint such as server:7443 resolves to the address Turmoil
assigned to the host named server, in IPv4 or IPv6 by configuration, and a name the table does not
hold fails as a name that does not exist. The scenarios register peers at their advertised
endpoints, so, as in production, every connection attempt resolves the peer’s name again inside its
setup deadline and dials the answer. Nothing in the simulated path opens an operating-system socket,
constructs the production Hickory resolver, reads a resolver configuration or hosts file, or sends a
DNS packet, so a fault scenario cannot escape to the real network and no resolver state is shared
between hosts. The layers above the socket, tokio-rustls, h2, the envelope codec, and Arrow IPC,
are the production code. These scenarios exercise the transport over simulated names; Hickory’s DNS
protocol, cache, and failure handling are checked by the resolver crate’s own tests against local
DNS authorities outside the simulation, as Name Resolution
describes.
Bounded CPU Execution
The transport encodes and decodes through nervix-execution, which submits every admitted CPU job
through the boundary’s task::spawn_cpu. In the Turmoil build that runs the job as a Tokio task on
the host’s scheduler instead of on the blocking pool, so the job body is one scheduling step of the
simulation. The executor is the mechanism’s only caller: the boundary check rejects it anywhere
else, so no caller runs CPU work on the scheduler without admission. Queue slots, per-class worker reservations, memory charges,
and cooperative cancellation follow the same policy as production. A queued caller that leaves
releases its slot and charge; a running job keeps its worker and charge until it exits, even when
its caller leaves.
Turmoil can vary the order of tasks around a job, but not instructions inside it. Races inside a job need Shuttle or real threads. Storage jobs still run on a real blocking thread and are outside the simulated path. Only bounded executor probes and interconnect codec jobs are approved for the simulation; storage jobs, external drivers, and unbounded CPU work are not.
Time
A simulated host has three distinct notions of time, and the simulation controls each one differently.
| Time | Source on a simulated host | Read by |
|---|---|---|
| Scheduler time | The timers and instants of nervix_primitives::time, which are Tokio’s and follow the host runtime’s paused clock, advanced by Turmoil one tick at a time | Every transport deadline: connection setup, request and progress timeouts, liveness, reconnect backoff, relay grant lifetimes, the drain deadline derived from certificate expiry |
| Certificate UTC | The bundle’s TransportClock, given a provider that reads the configured epoch plus simulated elapsed time, with an optional per-host skew | Rustls verification on both sides of a handshake, the transport’s checks of its local and peer certificates, and the mapping of certificate expiry onto a monotonic drain deadline |
| Domain time | Not simulated | Domain-clock authority and execution time, owned by Domain Clock |
Outside a simulated host the simulated UTC clock has no reading, and a certificate check fails instead of falling back to the host wall clock. The certificate fixtures’ epoch, 2027-01-15T00:00:00Z, lies after the wall clock of any machine the suite runs on today, so a check that consulted the host clock by mistake would find every fixture certificate not yet valid. The transport clock is physical infrastructure time only: it never defines domain time and never stamps connector arrivals.
Identities And Entropy
Node names are fixed per host. Each transport’s process epoch and relay grant identifiers come from
a seeded source, SimulatedEntropy, whose stream is named after the host. One seed therefore
allocates the same identities on every host in every process, while two hosts sharing the seed draw
different values. Fixtures choose their own delivery identities and acknowledgement registrations,
such as the channel incarnation and ACK identifier of each relay case and the run of the node that
registers it, so a trace can name them.
Certificates are generated for every run with fixed validity windows: 2027-01-01 to 2028-01-01 for the transport fixtures, and windows placed relative to the epoch for the certificate-clock checks. Validity decisions therefore replay while key bytes differ between runs. Rustls and AWS-LC keep their real cryptographic randomness; it changes ciphertext and never a decision the trace records.
The transport’s concurrent maps use per-process hash seeds. Every walk over one of them that causes effects runs in semantic key order, as Cluster Interconnect lists, so map order cannot reach a decision.
The Runner
SimulationConfig::run builds one Turmoil simulation from one configuration, lets a setup closure
add hosts and clients, and steps it on a dedicated scheduler thread until every client has finished
or a bound expires.
Configuration
Every run is built from exactly these inputs, and a failure record stores all of them.
| Input | Meaning |
|---|---|
seed | Turmoil’s random seed: link latency draws, the random host order within each step, and each host runtime’s scheduling seed. Fixtures derive their entropy streams from it |
epoch | The UTC instant simulated time starts at. It must not precede the Unix epoch |
topology | IPv4 or IPv6 host addresses |
network.min_message_latency, network.max_message_latency | The range each simulated message’s delay is drawn from with Turmoil’s exponential curve. Every scenario uses 0 to 100 ms |
network.fail_rate, network.repair_rate | Turmoil’s chance per step that a link starts or stops dropping messages. Every scenario uses 0 and 1, so links fail only through the scenario’s written fault plan |
network.tcp_capacity | Segments one simulated connection buffers before its sender waits. Every scenario uses 64 |
bounds.simulated_duration | Simulated time after which the run fails |
bounds.tick | Simulated time one step advances. Every scenario uses 1 ms |
bounds.max_steps | Steps after which the run fails |
bounds.wall_duration | Real time for the run and its cleanup together |
The runner validates the configuration before it starts a thread. A zero duration, an epoch before
the Unix epoch, a build without tokio_unstable, or a real-time bound too large for the machine’s
clock each fail with their own error, naming the scenario and seed.
Host Lifecycle And Supervision
A host is registered with its software, a closure that Turmoil calls when the host starts and again
each time the host is bounced. Each call builds the host’s transport afresh. A client is a future
that runs once; the run completes when every client has returned. Scenarios therefore add an
observer client that waits until each host has reported that it finished, so hosts, which never
complete a run themselves, still bound it.
HostSupervisor::runspawns a host’s body as a task on the host’s runtime and returns the body’s error or panic as the host’s result. A failed host fails the next step.- A panic in any other task on a host shuts that host’s runtime down, which fails the run. The runner reports the first panic raised on the scheduler thread, with its message and location, rather than Tokio’s generic shutdown message. A panic that something caught, such as a supervisor or Tokio while dropping a task, is reported the same way.
- A host shuts down every transport it bound before its body returns, and checks the transport’s connection counts after shutdown.
SimulationConfig::run_with_control, used throughScenarioRun::simulate_with_control, calls a control closure after every step that did not end the run. A fixture uses it to crash, release, repair, and bounce hosts at a protocol milestone a host published, never at a guessed step count.- When the steps complete, the simulation and every host runtime are dropped in a cleanup phase of their own, inside the same real-time deadline. A panic while dropping fails a run whose steps all succeeded.
How A Run Ends
| Ending | How the run fails |
|---|---|
| A host or client returns an error | With that error |
| A panic on the scheduler thread | With the first panic’s message and source location. This covers a panic in a task on a simulated host, which shuts that host’s runtime down, and a panic Tokio catches while it drops a task, which would otherwise only be printed |
| The simulated duration or the step limit runs out | With the bound and the simulated time reached |
| The real-time bound expires | With the phase the scheduler was in, the steps it completed, and the simulated time they reached, which locates a step that blocked the scheduler thread |
| Cleanup panics after the run completed | As a cleanup failure, even though every step succeeded |
The real-time deadline is fixed before the scheduler thread starts and is measured outside it, so it expires even when a host blocks that thread and simulated time cannot advance. The blocked thread is then abandoned and ends with the process that runs the attempt. A cleanup failure that follows a failed run is written to standard error beside the run’s own failure. The real thread and wall clock decide only when a test fails, never how a simulated request proceeds.
Fault Model
A fault plan is written with Turmoil’s link and host controls. Each control is an abstraction of a network or host event, and a scenario’s claim is limited to the abstraction it used.
| Control | Effect in Turmoil 0.7.2 | Used by |
|---|---|---|
partition(a, b) | Drops every message between two hosts in both directions | Partition before connect; partitioned authenticated exchange |
partition_oneway(from, to) | Drops messages in one direction only | One-way partition before connect; relay reply loss; receiver restart after body receipt and after admission |
repair(a, b), repair_oneway(from, to) | Restores delivery. Dropped messages are not replayed | Every partition above |
hold(a, b) | Queues messages between two hosts instead of delivering them | Held authenticated exchange; receiver restart with a delayed reply; stalled-peer teardown |
release(a, b) | Delivers every held message at once | The same scenarios |
crash(host) | Drops the host’s runtime and every task on it, running ordinary destructors | Receiver restart |
bounce(host) | Restarts a host by calling its software again | Receiver restart |
The model has consequences that every scenario respects:
- Simulated TCP does not retransmit. A message dropped by a partition is lost for good, so after a repair an established HTTP/2 session can be unusable. The established-exchange scenarios require both peers’ protocol pings to retire unusable sessions and allow reconnection without restarting either host or manually replacing a pool. No scenario infers kernel retransmission timing or packet-loss recovery.
- Holding and one-way partitions cannot be combined on one link, so scenarios use them separately.
- Turmoil 0.7.2 has no disconnect control. TCP closure is exercised by shutting a listener down or crashing a host.
- A request whose deadline expires while its messages are held stays expired when they are released.
- Turmoil’s own socket count can keep failed connection attempts after a partition. Scenarios measure session cleanup with the transport’s connection counts instead.
- Random link failures are disabled. A scenario’s faults are its written plan, applied at named protocol points.
Semantic Traces And Replay Rules
A run records a semantic trace: an ordered list of events, each with the simulated time it happened at, the host that recorded it, and what was decided. Fixtures record events at decisions: a connection became ready, a setup failed, a deadline expired, a relay was admitted or fenced, an epoch changed, a resource was released. The same scenario, seed, and inputs must record the same trace.
- An event names identities, admission decisions, and outcomes. It never contains key material, certificate bytes, ciphertext, or payload values, which differ between runs without changing a decision or do not belong in a record. Payload assertions compare values without printing them.
- An event never contains a value that describes the process rather than the simulation, such as a
process identifier, a real time, a memory address, or an iteration over a hashed collection.
process_dependent_traceis a deliberately divergent scenario that records its process identifier, and a regression test requires it to be recorded as diverged. - Every committed seed runs twice, each time in a fresh process, and the two traces and outcomes must be identical. The first differing event, or the first event only one run has, is a divergence and fails the seed. A divergence that can affect behavior is a defect to fix, never a seed to drop.
- A replay compares its outcome and its trace with the record, event by event.
Two nondeterminism sources remain outside the trace by design. Rustls and AWS-LC randomness changes
ciphertext and key bytes but no decision. Tokio numbers tasks across the whole process, and a
runtime tears its tasks down in an order derived from those numbers, so when a simulated host
crashes, the order in which its connections close depends on every task the process created before.
Each attempt and each replay therefore runs in a fresh process, where that count starts from the
same state. Tokio’s per-runtime scheduling seed is not a residual source: Turmoil derives it from
the simulation seed because the build sets tokio_unstable.
Scenarios, Seeds, And Attempts
A scenario is one case of a test: a name unique within the test, a written fault plan, and its
committed regression seeds. Scenario::check takes a function that builds a seed’s configuration
and a function that exercises it, and runs them in the mode the environment selects.
| Variable | Mode |
|---|---|
| None | Run every committed seed of every case twice, each in a fresh process |
NERVIX_TURMOIL_SWEEP=<first>..<end> | Replace every case’s committed seeds with that range, end exclusive. Everything else is unchanged |
NERVIX_TURMOIL_INJECT_FAILURE=<duration> | Add a client named injected-failure that fails at that simulated time, such as 5s. The injection is a recorded input |
NERVIX_TURMOIL_REPLAY=<record> | Run only the recorded case, once, with exactly the recorded inputs. It refuses to run beside a sweep or an injection |
NERVIX_TURMOIL_FAILURES=<directory> | Where records are written. Unset, they go under Cargo’s CARGO_TARGET_TMPDIR; the recipes set target/turmoil-failures |
The driver sets a fifth variable, NERVIX_TURMOIL_ATTEMPT, on the process it starts for one
attempt, and clears the other four there.
Seed Selection
Committed seeds are small and deliberate: one or a few per case, distinct across cases, and listed
beside the fault plan they exercise. A case’s committed set grows only by a seed that exposed a
defect, committed with its fix, so the regression set records what the simulation has found. The
sweep explores beyond them: just test-turmoil-sweep runs sixty-four consecutive seeds from 1000
through every case by default. A seed that fails in a sweep leaves an ordinary record, and once the
defect it found is fixed, it joins the committed seeds of its case. Seed 1036 of the receiver
restart with a delayed relay response is the first such seed.
Fresh-Process Attempts
For each seed, the driver writes a record marked running, then starts the test binary again with the
exact test name, --exact --include-ignored --test-threads=1 --nocapture, and the attempt’s
request. That process runs the case once and writes back its outcome and trace. The driver waits for
the run’s real-time bound plus 30 seconds, then kills the process. A process that ends without
writing its result yields an unreported outcome holding its ending and the last 40 lines of its
output. A process that passes without running the requested case is a driver error, because the test
and case names no longer match. After both attempts pass and agree, the record is removed.
Failure Records
A failed, panicked, diverged, or unreported seed leaves a JSON record at
<failures>/<test>/<case>-seed-<seed>.json, where the test and case are reduced to lowercase
letters and digits joined by dashes. The test then fails with a summary of the trace and the command
that replays the record.
| Field | Contents |
|---|---|
scenario | The Cargo package, the test target, the libtest test name, the case within it, and its fault_plan |
build | source, the checked-out commit and whether tracked files were modified; lockfile_sha256 of Cargo.lock; toolchain, the output of rustc -vV; and whether tokio_unstable and debug_assertions were compiled in |
inputs | The complete configuration, from seed to real-time bound, and any injected failure |
run | first, or repeat when the second attempt failed after the first passed |
outcome | Its kind and detail, as below |
events | The semantic trace of that run, as far as it got |
| Outcome kind | Meaning |
|---|---|
running | The seed had started when its process ended from outside, such as at the suite’s real-time budget. The record still holds its inputs |
failed | The runner returned an error: a failed host or client, an exhausted bound, or a scheduler panic |
panicked | The scenario panicked on the test thread around the simulation, such as in a check after the run |
diverged | Both runs passed with different traces; the record names the first differing event of each run |
unreported | The attempt’s process aborted, or outlived its bound and was killed, without reporting |
A record holds identities, inputs, outcomes, and semantic events only. It never holds certificates, key material, ciphertext, or payload values, so it is safe to attach to an issue or a CI artifact.
Commands
Every recipe builds with the repository’s kache compiler wrapper and passes --cfg tokio_unstable
itself.
| Command | What it runs | Bound and exit |
|---|---|---|
just test-turmoil [budget_seconds] | The primitive boundary’s simulated-host checks, the nervix-execution library under the turmoil feature, the interconnect library’s wire::simulation_checks and authentication::simulation_tests, and every case of the simulation target over its committed seeds | Builds first, then runs inside a real-time budget of 480 seconds by default; exits 124 naming the in-progress records when it expires. Reports how many tests each invocation and the whole suite discovered, selected, executed and completed, keeps each invocation’s output under target/turmoil-suite, and fails when an invocation executed none, when a test tests/turmoil-inventory.toml registers did not run or ran ignored, and when a simulation test ran unregistered |
just test-turmoil-simulation [args] | The simulation target alone, with libtest arguments, such as a test name and --exact | The per-run bounds only; fails when the selection executes no test |
just test-turmoil-replay <record> | Exactly the recorded package, target, test, and case, once, with the recorded inputs, in a fresh process | Exits nonzero when the replay fails, including when it reproduces the record, and when no case in the recorded test matches |
just test-turmoil-replay-check | Injects a failure at five simulated seconds into the partition-before-connect case, requires exactly one record, and requires its replay to report turmoil replay: reproduced the recorded outcome and trace | Fails when any of those does not hold |
just test-turmoil-sweep [first] [count] [budget_seconds] | Every case over count seeds from first, 64 from 1000 by default, each seed twice in fresh processes | A real-time budget of 1,500 seconds by default; exits 124 when it expires, and fails when the sweep executes no test |
just coverage-turmoil <output> | The canonical just test-turmoil suite through the native coverage collector, copying a complete product-source LCOV report to output and retaining its collection evidence | The same per-run bounds, inventory validation and 480-second post-build suite budget as just test-turmoil; failed or interrupted collection cannot produce a complete report |
The replay prints a verdict for every run:
| Line | Meaning |
|---|---|
build differs: … | The recorded source, lockfile, toolchain, or build flags differ from this build. Any of them can legitimately change a run |
fault plan differs: … | The case’s written plan changed since the record was made |
trace: all <n> recorded events replayed identically | Every recorded event recurred in order |
trace diverges at event <i>: … | The first event that differs, from each side |
turmoil replay: reproduced the recorded outcome and trace | The same outcome and trace; the failure reproduces |
turmoil replay: the recorded failure did not reproduce | The replay passed, which is how a fix is confirmed |
turmoil replay: the replay failed differently from the record | The replay failed with another outcome or trace |
The record and replay path is checked itself.
injected_failure_is_recorded_and_replayed_in_a_fresh_process injects a failure into a scenario in
a fresh process, requires exactly one record, replays it in a second fresh process, and requires the
recorded outcome and trace. diverged_and_panicked_runs_leave_records starts two deliberately
failing scenarios, one whose trace names the process that ran it and one whose check after the
simulation fails, and requires a diverged and a panicked record. just test-turmoil-replay-check
does the same through the documented commands. The suite still contains no failing test: the
injection exists only in the environment of the processes those checks start, and the failing
scenarios are ignored unless a check starts them.
An Example Investigation
The failure below was produced on purpose with the recorded injection, which exercises the same path a real failure takes. The output is quoted from that run, with paths shortened to the repository root.
NERVIX_TURMOIL_INJECT_FAILURE=5s just test-turmoil-simulation \
transport::network_disruption_respects_deadlines_and_repairs_authenticated_service --exact
-
Read the failure. The test fails with the case, seed, run ordinal, outcome, trace, and the replay command:
simulation transport::network_disruption_respects_deadlines_and_repairs_authenticated_service / "partition before connect" seed 61 (first run) failed: simulation partition before connect seed 61: host or client failed: injected harness failure at 5s simulated time 0.001s client PartitionBeforeConnect installed; peer registered failure record: target/turmoil-failures/transport-network-disruption-respects-deadlines-and-repairs-authenticated-service/partition-before-connect-seed-61.json replay it in a fresh process: just test-turmoil-replay target/turmoil-failures/transport-network-disruption-respects-deadlines-and-repairs-authenticated-service/partition-before-connect-seed-61.jsonIn CI the same record is in the job’s
turmoil-failuresartifact. Itsrunfield says whether the first attempt failed or only the repeat did. Arepeatfailure or adivergedoutcome points at nondeterminism before it points at the product. -
Compare the build. The record’s
buildnames the commit, lockfile hash, and toolchain. Check out that commit when the current branch has moved; the replay reports every remaining difference. -
Replay it.
just test-turmoil-replay target/turmoil-failures/transport-network-disruption-respects-deadlines-and-repairs-authenticated-service/partition-before-connect-seed-61.jsonrecorded outcome (first run): failed: simulation partition before connect seed 61: host or client failed: injected harness failure at 5s simulated time replayed outcome: failed: simulation partition before connect seed 61: host or client failed: injected harness failure at 5s simulated time trace: all 1 recorded events replayed identically 0.001s client PartitionBeforeConnect installed; peer registered turmoil replay: reproduced the recorded outcome and traceThe command exits with a failure status because the failure reproduced. The trace ends at the last decision before the failure: the client registered its peer 1 ms into the run, and the injected client failed at 5 seconds, before the twelve-second partition ended.
-
Narrow it. Read the case’s fault plan and the fixture’s events around the last recorded one. To see what happened between two events, add a temporary
tracingsubscriber that stamps lines withturmoil::sim_elapsed()and prints the lengths of byte arrays rather than their contents, then rerun the replay. Compare two runs of a divergence line by line from the first differing event. -
Fix it at its owner. A product defect gets its fix and regression at the owning boundary, with a Cucumber scenario when the behavior is public. A harness defect gets its fix in the runner or driver. Replay the record again until it prints
turmoil replay: the recorded failure did not reproduce, commit the seed to its case’s committed seeds, and runjust test-turmoil.
Seed 1036 is the recorded history of this procedure. The first sweep found that its two runs of the receiver restart with a delayed relay response diverged after the simulated crash while they shared one process. Tracing both runs with simulated timestamps showed that the crashed receiver’s connections closed in a different order, and that the order followed Tokio’s process-wide task numbering. The driver moved every attempt and replay into a fresh process, and seed 1036 became a committed seed of that case.
CI And Budgets
CI runs the simulation as a job of its own, turmoil, only for a pull request labeled
turmoil, beside the default tests and the shuttle and loom jobs. Its build enables the
turmoil feature and tokio_unstable, so it shares no compilation with them, and a failed
case’s record is its own artifact. The job runs just coverage-native-extras test-turmoil,
which executes the canonical gating suite once under LLVM source instrumentation, then runs
just test-turmoil-replay-check independently. Replay qualification contributes no counters.
The instrumented build lives under target/native-coverage-build-turmoil; its failure records
and suite logs, together with the ordinary replay’s target/turmoil-failures, are uploaded as
the turmoil-failures artifact when the job fails.
The coverage-turmoil artifact retains the product-source report, completion.json,
executions.jsonl and export.log from the collection’s own attempt. Only a report beside a
complete record establishes successful coverage; a failed or interrupted attempt retains its
failure evidence. Each fresh simulation process inherits LLVM_PROFILE_FILE with %p-%m,
so its counters are merged with the parent and the other attempts rather than overwritten.
The exporter matches every raw profile to a retained executable by build ID and includes
Turmoil-selected product paths in primitives, execution and interconnect. It excludes dependencies,
generated files and harness files below tests, examples or benches directories.
After the suite and replay qualification pass, the job publishes an advisory Turmoil patch
coverage comment through just coverage-patch --scope turmoil. Its marker is independent of
the ordinary coverage comment, and publication checks that the PR is open at the tested head.
A fast simulation run cannot replace the ordinary report, and a failed collection never supplies
a successful report. Coverage percentages and comment failures remain advisory; the simulation,
replay and collection verdicts remain mandatory. The ordinary coverage/CRAP job uses ordinary
reports only. just coverage-turmoil <output> uses this same collector for local inspection.
The job uses the shared native CI linker, so
adding --cfg tokio_unstable to RUSTFLAGS preserves Wild linking. It installs flatc, because
the interconnect’s test build takes its generated vocabulary values from nervix-arbitrary, whose
WASM protocol bindings are generated when that crate is built.
Every bound nests inside the next, so the innermost expired bound names the stuck run, and the job still has time to upload what it found. This follows the convention of The Suite Watchdog.
| Bound | Value | Expiry |
|---|---|---|
| Simulated duration per run | 1, 15, 30, 60, 85, 90, or 120 seconds by case | The run fails with the bound and the simulated time reached |
| Scheduler steps per run | 200, 20,000, 50,000, 100,000, or 120,000 by case | The run fails with the bound and the simulated time reached |
| Real time per run | 3, 60, 90, or 120 seconds by case | The run fails naming its phase, steps, and simulated time |
| Real time per attempt process | The run’s real-time bound plus 30 seconds | The driver kills the process and records an unreported outcome |
just test-turmoil tests | 480 seconds after the build | Exit 124; in-progress records name the unfinished runs |
just test-turmoil-sweep | 1,500 seconds | Exit 124 |
CI turmoil job | 30 minutes | The emergency guard around the build, the tests, and the upload |
The sweep’s 25 minutes is three times the 8m11s its default 64 seeds took after the build, one attempt at a time, on the 32-thread workstation where it was introduced.
Measured wall times for the qualified revision:
| Run | Wall time |
|---|---|
just test-turmoil locally, including compilation | 1m31s |
just test-turmoil-sweep 1000 64 locally | 11m02s, with a largest process of 131,060 KiB |
CI turmoil job, including build and replay check | 3m21s and 3m18s on the last two qualifying changes |
CI Shuttle stage of extra-tests | 9m03s of a job that took 27m33s inside its 45 minutes |
CI tests job | 53m03s inside its 60 minutes |
Scenario Matrix
Every case below is a Scenario in the simulation target. An ordinary run executes its committed
seeds twice each, in fresh processes; the sweep replaces the seeds without weakening any assertion.
| Case | Test | Committed seeds | Simulated / steps / real | Main assertion |
|---|---|---|---|---|
| peer resolution | peer_resolution_uses_simulated_dns | 41 | 1 s / 200 / 3 s | A peer endpoint resolves to its simulated address through Turmoil DNS |
| host isolation | simulated_hosts_keep_their_own_names_sockets_and_clocks | 43 | 15 s / 20,000 / 60 s | Two hosts listening on one port are each reached at their own address, a name no host holds fails as not existing without time passing, and a ten-second wait completes only on the simulated clock |
| bounded CPU worker | bounded_cpu_job_runs_on_the_simulated_scheduler | 1–12 | 1 s / 200 / 3 s | Every CPU and memory class runs its job on the scheduler thread, and every queue and reservation returns to zero |
| typed Arrow exchange | transport::production_transport_exchanges_typed_arrow_batch_and_state_frames_over_simulated_tcp | 49 | 30 s / 50,000 / 90 s | After a listener rebind, production TLS, HTTP/2, typed envelopes, and Arrow IPC carry a typed request, a relay payload and generation-qualified state frames |
| materialized state owner replacement | transport::materialized::materialized_branch_state_transfers_reconnect_to_the_replacement_owner | 51, 53 | 180 s / 200,000 / 120 s | Two branch placements describe and stream a 3 MiB Arrow payload at revision 1, then a partition after the first received frame interrupts another fetch. After repair and serving transport replacement, both branches transfer at revision 2. The client verifies exact length and digest at both revisions. This exercises the transport and typed checkpoint envelope; server installation, disk durability and whole-cluster recovery are covered separately. |
| multiple authenticated peers preserve remote failure classes | transport::multiple_peers_keep_remote_failure_classes_and_reject_invalid_authentication | 57 | 30 s / 50,000 / 90 s | One client resolves and exchanges typed Arrow responses with two peers, each peer’s rejected, unavailable, not-ready, and failed outcomes arrive as their own remote failure classes naming their subject, and a dial under a DNS identity the certificate does not name is rejected |
| partition before connect | transport::network_disruption_respects_deadlines_and_repairs_authenticated_service | 61 | 85 s / 100,000 / 90 s | Setup fails and liveness times out through twelve seconds of partition; repair restores authenticated service |
| one-way partition before connect | The same | 62 | 85 s / 100,000 / 90 s | The same, with only server-to-client messages dropped |
| held authenticated exchange | The same | 63 | 85 s / 100,000 / 90 s | A one-second liveness probe and a two-second request expire while held; release restores service |
| partitioned authenticated exchange | The same | 64 | 85 s / 100,000 / 90 s | Both deadlines expire; after repair, protocol pings replace the stalled sessions without a restart or pool reset |
| one-way loss after authenticated exchange | The same | 65 | 85 s / 100,000 / 90 s | Client-to-server packets are lost after authentication; repair restores service without a restart or pool reset |
| relay response lost after admission | transport::relay::relay_reconciliation_and_cancellation_survive_lost_replies | 71 | 60 s / 50,000 / 90 s | A retry to the same receiver process returns the admitted outcome and ACK without a second enqueue, and a late cancellation returns admitted |
| relay cancellation before grant | The same | 72 | 60 s / 50,000 / 90 s | A cancellation that reaches the receiver first fences the delivery |
| relay cancellation while reply is lost | The same | 73 | 60 s / 50,000 / 90 s | Cancellation wins the admission fence while the body reply is lost |
| relay cancellation during reconnect | The same | 74 | 60 s / 50,000 / 90 s | Cancellation wins the admission fence while the sender reconnects |
| receiver restart after relay body receipt | transport::relay::relay_restart_fences_unresolved_delivery_and_accepts_fresh_work | 81 | 90 s / 50,000 / 90 s | A new process epoch leaves the unresolved delivery indeterminate and admits fresh work |
| receiver restart after runtime admission | The same | 83 | 90 s / 50,000 / 90 s | The same after runtime admission |
| receiver restart with delayed relay response | The same | 85, 1036 | 90 s / 50,000 / 90 s | A released pre-crash reply confirms only historical receipt; admission stays indeterminate against the new epoch |
| sender replacement with a retained admitted intake | transport::relay::sender_retirement_reclaims_capacity_while_an_admitted_intake_is_retained | 95 | 30 s / 50,000 / 90 s | Removing the sender releases a one-item receiver’s capacity while its admitted intake remains borrowed; a recreated sender transport delivers three Arrow rows with a distinct full process identity |
| backup section partition and repair | transport::backup::backup_section_fetch_is_fenced_and_recovers_after_partition | 101, 1000 | 320 s / 400,000 / 90 s | A forged process identity cannot capture or consume a section; a stalled fetch reaches its deadline; a partitioned fetch fails and succeeds after repair; six 6 MiB materialized column sections, 36 MiB in all, stream in 64 KiB chunks and install as one checkpoint |
| window section partition and repair | transport::backup::window_sections_larger_than_the_bulk_budget_stream_across_a_partition | 103 | 320 s / 400,000 / 90 s | The same fences and faults for three window groups of input rows and argument columns, each section classified by its own kind; 36 MiB, beyond the 32 MiB bulk budget, streams in 64 KiB chunks and installs as one branched window checkpoint |
| backup owner restart during transfer | transport::backup::restart_during_backup_transfer_discards_the_process_stage | 102 | 320 s / 400,000 / 90 s | An incomplete section cannot finish after owner restart, and the replacement process has no prior capture stage |
| stalled peer isolation | transport::isolation::stalled_peer_cannot_consume_unrelated_capacity_or_leak_reservations | 91–93 | 120 s / 120,000 / 120 s | A stalled peer holds only its own connection’s slots, unrelated work progresses, and every reservation is released |
The simulation target also holds 26 runner and driver checks, such as the supervision, bound, clock, entropy, and trace checks and the fresh-process record and replay checks, and two ignored scenarios that only those checks start. The library-level checks use fixed seeds outside the driver and are rerun by name: the execution strategy’s queue, admission-order, and charge checks; the seeded rkyv round trips of seeds 1–12, alternating IPv4 and IPv6; and twelve certificate-clock checks, one of which compares the traces of two fresh processes.
Authenticated Exchange
The exchange cases prove that the simulated path is the production path. A server binds, shuts down,
and rebinds its listener in the same host, so listener cleanup is observable. The client then sends
a typed request carrying an Arrow batch and the same batch as a relay payload, and each side decodes
it with the production codec and checks the values without printing them. The three-host case adds a
second peer and a rejected dial whose DNS name the certificate does not carry, so authentication
failure is proven over the same connection path that succeeds.
The typed Arrow reply archives its native row count with the vocabulary’s fixed-width 64-bit
adapter, matching the product’s count representation.
The rejection assertions inspect the TransportError at the report’s current context; relay
timeout, cancellation, and indeterminate-delivery assertions do the same. This keeps the simulated
failure class independent of the report’s diagnostic text while production transport and wire
causes remain available underneath it.
The certificate-clock checks, beside the transport, prove that time-dependent authentication follows the simulated clock on both peers: not-yet-valid and expired boundaries of the local and the peer certificate, rejection by the client’s and by the server’s Rustls verifier under skew, the drain of an accepted session at the earlier expiry on both sides, and the setup deadline of a stalled handshake.
Link Faults
The two setup partitions stay in place for twelve seconds of simulated time, so bounded reconnect behavior is observable: setup failures are counted, liveness times out, and no connection appears. The established-link faults first complete an authenticated exchange, then expire a one-second liveness probe and a two-second typed request, and require at least three seconds of simulated time to have passed. Transport readiness and authenticated liveness are checked before each disruption; after each repair, the client reconnects, passes liveness, and completes another exchange. The symmetric and one-way partitions keep both processes and their listeners running; protocol ping deadlines close the unusable sessions on both ends so inbound slot limits do not prevent recovery. Snapshots bound open connections, pending requests, reconnect failures, and simulated sockets throughout, and the transport’s connection counts are checked after shutdown.
Relay Reconciliation And Cancellation
The relay cases drop the receiver-to-sender reply once an Arrow batch has entered the receiver’s queue, then reconnect to the same receiver process. The retained attempt reconciles to its admitted outcome and semantic ACK, and the application sees one enqueue. Cancellation competes with admission through the attempt’s one fence: before the grant, while the reply is lost, and while reconnecting, cancellation wins; after admission, it returns the admitted outcome. The checks hold within the receiver’s retention contract in Ordering, Retry, And Reconciliation. They say nothing about a receiver restart, which the next group covers.
Receiver Restart
The restart cases lose the reply at one of two milestones, after the Arrow body entered the receiver’s queue and after runtime admission, or hold an admitted attempt’s reply. At the milestone the receiver host publishes, the control closure crashes the receiver, repairs the one-way partition or releases the held reply, and bounces the host. The bounced host binds a new transport under the same node name with a distinct process epoch from its seeded entropy. The sender’s retained delivery resolves as indeterminate against that epoch and never enters the restarted receiver’s queue; a released pre-crash reply can confirm only that the old process received the body. A new delivery identity then crosses the rebound listener and is admitted. The fixture checks the Arrow batch and both process identities at the protocol boundary, and that the receiver crashed and restarted exactly once.
Backup Section Transfer
The backup cases send the production typed capture, inventory, and bulk fetch messages. A request that claims another process epoch is rejected before the capture or stream handler runs. The partition case bounds a stalled fetch by its request deadline, leaves the staged section available when the link is partitioned, repairs the link, and reads the verified section. The restart case ends an owner process after its first stream chunk; the incomplete stream fails, and the rebound owner has no stage from that process. These are transport and in-memory staging boundary checks; the Cucumber cluster scenarios verify the server’s archive and restore behavior.
Stalled-Peer Isolation
A hub and two peers run on three hosts. The stalled peer accepts shared management operations and never answers, leaves resource streams unread, and sends a relay batch the hub holds without admitting. The healthy peer keeps exchanging shared management work, liveness, typed Arrow commands, whole resource streams, and admitted relay batches with the hub throughout. Every observation of a host checks its pool connections per peer, leased streams per connection, admissions per direction and subquota, worker queues, and memory budgets against their configured bounds.
Key transitions assert exact values. The stalled peer holds all 32 shared management streams of its connection while liveness, cancellation, and discovery still use the reserved streams, and one more shared operation waits until its own deadline. A burst fills the one bulk worker and its eight-job queue, the two submissions beyond them are refused without a charge, and control work is admitted in the same pass. Unread streams stop at one HTTP/2 window each and release their admission and memory at the five-second progress deadline. The held relay reserves its exact body, largest decoded batch, and scratch, plus its retained grant metadata, until cancellation and release of the borrowed intake.
The fixture then holds the stalled link. The hub’s own liveness deadline ends its probe to the stalled peer while the healthy peer keeps exchanging every traffic class. Removing the stalled peer from membership ends the hub’s operations to it and closes the hub’s pools to it at once, without that peer’s cooperation. Tearing the stalled host down while the link is still held closes the connections the hub opened to it at once, together with the handlers still waiting to answer, and releasing the link closes the hub’s inbound connections from the torn-down peer. The Traffic And Resource Isolation contract is what these values check.
Simulated Restart And Real Process Crash
Sim::crash cancels the crashed host’s Tokio tasks and drops its runtime. Destructors run, and the
host’s connections close in the order its tasks are torn down, which Tokio’s task numbering sets.
Sim::bounce calls the host’s software again, which rebinds the listener and allocates a new
process epoch. The simulated hosts have no filesystem and no background thread of their own.
A simulated restart therefore establishes the transport’s volatile contract: a new process epoch fences in-memory delivery state, an unresolved delivery becomes indeterminate, and the transport never replays it into the new process. It establishes nothing about fsync, power loss, SIGKILL, partial writes, consensus or WASM checkpoint durability, or full-node recovery.
Those claims keep their real-process evidence.
tests/features/cluster/process_crash_recovery.feature starts a server process and kills it with
SIGKILL before checking recovery, and the Shutdown And Recovery, Consensus Storage
And Replication, and WASM State And
Recovery chapters own the durable contracts it checks. A Turmoil host bounce is
never cited as durability evidence.
Adding A Bounded Scenario
A new case follows the same contract as the existing ones.
- Name the claim. Write the interconnect contract the case checks and the fault that challenges it. A publicly observable outcome also needs its Cucumber scenario; the simulation adds fault timing, not public coverage.
- Place it with its owner. Add it to the fixture module whose contract it checks, or to a new
module declared from
transport.rswith its own ownership header. - Declare the scenario. Give it a name unique within its test, a fault plan that says which controls it applies and when, and committed seeds that no other case uses.
- Bound it. Start from the fixture’s configuration and set the smallest simulated duration, step limit, and real-time bound that complete the plan with room to spare. Keep the network parameters lossless, so the written plan is the only fault.
- Build each host inside its host. Bind a transport per host with
SimulatedEntropynamed after the host and aSimulatedUtcclock. Share only readiness and completion signals between hosts, run each host body underHostSupervisor::run, and have theobserverclient wait for every host to finish. - Stay inside the simulated boundary. Use
nervix_primitives::timefor every wait and instant andnervix_primitives::netfor every socket, and callnervix_primitives::task::consume_budget().awaitat the top of every loop. Never sleep a real thread, read the wall clock, open an operating-system socket, touch the filesystem, or submit storage or unbounded CPU work. - Inject faults at protocol points. Apply link controls from inside a host when the host reaches a decision, or apply host controls from the control closure when a host publishes a milestone.
- Record decisions. Record an event at every decision the case asserts, with identities and outcomes only, never payloads, key material, process identifiers, or map-ordered listings.
- Prove release. Shut every transport down before its host exits, assert its connection counts afterwards, and check every pool, queue, and reservation the plan touches against its bound.
- Register it. Add the test to its invocation in
tests/turmoil-inventory.toml, whichjust test-turmoilholds the suite to: a registered test that does not run, or runs ignored, fails the suite, and so does a simulation test that runs without being registered. - Run it. Iterate with
just test-turmoil-simulation <test> --exact, runjust test-turmoil-sweepto look for divergence across seeds, thenjust test-turmoilandjust validate. - Keep this chapter current in the same change: the scenario matrix, its bounds, and any new limit.
Findings And Retained Regressions
The simulation changed product code only to remove nondeterminism sources that reached a decision. Its other findings are recorded here and in their owning chapters as current behavior. The qualification run found no further product defect and no behavior-affecting trace difference.
| Finding | Resolution | Retained regression |
|---|---|---|
| Each certificate check read the process wall clock on its own: both Rustls verifiers, the transport’s local and peer checks, and the expiry drain deadline | Each TLS bundle carries one TransportClock, which every check reads | The certificate-clock checks in authentication::simulation_tests |
| The process epoch and relay grant identifiers came straight from operating-system randomness | TransportEntropy supplies them, from the operating system in production and from a seeded source in the simulation | The fresh-process trace comparison in the certificate-clock checks, and every committed seed |
| Walks over concurrent maps that cause effects followed per-process hash order | Those walks run in semantic key order | Every committed seed |
| Transport shutdown closes the connections peers opened, with the handlers serving them, at once rather than draining them | The behavior is stated in Cluster Interconnect and Shutdown And Recovery | The stalled-peer teardown |
A CPU queue refusal reaches a typed-request caller as RequestError::Encode when the first encode is refused, but as RequestError::Transport when an admitted request is refused at a later stage | The stalled-peer case asserts both shapes and counts every failure as exactly one queue refusal | The stalled-peer case |
| Two runs of one seed in one process diverged after a simulated crash, because teardown order follows Tokio’s process-wide task numbering | Every attempt and every replay runs in a fresh process | Seed 1036 of the receiver restart with a delayed relay response |
| A panic in a host task surfaced only as Tokio’s generic runtime-shutdown message | The runner reports the first panic of the scheduler thread, and supervises cleanup as its own phase | simulation_host_task_panic_reports_its_own_message, simulation_cleanup_panic_fails_a_completed_run |
Guarantees And Limits
The simulation guarantees, for the audited path:
- The production transport runs over Turmoil TCP and DNS only, and no scenario reaches the host network.
- Transport deadlines follow the simulated clock, certificate validity follows the supplied UTC clock, and neither falls back to the host clock.
- One scenario, seed, and set of inputs makes the same recorded decisions, at the same simulated times, in every fresh process of the same build on the same host configuration.
- Every failure leaves a record that replays the same inputs in a fresh process and reports whether the failure reproduced.
- Every run, attempt, suite, and sweep ends inside a real-time bound, including one whose host blocks the scheduler thread.
The audit behind those guarantees covered the boundary’s selection of sockets, timers, and CPU jobs and the simulated resolver, the transport identities and certificate clocks, request and relay deadlines, the map walks with side effects, the bounded CPU executor, and the runner. Its limits:
- The runner’s real thread and wall clock decide only when a test fails, never how a request proceeds.
- Execution storage jobs use a real blocking thread and are outside every case.
- Default worker counts follow the host’s CPU count, so a replay is established for the tested host configuration, not independently of host capacity.
- Rustls and AWS-LC keep their randomness, so traces compare authenticated decisions, never ciphertext.
- The TCP model neither retransmits nor models kernel timing, and Turmoil 0.7.2 has no disconnect control.
- A simulated host crash is not evidence of durability, as described above.
Prerequisites For Whole-Cluster Simulation
Running whole Nervix nodes under Turmoil needs more than substituting TCP. Each of these is an open prerequisite, not part of this simulation’s claim:
- Raft and gossip entropy. OpenRaft election timing and Chitchat gossip draw their own randomness. Both would need a seeded owner without changing production entropy.
- Storage and crash semantics. Fjall runs background threads and writes real files. A simulated store would need defined filesystem, fsync, and crash behavior, kept apart from the real-process durability evidence.
- Host attribution. Work a node spawns, including blocking tasks, timers, DNS queries, and sockets, must run on the host that owns it; code that escapes to another runtime or thread escapes the simulation.
- Domain time. Domain-clock authority, generations, and deadlines would need controlled time that preserves the Domain Clock contracts.
- Connectors. Every connector’s I/O, acknowledgement, and commit boundary would need to be classified as a simulated fixture or a real-process test.
- Host-independent capacities. Execution worker counts would need fixed values, so admission traces stop depending on the host’s CPU count.
Combining Shuttle’s scheduler with Turmoil’s network is a separate question. Shuttle owns the
interleaving of in-process tasks and Turmoil owns a runtime per host; the two scheduler modes are
mutually exclusive by build. Driving Shuttle over a simulated network would need a network substrate
integrated with Shuttle’s own scheduler, such as turmoil-net, and is not part of either suite.
Qualification Evidence
The simulation was qualified on 25 September 2026 at revision 7032bca2, which also contains the
final Shuttle qualification. The results below are from that run, except the CI row, which is from
the change that recorded it.
| Run | Result |
|---|---|
just test-turmoil | Passed in 1m31s including compilation: 14 execution tests, 12 interconnect library checks, and 34 simulation tests, with the ignored fresh-process probes started by their own checks |
just test-turmoil-sweep 1000 64 | Passed in 11m02s, inside its 25-minute budget: all 16 cases, each of 64 seeds twice in fresh processes, 2,048 attempts with identical outcomes and traces. The largest process used 131,060 KiB |
just test-turmoil-replay-check | The injected failure left one record for the partition-before-connect case, seed 61, and a fresh process reproduced its outcome and trace |
just test-shuttle | All 70 checks passed in ordinary mode and under the uncontrolled-nondeterminism detector, 140 invocations |
just test-interconnect, just test-execution | 44 and 11 tests passed in the normal build |
just validate, just ratchet | Passed, including the dependency and scheduler-conflict checks, with unchanged debt counts |
| Real authentication and transport, one and three nodes | internal_tls, interconnect_health, interconnect_lifetime, and interconnect_observability: 19 scenarios and 159 steps passed |
| Real-process crash recovery | process_crash_recovery: 2 scenarios and 57 steps passed with SIGKILL |
| CI | The turmoil job passed in 3m18s, the Shuttle stage in 9m03s, and the tests job in 53m03s |
No product defect and no behavior-affecting trace difference was found in the qualification. Seed 1036 remains a committed regression, and no failing seed was dropped.
The commands in this chapter were run again on d2cec5f2, the revision it documents, on a 32-thread
workstation. just test-turmoil passed in 1m01s with a warm build cache,
just test-turmoil-replay-check passed in 2 seconds, and just test-turmoil-sweep passed all 16
cases over its 64 seeds in 7m05s, leaving no failure record, with a largest process of 130,764 KiB.
The example investigation above was recorded and replayed on the same revision.
Routing the simulation’s sockets, timers and CPU jobs through the primitive boundary was requalified
on 29 September 2026 on a 24-thread workstation that other builds shared. just test-turmoil passed,
including the host isolation case and the boundary’s own simulated-host checks, and
just test-turmoil-replay-check reproduced its injected record. Every case of
just test-turmoil-sweep passed its default 64 seeds twice in fresh processes with identical traces,
leaving no failure record. Under that shared load the sweep ran past its 25-minute budget during its
last test, the receiver restart group, which then passed its 64 seeds alone in 2m30s.
Property Testing And Fuzzing
Bolero is Nervix’s default property-test engine for representation correctness. Each property is
an ordinary Rust test beside its production owner and a registered custom libFuzzer target. The
generator, production path and assertion are the same in both modes.
tests/bolero-targets.toml is the single inventory. Its IDs remain stable while a
generator-domain version advances when the current representable shape changes.
Property Contract
A lossless encode/decode or representation conversion asserts equality with the complete original
value over bounded, valid current inputs. Equality includes every significant field and order,
branch and domain identity, nullability, type and sensitivity. Where PartialEq is not
the contract, the property uses an explicit complete oracle. Float bit patterns and Arrow schema,
logical values and validity are examples; physical buffer layout need not match when it is not
public semantics. Canonicalizing, lossy and one-way transformations state and test their actual
contract. Malformed input has separate rejection and boundedness targets, so a round-trip
generator cannot pass by producing mostly invalid values. Reference models, operation sequences,
scalar/SIMD differential checks, encoded-size bounds and segmentation invariance are other useful
Bolero properties.
Generators stay in test code, construct bounded valid values, cover supported variants and deliberate boundaries, and never reintroduce historical shapes. They may not add test-only product APIs, row carriers, persistence for volatile state, or inner-layer parser dependencies. A Bolero property is independent of live services. Public behavior, concurrency and recovery retain their own Cucumber, Shuttle, Loom, Turmoil and external Chaos evidence.
Target And Representation Register
| ID | Package and invariant | Domain | Ordinary cases | Input limit |
|---|---|---|---|---|
client-emitter-wire | nervix-client-wire native emitter frame round-trip equality | all current request, reply, refusal and settlement variants with bounded exact schema, window, identity, branch and batch fields, v1 | 256 | 128 bytes |
client-requests | nervix-client-wire complete client request equality | every current request family, identities, positions, schemas and opaque I/O payloads, v1 | 128 | 2048 bytes |
client-replies | nervix-client-wire complete reply equality | every reply family, dispositions, ordered diagnostics and structured command metadata, v1 | 128 | 4096 bytes |
client-events | nervix-client-wire complete event equality and retained graph ownership | every unsolicited event, discovery, clocks and opaque graph text, v1 | 128 | 2048 bytes |
client-streams | nervix-client-wire complete stream equality and shared chunk ownership | all upload, download and restore roots and outcomes, v1 | 128 | 2048 bytes |
client-upload-archives | nervix-client-core complete directory tree and deterministic archive bytes against an independent tar decoder | empty directories, nested and GNU long paths, arbitrary file bytes and block boundaries, v1 | 128 | 4096 bytes |
client-reply-transfer | nervix-client-wire exact segmentation bytes, reply equality and rejected append isolation | single frames and ordered transfers, incorrect identities and duplicates, v1 | 128 | 256 bytes |
client-row-views | nervix-client-wire complete retained Row views | all scalar cells, nested vectors, fixed arrays, nulls, redaction, branches and float bits, v1 | 128 | 512 bytes |
client-arbitrary-frames | nervix-client-wire typed verification and decoding within budgets | arbitrary bytes at all eight frame roots, v1 | 256 | 16385 bytes |
client-frame-corruption | nervix-client-wire typed verification and decoding within budgets | current frame truncation, bit, offset, length and size mutations, v1 | 256 | 64 bytes |
client-discriminators | nervix-client-wire validate discriminants before accessing union members | all byte-valued request tags, v1 | 256 | 64 bytes |
client-arrow-rows | nervix-server Arrow logical values and validity against production Row views | scalar columns, nullable and nested lists, arrays, selected rows, concrete branches with every supported key field type, frame splitting and explicit redaction, v2 | 128 | 512 bytes |
client-arrow-selection | nervix-server whole-input typed rejection without partial output | invalid row bounds, order, duplicates and branch alignment, v2 | 128 | 512 bytes |
client-binding-rows | nervix-client-ffi complete column values and retained frame ownership | native scalar bits, states, string/bytes offsets, branch keys and nested cells, v1 | 128 | 512 bytes |
client-ffi-field-descriptors | nervix-client-ffi field descriptor ABI equality both ways: a host’s level-by-level description becomes the complete original field, and the field and level accessors read back its canonical description as the header’s nx_type values | every scalar inside 0 to 32 LIST and FIXED_LIST levels of 1 to INT32_MAX elements, either nullability and sensitivity, names from the whole name rule spelled in any letter case, v1 | 256 | 2048 bytes |
client-ffi-field-descriptors-malformed | nervix-client-ffi invalid-argument refusal that leaves the field list as the accepted calls built it | unknown types, lengths on scalar and LIST levels, empty and oversized FIXED_LIST levels, a 33rd list, refused names, elements without an open field, fields before the open one’s element, an open last field, and reads past a field’s last level or past the last field, v1 | 256 | 4096 bytes |
client-processor-choice-request | nervix-client-wire processor choice request round-trip equality | current targets with relay context, search, page and identity, v1 | 128 | 32 bytes |
nspl-expression | nervix-nspl expression render and reparse equality through a statement and the standalone expression, expression-list and route-construction readers | NSPL expressions of every form, v5 | 256 | 512 bytes |
nspl-model | nervix-nspl canonical CREATE render and client and server reparse equality | NSPL Models of every family, client emitters included, v7 | 256 | 2048 bytes |
nspl-archive-model | nervix-nspl complete ordered canonical Model equality through export, restore reads, reparse and re-export | cluster/domain archives with multiple ordered documents and every Model family, v8 | 128 | 4096 bytes |
nspl-statement | nervix-nspl canonical statement render and client and server reparse equality | NSPL statements of every form, session-only forms included, v5 | 256 | 2048 bytes |
nspl-statement-text | nervix-nspl edited statement text is rejected with located diagnostics or reads as canonical statements | edited canonical text, v5 | 256 | 2048 bytes |
nspl-expression-text | nervix-nspl edited expression text reads as the same expression, or as none, through a statement and the standalone expression, expression-list and route-construction readers | edited canonical expressions, v3 | 256 | 512 bytes |
nspl-format-document | nervix-nspl-format keeps statements and comments and is idempotent | documents with gaps, comments and either line ending, v5 | 256 | 4096 bytes |
nspl-format-text | nervix-nspl-format refuses unparseable text or formats it keeping its statements | edited documents, v5 | 256 | 4096 bytes |
models-names | nervix-models name text, conversion, JSON, archive and Model-name widening equality | every name type, v1 | 256 | 256 bytes |
models-name-validation | nervix-models name parsing matches the name rule; decoders accept only canonical text | arbitrary text, v1 | 256 | 256 bytes |
models-timestamps | nervix-models integer, RFC 3339, JSON, archive and chrono equality | every signed Unix nanosecond, v1 | 256 | 64 bytes |
models-timestamp-text | nervix-models RFC 3339 text reads as its reference instant or a typed error | generated RFC 3339 text, v1 | 256 | 64 bytes |
models-domain-clock | nervix-models period, skew and rate value and bit equality | every period, skew and positive finite rate, v1 | 256 | 64 bytes |
models-domain-clock-validation | nervix-models clock text and numbers read in range or fail typed; decoders refuse invalid values | arbitrary text and numbers, v1 | 256 | 64 bytes |
models-durations | nervix-models a duration reads back from the text humantime writes for it; only one within one of each unit of the longest duration may be refused as too long | every duration, v1 | 256 | 16 bytes |
models-duration-text | nervix-models duration text reads as humantime reads it or fails typed, and never panics | arbitrary duration text, v1 | 256 | 64 bytes |
models-json-paths | nervix-models path text, JSON and archive equality | paths up to the step limit, v1 | 256 | 1024 bytes |
models-json-path-validation | nervix-models path text reaches a fixed point or fails typed; decoders admit the constructor’s step counts | arbitrary path text, v1 | 256 | 512 bytes |
models-batch-limits | nervix-models message and size limit text, JSON and archive equality | every limit and unit, v1 | 256 | 64 bytes |
models-batch-limit-validation | nervix-models limit input reads in range or fails typed; decoders refuse out-of-range limits | arbitrary limits, v1 | 256 | 64 bytes |
models-identities | nervix-models execution reference, upload identity, endpoint and pool-bound equality | valid identities, v1 | 256 | 512 bytes |
models-identity-validation | nervix-models identity parsers and decoders accept exactly their rule | arbitrary identity text and bounds, v1 | 256 | 512 bytes |
models-archived-models | nervix-models Model and statement archive equality, resource-version widening and pinning | vocabulary Models and statements of every family and form, full-width counts included, v5 | 256 | 4096 bytes |
models-archived-counts | nervix-models count-bearing Model, statement, transaction identity and WASM inspection archive equality | zero where permitted, u32::MAX + 1, u64::MAX and generated native counts, v1 | 256 | 16 bytes |
consensus-archived-counts | nervix-consensus complete record equality through the production bounded storage codec | transaction commands, limits, positions, progress, failures, outcomes, plan and report headers with boundary and generated counts, v1 | 256 | 16 bytes |
registry-archived-models | nervix-server complete Model equality through the registry’s sealed storage codec | every vocabulary Model family with pinned resource versions and full-width counts, v3 | 256 | 4096 bytes |
runtime-window-archived-counts | nervix-server histogram delayed-removal archive equality | current removals with arbitrary expiry and boundary and generated bucket indices, v1 | 256 | 32 bytes |
backup-record-manifest | nervix-backup complete record and manifest encode/decode equality | all eleven current record kinds, lifecycle/catalog variants, every cut, full-width counters and ordered metadata, v4 | 256 | 4096 bytes |
backup-runtime-state-records | nervix-backup complete runtime state record equality | guest/materialized descriptors and identities, empty/multi-partition offsets and ordered branch lifecycle with bit-exact nested fields, v3 | 256 | 4096 bytes |
backup-materialized-identities | nervix-backup complete materialized descriptors and ordered scalar identity group equality | empty and one-to-four-group generations, unbranched and branched identities, nested typed fields, float bits and per-row watermarks, v2 | 256 | 2048 bytes |
backup-materialized-columns | nervix-server exact-schema Arrow/native generation fidelity and stored checkpoint re-export | nullable, sensitive, nested and byte columns; complete headers, ordered identities and original stored Arrow bytes, v2 | 128 | 4096 bytes |
backup-branch-state-descriptors | nervix-backup complete deduplicator and window descriptor equality | empty, unbranched and branched keyspaces and windows, model digests, row sequences and watermarks, group counts and delayed histogram removals, v1 | 256 | 4096 bytes |
backup-deduplicator-keys | nervix-server typed Arrow key group and restored keyspace fidelity, and the streamed checkpoint byte-identical to the runtime’s encoding of the same keys | every DEDUPLICATE ON key type with nulls, narrow integers, arbitrary float bit patterns, text, bytes, datetimes and list keys, repeated keys and bounded groups, v1 | 128 | 2048 bytes |
backup-window-groups | nervix-server window input and argument group and native checkpoint fidelity with equal aggregates | up to 192 admissions and interleaved steps over nullable integer, string and boolean arguments, concrete multi-type keys with bit-exact finite floats and fixed-offset datetimes, every exact aggregate family and a delayed histogram percentile, bounded groups sealed whole or split under generated identity limits, v3 | 64 | 1024 bytes |
backup-complete-archives | nervix-backup complete export/extraction, restore-read and deterministic re-export fidelity | cluster/domain scopes, resources included/omitted, every cut and supported state kind, empty/unbranched/multi-group materialized generations with valid Arrow columns, deduplicator key groups and window row and argument groups, synthetic raw payloads at tar block boundaries and long paths, v3 | 256 | 4096 bytes |
backup-malformed-records | nervix-backup safe typed validation of current record headers and values | all eleven kinds, truncated/altered headers, invalid current fields and bounded arbitrary bytes, v3 | 256 | 4096 bytes |
backup-corrupt-archives | nervix-backup rejection before verified restore contents are returned | changed lengths/digests/kinds, reordered/missing/extra sections, invalid records, materialized, deduplicator and window group metadata/membership, damaged Arrow sections, truncation and bounded arbitrary tar inputs, v3 | 256 | 4096 bytes |
console-pending-backups | nervix-web-console pending backup entry and tab storage round-trip equality, with forgetting only by its own reference | any execution reference and query text, no domain or one in any spelling the name rule admits, recorded over an earlier record, v1 | 256 | 1024 bytes |
console-pending-backups-malformed | nervix-web-console exact read-back or refusal of a tab’s stored record, which a refusal forgets while other entries stay | missing, canonical, edited, upper-cased and drawn reference, query and domain entries beside an unrelated entry, v1 | 256 | 1024 bytes |
console-restore-drafts | nervix-web-console restore draft lowering equality and existing-user option text | every RESTORE scope, source, mode, runtime state and lifecycle, domains in any spelling the name rule admits, every policy, v1 | 256 | 512 bytes |
backup-capture-wire | nervix-interconnect complete capture/drain/inventory request and response equality and fetch request equality through each request’s declared class limit | every drain action and counter, every success/refusal, empty and ordered inventories of all twelve kinds, zero/full-width lengths and counters, arbitrary digests and bounded Unicode paths, v1 | 256 | 1024 bytes |
backup-capture-wire-malformed | nervix-interconnect typed decode refusal or complete stable re-encoding, with management/bulk reservations released | truncated, bit-flipped, overwritten and arbitrary capture/drain/inventory request and response bytes and fetch request bytes, v1 | 256 | 1024 bytes |
backup-section-mapping | nervix-server total and injective capture-kind/archive-content mapping and distinct validated archive paths | all twelve section kinds over generated identities, group ordinals and branched/unbranched fingerprints, v1 | 256 | 128 bytes |
restore-installation-wire | nervix-interconnect complete installation request and success/refusal wire equality through the bulk class limit | all actions, authority, all eight runtime state kinds branched and unbranched, zero/full-width lengths, arbitrary digests, payload and inventory, v2 | 256 | 128 bytes |
restore-installation-storage | nervix-server complete staged checkpoint and publication record equality | authority, placement, revision, payload and inventory, v1 | 256 | 128 bytes |
restore-staging-kept-measurement | nervix-server the measurement of unpublished restore staging a store kept equals one read afresh from every staged entry, after every step | histories of up to ten steps over two domains, two entities and three generations: staging of up to two whole chunks and a partial one that completes, fails its digest or is cut short, retried staging, sweeps that retain or reclaim, publication and reopening, v1 | 96 | 96 bytes |
consensus-state-records | nervix-consensus complete recovered state equality through the state writer, a real keyspace and recovery, for a first revision and a second stored as changes | every record family with replaced and removed records, every transaction, outcome and command execution state, names across the whole name rule and boundary counts and times, v2 | 128 | 65536 bytes |
consensus-state-corruption | nervix-consensus typed InvalidState recovery failure or a canonical state its readers can rely on | flipped, truncated, extended, rekeyed, copied, unknown and missing records of a stored revision, drawn from bytes of their own, v2 | 256 | 65600 bytes |
consensus-raft-records | nervix-consensus complete Raft record equality through the storage codec, and keys that name their position or section | votes, log positions, entries of every command, state metadata, snapshot manifests and sections, v2 | 256 | 4096 bytes |
consensus-raft-records-malformed | nervix-consensus typed storage failure or a value that stores back to the same record | arbitrary bytes as every Raft record, log key and section key, v2 | 256 | 4096 bytes |
registry-stored-models | nervix-server complete Model equality through listing and keyed reads of a reopened registry, from its journal and from flushed tables | Models of every family in domains and with names across the whole name rule, v2 | 64 | 4096 bytes |
registry-stored-models-corruption | nervix-server typed registry storage failure or Models whose own keys are exactly the stored keys | flipped, truncated, extended, recased, extended-key, copied and unknown records, v2 | 128 | 4096 bytes |
runtime-state-keys | nervix-server exact decoded placement projection, and prefixes, index and chunk keys that reach no other placement | every state kind with schema, generation, Model kind and name and branch key, names across the whole name rule, v1 | 256 | 2048 bytes |
runtime-state-keys-malformed | nervix-server typed storage failure or a placement whose own key is the stored key | damaged and arbitrary keys, v1 | 256 | 2048 bytes |
runtime-state-chunk-keys-malformed | nervix-server typed chunk, storage-format or decoding failure, or a segment set whose placement key decodes canonically and whose chunk prefix and offset respell the key | damaged and arbitrary chunk keys, v1 | 256 | 2048 bytes |
runtime-state-staging-namespaces | nervix-server the domain each key names after its namespace, and a maintenance walk that visits every namespace once in key order without passing another’s key | placement, index and chunk keys of up to three domains in the initial namespace and restored generations of any value, v1 | 64 | 4096 bytes |
runtime-state-staging-namespaces-malformed | nervix-server typed storage-format failure at the first key whose namespace does not decode, or the namespace and domain the key’s leading bytes spell exactly | damaged and arbitrary keys, v1 | 128 | 2048 bytes |
runtime-state-identity-records | nervix-server complete handoff and forced-recovery record equality with branch values by archived bits, and key identity | transitions with checkpoints of every placement kind, v1 | 256 | 4096 bytes |
runtime-state-identity-records-malformed | nervix-server typed decode failure or a record that stores back unchanged | arbitrary bytes as every identity record, v1 | 256 | 4096 bytes |
runtime-state-snapshots | nervix-server every owner’s complete restored snapshot through its codec and the inline checkpoint envelope | Kafka offsets, branch lifecycles, deduplicator keyspaces with float bits and branch-aggregated metrics, v1 | 256 | 4096 bytes |
runtime-state-snapshots-malformed | nervix-server each kind’s typed decode failure or a value that stores back to the same payload | arbitrary bytes as every snapshot payload, v1 | 256 | 4096 bytes |
runtime-state-store-checkpoints | nervix-server byte equality at the stored revision through both readers and every reader of a backup cut’s listing, which lists exactly the domain’s checkpoints in key order and by kind with their stored sizes, before and after the database reopens | up to three placements of one domain stored inline, as chunked streams or in a published restore generation at, beside and between the chunk boundaries, beside an unpublished generation and another domain, v2 | 64 | 4096 bytes |
runtime-kafka-offset-captures | nervix-server every offset and topic schedule the replica’s cancellable reader restores from a capture’s native checkpoint, a written length within the capture’s reserved bound, and a check before every native entry | up to three topics of three partitions at any offset, and three topic schedules, v1 | 256 | 2048 bytes |
runtime-kafka-offset-transfer-headers | nervix-server exact revision, digest and payload from a checkpoint stream cut at every position and into generated chunks | advancing revisions beside the held and described ones, payloads of up to 128 bytes, v1 | 256 | 1024 bytes |
runtime-kafka-offset-transfer-headers-malformed | nervix-server typed length or revision refusal, or the revision and digest the header spells, with exactly the bytes behind the header forwarded at every split | arbitrary streams, half of them shorter than the header, with revisions beside the one they spell, v1 | 256 | 1024 bytes |
runtime-window-snapshots | nervix-server complete sealed window equality, and nothing for another branch lifetime | empty or up to 192 rows, concrete multi-type or absent branch keys, boundary sequences, nullable string arguments of zero or 2049 bytes and up to 96 delayed removals; generated 4–16 KiB column limits include IPC framing and split columns independently of the 1–32 KiB identity/typed limits, v2 | 128 | 2048 bytes |
runtime-window-snapshots-malformed | nervix-server typed window snapshot failure or a window that seals back to the same bytes | sealed windows damaged once and arbitrary bytes behind the magic, v1 | 256 | 4096 bytes |
runtime-window-sealed-checkpoints | nervix-server a window sealed in quota-owned pieces byte-identical to its resident encoding and restored completely | empty or up to 192 rows with bit-exact concrete branch keys or unbranched execution, boundary sequences, nullable string arguments of zero or 2049 bytes and up to 96 delayed removals under generated 4–16 KiB column and 1–32 KiB identity/typed limits, v2 | 64 | 2048 bytes |
runtime-arrow-bodies | nervix-server relay body and sealed snapshot section Arrow IPC equality: exact schema, values, nulls and float bits; several sections refused by the one-section decoders and concatenated by the schema-free one | every field type with nulls, nested lists, masked list elements, empty and sliced views and boundary values, v2 | 128 | 2048 bytes |
runtime-arrow-bodies-malformed | nervix-server typed refusal of a misframed or undecodable Arrow body, or a batch of the expected schema that encodes and decodes back to itself, without allocating from a declared length | relay bodies and sealed sections truncated, flipped, overwritten, shifted or trailed, and arbitrary bytes, v2 | 256 | 2048 bytes |
resource-store-layout | nervix-server complete manifest and archive read-back for every installed version | names across the whole name rule and those spelled like the store’s own directories, staging cleanup and removal of another version, v1 | 64 | 512 bytes |
client-producer-batches | nervix-server producer batch equality from the client library’s stream to the node’s batch, with exact row and byte limits | every field type with nulls, nested lists and sliced views, v1 | 128 | 2048 bytes |
client-producer-batches-malformed | nervix-server refusal with a batch defect or a batch within the ingestor’s schema and row limit | damaged producer streams and arbitrary bytes, v1 | 256 | 2048 bytes |
codec-schemaful-rows | nervix-server WIRE JSON, WIRE CBOR and WIRE AVRO row round-trip equality through one group builder | registry-accepted codecs over each format’s value domain, generic and exact wire types and shuffled wire field order, v1 | 256 | 4096 bytes |
codec-schemaful-payload-groups | nervix-server each payload decodes in a group exactly as alone; refusals are typed | codec payloads interleaved with damaged bytes, swapped values and changed keys, v1 | 256 | 4096 bytes |
codec-json-non-finite-floats | nervix-server the JSON projection of non-finite floats: optional top-level fields read back as nulls, required fields and list elements refuse the payload | WIRE JSON and WIRE CBOR codecs over every float bit pattern and field type, v1 | 256 | 4096 bytes |
codec-transforming-numbers | nervix-server number equality through the JAQ-native JSON, YAML, TOML and CBOR codecs and a protobuf codec with unchanged programs: F64 and F32 bits, alone and in lists, and I64 values; a protobuf singular zero reads back positive | generated batches of one schema of numbers over every finite float bit pattern, v1 | 256 | 1024 bytes |
jaq-native-numbers | nervix-jaq number equality through the identity program and each numeric native format’s writer and reader: float bits, integer value and signedness | finite floats of any bit pattern, boundary floats and 64-bit integers, TOML within its signed range, v1 | 256 | 256 bytes |
remote-relay-rows | nervix-server routed relay batch equality from the sender’s payload to the receiver’s rows, branch key bits and watermarks | every field type, concrete and absent branches and per-row registrations, v1 | 128 | 2048 bytes |
remote-relay-rows-malformed | nervix-server the first payload defect in the receiver’s check order | foreign bodies, miscounted watermarks and registrations and invalid branch keys, v1 | 128 | 2048 bytes |
relay-wire-messages | nervix-interconnect relay grant, reply, admission, acknowledgement and connection binding equality through the bounded codec | every message variant, branch key values with any float bits and per-row metadata, v1 | 256 | 2048 bytes |
relay-wire-messages-malformed | nervix-interconnect typed decode failure or a message that encodes and decodes back to itself, with every allocation of the decoder freed | damaged encodings of every message and arbitrary bytes, v1 | 256 | 2048 bytes |
client-producer-link-frames | nervix-server complete equality of every client producer link frame through the bounded duplex codec of its stream, with nothing left charged | the opening message, every serving-node frame and every owning-node answer, with schemas of any field type, limits and counts at their ends, opaque batch bytes, every refusal, outcome, admission state and end reason, v1 | 256 | 2048 bytes |
client-producer-link-frames-malformed | nervix-server typed decode failure, never an admission or execution refusal, or a frame that encodes and decodes back to itself | damaged encodings of every link frame, frames read as another direction’s type and arbitrary bytes, v1 | 256 | 2048 bytes |
client-consumer-stream-frames | nervix-server complete equality of every client consumer stream frame through the bounded duplex codec of its stream, with nothing left charged | the opening message, every serving-node frame and every owning-node answer, with schemas of any field type, policy durations from zero to the longest, every answer, refusal and settlement outcome, delivery headers across their ranges with and without a branch fingerprint, and chunks from empty to the largest one sent, v1 | 256 | 2048 bytes |
client-consumer-stream-frames-malformed | nervix-server typed decode failure, never an admission or execution refusal, or a frame that encodes and decodes back to itself | damaged encodings of every stream frame, frames read as another direction’s type and arbitrary bytes, v1 | 256 | 2048 bytes |
client-emitter-deliveries | nervix-server client emitter delivery equality from the sink’s Arrow IPC stream to the client library’s batch: the Arrow schema of exactly the declared fields, every delivered row in order, values, nulls and float bits; refusal under another row count or other fields | every field type with nulls, nested lists and sliced views, deliveries of adjacent and scattered rows, v1 | 128 | 2048 bytes |
wasm-generated-pools | nervix-server generated column pool equality from a WASM guest’s Arrow IPC stream to the node’s batch: unnamed fields, types, nullability, values, nulls and float bits | every field type with nulls, nested lists and sliced views, streams ended by the marker or closed, v1 | 128 | 2048 bytes |
wasm-generated-pools-malformed | nervix-server a typed generated-IPC or batch-count defect, or one batch of unnamed columns that writes and decodes back to itself | truncated, flipped, overwritten, shifted, trailing and arbitrary pools, v1 | 256 | 2048 bytes |
simd-checked-lanes | nervix-simd-kernels checked integer arithmetic equals scalar overflowing arithmetic at every SIMD level | all integer widths, runs and shared operands, v1 | 256 | 128 bytes |
simd-constant-division | nervix-simd-kernels constant quotients and remainders equal checked operations at every SIMD level | all integer widths, divisors, scalar fallback and tails, with full-range signed overflow, v1 | 256 | 128 bytes |
deadlock-evidence | nervix-deadlock complete evidence encode/decode equality | every finding and review variant, complete shared-reader cycles, cycles up to the thread bound with omitted threads, texts cut inside multi-byte characters, absent context and extreme identities and times, v4 | 256 | 4096 bytes |
deadlock-evidence-malformed | nervix-deadlock arbitrary bytes, bare or behind a valid evidence header, fail typed or decode within every bound to evidence that encodes back to itself | arbitrary bytes, v1 | 256 | 1024 bytes |
deadlock-evidence-bounds | nervix-deadlock a valid encoding that breaks one bound is refused as out of bounds | one broken bound of findings, cycle threads, omitted threads, identities, lost findings or kept texts, v1 | 256 | 1024 bytes |
deadlock-evidence-summary | nervix-deadlock the summary a qualification prints counts each finding once by kind, keeps repeated deliveries apart from lost findings, agrees with qualification, and names every count once | every finding variant in every scope, reviewed and unreviewed cycles, extreme repetition and loss counts, v3 | 256 | 4096 bytes |
branch-membership | nervix-branch-instances owner steps against the specified visible-set contract: each claim, admission, eviction, expiry and release publishes exactly the current owner lifetime’s membership, and a step that changes none publishes nothing | bounded claim, admit, expire and release sequences over six branch keys, v1 | 256 | 256 bytes |
task-status-transitions | nervix-server task status publishes the complete healthy or failed status, preserving retry on error-only changes | 64 transition bytes over current status and retry values, v1 | 256 | 64 bytes |
entity-freeze-transitions | nervix-server freeze publications contain exactly the coordination owners whose holds remain active | 64 insert/remove bytes over eight coordination identities, v1 | 256 | 64 bytes |
replica-progress | nervix-checkpoint-replication replica reports, offers and announcer steps against the monotonic quorum contract: each replica’s progress is the highest revision it reported, a count of replicas holding a revision never falls, and an offered revision keeps exactly one announcer until every assigned replica holds it or the replicated state is gone | bounded report, offer, step, cancel and retire sequences over four replicas and sixteen revisions, v1 | 256 | 256 bytes |
replica-catch-up | nervix-server an owner’s catalog of branch checkpoints and a replica’s catch-up rounds against convergence: whatever catalog reads and branch fetches fail and however announcements are lost, repeated or reordered, the replica holds every branch its lifecycle names at the owner’s revision once reads and fetches succeed, never records holding what its storage does not, and every listing reaches it through its wire form and encoding unchanged | bounded start, publish, stop, catalog replacement, naming, eviction, lifecycle, announcement and round sequences over four branches, unbranched work included, v1 | 256 | 256 bytes |
typed-report | nervix-lint-report complete compiler-report serialization equality | empty and multi-item findings, arguments and audit candidates; all current audit patterns, escaped Unicode text, full-width spans and counts, expansions and completion metadata, v3 | 256 | 32 bytes |
typed-source-contract | nervix-lint-report complete source-contract parsing equality | bounded current execution-contract annotations, v1 | 256 | 32 bytes |
typed-site-union | nervix-lint-report authored-site deduplication preserves every configuration | repeated ordinary/modeled findings over bounded current sites, with complete current reports, v3 | 256 | 32 bytes |
typed-source-syntax | nervix-lint-report exact cast/field counts and complete source/site representation equality | bounded inactive Rust casts, testing-gated tuple fields, raw identifiers and Unicode comments with exact locations, v1 | 256 | 32 bytes |
expiry-map-operations | nervix-expiry-map operation sequences against an IndexMap reference: iteration order, lengths, oldest entry, lookups, shared key allocations and one drop per value | up to 480 weighted insert, shared insert, keyed and oldest removal, lookup, shared-key copy and clear steps over 96 keys, v1 | 256 | 1024 bytes |
vm-decimal-rounding-floats | nervix-vm round(value, digits) over F64 and F32 columns against the exact decimal expansion, the identity on values holding no more digits, symmetry about zero and lane null and failure classification | any bit pattern, values rounded within their own digits, decimal-looking values and exact binary ties, counts near, within and beyond the bound, v1 | 256 | 1024 bytes |
vm-decimal-rounding-integers | nervix-vm integer rounding against a 128-bit reference, overflow failures, lane nulls and digit columns of every integer type clamped to the bound | every integer width, values anywhere and beside both limits, counts near, within and beyond the bound, v1 | 256 | 1024 bytes |
approx-into-rounding | nervix-approx-into integer-to-float and F64-to-F32 rounding against a bit-level IEEE 754 reference, and float-to-integer truncation against an exact reference | 128-bit patterns read at every width, integers and floats beside powers of two, ties and the integer limits, any F64 and F32, v1 | 256 | 64 bytes |
approx-into-exact | nervix-approx-into lossless round trips on the exact domain | integers whose significant bits fit each float’s significand, every non-NaN F32 through F64, v1 | 256 | 32 bytes |
simd-json-escapes | nervix-simd-kernels JSON escape block masks and row bits at every level against the scalar class, and the first offset defect | sparse and dense buffers up to 1,100 bytes, ascending offsets not starting at zero, empty, negative, out-of-buffer and descending offsets, v1 | 256 | 4096 bytes |
simd-paced-admission | nervix-simd-kernels packed admission bits at every level against the scalar definition | reached centers anywhere and at the timeline’s ends, every period reduction, skews to beyond half a period, timestamps beside edge and interior centers, v1 | 256 | 2048 bytes |
simd-elapsed-histograms | nervix-simd-kernels layout refusals against an integer reference, and latest instant, totals and buckets at every level against HdrHistogram | units from a nanosecond to any 32-bit count, highest units to 2^31, up to six figures, instants before, beside half units and after the reference, v1 | 256 | 4096 bytes |
simd-flag-packing | nervix-simd-kernels packed flag words at every level against scalar shifts, appended to held words | runs of up to 600 flag bytes of any density, v1 | 256 | 2048 bytes |
simd-window-runs | nervix-simd-kernels validity, boolean, sum, extreme, non-finite and reverse folds against scalar folds at every level; compensated sums exact for integers and within their bound of an exact sum for floats | up to 300 values read at every width and as floats, unaligned validity and boolean bitmaps, v1 | 256 | 4096 bytes |
simd-window-moments | nervix-simd-kernels means, M2 and paired cross moments against exact addition/product references, and exact histogram placement | up to 128 finite paired values, independent sliced null masks, constant overflow-scale runs, edges and clamps, v1 | 256 | 4096 bytes |
columnar-json-rows | nervix-columnar-json written rows against an independent rendering of their logical values and the first refused null | every supported scalar, list, fixed-size and nested list column, sliced, every null policy and number and bytes encoding, v1 | 256 | 8192 bytes |
bounded-write-limits | nervix-bounded-write whole writes within the limit, capacity within it, and the outcome classification against a reference of the rule | writes, encoder failures and writing on after a refusal under limits of 1 to 1,021 bytes, v1 | 256 | 1024 bytes |
connector-row-request-division | nervix-connector row request division against the documented halving rule, both limits and the measurement bound | declared and native row and byte limits, up to 160 rows, measures that grow and shrink with a candidate, v1 | 256 | 1024 bytes |
connector-confirmation-window | nervix-connector confirmation window outcomes, end instant and in-flight bound against a reference model of the documented window rule, so no record confirmed before the publish ends is left unresolved | MAX one to six, ACK TIMEOUT one to 40 ms, up to 24 records refused at hand-over or answered confirmed, refused or failed within, at, past or never by their deadlines, pauses between hand-overs and a driver failure, on a paused clock, v1 | 256 | 512 bytes |
emitter-batch-subdivision | nervix-server batch candidates against the documented rule, message limit, compatibility and encoding bound | up to 96 members in three compatibility classes, failing containers, encodings that grow and shrink, v1 | 256 | 1024 bytes |
emitter-batch-packing | nervix-server packed payloads and rejected rows against the documented packing of exact member encodings and container framing | generated schemaful codecs and rows in carriers across two relays, three branches, three envelopes and seals, MAX SIZE from below one member’s container to above the whole run’s, v1 | 256 | 4096 bytes |
codec-bounded-encodings | nervix-server bounded row, member and container encodings against the unbounded bytes, and container framing | generated schemaful codecs and batches under limits short of, at, past and around each encoding, and containers of 24, 64 and 256 repeated members where a count’s encoding grows, v1 | 256 | 4096 bytes |
emitter-row-preparations | nervix-server row request preparations kept unchanged or refused with the first violation | preparations of up to 12 selected rows spoiled by repeated, swapped, dropped, foreign and refused members and empty requests, v1 | 256 | 256 bytes |
emitter-retained-payloads | nervix-server retained payloads written unchanged in packing order and answered member by member | packing and write steps with delivered, rejected and unanswered records and failed writes over three buffered batches, v1 | 256 | 512 bytes |
sqs-batch-requests | nervix-connector-sqs measured record sizes, refusals above 256 KiB, and batch requests within every limit, ended only by a limit | small and near-limit bodies of one- to four-byte characters, attributes and FIFO groups, v1 | 256 | 1024 bytes |
mqtt-publish-packets | nervix-connector-mqtt measured PUBLISH sizes against the packet the client writes, the protocol’s largest packet and the broker maximum | topics up to 128 bytes, every quality of service, payloads beside every Remaining Length width and the protocol limit, v1 | 256 | 512 bytes |
postgres-insert-measures | nervix-connector-postgres measured inserts against the statement and the arrays the driver encodes, and the Bind message bound | up to four text columns of up to eight rows with NULLs, quotes and multi-byte characters, every run of rows, v1 | 256 | 1024 bytes |
websocket-signaling-protobuf-values | nervix-connector-websockets complete signaling JSON through the production protobuf conversions, with double and float bit equality | explicit scalar presence, absent defaults, full-width integers, booleans, UTF-8 strings and up to eight repeated doubles and floats, v1 | 256 | 256 bytes |
websocket-signaling-protobuf-malformed | nervix-connector-websockets typed refusal through the production protobuf decoder | invalid tags, truncated fixed-width fields and strings, oversized varints and incomplete packed floats, v1 | 256 | 256 bytes |
otel-hex-ids | nervix-connector-otel complete identifier bytes and acceptance against a per-digit scalar reference, with exact refusal key and reason | nonzero trace, span and parent-span identifiers in all letter cases, arbitrary UTF-8 text, odd lengths, trailing spaces and all-zero identifiers, v1 | 256 | 128 bytes |
Each target declares its Cargo manifest. Product targets use the root workspace; the synchronization
properties use tools/nervix-lint/Cargo.toml. The shared runner discovers declared tooling workspaces
from root metadata and uses the selected manifest in ordinary, sanitizer, replay and qualification
commands. Compiler executions are fixture checks, not work done in each fuzz iteration.
A target names the test executable that holds its property: its package’s library unit tests,
lib, the unit tests of one of its binaries, bin:<name>, or one integration test,
test:<name>. The web console’s backup dialog belongs to the console’s browser binary, whose unit
tests compile natively, so its properties are bin:nervix-web-console targets; Bolero and the
generators stay development dependencies outside the browser’s graph.
Before instrumenting a target, the runner reads Cargo metadata to resolve its package, target kind,
name and source root. It selects the emitted executable by that identity, including integration
tests in folders, a binary’s unit tests beside a library of the same name, and explicitly
configured library or test paths. A missing or ambiguous Cargo target or executable fails the run
before the campaign starts.
The CI sanitizer job budgets cold ordinary and instrumented builds separately from each target’s bounded campaign. Its overall limit is three hours; per-build, per-case and per-campaign deadlines remain enforced by the shared runner. Validation uses an 8-CPU runner and a two-hour limit to complete Clippy and the declared compiler synchronization matrix from a cold cache. Bolero inventory and compiled discovery run through the dedicated Bolero commands and workflow. The native extra-checks job retains its 8-CPU allocation and the same two-hour limit for ordinary instrumentation and per-mode primitive conformance; the Shuttle and Loom suites run in jobs of their own.
The inventory also records exact full test names, required features, corpus paths, case timeouts
and each invariant. Its corpus path is Bolero’s source-adjacent
__fuzz__/<test-name>/corpus directory. Ordinary tests replay those files before
randomized cases; checked-in corpus inputs are regression cases. Each target starts with
current-domain boundary seeds. Generated fuzz corpus and crash files live in retained artifacts
under target/bolero/runs. Promote a verified minimized failure to the checked-in corpus
when it remains a meaningful regression for the current domain. Breaking shape changes replace
obsolete seeds.
The storage representation coverage map
ties the consensus, registry, runtime-state and resource store targets to the stored shapes they
cover, their complete oracles, the projections their keys make and their corruption boundaries.
A filter selects every target whose ID or test name contains it, so just test-bolero runtime-state
runs the runtime-state store targets and just test-bolero storage:: the consensus and registry
storage targets.
The client representation coverage map ties the client targets to current schema families, complete oracles, ownership and malformed-input boundaries. Request, reply and event coverage checks compare generated families with the schema’s union declarations, so adding a family requires extending its generator.
The Arrow, codec and interconnect representation coverage map
ties the relay body, producer batch, client emitter delivery, WASM generated pool, schemaful codec,
JAQ-native and protobuf codec number, routed relay payload, relay wire message, client producer
link frame and client consumer stream frame targets to their oracles, each codec’s value domain and
the contract boundaries they record. Its server targets share one generator of schemas and batches
over every current field type and one logical oracle that compares batches cell by cell with floats
by their bits. just test-bolero codec- runs the four codec targets, just test-bolero jaq-native
the numbers a JAQ program and its native formats carry, and just test-bolero client-producer-link
and just test-bolero client-consumer-stream the frames a forwarded client producer and consumer
travel in between nodes.
Generated Domains
The WASM representation coverage map
registers protocol messages, native host conversions, SDK Arrow and snapshot conversions, archive
state descriptors and stored checkpoints. just test-bolero wasm selects their seventeen targets;
just test-wasm also builds and exercises the supported Rust and Go reference guests. The malformed
targets separately cover header lengths, offsets, vector counts, tags and invalid descriptor fields.
Shared guest-message generators live only in the nervix-arbitrary test harness; guests and product
libraries acquire no generator dependency. These properties preserve values and retain the existing
owners of callback, ACK, checkpoint and recovery semantics.
nervix-arbitrary supplies bounded vocabulary values and entropy; properties compose these into
their owning frame or Arrow representations in test code. It reads a property’s bytes
as a sequence of bounded choices, so the same bytes always build the same value and a saved failure
replays exactly. It is a harness outside the layer order: it depends only on the vocabulary, and
states what NSPL can spell as a rule over values instead of calling the parser, so the vocabulary’s
own properties use it without a language dependency. No product code names it.
For native client producers and consumers it draws the window a client asks for and is granted, with counts at one, at the bound the vocabulary declares and at the largest count their type holds, a producer’s whole description, every outcome of a submitted batch with each cause it names, and physical durations that land on zero, one nanosecond and the longest duration as often as anywhere else. It also lists one field of every declared type, each scalar, a list of it and a fixed-length array of such lists, across the four combinations of optional and sensitive, for a property that must carry every type on every run.
A generator draws from one of two domains:
- NSPL holds the values canonical NSPL spells and reads back as themselves. The language properties draw from it.
- Vocabulary holds every value the vocabulary types hold, including states NSPL has no
spelling for: negative, signed-zero and non-finite numeric literals, empty arrays, a
CASEwithout aWHEN, casts to collection types, and the statement and Model states listed under Uncovered Boundaries. Stored and archived forms carry these states, so the archive properties draw from it.
Both domains reach every Model family, every statement form, and every expression form: integer,
float, boolean, string and null literals with their range ends, arrays, field references in every
scope, every unary and binary operator nested to a bounded depth, casts and TRY_CAST, IF, CASE
with and without an operand, IN and BETWEEN with their negations, the JSON value and existence
forms, and calls to built-in functions and UDFs. Every form is written in every region a statement
embeds an expression, including a call to a built-in named like a keyword that begins the next
clause, such as max, right or replace, and a field in the output or right scope. Routes
are generated in every shape a node family allows: transforming and set-only construction,
INHERIT, ordered SET, FLUSH EACH and FLUSH IMMEDIATE, and branches declared per route or
node-wide. A name of any kind may spell any NSPL keyword, statement and expression keywords alike,
and a field, UDF or function name inside an expression or a route construction may also hold a -,
~ or ., or begin with a digit, which canonical NSPL writes between backticks. Names reach both
length limits, and counts reach the largest value their field holds.
The language properties also sweep sixteen deterministic byte sequences through every Model
family, every emitter sink and every statement form on every ordinary run, so none is left to the
random cases.
The NSPL Postgres generator keeps DO UPDATE targets nonempty and leaves at least one VALUES
column outside the conflict target, as the grammar requires. The vocabulary domain also generates
updates whose target includes every mapped column, so archive equality still covers those values.
Relay capacities in both domains reach u64::MAX on native 64-bit targets. Archived native counts
use the vocabulary’s fixed-width count representation.
Dedicated count properties assert complete current-record equality at zero where permitted,
u32::MAX + 1, u64::MAX, and generated values. The public scenario Relay capacities keep every
bit through replication and a full cluster restart checks creation and alteration at both positive
boundaries in one-node and three-node clusters, including a follower read and full restart.
Rejection targets start from valid canonical text and edit up to three characters, inserting, deleting or replacing delimiters, quotes, comment markers, line endings, digits and non-ASCII characters. The result is mostly no longer NSPL and sometimes still is, so both the rejection and the acceptance paths are exercised.
The expression-text target edits a canonical expression up to four times, deleting a character or
inserting a piece or putting one in a character’s place. The pieces are whitespace and line
comments, a . with and without spaces beside it, exponent letters, digits and a minus, statement
punctuation an expression never reads, and keywords only an expression reserves beside ones it reads
as names. It asserts that every entry point agrees on the result: a subscription’s WHERE, the
standalone expression reader, a one-element expression list and a route construction’s WHERE read
it as the same expression, or none of them reads it as one.
Uncovered Boundaries
The generated domains exclude states whose representation is not lossless. Each exclusion is a recorded boundary, not a claim:
- Canonical NSPL writes a negative literal,
-0.0included, as-applied to its magnitude, and reads it back that way. NaN and the infinities have no spelling, and rendering them fails with a typed error. The NSPL domain therefore holds only non-negative finite literals. - Outside an expression or a route construction, a generated name is one lower-case ASCII
identifier, because canonical NSPL writes a name holding a
-,~or., or beginning with a digit, between backticks only inside one. The name properties cover the whole name rule on each name type directly. In the NSPL domain a relay is never namedmessageorbranch, and a placement member is never named like anALTERoperation keyword, because NSPL reads those words there as its own. DROPhas no form for branches, generators, hash maps, signaling protocols, WASM processors or window processors, an HTTP emitter has noBATCHclause, a correlator’s filter has no spelling, and a PostgresON CONFLICT DO UPDATEwhose target names every mapped column is rejected, because it leaves no column to update. Only the vocabulary domain generates those states, for the archive properties.- A time rate’s text, JSON and archived forms keep every bit. The workspace builds
serde_jsonwithfloat_roundtrip, which reads a decimal as the float nearest to it; its default reader is best-effort and lands one unit in the last place away for a tenth to a quarter of the decimals that computed floats print as.
Behavioral Properties
Registered properties also hold behavioral contracts against independent references:
- Operation sequences.
expiry-map-operationsdrives the expiry map with weighted operation sequences against anIndexMapmodel and a ledger that counts every stored value’s drops. Miri runs the same property throughjust test-expiry-map-miri, which lifts Miri’s isolation so Bolero reads its checked-in corpus, with four random cases of at most 512 input bytes; every step still runs and checks its own answer, and the complete state is compared every sixteenth step and at the end. Mutation testing runs the property in every mutant throughjust test-expiry-map-mutants.connector-confirmation-windowpublishes generated hand-overs, refusals, pauses and scripted broker answers through the brokered sink confirmation window on a paused clock, and compares the instant the publish ends, the answers the window holds after each hand-over, the records it settles and the failure that ends it with a reference model of the documented window rule computed in whole milliseconds. - Numeric references. The decimal rounding kernels are compared with the exact decimal
expansion of each value and with a 128-bit integer reference. That a value holding no more
fractional digits than the count is its own rounding is checked apart from the rounded results.
approx-into-exactholds the lossless round trips, andapprox-into-roundingcompares every rounding and truncating conversion with a bit-level IEEE 754 reference: round to nearest, ties to even, and an infinity past the largest finite value, sou128::MAXnarrows to anF32infinity. - Kernel differentials. SIMD targets compare every level the host supports, the baseline and the forced scalar fallback with a complete scalar oracle, tails, offsets and unused bits included. A compensated float sum is checked against an exact sum of Shewchuk partials within the bound of compensated summation, because the window contract promises no bit equality across run partitions.
- JSON rendering.
columnar-json-rowsrenders generated logical values on its own: strings throughserde_json, bytes through a reference base64 encoder or octet escaping, and instants as RFC 3339. A float must be a JSON number of at most its type’s significant digits that reads back to its exact bits; its exponent’s spelling is the writer’s, which writes1e172whereserde_jsonwrites1e+172. - Encoded sizes. The bounded writer, codec row and container encodings, emitter subdivision and packing, row request division, row preparations, retained retry payloads, SQS records and batch requests, MQTT PUBLISH packets and Postgres inserts are checked against the final bytes, framing, headers and escaping included where the size contract includes them. Generated measures grow and shrink with a candidate, so no property assumes that a larger candidate encodes larger.
Lifecycle, acknowledgement and cancellation races keep their production-owner Shuttle checks. Kafka, NATS and Pulsar enforce their size limits inside their client libraries, and the MySQL, MongoDB, ClickHouse and OTEL measures share the division rule while their own measurements keep example tests.
Commands And Enforcement
Server properties embed the real web console assets. The Bolero recipes build them before
discovery, requiring Trunk and the repository Rust toolchain’s wasm32-unknown-unknown target.
Both CI modes install those prerequisites.
Asset builds stage Trunk’s complete output and preserve byte-identical published files, including
their timestamps, so preparing assets again keeps the native server’s compiled artifacts reusable.
On a shared host, a cold sanitizer build can exceed the runner’s build deadline.
just prepare-bolero <id> prepares one exact registered target through the same instrumented
build path that runs its campaigns. CARGO_BUILD_JOBS=1 just prepare-archive-counts-fuzz uses that
command for the declared server library shared by the registry and window count properties.
Then run the registered campaigns with just fuzz. Preparation records its own build log and
metadata with a 7,200-second compilation deadline; it does not count as a completed fuzz campaign.
An expected sanitizer skip contributes no execution or coverage evidence. The Check workflow’s
validation job and the Bolero randomized job retain their separate scopes and limits. Check calls
the reusable Bolero workflow with its exact tested SHA, event and label snapshot. Scheduled and
manual Bolero runs execute randomized checks and record a deliberate sanitizer skip.
Documentation-only Selection
Before starting ordinary suites, Check’s test-selection job proves which source it selects for.
Its checkout must be the commit the event tests: a merge whose first parent is its base and whose
second parent is the event’s exact head. The event and the tested merge need not name the same base.
GitHub records a pull request’s base as its base branch’s tip when the pull request opens, and moves
it forward only to the newest base commit that a later head merges, while it builds the tested merge
on the base branch’s tip whenever the head changes. A push made after the base branch advanced,
whose head does not merge the newest base, therefore tests a merge whose base is newer than the one
its event names, and label events that follow name the same recorded base.
Selection accepts the tested base when it is the event’s base, or when the event’s base is one of its ancestors. The checkout holds only the tested merge and its parents, so to decide the second case selection fetches the tested base’s complete history as commits, without trees or file contents, and then answers from that local history alone. When the tested base is the event’s own base, it fetches nothing. A checkout that is not the tested merge, a tested commit that does not merge the event’s exact head into one base, an event base that the tested base does not descend from, such as a newer base or one from another branch, and a failed history fetch all fail selection, so none of them can authorize an exclusion.
Selection then reads every changed path between the tested base and the tested merge: what the
merge changes on the base it was tested against, never the base branch’s own advance. Rename
detection is disabled so deleting or moving product code into a prose location still requires
execution. Only AGENTS.md, README.md, Markdown under docs/, and Markdown under
.agents/skills/ qualify for the documentation-only exclusion. An empty diff, code, tests, build
configuration, workflow definitions, scripts and unknown paths require suites; no-docker supplies
no evidence about this choice.
A verified documentation-only selection excludes the ordinary tests, scenarios, extra tests,
diagnostic evidence and their coverage aggregation. It excludes the entire reusable Bolero call
unless the event carries fuzz. Validation and the conventional PR title check remain independent
and mandatory. Label-selected mode checks remain selected by their labels; fuzz still requires
both ordinary properties and the complete sanitizer inventory even for documentation. Label
addition and removal compute a fresh selection from that event’s label snapshot.
The ci-test-selection artifact records run, attempt, job, event and action, the tested merge, the
event’s base, the tested base and the head, every changed path, labels and each ordinary/Bolero
requirement. Two different bases record an advance that selection proved. Its selected verdict
records a source decision, never successful property execution or coverage. Actual job conclusions
remain in the workflow run. The reusable workflow’s bolero-selection record is produced only
after all of its required execution succeeds; failed, cancelled, skipped or missing required
producers still fail its gate.
A rerun of test-selection reads the original event and tested merge and proves the same ancestry,
so it reaches the same selection after the base branch advances again. A validation-only rerun uses
the original event and tested commit. Its ordinary producer selection remains in the caller, outside
the reusable workflow’s always() gate, so deliberate non-code exclusion never becomes a cancelled
property campaign to excuse. Historical attempts retain their actual cancellations; this policy does
not turn an interrupted required campaign into success. just test-ci-selection exercises source
identity, advanced, stale and tampered bases, stale heads, other merges, shallow checkouts, mixed
changes, renames, label events, reruns and strict gates; just coverage-ci-selection measures that
Python contract without launching product campaigns. Selection does not claim a model-checker or
public Cucumber result.
Sanitizer Shards
A single job running every sanitizer campaign outgrew its limit as the inventory grew. With all 159 targets it took 209 to 258 minutes: discovery took 27 of them, the two server sanitizer builds with one Cargo build job 42, and the campaigns 132. CI therefore divides the inventory into shards that run in parallel, each in a job of its own:
- A harness, one package’s test target with one feature set, is never split, so a shard builds
only the harnesses it runs. The inventory’s
prepared_targetsnames one target of each harness too large for a campaign’s build deadline: today the server library with its empty andtestingfeature sets. - The plan deals the harnesses to the shards, the longest estimate first, each to the shard that
holds the least estimated time. A harness’s estimate is its build, 30 minutes for a prepared
harness and 3 for any other, plus the inventory’s PR duration and 15 seconds for each of its
targets. The plan depends only on the inventory and the shard count, so every runner computes the
same one, and
just fuzz-plan <count>prints it. - Each shard job discovers every registered target, prepares its own declared harnesses with one
Cargo build job and the 7,200-second preparation deadline, and runs
just coverage-bolero 30 <number>/<count>: the runner tests, its share of the campaigns and the complete failure qualification. Only the server preparation is held to one build job; discovery and library builds use every core. A shard samples CPU use, steal time and used memory every five seconds and adds the summary to its job summary. bolero-fuzz-combineddownloads every shard’s completion evidence, without the raw profiles, and runsjust combine-bolero-fuzz. It accepts only shards 1 to N of one plan, from one workflow run and attempt at the tested revision and inventory. Each must be complete, must have run exactly its planned targets and must hold a complete record for each of them and a report. Together they must select every registered target once. The job then writes the combined record and the merged Rust source report.
Each shard job allows 180 minutes. bolero-gate requires the randomized job, every shard and the
combined proof when the PR carries fuzz, and requires all of the sanitizer jobs to be skipped
otherwise. tests/release-evidence.toml holds a qualifying revision to the same checks, every shard
of the matrix included.
The fuzz profile uses one codegen unit, optimization level two and debug level one. The full server package uses optimization level one and no debug output to keep its instrumented build within the compilation budget. Both profiles retain debug assertions, overflow checks, AddressSanitizer and libFuzzer coverage feedback. Live campaigns also use Rust source instrumentation; ordinary and release builds retain their own settings.
An instrumented build has a 1,800-second deadline. On a loaded development host,
BOLERO_BUILD_TIMEOUT_SECONDS=7200 just fuzz <id> 30 grants compilation more time. The override
must be a positive integer and affects only compilation; the inventory’s case limit and the
requested campaign duration remain enforced. CI uses the default deadline. The runner prints the
effective compilation deadline alongside the build command and retains timeout evidence.
The campaign process has a deadline of its requested duration plus the greater of 30 seconds
and three case timeouts. The runner passes -print_funcs=0 to libFuzzer for preparation,
campaigns, minimization and failure qualification. This disables the NEW_FUNC progress
messages that otherwise start expensive symbolization on large sanitizer binaries. Crash
reports and exact replay retain symbolization where a symbolizer is available; sanitizer CI
exposes its installed LLVM symbolizer under the executable name libFuzzer discovers.
Ordinary execution prepares each selected package, harness and exact feature set once with
cargo test --no-run, under a separate 1,800-second compilation deadline. Discovery can require
the union of a package’s features; its binary does not prepare a target with a different declared
feature set. Each property still has a 240-second execution bound and must complete its exact
randomized iteration count and replay every checked-in corpus input. A failed preparation ends
the run before that harness executes any property. The ordinary CI job reserves 60 minutes for
discovery, the declared feature builds and every registered target.
Install the dated sanitizer nightly named in the inventory and the pinned CLI. Server properties
also require the native server build dependencies, including protoc for the Pulsar driver;
both ordinary and sanitizer CI jobs install the Protocol Buffers compiler, Trunk and the browser
target. Every compiled Bolero recipe builds the embedded web console before discovering the server
properties, using the same configured compiler wrapper as the product build.
rustup toolchain install nightly-2026-09-17 --profile minimal
just install-cargo-bolero
just help
just fuzz-list
just test-bolero
just test-bolero nspl-model
just fuzz nspl-model 30
just fuzz-all 30
just fuzz-plan 3
just fuzz-all 30 2/3
just prepare-bolero-shard 1/3
just coverage-bolero 30
just qualify-bolero
just fuzz-replay nspl-model <saved-input>
just fuzz-replay-sanitizer nspl-model <saved-input>
just fuzz-reduce nspl-model <saved-input>
just combine-bolero-fuzz <shard-evidence> <output>
just validate-bolero compares the inventory with all workspace packages declaring
Bolero, scans their property macros within each owning Cargo package, and queries compiled targets
through filtered library and integration-test discovery with the inventory’s declared features.
Source scanning stops at nested Cargo manifests and excludes Cargo build output; an authored module
named target remains part of its package. It rejects missing, unregistered, duplicate, ignored and
zero-selected targets, plus
corpus paths different from Bolero’s actual work directory. Discovery executes only
bolero_ tests of the registered library and integration-test targets under Bolero’s selection
mode; it cannot start the server’s scenario harness. It reports discovered, selected, executed and
completed counts.
just validate and just validate-ci do not run Bolero checks. Bolero commands perform discovery
before their selected action, and the dedicated ordinary Bolero CI job owns this check. The inventory includes server-owned Arrow
properties, so Bolero recipes prepare the web console assets the server library embeds through
just bolero-deps. This compiles assets only; it starts no live service. Both Bolero CI jobs install
Trunk, the browser Rust target and native server build dependencies before compiled discovery.
Console builds stage the complete real Trunk output and publish only changed assets. Identical
assets retain their file identity and timestamps, so repeated preparation preserves an already
instrumented server build; failed builds leave the published assets intact.
just test-bolero requires the configured randomized-case count and checked-in corpus
replay for every selected property, checking Bolero’s reported input counts. Fuzz runs use real
libFuzzer coverage with AddressSanitizer, bounded input length and per-case timeout. The fuzz
profile enables optimization, debug information, debug assertions and overflow checks. Builds
use the pinned nightly and keep the configured kache wrapper. Modeled execution features are
excluded; Loom, Shuttle and Turmoil remain independent build invocations.
The server package uses the current profile’s lighter optimization and omits debug output for its
single codegen unit; sanitizer instrumentation, coverage, debug assertions and overflow checks remain
enabled.
The pinned cargo-bolero CLI does not expose a Cargo test-harness selector. Each instrumented build
therefore records a run-local Rustup adapter that forwards the declared --lib, --bin or --test
selector to its Cargo test calls and forwards toolchain/version queries unchanged. It preserves the
toolchain, features, sanitizer flags and compiler wrapper. Library properties compile and execute only their
registered harness; they do not compile or invoke the server’s Cucumber executable.
The adapter selects the pinned compiler through scripts/bolero_rustc.py, still beneath the
configured kache wrapper. For proc-macro2 with span-locations, that compiler adapter removes
only its generic cfg(fuzzing): the dependency otherwise deliberately replaces source locations
with zero, violating the source parser’s contract and collapsing distinct findings. Sanitizer,
coverage and libFuzzer flags remain intact, and every other crate keeps its configuration. The
adapter’s content digest separates build directories. The source-location property and saved
regression corpus assert the same exact locations in ordinary and sanitizer execution.
On a shared host, a cold server build can exceed the campaign runner’s 30-minute compilation
limit. just prepare-bolero <id> prepares that same pinned, scoped sanitizer build with a two-hour
limit and retains its log and preparation metadata. It does not run a campaign or record fuzz
completion. just prepare-archive-counts-fuzz uses this shared path for the declared registry library harness.
just prepare-bolero-shard <number>/<count> prepares the declared harnesses one shard runs, as each
CI shard does before its campaigns.
The target must subsequently pass just fuzz <id> with the ordinary build and case
limits; CI campaigns continue to use that bounded runner.
PR CI runs a required ordinary randomized/corpus job through Check’s reusable Bolero call. The
sanitizer libFuzzer shards run only when the PR has the fuzz label, with 30 seconds of engine
time per target. Every job uses the native
CI linker, including sanitizer builds that supply
their own compiler flags. Adding or removing the label reevaluates the jobs, and a push to a
labeled PR runs the shards again. Scheduled and manual workflow runs execute the ordinary
properties. Job limits reserve additional time for compilation, artifacts and cleanup. A
cache may seed a campaign but cannot skip a target or replace checked-in regressions. An empty
selection, timeout, engine failure, sanitizer finding or property failure fails the job.
Each fuzz run retains its corpus, crashes, log and metadata with revision, target, domain version,
toolchain, features, flags, result and exact-input reproduction commands. Retain failure
inputs before cleanup. fuzz-replay stages saved bytes in the target’s actual crash
directory and runs the ordinary assertion with zero randomized cases. A sanitizer finding passes
there, because only the instrumented build detects it: fuzz-replay-sanitizer builds the target’s
instrumented harness and runs the exact saved bytes once, and a run’s metadata records both
commands. fuzz-reduce uses libFuzzer crash minimization, verifies that the minimized bytes still
fail the instrumented target and records whether the ordinary assertion fails on them as well.
A saved input inside a run directory replays only while it matches the digest its run’s metadata
holds. A missing, unreadable or foreign record, or changed bytes, is a corrupt replay record and
is refused; a copy outside the run directories carries no record and replays as given.
A random seed reproduces one generated case, not an entire entropy-driven campaign.
just qualify-bolero proves that every way a run must fail does fail, and keeps a
qualification.json naming each case, its requirement, what it observed and its evidence:
- A property mismatch fails the campaign and saves its input, which then minimizes and replays.
- An AddressSanitizer heap-buffer-overflow fails the campaign with its report, and the instrumented replay of the saved input reproduces it.
- A case past its timeout, an engine that cannot start and a campaign naming no compiled target fail.
- Compiled discovery of generated packages accepts an exact registration and fails on an ignored, an unregistered and a missing property.
- A filter, a shard plan or a campaign that would run nothing fails.
- An input that differs from its run record, and one whose record cannot be read, are refused.
The deliberately failing targets live in the standalone tests/bolero-qualification package,
outside the workspace and the inventory. Every CI shard runs the qualification, so each shard’s
runner proves its own failure paths.
Live Rust Source Coverage
just fuzz <id> <seconds> and just fuzz-all <seconds> collect Rust source coverage from the
inputs the live libFuzzer process executes, including candidates it discards from its retained
corpus. just coverage-bolero <seconds> runs the command/artifact tests, all live campaigns and
failure qualification while collecting the separate Python runner report. It does not rerun the
randomized producer that CI already requires independently.
The shared build path adds -C instrument-coverage to cargo-bolero’s sanitizer flags, retaining
the pinned nightly, assertions, optimization settings, case/input limits and kache wrapper.
Encoded Cargo flags are rejected because they would override cargo-bolero’s sanitizer flags.
Preparation and the zero-run build probe discard their counters. Ordinary discovery, exact replay
and deliberate failure qualification also discard counters; they cannot enter a product campaign
report or mix stable profiles with nightly profiles.
Each invocation allocates a fresh
target/native-coverage/bolero-fuzz/fuzz/<nightly>/<attempt>/ directory; a shard’s attempt name
ends in -shard-<number>-of-<count>, and its record names the shard and the digest of its plan.
Each selected target
has its own targets/<id>/ directory there, with profiles/%p-%m.profraw, executions.jsonl,
lcov.info, export.log and completion.json. The runner records the exact executable returned
by the hashed fuzz build and its Cargo fingerprint, effective compiler flags and profile settings.
It holds the fuzz build directory through execution and export, so another collector cannot
replace those objects. The shared native exporter matches profile binary IDs to retained
executables and any required child objects, using the producing nightly’s own llvm-profdata
and llvm-cov. Missing objects, absent counters and unreadable or mixed-format profiles fail
collection. LCOV paths are repository-relative; dependency, generated and harness files follow
the native collector’s exclusion policy. Tests inline in an included source file retain that
file’s attribution.
A successful target has a complete record only after a successful engine exit with nonzero
completed inputs and a successful source export. The aggregate report beside the invocation’s
completion record is complete only after all selected registered targets complete. The record
retains the inventory digest and exact entries, selection and discovered/executed/completed
counts, revision and whether it was modified, compiler/LLVM identity, workflow run and attempt,
event and labels, campaign limits, input throughput and source attribution. Each target’s record
also identifies the checked-in corpus the campaign started from, by its input count and a digest
of every seed’s name and content. A focused invocation or a shard states its selection and never
claims the rest of the inventory ran; only the combined record of every shard does.
A crash, abort, deadline or interrupt stays failed or interrupted even if it left some counters. The runner retains its original corpus, crash input, logs and incomplete evidence; it does not export those counters as a successful campaign. Failure minimization and exact replay remain independent qualification, with their original failure retained. No corpus replay substitutes for the live measurement.
Each CI shard publishes coverage-bolero-fuzz-<number> with its live campaign reports, records
and raw profiles, bolero-fuzz-evidence-<number> with its records and aggregate report but no
profiles or per-target reports, bolero-runs-<number> for corpus/crash and qualification artifacts with the Python
runner report as python.lcov, and runner-load-bolero-fuzz-<number> with its CPU and memory
samples. coverage-bolero-fuzz holds the combined record and the merged report, which sums the
shards’ line, function and branch counters because they ran disjoint campaigns, and
bolero-selection the gate’s exact producer verdicts, including deliberate skips. Fuzz reports
remain separate from ordinary workspace coverage and its CRAP report. A sanitizer skip publishes no
fuzz coverage artifact.
Adding A Target
Put the Bolero test beside its production owner, name it bolero_..., bound iterations
and input length, and assert the complete current-value contract. Register the package, full
test name, source, domain version, features, corpus, budgets and invariant in the inventory.
Check in current-domain seeds, run just validate-bolero,
just test-bolero <id> and just fuzz <id> 30, then run
just validate. Both CI jobs include the target in the same change; the shard plan places it with
its harness, or deals a new harness to a shard, so a target needs no shard assignment. A new harness
whose sanitizer build exceeds a campaign’s build deadline joins prepared_targets. A public product
bug found this way first gets a failing focused reproducer and affected public Cucumber scenario
before its fix.
Developing Nervix
This section is for working on Nervix itself. Everything here assumes a clone of the Nervix
repository and uses its just recipes; none of it is required to use Nervix.
Clone The Repository
git clone https://github.com/nervix-io/nervix
cd nervix
Prerequisites
Nervix is developed on Linux x86_64, Linux aarch64, and macOS arm64. The just recipes fetch an
ONNX Runtime build for the host and stop on any other.
The server uses ort 2.0.0-rc.13 with its highest supported C API level, 28. The downloader pins
ONNX Runtime 1.30.0 and installs it under .nervix-deps/onnxruntime. Download the CPU package or,
on Linux x86_64, the CUDA 13 package:
just download-onnxruntime
just download-onnxruntime gpu_cuda13
The optional second argument selects an upstream platform: host (the default), linux-x64,
linux-aarch64, or osx-arm64. For example, just download-onnxruntime cpu linux-aarch64
downloads for Linux ARM64 from another supported development host. CUDA packages are available
only for linux-x64. Version and destination are fixed; update the runtime pin together with the
selected ort API level.
Install:
- Rust via
rustup just(latest release)zellij
just build-web-console builds the console the server embeds. trunk uses a wasm-bindgen CLI
from PATH when its version equals the one Cargo.lock locks, and otherwise downloads it from
GitHub without retrying. So the build installs that exact version with cargo install --locked
into target/tools/wasm-bindgen-<version>, through the configured compiler wrapper, puts it first
on PATH and runs trunk build --offline. Cargo checks the crate against the registry index and
retries its own transient network failures. Offline, trunk stops at once when the CLI is missing or
reports another version. The image’s builder stage runs the same installer,
python3 scripts/wasm_bindgen.py install, before its own offline trunk build.
Build Container Images
just docker-build-debian
just docker-build-cuda
Both recipes require the repository’s remote compiler-cache configuration: KACHE_S3_BUCKET,
KACHE_S3_REGION, KACHE_S3_ENDPOINT, KACHE_S3_ACCESS_KEY, and KACHE_S3_SECRET_KEY. They build
and load a local image by default; the existing tag, platform, push, and BuildKit cache arguments
are available for publishing builds.
Dockerfile.debian has two complete image targets: cpu (the default) and cuda. The recipes
select them with --target cpu and --target cuda, respectively. Both targets copy the same
binaries and Sonic bundle from the shared builder stage using COPY --link, so those artifact
layers are shared across the images. Each target has its own runtime base and ONNX Runtime
download stage; building the CPU target requires only the CPU stages.
Both images pin their builder, downloader, and runtime base to debian:trixie-20260918-slim,
with its multi-platform digest fixed in Dockerfile.debian. The Debian image contains the CPU
runtime and supports AMD64 and ARM64. The CUDA image is AMD64 only. It installs pinned CUDA
13.2.2 and cuDNN 9.25.1 packages from NVIDIA’s Debian 13 repository in one filesystem layer
directly above the Debian base. The next layer runs apt-get update and apt-get upgrade before
installing Nervix’s dependencies. CUDA packages are held at their pinned versions during that
upgrade. Nervix’s user, ONNX Runtime libraries, and binaries follow, so application changes reuse
the large CUDA layer. Both images obtain ONNX Runtime through the same pinned downloader.
The CPU layer order is Debian → Debian updates → Nervix; the GPU layer order is Debian → CUDA/cuDNN → Debian updates → Nervix. The CUDA layer is built by Nervix and reused across its image builds.
just test-onnxruntime checks package selection and recipe targets. just test-onnxruntime-docker
builds both final targets with fixture binaries replacing the compiler stage. It verifies shared
binary layers, Debian upgrades immediately after the selected base, preserved CUDA pins, and
runtime shared-library dependencies without requiring remote compiler-cache credentials. The
host NVIDIA driver is provided at container startup by the NVIDIA Container Toolkit.
Start The Server
The server crate and executable are both named nervix-server. The just server recipe creates a
development CA and a default/node-1 identity, then runs that dedicated server binary.
NERVIX_INIT_DEFAULT_USER_PASSWORD='nervix' just server
Fresh clusters require an initial password for the default user. Set
NERVIX_INIT_DEFAULT_USER_PASSWORD or pass --init-default-user-password <password> on the first
startup. The leader stores the default user’s Argon2 password hash in the strongly consistent
control plane only when that user does not already exist. After the default user has been created,
remove the environment variable or flag from normal startup. Later users can be created through NSPL:
CREATE USER my_username WITH PASSWORD 'my_secure_password';
Clustered startup example:
just server -- \
--addr 127.0.0.1:47391 \
--http-listen-addr 0.0.0.0:8080 \
--https-listen-addr 0.0.0.0:8443 \
--grpc-advertise-addr 10.0.0.10:47391 \
--cluster-id production \
--node-id node-1 \
--interconnect-listen-addr 0.0.0.0:47395 \
--interconnect-advertise-addr node-1.internal.example:47395 \
--interconnect-tls-ca /etc/nervix/interconnect/ca.pem \
--interconnect-tls-cert /etc/nervix/interconnect/node-1.pem \
--interconnect-tls-key /etc/nervix/interconnect/node-1-key.pem \
--cluster-bootstrap-host node-2.internal.example:47395
Nervix uses separate listener addresses for plain and TLS server-side traffic:
--http-listen-addrfor HTTP and WS--https-listen-addrfor HTTPS and WSS
All internal node-to-node traffic uses one authenticated HTTP/2 listener. The CA, certificate, and
private-key options are mandatory. Each node certificate must support both TLS server and client
authentication, contain the advertised DNS name or IP address, and contain exactly one identity URI
of the form nervix://cluster/<cluster-id>/node/<node-id>. Peers with a different cluster identity
or a certificate identity that disagrees with their protocol identity are rejected. Gossip, Raft,
resource transfer, and relay traffic use independent connection pools on this listener; bounded
rkyv is the internal control encoding, while relay batches remain Arrow IPC.
The server monitors all three interconnect PEM files. It reloads a changed bundle only after two
consecutive reads agree and the complete replacement passes certificate, identity, and lifetime
validation; an incomplete or invalid update leaves the active bundle in service and is retried.
OpenTelemetry trace export is optional and uses the existing tracing instrumentation:
--otel-enabledorNERVIX_OTEL_ENABLED=trueenables OTLP trace export--otel-otlp-endpointorNERVIX_OTEL_OTLP_ENDPOINTsets the OTLP gRPC collector endpoint, defaulting tohttp://127.0.0.1:4317--otel-service-nameorNERVIX_OTEL_SERVICE_NAMEsets the OpenTelemetry service name, defaulting tonervix--otel-trace-sample-ratioorNERVIX_OTEL_TRACE_SAMPLE_RATIOsets parent-based trace sampling from0.0through1.0
The node’s trace exporter shares its configured name resolver. Startup, timeout and transport behavior are described in Node Trace Export.
The just deps stack includes Quickwit and Jaeger for local trace storage and viewing. Quickwit receives OTLP traces on host port 4317, and the Jaeger dashboard at http://127.0.0.1:16686 is configured to query Quickwit as its trace backend.
The observability listener exposes health and graph metrics:
/livezreports process liveness/readyzreports readiness once a leader is known/metricsreports raw Prometheus text metrics for graph nodes and relays
DESCRIBE commands include the same graph-node and relay metric labels, plus local derived values such as counter rates and histogram percentiles. See Metrics And Observability for the metric families, labels, and rate semantics.
Local Multi-Node Setup
just cluster-dashboard
The zellij dashboard seeds the default user once with the local password nervix and exports the
same password for the interactive client pane. Override it by setting NERVIX_PASSWORD or
NERVIX_INIT_DEFAULT_USER_PASSWORD before running the dashboard. If the local dashboard state was
already initialized with a different password, run just reset-local-dashboard-state before starting
fresh.
Start The Interactive Client
The separate interactive client crate and executable are both named nervix-cli. Its options and
interactive behaviour are documented in Command Line Client.
just client
The client connects as default unless --username or NERVIX_USERNAME is set. Pass
--password, set NERVIX_PASSWORD, or let the client prompt interactively.
Direct subscription example:
just client subscribe notifications
Start Local Broker Dependencies
just deps
The local dependency stack includes broker and service containers used by the documented examples, including Kafka, Pulsar, RabbitMQ, Redis, MQTT, ClickHouse, Postgres, MySQL, MongoDB, RustFS, Prometheus, Quickwit, Jaeger, and a Sentry-compatible Bugsink service.
The local Sentry-compatible service is available at http://127.0.0.1:18090. Sign in with
admin@example.org / admin, create a project, and copy its DSN into a TYPE SENTRY client used
by a Sentry emitter. These credentials and the Compose SECRET_KEY are development-only.
RustFS provides the local Rust-written S3-compatible target for Iceberg emitters:
- S3 endpoint:
http://127.0.0.1:9900 - console:
http://127.0.0.1:9901 - access key:
rustfsadmin - secret key:
rustfsadmin - bucket:
nervix-iceberg
The compose stack also starts fake-gcs for GCS API emulation and azurite for Azure Blob API emulation:
- GCS endpoint:
http://127.0.0.1:4443 - GCS bucket:
nervix-iceberg - Azure Blob endpoint:
http://127.0.0.1:10000/devstoreaccount1 - Azure Blob container:
nervix-iceberg - Azure Blob development account:
devstoreaccount1
The current Iceberg OpenDAL adapter honors custom GCS service endpoints, so fake-gcs can be used for local GCS tests. Azure Blob support is exposed through the adapter’s ADLS/Blob URL forms (wasb:// and wasbs://); the pinned adapter derives its endpoint from the storage URL and does not yet honor Azurite’s path-style local endpoint, so Azurite is available in compose for blob-client work but Iceberg Azure local integration needs an adapter patch or upstream endpoint support.
Iceberg emitters stage local batch files under /tmp by default before committing them to blob storage. Use --temp-dir or NERVIX_TEMP_DIR to place runtime temporary files elsewhere.
Prometheus Local Check
curl --get 'http://127.0.0.1:9090/api/v1/query' \
--data-urlencode 'query=label_replace(vector(42.5), "source", "local", "", "")'
OpenTelemetry Local Check
Start Nervix with --otel-enabled and keep the default OTLP endpoint when just deps is running. Open http://127.0.0.1:16686, select the nervix service, and search for traces. Quickwit is also available at http://127.0.0.1:7280.
Connector Crates
Connector Crates And The Connector Contract is the architecture reference for ownership, source and sink lifecycle, and the cross-layer checklist for adding an integration. The notes here identify the repository locations and validation commands.
The shared contract is in crates/connector, and each external integration is in
crates/connectors/<name>. The server’s src/runtime/ingestors and emitter modules compose the
typed plans with those crates. Keep driver dependencies in their integration crates; the server
test harness may name a driver in [dev-dependencies] to provision or inspect an external system.
The node’s own endpoint source lives in src/runtime/ingestors/endpoint.rs because it has no
external driver. Follow the architecture chapter’s integration checklist
when extending this layout.
Validation And Tests
For repository-wide validation:
just validate
The checks, tests, and scenarios CI jobs install the Go formatters through
scripts/install_go_ci_tool.py: dockerfmt is pinned to v0.5.4 and ghokin to v3.9.0. The installer
uses Go 1.26 in each job, keeps the public Go checksum database enabled, and disallows an implicit
toolchain download. It retries only a transport failure while reading that database, at most three
attempts within eight minutes. A checksum mismatch, compiler failure, or invalid module version
fails on the first attempt. Each attempt is retained in the job’s
go-tool-bootstrap-<job> artifact. Run just test-go-ci-tool to exercise the classifier and
workflow wiring without network access.
CI installs the latest just release. just validate, just validate-ci, just lint and
just cargo-clippy default to four concurrent recipe bodies across the [parallel] groups.
Pass a job count, such as just validate 2, or use just --jobs 2 validate to select another
limit. JUST_JOBS also supplies the default. Validation runs the product and compiler tooling
Clippy recipes in one invocation, so they share the same limit.
Each package and configuration is a separate parallel dependency of one shared Clippy recipe.
The [parallel] groups map package lists onto that recipe, including the packages checked with
--all-features and the compiler tooling packages. Feature, target, profile and compiler selections
stay separate, so mutually exclusive execution modes never share a Cargo invocation.
Clippy uses clippy/<package>/<configuration_hash> beneath CARGO_TARGET_DIR, or the repository’s
target directory when that variable is unset. The hash includes the Cargo arguments and compiler,
so each configuration has its own build directory lock. just cargo-clippy-package <package> [arguments ...] runs a focused check of all targets through the same recipe. The compiler driver
builds keep their separate typed-ratchet/driver directory. All builds retain the configured kache
wrapper. Cargo’s compiler job limit applies separately within each invocation. On CI, where nothing
reads a build directory after its lint and the runner’s disk cannot hold every configuration’s at
once, a target that passed deletes its own; a failed target keeps its directory, and a local run
keeps them all for the next one.
just validate-nspl-docs scans docs/src and parses every exact nspl code fence directly with
the parser crate. Use nspl,ignore only for loose grammar synopses and statement fragments that
are intentionally not complete NSPL scripts; the fence remains identified as NSPL in the rendered
documentation.
For test runs:
just test
For representation properties and sanitizer fuzzing, see
Property Testing And Fuzzing. The focused entry points are
just test-bolero [filter], just fuzz-list and
just fuzz <target> [duration]. These commands and the dedicated Bolero workflow own target
discovery; repository validation does not run Bolero checks. just help lists the recipes.
Native CI builds for validation, unit tests, scenarios, client conformance, Turmoil, Shuttle,
Loom, Bolero and extra checks use Clang 23 with Wild. Cargo’s x86_64-unknown-linux-gnu linker
points to scripts/ci_linker.sh, which selects Wild independently of RUSTFLAGS and
CARGO_ENCODED_RUSTFLAGS supplied by coverage, simulation or sanitizer tooling. Browser targets
use their own linker. just validate-workflows checks the Actions workflow definitions.
Connector crates have a focused recipe. just test-connectors runs the unit tests of the
nervix-connector contract crate and of every nervix-connector-* integration crate. Arguments are
passed to the test binaries, so a test name filter narrows the run:
just test-connectors <filter>
Compiler architecture gate
just ratchet, just validate and just validate-ci run the same source-driven compiler
architecture gate in the isolated tools/nervix-lint workspace. Product builds
remain stable. Tooling, analysis and product artifacts occupy separate target directories.
Install the declared toolchains with just toolchains-install, including the pinned analysis
compiler’s wasm32-unknown-unknown standard library for browser analysis. Prepare the ordinary
server build prerequisites, including just build-web-console.
When compiler reports need reanalysis, cleanup selects the configuration’s target as well as its
isolated build directory, so browser artifacts cannot remain fresh without their current reports.
just ratchet
just typed-ratchet --show
just typed-ratchet --configuration shuttle --inventory
just test-typed-ratchet
just test-typed-ratchet-docs
just qualify-typed-ratchet-cache
just lint-audit
just lint-audit reads the same complete evidence and reports review candidates for code shaped
around an inaccurate lint, each attributed to the commits and debt counts that introduced it.
Architecture Rules And The Debt Ratchet
states the candidates, the attribution and the review contract.
The complexity verdict is the ratchet’s functions_over_complexity_30 count: the functions that
the pinned cargo-crap scores above cyclomatic complexity 30. just ratchet builds that version
first with just install-cargo-crap, into target/tools/cargo-crap-<version>/ through the
configured wrapper, and refuses a binary of another version. A new function above 30 raises the
count and fails just validate and CI’s checks job, which name it among the listed sites:
just install-cargo-crap
just ratchet --show functions_over_complexity_30
Decompose the function rather than moving the number. The function complexity section states the analyzer’s scope, its limits and every failure that refuses an incomplete analysis.
Declare #[cfg_attr(nervix_lint, nervix::context(...))] at the source’s owning execution boundary.
Choose recurring, lifecycle, observer, outside or bounded; every kind requires a reason, and
bounded protocols also require their identity key and bound. Trait/type/module inheritance and
local call relationships supply defaults. Hot callers of lifecycle-only helpers, unknown dispatch,
invalid annotations and recurring acquisitions produce ordinary Rust diagnostics. Use a narrow
nervix_primitives::expect_lint! with a meaningful reason and owning repair task for retained debt.
An expectation covering multiple operations or no operation fails. See
Data-Plane Concurrency for the complete authoring
contract, inheritance, supported call effects and limitations.
The same gate checks contextual failure contracts, explicit outcome handling and canonical panic
APIs through nervix::bare_error_signature, nervix::discarded_outcome and nervix::bare_panic.
Aliases, associated outputs, generic calls, inferred closures and boxed future/stream returns
are resolved by the compiler. Ordinary values and custom same-named methods keep their own
contracts. Classify a semantic outcome or modeled library contract with a reason-bearing
nervix::error_boundary(outcome|library, reason = "...") on its exact error type or return
contract. These classifications do not inherit module defaults. Standard-error hooks retain a
complete report carrier. Errors And Diagnostics owns the classification
rules and the current carriers. just validate-typed-errors separately guards textual
Result<_, String> source, including inactive forms.
The JSON output records resolved APIs, spans, owner variants, expansions, source contracts,
compiled configurations and generated/external exclusions. It is generated evidence under
target/, not an approval database. Partial selections are inventories; the required diagnostic
gate needs the complete matrix and complete current compiler/Cargo evidence, including zero.
The workspace wrapper nests beneath configured kache. Completion identities cover current source,
annotations, rule code, compiler, driver, validator, dependencies, Cargo configuration and flags.
Cargo-fresh artifacts reuse matching complete side reports. Missing reports establish analysis
using KACHE_KEY_SALT and rebuild only isolated authored artifacts. --fresh reruns Cargo;
--recompile discards those authored artifacts to execute the callback. Every path preserves
RUSTC_WRAPPER. Changing a caller, callee, annotation or source location needs only a new run.
Cache qualification exercises fresh and Cargo-fresh analysis, an actual kache dependency hit, changed source/annotation/rule/dependency/configuration inputs, missing and interrupted output, fixture doctests twice, and cross-worktree rejection in two isolated worktrees. Kache runs workspace-wrapper chains other than Clippy’s directly while caching ordinary dependencies; complete evidence is validated independently of that detail. Compiler semantic fixtures qualify resolved lints and cross-crate metadata.
The same gate enforces source-owned architectural layers, configuration Model consumption,
language references, awaited cluster access and declared identity/installation state. Declare
nervix::layer at documented ownership boundaries, nervix::input(plans, ...) on connector engines,
and exact value, identity and state roles at their semantic owners. The
architecture contracts and
typed-state contracts state the
supported scope. just test-typed-ratchet-contracts <unittest selector...> runs focused pass/fail
compiler fixtures after preparing just test-typed-ratchet-compiler. just test-ratchet-units
also qualifies language provenance before resolution; python3 -m scripts.check_layer_provenance
prints current source/manifest violations. Calibration logs and complete matrix/coverage outputs
belong on the owning task, not in the source tree. Compilation under a modeled mode qualifies
recognition and supplies no concurrency execution evidence.
Typed errors and reports
just validate rejects a Result<_, String> in product code and a signature that returns a Nervix
failure without an error-stack report. Run the distinct source check, the complete compiler gate,
its semantic fixtures and the structural scanner tests with:
just validate-typed-errors
just ratchet
just test-typed-ratchet-contracts
just test-ratchet-units
Errors And Diagnostics holds the checklist for a new fallible operation: which layer owns the failure, what its context carries, where the report ends, how it is rendered and which evidence holds it.
Capability doctests
Execution Primitives distinguishes API doctests, Cargo graph and target probes, compiler fixtures, adapter conformance and actual production-owner model execution. Keep each verdict with the configuration and claim it checks.
just test-capability-docs checks runtime state handles, logical and physical deadlines,
subscription and VM predicates, the UDF execution context, consensus capabilities, DNS error
carriers, recovery outcomes and SIMD byte classes with compile_fail doctests beside their APIs.
Paired compiling examples check that the imports and current signatures remain valid. These
checks cover the specific rejected operations in each snippet. They run in just test and CI;
the runtime examples require the testing feature, which the recipe enables.
Deterministic concurrency checks
Run every registered Shuttle check of the execution, interconnect, Rust client and server crates with:
just test-shuttle
Each check runs in its own process under the exploration it declares, then again under the
uncontrolled-nondeterminism detector. crates/model-harness/shuttle-inventory.toml registers every
check by package and test name, and the run fails when a registered check is missing, ignored or did
not complete its exploration, and when a shuttle_ test is not registered; add a new check to its
package’s list, and rename a renamed one there, in the same change. The command reports how many
checks it discovered, selected, executed and saw complete. A substring selects a focused check or
protocol family in every package, and a substring that selects nothing fails:
just test-shuttle force_flush
On failure, the runner keeps the schedule Shuttle persisted, the run’s output and its metadata below
target/shuttle-failures/<package>/<fully-qualified-test-name>/. Pass the schedule file to the
replay recipe; its parent directories identify the exact package and check:
just test-shuttle-replay target/shuttle-failures/<package>/<fully-qualified-test-name>/<schedule-file>
Keep the schedule with the failure report while fixing the owning protocol, then run the focused
check and the full suite. just test-shuttle-replay-check proves persistence and replay without
changing a protocol: it fails one check deliberately after its invariant held, through
SHUTTLE_FORCE_FAILURE=1, and requires the one schedule it persisted to reproduce the failure in a
fresh process. Use SHUTTLE_REPORT_STEPS=1 to inspect the highest explored step count when setting
a check’s iteration and step budgets. Data-Plane Concurrency defines
what these checks model, their limits, and the invariant held by each protocol.
CI runs the checks and the replay check only for a pull request labeled shuttle. Label a pull
request that changes a protocol a Shuttle check holds.
Memory-ordering models
Explore every registered Loom model of a production owner to exhaustion, each in its own process:
just test-loom
A substring of a model’s test name or invariant selects it, and a filter that selects nothing fails:
just test-loom cancellation.publication
The run fails when a registered invariant is missing, ignored or did not complete its exploration,
and when a loom_* test is not registered in crates/model-harness/loom-inventory.toml. A failed
model leaves its Loom checkpoint, output and metadata.json below
target/loom-failures/<package>/<invariant>/; replay it with location tracking and tracing enabled:
just test-loom-replay target/loom-failures/<package>/<invariant>
just test-loom-qualification applies each registered weakening to a copy of the working tree and
requires its model to fail. The copy lives below target/loom-qualification-build/ and builds into a
target directory of its own there, so a weakened build never stands in for the working tree’s.
Every file the copy takes or restores gets a fresh modification time, so each weakening rebuilds only
what it changed, and qualifications that register the same weakening share one weakened build. The
server’s models embed the built web console, which the copy links rather than copies; the Loom
recipes build it first. The copied sources are removed when the qualification ends, and the target
directory stays, so a later run reuses the dependencies it built. The copy compiles incrementally,
because its weakened builds serve that run alone, so a weakening recompiles what it changed rather
than the whole crate. just test-loom-qualification NUMBER/COUNT qualifies one shard of the distinct
weakenings, the ones at the inventory positions that leave NUMBER - 1 when divided by COUNT; the
default, 1/1, qualifies them all.
CI runs the models and their qualification only for a pull request labeled loom. Label a pull
request that changes an ordering a Loom model holds.
Primitive boundary and execution modes
Every execution-sensitive primitive comes from nervix-primitives, and every mode is its own build.
just validate runs the checks that keep it that way: just validate-primitive-boundary for import
origin, permissions, manifests, global cfgs, the analysis cfg and release binaries, over every
authored source including the isolated analysis workspace,
just validate-execution-mode-dependencies for the ordinary and portable dependency graphs of the
workspace and of every package, and just validate-execution-mode-conflicts for the diagnostics of
combined modes, of a mode or the native capability requested for the browser’s target, of the
diagnostic mode requested without the native capability, and of a product binary built in a mode it
does not declare. just lint lints each mode in its own build, including the Shuttle builds and
checks in just cargo-clippy-shuttle, the Loom builds in just cargo-clippy-loom and the
diagnostic builds in just cargo-clippy-deloxide.
just test-primitives builds nervix-primitives once per execution mode and runs its conformance
checks: which backend each mode selects, the same contract scripts of every family against the
ordinary libraries and against the Shuttle adapters, the Shuttle checks that a publication between
a read and a waiter’s registration is reached, and the check that a Loom build takes the ordinary
libraries for the families Loom does not model. The timer checks measure every timer on a paused
clock in ordinary execution and show that Shuttle’s timers are scheduling points whose timeouts a
check triggers, the Turmoil check shows that sockets, name lookup, timers and admitted CPU jobs
belong to the simulated host that uses them, and the Deloxide checks show that the tracked locks keep
the contracts of the locks they replace, including their non-blocking Debug and the exact count of
waiters a notification wakes. Run it after changing an adapter or the families a mode provides:
just test-primitives
It runs just test-primitives-ordinary, the checks in ordinary native execution, then
just test-primitives-modeled, the checks under each other mode’s backend, then
just test-primitives-compile, the checks that compile rather than run; each also runs alone.
Data-Plane Concurrency defines the primitive boundary, what each
mode observes, and every model’s claim.
Integrated primitive qualification
At a release candidate revision, run the ordinary suite and the complete primitive, Shuttle, Loom and Turmoil inventories. Keep the replay and weakening checks separate from successful model exploration: they show that recorded failures reproduce and that the asserted ordering matters. Run the applicable public Cucumber scenarios at the same revision. An empty selection, incomplete inventory or timed-out exploration is a failed qualification, even if another mode passed.
The external smoke and soak suites use a fresh ordinary image built from that revision. The suite
resolves the image once and gives every scenario the same image identity; its suite.json and each
run’s manifest prove which image, fault action, accepted events and recovery outcome were observed.
For pull-request CI, select both chaos and chaos-soak when qualifying the complete matrix; the
labels run the suites against that pull request’s own image. A nightly run of a different commit
does not qualify the candidate. A recorded replay reproduces the external action plan, not the
operating system’s thread schedule.
Measure the ordinary, uninstrumented build on the same host and profile as its baseline:
just bench-primitives
just bench-relay-interaction
just bench-admitted-work
just bench-task-handles
bench-primitives times atomic access, publication loads and stores, an uncontended blocking
lock, and shared-reference cloning. The relay and admitted-work suites include their production
owners; bench-task-handles retains raw timing and allocation samples. Record the revisions,
host/load, toolchain, exact commands, raw results and comparison on the ClickUp task. Keep model
evidence, real-process Chaos evidence and performance measurements distinct; none establishes the
claim of another method. Generated qualification reports and logs belong on the task rather than
in the repository. A pull request labeled benchmark runs the product comparison and the ordinary
primitive probe on its benchmark worker and retains the probe output with the benchmark artifacts.
Deadlock diagnostics
Diagnostic builds select deloxide for active cycles, deloxide-order for active and historical
order analysis, or deloxide-stress for active cycles under Deloxide’s bounded scheduling
disturbance, which delays one in twenty of the acquisitions a thread makes while it holds a tracked
lock by 20µs to 200µs. Build and run one with an existing local evidence
directory:
just build-diagnostic-server deloxide-order
target/deloxide/debug/nervix-server --deadlock-evidence /var/tmp/nervix-deadlocks ...
The order build’s --deadlock-active-only option disables checking at runtime but keeps compiled
instrumentation and its cost. Ordinary product performance is measured separately. An active cycle
exits 3; a potential cycle retains evidence and continues; lost evidence or failed diagnostics
exit 4.
Run every selection of the diagnostic lane after any change that adds or alters thread-blocking synchronization, the order of tracked acquisitions, or a shutdown, drain, restore, cancellation, handoff or other lifecycle path that uses tracked locks:
just test-deloxide
just test-deloxide-order
just test-deloxide-stress
just test-deloxide-stress-restore
just deadlock-report inspect target/deloxide/test-deloxide/deloxide-order/run.XXXX/evidence/scenarios/deadlock-PID-TIME.rkyv --source potential
The lane runs every workload tests/deloxide-inventory.toml registers for its selection: the
disposable-process probes, the tracked locks’ conformance checks, the diagnostic owner tests and the
tagged one- and three-node scenarios, without retries. The two stress selections compile one
build: deloxide-stress runs the probes, conformance checks, owner tests, lifecycle scenarios and
paced-driver scenarios under the disturbance, and deloxide-stress-restore its capture, restore
and checkpoint-transfer scenarios, one at a time in a lane of their own. A scenario that joins the
active-only selection also joins the stress selection of its family, tagged @deloxide_stress or
@deloxide_stress_restore, under a stress. identity. It fails when a registered workload is
missing or ignored, when a probe, conformance check, owner test or tagged scenario is not
registered, when a process deadlocks, fails its diagnostics, is killed by a signal, outlives its
bound or leaves a process behind, and when evidence is missing, partly written or does not qualify.
An active deadlock exits 3, a diagnostic failure 4, a timeout or the expired budget 124, and
every other failure 1. A potential cycle fails the lane until its order is corrected; the lane
keeps no list of approved cycles.
Each run keeps a fresh attempt under target/deloxide/test-deloxide/<selection>/ with every log,
probe artifact, evidence file and finding description, and lane.json with the exact command,
environment, bound and ending of every launch. Replay one recorded launch in a fresh attempt, and
prove the lane’s supervision on deliberately failing processes:
just test-deloxide-replay target/deloxide/test-deloxide/deloxide/run.XXXX/lane.json scenarios
just test-deloxide-qualification deloxide-order
A new probe, conformance check, owner test or tagged scenario joins the inventory with a stable
identity, the invariant it owns, its selections and the coverage it declares; a renamed one is
renamed there in the same change. A source file that starts acquiring tracked blocking locks needs
an [[owner]] record naming the workloads that reach them or the path the lane does not reach and
why: just validate-deloxide-applicability, part of just validate, holds the records to the
compiler’s acquisition catalog. For a pull request labeled deloxide, which every change the
Deloxide rule applies to carries, CI’s deloxide job runs every selection through the native
coverage collector, as just coverage-native-extras test-deloxide test-deloxide-order test-deloxide-stress test-deloxide-stress-restore does locally,
and keeps their attempts, completion records and diagnostic coverage reports as artifacts. The
ordinary report tool also exports selected artifacts, records explicit proofs in reviewed copies and
qualifies whole-process evidence. just coverage-deadlock measures the diagnostic implementation
and the ordinary report command in separate instrumented builds through the native collector.
Data-Plane Concurrency has the lane’s
classes, its record, proof requirements, current graph limits and the paths detection does not
cover, and Integration Test Lifecycle its bounds.
Deterministic network simulation
Run the interconnect’s seeded Turmoil simulation, its library checks, and every scenario over its committed seeds with:
just test-turmoil
Each seed runs twice in fresh processes and both runs must record the same semantic trace. To iterate on one simulation test, pass its name to the simulation target alone:
just test-turmoil-simulation transport::relay::relay_reconciliation_and_cancellation_survive_lost_replies --exact
A failed seed leaves a JSON record below target/turmoil-failures/ and prints the command that
replays it in a fresh process with exactly its recorded inputs:
just test-turmoil-replay target/turmoil-failures/<test>/<case>-seed-<seed>.json
The suite reports how many tests each of its invocations and the whole suite discovered, selected,
executed and completed, and keeps each invocation’s output under target/turmoil-suite.
tests/turmoil-inventory.toml registers the tests each invocation runs and leaves ignored: the
suite fails when a registered test did not run or ran ignored, when a simulation test ran
unregistered, and when an invocation executed no test, and a just test-turmoil-simulation
selection that matches nothing fails too. just test-turmoil-sweep runs every scenario over
sixty-four seeds from 1000 in place of its committed seeds, and just test-turmoil-replay-check
proves the record and replay path end to end. Deterministic Interconnect
Simulation defines what the simulation controls, its fault model and
limits, the scenario matrix, and how to investigate a failure.
CI runs the suite and the replay check only for a pull request labeled turmoil. Label a pull
request that changes the interconnect or a seam the simulation plugs into.
External cluster chaos
just chaos runs black-box cluster scenarios against an already built Nervix image through Docker
Compose, Pumba and the image’s packaged CLI. It compiles nothing and needs no Rust toolchain. Two
suites run selections of those scenarios, each as its own just chaos run command:
just chaos suite list
just chaos suite smoke --image ghcr.io/nervix-io/nervix@sha256:<digest>
just chaos suite soak --image ghcr.io/nervix-io/nervix@sha256:<digest>
The smoke suite runs a baseline, a leader crash with its restart and a partition with its recovery;
the soak suite runs every delivered scenario with fixed and rotating mixed-instability seeds. CI
runs the smoke suite against a pull request’s own images only for a pull request labeled chaos,
and the soak suite for one labeled chaos-soak. The nightly build of main runs the soak suite,
and a manual run of the Chaos workflow takes a suite, an immutable image digest and its kind.
Label a pull request that changes the chaos runner, or the membership, failover, recovery or
delivery behavior its scenarios verify, with chaos.
Each suite runs twice on CI: against the release image and against the Deloxide diagnostic image
of the same revision, which just docker-build-diagnostic builds and whose label makes every run
judge the deadlock evidence of its nodes. Locally, just build-chaos-diagnostic-image overlays a
release-mode diagnostic server of this checkout on an available image:
just build-chaos-diagnostic-image nervix:diagnostic-local ghcr.io/nervix-io/nervix:debian-latest deloxide-order
just chaos suite smoke --image nervix:diagnostic-local --image-kind deloxide-order
scripts/chaos/README.md describes every scenario, the suites, diagnostic images, their budgets
and artifacts, and how to reproduce or replay a failed run.
Release evidence
tests/release-evidence.toml declares the evidence a revision needs before it qualifies for
release: the checks every pull request runs, the release images, and each label-gated method with
its label and checks, Bolero’s sanitizer fuzzing, Loom and its weakening qualification, Shuttle,
Turmoil, every Deloxide lane selection, client conformance, and the smoke and soak suites
against both the release image and the diagnostic image of the revision.
just release-evidence <pull-request>
The gate reads the pull request’s head revision, its labels and every check run GitHub recorded on
that revision. It refuses the revision when a method’s label is missing, so CI never ran it; when
the latest run of a required check, or of any shard of a sharded one, is missing, still running,
skipped, cancelled, timed out or failed. The register also lists every gap an owner of tracked
locks declares in tests/deloxide-inventory.toml, the path the diagnostic lane does not reach and
why. A declared gap is a reviewed declaration the Deloxide rule permits, so it does not refuse the
revision: just validate-deloxide-applicability, part of the required checks job, holds the
records to the compiler’s acquisition catalog, and the register shows each declared limit of the
diagnostic evidence to whoever qualifies the release. A rerun of a failed job supersedes the run it
repeats; a passing rerun of a nondeterministic failure still needs the failure’s own investigation,
which the gate cannot see. The register of every check’s
verdict, run and time goes to target/release-evidence/<revision>/ and belongs on the task that
qualifies the revision, not in the repository. just test-release-evidence exercises the gate on
recorded check runs and reads the Deloxide lane and release image matrices from the workflows, so
it fails when one of their jobs is missing from the inventory. A renamed CI job, or a value added to
the matrix of one the inventory names, is updated in the inventory in the same change.
SIMD kernel development
SIMD Kernels owns the typed-buffer admission rule, caller catalog, dispatch
and build-target matrix, result guarantees, and recorded performance evidence. New kernels need
generated-instruction inspection, complete scalar/level differential checks including forced
fallback, and a measured caller. Use its qualification recipes
and measurement recipes, retaining kache.
Ordinary registered Bolero properties run on PRs; sanitizer CI requires the fuzz label, and a
skip supplies no execution or coverage evidence. API-rejection checks use paired current
compile_fail and compiling doctests. Publish new benchmark and validation artifacts on the
corresponding ClickUp task; keep durable architectural conclusions in the chapter.
Source coverage of the native extra checks
The extra checks that execute Nervix code natively in ordinary mode, the compiler synchronization fixtures, benchmark bodies, primitive boundary conformance and NSPL completion walk, also measure which source lines they execute. Run them the way CI does:
just coverage-native-extras
Name producers to run only those: test-typed-ratchet, bench-smoke, nspl-completion-walk,
test-shuttle, test-loom, test-turmoil, test-deadlock-evidence-order, test-deadlock-report,
test-deloxide, test-deloxide-order, test-deloxide-stress, test-deloxide-stress-restore, or
an individual test-primitives-<mode> recipe. The focused diagnostic owner and probes use the
deloxide-order build; the local report command uses the ordinary build; each diagnostic lane
builds in the instrumented build of the feature its selection compiles, the two stress lanes in one,
and exports only after its
lane.json records a complete run. test-primitives
selects native conformance in ordinary, Shuttle, Loom, Turmoil and Deloxide execution:
just coverage-native-extras nspl-completion-walk
just coverage-native-extras test-primitives
just coverage-native-extras test-shuttle test-loom
just coverage-native-extras test-turmoil
just coverage-native-extras test-shuttle --filter cancellation
just coverage-loom target/loom.lcov execution.cancellation
A producer runs its check exactly as just <producer> does and fails when the check fails.
rust-toolchain.toml includes the llvm-tools component because only the compiler’s own LLVM
tools read its profiles. What the check needs first, such as the web console the server benches
link, is built normally. The recipe
that executes Nervix code then runs in the environment cargo llvm-cov show-env --sh --no-rustc-wrapper describes: every crate is compiled with source coverage instrumentation into
target/native-coverage-build for ordinary mode and target/native-coverage-build-<mode> for
each other mode, and the configured kache wrapper stays in place. The test-deloxide,
test-deloxide-order, test-deloxide-stress and test-deloxide-stress-restore lanes instrument
only workspace crates: the collector moves the
instrumentation flags into scripts/coverage_workspace_wrapper.py, which Cargo runs beneath kache
as the workspace compiler wrapper, the scope cargo llvm-cov gives the ordinary coverage build.
Their nodes must meet product deadlines while they compile WASM processors and process Arrow state,
and instrumented dependencies made that several times slower; the report is the same, because
export keeps only repository sources. Kache runs a workspace wrapper chain directly, so their
workspace crates are compiled each run while dependencies come from the cache. Each mode has a
separate build lock through export. The parts that only compile or target the browser stay uninstrumented;
just test-primitives-compile retains their independent verdict. Miri, mutation testing and
just test-loom-qualification never run under this command. The compile and qualification
commands run without the collector’s profile environment, so altered source cannot contribute to
the current-source report.
Every run collects into a directory of its own,
target/native-coverage/<producer>/<mode>/<toolchain>/<attempt>/. The attempt is the GitHub
Actions run and attempt in CI, and the time and process locally. No run reuses, reads or cleans
another run’s directory, so no run can count another’s counters. The directory holds:
| Entry | Content |
|---|---|
lcov.info | The LCOV report of the repository’s sources, with the absolute paths llvm-cov writes |
completion.json | The completion record: the verdict and everything it covers |
executions.jsonl | Every executable Cargo ran, with its arguments and build ID |
models.json | For Shuttle and Loom, canonical discovery, selection, execution and completion evidence |
export.log | The diagnostics of llvm-profdata and llvm-cov, each bounded |
profiles/ and merged.profdata | The raw counters and their merge; they stay on the machine |
Cargo runs each instrumented executable through the collector as its runner, which records the
execution and points the counters of the executable, and of every process it starts, at profiles/,
one file per process and module. Build scripts and procedural macros execute only when a crate is
compiled rather than served from the cache, so their ordinary build-time counters are discarded.
The compiler fixture is itself an executed check: its loaded procedural macro objects are retained
by build ID, including those in nested Cargo output directories. The producer uses its pinned
compiler’s LLVM tools. The report is exported with the toolchain’s own
llvm-profdata and llvm-cov from exactly the executables that ran and the children they started.
It keeps the files inside the repository and leaves out dependencies, harness code below a tests,
examples or benches directory, and code generated below a Cargo target directory.
completion.json names the producer and mode, the tested commit and whether the working tree was
modified, the CI run and attempt, the compiler and its LLVM version, the instrumentation flags, the
recipes that make up the check, the executions and child executables selected, the profile files
with the binary IDs they name, the warnings llvm-cov printed, and the executable and covered lines
of each package. Model producers also embed models.json: exact package/test identities,
InvariantIds and bounds for Loom, and paired exploration/nondeterminism records for Shuttle.
The canonical runners validate their own inventories and filters. Missing or ignored checks,
zero overall selection, incomplete exploration and nondeterminism failures retain their failure
verdicts. A package with no filter match is allowed within a nonempty cross-package selection.
Its verdict reads running from the moment the directory exists. It becomes
complete only once the whole check passed and the report was written, and failed or
interrupted otherwise, with a failure naming the stage, prepare, instrument, run, export
or finish, and what went wrong. A collection fails when the recipe ran no executable, when an
executable it ran is gone or was rebuilt before the export, when one wrote no profile, or when a
profile is unreadable or names an executable that is no longer there. Every attempt keeps its
profiles, so a failed one keeps its evidence; remove target/native-coverage when the collections
are no longer needed.
Every profile is matched to the executable that wrote it before the export, so a warning from
llvm-cov never means a stale profile. When several executables share a function that only some of
them run, such as an inlined dependency function, the copies that never ran can carry a different
hash, and llvm-cov warns that they have mismatched data and reads the function from the
executables that ran it.
CI’s extra-tests job collects native extras and per-mode primitive conformance, and runs primitive
compile checks independently. The shuttle job, which runs only for a pull request labeled
shuttle, collects its complete inventory, including random/PCT exploration and paired
nondeterminism checking, and runs schedule replay qualification independently. For a pull
request labeled turmoil, the turmoil job collects the canonical simulation suite once,
including each fresh-process attempt, and runs failure replay qualification independently.
For a request labeled loom, the loom job collects the complete Loom inventory, and two
loom-qualification jobs split weakening qualification into shards, because it rebuilds the server
once for each weakening of a server owner. The jobs upload lcov.info, completion.json,
executions.jsonl, export.log and model evidence as coverage-native-extras,
coverage-shuttle, coverage-loom and coverage-turmoil, whatever the verdict. The
ordinary workspace report merges ordinary collections only; mode
reports describe modeled or diagnostic execution and retain separate attribution.
just coverage-shuttle <output> [filter],
just coverage-loom <output> [filter] and just coverage-turmoil <output> copy a successfully
completed canonical report to the
requested path while retaining its attempt evidence. Replay uses the unchanged exact-check
commands and the failure directory under the matching mode build directory.
The benchmark bodies run instrumented, so
their Criterion test mode shows that each body executes and measures nothing. The collector itself,
including real instrumented runs of a fixture crate and its refusal of every incomplete collection,
is tested with:
just test-native-coverage
The ordinary workspace report
Every ordinary coverage consumer reads one report, lcov-workspace.info: patch line coverage, the
CRAP report and the coverage-merged artifact. just coverage-merge builds it from the
ordinary producers’ reports, and fails, writing no report, rather than publish an incomplete one.
just test-coverage and just test-scenarios-coverage export lcov-workspace.info from every
profile they collected with --no-default-ignore-filename-regex, which keeps every workspace crate
and the test sources the run executed. Each writes the absolute checkout path it ran in to
coverage-source-root.txt beside the report. just coverage-report-workspace writes the same pair
from the profiles a focused recipe collected. CI uploads the pair as the coverage-tests and
coverage-scenarios artifacts, the second with the Python paced drivers’ report, and the
extra-tests job uploads its native collections and Python tooling reports as
coverage-native-extras. The coverage job then runs:
just coverage-merge coverage-inputs/tests coverage-inputs/scenarios coverage-inputs/native-extras
An input is a producer’s artifact directory or one report, with the producer’s
coverage-source-root.txt in the directory or beside the report. The merge reads the workspace
export, every Python tooling report named python.lcov, and every complete ordinary native
collection below native-coverage. A collection of another mode describes modeled or diagnostic
execution, so it is never read: the Shuttle, Loom, Turmoil and Deloxide primitive conformance
collections in the native extras artifact are recorded as left out. The reports of the shuttle,
loom, turmoil, deloxide and Bolero fuzz jobs are not inputs at all.
Every record is mapped onto the repository. The merge strips the producer’s checkout root and
resolves .. segments lexically, so the record of a #[path] module names the file it includes,
and it keeps a record only when its file is tracked at the tested revision, test sources included.
It drops the rest and counts the dropped files by class: dependencies below ~/.cargo/registry,
~/.cargo/git and /kache/.cargo/registry, the standard library below /rustc/<commit> or a
local rustup toolchain, files generated below the checkout’s Cargo target directory, such as the
FlatBuffers OUT_DIR sources, untracked files, and any other path outside the checkout. The counters of every report that
measured a file are summed. The report holds line and function counters. Branch records are left
out: the Rust producers export none, and coverage.py names Python branches in words that LCOV readers
such as cargo crap refuse.
The merge fails with a message naming what to fix when:
- an input is missing, or holds neither a workspace export nor a complete ordinary collection;
- an input has no root metadata;
- none of a report’s records lies below its producer’s checkout root;
- a report contributes no record of a tracked file;
- a native report has no complete ordinary completion record beside it, or one producer has two collections;
- a workspace member that
cargo metadatalists has no Rust record in the merged report.
Either way it writes its merge record, target/coverage-merge/record.json: the verdict and the
tested revision, every input with its checkout root, each report it read with its record counts and
its dropped records by class, the collections it left out, the dropped files by class, and the
workspace members the report measures. CI retains the report and its record as the
coverage-merged artifact.
Without inputs, the merge rewrites in place the lcov-workspace.info that just test-coverage
wrote, into the shape CI publishes, and the CRAP report and patch line coverage read it:
just test-coverage
just coverage-merge
just check-coverage
just coverage-patch origin/main
just test-coverage-merge exercises the mapping, every integrity failure and the CI contract, and
just coverage-merge-runner exports the merge’s measured Python coverage for CI.
CRAP report
just check-coverage publishes an advisory CRAP report over the merged ordinary workspace report.
It runs the pinned cargo-crap, the version scripts/cargo_crap.py declares for the complexity count
too, over the whole repository root. The root package’s directory holds every workspace member,
and the Cargo projects nested in it, such as the lint workspace, are walked as well, because some
ordinary producers measure them. No default exclusion applies, so test, bench and example sources
are scored like any other. The analyzer is handed each record of the report under its absolute
path, which binds the record to its own file. A relative path would also bind every other file
whose path ends with it, such as another crate’s src/lib.rs, to that record’s coverage.
A function’s CRAP score is complexity² × (1 − coverage)³ + complexity, from its cyclomatic
complexity and the share of its executable lines the report covers, so at full coverage it equals
the complexity. The report lists every function that scores above 30. A function in a file that
the report holds no record of is unmeasured: mode-only sources such as primitive adapters and
Shuttle checks, build scripts, benches, the lint workspace’s fixtures and the WASM guests. The
report lists unmeasured functions with their complexity and never scores them as uncovered. A
report file in which the analyzer scores no function, such as a file of #[test] functions, is
listed as read without a scored function.
No CRAP score fails the check. Scores move from run to run with the coverage the suites happen to record, and the mandatory complexity verdict is the ratchet count above. The check fails only when the analysis is vacuous, that is when it cannot describe the merged report:
- the merge record is missing, did not complete, or names another revision than the checkout’s;
- a record of the report names a source the checkout does not track, such as a path below another checkout’s root;
- the analyzer’s walk cannot read a Rust file the report holds: a hidden path, a symbolic link or a file Git ignores;
- the analyzer measured functions in a report file without binding its record to them;
- the analysis matched no report file.
Whatever the verdict, it writes target/crap-report/crap.json and target/crap-report/crap.md.
The JSON report holds the verdict and its cause, the analysed revision, the scope counts, every
measured function with its complexity, coverage and score, and every unmeasured function. The
Markdown report lists the functions above 30, the unmeasured files and the files without a scored
function. CI’s coverage job runs the check after the merge and retains both reports in the
coverage-merged artifact. The patch line coverage comment carries the CRAP section: the functions
above 30 in the files the pull request changes, the project’s total above 30 and the unmeasured
count, or the cause of a vacuous analysis.
The reports name the analysed report by its file name, so a retained coverage-merged artifact
reproduces its reports byte for byte without the coverage binaries, in a checkout of the revision
its merge record names:
just check-coverage <artifact>/lcov-workspace.info <artifact>/target/coverage-merge/record.json
just test-crap-report exercises the analysis, every vacuity failure and the CI contract, and
just coverage-crap-report-runner exports the report’s measured Python coverage for CI.
Patch line coverage
CI runs just coverage-patch against the merged ordinary workspace report and updates one PR
comment with patch line coverage, project line coverage, per-file totals and uncovered added line
numbers. The comment names the merge base and tested commit and links to the workflow artifacts.
Patch coverage is advisory: no percentage is a build gate, and a missing report or a failed comment
publication does not fail CI. The complexity verdict is the just ratchet count of functions
above cyclomatic complexity 30, which reads no coverage. With --crap target/crap-report/crap.json,
as CI passes it, the comment also carries the section of the CRAP report for the
files the pull request changes, including the files it only shortens.
The label-enabled Turmoil job publishes a separate Turmoil patch coverage comment using
--scope turmoil after its canonical instrumented suite and independent replay qualification
pass. The scope selects a distinct marker, so the two jobs update their own comments regardless
of completion order. This report describes only the current Turmoil build’s product sources;
it does not merge modeled counters into the ordinary report or its CRAP report. Failed
collection keeps its verdict and diagnostics without publishing a successful coverage report.
Run the same calculation manually after collecting coverage for the committed source revision:
just coverage-patch origin/main lcov-workspace.info
just coverage-patch origin/main lcov-workspace.info --pr 123 --repo nervix-io/nervix
just coverage-turmoil target/turmoil.lcov
just coverage-patch origin/main target/turmoil.lcov --scope turmoil --output target/turmoil-patch-coverage.md
The first command prints the Markdown report and writes target/patch-coverage.md; the second also
creates or updates the authenticated publisher’s marked PR comment through gh. Additional
--report <path> arguments union line hits from multiple LCOV reports. Coverage must describe the
tested commit’s line coordinates. The reporter maps sources onto tracked files as
just coverage-merge does, so a .. segment names the file it reaches, and the merged report,
already relative to the repository, needs no checkout root. CI’s reporter reads that report, so the
different checkout paths of Blacksmith and GitHub-hosted runners are resolved once, by the merge. A
focused local report, such as the Backup and Visual Create coverage recipes write, describes the
local checkout and needs none either. For reports collected in another checkout, use
--source-root <original-checkout-path>, or --source-root-file with the producer’s
coverage-source-root.txt, to remap absolute source paths explicitly. Repeat either when reports
come from multiple source roots.
The command compares the base’s merge base to HEAD, or to --head <commit>. Only added lines with
LCOV DA counters enter its denominator; repeated records count each file/line once, and a hit in
any report covers the line. Deleted lines, unchanged lines and non-executable additions do not
enter it. A changed file absent from LCOV is shown as unavailable. An empty measured patch has no
percentage. Project coverage describes the tracked files present in the supplied reports; no
baseline project report is assumed.
CI compares the PR base to the tested synthetic merge commit, so the diff uses the same line
coordinates as the collected coverage. It passes the PR head separately with --pr-head and checks
that the PR is still open at that head before publication. Fork PR tokens may lack comment access;
the local report, workflow summary and artifact remain available. just test-patch-coverage
exercises accounting and publication, and just coverage-patch-runner exports the reporter’s
measured Python coverage for CI.
The scenario suite’s execution budget
The Cucumber suite bounds its own run. A step, a teardown diagnostic or a node stop that never
returns ends the whole run at the budget rather than leaving the process alive until CI cancels the
job, which kills it mid-scenario with no record of what each scenario was doing. When the budget
expires the suite prints every scenario still active with its attempt, its phase, how long it has
been in that phase and the cluster nodes it holds, asks every live node to stop, waits one bounded
cleanup window for them, and exits with status 124. A passing run still exits 0 and a failing
one still panics, so a wedged suite is told apart from a failing one by the exit status alone.
The default budget leaves the workflow job time for the work that precedes the suite and for the
artifact upload that follows a timeout. Give a run a budget of its own with --suite-budget or the
NERVIX_TEST_SUITE_BUDGET environment variable, which is how the timeout path is exercised without
waiting out the suite’s own budget:
just test-scenarios --input tests/features/cluster/rejoin.feature --suite-budget 5s
The focused regressions that hold the harness’s startup, status, teardown and watchdog budgets run in seconds:
just test-harness-liveness
Integration Test Lifecycle defines every harness deadline, the phases a scenario reports, and how each failure reaches CI output.
Building The Documentation
just book <version> renders this book, and just book-pdf <version> additionally produces
nervix.pdf through pandoc and XeLaTeX.
Both recipes require the exact mdBook release pinned by MDBOOK_VERSION in
scripts/build_book.py, and they fail with that version in the message when a different one is
installed. CI resolves the same constant, so a local build always matches what is published. The
pin is exact because the rendered theme targets mdBook’s internal element IDs and because the PDF
relies on mdBook rewriting print-page links into in-document anchors — on an older release every
cross-chapter link in the PDF would point back at the website instead.
Install the pinned release with:
cargo install mdbook --version <MDBOOK_VERSION> --locked
Building the PDF also needs pandoc and a XeLaTeX installation on PATH.
License
Nervix is licensed under the Fair Core License (FCL).
The full license text is included in the repository root as LICENSE.md.