Skip to content

client: a dozen commands in one round trip, with an answer apiece (#11) - #44

Merged
sshaplygin merged 4 commits into
mainfrom
feature/batch-requests
Aug 9, 2026
Merged

client: a dozen commands in one round trip, with an answer apiece (#11)#44
sshaplygin merged 4 commits into
mainfrom
feature/batch-requests

Conversation

@sshaplygin

Copy link
Copy Markdown
Owner

Closes #11.

What

BatchRequest (new src/batch.rs) + Client::execute_batch — the cluster's execute_batch with per-part Results: execute_batch(&BatchRequest) -> Result<Vec<Result<YsonValue>>>. Each Ok is that part's own v4 envelope; each Err is a ClientError::Cluster named after the part's command, flattened outer+innermost.

  • A builder, not a slice of prepared commands. Typed methods (create, create_table, exists, get, list, remove, remove_tree, set_attribute) sending byte-identical parameters to their Client namesakes, plus raw/raw_with. The reason is the retry class: it turns on what the parts are, and a builder knows where a slice would have to guess conservatively for everyone.
  • Retry class derived from the actual parts. Read-only → Freely; modelled mutations → WithMutationId; any raw part → Never unless the caller supplies the class through raw_with. Safe because the driver derives per-part ids from the batch's own id (GetOrGenerateMutationId + GenerateNextBatchMutationId, read first-hand in etc_commands.cpp/rpc/helpers.cpp).
  • concurrency (server-side) and with_max_part_size (client-side split). Results stitch in part order, one mutation id per request.
  • Transactions are stamped per part, never on the envelope — because the cluster silently drops an envelope transaction_id on this command and the node escapes the transaction. Measured; execute_batch joined NO_TRANSACTION.
  • Client::execute_batch_with takes a caller-supplied MutationId for the crash-replay story, and refuses one on a batch that would split (one id cannot cover several requests).
  • ClientError::BatchInterrupted { answered, parts, cause } carries the prefix that already came back when a later chunk fails wholesale, instead of discarding it.

What the cluster actually does, measured

Three things this branch asserted before measuring, then corrected:

  • A batch refused wholesale is not a rollback, and not a race. [create a1, frobnicate] → 400, no per-part results, and a1 exists. Bad part first, bad part last, concurrency=1, eight creates before the bad one — every part applies, always. Dispatch is never aborted: sub-requests are collected into callbacks and run by CancelableRunWithBoundedConcurrency; the unknown name throws inside one executor and .ValueOrThrow() discards the whole result list. Documenting this as a race under-warned — a reader would conclude a bad part early in the list limits the damage. It does not.
  • Parse failures apply nothing; execution failures apply everything. concurrency=0, a part missing command, a part whose parameters is not a dict → validation errors with zero effect. That distinction is the only thing that lets a caller reason about a 400.
  • The cluster does not refuse a batch for holding a heavy part. write_table was accepted as a part and wrote its rows (row_count 0→2); get_job_spec came back as an ordinary per-part error. The crate still refuses these, but now says the refusal is its own policy. The real cluster rule is the output/input data type (Command %Qv cannot be part of a batch since it has inappropriate output type), so the gate is now NOT_A_BATCH_PART, derived from the registry the proxy serves at GET /api/v4 (190 commands) rather than from C++ headers — which is also how the Output classification was corrected, having first been read off the ApiVersion3 rows.

Acceptance criteria → tests

  • Per-part results, order and sidesper_part_results_keep_their_order_and_their_sides (live-captured fixture) and one_part_fails_and_the_rest_succeed_in_order, the latter through the full Client wire path.
  • Retry classthe_retry_class_is_the_most_cautious_part; a_batch_with_a_raw_part_is_sent_once_whatever_the_policy_says forces a retriable 503 against a 5-attempt policy, so a Freely misclassification really fails it (mutation gave left: 5, right: 1).
  • Replaya_retried_batch_keeps_its_mutation_id_and_admits_to_the_replay, params decoded from X-YT-Parameters rather than matched as text.
  • Chunkinga_big_batch_is_split_and_the_results_stitched_back_in_order (3 requests sized 2/2/1, per-chunk ids pairwise distinct, failure at its own index) plus every_request_of_a_split_batch_gets_its_own_mutation_id.
  • Transaction stamping — pinned twice, including the negative "the envelope wore none".
  • Parser strictness — eight malformed envelopes refused as Decode, count mismatch included; a create answering {} names the panic it would have caused.
  • Partial successa_split_batch_that_stops_hands_back_the_parts_that_already_applied.

Validation

Four independent passes: a critic, a test validator, and two verification rounds over the fix rounds. Every load-bearing claim was mutation-tested; the validator measured "one round trip" with a counting TCP relay rather than inferring it — 12 creates = 1 request, 9.16 ms against 140.77 ms for twelve individual calls — and reproduced the strongest claim live: replay under one explicit mutation id with retry=%true returned the identical node id pair, where a fresh id returned two 501 already exists.

cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace (663 passed), cargo test --workspace --doc (67 passed): all clean. examples/batch.rs is self-checking and passes against a local cluster.

Follow-up candidates (out of scope)

  • Per-part client-side retry. C++ re-queues retriable parts; disclosed in docs/sdk-comparison.md instead.
  • NOT_A_BATCH_PART is a snapshot of one cluster's registry and will drift. Since the proxy serves /api/v4, both this list and HEAVY could be derived from the live registry at connect time.
  • Pre-existing, unrelated: dispatch returns on an X-YT-Error header before the body is read, so that connection is not pooled — against AGENTS.md's "Connections" rule, and affecting every command equally. Separately, the registry reports 41 is_heavy commands where HEAVY lists 7.

`BatchRequest` and `Client::execute_batch` -- the cluster's
`execute_batch`, which both official clients have had all along (#11). A
launcher that creates a dozen tables no longer makes a dozen round
trips, and the answer is a Vec of per-part Results, because that is what
a batch is: its parts fail individually, and collapsing them into one
Result would lose the only thing batching costs any clarity on. Each Ok
carries the part's own v4 envelope, keyed by what that command returns
(`{node_id=...}` for a create, `{value=...}` for an exists); each Err is
an ordinary ClientError::Cluster named after the part's command,
flattened outer-plus-innermost like every other.

The building shape is a builder rather than a slice of prepared
commands: typed methods (create, create_table, exists, get, list,
remove, remove_tree, set_attribute) that send exactly what their Client
namesakes send, plus a raw escape hatch. A builder is what lets the
retry class be decided instead of guessed: parts this crate models are
Cypress commands the master's mutation cache covers, so a mutating batch
retries under a mutation id -- the driver hands part k the batch's id
plus k (GenerateNextBatchMutationId) and stamps the batch's retry flag
into every volatile part, so the master answers a marked replay with
each part's first response. Measured on a local cluster with parts that
carry no ignore_existing -- create_table, not create: a two-part batch
replayed under its id with retry=%true answered the same two node ids both
times, where a fresh id got two 501s. The same probe with BatchRequest::create
proves nothing, because ignore_existing answers with the old node's id under
any id at all. A batch of nothing but
reads carries no id at all, and a batch holding a raw part is sent once,
whatever the policy says, because a command this crate cannot classify
may mutate somewhere no mutation cache covers.

Both official options travel: with_concurrency, the command's own
server-side cap (default 50 in the cluster's registration), and
with_max_part_size, the C++ client's BatchPartMaxSize -- a bigger batch
is split into consecutive requests, concurrency x 5 apiece unless told
otherwise, results stitched back in part order and a mutation id per
request. The C++ client also re-queues a retriable part client-side;
this one deliberately does not, and docs/sdk-comparison.md discloses it.

Two wire decisions with measurements behind them. The batch's
parameters go in the POST body, where every other command's ride in
X-YT-Parameters: a batch's parameters are the batched commands, a header
has a size nobody promises, the C++ client makes the same choice for
this same command, and the proxy merges body parameters with the
header's (TContext::CaptureParameters) -- requests in the body and
mutation_id in the header measured landing as one parameter set. And a
client bound to a transaction stamps the parts, not the envelope:
execute_batch has no transactional options, and a local cluster dropped
an outer transaction_id in silence -- the part's create landed outside
the transaction and survived its abort, which is the silent escape a
transaction exists to prevent. execute_batch therefore joins
NO_TRANSACTION, and each part is stamped with the transport's own
exceptions.

The parser refuses what it does not recognise -- a results count that
does not match the parts, an item that is neither {output=...} nor
{error=...} nor the empty map a null-output command answers with --
rather than reading it as somebody's success.

Verified live end to end by `cargo run -p ytsaurus-client --example
batch`: twelve creates in one round trip and every node there
afterwards, one part failing (create over an existing node) with the
rest succeeding in order, a split batch keeping a failure at its own
index, and a batch inside a transaction invisible until the commit.
Also watched: parts run in parallel (an exists beside its own create
answered %false), and a part naming an unknown command fails the whole
batch with no per-part results. tests/batch.rs pins the wire shape
against an in-process stub that reads the whole request before
answering -- verb, body, the mutation id repeated across a retry and
absent from a read-only batch -- decoding X-YT-Parameters and comparing
values, never asserting on the rendered text of a generated id.
…ed for, a stricter parser, a wider raw door

The feature works; these are the honesty gaps two reviews found in it.

A split batch that stopped mid-sequence used to return the failure and
nothing else, with the earlier requests already applied and no way for the
caller to learn it — and re-running the same BatchRequest is not a recovery,
because a second execution mints fresh mutation ids. `ClientError::BatchInterrupted`
now carries the prefix that was answered for, beside the count and the cause.
A batch that fits one request is unchanged. Measured on a local cluster while
writing the test for it: a request refused wholesale can still have applied
some of its parts — a create beside a part naming an unknown command created
its node — so the error reports what came *back* and does not claim to know
what landed.

The bare `{}` success arm accepted "no output" for every command, including
the ones whose success has a value in it. A cluster answering `{}` to a
create passed the parser and then panicked in caller code at
`answer["node_id"]`, which is the access the rustdoc, the example and the
tests all teach. The arm is now restricted to the commands the driver source
names — the null-output ones — plus raw, whose registry bits only its caller
knows.

The raw door in a batch was narrower than `Client::raw_command_with`:
`BatchRequest::raw_with` takes the Repeatable, so one raw *read* no longer
demotes an all-read batch to send-once, and `Client::execute_batch_with`
takes the MutationId, so the crash-replay guarantee is expressible through
the API rather than only around it. Reproduced through it on a local cluster,
with create_table parts because they carry no ignore_existing: the same two
node ids under a replayed id, two 501s under a fresh one. Run the same probe
with BatchRequest::create and it proves nothing -- ignore_existing answers with
the old node's id whether or not a replay was recognised.
A command the cluster will not take as a part is refused where it is written
rather than costing a round trip.

Documented what was undocumented: the redirect a batch body cannot follow
where the same creates sent individually can, the consuming option setters
beside the borrowing part adders, and what executing one batch twice does.
AGENTS.md gains the cluster facts — the silently dropped envelope
transaction, the whole-batch failure on an unknown part command and that it
is not a rollback, the parallel parts, the per-part replay — and the example
stops claiming a round-trip count it cannot see: tests/batch.rs counts it.
…to its key

A second review found four cluster facts written down wrong. All four are
re-measured here against a local v4 cluster, and the code moved wherever a
measurement said a check was pointing at nothing.

**The "not a rollback" note described a race, and there is no race.** Four
places said the parts the driver could resolve "had already reached the master"
when the unknown name threw — which reads as though a bad part early in the
list, or a low concurrency, would limit the damage. It does not.
`TExecuteBatchCommand` collects the sub-requests into callbacks, runs them all
through `CancelableRunWithBoundedConcurrency`, and only then calls
`.ValueOrThrow()` on the collected list, discarding every result together.
Dispatch is never aborted. Probed five ways: `[create, frobnicate]` created its
node; so did `[frobnicate, create]` with the bad part first;
`[create, frobnicate, create]` created both; and at `concurrency=1`,
`[frobnicate, create, create]` created both and eight creates before a
`frobnicate` created all eight. Every part runs; the failure only destroys the
answers.

The distinction that does bound the damage was nowhere, and it is the one fact
that lets a caller read a 400 at all: a batch refused while its parameters are
being parsed runs **nothing** (`concurrency=0` → `Validation failed at
/concurrency`; a part missing `command`, a part whose `parameters` are not a
dict, and a non-list `requests` → `Error loading parameter /requests`; a missing
`requests` → `Missing required parameter /requests` — a `create` in each of
those requests left no node behind). A batch that reaches execution applies all
of it. Both halves now in AGENTS.md and the rustdoc.

**`BatchInterrupted`'s Display contradicted its own rustdoc.** It rendered "the
batch stopped after 2 of 5 parts, which are already applied on the cluster",
which is wrong twice: `answered` holds `Err` entries that applied nothing, and
per the above the failed request's parts apply whether or not it answers for
them. The variant's rustdoc said exactly that; the one-liner that reaches logs
and `unwrap()` panics said the opposite, in the sentence that gets a caller to
infer a no-op and corrupt state. Rewritten, and pinned by a test that refuses
the old phrase.

**`is_heavy` was the wrong gate.** The cluster's rule is the command's data
types: it throws `cannot be part of a batch since it has inappropriate output
type` before any part runs. Taken from the registry the cluster serves at
`GET /api/v4` (190 commands) and confirmed name by name — a part is refused when
its output type is `tabular` or `binary`, or its input type is `binary`. That is
21 names against `HEAVY`'s 7, and the two differ in both directions:
`get_job_spec` is `isHeavy` and is accepted as a part, while `alter_query` and
`push_queue_producer` are not heavy and are refused. `select_rows` and
`lookup_rows` were sailing through the old gate and are exactly what a caller
would try to batch — `[create x1, select_rows]` is answered 400 with `x1`
created anyway. `NOT_A_BATCH_PART` is the check now, documented as a snapshot of
one registry rather than a promise. `HEAVY` keeps a narrower job: this crate's
own policy that bulk data does not travel inline in a batch body to a light
proxy. That policy is now labelled as the crate's, because the old message
claimed the cluster fails the whole batch over a heavy part and it does not — a
`write_table` part was accepted and wrote its rows.

**`Output` read the v3 registry rows.** `remove`, `remove_tree` and
`set_attribute` were classified `Null`, which is the `ApiVersion3` row; on v4 —
the version this crate speaks — they are `Structured`, and the cluster's own
`/api/v4` says so beside `/api/v3`. Measured one part apiece: `create` →
`{output={node_id=…}}`, `set` → `{output={}}`, `remove` → `{output={}}`,
`exists` → `{output={value=%false}}`. No modelled command answers a bare `{}` on
v4 — which also means the guard added last round was dead against its own
motivating scenario, since it refused only a bare `{}` while the shape a v4
cluster really produces is `{output={}}`, and that is a legitimate `set` success
no output-type bit can tell from a broken `create`. The classification is now
the finer fact every method already documents, the key the success carries: a
`create` is held to `node_id` and a `get` to `value` however the answer is
wrapped, and `set`, `remove` and `raw` have no key to be held to. A `create`
answering `{output={}}` is refused where it used to reach `answer["node_id"]`
and panic a frame away.

Two smaller things. Both rustdocs claimed a two-`create` batch under a fresh id
got two `501`s; `BatchRequest::create` sends `ignore_existing`, so it does not —
measured, a fresh id returns ids identical to the first send, which looks
exactly like a deduplicated replay and is not one. The contrast only holds with
`create_table`, which `examples/batch.rs` already used and explained. And the
mutation-id-per-request property had a single assertion covering it, deep inside
a split test: an A,B,A recycling implementation passed the whole workspace with
that one line deleted. It now has a test of its own, over five chunks.
# Conflicts:
#	AGENTS.md
#	crates/ytsaurus-client/src/error.rs
#	docs/sdk-comparison.md
@sshaplygin
sshaplygin merged commit a88c7e2 into main Aug 9, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Batch requests: a dozen creates are a dozen round trips

1 participant