Skip to main content

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, &params)?;
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(&parameters)
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        &parameters.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}