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

application threadSubmitter · Request application threadSubmitter · Request … any number, none with a connection Arc<OpShape>made once · used by any thread Executorthread ic_executor_N, started on the global ApidConnectionits own block · queries kept per shape inboxreplies · requests · nudges, one mutex ApidGlobalreceive threads · send poolheartbeat · connect threads data nodescoordinator · fragments submit send post signals

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
in_flight400: requests out at once, each a transaction at its coordinator
queue4 000: requests waiting; beyond, execute and submit_wait wait for room, submit refuses
scans64: scans running at once (module 07)

iclaustron: 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; execute blocks until it has ended.
  • Ok if it committed. A read of a missing row fails alone: committed, and the operation's error() 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

CallGives, for the last run
sub.executeOk 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

applicationthread executorthread receivethread data nodes time → execute inboxrequest list asleep on its slot takeone lock admitdefine, commit sendone write/node KEY_OP_REQ data node KEY_OP_CONFROW_DATA_REP routeby block post executerow → request hand backslot + wake returns Ok

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_hint names a node or a partition.
  • A failed write rolls all back, a failed read fails alone: op.set_abort_option per 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

Request op 0 · insertkey row · attribute row op 1 · insertkey row · attribute row op 2 · readkey row · row read idle queries of the shape queryrequest 7 · place 0 queryrequest 7 · place 1 queryrequest 7 · place 2 rows in transactionplaced by op 0's keycommit on op 2one write/node data nodecoordinator KEY_OP_REQ ×3 KEY_OP_CONF · ROW_DATA_REP row out done: transaction closed, request to its slot

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

applicationthreads executorthread receivethread datanodes time → A · execute B · execute C · execute inboxrequest list: A, B, Cno wake-up: the executor is busy busylast pass asleep on their slots takeone lock admitA, B, C sendper node KEY_OP_REQ node 1 · A, C node 2 · B replies route, post hand backslot + wake ×3 C: OkB: OkA: Ok

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 its usize.
  • It must not block, as nothing else of the executor's runs meanwhile: execute, scan and 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

  1. 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.
  2. Execute the replies: operations complete, a read's row is copied into its request.
  3. Hand back each request whose transaction has ended: closed, then slot and wake-up, or its callback, called there.
  4. Admit waiting requests while fewer than in_flight are out: start, define, commit on the last.
  5. 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

receive threadsa page per round submittersexecute, submit: a request scan readersbatch back, close inbox · one mutex · one condition pages of replieswake the executor if it sleeps requests · at most queuewake it only if it sleeps willing to take one nudges · module 07never refused; wake it if it sleeps flags: waiting · wants_requests executor threadtakes all three in one locksleeps on the condition take

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 execute and submit_wait wait 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.
  • submit refuses 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 happenedThe request comes back with
The executor is stoppedIC_ERROR_EXECUTOR_STOPPED, 7160: refused, left as it was; those waiting fail unsent
Its deadline passed before admissionIC_ERROR_TIMEOUT, 7128, unsent; set with req.set_timeout_ms before each run
An operation could not be definedits refusal; the transaction is rolled back before anything is sent
No data node is started7142, unsent
A node failure ends the transactionthe transaction's error, such as 4010 or 4031: temporary
No reply within the reply wait4012, 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

RunPer secondMedian callClient CPU a callRequests a send
64 threads, a connection each, blocking198 000 reads319 µs18.3 µs1
64 callers, one executor268 000 reads231 µs9.8 µs21.0
256 callers, two executors307 000 reads821 µs18.1 µs38.7
64 callers, one executor: updates68 500 updates918 µs29.6 µs4.0
For scale: one thread, pipelined1.99 M reads158 µs a batch0.53 µs200

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 · 04Through an executor · 06Asynchronous · 05
Caller writesblocking callsblocking calls, or callbacksdefine, send, poll or callbacks
Threads on RonDBat most 4 711any number, few executorsfew user threads
In flightone per threadmany per executormany per thread
Batchingwithin one caller's transactionacross callers, by itselfchosen by the application
Latency, light loadlowesta hand-off each way morelowest
Client CPU a call18 µs, 64 threads10 µs, 64 callers0.5 µs
Reads a second198 000, 64 threads268 000, one executor2.6 million, one thread, depth 4
Fitstools, tests, low-rate servicesservers with a thread per requestloaders, 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.execute blocks until the transaction ends: Ok if committed.
  • Or submit with Done::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).

Next · 07 · Scans