ytsaurus_client/batch.rs
1//! Several commands in one round trip.
2//!
3//! The cluster command is `execute_batch`, from the
4//! [command reference](https://ytsaurus.tech/docs/en/api/commands#execute_batch):
5//! *"Use a single query to execute the set of commands passed in the
6//! parameters."* Both official clients batch — C++
7//! `IClientBase::CreateBatchRequest()`, Go `Client.NewBatchRequest()` — and a
8//! launcher that creates a dozen tables without it makes a dozen round trips.
9//!
10//! [`BatchRequest`] is the request half; [`Client::execute_batch`] sends one
11//! and is where the answer's shape — a `Result` **per part** — is explained.
12//!
13//! [`Client::execute_batch`]: crate::Client::execute_batch
14
15use ytsaurus_yson::{YsonFormat, YsonNode, YsonValue, to_string};
16
17use crate::error::{ClientError, Result};
18use crate::retry::Repeatable;
19use crate::schema::TableSchema;
20use crate::yson_build;
21
22/// The `concurrency` the cluster assumes when none is sent.
23///
24/// `Default(50)` in the command's own registration —
25/// `TExecuteBatchCommand::Register` in
26/// [`yt/yt/client/driver/etc_commands.cpp`](https://github.com/ytsaurus/ytsaurus/blob/main/yt/yt/client/driver/etc_commands.cpp)
27/// — and the same 50 the C++ SDK falls back to
28/// (`options.Concurrency_.GetOrElse(50)` in
29/// `yt/cpp/mapreduce/http_client/raw_batch_request.cpp`). Written here because
30/// the default **part size** is derived from it, so the number matters even to
31/// a caller who never sets either option.
32const DEFAULT_CONCURRENCY: i64 = 50;
33
34/// How many parts one HTTP request carries when the caller does not say.
35///
36/// The C++ SDK's rule, from `TExecuteBatchOptions` in
37/// `yt/cpp/mapreduce/interface/client_method_options.h`: *"If not specified it
38/// is set to `Concurrency * 5`"* — 250 at the default concurrency. See
39/// [`BatchRequest::with_max_part_size`].
40const PARTS_PER_CONCURRENCY: usize = 5;
41
42/// Commands the cluster refuses to take as a batch part at all.
43///
44/// **The rule is the part's data types, not `isHeavy`.** The driver checks the
45/// command's registered input and output types and throws
46/// `Command %Qv cannot be part of a batch since it has inappropriate output
47/// type %Qlv` before any part runs — so one such name fails the *whole*
48/// request and costs every other part its answer. Measured against the
49/// registry the cluster serves at `GET /api/v4` (190 commands on the local
50/// cluster) and confirmed name by name through a real batch: a part is refused
51/// when its **output type** is `tabular` or `binary`, or its **input type** is
52/// `binary`. That is the list below, and it is 21 names where `isHeavy` is 7.
53///
54/// `isHeavy` is not merely a smaller list, it is a different one, in both
55/// directions. `get_job_spec` is `is_heavy: true` and was **accepted** as a
56/// part (it came back as an ordinary per-part error), while `alter_query` and
57/// `push_queue_producer` are `is_heavy: false` and are refused. The harm the
58/// check exists to prevent is the measured one: `[create x1, select_rows]` was
59/// answered HTTP 400 `inappropriate output type "tabular"` — and `x1` was
60/// created anyway, so the round trip cost the create its answer and nothing
61/// else.
62///
63/// **A snapshot, and it can only be a snapshot.** These are the names one
64/// cluster refused in one measurement; a cluster of another version registers
65/// other commands, and one not listed here can still be refused on the wire.
66/// [`BatchRequest::raw_with`] says so rather than promising the list is
67/// complete. Two nearby refusals are the cluster's too but are *not* here,
68/// because they depend on the call and not on the name: a part whose command
69/// takes input and is given none fails the whole batch with
70/// `Command %Qv requires input` (measured for `insert_rows`, `write_table` and
71/// seven more), and an unknown name fails it with `Unknown command %Qv`.
72const NOT_A_BATCH_PART: &[&str] = &[
73 "alter_query",
74 "get_job_fail_context",
75 "get_job_input",
76 "get_job_stderr",
77 "get_job_trace",
78 "lookup_rows",
79 "pull_consumer",
80 "pull_queue",
81 "pull_queue_consumer",
82 "pull_rows",
83 "read_blob_table",
84 "read_file",
85 "read_journal",
86 "read_query_result",
87 "read_shuffle_data",
88 "read_table",
89 "read_table_partition",
90 "run_job_shell_command",
91 "select_rows",
92 "write_file",
93 "write_file_fragment",
94];
95
96/// How one part may be repeated, which decides how the whole batch may be.
97///
98/// The whole batch is one HTTP request, so it retries as one — and the safe
99/// answer for the envelope is the most cautious answer among its parts. See
100/// [`BatchRequest::repeatable`].
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102enum PartKind {
103 /// `exists`, `get`, `list` — non-mutating, so re-running one is harmless.
104 Read,
105 /// `create`, `remove`, `set` — mutating commands the **master's** mutation
106 /// cache covers, which is what makes a replay of the batch safe: the
107 /// driver hands each volatile part a mutation id derived from the batch's
108 /// own, and the master answers a marked replay with the first response.
109 /// See [`Client::execute_batch`](crate::Client::execute_batch).
110 MasterMutation,
111 /// A command nobody has classified. It may be mutating somewhere no
112 /// mutation cache covers — the scheduler, say — so a batch carrying one is
113 /// sent once, exactly as [`Client::raw_command`](crate::Client::raw_command)
114 /// is and for the same reason.
115 ///
116 /// Where a [`BatchRequest::raw`] part lands by default. A caller who knows
117 /// the command's registry bits says so with [`BatchRequest::raw_with`],
118 /// and the part is then one of the two above — the *retry* class is the
119 /// cluster's fact about the command, not a property of how this crate
120 /// happened to spell the call.
121 Raw,
122}
123
124/// What a part's success is keyed by, which is what its answer can be held to.
125///
126/// **Not the registry's output-type bit.** That bit was the first thing tried
127/// here, and under the API version this crate speaks it does not separate
128/// anything: the cluster serves its own v4 registry at `GET /api/v4`, and
129/// there `remove` and `set` are `output_type: structured` exactly as `create`
130/// and `get` are — it is *v3* that registers them `null`
131/// (`REGISTER(TRemoveCommand, "remove", Null, Null, …, ApiVersion3)` beside
132/// `REGISTER(TRemoveCommand, "remove", Null, Structured, …, ApiVersion4)` in
133/// [`driver.cpp`](https://github.com/ytsaurus/ytsaurus/blob/main/yt/yt/client/driver/driver.cpp)).
134/// Measured on a local v4 cluster, one part apiece: `create` →
135/// `{output={node_id=…}}`, `get` → `{output={value=…}}`, `exists` →
136/// `{output={value=%false}}`, `set` → `{output={}}`, `remove` →
137/// `{output={}}`. **No modelled command answers a bare `{}` on v4**, and the
138/// output-type bit calls `set` and `create` the same thing.
139///
140/// So the useful fact is finer than the registry's, and it is the one every
141/// [`BatchRequest`] method already documents: *which key the success carries*.
142/// That is what makes the check bite where it was meant to — a `create` whose
143/// answer has no `node_id` is refused, whether it arrived as `{}` or as the
144/// `{output={}}` that a v4 cluster really can emit. See [`part_result`].
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146enum Output {
147 /// The success is a value under this key — `node_id` for `create`,
148 /// `value` for `exists`, `get` and `list`. An answer without it is a shape
149 /// this client refuses rather than reads as a success with nothing in it.
150 Keyed(&'static str),
151 /// Nothing this crate can hold the answer to, for one of two reasons:
152 /// `set` and `remove`, whose v4 success is measurably an empty `output`
153 /// and so has no key to check; and a [`BatchRequest::raw`] part, whose
154 /// answer only its caller knows the shape of. Both take what comes.
155 Unchecked,
156}
157
158/// One command inside a batch, in the shape the cluster takes it.
159///
160/// `{command=…; parameters={…}}` with an optional `input=…`, verified three
161/// ways: the [command reference](https://ytsaurus.tech/docs/en/api/commands#execute_batch)
162/// spells out all three fields, `TExecuteBatchCommandRequest::Register` in
163/// [`yt/yt/client/driver/etc_commands.cpp`](https://github.com/ytsaurus/ytsaurus/blob/main/yt/yt/client/driver/etc_commands.cpp)
164/// registers exactly `command`, `parameters` and `input` (the last
165/// `.Default()`), and a local cluster took every part this module builds.
166/// `input` is where a structured-input command's value goes — `set` is the one
167/// modelled here — and the driver encodes it and sets the part's
168/// `input_format` itself.
169#[derive(Debug, Clone)]
170pub(crate) struct BatchPart {
171 pub(crate) command: String,
172 parameters: YsonValue,
173 input: Option<YsonValue>,
174 kind: PartKind,
175 output: Output,
176}
177
178/// Commands batched to be sent in one round trip.
179///
180/// A launcher that creates a dozen tables one call at a time pays a dozen
181/// round trips; batched, it pays one, and gets a dozen answers:
182///
183/// ```no_run
184/// use ytsaurus_client::{BatchRequest, Client};
185///
186/// # fn main() -> Result<(), ytsaurus_client::ClientError> {
187/// # let client = Client::from_env()?;
188/// let mut batch = BatchRequest::new();
189/// for name in ["clicks", "visits", "errors"] {
190/// batch.create("table", &format!("//tmp/pipeline/{name}"));
191/// }
192///
193/// for (name, made) in ["clicks", "visits", "errors"]
194/// .iter()
195/// .zip(client.execute_batch(&batch)?)
196/// {
197/// match made {
198/// Ok(_) => {}
199/// Err(error) => eprintln!("{name}: {error}"),
200/// }
201/// }
202/// # Ok(())
203/// # }
204/// ```
205///
206/// # The building shape, and why it is a builder
207///
208/// A batch could equally have been a slice of prepared commands. It is a
209/// builder with a typed method per modelled command because the parts are not
210/// free-form: the cluster refuses a command whose output type is a data stream
211/// (the [command reference](https://ytsaurus.tech/docs/en/api/commands#execute_batch)
212/// puts it as *light, with `null` or `structured` input and output*, which
213/// measurably over-states it — `get_job_spec` is heavy and is taken, and
214/// `write_table` has tabular input and is taken; see [`NOT_A_BATCH_PART`]),
215/// and — the half a slice cannot answer — the
216/// **retry** of the whole batch turns on what the parts are. A typed method
217/// knows its command is a master-side Cypress command, so the batch stays
218/// retriable under a mutation id; [`BatchRequest::raw`] cannot know, so it
219/// makes the batch send-once. A slice of prepared commands would have had to
220/// assume one answer for everything, and the safe assumption would have taken
221/// the retry away from the common case. Each typed method sends **exactly**
222/// the parameters its [`Client`](crate::Client) namesake sends, so a call
223/// moved into a batch does not change meaning.
224///
225/// # Parts run in parallel
226///
227/// From the same reference: *"The command can (and will be) executed in
228/// parallel. It means that if a set includes both writing to and reading from
229/// the node, the reading result can either be the older value or the updated
230/// one."* Watched happening on a local cluster: a batch that created
231/// `//tmp/impl-batch-a` and asked `exists` about it in the same breath was
232/// answered `%false` — both parts succeeded, in order, and the read simply ran
233/// first. Do not put a part and its consequence in one batch.
234///
235/// # Options
236///
237/// [`BatchRequest::with_concurrency`] is the server-side parallelism, and
238/// [`BatchRequest::with_max_part_size`] is a client-side split into several
239/// requests — the same pair the C++ client exposes as
240/// `TExecuteBatchOptions{Concurrency, BatchPartMaxSize}`.
241///
242/// **The two option setters take `self`, and the part adders take `&mut self`.**
243/// They are different jobs and the shapes say so: the options are the request's
244/// settings, chosen once and up front, so they chain off the constructor and
245/// are gone by the time the batch has a name; the adders are the contents,
246/// added in a loop, so they hand the borrow straight back. Set the options
247/// first and the mix never shows:
248///
249/// ```
250/// # use ytsaurus_client::BatchRequest;
251/// let mut batch = BatchRequest::new().with_concurrency(8).with_max_part_size(64);
252/// for index in 0..3 {
253/// batch.create("table", &format!("//tmp/pipeline/t{index}"));
254/// }
255/// assert_eq!(batch.len(), 3);
256/// ```
257///
258/// # Executing one twice sends everything twice
259///
260/// [`Client::execute_batch`](crate::Client::execute_batch) borrows the batch,
261/// so it is still there afterwards and can be sent again — and doing so is
262/// **new work, not a replay**. The parts are unchanged, but each execution
263/// mints its own mutation ids, so the cluster has nothing to deduplicate
264/// against and runs every part a second time. What that looks like is the
265/// part's own business, and measured: a batch of [`BatchRequest::create_table`]
266/// answers `501 already exists` throughout the second run, a batch of
267/// [`BatchRequest::remove`] answers `500`, and a batch of
268/// [`BatchRequest::create`] answers **with the same node ids as the first run**
269/// — because `create` sends `ignore_existing`, not because anything was
270/// deduplicated. Do not read that last one as a replay: an unchanged answer
271/// from a second execution is the least informative signal here, which is why
272/// a real replay wants [`Client::execute_batch_with`](crate::Client::execute_batch_with)
273/// and a part that has no `ignore_existing` in it.
274/// The reuse worth having is a batch of reads, or one
275/// rebuilt from [`BatchRequest::new`] for the second pass. A *replay* — the
276/// same mutation deduplicated against the first send — is
277/// [`Client::execute_batch_with`](crate::Client::execute_batch_with) with the
278/// id you kept.
279#[derive(Debug, Clone, Default)]
280pub struct BatchRequest {
281 parts: Vec<BatchPart>,
282 concurrency: Option<i64>,
283 max_part_size: Option<usize>,
284}
285
286impl BatchRequest {
287 /// An empty batch. Add parts with the typed methods, then hand it to
288 /// [`Client::execute_batch`](crate::Client::execute_batch).
289 #[must_use]
290 pub fn new() -> Self {
291 Self::default()
292 }
293
294 /// Caps how many parts the cluster works on at once.
295 ///
296 /// A parameter of the command itself — `concurrency`, default 50, refused
297 /// unless positive (`TExecuteBatchCommand::Register` in the cluster's
298 /// [driver](https://github.com/ytsaurus/ytsaurus/blob/main/yt/yt/client/driver/etc_commands.cpp);
299 /// the [reference](https://ytsaurus.tech/docs/en/api/commands#execute_batch)
300 /// documents both). The documentation's reason to lower it: *"Use this
301 /// parameter to avoid exhausting your request rate limit."* Left unset,
302 /// nothing is sent and the cluster's own default applies.
303 ///
304 /// Zero is clamped to one, as [`RetryPolicy::new`](crate::RetryPolicy::new)
305 /// clamps attempts: the cluster refuses `concurrency=0` outright, and a
306 /// builder that quietly built a refused request would fail at the wrong
307 /// end.
308 #[must_use]
309 pub fn with_concurrency(mut self, concurrency: u32) -> Self {
310 self.concurrency = Some(i64::from(concurrency.max(1)));
311 self
312 }
313
314 /// Caps how many parts travel in one HTTP request.
315 ///
316 /// A bigger batch is split **client-side** into several `execute_batch`
317 /// requests, sent one after another with the results stitched back in
318 /// order. This is the C++ client's `BatchPartMaxSize`, defaults included:
319 /// unset, it is `concurrency × 5` — 250 when concurrency is unset too
320 /// (`yt/cpp/mapreduce/interface/client_method_options.h`: *"If not
321 /// specified it is set to `Concurrency * 5`"*).
322 ///
323 /// The trade is the ordinary one. One request is one round trip and one
324 /// retryable unit; a split spends a round trip per piece, and a piece that
325 /// fails wholesale fails [`Client::execute_batch`](crate::Client::execute_batch)
326 /// wholesale with the earlier pieces already run — which that method's
327 /// documentation spells out. Zero is clamped to one, because a part size
328 /// of nothing sends nothing forever.
329 #[must_use]
330 pub fn with_max_part_size(mut self, parts: usize) -> Self {
331 self.max_part_size = Some(parts.max(1));
332 self
333 }
334
335 /// Adds a `create` — the same request [`Client::create`](crate::Client::create)
336 /// sends: parents are created and an existing node is accepted.
337 ///
338 /// The part's answer is `{node_id=…}`. With `ignore_existing` in it, a
339 /// node that already existed answers with the **old** node's id and any
340 /// attributes are silently ignored — the same trap
341 /// [`Client::create_table`](crate::Client::create_table) documents, and
342 /// the reason [`BatchRequest::create_table`] exists beside this.
343 pub fn create(&mut self, node_type: &str, path: &str) -> &mut Self {
344 self.push(
345 "create",
346 yson_build::map([
347 ("path", yson_build::string(path)),
348 ("type", yson_build::string(node_type)),
349 ("recursive", yson_build::boolean(true)),
350 ("ignore_existing", yson_build::boolean(true)),
351 ]),
352 None,
353 PartKind::MasterMutation,
354 Output::Keyed("node_id"),
355 )
356 }
357
358 /// Adds a table creation with a schema — the same request
359 /// [`Client::create_table`](crate::Client::create_table) sends, refusals
360 /// included.
361 ///
362 /// The schema goes **inside `attributes`**, where `create` reads it; a
363 /// top-level `schema` would be accepted and silently ignored. And unlike
364 /// [`BatchRequest::create`] this part **fails on a path that already
365 /// exists**, deliberately: the cluster ignores the attributes of a create
366 /// it skips, so an `ignore_existing` spelling would leave the old table
367 /// with the old schema under a per-part `Ok`.
368 ///
369 /// # Errors
370 ///
371 /// Returns [`ClientError::Config`] if the schema is one the cluster would
372 /// refuse — checked here, when the part is built, so the mistake is
373 /// reported once rather than as a per-part error after a round trip.
374 pub fn create_table(&mut self, path: &str, schema: &TableSchema) -> Result<&mut Self> {
375 schema
376 .validate()
377 .map_err(|reason| ClientError::Config(format!("{path}: {reason}")))?;
378
379 Ok(self.push(
380 "create",
381 yson_build::map([
382 ("path", yson_build::string(path)),
383 ("type", yson_build::string("table")),
384 ("recursive", yson_build::boolean(true)),
385 (
386 "attributes",
387 yson_build::map([("schema", schema.to_yson())]),
388 ),
389 ]),
390 None,
391 PartKind::MasterMutation,
392 Output::Keyed("node_id"),
393 ))
394 }
395
396 /// Adds an `exists` — as [`Client::exists`](crate::Client::exists).
397 ///
398 /// The part's answer is `{value=%true}` or `{value=%false}` — the key is
399 /// `value`, not the command's name, exactly as it is outside a batch.
400 pub fn exists(&mut self, path: &str) -> &mut Self {
401 self.push(
402 "exists",
403 yson_build::map([("path", yson_build::string(path))]),
404 None,
405 PartKind::Read,
406 Output::Keyed("value"),
407 )
408 }
409
410 /// Adds a `get` — as [`Client::get`](crate::Client::get). The part's
411 /// answer is `{value=…}`.
412 pub fn get(&mut self, path: &str) -> &mut Self {
413 self.push(
414 "get",
415 yson_build::map([("path", yson_build::string(path))]),
416 None,
417 PartKind::Read,
418 Output::Keyed("value"),
419 )
420 }
421
422 /// Adds a `list` — as [`Client::list`](crate::Client::list). The part's
423 /// answer is `{value=[…]}`, unsorted and — unlike
424 /// [`Client::list`](crate::Client::list) — **not checked for the
425 /// `incomplete` marker**: a batch hands back what each part answered, and
426 /// reading the attribute is the caller's to do if the node may be large.
427 pub fn list(&mut self, path: &str) -> &mut Self {
428 self.push(
429 "list",
430 yson_build::map([("path", yson_build::string(path))]),
431 None,
432 PartKind::Read,
433 Output::Keyed("value"),
434 )
435 }
436
437 /// Adds a `remove` — as [`Client::remove`](crate::Client::remove): the
438 /// node must exist, and a map node must be empty.
439 pub fn remove(&mut self, path: &str) -> &mut Self {
440 self.push(
441 "remove",
442 yson_build::map([
443 ("path", yson_build::string(path)),
444 ("recursive", yson_build::boolean(false)),
445 ("force", yson_build::boolean(false)),
446 ]),
447 None,
448 PartKind::MasterMutation,
449 Output::Unchecked,
450 )
451 }
452
453 /// Adds a `remove` of a whole subtree, absent included — as
454 /// [`Client::remove_tree`](crate::Client::remove_tree).
455 pub fn remove_tree(&mut self, path: &str) -> &mut Self {
456 self.push(
457 "remove",
458 yson_build::map([
459 ("path", yson_build::string(path)),
460 ("recursive", yson_build::boolean(true)),
461 ("force", yson_build::boolean(true)),
462 ]),
463 None,
464 PartKind::MasterMutation,
465 Output::Unchecked,
466 )
467 }
468
469 /// Adds a `set` of one attribute — what
470 /// [`Client::set_attribute`](crate::Client::set_attribute) does.
471 ///
472 /// `set` takes structured input, and inside a batch that input is the
473 /// part's own `input` field rather than a request body — the
474 /// [reference](https://ytsaurus.tech/docs/en/api/commands#execute_batch)'s
475 /// own example is a `set` carried this way, and the driver encodes the
476 /// value and sets the part's `input_format` itself
477 /// (`TExecuteBatchCommand::TRequestExecutor::Run`). Verified on a local
478 /// cluster; the part answers `{output={}}`.
479 pub fn set_attribute(&mut self, path: &str, name: &str, value: YsonValue) -> &mut Self {
480 self.push(
481 "set",
482 yson_build::map([("path", yson_build::string(format!("{path}/@{name}")))]),
483 Some(value),
484 PartKind::MasterMutation,
485 Output::Unchecked,
486 )
487 }
488
489 /// Adds a command this crate does not model.
490 ///
491 /// The escape hatch, as [`Client::raw_command`](crate::Client::raw_command)
492 /// is outside a batch — and with the same default and the same
493 /// consequence: **a batch carrying a raw part is sent once**, whatever the
494 /// retry policy says, because a command this crate cannot classify may be
495 /// mutating somewhere no mutation cache covers, and a replayed batch would
496 /// apply it twice. [`BatchRequest::raw_with`] is where a caller who knows
497 /// the command's registry bits says otherwise, exactly as
498 /// [`Client::raw_command_with`](crate::Client::raw_command_with) is
499 /// outside a batch; [`Client::execute_batch`](crate::Client::execute_batch)
500 /// documents the retry rule this feeds.
501 ///
502 /// `input` is for a structured-input command (the rule
503 /// [`BatchRequest::set_attribute`] describes); commands with no input
504 /// stream pass `None`. Only light commands with `null` or `structured`
505 /// input and output can be parts at all — and know that a part naming a
506 /// command the cluster has never heard of fails the **whole batch**, not
507 /// the part: watched on a local cluster, where `{command=frobnicate}` was
508 /// answered HTTP 400 and `Unknown command "frobnicate"` with no per-part
509 /// results at all. (The driver decides per-part errors only after it has
510 /// resolved the command's descriptor — `TRequestExecutor::Run` throws
511 /// before that on an unknown name.)
512 ///
513 /// **A refused batch is not a partly-run batch. Every part runs.** The
514 /// failure destroys the *answers*, not the work: the driver collects the
515 /// sub-requests into callbacks, runs them all through
516 /// `CancelableRunWithBoundedConcurrency`, and only then calls
517 /// `.ValueOrThrow()` on the collected list — which discards every result
518 /// together the moment one of them is the unknown-name throw. Dispatch is
519 /// never aborted. Measured five ways on a local cluster, and it is not a
520 /// race: `[create, frobnicate]` created its node; so did
521 /// `[frobnicate, create]` with the bad part **first**;
522 /// `[create, frobnicate, create]` created **both**; and at
523 /// `concurrency=1`, where a reader would most expect the damage to stop
524 /// early, `[frobnicate, create, create]` still created both and eight
525 /// creates followed by a `frobnicate` created **all eight**. Putting the
526 /// bad part first does not help, and lowering the concurrency does not
527 /// help. A name worth typing here is one you have checked.
528 ///
529 /// The one distinction that does bound the damage is **when** the request
530 /// fails. A batch refused while its *parameters are being read* never runs
531 /// anything: `concurrency=0` was answered `Validation failed at
532 /// /concurrency`, a part missing its `command` field and a part whose
533 /// `parameters` were not a dict were both answered `Error loading parameter
534 /// /requests`, and in every one of those a `create` sitting in the same
535 /// request left **no node behind**. A batch that gets as far as *executing*
536 /// applies all of it. Parse-time failures are total; execution-time
537 /// failures are total the other way.
538 ///
539 /// # Errors
540 ///
541 /// Returns [`ClientError::Config`] if `command` is not a bare command
542 /// name, or if `params` is not a YSON dict — the same refusals, for the
543 /// same reasons, as [`Client::raw_command`](crate::Client::raw_command) —
544 /// or if `command` is one the cluster will not take as a part, by the
545 /// data-type rule [`BatchRequest::raw_with`] describes.
546 pub fn raw(
547 &mut self,
548 command: &str,
549 params: YsonValue,
550 input: Option<YsonValue>,
551 ) -> Result<&mut Self> {
552 self.raw_with(command, params, input, Repeatable::Never)
553 }
554
555 /// As [`BatchRequest::raw`], saying how the part may be repeated.
556 ///
557 /// The asymmetry this removes: `raw` hard-codes [`Repeatable::Never`], and
558 /// because the batch retries as the most cautious of its parts, **one**
559 /// raw part demotes an otherwise all-read batch to send-once. A raw read —
560 /// `check_permission`, `get_supported_features`, `parse_ypath` — is
561 /// [`Repeatable::Freely`], and saying so leaves the batch as retriable as
562 /// it was. The judgement is the cluster's, from the same `REGISTER_ALL`
563 /// row [`Client::raw_command_with`](crate::Client::raw_command_with) reads
564 /// it from, and the same caution applies: *light and mutating* is not
565 /// enough for [`Repeatable::WithMutationId`], because the mutation cache
566 /// is the **master's** and a scheduler command is not in it. Prefer
567 /// [`Repeatable::Never`] when in doubt — that is why it is what `raw`
568 /// gives you.
569 ///
570 /// A part's class is combined with the others, never applied alone: the
571 /// batch is one HTTP request, so it goes out as the most cautious answer
572 /// among its parts.
573 ///
574 /// # Errors
575 ///
576 /// As [`BatchRequest::raw`], and additionally [`ClientError::Config`] for
577 /// [`Repeatable::Heavy`], which is not a class a part can have: it asks for
578 /// a heavy proxy, and a batch does not go to one.
579 ///
580 /// A name is also refused when the cluster would refuse it as a part. **The
581 /// cluster's rule is the command's data types, not `isHeavy`**: a part is
582 /// refused when its registered output type is `tabular` or `binary`, or its
583 /// input type is `binary`, and the driver throws before any part runs, so
584 /// the **whole** batch fails and every other part loses its answer. That is
585 /// the check [`NOT_A_BATCH_PART`] makes, whichever class is claimed for the
586 /// name — `select_rows` and `lookup_rows` are on it, and are the ones a
587 /// caller is likeliest to try.
588 ///
589 /// **The list is a snapshot of one cluster's registry, not a promise.** A
590 /// cluster of another version registers other commands, and a name this
591 /// crate has never heard of can still be refused on the wire — as can a
592 /// part whose command takes input and is given none
593 /// (`Command %Qv requires input`), which no list can catch because it
594 /// depends on the call. What the check buys is the common mistake caught
595 /// before the socket, not a guarantee that the batch will be taken.
596 ///
597 /// Separately, this crate refuses the bulk-data commands it lists as heavy
598 /// even where the cluster would take them — `write_table` was measured
599 /// being accepted as a part and applying its rows — because a part's input
600 /// travels inline in the batch body to a light proxy, which is not where
601 /// this crate sends table or file data.
602 pub fn raw_with(
603 &mut self,
604 command: &str,
605 params: YsonValue,
606 input: Option<YsonValue>,
607 repeatable: Repeatable,
608 ) -> Result<&mut Self> {
609 crate::check_command_name(command)?;
610 crate::refuse_non_dict_parameters(command, ¶ms)?;
611
612 if NOT_A_BATCH_PART.contains(&command) {
613 return Err(ClientError::Config(format!(
614 "the cluster refuses {command} as a batch part: its registered \
615 input or output type is a data stream, and the driver throws \
616 \"cannot be part of a batch since it has inappropriate output \
617 type\" before any part runs — so the whole request fails and \
618 every other part loses its answer, while the parts that were \
619 going to apply still apply. Send it with \
620 Client::raw_command_streaming or Client::raw_command_upload, \
621 outside the batch."
622 )));
623 }
624 if crate::http::is_heavy(command) {
625 return Err(ClientError::Config(format!(
626 "{command} moves bulk data, and a batch part carries its input \
627 inline in the batch body to a light proxy — which is not where \
628 this crate sends table or file data. The refusal is this \
629 crate's, not the cluster's: a {command} part was measured \
630 being accepted and applied. Send it with \
631 Client::raw_command_streaming or Client::raw_command_upload, \
632 outside the batch."
633 )));
634 }
635 if repeatable == Repeatable::Heavy {
636 return Err(ClientError::Config(format!(
637 "{command} was declared Repeatable::Heavy, which is not a class \
638 a batch part can have: a heavy command is refused as a part, \
639 and Repeatable::Heavy also asks for a heavy proxy, which is \
640 not where a batch goes. Send it outside the batch."
641 )));
642 }
643
644 let kind = match repeatable {
645 Repeatable::Freely => PartKind::Read,
646 Repeatable::WithMutationId => PartKind::MasterMutation,
647 // `Never`, and any class a later release names: the batch is sent
648 // once, which is the answer that is safe for all of them.
649 _ => PartKind::Raw,
650 };
651 Ok(self.push(command, params, input, kind, Output::Unchecked))
652 }
653
654 /// How many parts the batch holds.
655 #[must_use]
656 pub fn len(&self) -> usize {
657 self.parts.len()
658 }
659
660 /// Whether the batch holds no parts. An empty batch is refused by
661 /// [`Client::execute_batch`](crate::Client::execute_batch) rather than
662 /// sent.
663 #[must_use]
664 pub fn is_empty(&self) -> bool {
665 self.parts.is_empty()
666 }
667
668 fn push(
669 &mut self,
670 command: &str,
671 parameters: YsonValue,
672 input: Option<YsonValue>,
673 kind: PartKind,
674 output: Output,
675 ) -> &mut Self {
676 self.parts.push(BatchPart {
677 command: command.to_owned(),
678 parameters,
679 input,
680 kind,
681 output,
682 });
683 self
684 }
685
686 /// The parts, for [`Client::execute_batch`](crate::Client::execute_batch)
687 /// to chunk and send.
688 pub(crate) fn parts(&self) -> &[BatchPart] {
689 &self.parts
690 }
691
692 /// The `concurrency` to send, when the caller set one.
693 pub(crate) fn concurrency(&self) -> Option<i64> {
694 self.concurrency
695 }
696
697 /// How many parts one HTTP request may carry — the caller's cap, or the
698 /// C++ client's `concurrency × 5` when there is none.
699 pub(crate) fn max_part_size(&self) -> usize {
700 self.max_part_size.unwrap_or_else(|| {
701 usize::try_from(self.concurrency.unwrap_or(DEFAULT_CONCURRENCY))
702 .unwrap_or(usize::MAX)
703 .saturating_mul(PARTS_PER_CONCURRENCY)
704 .max(1)
705 })
706 }
707
708 /// How the whole batch may be repeated: the most cautious of its parts.
709 ///
710 /// All reads — repeat freely; the batch mutates nothing, and the
711 /// [reference](https://ytsaurus.tech/docs/en/api/commands#execute_batch)
712 /// says as much: *"Mutating if the set includes mutating commands."* Any
713 /// modelled mutation — under a mutation id, which the driver spreads over
714 /// the volatile parts (see
715 /// [`Client::execute_batch`](crate::Client::execute_batch)). Any raw part
716 /// — sent once, because nothing can vouch for what a replay would do.
717 pub(crate) fn repeatable(&self) -> Repeatable {
718 if self.parts.iter().any(|part| part.kind == PartKind::Raw) {
719 return Repeatable::Never;
720 }
721 if self
722 .parts
723 .iter()
724 .any(|part| part.kind == PartKind::MasterMutation)
725 {
726 return Repeatable::WithMutationId;
727 }
728 Repeatable::Freely
729 }
730}
731
732/// Renders one chunk of parts as the parameters `execute_batch` takes.
733///
734/// `transaction` is the client's bound transaction, stamped into **each
735/// part**: the outer command has no transaction to be in — its options are
736/// `TExecuteBatchOptions : TMutatingOptions`, with no transactional half — and
737/// a local cluster proved the point by dropping an outer `transaction_id` in
738/// silence: the part's create landed *outside* the transaction and survived
739/// its abort. Stamping the parts is the only spelling the cluster honours,
740/// and it follows the transport's own rules: a part that already names a
741/// transaction keeps it, and a command on the no-transaction list is left
742/// alone.
743pub(crate) fn render_chunk(
744 parts: &[BatchPart],
745 concurrency: Option<i64>,
746 transaction: Option<&str>,
747) -> Result<Vec<u8>> {
748 let requests = parts.iter().map(|part| {
749 let mut parameters = part.parameters.clone();
750 if let Some(id) = transaction
751 && !crate::http::takes_no_transaction(&part.command)
752 && !names_transaction(¶meters)
753 {
754 yson_build::insert(&mut parameters, "transaction_id", yson_build::string(id));
755 }
756
757 let mut request = yson_build::map([
758 ("command", yson_build::string(&part.command)),
759 ("parameters", parameters),
760 ]);
761 if let Some(input) = &part.input {
762 yson_build::insert(&mut request, "input", input.clone());
763 }
764 request
765 });
766
767 let mut rendered = yson_build::map([("requests", yson_build::list(requests))]);
768 if let Some(concurrency) = concurrency {
769 yson_build::insert(&mut rendered, "concurrency", yson_build::int(concurrency));
770 }
771
772 to_string(&rendered, YsonFormat::Text)
773 .map(String::into_bytes)
774 .map_err(|e| ClientError::Decode {
775 command: "execute_batch".to_owned(),
776 reason: format!("could not encode the batch: {e}"),
777 })
778}
779
780/// Whether a part's parameters already name a transaction of their own.
781fn names_transaction(parameters: &YsonValue) -> bool {
782 matches!(
783 ¶meters.node,
784 YsonNode::Map(m) if m.contains_key(b"transaction_id".as_slice())
785 )
786}
787
788/// Reads one chunk's response into per-part `Result`s.
789///
790/// The envelope is `{results=[…]}` — `ProduceSingleOutput(context, "results",
791/// …)` in the driver, the ordinary v4 wrapping — with **one item per part, in
792/// the order the parts were sent**. Each item is what
793/// `TRequestExecutor::OnResponse` builds and what a local cluster actually
794/// answered:
795///
796/// - `{error={…}}` — the part failed, and the value is a YTsaurus error
797/// document in YSON: `code`, `message`, `attributes`, nested
798/// `inner_errors`;
799/// - `{output={…}}` — the part succeeded, and the value is the part's own
800/// v4 answer, keyed by what that command returns: `{node_id=…}` for
801/// `create`, `{value=…}` for `exists`, `get` and `list`, and `{}` — an
802/// empty `output`, not an absent one — for `set` and `remove`;
803/// - `{}` — the part succeeded and the driver wrote no `output` key at all,
804/// which is what the reference's own example shows for a `set`. **No
805/// modelled command answers this way on API v4**, the version this crate
806/// speaks: measured one part apiece, `set` and `remove` both answer
807/// `{output={}}`. The arm is kept for a [`BatchRequest::raw`] part, whose
808/// command may be registered `null`-output, and is refused for any part
809/// whose success this crate knows a key for. See [`part_result`].
810///
811/// Anything else is refused as [`ClientError::Decode`] rather than read as
812/// one of the three: this crate's envelope rules were learned from `exists`
813/// answering under `value` and the file cache answering with a bare string,
814/// and a shape this parser does not recognise is likelier to be a new answer
815/// than an empty one. A response with the wrong number of items is refused
816/// whole for the same reason — pairing what answers there are against the
817/// wrong parts would hand every caller after the gap somebody else's result.
818pub(crate) fn parse_results(body: &[u8], parts: &[BatchPart]) -> Result<Vec<Result<YsonValue>>> {
819 let envelope: YsonValue =
820 ytsaurus_yson::from_slice(body, YsonFormat::Text).map_err(|e| ClientError::Decode {
821 command: "execute_batch".to_owned(),
822 reason: format!(
823 "{e}; body was {}",
824 crate::error::truncate(&String::from_utf8_lossy(body), 200)
825 ),
826 })?;
827
828 let results = match &envelope.node {
829 YsonNode::Map(m) => m.get(b"results".as_slice()).ok_or_else(|| {
830 refused(format!(
831 "the answer has no \"results\"; keys were {:?}",
832 m.keys()
833 .map(|k| String::from_utf8_lossy(k).into_owned())
834 .collect::<Vec<_>>()
835 ))
836 }),
837 other => Err(refused(format!("expected a dict, got {other:?}"))),
838 }?;
839
840 let YsonNode::List(items) = &results.node else {
841 return Err(refused(format!(
842 "\"results\" is not a list: {:?}",
843 results.node
844 )));
845 };
846
847 if items.len() != parts.len() {
848 return Err(refused(format!(
849 "{} parts were sent and {} results came back; pairing them up \
850 would hand callers each other's answers",
851 parts.len(),
852 items.len()
853 )));
854 }
855
856 items.iter().zip(parts).map(part_result).collect()
857}
858
859/// One item of the `results` list, read by the rules above.
860///
861/// **The check is on the key, not on the wrapper.** The scenario it exists for
862/// is a `create` whose answer has no `node_id` in it: the access this crate
863/// teaches for a create is `answer["node_id"]`, [`YsonValue`]'s `Index` panics
864/// on a missing key, and a parser that waved the answer through would have
865/// turned a strange response into a panic in caller code one frame away. A
866/// guard on the *wrapper* alone — refusing only a bare `{}` — misses that
867/// scenario entirely on API v4, because the shape a v4 cluster would actually
868/// produce is `{output={}}`, and `set` and `remove` measurably emit exactly
869/// that as their success. So [`Output::Keyed`] is held to its key wherever the
870/// answer arrives, and only [`Output::Unchecked`] — `set`, `remove`, and a
871/// [`BatchRequest::raw`] part whose shape only its caller knows — takes what
872/// comes.
873fn part_result((item, part): (&YsonValue, &BatchPart)) -> Result<Result<YsonValue>> {
874 let command = &part.command;
875
876 let YsonNode::Map(fields) = &item.node else {
877 return Err(refused(format!(
878 "{command}: a part's result is not a dict: {:?}",
879 item.node
880 )));
881 };
882
883 let error = fields.get(b"error".as_slice());
884 let output = fields.get(b"output".as_slice());
885
886 match (error, output, fields.len()) {
887 (Some(error), None, 1) => Ok(Err(part_error(command, error))),
888 (None, Some(output), 1) => match part.output {
889 Output::Keyed(key) if field(output, key.as_bytes()).is_none() => Err(refused(format!(
890 "{command}: a part succeeded with {}, which has no \"{key}\" \
891 in it — and a {command} answers under \"{key}\". Handing \
892 that back would panic one frame away, where this crate \
893 teaches answer[\"{key}\"].",
894 to_string(output, YsonFormat::Text).unwrap_or_else(|_| "?".to_owned())
895 ))),
896 _ => Ok(Ok(output.clone())),
897 },
898 // Success with no `output` key at all. No modelled command answers
899 // this way on v4; a raw part's command may be registered null-output.
900 (None, None, 0) if part.output == Output::Unchecked => Ok(Ok(yson_build::empty_map())),
901 (None, None, 0) => Err(refused(format!(
902 "{command}: a part answered with an empty result, which means \"no \
903 output\" — but a {command} answers with a value in it, so this is \
904 a shape from nowhere. Reading it as an empty success would hand \
905 back a map with no node_id or value in it, and indexing that panics."
906 ))),
907 _ => Err(refused(format!(
908 "{command}: a part's result carries keys this client does not \
909 recognise: {:?}",
910 fields
911 .keys()
912 .map(|k| String::from_utf8_lossy(k).into_owned())
913 .collect::<Vec<_>>()
914 ))),
915 }
916}
917
918/// A response shape this parser refuses to guess about.
919fn refused(reason: String) -> ClientError {
920 ClientError::Decode {
921 command: "execute_batch".to_owned(),
922 reason,
923 }
924}
925
926/// Builds a part's failure from its error document.
927///
928/// The same flattening as everywhere else in the crate — the outer message is
929/// often a category (`Error resolving path …`) with the cause at the bottom of
930/// `inner_errors`, so both are carried. The document arrives as YSON here
931/// rather than as the JSON of an `X-YT-Error` header, which is why this walk
932/// exists beside [`ClientError::from_yt_error`]; `raw` keeps the whole
933/// document in the shape it arrived, YSON text.
934fn part_error(command: &str, document: &YsonValue) -> ClientError {
935 let code = field(document, b"code")
936 .and_then(YsonValue::as_i64)
937 .unwrap_or(-1);
938 let outer = field(document, b"message")
939 .and_then(|value| value.as_str().map(str::to_owned))
940 .unwrap_or_else(|| "(no message)".to_owned());
941
942 let message = match innermost_message(document) {
943 Some(inner) if inner != outer => format!("{outer}: {inner}"),
944 _ => outer,
945 };
946
947 ClientError::Cluster {
948 command: command.to_owned(),
949 code,
950 message,
951 raw: to_string(document, YsonFormat::Text).unwrap_or_default(),
952 }
953}
954
955/// One field of a YSON dict, or nothing where it is not a dict.
956fn field<'a>(value: &'a YsonValue, key: &[u8]) -> Option<&'a YsonValue> {
957 match &value.node {
958 YsonNode::Map(m) => m.get(key),
959 _ => None,
960 }
961}
962
963/// Walks `inner_errors` to the deepest message — the YSON twin of the JSON
964/// walk in `error.rs`, kept in step with it.
965fn innermost_message(document: &YsonValue) -> Option<String> {
966 let inner = field(document, b"inner_errors")?;
967 let YsonNode::List(errors) = &inner.node else {
968 return None;
969 };
970 let first = errors.first()?;
971 innermost_message(first).or_else(|| {
972 field(first, b"message").and_then(|message| message.as_str().map(str::to_owned))
973 })
974}
975
976#[cfg(test)]
977mod tests {
978 use super::*;
979 use crate::schema::{Column, ColumnType};
980
981 /// Captured from a local cluster: one batch, four parts, two of them
982 /// failed — a `create` over an existing node, a `set` with input, a
983 /// `get`, and a `remove` of nothing.
984 const ONE_FAILS_REST_SUCCEED: &[u8] = br#"{"results"=[{"error"={"code"=501;"message"="Node //tmp/impl-batch-a already exists";"attributes"={"host"="localhost";};};};{"output"={};};{"output"={"value"="table";};};{"error"={"code"=500;"message"="Node //tmp has no child with key \"impl-batch-nothing-here\"";"attributes"={"host"="localhost";};};};];}"#;
985
986 fn four_parts() -> BatchRequest {
987 let mut batch = BatchRequest::new();
988 batch
989 .create("table", "//tmp/impl-batch-a")
990 .set_attribute("//tmp/impl-batch-b", "note", yson_build::string("hello"))
991 .get("//tmp/impl-batch-b/@type")
992 .remove("//tmp/impl-batch-nothing-here");
993 batch
994 }
995
996 #[test]
997 fn per_part_results_keep_their_order_and_their_sides() {
998 let results = parse_results(ONE_FAILS_REST_SUCCEED, four_parts().parts()).expect("parses");
999
1000 assert_eq!(results.len(), 4);
1001 assert!(results[0].is_err() && results[3].is_err());
1002 assert!(results[1].is_ok() && results[2].is_ok());
1003
1004 // The success carries the part's own envelope, keyed by what that
1005 // command returns.
1006 assert_eq!(
1007 results[2].as_ref().expect("a get succeeded")["value"].as_str(),
1008 Some("table")
1009 );
1010 // A `set` succeeds with an empty envelope, not with an absent one.
1011 assert_eq!(
1012 results[1].as_ref().expect("a set succeeded"),
1013 &yson_build::empty_map()
1014 );
1015 }
1016
1017 #[test]
1018 fn a_part_error_flattens_like_every_other_cluster_error() {
1019 // Captured from a local cluster: a `get` on a missing path, where the
1020 // outer message is a category and the cause is one level down.
1021 let document = ytsaurus_yson::from_slice(
1022 br#"{"code"=500;"message"="Error resolving path //tmp/impl-batch-nothing/@x";"inner_errors"=[{"code"=500;"message"="Node //tmp has no child with key \"impl-batch-nothing\"";};];}"#,
1023 YsonFormat::Text,
1024 )
1025 .expect("valid YSON");
1026
1027 let error = part_error("get", &document);
1028 let ClientError::Cluster {
1029 command,
1030 code,
1031 message,
1032 raw,
1033 } = &error
1034 else {
1035 panic!("a part failure is a cluster error: {error:?}");
1036 };
1037
1038 assert_eq!(command, "get");
1039 assert_eq!(*code, 500);
1040 assert_eq!(
1041 message,
1042 "Error resolving path //tmp/impl-batch-nothing/@x: \
1043 Node //tmp has no child with key \"impl-batch-nothing\""
1044 );
1045 // The whole document survives, in the shape it arrived.
1046 assert!(raw.contains("inner_errors"), "{raw}");
1047 }
1048
1049 #[test]
1050 fn a_result_shape_from_nowhere_is_refused_rather_than_guessed() {
1051 let mut one_get = BatchRequest::new();
1052 one_get.get("//tmp/t");
1053
1054 for (body, why) in [
1055 (br#"{"results"=[]}"#.to_vec(), "a missing answer"),
1056 (br#"[]"#.to_vec(), "no envelope at all"),
1057 (br#"{"value"=[{}]}"#.to_vec(), "the wrong envelope key"),
1058 (br#"{"results"={}}"#.to_vec(), "results that are not a list"),
1059 (
1060 br#"{"results"=[{"outcome"={}}]}"#.to_vec(),
1061 "a key this client has never seen",
1062 ),
1063 (
1064 br#"{"results"=[{"output"={};"error"={}}]}"#.to_vec(),
1065 "both sides at once",
1066 ),
1067 (
1068 br#"{"results"=["ok"]}"#.to_vec(),
1069 "an item that is not a dict",
1070 ),
1071 (
1072 br#"{"results"=[{};{}]}"#.to_vec(),
1073 "more answers than parts",
1074 ),
1075 ] {
1076 let error = parse_results(&body, one_get.parts())
1077 .expect_err(&format!("{why} must not pass as a result"));
1078 assert!(
1079 matches!(error, ClientError::Decode { .. }),
1080 "{why}: {error:?}"
1081 );
1082 }
1083 }
1084
1085 #[test]
1086 fn an_empty_item_is_a_success_with_nothing_to_say() {
1087 // The documented shape for a null-output part — the reference's own
1088 // example answers a `set` with `{ }`. No modelled command answers
1089 // that way on v4 (`set` and `remove` both answer `{output={}}`), so
1090 // the arm stands for a raw part whose command may be registered
1091 // null-output on some version — and for `set`/`remove`, which have no
1092 // key to be held to either way.
1093 let mut batch = BatchRequest::new();
1094 batch.set_attribute("//tmp/t", "note", yson_build::string("x"));
1095
1096 let results = parse_results(br#"{"results"=[{}]}"#, batch.parts()).expect("parses");
1097 assert_eq!(
1098 results[0].as_ref().expect("a success"),
1099 &yson_build::empty_map()
1100 );
1101 }
1102
1103 #[test]
1104 fn a_keyed_part_is_held_to_its_key_however_the_answer_is_wrapped() {
1105 // The scenario the check exists for, in the shape a v4 cluster can
1106 // really produce. `{output={}}` is a legitimate success for `set` and
1107 // `remove` on v4 — measured — so a guard that only refused a bare
1108 // `{}` would wave this through and panic one frame away at
1109 // `answer["node_id"]`.
1110 for (build, key) in [
1111 (
1112 (|batch: &mut BatchRequest| {
1113 batch.create("table", "//tmp/t");
1114 }) as fn(&mut BatchRequest),
1115 "node_id",
1116 ),
1117 (
1118 |batch| {
1119 batch.exists("//tmp/t");
1120 },
1121 "value",
1122 ),
1123 (
1124 |batch| {
1125 batch.get("//tmp/t");
1126 },
1127 "value",
1128 ),
1129 (
1130 |batch| {
1131 batch.list("//tmp/t");
1132 },
1133 "value",
1134 ),
1135 ] {
1136 let mut batch = BatchRequest::new();
1137 build(&mut batch);
1138 let command = batch.parts()[0].command.clone();
1139
1140 for body in [
1141 br#"{"results"=[{"output"={}}]}"#.to_vec(),
1142 br#"{"results"=[{"output"={"something_else"=1}}]}"#.to_vec(),
1143 br#"{"results"=[{"output"="a string"}]}"#.to_vec(),
1144 ] {
1145 let error = parse_results(&body, batch.parts()).expect_err(&format!(
1146 "{command} must not succeed without its {key}: {}",
1147 String::from_utf8_lossy(&body)
1148 ));
1149 assert!(matches!(error, ClientError::Decode { .. }), "{error:?}");
1150 assert!(error.to_string().contains(key), "{error}");
1151 }
1152
1153 // And the real answer still passes.
1154 let good = format!(r#"{{"results"=[{{"output"={{"{key}"="x"}}}}]}}"#);
1155 let results = parse_results(good.as_bytes(), batch.parts()).expect("parses");
1156 assert!(results[0].is_ok(), "{results:?}");
1157 }
1158
1159 // `set` and `remove` have no key to be held to: their v4 success is
1160 // an empty `output`, so anything is taken as it comes.
1161 let mut nulls = BatchRequest::new();
1162 nulls
1163 .set_attribute("//tmp/t", "note", yson_build::string("x"))
1164 .remove("//tmp/t");
1165 let results = parse_results(
1166 br#"{"results"=[{"output"={}};{"output"={}}]}"#,
1167 nulls.parts(),
1168 )
1169 .expect("both parse");
1170 assert!(results.iter().all(Result::is_ok), "{results:?}");
1171 }
1172
1173 #[test]
1174 fn an_empty_result_is_refused_for_a_part_whose_success_has_a_value() {
1175 // `{}` means the driver wrote no `output` key, which it does only for
1176 // a command whose output type is Null. A `create` answering that way
1177 // is a shape from nowhere, and reading it as an empty success hands
1178 // the caller a map with no `node_id` in it — which the access this
1179 // crate teaches, `answer["node_id"]`, then panics on.
1180 for build in [
1181 (|batch: &mut BatchRequest| {
1182 batch.create("table", "//tmp/t");
1183 }) as fn(&mut BatchRequest),
1184 |batch| {
1185 batch.exists("//tmp/t");
1186 },
1187 |batch| {
1188 batch.get("//tmp/t");
1189 },
1190 |batch| {
1191 batch.list("//tmp/t");
1192 },
1193 ] {
1194 let mut batch = BatchRequest::new();
1195 build(&mut batch);
1196 let command = batch.parts()[0].command.clone();
1197
1198 let error = parse_results(br#"{"results"=[{}]}"#, batch.parts())
1199 .expect_err(&format!("{command} does not succeed with nothing to say"));
1200 assert!(matches!(error, ClientError::Decode { .. }), "{error:?}");
1201 assert!(error.to_string().contains("shape from nowhere"), "{error}");
1202 }
1203
1204 // The parts with no key to be held to still answer bare: `set` and
1205 // `remove`, whose v4 success is measurably an empty `output`, and a
1206 // raw part, whose shape only its caller knows.
1207 let mut nulls = BatchRequest::new();
1208 nulls
1209 .set_attribute("//tmp/t", "note", yson_build::string("x"))
1210 .remove("//tmp/t");
1211 nulls
1212 .raw(
1213 "parse_ypath",
1214 yson_build::map([("path", yson_build::string("//tmp"))]),
1215 None,
1216 )
1217 .expect("a fine command name");
1218
1219 let results =
1220 parse_results(br#"{"results"=[{};{};{}]}"#, nulls.parts()).expect("all three parse");
1221 assert!(results.iter().all(Result::is_ok), "{results:?}");
1222 }
1223
1224 #[test]
1225 fn a_heavy_command_cannot_be_a_part_however_it_is_classified() {
1226 // The cluster fails the *whole* batch over a command whose data types
1227 // it will not take as a part — so the other parts would lose their
1228 // answers to a mistake this list can catch before the socket. The
1229 // rule is the data types and not `isHeavy`: `select_rows` and
1230 // `lookup_rows` are the ones a caller would plausibly try to batch,
1231 // and both were measured being refused with `inappropriate output
1232 // type "tabular"` while a `create` beside them applied anyway.
1233 for refused in [
1234 "write_table",
1235 "read_table",
1236 "write_file",
1237 "get_job_input",
1238 "select_rows",
1239 "lookup_rows",
1240 "get_job_trace",
1241 "pull_queue",
1242 "alter_query",
1243 "read_journal",
1244 "write_file_fragment",
1245 ] {
1246 let mut batch = BatchRequest::new();
1247 let error = batch
1248 .raw(refused, yson_build::empty_map(), None)
1249 .expect_err(&format!("{refused} cannot be a part"));
1250 assert!(
1251 matches!(error, ClientError::Config(_)),
1252 "{refused}: {error}"
1253 );
1254 assert!(batch.is_empty(), "a refused part must not be half-added");
1255
1256 // And claiming a class for it does not make it acceptable.
1257 assert!(
1258 batch
1259 .raw_with(refused, yson_build::empty_map(), None, Repeatable::Freely)
1260 .is_err(),
1261 "{refused} was accepted once it claimed to be a read"
1262 );
1263 }
1264
1265 // A command the cluster *does* take as a part is not refused for
1266 // being registered heavy: `get_job_spec` is `is_heavy: true` and was
1267 // measured coming back as an ordinary per-part error, not a
1268 // whole-batch failure.
1269 let mut fine = BatchRequest::new();
1270 fine.raw(
1271 "get_job_spec",
1272 yson_build::map([("job_id", yson_build::string("1-2-3-4"))]),
1273 None,
1274 )
1275 .expect("a heavy command the cluster takes as a part");
1276 assert_eq!(fine.len(), 1);
1277
1278 // `Heavy` is not a class a part can have at all, whatever it names.
1279 let mut batch = BatchRequest::new();
1280 let error = batch
1281 .raw_with(
1282 "check_permission",
1283 yson_build::empty_map(),
1284 None,
1285 Repeatable::Heavy,
1286 )
1287 .expect_err("a part is never heavy");
1288 assert!(matches!(error, ClientError::Config(_)), "{error}");
1289 assert!(batch.is_empty());
1290 }
1291
1292 #[test]
1293 fn the_retry_class_is_the_most_cautious_part() {
1294 let mut reads = BatchRequest::new();
1295 reads.exists("//tmp/a").get("//tmp/b").list("//tmp/c");
1296 assert_eq!(reads.repeatable(), Repeatable::Freely);
1297
1298 let mut mutating = BatchRequest::new();
1299 mutating.exists("//tmp/a").create("table", "//tmp/b");
1300 assert_eq!(mutating.repeatable(), Repeatable::WithMutationId);
1301
1302 let mut raw = BatchRequest::new();
1303 raw.create("table", "//tmp/b");
1304 raw.raw(
1305 "parse_ypath",
1306 yson_build::map([("path", yson_build::string("//tmp"))]),
1307 None,
1308 )
1309 .expect("a fine command name");
1310 assert_eq!(raw.repeatable(), Repeatable::Never);
1311
1312 // A caller who knows the command's registry bits says so, and one raw
1313 // *read* no longer costs an all-read batch its retry.
1314 let mut vouched = BatchRequest::new();
1315 vouched.exists("//tmp/a");
1316 vouched
1317 .raw_with(
1318 "check_permission",
1319 yson_build::map([("path", yson_build::string("//tmp"))]),
1320 None,
1321 Repeatable::Freely,
1322 )
1323 .expect("a fine command name");
1324 assert_eq!(vouched.repeatable(), Repeatable::Freely);
1325
1326 // And a raw light mutation the master's cache covers keeps the batch
1327 // replayable rather than demoting it to send-once.
1328 vouched
1329 .raw_with(
1330 "concatenate",
1331 yson_build::map([("destination_path", yson_build::string("//tmp/c"))]),
1332 None,
1333 Repeatable::WithMutationId,
1334 )
1335 .expect("a fine command name");
1336 assert_eq!(vouched.repeatable(), Repeatable::WithMutationId);
1337 }
1338
1339 #[test]
1340 fn a_raw_part_is_checked_like_a_raw_command() {
1341 let mut batch = BatchRequest::new();
1342
1343 for bad in ["", "get?x=1", "get value", "get/../hosts"] {
1344 let error = batch
1345 .raw(bad, yson_build::empty_map(), None)
1346 .expect_err(&format!("{bad:?} was accepted as a command name"));
1347 assert!(matches!(error, ClientError::Config(_)), "{bad:?}: {error}");
1348 }
1349
1350 let error = batch
1351 .raw("get", yson_build::string("//tmp"), None)
1352 .expect_err("parameters must be a dict");
1353 assert!(matches!(error, ClientError::Config(_)), "{error}");
1354 assert!(batch.is_empty(), "a refused part must not be half-added");
1355 }
1356
1357 #[test]
1358 fn a_batch_schema_is_validated_where_the_client_validates_one() {
1359 let mut batch = BatchRequest::new();
1360 let unsound = TableSchema::new([Column::new("", ColumnType::Int64)]);
1361
1362 let error = batch
1363 .create_table("//tmp/t", &unsound)
1364 .expect_err("an empty column name never reaches the cluster");
1365 assert!(matches!(error, ClientError::Config(_)), "{error}");
1366 assert!(batch.is_empty());
1367 }
1368
1369 #[test]
1370 fn the_part_size_default_is_the_cpp_clients_rule() {
1371 let batch = BatchRequest::new();
1372 assert_eq!(batch.max_part_size(), 250, "concurrency 50 × 5");
1373
1374 assert_eq!(
1375 BatchRequest::new().with_concurrency(8).max_part_size(),
1376 40,
1377 "the default part size follows the concurrency"
1378 );
1379 assert_eq!(
1380 BatchRequest::new()
1381 .with_concurrency(8)
1382 .with_max_part_size(3)
1383 .max_part_size(),
1384 3,
1385 "an explicit part size wins"
1386 );
1387 // Zero would loop forever; it is clamped as RetryPolicy clamps.
1388 assert_eq!(BatchRequest::new().with_max_part_size(0).max_part_size(), 1);
1389 assert_eq!(
1390 BatchRequest::new().with_concurrency(0).concurrency(),
1391 Some(1)
1392 );
1393 }
1394
1395 #[test]
1396 fn a_bound_transaction_reaches_the_parts_that_can_take_one() {
1397 let mut batch = BatchRequest::new();
1398 batch.create("table", "//tmp/a");
1399 batch
1400 .raw(
1401 "get_operation",
1402 yson_build::map([("operation_id", yson_build::string("1-2-3-4"))]),
1403 None,
1404 )
1405 .expect("a fine command name");
1406 batch
1407 .raw(
1408 "create",
1409 yson_build::map([
1410 ("path", yson_build::string("//tmp/b")),
1411 ("type", yson_build::string("table")),
1412 ("transaction_id", yson_build::string("3-aaa-bbb-ccc")),
1413 ]),
1414 None,
1415 )
1416 .expect("a fine command name");
1417
1418 let body = render_chunk(batch.parts(), None, Some("3-5d231-10001-db88")).expect("renders");
1419 let rendered: YsonValue =
1420 ytsaurus_yson::from_slice(&body, YsonFormat::Text).expect("valid YSON");
1421 let YsonNode::List(requests) = &rendered["requests"].node else {
1422 panic!("requests is a list");
1423 };
1424
1425 // The create is stamped with the client's transaction.
1426 assert_eq!(
1427 requests[0]["parameters"]["transaction_id"].as_str(),
1428 Some("3-5d231-10001-db88")
1429 );
1430 // A command with no transaction to be in is left alone.
1431 assert!(
1432 field(&requests[1]["parameters"], b"transaction_id").is_none(),
1433 "get_operation takes no transaction"
1434 );
1435 // A part that names its own transaction keeps it.
1436 assert_eq!(
1437 requests[2]["parameters"]["transaction_id"].as_str(),
1438 Some("3-aaa-bbb-ccc")
1439 );
1440 }
1441}