iClaustron training for Rust developers · module 6 of 9 · 18 min
Key operations through an executor
Synchronous and batched synchronous calls, and callbacks, from threads with no connection of their own: submitters, shapes, requests, and one executor thread that sends for them all.
The problem · work that arrives on the application's threads
Why executors: a thread per request cannot each have a connection
A connection per thread (module 04)
- A connection is a user thread with a block of its own: at most 4 711 in a process.
- A thread that sends one call at a time pays a whole round's costs for each: a write, a read, a round trip.
An executor
- A thread of the library's, with one connection, runs the requests of any number of threads.
- Each thread holds only a submitter: no block, no connection of its own.
- Whatever one pass admits goes in one write per data node.
For programs whose work arrives on threads of their own, such as a server with a thread per request.
iclaustron: doc/user-guide.md §5 (Through an executor), §2 (one connection per thread) · crates/ic_apid/src/executor.rs (module note) · ic_port consts.rs (IC_MAX_THREAD_CONNECTIONS)
Objects · module 03 in short
The objects: one executor, a submitter per thread, shapes and requests
The executor, its shapes and its stats serve any thread; a submitter and a request belong to one thread at a time.
iclaustron: crates/ic_apid/src/executor.rs (module note; Executor, Submitter, OpShape, Request; Executor::start) · thread_conn.rs (Inbox) · apid_global.rs (ApidGlobal) · apid_conn.rs (ApidConnection)
Starting · one executor, a submitter per thread
Starting an executor: its options, then a submitter per thread
let options = ExecutorOptions {
in_flight: 64, // 400 by default
..ExecutorOptions::default()
};
let executor = Executor::start(&global, &options)?;
let sub = executor.submitter();
let table = sub.table_bind("ic_ex", "t1")?;
// each application thread a submitter of its own:
let mine = sub.clone();
std::thread::spawn(move || { /* ... requests on mine ... */ });
// ...
drop(executor); // stops it, before the global
execute and submit_wait wait for room, submit refusesiclaustron: executor.rs (ExecutorOptions and its Default, Executor::start, submitter, stop; impl Clone for Submitter; Submitter::table_bind) · examples/rust/scan_executor.rs (run) · ic_apid tests/integration.rs (executor_with)
Shapes · the executor's form of a query
Operation shapes: a table or unique index and two records, made once
let rec = Record::default_for(&table, None)?; // every field
let code = table.field_id("code")?;
let code_rec = Record::default_for(&table, Some(&[code]))?;
let shape = OpShape::new(&table, &rec, &rec)?; // an Arc<OpShape>
let index = sub.index_bind("ic_ex", "t1_code$unique", "t1")?;
let by_code = OpShape::new_unique(&index, &code_rec, &rec)?;
// no free: each KeyOp holds an Arc of its shape, and the
// executor lets the shape's queries go once nobody holds it
- A shape says what an operation reads or writes: a key record and an attribute record, not the rows.
- Made once, shared by every thread and request.
- The executor keeps idle queries per shape and copies rows in and out: nothing is allocated per request.
- Bind once: the executor waits for the dictionary while it binds.
iclaustron: executor.rs (OpShape::new, new_unique; KeyOp::new; Submitter::index_bind; Pool, Runner::query_for, sweep) · record.rs (Record::default_for) · ic_apid tests/integration.rs (pk_through; executor_runs_operations_through_a_unique_index)
Synchronous through an executor · one operation, one call
Synchronous through an executor: one operation, one blocking call
let mut req =
Request::with_op(KeyOp::read(&shape, ReadKind::Committed));
req.ops_mut()[0].key_row_mut().copy_from_slice(&key);
sub.execute(&mut req)?;
match req.ops()[0].error() {
Some(e) if e.code as u32 == key_op::IC_NDB_ERROR_NO_SUCH_ROW => {
// no such row
}
Some(e) => return Err(e),
None => row.copy_from_slice(req.ops()[0].attr_row()),
}
- A request of one operation is a transaction of its own;
executeblocks until it has ended. Okif it committed. A read of a missing row fails alone: committed, and the operation'serror()says 626.- Keep the request: put the next key in the same row and run it again.
iclaustron: doc/user-guide.md §5 (Through an executor, the Rust example) · executor.rs (Request::with_op, ops_mut, KeyOp::read, key_row_mut, Submitter::execute, KeyOp::error, Request::outcome, Runner::complete_op) · key_op.rs (IC_NDB_ERROR_NO_SUCH_ROW)
Results · read once execute has returned
A request's results: each operation's outcome, then the transaction's
| Call | Gives, for the last run |
|---|---|
| sub.execute | Ok if committed; else the transaction's error: 630 for an insert of a row already there; also req.outcome() |
| req.ops()[i].error() | the operation's error, or None; 626 for a read of a missing row |
| req.ops()[i].error_data() | what the data node said beyond the code: for 893, the unique index's id |
| req.ops()[i].result_len() | bytes a read brought; 0 for a write, or a row that was not there |
| req.commit_state() | CommitState::Committed or CommitState::RolledBack |
| req.error() | why it did not commit, or why its reads were lost when a takeover committed it |
| req.gci() | the global checkpoint the commit went into |
iclaustron: executor.rs (Submitter::execute, Request::outcome, commit_state, error, gci; KeyOp::error, error_data, result_len; Runner::finish) · transaction.rs (CommitState) · query.rs (ApidQuery::error_data: 893)
On the threads · module 02's threads, one call
A synchronous call through the threads: submit, take, send, wake
sub.execute appends the request to the inbox's request list, then sleeps on the submitter's slot.
The executor's loop takes the request list in the same lock as the reply pages. If it slept, the submit woke it, after unlocking.
It admits the request: a transaction placed by the key, the operation as a query of its shape, the commit on it. Then one write per data node.
The coordinator answers KEY_OP_CONF, the row comes in ROW_DATA_REP, and a receive thread routes both by block number.
They reach the executor's inbox. Its next pass executes them: the row is copied into the request, the transaction is closed.
The request goes into the caller's slot and the caller is woken: execute returns Ok, the row at req.ops()[0].attr_row().
iclaustron: executor.rs (Submitter::execute, Runner::run, admit, complete_op, finish, deliver; Slot) · thread_conn.rs (submit, exchange_with_requests, post) · doc/rust/05-ndb-protocol.md (Names; §6.5)
Batched synchronous through an executor · many operations, one transaction
Batched synchronous through an executor: many operations, one call
req.clear();
for row in &rows {
let mut op = KeyOp::write(&shape, WriteKind::Insert);
op.key_row_mut().copy_from_slice(row);
op.attr_row_mut().copy_from_slice(row);
req.push(op);
}
match sub.execute(&mut req) { // one transaction
Err(e) if e.code as u32 == key_op::IC_NDB_ERROR_ROW_EXISTS => {
// rolled back: none of the rows is written
}
// ...
}
- Defined together, the commit on the last, sent together: one transaction.
- Placed by the first operation's key;
req.set_hintnames a node or a partition. - A failed write rolls all back, a failed read fails alone:
op.set_abort_optionper operation says otherwise. - Read, decide, then write: two requests.
iclaustron: ic_apid tests/integration.rs (executor_a_request_of_several_operations_is_one_transaction: 630, none inserted) · executor.rs (Request::clear, push, set_hint; KeyOp::write, set_abort_option) · key_op.rs (IC_NDB_ERROR_ROW_EXISTS) · doc/rust/04-api-design.md (Executors)
In the objects · module 03's objects, one request of three operations
Inside the executor: each operation runs as a pooled query of its shape
A request of three operations, two inserts and a read. Each owns its key row and attribute row, filled before the call.
Admitted, each operation takes an idle query of its shape, its rows copied in. The query carries the request's number and the operation's place.
The three queries go on one transaction, placed by the first key, the commit on the last, sent in the pass's one write per data node.
A reply names its query, the query its request and place: the row and each error are copied out, and the query goes back to its shape.
Once every operation has completed and the transaction is done, it is closed: state, error and GCI go into the request, and it goes to its slot.
iclaustron: executor.rs (Runner::admit, start_for, define_op, query_for, complete_op, finish_ended, finish; Flight) · doc/rust/04-api-design.md (Executors) · 05-ndb-protocol.md (Names)
On the threads · three callers, one pass
Batching across callers: one pass, one write per data node
Three threads call execute while the executor is busy: the requests wait on the list, and no submit pays for a wake-up.
The next pass takes all three in one lock and admits them: three transactions, each placed by its own first key.
Everything admitted in the pass goes in one write per data node: here node 1 carries A's and C's operations, node 2 B's.
The replies come back through a receive thread, one page per round, and the next pass hands each request back to its own slot.
Each caller is woken on its own: the writes are shared, the wake-ups are not. With 64 callers a send carried 21 requests.
iclaustron: executor.rs (Runner::run, admit_waiting; module note) · thread_conn.rs (submit: wake only if waiting) · doc/rust/architecture.html §3 (The executor's loop) · 06-phases.md §8.1 (21.0 requests a send)
Asynchronous through an executor · Rust only · beside the five models
A callback through an executor: a function gets the request back
fn note_request(request: Request, user_ref: usize) {
// on the executor's thread, once the request has ended:
// request.outcome(), request.ops(); user_ref is the i below
}
let mut read = KeyOp::read(&shape, ReadKind::Committed);
read.key_row_mut().copy_from_slice(&key);
let done = Done::Callback(note_request, i);
if let Err(refused) = sub.submit(Request::with_op(read), done) {
// refused.error: 7159 busy or 7160 stopped; refused.request
}
// not waited for: the thread goes on
- Asynchronous, as module 05's callbacks, but from threads with no connection; C has only the blocking
execute. - A plain
fn, called on the executor's thread with the request and itsusize. - It must not block, as nothing else of the executor's runs meanwhile:
execute,scanand binds are refused there. - It must not panic: a panic stops the executor.
- AUTO_INCREMENT calls are allowed: a refill holds the executor for its round trip.
iclaustron: executor.rs (Submitter::submit, submit_wait; Done, RequestCallback, Refused; Runner::deliver; ExecutorShared::on_executor_thread) · auto_increment.rs (impl Submitter) · ic_apid tests/integration.rs (executor_hands_requests_to_a_callback_on_its_thread) · doc/user-guide.md §5, §9 (7159)
The loop · one pass, every pass
The executor's loop in five steps: replies before admits
- Take, in one lock: reply pages, scan readers' nudges, and new requests while none wait in its own queue. Sleep only with nothing to do.
- Execute the replies: operations complete, a read's row is copied into its request.
- Hand back each request whose transaction has ended: closed, then slot and wake-up, or its callback, called there.
- Admit waiting requests while fewer than
in_flightare out: start, define, commit on the last. - Send what this pass admitted: one write per data node.
Replies come first: ended transactions free their coordinator records and their place under in_flight before new ones start, and results go back at once.
iclaustron: crates/ic_apid/src/executor.rs (module note; Runner::run, take_completions, deliver, admit_waiting) · doc/rust/architecture.html §3 (The executor's loop) · apid_conn.rs (poll_with_requests)
One wait point · three lists, one lock
One wait point: replies, requests and nudges share the inbox's lock
A thread can sleep on one thing only, so all three share it. A producer appends under the lock, unlocks, then wakes the executor only if it sleeps.
iclaustron: crates/ic_apid/src/thread_conn.rs (module note "Requests", "Nudges"; Inbox, post, submit, nudge, exchange_with_requests) · doc/rust/architecture.html §3 (Why both inputs must share one wait point)
Back-pressure · two bounds
Back-pressure: a bounded request list and a limit on requests out
The inbox: queue, 4 000
- A full list makes
executeandsubmit_waitwait for room, on a second condition. - The executor takes the list whole, in the lock it takes replies in, then wakes those waiting for room.
submitrefuses instead,IC_ERROR_EXECUTOR_BUSY, 7159, and hands the request back.
The executor: in_flight, 400
- At most 400 requests out, each a transaction holding a record at its coordinator.
- The rest wait in its own queue, in order; meanwhile it takes no more from the inbox.
- At the limit it sleeps for replies alone: a request does not wake it.
The data nodes allow only so many transactions at once, across every API node: in_flight bounds one executor's share.
iclaustron: thread_conn.rs (submit, exchange_with_requests: room) · executor.rs (ExecutorOptions, Runner::run, admit_waiting, Submitter::submit, submit_wait, execute; Refused) · doc/rust/architecture.html §3 (Back-pressure in two places)
Failure · nothing is lost silently
Failures: every request comes back, sent or not, with its error
| What happened | The request comes back with |
|---|---|
| The executor is stopped | IC_ERROR_EXECUTOR_STOPPED, 7160: refused, left as it was; those waiting fail unsent |
| Its deadline passed before admission | IC_ERROR_TIMEOUT, 7128, unsent; set with req.set_timeout_ms before each run |
| An operation could not be defined | its refusal; the transaction is rolled back before anything is sent |
| No data node is started | 7142, unsent |
| A node failure ends the transaction | the transaction's error, such as 4010 or 4031: temporary |
| No reply within the reply wait | 4012, after six minutes by default: the outcome is unknown |
A temporary error: run the same request again, after a pause that grows. After 4012, learn whether a write committed before running it again.
iclaustron: executor.rs (module note; Submitter::execute, Request::set_timeout_ms; Runner::admit, abandon_defined, refuse_waiting, finish) · ic_port err.rs (IcError::is_temporary) · ic_apid tests/integration.rs (executor_deadlines_and_stopping_fail_requests_unsent) · doc/user-guide.md §9, §10
Measured · Mac, two release data nodes on the same machine, t9 by primary key
Measured: one executor reads 36 % more than a connection per thread
| Run | Per second | Median call | Client CPU a call | Requests a send |
|---|---|---|---|---|
| 64 threads, a connection each, blocking | 198 000 reads | 319 µs | 18.3 µs | 1 |
| 64 callers, one executor | 268 000 reads | 231 µs | 9.8 µs | 21.0 |
| 256 callers, two executors | 307 000 reads | 821 µs | 18.1 µs | 38.7 |
| 64 callers, one executor: updates | 68 500 updates | 918 µs | 29.6 µs | 4.0 |
| For scale: one thread, pipelined | 1.99 M reads | 158 µs a batch | 0.53 µs | 200 |
Your own figure: executor.stats(), then admitted over sends is requests a send.
iclaustron: doc/rust/06-phases.md §8.1 (2026-10-15; ic_bench, 100 000 keys, 10 s) · 12-performance-model.md (Calibration: M5 Pro) · user-guide.md §5 · executor.rs (Executor::stats, ExecutorStats)
The limit · what batching cannot share
What batching cannot share: a sleep and a wake-up for every call
≈10 µs
of CPU a blocking call: the caller's sleep and wake-up, most of it the kernel's
0.5 µs
a read pipelined on one thread, batches of 200 (module 05)
≈270 000
calls a second bound one executor: its own thread wakes every caller
More executors help while there are cores: two with 256 callers gave 15 % more, not twice. One per core for RonDB work, picked by key hash or round robin.
iclaustron: doc/rust/06-phases.md §8.1 (One executor is bound by its own thread; More executors) · architecture.html §3 (How many executors; What the hand-off costs) · user-guide.md §11
Also · and not yet
AUTO_INCREMENT through a submitter, and what executors cannot do yet
AUTO_INCREMENT
let id = sub.auto_increment(&table)?;
From the table's one range in the process, refilled on the library's counter connection, never through the executor. _block, _seen and _peek too.
Not through an executor yet
- Taking over rows a scan has locked: on a connection only.
- A request handed to a function when it ends: here in Rust,
Done::Callback; for C, later. - A transaction kept open across calls: a request runs start to end.
- Asynchronous C calls and Rust futures over executors: later.
iclaustron: auto_increment.rs (impl Submitter: auto_increment and _block, _seen, _peek) · doc/user-guide.md §7, §12 · 04-api-design.md (Executors; Taking scanned rows over; Kept for later) · executor.rs (Done)
Choosing · modules 04, 06 and 05 side by side
Three ways to call: a connection per thread, an executor, asynchronous
| Connection per thread · 04 | Through an executor · 06 | Asynchronous · 05 | |
|---|---|---|---|
| Caller writes | blocking calls | blocking calls, or callbacks | define, send, poll or callbacks |
| Threads on RonDB | at most 4 711 | any number, few executors | few user threads |
| In flight | one per thread | many per executor | many per thread |
| Batching | within one caller's transaction | across callers, by itself | chosen by the application |
| Latency, light load | lowest | a hand-off each way more | lowest |
| Client CPU a call | 18 µs, 64 threads | 10 µs, 64 callers | 0.5 µs |
| Reads a second | 198 000, 64 threads | 268 000, one executor | 2.6 million, one thread, depth 4 |
| Fits | tools, tests, low-rate services | servers with a thread per request | loaders, servers built for it |
iclaustron: doc/rust/architecture.html §4 (the table of forms A, B and asynchronous) · 06-phases.md §8.1 (measured, the Mac, 2026-10-15) · 12-performance-model.md (Calibration)
Recap · module 06
Recap: key operations through an executor
- An executor: one library thread, one connection, any number of callers.
- A shape is made once; a request is one transaction that owns its rows.
sub.executeblocks until the transaction ends:Okif committed.- Or
submitwithDone::Callback: a function on the executor's thread gets the request, and must not block. - Each pass: replies, then admits up to
in_flight, then one write per data node. - Every request comes back with its error; run a temporary one again.
- Writes are shared, wake-ups are not: about 10 µs a call (module 05 goes further).
iClaustron training for Rust developers · 9 modules · you are at 06 · this module for C developers