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
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
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 inOk, the error inErr;?hands it up. - An
IcErroris its code alone,code: i32:Copy, astd::error::Error, displayed as the code and its text. key_op's NDB codes areu32constants: hencee.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 asArc<TableDef>.- A connection holds an
Arcof its global, not a borrow: drop connections and executors first, then the global, which stops the threads. ApidConnectionisSend; what changes it takes&mut self: one thread at a time.
Ids
QueryId,TransId,ScanId:Copyids in their connection, freed byfree_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)isNonefor 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_forlays a row out for you.rec.field(pos)gives a field'soffset()andsize(); the examples'put_u32writesto_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=Nasks for that node id. connectfetches a node id and the configuration, starts the library's threads and returns; data nodes link in the background.wait_for_all_startedwaits 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
| GlobalOptions | Default | Meaning |
|---|---|---|
| receive_threads | 1 | threads reading the data nodes' links; one per data node at most |
| reply_wait_ms | 0: 6 min | how long a transaction waits for replies before it ends with 4012 |
| mgm_timeout_ms | 30 000 | how long a management server has to answer |
| auto_increment_prefetch | 4 096 | values 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, thenRecord::default_for.table_bindfetches 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_keyis a transaction of its own, sent and waited for; the operation is anIC_OP_*ofic_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
runreturns, 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 · modules | The caller writes | Threads | In flight | Batching | Reads a second |
|---|---|---|---|---|---|
| Synchronous04 · 07 | one blocking call per query, on its own rows | the caller, with its connection | 1 per thread | none: a send per call | 198 000 · 64 threads |
| Batched synchronous04 · 07 | queries on transactions, then flush until done | the caller, with its connection | a batch per thread | one send per data node for the batch | 1.17–1.35 M · 1 thread, batch 200 |
| Asynchronous05 · 07 | queries with callbacks; send, then poll | the caller, with its connection | many transactions per thread | all defined since the last send | 2.6 M · 1 thread, depth 4 |
| Synchronous through an executor06 · 07 | a request of one operation, executed | the callers, no connection; the executor | 1 per caller | across callers, by the executor | 268 000 · 64 callers, 1 executor |
| Batched synchronous through an executor06 · 07 | a request of many operations: one transaction | the callers, no connection; the executor | a request per caller | within the request and across callers | not 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
| # | Module | Teaches |
|---|---|---|
| 01 | Overview | the library, its crates, the Rust conventions, connecting, errors, the five models |
| 02 | Thread architecture | user, receive, send pool, heartbeat, connect and executor threads; node failure |
| 03 | Object architecture | global, connection, definitions, records, queries, transactions, scans, shapes, requests |
| 04 | Key operations, synchronous | Synchronous and Batched synchronous, on a connection |
| 05 | Key operations, asynchronous | Asynchronous: send, poll, callbacks, pipelines |
| 06 | Key operations through an executor | Synchronous and Batched synchronous through an executor |
| 07 | Scans | scans in the five models: row by row, a batch at a time, asynchronously, through a reader |
| 08 | Advanced scans | index ranges, order, partition pruning, filters in the data nodes, updating scanned rows |
| 09 | Inside the crates | how 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.
iClaustron training for Rust developers · 9 modules · you are at 01 · this module for C developers