iClaustron training for Rust developers · module 1 of 9 · 15 min

iClaustron and its Rust API

A library that makes a program an API node of RonDB 26.10: where it sits, how Rust calls it, how it fails, and the five ways to program it.

What it is · an API node in a library

iClaustron: a library that makes your program an API node of RonDB

A library

Linked into the application. It joins a RonDB 26.10 cluster as an API node and speaks the NDB protocol to the data nodes itself.

Rust, MIT licensed

Written in Rust, in eight crates. A Rust program uses the crate ic_apid directly.

A C ABI

libiclaustron, shared and static, with the header iclaustron/ic_apid.h and a pkg-config file: for C, C++ and any language that calls C.

[dependencies]
ic_apid = { git = "https://github.com/iClaustron/iclaustron", branch = "RUST-iclaustron" }
ic_port = { git = "https://github.com/iClaustron/iclaustron", branch = "RUST-iclaustron" }

iclaustron: README.md; LICENSE; doc/user-guide.md (opening, §1 Building and linking: from Rust); Cargo.toml (workspace members); crates/ic_capi/Cargo.toml ([lib] cdylib, staticlib); examples/rust/Cargo.toml ([dependencies])

Scope · RonDB 26.10, release 0.1 and after

What iClaustron does today, and what it does not do yet

Does

  • Key operations by primary key and unique key
  • Transactions: commit, rollback, hints
  • Scans of tables and ordered indexes
  • Programs run in the data nodes
  • AUTO_INCREMENT; tables without a primary key
  • Node failure: heartbeats, reconnects, takeover

Not yet

  • Blob columns: BLOB, TEXT, JSON, GEOMETRY
  • Schema changes: use a MySQL server
  • TLS; data nodes chosen by location domain
  • Events, pushdown joins, aggregation
  • Windows: Linux and macOS only

iclaustron: doc/user-guide.md (opening, §10 Node failure, §12 What is not done yet); crates/ic_apid/src (key_op.rs, transaction.rs, scan.rs, index_scan.rs, program.rs, auto_increment.rs); crates/ic_apid/src/handshake.rs (TLS disabled)

Where it sits · application, API node, data nodes

Where iClaustron sits: inside the application, linked to every data node

application process your codethe application's threads call the Rust API iClaustron · API nodethe crate ic_apid, linked in,with threads of its own (module 02) management servernode ids, configuration · port 1186 data node 1coordinator · the rows of its fragments data node 2coordinator · the rows of its fragments node id, configuration NDB protocol: KEY_OP_REQ out; KEY_OP_CONF, ROW_DATA_REP back

The application links the library. Its calls run on the application's own threads; the library adds a few threads of its own (module 02).

ApidGlobal::connect asks a management server for a node id and the cluster's configuration: the program is now an API node.

It then links to every data node and speaks the NDB protocol directly: requests out, replies and rows back. No MySQL server on the path.

iclaustron: doc/user-guide.md (§1 What you need, §2 Connecting); crates/ic_apid/src/apid_global.rs (ApidGlobal::connect, ApidGlobal::start_with_options); crates/ic_apid/Cargo.toml (description); doc/rust/05-ndb-protocol.md (Names)

Inside the library · eight crates

The eight crates: the C ABI on top, the portability layer at the bottom

C, C++, others Rust programs ic_capithe C ABI: libiclaustron, shared and static, and the header ic_apid.h ic_apidthe Data API: connections, queries, transactions, scans, executors, programs ic_apicconfiguration client, management server ic_signalsNDB signals: numbers, blocks, framing ic_commsockets, poll sets, buffer pages ic_protocolbase64, management protocol words ic_utilcontainers, bitmaps, arrays, strings, a thread pool ic_portportability: the operating system, locks, time, errors, tracing

Each crate uses only crates below it. The Rust API in ic_apid is the implementation; ic_capi is a thin wrapper, one exported function per method.

iclaustron: Cargo.toml (workspace members); crates/*/Cargo.toml (description, dependencies; ic_capi [lib]); doc/rust/04-api-design.md (Three layers, one implementation)

Rust conventions · results

Rust API results: every call that can fail returns a Result

use ic_apid::key_op;
use ic_port::IcError;

fn run(args: &[String]) -> Result<(), IcError> {
  let (_global, mut conn) = connect(args, "ex_key_ops")?;
  // ... T1::bind, the first insert ...
  // A second insert of the same key is refused, and the error says so.
  match write(&mut conn, &t1, key_ops::IC_OP_INSERT, &values) {
    Err(e) if e.code as u32 == key_op::IC_NDB_ERROR_ROW_EXISTS => {
      println!("a second insert: error {}, as it should", e.code)
    }
    Err(e) => return Err(e),
    Ok(()) => return Err(IcError::new(err::IC_ERROR_INCONSISTENT_DATA)),
  }
  • Every call that can fail returns Result<_, IcError>: what it makes in Ok, the error in Err; ? hands it up.
  • An IcError is its code alone, code: i32: Copy, a std::error::Error, displayed as the code and its text.
  • key_op's NDB codes are u32 constants: hence e.code as u32.
  • Each item is named from its module, as the examples do: ic_apid::key_op, ic_port::IcError.

iclaustron: crates/ic_port/src/err.rs (IcError, Display, std::error::Error); crates/ic_apid/src/key_op.rs (IC_NDB_ERROR_ROW_EXISTS); examples/rust/key_ops.rs (run, write); examples/rust/lib.rs (exit_on); doc/user-guide.md (§1 Building and linking, §9 What an error says)

Rust conventions · option and argument structs

Structs with Default: set what you need, ..default() for the rest

#[derive(Clone, Copy, Debug, Default)]
pub struct WriteKeyArgs {
  pub kind: WriteKind,            // default: Update
  pub abort_option: AbortOption,  // default: Default
  pub batch_hint: BatchHint,      // default: None
  // ... user_ref, callback, binding ...
}

// retry.rs
let update = WriteKeyArgs {
  kind: WriteKind::Update,
  ..WriteKeyArgs::default()
};
  • Options and arguments are structs with public fields and Default: GlobalOptions, ExecutorOptions, ReadKeyArgs, WriteKeyArgs, ScanArgs, …
  • Name the fields you set; ..WriteKeyArgs::default() fills the rest, so a field a later release adds takes its default.
  • Kinds are enums with a default: WriteKind, ReadKind, AbortOption, BatchHint.
  • No rows in the arguments: a query owns its rows (module 04).

iclaustron: crates/ic_apid/src/query.rs (ReadKeyArgs, WriteKeyArgs, WriteKind, ReadKind, AbortOption, BatchHint); crates/ic_apid/src/apid_global.rs (GlobalOptions, Default); crates/ic_apid/src/executor.rs (ExecutorOptions, Default); crates/ic_apid/src/scan.rs (ScanArgs); examples/rust/retry.rs (run)

Rust conventions · ownership and ids

Ownership: values you drop; queries, transactions and scans are ids

Owned values

  • ApidGlobal, ApidConnection, Record, Executor, …: dropping one lets it go. Tables come as Arc<TableDef>.
  • A connection holds an Arc of its global, not a borrow: drop connections and executors first, then the global, which stops the threads.
  • ApidConnection is Send; what changes it takes &mut self: one thread at a time.

Ids

  • QueryId, TransId, ScanId: Copy ids in their connection, freed by free_query, close_transaction, free_scan.
  • A slot and its generation in one word; a free or a close moves the generation on.
  • conn.query(q) is None for an old id, and calls refuse it, never follow it: a later object always has an id of its own.

iclaustron: crates/ic_apid/src (apid_global.rs: ApidGlobal, Drop; apid_conn.rs: ApidConnection, Drop; executor.rs: Executor::start; query.rs, scan.rs: QueryId, TransId, ScanId; transaction.rs: query, free_query); crates/ic_util/src/ptr_array.rs (PtrArray::get); doc/user-guide.md (§2, §4)

Rust conventions · rows

Rows and records: a row is bytes, a record says where each field lies

// id at 0; val at 4, null bit 0; code at 8; name at 12, its length
// byte and then 32 bytes, null bit 1; the null bits in byte 45
let spec = [
  FieldSpec::not_null(id_of_id, 0),
  FieldSpec::nullable(id_of_val, 4, 45, 0),
  FieldSpec::not_null(id_of_code, 8),
  FieldSpec::nullable(id_of_name, 12, 45, 1),
];
let rec = Record::new(&table, &spec, 48)?;

// or laid out by the library, as the examples' T1::bind does
let rec = Record::default_for(&table, None)?;
let mut row = vec![0u8; rec.row_size() as usize];
  • A row is a slice of bytes. A record lists, for each field (its id from table.field_id), its offset and its null bit.
  • Values are kept as the data nodes keep them: little-endian integers, a VARCHAR as a length byte, then its bytes.
  • Record::default_for lays a row out for you.
  • rec.field(pos) gives a field's offset() and size(); the examples' put_u32 writes to_le_bytes() there.

iclaustron: doc/user-guide.md (§3 Tables, indexes and records; §5 Synchronously); crates/ic_apid/src/record.rs (FieldSpec, Record::new, Record::default_for, field, row_size); examples/rust/lib.rs (T1::bind, T1::row, place, put_u32)

Connecting · from examples/rust/lib.rs

Connecting: start a global, wait for the data nodes, make a connection

// connect(args, name): the global and a connection, or an IcError
let global = ApidGlobal::connect(
  connect_string(args),
  Some(name),
  &GlobalOptions::default(),
)?;
if global.wait_for_all_started(EX_START_WAIT_MS) == 0 {
  return Err(IcError::new(err::IC_ERROR_NO_STARTED_DATA_NODE));
}
let conn = global.create_connection()?;
Ok((global, conn))
  • The connectstring lists management servers, host[:port], comma separated; nodeid=N asks for that node id.
  • connect fetches a node id and the configuration, starts the library's threads and returns; data nodes link in the background.
  • wait_for_all_started waits up to 30 s here, for every data node, and returns how many started.
  • The caller gets both, and keeps the global as long as the connection is used.

iclaustron: examples/rust/lib.rs (connect, connect_string, EX_START_WAIT_MS); crates/ic_apid/src/apid_global.rs (ApidGlobal::connect, wait_for_all_started, wait_for_first_started, create_connection); doc/user-guide.md (§2 Connecting)

Connecting · the global's options

Global options; one global per cluster, one connection per thread

GlobalOptionsDefaultMeaning
receive_threads1threads reading the data nodes' links; one per data node at most
reply_wait_ms0: 6 minhow long a transaction waits for replies before it ends with 4012
mgm_timeout_ms30 000how long a management server has to answer
auto_increment_prefetch4 096values one refill reserves for a table
  • One global per cluster: a program that talks to two clusters starts two.
  • A connection is one thread's view: its queries, transactions, replies. One thread at a time uses it.
  • At most 4 711 connections per process, an executor's own included (7139).
  • The global runs receive threads, a send pool, a heartbeat thread and a connect thread per data node: module 02.

iclaustron: crates/ic_apid/src/apid_global.rs (GlobalOptions, Default, IC_MAX_REPLY_WAIT_MS, start_with_options, create_connection); crates/ic_apid/src/thread_conn.rs (IC_ERROR_TOO_MANY_USER_THREADS); crates/ic_apid/src/executor.rs (Executor::start); crates/ic_port/src/consts.rs (IC_MAX_THREAD_CONNECTIONS); doc/user-guide.md (§2)

A first program · examples/rust/key_ops.rs

A first program, part 1: bind a table, build a record, insert a row

let (_global, mut conn) = connect(args, "ex_key_ops")?;
let t1 = T1::bind(&mut conn)?;
let mut values = Values {
  id: 1,
  val: Some(10),
  code: 100,
  name: Some("one"),
};
write(&mut conn, &t1, key_ops::IC_OP_INSERT, &values)?;

// fn write(conn, t1, operation, values) -> Result<(), IcError>
let mut row = t1.row();
t1.fill(&t1.rec, &mut row, values)?;
key_op::write_key(conn, operation, &t1.rec, &row, &t1.rec, &row)
  • T1::bind (lib.rs): table_bind, then Record::default_for.
  • table_bind fetches the table's description from the cluster, once for all connections of the global.
  • One record serves as key record and attribute record: it holds the primary key.
  • key_op::write_key is a transaction of its own, sent and waited for; the operation is an IC_OP_* of ic_signals::key_ops.

iclaustron: examples/rust/key_ops.rs (run, write); examples/rust/lib.rs (connect, T1::bind, T1::row, T1::fill, Values); crates/ic_apid/src/key_op.rs (write_key); crates/ic_signals/src/key_ops.rs (IC_OP_INSERT); doc/user-guide.md (§3, §5 Synchronously)

A first program · examples/rust/key_ops.rs

A first program, part 2: read the row back, then drop in order

// read_and_print(conn, t1, id, what)
let mut key = t1.row();
put_u32(&t1.rec, &mut key, t1.id, id)?;
let mut row = t1.row();
if key_op::read_committed(conn, &t1.rec, &key, &t1.rec, &mut row)? {
  println!("{}: {}", what, t1.show(&row));
} else {
  println!("{}: no row {}", what, id);
}

fn run(args: &[String]) -> Result<(), IcError> {
  let (_global, mut conn) = connect(args, "ex_key_ops")?;
  // ... T1::bind, insert, read, update, delete ...
}   // dropped here: t1, conn, then _global
  • A committed read takes no lock: the last committed value.
  • No row is no error: the call returns Ok(false).
  • Nothing to free: as run returns, its locals are dropped in reverse order of declaration, so the global goes last and stops the library's threads. Bind it as _global: _ would drop it at once.

iclaustron: examples/rust/key_ops.rs (read_and_print, run); examples/rust/lib.rs (connect: keep the global; put_u32); crates/ic_apid/src/key_op.rs (read_committed); crates/ic_apid/src/apid_global.rs (ApidGlobal: dropping it stops every thread)

Errors · what a code says

Errors: a code with a category, a severity and a text

Where from

7000 and up: iClaustron's own, IC_ERROR_*. Small positive: the operating system's. The rest: NDB's, as the data nodes report them, IC_NDB_ERROR_*.

Category

e.category(): User, the request was wrong (no such row, a duplicate key); Internal; External, a node failure, an overload, a timeout, a lost link.

Severity

e.severity(): Temporary, the same request may well succeed if tried again; Error, something must change; Stop, the library can no longer be used.

e.message() gives the code's text. e.is_temporary() is the question to ask first. Category, severity and text all come from the code. Modules 04 to 08 meet the codes of each model.

iclaustron: crates/ic_port/src/err.rs (IcError: category, severity, is_temporary, message; ErrorCategory, ErrorSeverity, ic_class); crates/ic_port/src/ndb_err.rs (ErrorClass::category, ErrorClass::severity); doc/user-guide.md (§9)

Errors · when to try again, from examples/rust/retry.rs

Retrying: a temporary error means the whole transaction again

loop {
  // ... copy the row into the query's key and attribute rows ...
  let trans = conn.start_transaction(hint, Some(&t1.table))?;
  conn.write_key(updater, trans, &update)?;
  conn.commit_transaction(trans)?;
  match committed(&mut conn, trans, updater) {  // flush until done
    Ok(()) => { /* ... */ break; }              // committed
    Err(e) if e.is_temporary() && attempt < ATTEMPTS => { /* ... */ }
    Err(e) => return Err(e),
  }
  // ... the first time round, the other lock on the row is let go ...
  std::thread::sleep(Duration::from_millis(100 * attempt));
  attempt += 1;
}
  • Temporary (266 lock wait, 4010 node failure, 7135 link lost, …): a new transaction, every query again, after a pause.
  • 4012: the replies stopped, the outcome is unknown. It counts as temporary, but retry a write only once you know it did not commit, or when doing it twice does no harm.
  • A user error, such as 626 no row, does not clear up by itself.
  • committed (lib.rs): flush until done, close, and return the error.

iclaustron: examples/rust/retry.rs (run, ATTEMPTS); examples/rust/lib.rs (committed, finish); crates/ic_port/src/err.rs (IcError::is_temporary); crates/ic_apid/src/transaction.rs (IC_NDB_ERROR_NO_REPLY); crates/ic_port/src/ndb_err.rs (ErrorClass::severity: UnknownResult); doc/user-guide.md (§9 When to try again)

Five programming models · at a glance

The five programming models: what the caller writes and what it gets

Model · modulesThe caller writesThreadsIn flightBatchingReads a second
Synchronous04 · 07one blocking call per query, on its own rowsthe caller, with its connection1 per threadnone: a send per call198 000 · 64 threads
Batched synchronous04 · 07queries on transactions, then flush until donethe caller, with its connectiona batch per threadone send per data node for the batch1.17–1.35 M · 1 thread, batch 200
Asynchronous05 · 07queries with callbacks; send, then pollthe caller, with its connectionmany transactions per threadall defined since the last send2.6 M · 1 thread, depth 4
Synchronous through an executor06 · 07a request of one operation, executedthe callers, no connection; the executor1 per calleracross callers, by the executor268 000 · 64 callers, 1 executor
Batched synchronous through an executor06 · 07a request of many operations: one transactionthe callers, no connection; the executora request per callerwithin the request and across callersnot measured

Reads of t9 by primary key on a Mac, two release data nodes on the same machine.

iclaustron: doc/rust/architecture.html (§1 What it does, measured; §3 Asynchronous model; §4 Synchronous model, table); doc/rust/12-performance-model.md (Calibration: 1 thread, depth 1, batch 200; Executors); doc/user-guide.md (§5 Three ways to work); crates/ic_apid/src/executor.rs (Executor::start, Submitter::execute)

The training · nine modules

The training map, module by module

#ModuleTeaches
01Overviewthe library, its crates, the Rust conventions, connecting, errors, the five models
02Thread architectureuser, receive, send pool, heartbeat, connect and executor threads; node failure
03Object architectureglobal, connection, definitions, records, queries, transactions, scans, shapes, requests
04Key operations, synchronousSynchronous and Batched synchronous, on a connection
05Key operations, asynchronousAsynchronous: send, poll, callbacks, pipelines
06Key operations through an executorSynchronous and Batched synchronous through an executor
07Scansscans in the five models: row by row, a batch at a time, asynchronously, through a reader
08Advanced scansindex ranges, order, partition pruning, filters in the data nodes, updating scanned rows
09Inside the crateshow iClaustron is built, for Rust developers who read or change it

hops_slideware: 2026-10/iclaustron-training/PLAN.md (Agenda, The five programming models)

Recap · module 01

What to take away from the overview

  • iClaustron is a library: the program becomes an API node and talks to the data nodes directly.
  • Every call that can fail returns Result<_, IcError>; an option or argument struct takes ..Default::default() for what you do not set.
  • What you own is dropped, connections before the global; queries, transactions and scans are ids in their connection.
  • One global per cluster, one connection per thread, at most 4 711 in a process.
  • A temporary error means the whole transaction again; after 4012, find out first.
  • Five models: Synchronous, Batched synchronous, Asynchronous, and the two through an executor.

Next · 02 · Thread architecture