Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Rust 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(...) and Client::connect_with_options(...)
  • Client::execute(...), and Client::prepare_execution(...) with Client::execute_prepared(...)
  • Client::transaction_status(), Client::attach_transaction(...) and Client::inspect_transaction(...)
  • Client::list_domains(), Client::domain() and Client::set_domain(...)
  • Client::subscribe(...), Client::unsubscribe(...) and Client::next_subscription()
  • Client::attach_domain_clock(...), Client::detach_domain_clock(...), Client::domain_clock(...) and Client::next_domain_clock_event()
  • Client::open_ingestor(...), with Producer::send(...), Producer::submit(...), Producer::rejoin(...), Producer::pending_submissions(), Producer::release(...) and Producer::close()
  • Client::subscribe_emitter(...), with EmitterConsumer::next_batch(), EmitterDelivery::ack(), EmitterDelivery::retry(), EmitterDelivery::reject(...) and EmitterConsumer::close()
  • Client::upload_resource_from_directory(...)
  • Client::download_backup(...)
  • Client::restore(...) and Client::restore_with_reference(...)
  • Client::next_server_event(), Client::next_domain_list() and Client::leadership()
  • Client::suggest(...) behind the autocomplete feature
  • Client::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_of tells an NX_CLOCK_EVENT_STATE, NX_CLOCK_EVENT_TICK, NX_CLOCK_EVENT_ENDED, NX_CLOCK_EVENT_INTERRUPTED or NX_CLOCK_EVENT_RESTORATION_FAILED event apart, and nx_clock_event_domain borrows 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_generation reads the START generation of a state or tick event, nx_clock_event_state the installation state of a state event, nx_clock_event_paced the period, skew, logical origin, UTC anchor and time rate of a paced one, nx_clock_event_tick the id, logical boundary, authority UTC observation and serving node’s logical reading of a tick, and nx_clock_event_end_reason why the server ended an attachment. Each fails with NX_ERROR_TYPE for 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_retain and nx_clock_event_release count references the way nx_event_retain and nx_event_release do, 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_generation and nx_domain_clock_state read the domain, the generation and the installation state. nx_domain_clock_paced reads the period, skew, logical origin, UTC anchor and time rate of a paced clock and fails with NX_ERROR_TYPE for any other state, and nx_domain_clock_tick says 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_window and nx_domain_clock_admits answer with the arithmetic of AttachedDomainClock: the logical time at a UTC instant, rounded down; the physical wait until a logical target, rounded up; the oldest and newest tick centers a TIMESTAMP AT ingestor 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 with NX_ERROR_TYPE, and a result outside the timestamp range with NX_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() reports Interrupted for 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_add starts a field with its name, outermost type, nullability and sensitivity, and nx_fields_element adds the element of each list around it, so ARRAY<I16, 2, 2> is a FIXED_LIST of 2, a FIXED_LIST of 2, then I16. nx_session_open_ingestor and nx_session_subscribe_emitter open a producer or a consumer on a domain named explicitly, with the credit the host asks for, and copy the fields. A refusal is NX_ERROR_REJECTED, and nx_error_open_refusal reads its nx_open_refusal, such as NX_OPEN_SCHEMA_MISMATCH.
  • Description. nx_producer_schema, nx_consumer_schema, *_generation, *_contract, *_grant and *_policy read what the open established: the schema with every field’s nullability, sensitivity and type levels, the START generation, the contract digest, the granted credit and batch limits, and the acknowledgement window, ACK timeout and backoff. nx_producer_state and nx_consumer_state read the connection, nx_producer_admission the admission, and *_reopen_reason why a handle needs a new open.
  • Submitting. A host builds a batch from its own buffers with nx_batch_builder_new for the producer’s schema: one nx_batch_builder_states call per nullable column, one nx_batch_builder_offsets call per variable-length list level, and one nx_batch_builder_fixed or nx_batch_builder_varlen call for the innermost values. Every call copies its buffers before it returns, and nothing is converted. nx_producer_submit waits for credit, checks the batch against the producer’s schema and row limit, and returns the submission’s identity; nx_producer_rejoin waits for its outcome and takes it, and nx_submission_outcome_* read its class, typed cause and message. nx_producer_pending lists what the producer holds and nx_producer_release lets a submission go. A host that writes canonical Arrow IPC itself submits it with nx_producer_submit_ipc, which copies it and leaves its checking to the server.
  • Consuming. nx_consumer_next returns a reference-counted nx_delivery: its identity, reference, source relay, branch fingerprint, member count and execution time, its stream, borrowed with nx_delivery_ipc, and its batch, which nx_delivery_batch decodes the first time it is asked for and then shares. A batch is read one level of a column at a time: nx_batch_states for the rows, nx_batch_offsets for each variable-length list level, and nx_batch_fixed or nx_batch_varlen for the values, each in one call. nx_delivery_ack, nx_delivery_retry and nx_delivery_reject settle the attempt and return the server’s nx_settlement. Releasing a delivery settles nothing.
  • Interruption. After a session ends, the next nx_consumer_next returns NX_ERROR_INTERRUPTED once, a delivery read before the gap fails to settle with NX_ERROR_REJECTED, a submission sent before it is NX_UNCERTAINTY_SESSION_LOST, and a settlement whose answer the session lost is NX_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.