iClaustron training for C developers · module 6 of 8 · 18 min
Key operations through an executor
Synchronous and batched synchronous calls 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: include/iclaustron/ic_apid.h (header note "Executors"; IC_APID_EXECUTOR, IC_APID_SUBMITTER, IC_OP_SHAPE, IC_REQUEST) · crates/ic_apid/src/executor.rs (Executor::start, Submitter) · thread_conn.rs (Inbox)
Starting · one executor, a submitter per thread
Starting an executor: its options, then a submitter per thread
IC_EXECUTOR_OPTIONS options; IC_APID_EXECUTOR *executor; IC_APID_SUBMITTER *sub; IC_TABLE_DEF *table; ic_executor_options_init(&options, sizeof options); options.in_flight = 32; /* 400 by default */ ret = ic_apid_executor_start(global, &options, &executor); /* in each application thread: */ ret = ic_apid_executor_submitter(executor, &sub); ret = ic_apid_submitter_table_bind(sub, "ic_ex", "t1", &table); /* ... requests ... */ ic_apid_submitter_free(sub); ic_apid_executor_free(executor); /* before the global */
_init; a later release adds fields at the endexecute waits for roomiclaustron: ic_apid.h (IC_EXECUTOR_OPTIONS, ic_apid_executor_start, _free) · examples/c/scan_executor.c (main) · ic_capi tests/c_api.rs
Shapes · the executor's form of a query
Operation shapes: a table or unique index and two records, made once
IC_RECORD *rec = ex_t1_record(table, 0); /* t1.h */
IC_RECORD *code_rec = ex_code_record(table);
IC_INDEX_DEF *index;
IC_OP_SHAPE *shape, *by_code;
ret = ic_op_shape_create(table, rec, rec, &shape);
ret = ic_apid_submitter_index_bind(sub, "ic_ex", "t1_code$unique",
"t1", &index);
ret = ic_op_shape_create_unique(index, code_rec, rec, &by_code);
/* ... at the end, any order: a request keeps what it needs */
ic_op_shape_free(by_code);
ic_op_shape_free(shape);
- 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: include/iclaustron/ic_apid.h (ic_op_shape_create, ic_op_shape_create_unique, ic_apid_submitter_index_bind) · examples/c/t1.h, unique.c · executor.rs (OpShape, Runner::query_for, Runner::sweep)
Synchronous through an executor · one operation, one call
Synchronous through an executor: one operation, one blocking call
IC_REQUEST *req; uint32_t op; size_t len; ic_request_create(&req); ic_request_add_read(req, shape, IC_READ_KIND_COMMITTED, &op); memcpy(ic_request_key_row(req, op, &len), &key, sizeof key); if ((ret = ic_apid_submitter_execute(sub, req)) != 0) goto error; ret = ic_request_op_error(req, op); if (ret == IC_NDB_ERROR_NO_SUCH_ROW) /* no such row */; else if (ret != 0) goto error; else memcpy(&row, ic_request_attr_row(req, op, &len), sizeof row);
- A request of one operation is a transaction of its own;
executeblocks until it has ended. - 0 if it committed. A read of a missing row fails alone: committed, and the operation 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 C example) · include/iclaustron/ic_apid.h (ic_apid_submitter_execute, ic_request_op_error) · executor.rs (Request::outcome, Runner::complete_op)
Results · read once execute has returned
A request's results: each operation's outcome, then the transaction's
| Call | Gives, for the last run |
|---|---|
| ic_apid_submitter_execute | 0 if committed; else the transaction's error: 630 for an insert of a row already there |
| ic_request_op_error | the operation's error, or 0; 626 for a read of a missing row |
| ic_request_op_error_data | what the data node said beyond the code: for 893, the unique index's id |
| ic_request_op_result_len | bytes a read brought; 0 for a write, or a row that was not there |
| ic_request_state | IC_COMMIT_STATE_COMMITTED or IC_COMMIT_STATE_ROLLED_BACK |
| ic_request_error | why it did not commit, or why its reads were lost when a takeover committed it |
| ic_request_gci | the global checkpoint the commit went into |
iclaustron: include/iclaustron/ic_apid.h (ic_apid_submitter_execute, ic_request_op_error, _op_error_data, _op_result_len, ic_request_state, _error, _gci) · executor.rs (Runner::finish, KeyOp::error)
On the threads · module 02's threads, one call
A synchronous call through the threads: submit, take, send, wake
ic_apid_submitter_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 0, the row at ic_request_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
ic_request_clear(req);
for (i = 0; i < n; i++) {
ic_request_add_write(req, shape, IC_WRITE_KIND_INSERT, &op);
memcpy(ic_request_key_row(req, op, &len), &r[i], sizeof r[i]);
memcpy(ic_request_attr_row(req, op, &len), &r[i], sizeof r[i]);
}
ret = ic_apid_submitter_execute(sub, req); /* one transaction */
if (ret == IC_NDB_ERROR_ROW_EXISTS)
/* rolled back: none of the n rows is written */;
- Defined together, the commit on the last, sent together: one transaction.
- Placed by the first operation's key;
ic_request_set_hintnames a node or a partition. - A failed write rolls all back, a failed read fails alone:
ic_request_set_abort_optionper operation says otherwise. - Read, decide, then write: two requests.
iclaustron: crates/ic_capi/tests/c_api.rs (two inserts in one request, run again: 630, rolled back) · ic_apid.h (ic_request_add_write, ic_request_set_hint, ic_request_set_abort_option, IC_ABORT_DEFAULT) · 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)
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.
- 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, 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
executewait 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.
- In Rust,
submitrefuses instead:IC_ERROR_EXECUTOR_BUSY, 7159.
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) · 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 ic_request_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; Runner::admit, abandon_defined, refuse_waiting, finish) · ic_apid.h (ic_apid_submitter_execute, ic_request_set_timeout_ms, ic_error_is_temporary) · 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: ic_apid_executor_stats(executor, &stats, sizeof 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 · ic_apid.h (IC_EXECUTOR_STATS)
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
ret = ic_apid_submitter_auto_increment(sub, table, &id);
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: Rust only,
Submitter::submitwithDone::Callback. - A transaction kept open across calls: a request runs start to end.
- Asynchronous C calls and Rust futures over executors: later.
iclaustron: ic_apid.h (ic_apid_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 | 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.
ic_apid_submitter_executeblocks until the transaction ends: 0 if committed.- 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 C developers · 8 modules · you are at 06 · this module for Rust developers