Skip to main content

ytsaurus_client/
lib.rs

1//! A thin [YTsaurus](https://ytsaurus.tech) client: enough of the HTTP API v4
2//! to run a Rust worker without a Python installation.
3//!
4//! It is deliberately small. It does what launching a job needs — create a
5//! node, upload the worker, write and read tables, start an operation and wait
6//! for it — and nothing else. For everything beyond that, the `yt` CLI remains
7//! the right tool.
8//!
9//! # Launching a job
10//!
11//! ```no_run
12//! use ytsaurus_client::{Client, MapSpec};
13//!
14//! # fn main() -> Result<(), ytsaurus_client::ClientError> {
15//! let client = Client::from_env()?;
16//!
17//! // Upload the worker, marked executable so the node can run it.
18//! client.upload_worker("target/.../my_job", "//tmp/my_job")?;
19//!
20//! let spec = MapSpec::new("./my_job", ["//tmp/input"], ["//tmp/output"])
21//!     .with_local_file("//tmp/my_job")
22//!     .with_memory_limit(512 * 1024 * 1024);
23//!
24//! let id = client.start_map(&spec)?;
25//! client.wait_for_operation(&id)?;
26//! # Ok(())
27//! # }
28//! ```
29//!
30//! # Configuration
31//!
32//! [`Client::from_env`] reads `YT_PROXY` for the cluster address, and finds a
33//! token the way the `yt` CLI does: `YT_TOKEN`, then the file named by
34//! `YT_TOKEN_PATH`, then `~/.yt/token`. A machine where the CLI already works
35//! needs nothing else. A bare host is assumed to be HTTPS; a local cluster is
36//! reached as `http://localhost:8000`.
37//!
38//! `YT_CA_BUNDLE` names a PEM file of root certificates, for an installation
39//! whose certificate chains to a CA the Mozilla bundle has never heard of. It
40//! is read by any build with the `tls` feature — which is the default, and the
41//! only kind that has a handshake to configure — and the `platform-verifier`
42//! feature is the same answer without a variable to set. Every block in the
43//! file must be an X.509 certificate: one that is not, a `.p7b` re-armoured
44//! under a `BEGIN CERTIFICATE` label being the usual case, refuses the whole
45//! file rather than becoming a root store quietly shorter than the caller
46//! wrote down. Without it, and without that feature, a cluster behind a private
47//! CA fails its very first request with `invalid peer certificate:
48//! UnknownIssuer` — the refusal names both ways out, because on a machine where
49//! `curl` reaches the same cluster nothing else about it suggests whose roots
50//! were consulted.
51//!
52//! **An installation differs from a local cluster in ways a caller of
53//! [`Client::from_env`] cannot otherwise reach**, so it reads four more:
54//! `YT_PROXY_SUFFIX` completes a bare cluster name, `YT_HEAVY_PROXY_DOMAINS`
55//! names another domain its heavy proxies live in, `YT_HEAVY_PROXIES_ANYWHERE`
56//! removes that rule outright, and `YT_FILE_CACHE` moves the worker cache. Each
57//! is inert when unset, and each but the first has a builder method beside it —
58//! see [`Client::from_env`] for the table.
59//!
60//! # When an operation fails
61//!
62//! [`Client::wait_for_operation`] does not stop at the state. It asks the
63//! cluster which jobs failed and what they wrote to stderr, and carries both in
64//! [`ClientError::OperationFailed`], so a failure explains itself without a
65//! trip to the web UI:
66//!
67//! ```text
68//! operation 1ba94195-… finished as failed: Failed jobs limit exceeded: Process terminated by signal 6
69//!   job 24c164af-… on localhost:24403: User job failed: Process terminated by signal 6
70//!   stderr:
71//!     thread 'main' panicked at examples/src/bin/boom.rs:37:17:
72//!     boom: this job fails on purpose (row 1, 23 bytes)
73//! ```
74//!
75//! That costs one [`Client::list_jobs`] and a few [`Client::get_job_stderr`]
76//! calls per failed operation; [`Client::with_job_diagnostics`] turns it off.
77//!
78//! # After it has started
79//!
80//! An operation can be paused, given more of its pool, finished early, found by
81//! the alias its spec gave it, and — the one that matters for a pipeline that
82//! restarts — picked up again by a process that did not start it:
83//!
84//! ```no_run
85//! # use ytsaurus_client::{Client, OperationParameters};
86//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
87//! # let client = Client::from_env()?;
88//! let op = client.attach_operation(std::fs::read_to_string("run.id")?);
89//!
90//! op.suspend(false)?;
91//! op.update_parameters(&OperationParameters::new().with_weight(2.0))?;
92//! op.resume()?;
93//! op.wait()?;
94//! # Ok(())
95//! # }
96//! ```
97//!
98//! Everything on [`Operation`] is also on [`Client`], taking the id. See the
99//! [`operation`] module for what the cluster does and does not promise about
100//! each of those commands — some of it is surprising, and all of it was
101//! measured.
102//!
103//! # All at once, or not at all
104//!
105//! Each step above can fail halfway and leave something behind — an empty
106//! table, a stale worker, an output table holding neither the old result nor
107//! the new one. [`Client::start_transaction`] makes the whole sequence one
108//! event: nothing it does is visible until [`Transaction::commit`], and
109//! dropping the handle aborts it, so a `?` on any line leaves the cluster as it
110//! was.
111//!
112//! A transaction can also outlive its handle: [`Transaction::detach`] stops
113//! the keep-alive and leaves it running, [`Client::attach_transaction`] turns
114//! the id back into a handle elsewhere, and [`Client::ping_transaction`],
115//! [`Client::commit_transaction`] and [`Client::abort_transaction`] finish one
116//! from a process that holds nothing but the id.
117//!
118//! # Seeing what it did
119//!
120//! The cluster traces itself, so joining its trace costs a header and no
121//! dependency: [`Client::with_trace_context`] puts every request into the
122//! trace a [`TraceContext`] names, and the proxy's own span for that request
123//! is placed inside it rather than starting an orphan.
124//!
125//! This process's own side is the `tracing` feature, off by default: with it,
126//! each attempt runs in a span carrying the command, the attempt number and
127//! the elapsed time, and the message a retry prints on stderr becomes a `WARN`
128//! event instead. It is off because this crate is linked into worker binaries
129//! cross-compiled to musl — the same reason `tls` is.
130//!
131//! # Heavy commands go where the cluster says
132//!
133//! Table and file data — [`Client::write_table`], [`Client::read_table`],
134//! [`Client::write_file`], [`Client::read_file`], [`Client::upload_worker`]
135//! and the streaming forms of each — is what YTsaurus calls a *heavy* command,
136//! and a large installation serves those on a separate set of proxies. This
137//! client asks `/hosts` the first time it sends a heavy command, keeps the
138//! whole answer as a **pool**, and sends each heavy command to a member
139//! **picked at random** — the way both official SDKs pick, because `/hosts`
140//! is ordered by load and a client that keeps one pick for its lifetime never
141//! rebalances: a draining host keeps every client that ever picked it. The
142//! answer is **refreshed** when it outlives
143//! [`Client::with_host_list_refresh_interval`] — a minute by default, the
144//! documentation's own advice — lazily, by the heavy command that finds it
145//! stale; there is no background thread, and a client that stops uploading
146//! stops asking. Light commands stay on the address it was configured with.
147//!
148//! **A proxy that fails is dropped from the pool, not committed to.** A heavy
149//! command that fails for a reason attributable to the host it went to — a
150//! refused connection, a 503, a certificate that does not match that host's
151//! own name — takes that host out of the pool, and the next command picks
152//! from what remains; a later refresh that still names the host puts it back.
153//! Only a pool with nobody left in it sends the client back to the configured
154//! address — and then only until it asks the cluster again, a few seconds
155//! later ([`Client::with_hosts_retry_after`]). That order matters: on a
156//! deployment with separate proxy roles the configured address is a *control*
157//! proxy, and going back there on the first hiccup is the failure this
158//! feature exists to prevent.
159//!
160//! **A cluster that names no heavy proxy is answered by using the configured
161//! address**, which is what leaves a single-node installation working exactly
162//! as it did — asked about again one refresh interval later, so a first
163//! lookup that landed during a rolling restart is not a verdict for life.
164//! Nor is such a cluster asked in the first place when its address
165//! is on loopback: `localhost` is this machine's own cluster or a tunnel to
166//! one, and the address a far-side proxy publishes for itself is not reachable
167//! from either. [`Client::with_proxy_discovery`] overrides that in both
168//! directions, and [`Client::heavy_proxy`] answers the question directly.
169//!
170//! **A discovered host is used only if it shares the configured address's own
171//! domain**, and the scheme and port come from that address rather than from
172//! the answer. That rule is a guard against a typo in a configuration and
173//! against an obviously foreign name — not a promise about where a token can
174//! end up. Steering it with a `/hosts` body means controlling that body, which
175//! over `https://` means owning the proxy (which has the token already) and
176//! over `http://` means being a man-in-the-middle (who reads it out of every
177//! light command anyway). Where the rule does bite is a proxy registering
178//! itself in the cluster's coordinator under an unintended name, and even there
179//! it is coarse: sharing a parent domain on a hosting platform means sharing it
180//! with every other tenant of that platform.
181//! [`Client::with_heavy_proxies_in`] is the version that is a boundary — a list
182//! written out on purpose — [`Client::with_heavy_proxies_under`] names one more
183//! domain for an installation that publishes its heavy proxies in a second zone,
184//! and [`Client::with_heavy_proxies_anywhere`] removes the rule. When a whole
185//! answer is declined the client says so once, naming what it refused and why,
186//! rather than leaving it to be deduced from a cluster error later on.
187//!
188//! Getting this wrong does not look like a routing problem, which is why it is
189//! worth spelling out what it does look like. The refusal arrives as a
190//! structured YTsaurus error — `cluster error 1: Control proxy may not serve
191//! heavy requests with input data` — and this crate's own error rendering does
192//! not print the status beside it, which is how the status came to be recorded
193//! here as 200. The cluster's own rule, from
194//! `TContext::TryRedirectHeavyRequests`, turns on whether the request carries
195//! input data: a heavy **write** gets **503** with `Retry-After: 60`, and a
196//! heavy **read** gets a **307** to a data proxy. And a deployment **behind a
197//! balancer is the case that breaks**, not the case that works: the balancer
198//! fronts the control proxies, so every upload arrives at one.
199
200#![warn(missing_docs)]
201
202use std::time::{Duration, Instant};
203
204mod batch;
205/// Errors.
206pub mod error;
207mod http;
208mod jobs;
209/// Cypress locks.
210pub mod lock;
211mod observe;
212/// The operation handle, and what its commands take and answer.
213pub mod operation;
214/// Table paths that carry attributes.
215pub mod path;
216mod retry;
217/// Table schemas.
218pub mod schema;
219mod spec;
220/// Streaming table I/O.
221pub mod stream;
222/// The trace a request belongs to.
223pub mod trace;
224mod transaction;
225mod unique;
226mod worker;
227/// Constructors for YSON documents, for specs this crate does not model.
228pub mod yson_build;
229
230pub use crate::batch::BatchRequest;
231pub use crate::error::{ClientError, RedirectRefusal, Result};
232pub use crate::http::Method;
233pub use crate::jobs::{JobFailure, JobInfo};
234pub use crate::lock::{Lock, LockMode};
235pub use crate::operation::{
236    Operation, OperationEvent, OperationFilter, OperationInfo, OperationList, OperationParameters,
237    OperationStatus,
238};
239pub use crate::path::{Key, RowRange, TablePath};
240pub use crate::retry::{MutationId, Repeatable, RetryPolicy};
241pub use crate::schema::{Column, ColumnType, SortOrder, TableRow, TableSchema};
242// The derive and the trait share a name, as `serde::Serialize` does: they live
243// in different namespaces, and a user wants both under one import.
244pub use crate::spec::{
245    EraseSpec, MapReduceSpec, MapSpec, MergeMode, MergeSpec, OperationType, ReduceSpec,
246    RemoteCopySpec, SortSpec, VanillaSpec, VanillaTask,
247};
248pub use crate::stream::{FileReader, ResponseReader, TableReader};
249pub use crate::trace::TraceContext;
250pub use crate::transaction::Transaction;
251pub use ytsaurus_format::DataFormat;
252#[cfg(feature = "derive")]
253pub use ytsaurus_helpers::TableRow;
254pub use ytsaurus_skiff::{
255    Format as SkiffFormat, Schema as SkiffSchema, SchemaRef as SkiffSchemaRef,
256    WireType as SkiffWireType,
257};
258
259use crate::http::{Payload, Transport};
260use ytsaurus_skiff::Decoder as SkiffDecoder;
261use ytsaurus_yson::{YsonFormat, YsonNode, YsonValue, from_slice};
262
263/// Default request timeout.
264const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
265
266/// How often [`Client::wait_for_operation`] asks the cluster for progress.
267const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
268
269/// How many failed jobs a failed operation reports.
270///
271/// Jobs of one operation usually fail the same way, so the first few explain
272/// the failure and the rest only make the message longer.
273const REPORTED_JOBS: u32 = 3;
274
275/// How much of a job's stderr goes into the error message.
276///
277/// The cluster caps saved stderr at megabytes; an error a user reads in a
278/// terminal wants the tail of it, not all of it.
279const STDERR_EXCERPT: usize = 4096;
280
281/// Where the cluster's file cache lives.
282///
283/// The path the Python wrapper uses, so a cache an installation already
284/// maintains — and already expires entries from — is the one this client uses
285/// too.
286const DEFAULT_FILE_CACHE: &str = "//tmp/yt_wrapper/file_storage/new_cache";
287
288/// Where a worker goes when the file cache will not have it.
289///
290/// `//tmp` because it is the scratch directory an installation gives its users
291/// — the cache itself lives under it — so a caller refused the cache can still
292/// be expected to have this. There is nowhere further to fall: a cluster that
293/// refuses this too is reported rather than worked around.
294const UNCACHED_UPLOAD_DIR: &str = "//tmp";
295
296/// `Access denied` — the cluster's code for a request no matching ACE allows.
297///
298/// What an installation-managed file cache answers a write with, and the whole
299/// of what [`Client::upload_worker_cached`] treats as "no cache for you".
300const ACCESS_DENIED: i64 = 901;
301
302/// The `{value=…}` API v4 wraps a structured answer in.
303///
304/// Deserialised rather than walked, so [`Client::get_as`] reads the response
305/// once. Keys the type does not mention are ignored, which is what lets the
306/// envelope grow a field without breaking this.
307#[derive(serde::Deserialize)]
308struct Envelope<T> {
309    value: T,
310}
311
312/// A worker binary on the cluster, as [`Client::upload_worker_cached`] left it.
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct CachedFile {
315    /// Cypress path to reference from a spec.
316    pub path: String,
317    /// The name to give it in the job's sandbox.
318    ///
319    /// The cached node is named after the file's hash, so a command like
320    /// `./my_job` needs this passed to
321    /// [`MapSpec::with_local_file_named`].
322    pub name: String,
323    /// Whether this call had to upload it. `false` is a cache hit.
324    pub uploaded: bool,
325    /// Whether [`CachedFile::path`] is inside the shared file cache.
326    ///
327    /// `true` for a cache hit and for an upload the cache accepted; `false`
328    /// only when the cache refused this caller and the worker went up under
329    /// `//tmp` instead — see [`Client::upload_worker_cached`].
330    ///
331    /// **This is the field to branch on before removing anything.** The two
332    /// are not the same question and neither answers the other: `uploaded`
333    /// alone says the bytes were sent, which is true of both destinations, so
334    /// a caller that tidies up after itself on that signal deletes the *shared
335    /// cache entry* on an ordinary cluster and evicts the binary for everyone
336    /// else. A caller that never tidies up leaks a node per launch on the
337    /// cluster where this is `false`, since nothing expires `//tmp` uploads —
338    /// which is the other half of why the fallback warns.
339    pub cached: bool,
340}
341
342/// What an upload through the file cache came to.
343enum Cached {
344    /// It is in the cache, at this path.
345    At(String),
346    /// The cache refused this caller, in the cluster's own words. Carried back
347    /// rather than returned as an error: see [`Client::upload_worker_cached`],
348    /// which uploads outside the cache instead and says so.
349    Refused(ClientError),
350}
351
352/// A connection to one YTsaurus cluster.
353#[derive(Debug, Clone)]
354pub struct Client {
355    transport: Transport,
356    poll_interval: Duration,
357    job_diagnostics: bool,
358    file_cache: String,
359}
360
361impl Client {
362    /// Connects to `proxy`, with no token.
363    ///
364    /// `proxy` may be a bare host (`cluster.example.com`, assumed HTTPS) or
365    /// carry a scheme (`http://localhost:8000`).
366    #[must_use]
367    pub fn new(proxy: &str) -> Self {
368        Self {
369            transport: Transport::new(proxy, None, DEFAULT_TIMEOUT),
370            poll_interval: DEFAULT_POLL_INTERVAL,
371            job_diagnostics: true,
372            file_cache: DEFAULT_FILE_CACHE.to_owned(),
373        }
374    }
375
376    /// Connects to `proxy` using `token` for authentication.
377    #[must_use]
378    pub fn with_token(proxy: &str, token: impl Into<String>) -> Self {
379        Self {
380            transport: Transport::new(proxy, Some(token.into()), DEFAULT_TIMEOUT),
381            poll_interval: DEFAULT_POLL_INTERVAL,
382            job_diagnostics: true,
383            file_cache: DEFAULT_FILE_CACHE.to_owned(),
384        }
385    }
386
387    /// Connects using `YT_PROXY`, and whatever token the environment offers.
388    ///
389    /// The token is looked for the way the `yt` CLI looks for it, and stops at
390    /// the first that has one:
391    ///
392    /// 1. `YT_TOKEN`;
393    /// 2. the file named by `YT_TOKEN_PATH`;
394    /// 3. `~/.yt/token`.
395    ///
396    /// So a machine where the CLI already works needs no extra setup. A token
397    /// read from a file is **trimmed**: one written with `echo` ends in a
398    /// newline, and sending that produces an authentication failure that says
399    /// nothing about a newline. An unreadable file is treated as no token
400    /// rather than as an error, because that is what it means on a cluster that
401    /// wants none.
402    ///
403    /// # What else it reads
404    ///
405    /// Everything a cluster can differ in that a *caller* cannot reach from
406    /// here. Every example in this repository builds its client with this one
407    /// method, so a policy settable only in Rust is a policy an example cannot
408    /// be run under — which is how an installation that publishes its heavy
409    /// proxies in another domain came to be unrunnable by any configuration at
410    /// all, and had to be answered with a patch. Each of these is inert when
411    /// unset, so a client built on a machine that sets none behaves exactly as
412    /// [`Client::new`] does.
413    ///
414    /// | Variable | Effect |
415    /// | --- | --- |
416    /// | `YT_PROXY_SUFFIX` | Completes a bare cluster name: `YT_PROXY=hume` with `YT_PROXY_SUFFIX=.yt.example.net` addresses `hume.yt.example.net`. Off unless set, and applied only to a name with no dot, no colon and no `localhost` in it — the gate the Go SDK uses. There is no builder for this one: in Rust, spell the address out. |
417    /// | `YT_CA_BUNDLE` | A PEM file of roots, for a cluster behind a private CA. Read by the transport rather than here, and by [`Client::new`] too. |
418    /// | `YT_HEAVY_PROXY_DOMAINS` | One more domain — or several, comma- or space-separated — that `/hosts` may name a heavy proxy under. [`Client::with_heavy_proxies_under`]. |
419    /// | `YT_HEAVY_PROXIES_ANYWHERE` | `1`, `true` or `yes` removes the domain rule outright. [`Client::with_heavy_proxies_anywhere`]. |
420    /// | `YT_FILE_CACHE` | Where [`Client::upload_worker_cached`] keeps its files, for an installation whose shared cache is read-only. [`Client::with_file_cache`]. |
421    ///
422    /// `YT_HEAVY_PROXIES_ANYWHERE` is applied after `YT_HEAVY_PROXY_DOMAINS`, so
423    /// a machine that sets both is one where the rule is off — the wider of the
424    /// two wins, rather than the order they happen to be exported in.
425    ///
426    /// **The environment can widen the heavy-proxy rule and cannot narrow it**,
427    /// which is deliberate: [`Client::with_heavy_proxies_in`] is the one mode
428    /// that is a boundary rather than a heuristic, and a boundary that a
429    /// variable could set is a boundary that a variable could move. Write that
430    /// one in Rust.
431    ///
432    /// A variable **set to nothing counts as unset**, all of them alike:
433    /// `export YT_FILE_CACHE=` in a shell profile is how a knob gets turned back
434    /// off, and reading it literally would point the cache at `""`. `YT_PROXY`
435    /// included — an empty one earns the same message as a missing one, which is
436    /// the message that says what to export.
437    ///
438    /// # Errors
439    ///
440    /// Returns [`ClientError::Config`] if `YT_PROXY` is not set, or set to
441    /// nothing.
442    pub fn from_env() -> Result<Self> {
443        Self::from_lookup(environment_value)
444    }
445
446    /// [`Client::from_env`], with the environment handed in.
447    ///
448    /// Everything that method does except reading the process environment, so a
449    /// test can pin **which variable does what** — that a typo in one of the
450    /// five names, or the two heavy-proxy knobs applied in the other order, is
451    /// caught by something other than review. Writing the process environment is
452    /// global, and unsafe in edition 2024; the same split is why
453    /// `http::roots_for` exists beside `http::configured_bundle`.
454    ///
455    /// **Except the token**, which finds its own way in through
456    /// [`token_from_environment`] — `YT_TOKEN`, then `YT_TOKEN_PATH`, then
457    /// `~/.yt/token`, the last of which is a file and not a variable at all. A
458    /// caller of this seam is configuring the five above and nothing else.
459    ///
460    /// The trimming and the empty-is-unset rule live **here** rather than in the
461    /// lookup, so they are on the path every caller takes: a test that
462    /// reimplemented them in its own fake would be pinning the fake, and
463    /// deleting them from [`environment_value`] would leave everything green.
464    fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self> {
465        let value = |name: &str| {
466            lookup(name)
467                .map(|value| value.trim().to_owned())
468                .filter(|value| !value.is_empty())
469        };
470
471        let proxy = value("YT_PROXY").ok_or_else(|| {
472            ClientError::Config(
473                "YT_PROXY is not set; export it (for a local cluster: \
474                 YT_PROXY=http://localhost:8000) or use Client::new"
475                    .to_owned(),
476            )
477        })?;
478        let proxy = expanded_proxy(&proxy, value("YT_PROXY_SUFFIX").as_deref());
479
480        let mut client = match token_from_environment() {
481            Some(token) => Self::with_token(&proxy, token),
482            None => Self::new(&proxy),
483        };
484
485        if let Some(domains) = value("YT_HEAVY_PROXY_DOMAINS") {
486            client = client.with_heavy_proxies_under(split_domains(&domains));
487        }
488        if value("YT_HEAVY_PROXIES_ANYWHERE").is_some_and(|value| truthy(&value)) {
489            client = client.with_heavy_proxies_anywhere(true);
490        }
491        if let Some(cache) = value("YT_FILE_CACHE") {
492            client = client.with_file_cache(cache);
493        }
494
495        Ok(client)
496    }
497
498    /// Overrides how often [`Client::wait_for_operation`] polls.
499    #[must_use]
500    pub fn with_poll_interval(mut self, interval: Duration) -> Self {
501        self.poll_interval = interval;
502        self
503    }
504
505    /// Overrides the request timeout, which defaults to two minutes.
506    ///
507    /// For a buffered command the limit is end to end, **redirects included**:
508    /// an attempt takes its deadline once and the hops it makes share what is
509    /// left of it, so a proxy that redirects cannot multiply the limit by the
510    /// length of the chain. A retry is a fresh attempt and gets a fresh budget,
511    /// which is what [`Client::with_retries`] bounds.
512    ///
513    /// A streaming transfer — [`Client::read_table_streaming`],
514    /// [`Client::write_table_rows`] and their kin — is not cut off mid-table:
515    /// there the timeout bounds each wait *around* the data (connecting,
516    /// sending the request, the response headers), and the data itself moves
517    /// for as long as it takes.
518    #[must_use]
519    pub fn with_timeout(mut self, timeout: Duration) -> Self {
520        self.transport.set_timeout(timeout);
521        self
522    }
523
524    /// Overrides how a failed request is repeated.
525    ///
526    /// The default is five attempts with a doubling delay, which covers the
527    /// transient failures a shared cluster produces — a restarting proxy, a
528    /// scheduler that has lost the master. [`RetryPolicy::none`] turns it off.
529    ///
530    /// This applies to light commands only. Heavy ones — table and file I/O —
531    /// are sent once whatever the policy says, because the documentation is
532    /// explicit that they cannot be retried; a transaction is the way to make
533    /// one atomic.
534    #[must_use]
535    pub fn with_retries(mut self, policy: RetryPolicy) -> Self {
536        self.transport.set_retries(policy);
537        self
538    }
539
540    /// Overrides where [`Client::upload_worker_cached`] keeps its files.
541    ///
542    /// Defaults to the path the Python wrapper uses, so the cache is shared
543    /// with whatever else the installation runs — and whatever expiry its
544    /// administrators have set applies here too.
545    ///
546    /// That default is **read-only for an ordinary user** on a managed
547    /// installation, which the client itself handles — a refused cache degrades
548    /// to a plain upload and says so — but which anything that needs to *clear*
549    /// an entry cannot. `YT_FILE_CACHE` sets the same thing for a client built
550    /// by [`Client::from_env`].
551    #[must_use]
552    pub fn with_file_cache(mut self, path: impl Into<String>) -> Self {
553        self.file_cache = path.into();
554        self
555    }
556
557    /// Overrides whether heavy commands ask the cluster where to go.
558    ///
559    /// They do by default, which is what makes an upload work on an
560    /// installation that separates proxy roles — unless the address this client
561    /// was given is on loopback, where the lookup can only cost a round trip or
562    /// name a host this process cannot reach. See the module documentation.
563    ///
564    /// Both overrides have a use:
565    ///
566    /// - `true` for a cluster reached at `localhost` that really does have
567    ///   heavy proxies this process can reach — a port-forward into a real
568    ///   installation, where the discovered addresses resolve;
569    /// - `false` to pin every command to the address given, which is what a
570    ///   balancer that already routes by role wants, and what to reach for if
571    ///   the lookup itself is the thing misbehaving.
572    ///
573    /// This does not disturb what a client it was cloned from has already
574    /// resolved.
575    #[must_use]
576    pub fn with_proxy_discovery(mut self, enabled: bool) -> Self {
577        self.transport.set_proxy_discovery(enabled);
578        self
579    }
580
581    /// Lets `/hosts` name a heavy proxy outside the configured address's own
582    /// domain.
583    ///
584    /// **Off by default.** A discovered name is used only if it is the
585    /// configured host itself or sits under that host's parent domain —
586    /// `https://cluster.example.net` will follow `n0132-sas.example.net` and
587    /// will not follow `n0132-sas.somewhere-else.net`. A configured name with
588    /// no dots in it, which is how `YT_PROXY` is usually written, is matched as
589    /// a label instead: `hume` follows `n0008-sas.hume.yt.example.net`. A name
590    /// that is refused is passed over; a `/hosts` answer that is refused
591    /// entirely leaves the upload going to the configured address, which is
592    /// where it went before this client routed anything, and the client says so
593    /// once rather than leaving it to be deduced.
594    ///
595    /// **What that rule is worth**, since it was once written down here as more
596    /// than it is: it guards against a typo in a configuration and against an
597    /// obviously foreign name. It is not what keeps a token where you put it.
598    /// Steering a heavy command with a `/hosts` body means controlling that
599    /// body — over `https://` that is owning the proxy, which has the token
600    /// already, and over `http://` that is being a man-in-the-middle, who reads
601    /// the token out of every light command without coming near this. Where the
602    /// rule does bite is a proxy registering itself in the coordinator under an
603    /// unintended name, and even there a shared parent domain on a hosting
604    /// platform is shared with every tenant of it. Use
605    /// [`Client::with_heavy_proxies_in`] where a real boundary is wanted.
606    ///
607    /// Turn it on for an installation whose `/hosts` genuinely names another
608    /// domain — a cluster fronted by a vanity address, or one whose data proxies
609    /// live under a separate zone. Nothing else in the client changes; the
610    /// scheme still comes from the configured address, a name carrying `://`,
611    /// `/`, `@` or whitespace is still refused, and the configured port still
612    /// carries through.
613    ///
614    /// The symptom of needing it is an upload that reaches the *configured*
615    /// address and is refused there — `Control proxy may not serve heavy
616    /// requests with input data` — while [`Client::heavy_proxy`] shows a
617    /// perfectly good address the client declined to use. The client says so
618    /// itself, once, when it declines a whole `/hosts` answer, and the refusal
619    /// it then collects carries the same sentence.
620    ///
621    /// ```
622    /// use ytsaurus_client::Client;
623    ///
624    /// let client = Client::new("https://cluster.example.net")
625    ///     .with_heavy_proxies_anywhere(true);
626    /// ```
627    ///
628    /// **This is all or nothing**, which is why
629    /// [`Client::with_heavy_proxies_under`] and
630    /// [`Client::with_heavy_proxies_in`] exist beside it: a domain rule that
631    /// misses by one label should not have to be answered by removing the rule
632    /// — name the other domain, or the proxies themselves. The last of the
633    /// three called is the one that decides.
634    ///
635    /// This does not disturb what a client it was cloned from has already
636    /// resolved.
637    #[must_use]
638    pub fn with_heavy_proxies_anywhere(mut self, enabled: bool) -> Self {
639        self.transport.set_heavy_proxies_anywhere(enabled);
640        self
641    }
642
643    /// Restricts heavy commands to a list of proxies written out by hand.
644    ///
645    /// The third answer to "which of the names `/hosts` gives may this client
646    /// send a token to", and the only one that is a boundary rather than a
647    /// heuristic. The domain rule is a guard against a typo and against an
648    /// obviously foreign name — it cannot be more than that without a
649    /// public-suffix list, and on a shared platform a shared parent domain
650    /// means very little: `yt-1234.us-east-1.elb.amazonaws.com` and every other
651    /// load balancer in that region share one. A list somebody wrote on purpose
652    /// does not have that problem.
653    ///
654    /// Names are compared **without their ports and without case**; the port a
655    /// command is sent to still comes from the configured address, or from the
656    /// `/hosts` entry when it carries one. Everything else in the client is
657    /// unchanged: the scheme comes from the configured address, and a name
658    /// carrying `://`, `/`, `@` or whitespace is still not a name.
659    ///
660    /// ```
661    /// use ytsaurus_client::Client;
662    ///
663    /// let client = Client::new("https://cluster.example.net")
664    ///     .with_heavy_proxies_in(["n0132-sas.example.net", "n0133-sas.example.net"]);
665    /// ```
666    ///
667    /// An empty list admits nothing, so every heavy command stays on the
668    /// configured address — [`Client::with_proxy_discovery`] is the plainer way
669    /// to say that. The last of this and
670    /// [`Client::with_heavy_proxies_anywhere`] to be called is the one that
671    /// decides, and neither disturbs what a client this was cloned from has
672    /// already resolved.
673    #[must_use]
674    pub fn with_heavy_proxies_in<I, S>(mut self, names: I) -> Self
675    where
676        I: IntoIterator<Item = S>,
677        S: Into<String>,
678    {
679        self.transport
680            .set_heavy_proxies_in(names.into_iter().map(Into::into).collect());
681        self
682    }
683
684    /// Lets `/hosts` name a heavy proxy under a domain given here, as well as
685    /// under the configured address's own.
686    ///
687    /// The middle setting, and on a large installation the only one that fits.
688    /// A cluster addressed as `cluster.example.net` may publish its heavy
689    /// proxies as `n0132-sas.rack7.proxy-zone.net` — a different domain, so the
690    /// default rule refuses every one of them and no upload can leave the
691    /// control proxy: `Control proxy may not serve heavy requests with input
692    /// data`. The two answers that existed for that were writing all
693    /// seventy-nine names out by hand, which goes stale the moment a proxy
694    /// rotates, and [`Client::with_heavy_proxies_anywhere`], which removes the
695    /// rule. What such an installation actually has is one more domain.
696    ///
697    /// ```
698    /// use ytsaurus_client::Client;
699    ///
700    /// let client = Client::new("https://cluster.example.net")
701    ///     .with_heavy_proxies_under(["proxy-zone.net"]);
702    /// ```
703    ///
704    /// A domain is matched as a suffix and as itself, without case: the entry
705    /// above admits `proxy-zone.net` and anything under it, and nothing else.
706    /// Every way a person writes one is accepted — surrounding space, a leading
707    /// or trailing dot, a leading `*`, a scheme, a port — so a value read out of
708    /// a configuration file works as written. An entry left with **no dot in
709    /// it** is dropped rather than honoured: `net` would admit every `.net` host
710    /// the cluster could name, which is
711    /// [`Client::with_heavy_proxies_anywhere`] by accident.
712    ///
713    /// The configured address's own domain still applies — this widens the
714    /// rule, it does not replace it — and an empty list therefore means exactly
715    /// the default. A **second call replaces the first**, like every other
716    /// setter here; it does not accumulate. And note the shape of the family
717    /// rather than the reading of one word:
718    /// `with_heavy_proxies_anywhere(false)` after this means *the default rule*
719    /// and so discards these domains, which is not "stop widening".
720    ///
721    /// **It is still a suffix rule**, so it is worth what the domain rule is
722    /// worth: a guard against a typo and against an obviously foreign name, not
723    /// a boundary that holds a credential — see
724    /// [`Client::with_heavy_proxies_anywhere`] for why that is, and
725    /// [`Client::with_heavy_proxies_in`] for the version that is a boundary.
726    /// A domain somebody wrote on purpose is a narrower statement than removing
727    /// the rule, and it survives proxy rotation, which is the whole of what it
728    /// claims.
729    ///
730    /// The last of this,
731    /// [`Client::with_heavy_proxies_anywhere`] and
732    /// [`Client::with_heavy_proxies_in`] to be called is the one that decides,
733    /// and none of them disturbs what a client this was cloned from has already
734    /// resolved.
735    #[must_use]
736    pub fn with_heavy_proxies_under<I, S>(mut self, domains: I) -> Self
737    where
738        I: IntoIterator<Item = S>,
739        S: Into<String>,
740    {
741        self.transport
742            .set_heavy_proxies_under(domains.into_iter().map(Into::into).collect());
743        self
744    }
745
746    /// Overrides the budget for the `/hosts` lookup, which defaults to 800 ms.
747    ///
748    /// The lookup sits in front of the first heavy command and gets its own
749    /// budget rather than the client's, because not getting an answer costs
750    /// nothing worse than the routing this crate had none of a release ago —
751    /// see [`Client::with_timeout`] for the one that bounds a command.
752    ///
753    /// **Raising it is the point.** The budget used to be the smaller of 800 ms
754    /// and the client's own timeout, so it could only ever be lowered: a
755    /// cluster that answers `/hosts` in 900 ms could not be routed to by any
756    /// configuration at all. And 800 ms is not always generous — the first
757    /// heavy command is often a client's first request, which puts DNS, TCP and
758    /// a TLS handshake inside the same budget.
759    ///
760    /// ```
761    /// use std::time::Duration;
762    /// use ytsaurus_client::Client;
763    ///
764    /// let client = Client::new("https://cluster.example.net")
765    ///     .with_hosts_timeout(Duration::from_secs(3));
766    /// ```
767    #[must_use]
768    pub fn with_hosts_timeout(mut self, timeout: Duration) -> Self {
769        self.transport.set_hosts_timeout(timeout);
770        self
771    }
772
773    /// Overrides how long routing stays off after it falls back, which defaults
774    /// to ten seconds.
775    ///
776    /// Two things end up here: a `/hosts` lookup that failed for a reason that
777    /// might pass, and a pool whose every host has been dropped. Both mean
778    /// "use the address the caller gave, and ask the cluster again in a
779    /// moment"; this is the moment. A lookup that *settled* — no such endpoint,
780    /// an answer that is not a list of names, a cluster that names no heavy
781    /// proxy — runs on the other clock instead: it is asked about again one
782    /// [`Client::with_host_list_refresh_interval`] later, like any other
783    /// answer that has grown old. So does a failed *refresh*, deliberately —
784    /// a pool in hand still routes, so nothing there is urgent enough for
785    /// this window.
786    ///
787    /// Shorter brings routing back sooner after a cluster recovers, and costs a
788    /// lookup more often while it is broken. Longer is the other trade.
789    #[must_use]
790    pub fn with_hosts_retry_after(mut self, after: Duration) -> Self {
791        self.transport.set_hosts_retry_after(after);
792        self
793    }
794
795    /// Overrides how old a `/hosts` answer may grow before a heavy command
796    /// re-asks, which defaults to one minute.
797    ///
798    /// The default is the documentation's own advice — "a good strategy is to
799    /// re-query the `/hosts` list every minute or every few queries" — and
800    /// the refresh is lazy, the way the C++ SDK does it: the heavy command
801    /// that finds the list stale asks first, and a client that stops
802    /// uploading stops asking. There is no background thread. A refresh that
803    /// fails keeps the previous answer in use rather than dropping routing on
804    /// the floor, and waits out another interval before asking again.
805    ///
806    /// The refresh is also what restores a proxy the client dropped: a heavy
807    /// command that fails for a reason attributable to the host it went to —
808    /// a refused connection, a 503, a certificate that does not match that
809    /// host's name — takes that host out of the pool, and the next fresh
810    /// answer that still names it puts it back.
811    ///
812    /// ```
813    /// use std::time::Duration;
814    /// use ytsaurus_client::Client;
815    ///
816    /// let client = Client::new("https://cluster.example.net")
817    ///     .with_host_list_refresh_interval(Duration::from_secs(300));
818    /// ```
819    ///
820    /// Shorter follows the cluster's load-ordering more closely and costs a
821    /// lookup more often — `Duration::ZERO` re-asks before every heavy
822    /// command. `Duration::MAX` disables the refresh: the first answer is
823    /// then kept as long as it keeps working, though a failed host is still
824    /// dropped and an emptied pool still falls back and re-asks.
825    #[must_use]
826    pub fn with_host_list_refresh_interval(mut self, interval: Duration) -> Self {
827        self.transport.set_host_list_refresh_interval(interval);
828        self
829    }
830
831    /// Turns the failed-job report in [`Client::wait_for_operation`] on or off.
832    ///
833    /// On by default: when an operation fails, the client asks the cluster
834    /// which jobs failed and what they printed, and puts that in the error.
835    /// That costs one `list_jobs` and a few `get_job_stderr` calls per failed
836    /// operation. The YTsaurus documentation asks that `list_jobs` not be used
837    /// without an administrator's approval, so this is the way to switch it
838    /// off on an installation where that approval was not given.
839    #[must_use]
840    pub fn with_job_diagnostics(mut self, enabled: bool) -> Self {
841        self.job_diagnostics = enabled;
842        self
843    }
844
845    /// Binds this client to an existing transaction.
846    ///
847    /// Every command it then sends happens inside that transaction. This is the
848    /// low-level door: [`Client::start_transaction`] is the one that starts a
849    /// transaction, keeps it alive and aborts it if the work does not finish,
850    /// and [`Client::attach_transaction`] is the one that turns an id from
851    /// elsewhere into such a handle — pinging, able to commit and abort.
852    ///
853    /// This binding does neither: nothing pings the transaction on this path,
854    /// so it expires on the cluster's schedule unless its owner — or
855    /// [`Client::ping_transaction`] — is pinging it, and finishing it takes
856    /// [`Client::commit_transaction`] or [`Client::abort_transaction`] with
857    /// the id. What it buys over `attach_transaction` is costlessness: no
858    /// round trip, no thread.
859    #[must_use]
860    pub fn with_transaction(mut self, id: impl Into<String>) -> Self {
861        self.transport.set_transaction(Some(id.into()));
862        self
863    }
864
865    /// The transaction this client is bound to, if any.
866    #[must_use]
867    pub fn transaction_id(&self) -> Option<&str> {
868        self.transport.transaction()
869    }
870
871    /// Puts every request this client sends into `context`'s trace.
872    ///
873    /// The cluster traces itself: the proxy opens a span for each request, and
874    /// a request that names a trace has its span put inside that one instead of
875    /// starting an orphan. So this is the cheap half of making a launch
876    /// visible — nothing is emitted from this process, and the work the cluster
877    /// does on its behalf turns up under the caller's own trace.
878    ///
879    /// ```
880    /// use ytsaurus_client::{Client, TraceContext};
881    ///
882    /// # fn main() -> Result<(), ytsaurus_client::ClientError> {
883    /// // A service passing on the trace it was called in.
884    /// let incoming = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
885    /// let client = Client::new("http://localhost:8000")
886    ///     .with_trace_context(&TraceContext::parse(incoming)?);
887    /// # Ok(())
888    /// # }
889    /// ```
890    ///
891    /// [`TraceContext::new`] starts a trace for a program that was not called
892    /// by anything, and [`TraceContext::yt_trace_id`] spells its id the way the
893    /// cluster's own logs and UI do.
894    ///
895    /// A [`Transaction`] started from this client inherits the context, pings
896    /// included — the transaction is part of the same piece of work, and a
897    /// commit that hung is one of the things a trace is for.
898    #[must_use]
899    pub fn with_trace_context(mut self, context: &TraceContext) -> Self {
900        self.transport.set_trace(context);
901        self
902    }
903
904    /// The `traceparent` header this client sends, if it was given one.
905    #[must_use]
906    pub fn traceparent(&self) -> Option<&str> {
907        self.transport.trace()
908    }
909
910    /// The `tracestate` header this client sends, if the context it joined
911    /// carried one. See [`TraceContext::with_tracestate`].
912    #[must_use]
913    pub fn tracestate(&self) -> Option<&str> {
914        self.transport.tracestate()
915    }
916
917    /// Starts a transaction, and keeps it alive while the handle lives.
918    ///
919    /// Everything sent through the returned [`Transaction`] is invisible to
920    /// everything else until it commits, and is discarded if it does not:
921    ///
922    /// ```no_run
923    /// # use ytsaurus_client::Client;
924    /// # fn main() -> Result<(), ytsaurus_client::ClientError> {
925    /// # let client = Client::from_env()?;
926    /// # let rows: Vec<u8> = Vec::new();
927    /// let tx = client.start_transaction()?;
928    ///
929    /// tx.create("table", "//tmp/out")?;   // no one else can see it yet
930    /// tx.write_table("//tmp/out", &rows)?;
931    ///
932    /// tx.commit()?;                       // and now everyone can
933    /// # Ok(())
934    /// # }
935    /// ```
936    ///
937    /// The transaction lasts 30 seconds without a ping — the cluster's own
938    /// default — and the handle pings it every ten, so an operation that runs
939    /// for an hour is fine. [`Client::start_transaction_with`] changes the
940    /// timeout.
941    ///
942    /// # Errors
943    ///
944    /// Returns [`ClientError`] if the transaction cannot be started.
945    pub fn start_transaction(&self) -> Result<Transaction> {
946        Transaction::start(self, transaction::DEFAULT_TRANSACTION_TIMEOUT)
947    }
948
949    /// Starts a transaction that expires `timeout` after its last ping.
950    ///
951    /// The handle pings three times per timeout, so this is about what happens
952    /// when the handle is *gone*: how long the transaction holds its locks
953    /// after the process holding it dies without aborting. Shorter frees them
954    /// sooner; longer survives a longer pause.
955    ///
956    /// # Errors
957    ///
958    /// Returns [`ClientError`] if the transaction cannot be started.
959    pub fn start_transaction_with(&self, timeout: Duration) -> Result<Transaction> {
960        Transaction::start(self, timeout)
961    }
962
963    /// Attaches to a transaction something else started, and keeps it alive.
964    ///
965    /// The receiving half of [`Transaction::detach`]: one process starts a
966    /// transaction and detaches, hands the id over, and this turns the id back
967    /// into a real [`Transaction`] — a bound client, a pinging thread, and
968    /// `commit`/`abort`/`ping` that work. Two things differ from a handle the
969    /// same process started, and both follow from not being the owner:
970    ///
971    /// - **Dropping it detaches rather than aborts** — the pings stop and
972    ///   nothing is sent. The C++ client's destructor draws the same line, and
973    ///   for the same reason: an attacher's `?` must not destroy work the
974    ///   process that started the transaction is still counting on. An
975    ///   explicit [`Transaction::abort`] still aborts; only the drop differs.
976    /// - **The ping interval is read, not chosen.** Pinging needs the
977    ///   transaction's timeout and the id alone does not carry it, so this
978    ///   asks the cluster for `#<id>/@timeout` — one round trip, which is also
979    ///   what makes attaching to a transaction that is gone fail *here*,
980    ///   rather than on the first command sent through the handle.
981    ///
982    /// **It pings before it returns**, one more round trip. `@timeout` is the
983    /// *configured* lifetime and says nothing about how much of it is left:
984    /// the id carries no hint of when its last holder pinged, so a handoff
985    /// that took longer than two thirds of the timeout would otherwise hand
986    /// back a handle whose first ping is already too late. That ping restarts
987    /// the cluster's clock at the attach, and doubles as the liveness probe
988    /// this call reports on.
989    ///
990    /// So this is **two retryable round trips**, both on this client and so
991    /// under its retry policy — five attempts of two minutes by default,
992    /// backoff between — where the keep-alive's own pings run one attempt on a
993    /// budget of half the ping interval. A ping the caller is waiting on
994    /// should not fail over one dropped packet; a keep-alive ping is retried
995    /// by being sent again next interval.
996    ///
997    /// **Nothing stops two attaches to the same id.** Each is a real handle
998    /// with a thread of its own, and they simply ping the same transaction
999    /// twice as often; whichever commits or aborts first decides it, and the
1000    /// other's next command fails with `No such transaction`. There is no
1001    /// registry, on purpose — a second process attaching is the whole point,
1002    /// and this process is not in a position to know about it.
1003    ///
1004    /// The handle always pings. One that did not would be
1005    /// [`Client::with_transaction`] — the plain binding, which already exists —
1006    /// plus [`Client::ping_transaction`], [`Client::commit_transaction`] and
1007    /// [`Client::abort_transaction`], which take the bare id; reach for those
1008    /// where a thread per transaction is not wanted. (The Go SDK spells that
1009    /// choice `AttachTx(id, &AttachTxOptions{AutoPingable: false})`.)
1010    ///
1011    /// ```no_run
1012    /// # use ytsaurus_client::Client;
1013    /// # fn main() -> Result<(), ytsaurus_client::ClientError> {
1014    /// # let client = Client::from_env()?;
1015    /// # let id_from_elsewhere = String::new();
1016    /// let tx = client.attach_transaction(&id_from_elsewhere)?;
1017    ///
1018    /// tx.create("table", "//tmp/out")?;   // inside the shared transaction
1019    /// tx.commit()?;                       // and now published, by this process
1020    /// # Ok(())
1021    /// # }
1022    /// ```
1023    ///
1024    /// # Errors
1025    ///
1026    /// Returns [`ClientError`] if the transaction does not exist or the
1027    /// timeout cannot be read. The error names the id and the operation
1028    /// itself, because the cluster's own answer does not always do either.
1029    /// Both spellings were observed on a local cluster: an expired id earns
1030    /// `Error resolving path #<id>/@timeout` around `No such object <id>` —
1031    /// object, not transaction, since the id is addressed as one — while an
1032    /// id that never named anything is refused as `Unknown cell tag 0`, with
1033    /// no id in it at all. A transaction that expires between the two round
1034    /// trips fails the same way, on the ping: `No such transaction`.
1035    pub fn attach_transaction(&self, id: &str) -> Result<Transaction> {
1036        Transaction::attach(self, id.to_owned())
1037    }
1038
1039    /// Tells the cluster a transaction is still wanted, by bare id.
1040    ///
1041    /// A held [`Transaction`] does this on its own thread; this is for a
1042    /// process that has nothing but the id — between a [`Transaction::detach`]
1043    /// in one process and the commit in another, *somebody* must say the
1044    /// transaction is still wanted, or it expires its timeout after its last
1045    /// ping (30 seconds by default; verified on a local cluster with a
1046    /// two-second timeout left alone for four). A ping is also the cheapest
1047    /// liveness probe: the cluster answers one for a transaction that is gone
1048    /// with `No such transaction`.
1049    ///
1050    /// # Errors
1051    ///
1052    /// Returns [`ClientError`] if the transaction has expired, was aborted, or
1053    /// never existed.
1054    pub fn ping_transaction(&self, id: &str) -> Result<()> {
1055        transaction::ping(self, id)
1056    }
1057
1058    /// Publishes everything done in a transaction, by bare id.
1059    ///
1060    /// What lets a process finish a transaction it did not start — the other
1061    /// end of a [`Transaction::detach`], without the round trip and the ping
1062    /// thread of [`Client::attach_transaction`].
1063    ///
1064    /// Sent under a mutation ID, because **a commit is not idempotent**: the
1065    /// second commit of the same transaction is refused with `No such
1066    /// transaction`, which reads like the first one failed. The mutation ID
1067    /// makes a retried commit the same commit rather than a second one.
1068    ///
1069    /// # Errors
1070    ///
1071    /// Returns [`ClientError`] if the commit fails — including `No such
1072    /// transaction` for one that expired, was aborted, or was already
1073    /// committed.
1074    pub fn commit_transaction(&self, id: &str) -> Result<()> {
1075        transaction::commit_by_id(self, id)
1076    }
1077
1078    /// Discards everything done in a transaction, by bare id.
1079    ///
1080    /// **Forgiving, unlike [`Client::abort_operation`]**: aborting a
1081    /// transaction that already committed, aborted or expired — or one that
1082    /// never existed — answers `{}`, verified on a local cluster. So this is
1083    /// safe to send on any cleanup path, and it is retried freely on the same
1084    /// grounds.
1085    ///
1086    /// # Errors
1087    ///
1088    /// Returns [`ClientError`] if the request fails. The transaction expires
1089    /// on its own either way, once nothing is pinging it.
1090    pub fn abort_transaction(&self, id: &str) -> Result<()> {
1091        transaction::abort_by_id(self, id)
1092    }
1093
1094    /// Asks the cluster for the least-loaded heavy proxy, if it has one.
1095    ///
1096    /// **The client already does this for itself.** Heavy commands — table and
1097    /// file data, in either direction — resolve a heavy proxy on their own and
1098    /// go there; see the module documentation for when, and for how long the
1099    /// answer is kept. So this is no longer the way to make an upload work: it
1100    /// is the way to *see* the address, or to hand it to something that is not
1101    /// this client — a second [`Client`], another process, a `curl`.
1102    ///
1103    /// It asks every time and shares nothing with what the client resolved for
1104    /// itself, so calling it neither costs nor changes anything the next
1105    /// command does. It also reports the name **as the cluster gave it**,
1106    /// before the checks automatic routing puts it through — which is what
1107    /// makes it the way to see why a host was declined. A name here that the
1108    /// uploads are not using is the symptom
1109    /// [`Client::with_heavy_proxies_anywhere`] exists for.
1110    ///
1111    /// It shares the lookup's budget, though: one attempt bounded by
1112    /// [`Client::with_hosts_timeout`] — 800 ms unless that says otherwise —
1113    /// rather than the client's retry policy and request timeout. The budget
1114    /// belongs to the question, not to whoever asked it.
1115    ///
1116    /// # Errors
1117    ///
1118    /// Returns [`ClientError`] if the request fails, or if `/hosts` does not
1119    /// answer with the documented list of host names. `Ok(None)` means the
1120    /// cluster answered and named no heavy proxy — which a failure must not be
1121    /// allowed to look like, since the caller's next move is to stop looking.
1122    pub fn heavy_proxy(&self) -> Result<Option<String>> {
1123        // Through the transport, so this carries the token and the TLS guard
1124        // like every other request, and so that the automatic routing and this
1125        // read the same answer with the same parser. Not the timeout and not
1126        // the retry policy: `Transport::fetch` gives this question its own
1127        // budget, which is the whole point of it having one.
1128        Ok(self.transport.heavy_hosts()?.into_iter().next())
1129    }
1130
1131    // ------------------------------------------------------------- Cypress
1132
1133    /// Whether a Cypress node exists.
1134    ///
1135    /// # Errors
1136    ///
1137    /// Returns [`ClientError`] if the request fails.
1138    pub fn exists(&self, path: &str) -> Result<bool> {
1139        let params = yson_build::map([("path", yson_build::string(path))]);
1140        let body = self.transport.call(
1141            Method::Get,
1142            "exists",
1143            &params,
1144            Payload::None,
1145            Repeatable::Freely,
1146        )?;
1147        // `{"value"=%false;}` — the envelope key is `value`, as it is for
1148        // `get`, not the command's own name. Asking for `exists` here failed
1149        // every call with a decode error, and nothing in the crate called this
1150        // until transactions needed to ask whether a node had survived one.
1151        Ok(matches!(
1152            self.value_field(&body, "value")?.node,
1153            YsonNode::Boolean(true)
1154        ))
1155    }
1156
1157    /// Creates a Cypress node, e.g. `table`, `file` or `map_node`.
1158    ///
1159    /// Creates missing parents and succeeds if the node already exists.
1160    ///
1161    /// # Errors
1162    ///
1163    /// Returns [`ClientError`] if the request fails.
1164    pub fn create(&self, node_type: &str, path: &str) -> Result<()> {
1165        let params = yson_build::map([
1166            ("path", yson_build::string(path)),
1167            ("type", yson_build::string(node_type)),
1168            ("recursive", yson_build::boolean(true)),
1169            ("ignore_existing", yson_build::boolean(true)),
1170        ]);
1171        self.transport.call(
1172            Method::Post,
1173            "create",
1174            &params,
1175            Payload::None,
1176            Repeatable::WithMutationId,
1177        )?;
1178        Ok(())
1179    }
1180
1181    /// Creates a table with a schema.
1182    ///
1183    /// A schematised table is checked on every write, stores its columns in
1184    /// their own types, and can be sorted and merged; an unschematised one
1185    /// takes anything and finds out later.
1186    ///
1187    /// ```no_run
1188    /// # use ytsaurus_client::{Client, Column, ColumnType, TableSchema};
1189    /// # fn main() -> Result<(), ytsaurus_client::ClientError> {
1190    /// # let client = Client::from_env()?;
1191    /// let schema = TableSchema::new([
1192    ///     Column::new("host", ColumnType::Utf8).required().key(),
1193    ///     Column::new("size", ColumnType::Int64).required(),
1194    /// ]);
1195    /// client.create_table("//tmp/visits", &schema)?;
1196    /// # Ok(())
1197    /// # }
1198    /// ```
1199    ///
1200    /// Unlike [`Client::create`], this **fails if the path already exists**.
1201    /// That is deliberate: the cluster ignores the attributes of a create it
1202    /// skips, so an `ignore_existing` version of this would quietly leave the
1203    /// old table with the old schema and report success. Changing the schema of
1204    /// a table that exists is `alter_table`'s job.
1205    ///
1206    /// # Errors
1207    ///
1208    /// Returns [`ClientError::Config`] if the schema is one the cluster would
1209    /// refuse, or [`ClientError`] if the request fails.
1210    pub fn create_table(&self, path: &str, schema: &TableSchema) -> Result<()> {
1211        // Locally first: the same rules, but as one sentence naming the column
1212        // rather than a nested error document from the cluster.
1213        schema
1214            .validate()
1215            .map_err(|reason| ClientError::Config(format!("{path}: {reason}")))?;
1216
1217        let params = yson_build::map([
1218            ("path", yson_build::string(path)),
1219            ("type", yson_build::string("table")),
1220            ("recursive", yson_build::boolean(true)),
1221            // The schema goes *inside* `attributes`. A top-level `schema` here
1222            // is accepted, answered with 200 and a node id, and silently
1223            // ignored — the table comes back with an empty weak schema. This
1224            // is the single worst mistake available in this command.
1225            (
1226                "attributes",
1227                yson_build::map([("schema", schema.to_yson())]),
1228            ),
1229        ]);
1230
1231        self.transport.call(
1232            Method::Post,
1233            "create",
1234            &params,
1235            Payload::None,
1236            Repeatable::WithMutationId,
1237        )?;
1238        Ok(())
1239    }
1240
1241    /// Changes the schema of a table that already exists.
1242    ///
1243    /// The other half of [`Client::create_table`]: a table outlives the program
1244    /// that made it, and the rows it holds gain columns.
1245    ///
1246    /// ```no_run
1247    /// # use ytsaurus_client::{Client, Column, ColumnType, TableSchema};
1248    /// # fn main() -> Result<(), ytsaurus_client::ClientError> {
1249    /// # let client = Client::from_env()?;
1250    /// let wider = TableSchema::new([
1251    ///     Column::new("host", ColumnType::Utf8).required().key(),
1252    ///     Column::new("size", ColumnType::Int64).required(),
1253    ///     Column::new("referrer", ColumnType::Utf8), // new, and optional
1254    /// ]);
1255    /// client.alter_table("//tmp/visits", &wider)?;
1256    /// # Ok(())
1257    /// # }
1258    /// ```
1259    ///
1260    /// **A table with rows in it accepts only changes that ask less of the
1261    /// rows already written.** Watched on a cluster, on a table holding two
1262    /// rows — and each refusal says which column and why:
1263    ///
1264    /// | Change | |
1265    /// | --- | --- |
1266    /// | add an **optional** column, anywhere in the order | allowed |
1267    /// | make a required column optional | allowed |
1268    /// | `strict` → non-strict | allowed |
1269    /// | add a **required** column | `Cannot insert a new required column "must" into a non-empty table` |
1270    /// | remove a column | `Cannot remove column "size" from a strict schema` |
1271    /// | change a column's type | `Type … is modified in non backward compatible manner` |
1272    /// | rename a column | read as a removal, and refused as one |
1273    /// | make the table sorted | `Cannot change schema from unsorted to sorted` |
1274    /// | non-strict → `strict` | `Changing "strict" from "false" to "true" is not allowed` |
1275    ///
1276    /// Two consequences worth knowing before either becomes permanent:
1277    ///
1278    /// - **An empty table accepts all of it** — dropping columns, changing types,
1279    ///   becoming sorted. So a schema change tried out on an empty table proves
1280    ///   nothing about the same change on a full one.
1281    /// - **A non-strict schema can never gain a named column**:
1282    ///   `Cannot insert a new column "note" into non-strict schema`. Relaxing
1283    ///   `strict` is a one-way door out of schema evolution.
1284    ///
1285    /// Unlike `create`, the schema here is a **top-level parameter** rather than
1286    /// an attribute — the two commands are exact opposites on this, and `create`
1287    /// silently ignores the spelling `alter_table` requires.
1288    ///
1289    /// # Errors
1290    ///
1291    /// Returns [`ClientError::Config`] if the schema is one the cluster would
1292    /// refuse outright, or [`ClientError`] if the change is rejected as
1293    /// incompatible.
1294    pub fn alter_table(&self, path: &str, schema: &TableSchema) -> Result<()> {
1295        schema
1296            .validate()
1297            .map_err(|reason| ClientError::Config(format!("{path}: {reason}")))?;
1298
1299        let params = yson_build::map([
1300            ("path", yson_build::string(path)),
1301            // Top-level, where `create` wants it inside `attributes`. Getting
1302            // this the wrong way round fails loudly here and silently there.
1303            ("schema", schema.to_yson()),
1304        ]);
1305        self.transport.call(
1306            Method::Post,
1307            "alter_table",
1308            &params,
1309            Payload::None,
1310            Repeatable::WithMutationId,
1311        )?;
1312        Ok(())
1313    }
1314
1315    /// The schema of a table, as the cluster stores it.
1316    ///
1317    /// Returns the raw YSON: the cluster answers with more than it was given —
1318    /// every column carries `required`, `type` *and* `type_v3` whichever was
1319    /// written, and the keys come back in alphabetical order.
1320    ///
1321    /// # Errors
1322    ///
1323    /// Returns [`ClientError`] if the request fails.
1324    pub fn table_schema(&self, path: &str) -> Result<YsonValue> {
1325        self.get(&format!("{path}/@schema"))
1326    }
1327
1328    /// Removes a Cypress node.
1329    ///
1330    /// The node must exist, and a map node must be empty — the cluster's own
1331    /// defaults, and the safe ones: a mistyped path fails instead of deleting
1332    /// whatever it happened to name. [`Client::remove_tree`] is the deliberate
1333    /// spelling for a subtree.
1334    ///
1335    /// # Errors
1336    ///
1337    /// Returns [`ClientError`] if the node does not exist, is a non-empty map
1338    /// node, or the request fails.
1339    pub fn remove(&self, path: &str) -> Result<()> {
1340        self.remove_with(path, false, false)
1341    }
1342
1343    /// Removes a Cypress node and everything under it. Succeeds if it is
1344    /// already absent.
1345    ///
1346    /// This is `recursive` plus `force`: the spelling for "make this path not
1347    /// exist", whatever is there now — which is also why it deserves a moment
1348    /// of care with the argument.
1349    ///
1350    /// # Errors
1351    ///
1352    /// Returns [`ClientError`] if the request fails.
1353    pub fn remove_tree(&self, path: &str) -> Result<()> {
1354        self.remove_with(path, true, true)
1355    }
1356
1357    fn remove_with(&self, path: &str, recursive: bool, force: bool) -> Result<()> {
1358        let params = yson_build::map([
1359            ("path", yson_build::string(path)),
1360            ("recursive", yson_build::boolean(recursive)),
1361            ("force", yson_build::boolean(force)),
1362        ]);
1363        self.transport.call(
1364            Method::Post,
1365            "remove",
1366            &params,
1367            Payload::None,
1368            Repeatable::WithMutationId,
1369        )?;
1370        Ok(())
1371    }
1372
1373    /// The names of a node's children.
1374    ///
1375    /// **Not sorted.** The order is the cluster's own and has no meaning; a
1376    /// listing of three dated tables came back as the second, the third and
1377    /// then the first. Sort it if the order matters.
1378    ///
1379    /// A path that is not a map node is an error rather than an empty list —
1380    /// `"List" method is not supported` — and so is a path that does not exist.
1381    ///
1382    /// # Errors
1383    ///
1384    /// Returns [`ClientError`] if the request fails, or if the cluster marks
1385    /// the answer `incomplete`: a listing that is silently short is worse than
1386    /// no listing.
1387    pub fn list(&self, path: &str) -> Result<Vec<String>> {
1388        let params = yson_build::map([("path", yson_build::string(path))]);
1389        let body = self.transport.call(
1390            Method::Get,
1391            "list",
1392            &params,
1393            Payload::None,
1394            Repeatable::Freely,
1395        )?;
1396
1397        child_names(&self.value_field(&body, "value")?, path)
1398    }
1399
1400    /// Copies a node, creating missing parents.
1401    ///
1402    /// Fails if `destination` exists; [`Client::copy_replacing`] is the one that
1403    /// overwrites.
1404    ///
1405    /// # Errors
1406    ///
1407    /// Returns [`ClientError`] if the request fails.
1408    pub fn copy(&self, source: &str, destination: &str) -> Result<()> {
1409        self.transfer("copy", source, destination, false)
1410    }
1411
1412    /// Copies a node over whatever is at `destination`.
1413    ///
1414    /// # Errors
1415    ///
1416    /// Returns [`ClientError`] if the request fails.
1417    pub fn copy_replacing(&self, source: &str, destination: &str) -> Result<()> {
1418        self.transfer("copy", source, destination, true)
1419    }
1420
1421    /// Moves a node, creating missing parents.
1422    ///
1423    /// Fails if `destination` exists; [`Client::move_replacing`] is the one that
1424    /// overwrites, and the pair is how a result is published: write a staging
1425    /// table, then move it over the live one.
1426    ///
1427    /// Named `move_node` because `move` is a Rust keyword, and `client.r#move`
1428    /// at every call site would be a worse tax than the four extra characters.
1429    ///
1430    /// # Errors
1431    ///
1432    /// Returns [`ClientError`] if the request fails.
1433    pub fn move_node(&self, source: &str, destination: &str) -> Result<()> {
1434        self.transfer("move", source, destination, false)
1435    }
1436
1437    /// Moves a node over whatever is at `destination`.
1438    ///
1439    /// # Errors
1440    ///
1441    /// Returns [`ClientError`] if the request fails.
1442    pub fn move_replacing(&self, source: &str, destination: &str) -> Result<()> {
1443        self.transfer("move", source, destination, true)
1444    }
1445
1446    fn transfer(&self, command: &str, source: &str, destination: &str, force: bool) -> Result<()> {
1447        let params = yson_build::map([
1448            ("source_path", yson_build::string(source)),
1449            ("destination_path", yson_build::string(destination)),
1450            ("recursive", yson_build::boolean(true)),
1451            ("force", yson_build::boolean(force)),
1452        ]);
1453        self.transport.call(
1454            Method::Post,
1455            command,
1456            &params,
1457            Payload::None,
1458            Repeatable::WithMutationId,
1459        )?;
1460        Ok(())
1461    }
1462
1463    /// Creates a link at `link_path` pointing at `target`.
1464    ///
1465    /// A link resolves to its target, so `//tmp/latest/@row_count` reads the
1466    /// target's row count. To ask about the link itself, put `&` after its path:
1467    /// `//tmp/latest&/@target_path`. Without the `&` the question goes through
1468    /// to the target and is answered as if the link were not there.
1469    ///
1470    /// Fails if `link_path` exists; [`Client::link_replacing`] is what points an
1471    /// existing link somewhere else.
1472    ///
1473    /// # Errors
1474    ///
1475    /// Returns [`ClientError`] if the request fails.
1476    pub fn link(&self, target: &str, link_path: &str) -> Result<()> {
1477        self.link_inner(target, link_path, false)
1478    }
1479
1480    /// Points a link at `target`, replacing whatever is at `link_path`.
1481    ///
1482    /// The `//tmp/thing/latest` pattern: publish under a dated name, then move
1483    /// the link. Readers that follow the link see the old version until this
1484    /// call and the new one after it, and never a half-written table.
1485    ///
1486    /// # Errors
1487    ///
1488    /// Returns [`ClientError`] if the request fails.
1489    pub fn link_replacing(&self, target: &str, link_path: &str) -> Result<()> {
1490        self.link_inner(target, link_path, true)
1491    }
1492
1493    fn link_inner(&self, target: &str, link_path: &str, force: bool) -> Result<()> {
1494        let params = yson_build::map([
1495            ("target_path", yson_build::string(target)),
1496            ("link_path", yson_build::string(link_path)),
1497            ("recursive", yson_build::boolean(true)),
1498            ("force", yson_build::boolean(force)),
1499        ]);
1500        self.transport.call(
1501            Method::Post,
1502            "link",
1503            &params,
1504            Payload::None,
1505            Repeatable::WithMutationId,
1506        )?;
1507        Ok(())
1508    }
1509
1510    /// Takes a lock, or fails because somebody else holds one.
1511    ///
1512    /// Only inside a transaction: a lock lives as long as the transaction that
1513    /// took it, and there is nothing else for it to belong to. A client that is
1514    /// not in one is told so here rather than by the cluster.
1515    ///
1516    /// The failure is worth reading — it names the transaction that won:
1517    ///
1518    /// ```text
1519    /// Cannot take "exclusive" lock for node //tmp/live since "exclusive" lock
1520    /// is taken by concurrent transaction 4-dac2-10001-eb1b
1521    /// ```
1522    ///
1523    /// [`Client::lock_waiting`] queues for it instead of failing.
1524    ///
1525    /// # Errors
1526    ///
1527    /// Returns [`ClientError::Config`] if this client is not in a transaction,
1528    /// or [`ClientError`] if the lock is refused.
1529    pub fn lock(&self, path: &str, mode: LockMode) -> Result<Lock> {
1530        self.lock_inner(path, mode, false)
1531    }
1532
1533    /// Queues for a lock, and waits until it is held.
1534    ///
1535    /// A waitable lock is **granted later, or never** — the cluster answers
1536    /// immediately with a lock that is `pending`, and it becomes `acquired` when
1537    /// the transactions ahead of it end. Returning that lock as though it were
1538    /// held is the mistake this command exists to make impossible: this polls
1539    /// until the cluster says `acquired`, and gives up after `wait_for`.
1540    ///
1541    /// The deadline is not a nicety. A request can queue for something that will
1542    /// never happen and the cluster will not say so: a transaction that already
1543    /// holds a snapshot lock on the node is refused an exclusive one outright,
1544    /// but the *waitable* version of the same request is queued behind a lock
1545    /// only that transaction's own end will release.
1546    ///
1547    /// # Errors
1548    ///
1549    /// Returns [`ClientError::Config`] if this client is not in a transaction or
1550    /// the wait ran out, or [`ClientError`] if a request fails. A lock that is
1551    /// still queued when the wait runs out stays queued until the transaction
1552    /// ends.
1553    pub fn lock_waiting(&self, path: &str, mode: LockMode, wait_for: Duration) -> Result<Lock> {
1554        let lock = self.lock_inner(path, mode, true)?;
1555        let deadline = Instant::now() + wait_for;
1556
1557        loop {
1558            let state = self.get(&format!("#{}/@state", lock.id))?;
1559            if state.as_str() == Some("acquired") {
1560                return Ok(lock);
1561            }
1562
1563            if Instant::now() >= deadline {
1564                return Err(ClientError::Config(format!(
1565                    "lock on {path}: still {} after {:.0}s — the locks ahead of it are \
1566                     still held, which can include a snapshot lock this same \
1567                     transaction took. It stays queued until this transaction ends.",
1568                    state.as_str().unwrap_or("queued"),
1569                    wait_for.as_secs_f64()
1570                )));
1571            }
1572            std::thread::sleep(self.poll_interval);
1573        }
1574    }
1575
1576    fn lock_inner(&self, path: &str, mode: LockMode, waitable: bool) -> Result<Lock> {
1577        if self.transaction_id().is_none() {
1578            return Err(ClientError::Config(format!(
1579                "lock {path}: a lock belongs to a transaction, and this client is not in \
1580                 one — take it through a Client::start_transaction handle. The cluster \
1581                 answers this with `A valid master transaction is required`."
1582            )));
1583        }
1584
1585        let params = yson_build::map([
1586            ("path", yson_build::string(path)),
1587            ("mode", yson_build::string(mode.as_str())),
1588            ("waitable", yson_build::boolean(waitable)),
1589        ]);
1590        let body = self.transport.call(
1591            Method::Post,
1592            "lock",
1593            &params,
1594            Payload::None,
1595            Repeatable::WithMutationId,
1596        )?;
1597
1598        let envelope = self.strip_envelope(&body, "lock")?;
1599        let text = |key: &str| -> Result<String> {
1600            match &self.field_of(&envelope, key)?.node {
1601                YsonNode::String(bytes) => Ok(String::from_utf8_lossy(bytes).into_owned()),
1602                other => Err(ClientError::Decode {
1603                    command: "lock".to_owned(),
1604                    reason: format!("{key} is not a string: {other:?}"),
1605                }),
1606            }
1607        };
1608
1609        Ok(Lock {
1610            id: text("lock_id")?,
1611            node_id: text("node_id")?,
1612        })
1613    }
1614
1615    /// Reads a node attribute, such as `@row_count`.
1616    ///
1617    /// # Errors
1618    ///
1619    /// Returns [`ClientError`] if the request fails.
1620    pub fn get(&self, path: &str) -> Result<YsonValue> {
1621        let params = yson_build::map([("path", yson_build::string(path))]);
1622        let body = self.transport.call(
1623            Method::Get,
1624            "get",
1625            &params,
1626            Payload::None,
1627            Repeatable::Freely,
1628        )?;
1629        self.value_field(&body, "value")
1630    }
1631
1632    /// Number of rows in a table.
1633    ///
1634    /// # Errors
1635    ///
1636    /// Returns [`ClientError`] if the request fails or the attribute is absent.
1637    pub fn row_count(&self, path: &str) -> Result<i64> {
1638        let value = self.get(&format!("{path}/@row_count"))?;
1639        value.as_i64().ok_or_else(|| ClientError::Decode {
1640            command: "get".to_owned(),
1641            reason: format!("{path}/@row_count is not an integer"),
1642        })
1643    }
1644
1645    // ------------------------------------------------------------- batches
1646
1647    /// Executes every part of a [`BatchRequest`] in **one round trip**, and
1648    /// answers with a `Result` **per part**.
1649    ///
1650    /// The parts fail individually — that is the entire point of the shape.
1651    /// One part hitting a node that already exists does not cost the other
1652    /// eleven their tables, and collapsing the answers into one `Result`
1653    /// would lose exactly the thing batching makes harder to see. The outer
1654    /// `Result` is for the envelope alone: the request that could not be
1655    /// sent, the response that could not be read.
1656    ///
1657    /// ```no_run
1658    /// # use ytsaurus_client::{BatchRequest, Client};
1659    /// # fn main() -> Result<(), ytsaurus_client::ClientError> {
1660    /// # let client = Client::from_env()?;
1661    /// let mut batch = BatchRequest::new();
1662    /// batch
1663    ///     .create("map_node", "//tmp/pipeline")
1664    ///     .create("table", "//tmp/pipeline/clicks")
1665    ///     .exists("//tmp/elsewhere");
1666    ///
1667    /// for part in client.execute_batch(&batch)? {
1668    ///     match part {
1669    ///         // The envelope is keyed by what each command returns —
1670    ///         // `{node_id=…}` for a create, `{value=…}` for an exists.
1671    ///         Ok(answer) => println!("{answer:?}"),
1672    ///         Err(error) => eprintln!("{error}"),
1673    ///     }
1674    /// }
1675    /// # Ok(())
1676    /// # }
1677    /// ```
1678    ///
1679    /// Each `Ok` carries the part's own answer exactly as that command would
1680    /// have answered alone — `{node_id=…}`, `{value=…}`, `{}` for a `set` —
1681    /// and each `Err` is a [`ClientError::Cluster`] named after the part's
1682    /// command, flattened outer-plus-innermost like every other cluster error
1683    /// here. Results come back **in the order the parts went in**; watched on
1684    /// a local cluster, where a batch of create·set·get·remove answered
1685    /// `[error 501, ok, ok, error 500]` in exactly that order. An answer with
1686    /// the wrong number of results, or a part result shaped like nothing this
1687    /// client knows, fails the whole call as [`ClientError::Decode`] rather
1688    /// than being read as somebody's success.
1689    ///
1690    /// # The wire
1691    ///
1692    /// The command is `execute_batch` — `REGISTER_ALL(TExecuteBatchCommand,
1693    /// "execute_batch", Null, Structured, true, false)` in the cluster's own
1694    /// [registry](https://github.com/ytsaurus/ytsaurus/blob/main/yt/yt/client/driver/driver.cpp):
1695    /// volatile and light, so a POST. The parts travel as
1696    /// `requests=[{command=…; parameters={…}; input=…}]` and the answer is the
1697    /// v4 envelope `{results=[{output=…}|{error=…}]}`
1698    /// ([command reference](https://ytsaurus.tech/docs/en/api/commands#execute_batch);
1699    /// `TExecuteBatchCommand` in
1700    /// [`etc_commands.cpp`](https://github.com/ytsaurus/ytsaurus/blob/main/yt/yt/client/driver/etc_commands.cpp);
1701    /// both shapes confirmed against a local cluster).
1702    ///
1703    /// **The parameters go in the request body**, not the `X-YT-Parameters`
1704    /// header that carries every other command's. A batch's parameters *are*
1705    /// the batched commands, and a header has a size nobody promises; the C++
1706    /// client makes the same choice for this same command
1707    /// (`THttpRawBatchRequest::ExecuteBatch` sends the parameter node as the
1708    /// POST body), and the proxy reads body parameters for any POST and
1709    /// merges them with the header's
1710    /// (`TContext::CaptureParameters` in
1711    /// [`context.cpp`](https://github.com/ytsaurus/ytsaurus/blob/main/yt/yt/server/http_proxy/context.cpp)
1712    /// — query string, then header, then body). Measured here: `requests` in
1713    /// the body and `mutation_id` in the header land as one parameter set.
1714    ///
1715    /// # Retries, and what makes them safe
1716    ///
1717    /// A batch of the typed parts retries like any light command, and a
1718    /// mutating one retries **under a mutation id** — because the cluster
1719    /// spreads that id over the parts. The driver takes the batch's own id
1720    /// and hands part *k* the id plus *k*
1721    /// (`Options.GetOrGenerateMutationId()` then
1722    /// `NRpc::GenerateNextBatchMutationId` per part in
1723    /// `TExecuteBatchCommand::DoExecute`; the increment is `++id.Parts32[0]`,
1724    /// `yt/yt/core/rpc/helpers.cpp`), stamping it and the batch's `retry`
1725    /// flag into every **volatile** part. A replay of the whole batch
1726    /// therefore replays every part under its original id, and the master's
1727    /// mutation cache answers each with its first response. **Measured on a
1728    /// local cluster**: a two-[`BatchRequest::create_table`] batch sent under
1729    /// an explicit id, then sent again with `retry=%true`, answered the *same
1730    /// two node ids* both times — where the same batch under a fresh id got two
1731    /// `501 already exists`.
1732    ///
1733    /// The measurement uses `create_table` and not
1734    /// [`BatchRequest::create`] on purpose, and repeating it with `create`
1735    /// proves nothing: `create` sends `ignore_existing`, so a second send
1736    /// answers with the *old* node's id whether or not the cluster recognised a
1737    /// replay. Measured that way too — `create` under a **fresh** id returned
1738    /// the same two ids as the first send, with no mutation cache involved at
1739    /// all. `create_table` omits `ignore_existing`, so its second send fails
1740    /// unless it was deduplicated, which is what makes the identical ids mean
1741    /// something.
1742    ///
1743    /// That safety is the master's, which is why the default is per-part
1744    /// kind: parts this crate models are Cypress commands the master's cache
1745    /// covers, so their batches go out [`Repeatable::WithMutationId`] (or
1746    /// [`Repeatable::Freely`] when every part is a read, since such a batch
1747    /// mutates nothing). A [`BatchRequest::raw`] part may name a command the
1748    /// cache does not cover — the scheduler commands are the measured example,
1749    /// where a replayed id turns a success into `No such operation` — so a
1750    /// batch carrying one is **sent once**, exactly as
1751    /// [`Client::raw_command`] is.
1752    ///
1753    /// # Transactions
1754    ///
1755    /// A client bound to a transaction puts the parts in it — each part is
1756    /// stamped with `transaction_id`, not the envelope. The envelope has no
1757    /// transaction to be in, and the distinction is measurable: an outer
1758    /// `transaction_id` was dropped in silence by a local cluster, the
1759    /// part's create landing outside the transaction and surviving its
1760    /// abort. A part that already names a transaction keeps its own, and a
1761    /// part whose command takes none is left alone, both as the transport
1762    /// itself would have it.
1763    ///
1764    /// # A big batch is several requests, and a failed one leaves a prefix
1765    ///
1766    /// More parts than [`BatchRequest::with_max_part_size`] allows are split
1767    /// into consecutive `execute_batch` requests — the C++ client's
1768    /// `BatchPartMaxSize` behaviour, defaults included — with the results
1769    /// stitched back in part order and a mutation id per request. There is no
1770    /// rollback across them: when a later request fails **wholesale**, the
1771    /// earlier ones have already run and their parts have taken effect, the
1772    /// same way the C++ client's `ExecuteBatch` throws with the earlier
1773    /// requests applied.
1774    ///
1775    /// What this method does *not* do is throw that prefix away. A split batch
1776    /// that stops part of the way through fails with
1777    /// [`ClientError::BatchInterrupted`], which carries every answer already
1778    /// received, in part order, beside the failure that stopped it — so a
1779    /// caller can see which parts landed and pick up from `answered.len()`.
1780    /// Re-running the same [`BatchRequest`] is *not* how to recover: a second
1781    /// execution mints fresh mutation ids, so the parts that already applied
1782    /// are applied again rather than deduplicated. Keep a batch inside one
1783    /// request's worth if that matters, or give the sequence a transaction.
1784    ///
1785    /// `answered` is what came **back**, which is not the same as what was
1786    /// applied, and the difference is the whole failed request. A request
1787    /// refused *while executing* has no per-part results and has nonetheless
1788    /// run **every one of its parts** — the driver collects the sub-requests
1789    /// into callbacks, runs them all through
1790    /// `CancelableRunWithBoundedConcurrency`, and then throws away the entire
1791    /// result list at `.ValueOrThrow()` the moment one entry is a throw.
1792    /// Dispatch is never aborted, so this is not a race and there is no way to
1793    /// arrange the parts to limit it: measured on a local cluster, a `create`
1794    /// beside a part naming an unknown command created its node with the bad
1795    /// part first *and* last, two creates around one both landed, and at
1796    /// `concurrency=1` eight creates followed by the bad part all eight landed
1797    /// — every time answered `Unknown command …` with no results at all.
1798    ///
1799    /// The bound worth knowing is the other one: a request refused *while its
1800    /// parameters are being read* runs nothing. `Validation failed at
1801    /// /concurrency`, `Error loading parameter /requests` and
1802    /// `Missing required parameter /requests` all left a `create` in the same
1803    /// request with no node behind it. Parse-time failure means none of it ran;
1804    /// execution-time failure means all of it did.
1805    ///
1806    /// So the parts before `answered.len()` are settled, and the request that
1807    /// failed is unknown territory — not because some of it might have run, but
1808    /// because all of it did and none of it said what happened. That is what a
1809    /// transaction is for.
1810    ///
1811    /// # A redirect this batch cannot follow
1812    ///
1813    /// The parts travel in the body, so this is the crate's first light
1814    /// command with bytes in one — and the redirect rule reads a body as data
1815    /// a redirect must not hand to another origin
1816    /// ([`RedirectRefusal::Payload`]). A cross-origin `3xx` on a batch is
1817    /// therefore refused where the *same* creates sent one at a time are
1818    /// bodiless `POST`s the rule deliberately lets through. It is narrow — a
1819    /// client with a token is refused a cross-origin hop anyway, by the
1820    /// credentials rule — but a **tokenless** client behind a balancer that
1821    /// canonicalises to another origin finds batching breaks what individual
1822    /// calls did. Address the origin the balancer canonicalises to, and the
1823    /// hop never happens.
1824    ///
1825    /// # Errors
1826    ///
1827    /// Returns [`ClientError::Config`] for an empty batch — the cluster would
1828    /// answer `{results=[]}` and this crate does not report a no-op as work
1829    /// done — [`ClientError::BatchInterrupted`] when a split batch stops after
1830    /// some of its requests have applied, and otherwise [`ClientError`] as any
1831    /// command fails. Per-part failures are **not** errors of this method:
1832    /// they are the `Err` halves of the vector.
1833    pub fn execute_batch(&self, batch: &BatchRequest) -> Result<Vec<Result<YsonValue>>> {
1834        self.execute_batch_with(batch, None)
1835    }
1836
1837    /// As [`Client::execute_batch`], with a caller-supplied [`MutationId`].
1838    ///
1839    /// The guarantee is the one [`Client::raw_command_with`] describes and the
1840    /// one a single process cannot give itself: persist the id, and a batch
1841    /// replayed after a crash is deduplicated against the send that already
1842    /// happened instead of applying every part a second time. **Measured on a
1843    /// local cluster through this method**: a batch of two
1844    /// [`BatchRequest::create_table`] parts sent under an explicit id, then
1845    /// sent again under `id.as_retry()`, answered the *same two node ids* both
1846    /// times — where the same batch under a fresh id got two
1847    /// `501 already exists`.
1848    ///
1849    /// Reach for `create_table` and not [`BatchRequest::create`] when checking
1850    /// this by hand. `create` sends `ignore_existing`, which makes a second
1851    /// send answer with the old node's id on its own: measured, a two-`create`
1852    /// batch under a **fresh** id returned ids identical to the first send's,
1853    /// which looks exactly like a deduplicated replay and is not one.
1854    /// `create_table` sends no `ignore_existing`, so identical ids there can
1855    /// only be the mutation cache.
1856    ///
1857    /// That works because the cluster spreads the id over the parts rather
1858    /// than deduplicating the envelope: the driver hands part *k* the batch's
1859    /// id plus *k*, so a replay replays each part under the id its first send
1860    /// used. It is also why **an id covers one request and not a split batch**
1861    /// — see the refusal below.
1862    ///
1863    /// ```no_run
1864    /// # use ytsaurus_client::{BatchRequest, Client, MutationId};
1865    /// # fn main() -> Result<(), ytsaurus_client::ClientError> {
1866    /// # let client = Client::from_env()?;
1867    /// # let mut batch = BatchRequest::new();
1868    /// # batch.create("table", "//tmp/pipeline/clicks");
1869    /// let id = MutationId::new();
1870    /// // …persist `id.as_str()` here, before sending…
1871    /// let made = match client.execute_batch_with(&batch, Some(&id)) {
1872    ///     Ok(made) => made,
1873    ///     // After a crash, the same id marked as a replay: the cluster
1874    ///     // answers with what the first send did, whether or not it landed.
1875    ///     Err(_) => client.execute_batch_with(&batch, Some(&id.as_retry()))?,
1876    /// };
1877    /// # let _ = made;
1878    /// # Ok(())
1879    /// # }
1880    /// ```
1881    ///
1882    /// An id is stamped whatever the batch's own retry class works out to,
1883    /// including on an all-read batch that would otherwise carry none — the
1884    /// two answer different questions, as [`Client::raw_command_with`] spells
1885    /// out. It does not make a send-once batch retriable in-process: a batch
1886    /// holding an unclassified [`BatchRequest::raw`] part is still sent once.
1887    ///
1888    /// # Errors
1889    ///
1890    /// As [`Client::execute_batch`], and additionally [`ClientError::Config`]
1891    /// when an id is given for a batch that would be **split** into more than
1892    /// one request. One id cannot cover several: the driver derives each
1893    /// part's id by incrementing the batch's, so a second request under
1894    /// anything derived from the same id would collide with the first
1895    /// request's parts and be answered with their results. Raise
1896    /// [`BatchRequest::with_max_part_size`] until the batch fits one request,
1897    /// or send it without an id.
1898    pub fn execute_batch_with(
1899        &self,
1900        batch: &BatchRequest,
1901        mutation_id: Option<&MutationId>,
1902    ) -> Result<Vec<Result<YsonValue>>> {
1903        if batch.is_empty() {
1904            return Err(ClientError::Config(
1905                "an empty batch is not a request worth sending: the cluster \
1906                 would answer with no results, and reporting that as success \
1907                 would call a no-op work done"
1908                    .to_owned(),
1909            ));
1910        }
1911
1912        let max_part_size = batch.max_part_size();
1913        if mutation_id.is_some() && batch.len() > max_part_size {
1914            return Err(ClientError::Config(format!(
1915                "a batch of {} parts is sent as several requests at {max_part_size} \
1916                 parts each, and one mutation id cannot cover them: the cluster \
1917                 derives each part's id by incrementing the batch's, so a second \
1918                 request under the same id would be answered with the first \
1919                 request's results. Raise with_max_part_size past {}, or send it \
1920                 without an id.",
1921                batch.len(),
1922                batch.len()
1923            )));
1924        }
1925
1926        let repeatable = batch.repeatable();
1927        let mut results = Vec::with_capacity(batch.len());
1928
1929        for chunk in batch.parts().chunks(max_part_size) {
1930            let answered = batch::render_chunk(chunk, batch.concurrency(), self.transaction_id())
1931                .and_then(|body| {
1932                    self.transport.call_with(
1933                        Method::Post,
1934                        "execute_batch",
1935                        &yson_build::empty_map(),
1936                        Payload::Bytes(&body),
1937                        repeatable,
1938                        mutation_id,
1939                    )
1940                })
1941                .and_then(|answer| batch::parse_results(&answer, chunk));
1942
1943            match answered {
1944                Ok(answers) => results.extend(answers),
1945                // Nothing has been applied yet, so there is no prefix to
1946                // report and the failure speaks for itself.
1947                Err(cause) if results.is_empty() => return Err(cause),
1948                // Earlier requests have run. Reporting only the failure would
1949                // hide that they did.
1950                Err(cause) => {
1951                    return Err(ClientError::BatchInterrupted {
1952                        answered: results,
1953                        parts: batch.len(),
1954                        cause: Box::new(cause),
1955                    });
1956                }
1957            }
1958        }
1959
1960        Ok(results)
1961    }
1962
1963    // ---------------------------------------------------------------- data
1964
1965    /// Uploads a local file to Cypress, marking it executable.
1966    ///
1967    /// This is what makes a worker runnable on a node: without the `executable`
1968    /// attribute YTsaurus copies the binary but refuses to exec it, and the job
1969    /// fails with a permission error that does not mention the attribute.
1970    ///
1971    /// # Errors
1972    ///
1973    /// Returns [`ClientError`] if the file cannot be read or the upload fails.
1974    pub fn upload_worker(&self, local: impl AsRef<std::path::Path>, remote: &str) -> Result<()> {
1975        let local = local.as_ref();
1976        let bytes = std::fs::read(local).map_err(|source| ClientError::Io {
1977            path: local.display().to_string(),
1978            source,
1979        })?;
1980
1981        self.upload_executable(remote, &bytes)
1982    }
1983
1984    /// Uploads the **running executable** to Cypress, marked executable.
1985    ///
1986    /// This is the one-binary pattern: the same program launches the operation
1987    /// and runs as its job, telling the two apart with
1988    /// [`ytsaurus_job::is_inside_job`]. The binary on the cluster is then by
1989    /// construction the one you just built — the whole "I uploaded a stale
1990    /// worker" class of bug disappears.
1991    ///
1992    /// The running executable has to be something a node can exec, so its ELF
1993    /// header is checked before the upload: Linux, x86-64, statically linked.
1994    /// Launching from macOS, or from a Linux host where the launcher is
1995    /// dynamically linked, it is not — this returns
1996    /// [`ClientError::NotAWorker`] naming the reason, instead of uploading a
1997    /// binary that fails on the node minutes later. Build the worker with
1998    /// `scripts/build-worker.sh` and upload it with [`Client::upload_worker`]
1999    /// in that case.
2000    ///
2001    /// [`ytsaurus_job::is_inside_job`]: https://docs.rs/ytsaurus-job/latest/ytsaurus_job/fn.is_inside_job.html
2002    ///
2003    /// # Errors
2004    ///
2005    /// Returns [`ClientError::NotAWorker`] if the running executable cannot run
2006    /// on a node, or [`ClientError`] if the upload fails.
2007    pub fn upload_current_exe(&self, remote: &str) -> Result<()> {
2008        let exe = std::env::current_exe().map_err(|source| ClientError::Io {
2009            path: "the running executable".to_owned(),
2010            source,
2011        })?;
2012
2013        let bytes = std::fs::read(&exe).map_err(|source| ClientError::Io {
2014            path: exe.display().to_string(),
2015            source,
2016        })?;
2017
2018        if let Err(reason) = worker::check_worker_binary(&bytes) {
2019            return Err(ClientError::NotAWorker {
2020                path: exe.display().to_string(),
2021                reason,
2022            });
2023        }
2024
2025        self.upload_executable(remote, &bytes)
2026    }
2027
2028    /// Uploads a worker, or finds it already on the cluster.
2029    ///
2030    /// Keyed by the file's MD5, so an unchanged binary is uploaded once and
2031    /// every later launch reuses it. That is the difference between a dev loop
2032    /// that re-sends tens of megabytes on every run and one that does not.
2033    ///
2034    /// The cached node is named after the hash, so the returned
2035    /// [`CachedFile::name`] is the name to give it in the sandbox — see
2036    /// [`MapSpec::with_local_file_named`]:
2037    ///
2038    /// ```no_run
2039    /// # use ytsaurus_client::{Client, MapSpec};
2040    /// # fn main() -> Result<(), ytsaurus_client::ClientError> {
2041    /// # let client = Client::from_env()?;
2042    /// let worker = client.upload_worker_cached("target/.../my_job")?;
2043    /// let spec = MapSpec::new("./my_job", ["//tmp/in"], ["//tmp/out"])
2044    ///     .with_local_file_named(&worker.path, &worker.name);
2045    /// # Ok(())
2046    /// # }
2047    /// ```
2048    ///
2049    /// The cache is shared: [`Client::with_file_cache`] defaults to the path
2050    /// the Python wrapper uses, so an installation that already expires old
2051    /// entries there expires these too.
2052    ///
2053    /// # A cache you may not write to
2054    ///
2055    /// On an installation where that shared path is maintained by its
2056    /// operators, an ordinary user may read it and nothing more — and the
2057    /// cluster answers a write with `Access denied`. That is a **degraded
2058    /// cache, not a failed upload**: the worker goes up outside the cache
2059    /// instead, to a path of its own under `//tmp`, and the launch proceeds.
2060    ///
2061    /// It is warned about rather than passed over, on stderr — as a `WARN`
2062    /// event where the `tracing` feature is on — because the state is
2063    /// permanent until someone acts on it and invisible otherwise: every launch
2064    /// re-sends the whole binary, and every launch leaves a node behind that no
2065    /// cache expiry will collect. The warning names
2066    /// [`Client::with_file_cache`], which is the one line that puts a cache
2067    /// back.
2068    ///
2069    /// Only the cluster's refusal of *the cache* is treated this way — creating
2070    /// the cache directory, creating the staging node inside it, and the
2071    /// handover to `put_file_to_cache`. Any other failure, including an
2072    /// `Access denied` on anything else, is returned.
2073    ///
2074    /// [`CachedFile::cached`] is which of the two happened, and it is the field
2075    /// to read before doing anything to [`CachedFile::path`]: on the fallback
2076    /// path that node is this launch's own and nobody else's, while on the
2077    /// ordinary path it is the installation's shared cache entry.
2078    ///
2079    /// # Errors
2080    ///
2081    /// Returns [`ClientError`] if the file cannot be read or the upload fails.
2082    pub fn upload_worker_cached(&self, local: impl AsRef<std::path::Path>) -> Result<CachedFile> {
2083        let local = local.as_ref();
2084        let bytes = std::fs::read(local).map_err(|source| ClientError::Io {
2085            path: local.display().to_string(),
2086            source,
2087        })?;
2088
2089        let name = local
2090            .file_name()
2091            .map(|n| n.to_string_lossy().into_owned())
2092            .unwrap_or_else(|| "worker".to_owned());
2093        let digest = format!("{:x}", md5::compute(&bytes));
2094
2095        if let Some(path) = self.file_from_cache(&digest)? {
2096            return Ok(CachedFile {
2097                path,
2098                name,
2099                uploaded: false,
2100                cached: true,
2101            });
2102        }
2103
2104        let (path, cached) = match self.upload_into_cache(&bytes, &digest)? {
2105            Cached::At(path) => {
2106                // Set on the cached path too: whether the attribute survives
2107                // the move decides whether the job can exec at all, and it is
2108                // cheap to be sure.
2109                self.set_attribute(&path, "executable", yson_build::boolean(true))?;
2110                (path, true)
2111            }
2112            Cached::Refused(denial) => {
2113                observe::cache_refused(&self.file_cache, &denial);
2114                (self.upload_uncached(&digest, &bytes)?, false)
2115            }
2116        };
2117
2118        Ok(CachedFile {
2119            path,
2120            name,
2121            uploaded: true,
2122            cached,
2123        })
2124    }
2125
2126    /// Everything in [`Client::upload_worker_cached`] that touches the cache.
2127    ///
2128    /// Three of the calls here can be refused by an installation that keeps the
2129    /// cache to itself, and all three mean the same thing — this caller has no
2130    /// cache at this path — so all three come back as [`Cached::Refused`] for
2131    /// the caller to fall back on: creating the cache directory, creating the
2132    /// staging node **inside** it, and the handover, `put_file_to_cache`. The
2133    /// two creates ask for the same permission on the same directory, so which
2134    /// of them a given cluster refuses first is its own business.
2135    ///
2136    /// Nothing else is caught, deliberately. Between those calls the client is
2137    /// writing to a node it has just created: a refusal there is about that
2138    /// node rather than about the cache, and the same bytes sent to another
2139    /// path would earn the same answer, so falling back would upload twice and
2140    /// still fail. And a create refused for some *other* reason — a path that
2141    /// resolves to something else, a lock held elsewhere — is not a permission
2142    /// problem at all. Both are returned as they always were.
2143    fn upload_into_cache(&self, bytes: &[u8], digest: &str) -> Result<Cached> {
2144        // Created here rather than in the lookup: a cache the installation
2145        // maintains is one a user may only be able to read, and a lookup that
2146        // mutated it would fail on exactly the clusters where the cache is
2147        // worth the most. Being refused *here* costs a slower upload, which is
2148        // what makes that trade worth making.
2149        if let Err(denial) = self.create("map_node", &self.file_cache) {
2150            return refused_or_reported(denial);
2151        }
2152
2153        // Staged inside the cache node, so a cluster that expires the cache
2154        // expires an interrupted upload with it.
2155        //
2156        // The name carries a nonce as well as the hash. Keyed by the hash alone
2157        // it names the same node for every process uploading the same binary,
2158        // and two CI jobs launching together would write to one node and then
2159        // remove it from under each other.
2160        let staging = format!("{}/staged_{digest}_{}", self.file_cache, MutationId::new());
2161        if let Err(denial) = self.create("file", &staging) {
2162            return refused_or_reported(denial);
2163        }
2164
2165        let cached = self
2166            .write_file_computing_md5(&staging, bytes)
2167            .and_then(|()| self.set_attribute(&staging, "executable", yson_build::boolean(true)))
2168            .and_then(|()| self.put_file_to_cache(&staging, digest));
2169
2170        // Removed whichever way that went. On success the cache may have kept
2171        // the node itself rather than a copy, so this is `force`-removing
2172        // something that may already be gone, which `remove_tree` tolerates.
2173        // On failure it is what stops a rejected upload from leaving tens of
2174        // megabytes behind for good: cache expiry walks the entries the cache
2175        // itself created, not the staging nodes beside them.
2176        let removed = self.remove_tree(&staging);
2177
2178        match cached {
2179            Ok(path) => {
2180                // The upload's own failure is the one worth reporting; a
2181                // cleanup that also failed only matters when there was nothing
2182                // else wrong.
2183                removed?;
2184                Ok(Cached::At(path))
2185            }
2186            // Refused at the handover, with the bytes already on the cluster —
2187            // they are about to be sent again, which is the price of a launch
2188            // that runs at all. A removal that failed too is dropped here
2189            // rather than reported: a cache that refuses the handover may well
2190            // refuse the cleanup, and failing the launch over a staging node is
2191            // exactly what this is not doing.
2192            Err(denial) if denied(&denial, "put_file_to_cache") => Ok(Cached::Refused(denial)),
2193            Err(failed) => Err(failed),
2194        }
2195    }
2196
2197    /// Uploads the worker outside the cache, for a cluster whose cache this
2198    /// caller may not write to.
2199    ///
2200    /// A path of its own every time, nonce and all, for the reason the staging
2201    /// node has one: a name derived from the hash alone is the same node for
2202    /// every process uploading the same binary, and two launchers starting
2203    /// together would take an exclusive lock on it in turn. The cost is a node
2204    /// per launch that no cache expiry will collect, which is the second reason
2205    /// the warning names [`Client::with_file_cache`].
2206    ///
2207    /// # What this node is not
2208    ///
2209    /// It is an ordinary `//tmp` node: it inherits whatever ACL `//tmp` carries
2210    /// on the installation, it is given no expiry, and its name is unguessable
2211    /// only as far as [`MutationId`] is — and the entropy it draws on says of
2212    /// itself that its callers need an id to be *unique, not unpredictable*,
2213    /// because what it was built for is deduplicating a retry rather than
2214    /// withholding a name. On a cluster where
2215    /// `//tmp` is shared scratch space, a co-tenant who can list it can also
2216    /// **rewrite the worker's bytes between this upload and the job that execs
2217    /// them**.
2218    ///
2219    /// That is the ordinary exposure of anything left in `//tmp`, and it is the
2220    /// same exposure the shared file cache has — but the cache is at least a
2221    /// path an installation curates, and this is the path taken *because* the
2222    /// curated one was refused. A caller who cannot accept it should point
2223    /// [`Client::with_file_cache`] at a directory of its own, which removes
2224    /// both this node and the refusal that produced it.
2225    fn upload_uncached(&self, digest: &str, bytes: &[u8]) -> Result<String> {
2226        let remote = format!(
2227            "{UNCACHED_UPLOAD_DIR}/ytsaurus_rs_worker_{digest}_{}",
2228            MutationId::new()
2229        );
2230        self.upload_executable(&remote, bytes)?;
2231        Ok(remote)
2232    }
2233
2234    /// Looks up a file in the cluster's file cache by its MD5.
2235    ///
2236    /// `None` means nothing is cached under that hash — including when the
2237    /// cache directory does not exist yet, which is what
2238    /// [`Client::upload_worker_cached`] creates on its way past, on a cluster
2239    /// that lets it.
2240    ///
2241    /// A lookup and nothing more: it sends no mutation, so it works against a
2242    /// cache the caller may only read.
2243    ///
2244    /// # Errors
2245    ///
2246    /// Returns [`ClientError`] if the request fails.
2247    pub fn file_from_cache(&self, md5: &str) -> Result<Option<String>> {
2248        let params = yson_build::map([
2249            ("md5", yson_build::string(md5)),
2250            ("cache_path", yson_build::string(&self.file_cache)),
2251        ]);
2252        // A `cache_path` that does not exist needs no special case: the cluster
2253        // answers 200 with the same empty string it uses for any other miss,
2254        // rather than the resolve error a missing path usually earns. Checked
2255        // against a local cluster with no `//tmp/yt_wrapper` at all, which is
2256        // the state a first upload starts from.
2257        let body = self.transport.call(
2258            Method::Get,
2259            "get_file_from_cache",
2260            &params,
2261            Payload::None,
2262            Repeatable::Freely,
2263        )?;
2264
2265        self.cached_path(&body, "get_file_from_cache")
2266    }
2267
2268    /// Hands a file already written to Cypress to the file cache.
2269    ///
2270    /// The cluster verifies that the node's MD5 is the one given, which is why
2271    /// it must have been written with `compute_md5`. Returns the path the file
2272    /// now lives at.
2273    ///
2274    /// # Errors
2275    ///
2276    /// Returns [`ClientError`] if the request fails.
2277    pub fn put_file_to_cache(&self, path: &str, md5: &str) -> Result<String> {
2278        let params = yson_build::map([
2279            ("path", yson_build::string(path)),
2280            ("md5", yson_build::string(md5)),
2281            ("cache_path", yson_build::string(&self.file_cache)),
2282        ]);
2283        let body = self.transport.call(
2284            Method::Post,
2285            "put_file_to_cache",
2286            &params,
2287            Payload::None,
2288            Repeatable::WithMutationId,
2289        )?;
2290
2291        self.cached_path(&body, "put_file_to_cache")?
2292            .ok_or_else(|| ClientError::Decode {
2293                command: "put_file_to_cache".to_owned(),
2294                reason: "the cluster returned no path for the cached file".to_owned(),
2295            })
2296    }
2297
2298    /// Reads the path out of a file-cache response.
2299    ///
2300    /// These two commands answer with a **bare string**, not the `{path=…}`
2301    /// envelope the rest of API v4 uses, and a cache miss is an *empty* string
2302    /// rather than an error or an entity. Both shapes are accepted so that a
2303    /// cluster that grows an envelope later does not break this.
2304    fn cached_path(&self, body: &[u8], command: &str) -> Result<Option<String>> {
2305        let value = self.strip_envelope(body, command)?;
2306        let value = match &value.node {
2307            YsonNode::Map(_) => self.field_of(&value, "path")?,
2308            _ => value,
2309        };
2310
2311        match &value.node {
2312            YsonNode::String(bytes) if !bytes.is_empty() => {
2313                Ok(Some(String::from_utf8_lossy(bytes).into_owned()))
2314            }
2315            YsonNode::String(_) | YsonNode::Entity => Ok(None),
2316            other => Err(ClientError::Decode {
2317                command: command.to_owned(),
2318                reason: format!("the cached path is not a string: {other:?}"),
2319            }),
2320        }
2321    }
2322
2323    /// Writes `bytes` to `remote` as a file a node is allowed to run.
2324    fn upload_executable(&self, remote: &str, bytes: &[u8]) -> Result<()> {
2325        self.create("file", remote)?;
2326        self.write_file(remote, bytes)?;
2327        self.set_attribute(remote, "executable", yson_build::boolean(true))
2328    }
2329
2330    /// Writes raw bytes to a Cypress file, replacing its contents.
2331    ///
2332    /// # Errors
2333    ///
2334    /// Returns [`ClientError`] if the request fails.
2335    pub fn write_file(&self, path: &str, contents: &[u8]) -> Result<()> {
2336        self.write_file_inner(path, contents, false)
2337    }
2338
2339    /// As `write_file`, asking the cluster to record the file's MD5 — which is
2340    /// what `put_file_to_cache` then checks against.
2341    fn write_file_computing_md5(&self, path: &str, contents: &[u8]) -> Result<()> {
2342        self.write_file_inner(path, contents, true)
2343    }
2344
2345    fn write_file_inner(&self, path: &str, contents: &[u8], compute_md5: bool) -> Result<()> {
2346        let mut params = yson_build::map([("path", yson_build::string(path))]);
2347        if compute_md5 {
2348            yson_build::insert(&mut params, "compute_md5", yson_build::boolean(true));
2349        }
2350
2351        self.transport.call(
2352            Method::Put,
2353            "write_file",
2354            &params,
2355            Payload::Bytes(contents),
2356            Repeatable::Heavy,
2357        )?;
2358        Ok(())
2359    }
2360
2361    /// Reads a whole Cypress file into memory.
2362    ///
2363    /// The mirror of [`Client::write_file`], and the buffered half of the
2364    /// pair: for a worker binary fetched back, a config a launcher inspects —
2365    /// results, not bulk data. For a file that does not fit,
2366    /// [`Client::read_file_streaming`] moves the same bytes without holding
2367    /// them.
2368    ///
2369    /// **The whole file is held in memory, and there is a ceiling: 512 MiB.**
2370    /// That is the transport's cap on any buffered response, counted in the
2371    /// bytes that land in the `Vec` — and a file past it is refused rather
2372    /// than truncated, with a [`ClientError::ResponseTooLarge`] that names the
2373    /// number and names the streaming half. A file of exactly the ceiling is
2374    /// not past it. A worker binary is comfortably under; a dataset someone
2375    /// stored as a file may not be, and that is exactly the case the pair
2376    /// comes in two halves for.
2377    ///
2378    /// **512 MiB held is not 512 MiB of process.** The buffer grows by
2379    /// doubling and copies as it grows, so both halves are resident for the
2380    /// length of a copy — up to about 1.5× the cap where the allocator cannot
2381    /// extend in place. Measured in a release build: a read that hands back
2382    /// 536 870 911 bytes peaks at 544 178 176 of resident set, and a 600 MiB
2383    /// read refused by the cap peaks at 611 385 344. Size for that, not for
2384    /// the ceiling.
2385    ///
2386    /// The cap counts *decoded* bytes because the compressed ones are not the
2387    /// same quantity and are not close to it: this client asks for gzip, and
2388    /// measured against a cluster, a 600 MiB file of zeros crosses the wire in
2389    /// 611 522 bytes. A cap on what arrives would have let all 600 MiB into
2390    /// memory — which is what it did until this was fixed.
2391    ///
2392    /// `path` is a **plain node path** — `//tmp/worker`. Not a rich one, and
2393    /// the reason is worth spelling out, because a rich path here does not
2394    /// fail so much as quietly do nothing. Measured on a cluster, on a file of
2395    /// 1000 bytes:
2396    ///
2397    /// - `<lower_limit={offset=0};upper_limit={offset=10}>//tmp/f` reads back
2398    ///   **all 1000 bytes** and passes the size check. A file is sliced by the
2399    ///   command's own `offset` and `length` parameters, not by limits on the
2400    ///   path, so limits written there are accepted and ignored — and the
2401    ///   caller who thought they had asked for ten bytes is told nothing.
2402    ///   `<append=%false>//tmp/f` is the same story with a harmless attribute.
2403    /// - `//tmp/f[#0:#10]` also reads back all 1000 bytes, and then fails: the
2404    ///   size check builds `{path}/@uncompressed_data_size` out of this string
2405    ///   textually, and `//tmp/f[#0:#10]/@uncompressed_data_size` is not a path
2406    ///   the cluster will parse — `Error reading parameter /path: Unexpected
2407    ///   token "/" of type "slash"`. A whole file downloaded and then refused
2408    ///   over a range that was never going to be honoured.
2409    ///
2410    /// So: a plain path. Selection on reads is [#12], and belongs in
2411    /// parameters this method would have to grow, not smuggled in through
2412    /// this argument.
2413    ///
2414    /// The body's length is checked against the size Cypress records for the
2415    /// node. That is not pedantry — the proxy reports a mid-stream failure in
2416    /// a trailer this client cannot see (see [`TableReader`] for the trailer
2417    /// gap), and a file's bytes carry no framing of their own: where a
2418    /// truncated table leaves a record that does not parse, a truncated file
2419    /// just ends, looking exactly like a shorter file. So after the read, one
2420    /// light `get` fetches the node's `@uncompressed_data_size` — the byte
2421    /// count of the content, whatever compression the node's own codec applies
2422    /// beneath it — and a body of any other length is an error rather than a
2423    /// file.
2424    ///
2425    /// The two requests are not atomic, and the race runs both ways. A writer
2426    /// replacing the file between them can fail the check for a body that was
2427    /// complete when it was sent — the ordinary hazard of reading what someone
2428    /// else is rewriting, surfaced as an error rather than as a mix of the two
2429    /// versions. The converse is rarer and quieter: a body genuinely cut short
2430    /// at N bytes, racing a replacement whose own
2431    /// `@uncompressed_data_size` is exactly N, passes the check, and a
2432    /// truncated read of the old version is returned as a whole file. That one
2433    /// cannot be closed from here — the only in-band verdict on a cut stream
2434    /// is the proxy's trailer, which `ureq` 3.3 does not read, so there is no
2435    /// header to prefer over the second request. A reader who needs a file
2436    /// pinned while others replace it takes a [`LockMode::Snapshot`] lock in a
2437    /// transaction, which is exactly what that mode is for, and closes both
2438    /// directions at once.
2439    ///
2440    /// Verified against a local cluster: a 4 MB [`Client::write_file`] of
2441    /// non-UTF-8 bytes comes back byte-for-byte through both halves of the
2442    /// pair, an empty file reads back empty, and a node carrying
2443    /// `compression_codec=zlib_6` — 1 000 000 logical bytes, 4 214 on disk —
2444    /// reads back its logical bytes with the check passing, which is the case
2445    /// that would break if the attribute were the on-disk size. And a 600 MiB
2446    /// file of zeros — 611 522 bytes on the wire — is refused rather than held,
2447    /// while `read_file_streaming` moves all 629 145 600 of it.
2448    ///
2449    /// # Errors
2450    ///
2451    /// Returns [`ClientError`] if the request fails, if the response is larger
2452    /// than the 512 MiB this holds in memory — a
2453    /// [`ClientError::ResponseTooLarge`], which is never retried and never
2454    /// blamed on the proxy that served it — if the node's size cannot be
2455    /// read — the check refuses loudly rather than quietly not happening — or
2456    /// if the body's length is not the size the cluster records. A missing
2457    /// path fails the read itself, before the size is ever asked for: code 1,
2458    /// `Error getting basic attributes of user objects`, with the resolve
2459    /// error nested inside — a category outside and the reason within, as a
2460    /// missing table is reported too.
2461    ///
2462    /// [#12]: https://github.com/sshaplygin/ytsaurus-rs/issues/12
2463    pub fn read_file(&self, path: &str) -> Result<Vec<u8>> {
2464        let params = yson_build::map([("path", yson_build::string(path))]);
2465        let body = self.transport.call(
2466            Method::Get,
2467            "read_file",
2468            &params,
2469            Payload::None,
2470            Repeatable::Heavy,
2471        )?;
2472
2473        // After the body rather than before: a size read first would age
2474        // across the whole transfer, and the point of comparing is to compare
2475        // against what the file was when the proxy finished sending it.
2476        let recorded = self.file_size(path)?;
2477        if recorded != body.len() as i64 {
2478            return Err(ClientError::Decode {
2479                command: "read_file".to_owned(),
2480                reason: format!(
2481                    "{path}: the cluster records {recorded} bytes but the response carried {}; \
2482                     either the stream was cut short — the proxy says so in a trailer this \
2483                     client cannot read — or the file was rewritten while it was being read",
2484                    body.len()
2485                ),
2486            });
2487        }
2488
2489        Ok(body)
2490    }
2491
2492    /// The byte count Cypress records for a file's content.
2493    ///
2494    /// `@uncompressed_data_size`, which is the content's logical length — a
2495    /// `compression_codec` on the node changes what the chunks weigh
2496    /// (`@compressed_data_size`), not what `read_file` returns. Both watched
2497    /// on a local cluster; there is no `@file_size`, whatever the name
2498    /// suggests — asked for one, the cluster answers `Attribute "file_size"
2499    /// is not found`. An answer that is not an integer is refused rather than
2500    /// skipped: a completeness check that quietly stopped checking would be
2501    /// worse than none, because [`Client::read_file`] promises it.
2502    ///
2503    /// Both ways of failing are reported as `read_file`, and the `get`'s own
2504    /// error is quoted inside rather than handed back as itself. The `get` is
2505    /// an implementation detail of the read, and it fails *after* the file's
2506    /// bytes have already arrived — so a bare `get: transport error …` names
2507    /// a command the caller never sent, and the obvious remedy for it, sending
2508    /// it again, is not what their retry will do: it will download the whole
2509    /// file a second time. The message says which command failed and which
2510    /// part of it did.
2511    fn file_size(&self, path: &str) -> Result<i64> {
2512        let size = self
2513            .get(&format!("{path}/@uncompressed_data_size"))
2514            .map_err(|error| ClientError::Decode {
2515                command: "read_file".to_owned(),
2516                reason: format!(
2517                    "the file's bytes arrived, but the size they were to be checked \
2518                     against could not be read: {error}"
2519                ),
2520            })?;
2521        size.as_i64().ok_or_else(|| ClientError::Decode {
2522            command: "read_file".to_owned(),
2523            reason: format!(
2524                "{path}/@uncompressed_data_size is not an integer: {:?}; without it the \
2525                 response cannot be checked for truncation",
2526                size.node
2527            ),
2528        })
2529    }
2530
2531    /// Reads a file as a stream, without holding it.
2532    ///
2533    /// The same bytes [`Client::read_file`] returns, arriving as they come off
2534    /// the connection — and a file is exactly the thing that might not fit in
2535    /// memory, which is why [`Client::write_file`]'s mirror comes in two
2536    /// halves. What comes out is a plain `Read`:
2537    ///
2538    /// ```no_run
2539    /// # use ytsaurus_client::Client;
2540    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
2541    /// # let client = Client::from_env()?;
2542    /// let mut file = client.read_file_streaming("//tmp/worker")?;
2543    /// std::io::copy(&mut file, &mut std::fs::File::create("worker")?)?;
2544    /// # Ok(())
2545    /// # }
2546    /// ```
2547    ///
2548    /// [`Client::read_file`] checks the body against the size the cluster
2549    /// records; this cannot, because the point is not to have the whole thing
2550    /// — and unlike a table, whose truncation leaves a record that does not
2551    /// parse, a file cut short by a mid-stream failure simply ends. A caller
2552    /// who needs certainty compares the reader's
2553    /// [`bytes_read`](ResponseReader::bytes_read) against the node's
2554    /// `@uncompressed_data_size` — see [`FileReader`] for why that gap exists.
2555    ///
2556    /// # Errors
2557    ///
2558    /// Returns [`ClientError`] if the request fails. Failures *during* the
2559    /// read arrive from the reader, not from here.
2560    pub fn read_file_streaming(&self, path: &str) -> Result<FileReader> {
2561        let params = yson_build::map([("path", yson_build::string(path))]);
2562        let body = self.transport.open(Method::Get, "read_file", &params)?;
2563        Ok(FileReader::new(body))
2564    }
2565
2566    /// Sets a node attribute.
2567    ///
2568    /// # Errors
2569    ///
2570    /// Returns [`ClientError`] if the request fails.
2571    pub fn set_attribute(&self, path: &str, name: &str, value: YsonValue) -> Result<()> {
2572        let encoded =
2573            ytsaurus_yson::to_vec(&value, YsonFormat::Binary).map_err(|e| ClientError::Decode {
2574                command: "set".to_owned(),
2575                reason: format!("could not encode the attribute: {e}"),
2576            })?;
2577
2578        let params = yson_build::map([
2579            ("path", yson_build::string(format!("{path}/@{name}"))),
2580            ("input_format", yson_build::binary_yson_format()),
2581        ]);
2582        self.transport.call(
2583            Method::Put,
2584            "set",
2585            &params,
2586            Payload::Bytes(&encoded),
2587            Repeatable::WithMutationId,
2588        )?;
2589        Ok(())
2590    }
2591
2592    /// Writes rows to a table, replacing its contents.
2593    ///
2594    /// `rows` must be a binary YSON list fragment — exactly what a
2595    /// `ytsaurus-job` worker writes.
2596    ///
2597    /// A path carrying a read selection — [`TablePath::columns`],
2598    /// [`TablePath::range`], or rich YPath syntax spelled into the path
2599    /// string — is **refused locally**, before anything is sent. The cluster
2600    /// ignores those on a write and replaces the whole table with a 200
2601    /// (measured: `write_table_rows("//tmp/t[#0:#2]", rows)` replaced
2602    /// everything and reported success), and this refusal is what keeps that
2603    /// silent loss unwritable. See [`TablePath`].
2604    ///
2605    /// # Errors
2606    ///
2607    /// Returns [`ClientError::Config`] if the path carries a read selection,
2608    /// or [`ClientError`] if the request fails.
2609    pub fn write_table(&self, path: impl Into<TablePath>, rows: &[u8]) -> Result<()> {
2610        self.write_table_with_format(path, rows, &DataFormat::binary_yson())
2611    }
2612
2613    /// Writes rows to a table using a shared [`DataFormat`], replacing its
2614    /// contents.
2615    ///
2616    /// YSON data is a list fragment in the selected representation. Skiff data
2617    /// is a complete schema-described stream; direct table I/O requires exactly
2618    /// one schema with named non-system fields.
2619    ///
2620    /// # Errors
2621    ///
2622    /// Returns [`ClientError`] if the format is unsupported, the data is not a
2623    /// complete Skiff stream, or the request fails.
2624    pub fn write_table_with_format(
2625        &self,
2626        path: impl Into<TablePath>,
2627        rows: &[u8],
2628        format: &DataFormat,
2629    ) -> Result<()> {
2630        let path = path.into();
2631        match format {
2632            DataFormat::Yson(format) => self.write_yson_table(&path, rows, *format),
2633            DataFormat::Skiff(format) => self.write_skiff_table_impl(&path, rows, format),
2634            _ => Err(unsupported_data_format()),
2635        }
2636    }
2637
2638    fn write_yson_table(&self, path: &TablePath, rows: &[u8], format: YsonFormat) -> Result<()> {
2639        refuse_selection_on_write(path)?;
2640        let params = yson_build::map([
2641            ("path", path.to_yson()),
2642            ("input_format", DataFormat::yson(format).to_yson()),
2643        ]);
2644        self.transport.call(
2645            Method::Put,
2646            "write_table",
2647            &params,
2648            Payload::Bytes(rows),
2649            Repeatable::Heavy,
2650        )?;
2651        Ok(())
2652    }
2653
2654    /// Writes a complete Skiff stream to one table, replacing its contents.
2655    ///
2656    /// `format` must have exactly one table schema. Its named fields are sent
2657    /// as the rich-path `columns` projection, matching the Go SDK; this is how
2658    /// the proxy maps the positional Skiff tuple to table columns. `rows` is
2659    /// checked against that schema before the request is made.
2660    ///
2661    /// # Errors
2662    ///
2663    /// Returns [`ClientError`] if the format is not a direct-table format, the
2664    /// stream is incomplete, or the request fails.
2665    pub fn write_skiff_table(
2666        &self,
2667        path: impl Into<TablePath>,
2668        rows: &[u8],
2669        format: &SkiffFormat,
2670    ) -> Result<()> {
2671        self.write_table_with_format(path, rows, &DataFormat::skiff(format.clone()))
2672    }
2673
2674    fn write_skiff_table_impl(
2675        &self,
2676        path: &TablePath,
2677        rows: &[u8],
2678        format: &SkiffFormat,
2679    ) -> Result<()> {
2680        refuse_selection_on_write(path)?;
2681        // The path first: it is what rejects a format that is not single-table
2682        // direct I/O. Checking the stream first would answer a multi-table
2683        // format with a decode error about a tag mismatch, which describes a
2684        // consequence rather than the mistake.
2685        let path_value = skiff_table_path(path, format)?;
2686        check_complete_skiff_stream(rows, format).map_err(|reason| ClientError::Decode {
2687            command: "write_table".to_owned(),
2688            reason: format!("{}: {reason}", path.as_str()),
2689        })?;
2690
2691        let params = yson_build::map([("path", path_value), ("input_format", format.to_yson())]);
2692        self.transport.call(
2693            Method::Put,
2694            "write_table",
2695            &params,
2696            Payload::Bytes(rows),
2697            Repeatable::Heavy,
2698        )?;
2699        Ok(())
2700    }
2701
2702    /// Reads a whole table as a binary YSON list fragment.
2703    ///
2704    /// Reads it into memory: this is for results a launcher inspects, not for
2705    /// bulk export.
2706    ///
2707    /// The path can select which part of the table to read —
2708    /// [`TablePath::columns`] and [`TablePath::range`] travel as attributes on
2709    /// it, so three columns of a hundred rows cost three columns of a hundred
2710    /// rows, not the whole table:
2711    ///
2712    /// ```no_run
2713    /// # use ytsaurus_client::{Client, TablePath};
2714    /// # fn main() -> Result<(), ytsaurus_client::ClientError> {
2715    /// # let client = Client::from_env()?;
2716    /// let head = client.read_table(TablePath::new("//tmp/log").columns(["host"]).range(0..100))?;
2717    /// # Ok(())
2718    /// # }
2719    /// ```
2720    ///
2721    /// The result is checked to be a complete list fragment. That is not
2722    /// pedantry — the proxy reports a mid-stream failure in a trailer this
2723    /// client cannot see (see the `http` module), so a truncated body is the
2724    /// symptom that *is* detectable, and returning it as success would hand the
2725    /// caller a silently short table.
2726    ///
2727    /// # Errors
2728    ///
2729    /// Returns [`ClientError`] if the request fails or the stream is truncated.
2730    pub fn read_table(&self, path: impl Into<TablePath>) -> Result<Vec<u8>> {
2731        self.read_table_with_format(path, &DataFormat::binary_yson())
2732    }
2733
2734    /// Reads a whole table using a shared [`DataFormat`].
2735    ///
2736    /// The returned bytes are a YSON list fragment or a complete Skiff stream,
2737    /// according to `format`. The response is checked for truncated records
2738    /// before it is returned.
2739    ///
2740    /// # Errors
2741    ///
2742    /// Returns [`ClientError`] if the format is unsupported, the response is
2743    /// incomplete, or the request fails.
2744    pub fn read_table_with_format(
2745        &self,
2746        path: impl Into<TablePath>,
2747        format: &DataFormat,
2748    ) -> Result<Vec<u8>> {
2749        let path = path.into();
2750        match format {
2751            DataFormat::Yson(format) => self.read_yson_table(&path, *format),
2752            DataFormat::Skiff(format) => self.read_skiff_table_impl(&path, format),
2753            _ => Err(unsupported_data_format()),
2754        }
2755    }
2756
2757    fn read_yson_table(&self, path: &TablePath, format: YsonFormat) -> Result<Vec<u8>> {
2758        refuse_mixed_selection_on_read(path)?;
2759        let params = yson_build::map([
2760            ("path", path.to_yson()),
2761            ("output_format", DataFormat::yson(format).to_yson()),
2762        ]);
2763        let body = self.transport.call(
2764            Method::Get,
2765            "read_table",
2766            &params,
2767            Payload::None,
2768            Repeatable::Heavy,
2769        )?;
2770
2771        check_complete_yson_fragment(&body, format).map_err(|reason| ClientError::Decode {
2772            command: "read_table".to_owned(),
2773            reason: format!("{path}: {reason}"),
2774        })?;
2775
2776        Ok(body)
2777    }
2778
2779    /// Reads one table as a complete Skiff stream.
2780    ///
2781    /// `format` must have exactly one table schema. Its named fields select
2782    /// the table columns and determine the bytes returned — which is why a
2783    /// path that *also* names columns is refused. That covers both spellings,
2784    /// [`TablePath::columns`] and `{…}` in the path *string*, because the
2785    /// format's fields become a `columns` attribute here whether the caller
2786    /// named one or not.
2787    ///
2788    /// **What that costs is a silently ignored filter, not a corrupt decode.**
2789    /// Measured, the synthesised attribute wins: `<columns=[n]>"//tmp/t{k}"`
2790    /// answered with column `n`. A Skiff read therefore still receives exactly
2791    /// the columns its format names, and the tuple stays aligned — but the
2792    /// `{…}` the caller wrote is discarded without a word, at 200. Refusing is
2793    /// how they get to hear about it. A path string opening with `<…>` is
2794    /// refused one step removed: this client cannot parse the block to see
2795    /// whether it names `columns` as well.
2796    ///
2797    /// **Row selections are not column selections and are not refused.** A
2798    /// [`TablePath::range`] combines, and so does a range spelled into the
2799    /// string — measured, `<columns=[n]>"//tmp/t[#0:#2]"` answered 200 with
2800    /// rows 0-1 carrying only `n`. Ranges pick rows, the schema picks columns.
2801    ///
2802    /// The response is decoded to its end before being returned so a truncated
2803    /// Skiff stream is never reported as a successful table read.
2804    ///
2805    /// # Errors
2806    ///
2807    /// Returns [`ClientError`] if the format is not a direct-table format, the
2808    /// path also selects columns — through [`TablePath::columns`] or as `{…}`
2809    /// in its string — the path string opens with an attribute block, the
2810    /// response is incomplete, or the request fails.
2811    pub fn read_skiff_table(
2812        &self,
2813        path: impl Into<TablePath>,
2814        format: &SkiffFormat,
2815    ) -> Result<Vec<u8>> {
2816        self.read_table_with_format(path, &DataFormat::skiff(format.clone()))
2817    }
2818
2819    fn read_skiff_table_impl(&self, path: &TablePath, format: &SkiffFormat) -> Result<Vec<u8>> {
2820        refuse_mixed_selection_on_read(path)?;
2821        let params = yson_build::map([
2822            ("path", skiff_table_path(path, format)?),
2823            ("output_format", format.to_yson()),
2824        ]);
2825        let body = self.transport.call(
2826            Method::Get,
2827            "read_table",
2828            &params,
2829            Payload::None,
2830            Repeatable::Heavy,
2831        )?;
2832
2833        check_complete_skiff_stream(&body, format).map_err(|reason| ClientError::Decode {
2834            command: "read_table".to_owned(),
2835            reason: format!("{path}: {reason}"),
2836        })?;
2837
2838        Ok(body)
2839    }
2840
2841    /// Writes rows to a table from anything that yields them.
2842    ///
2843    /// The rows are Rust values; the encoding is this crate's problem, which is
2844    /// the difference between this and [`Client::write_table`]:
2845    ///
2846    /// ```no_run
2847    /// # use ytsaurus_client::Client;
2848    /// # fn main() -> Result<(), ytsaurus_client::ClientError> {
2849    /// # let client = Client::from_env()?;
2850    /// #[derive(serde::Serialize)]
2851    /// struct Contact<'a> {
2852    ///     name: &'a str,
2853    ///     email: &'a str,
2854    ///     age: i64,
2855    /// }
2856    ///
2857    /// client.write_table_rows("//tmp/contacts", (0..100).map(|n| Contact {
2858    ///     name: "Gordon Freeman",
2859    ///     email: "gordon@black-mesa.example",
2860    ///     age: 27 + n,
2861    /// }))?;
2862    /// # Ok(())
2863    /// # }
2864    /// ```
2865    ///
2866    /// It takes an iterator rather than a slice because the encoder sits
2867    /// *inside* the request body: rows are serialised a bufferful at a time as
2868    /// the connection asks for bytes, so a million rows cost one buffer rather
2869    /// than a million rows' worth of memory, and the caller never has to
2870    /// materialise them either.
2871    ///
2872    /// Replaces the table's contents, as [`Client::write_table`] does — and
2873    /// refuses a path carrying a read selection before anything is sent, for
2874    /// the reason given there.
2875    ///
2876    /// # Errors
2877    ///
2878    /// Returns [`ClientError::Config`] if the path carries a read selection,
2879    /// [`ClientError::Decode`] naming the row if one cannot be serialised —
2880    /// the write fails rather than sending the rows before it — or
2881    /// [`ClientError`] if the request fails.
2882    pub fn write_table_rows<T, I>(&self, path: impl Into<TablePath>, rows: I) -> Result<()>
2883    where
2884        T: serde::Serialize,
2885        I: IntoIterator<Item = T>,
2886    {
2887        let path = path.into();
2888        refuse_selection_on_write(&path)?;
2889        let params = yson_build::map([
2890            ("path", path.to_yson()),
2891            ("input_format", yson_build::binary_yson_format()),
2892        ]);
2893
2894        let mut stream = stream::RowStream::new(rows.into_iter());
2895        let sent = self
2896            .transport
2897            .upload(Method::Put, "write_table", &params, &mut stream);
2898
2899        // Checked first: a body that failed to encode fails the request too,
2900        // and the transport's account of that is "the body ended early".
2901        if let Some(reason) = stream.failed {
2902            return Err(ClientError::Decode {
2903                command: "write_table".to_owned(),
2904                reason: format!("{path}: {reason}"),
2905            });
2906        }
2907        sent.map(|_| ())
2908    }
2909
2910    /// Reads a whole table as typed rows.
2911    ///
2912    /// ```no_run
2913    /// # use ytsaurus_client::Client;
2914    /// # fn main() -> Result<(), ytsaurus_client::ClientError> {
2915    /// # let client = Client::from_env()?;
2916    /// #[derive(serde::Deserialize)]
2917    /// struct Contact {
2918    ///     name: String,
2919    ///     age: i64,
2920    /// }
2921    ///
2922    /// for contact in client.read_table_rows::<Contact>("//tmp/contacts")? {
2923    ///     println!("{} is {}", contact.name, contact.age);
2924    /// }
2925    /// # Ok(())
2926    /// # }
2927    /// ```
2928    ///
2929    /// Rows are **owned**, and the whole table is read before any of it is
2930    /// returned — this is [`Client::read_table`] with the decoding done, and it
2931    /// inherits the same purpose: results a launcher inspects. For a table that
2932    /// does not fit, or for rows borrowed from the buffer they arrived in,
2933    /// [`Client::read_table_streaming`] feeds `ytsaurus_job::JobReader`.
2934    ///
2935    /// Columns the type does not mention are ignored, so a struct naming two
2936    /// columns of a twenty-column table is a projection rather than an error —
2937    /// but the *whole* row still crosses the wire and is decoded before the
2938    /// projection happens. [`TablePath::columns`] moves the projection to the
2939    /// cluster, and [`TablePath::range`] does the same for rows:
2940    ///
2941    /// ```no_run
2942    /// # use ytsaurus_client::{Client, TablePath};
2943    /// # fn main() -> Result<(), ytsaurus_client::ClientError> {
2944    /// # let client = Client::from_env()?;
2945    /// # #[derive(serde::Deserialize)]
2946    /// # struct Contact { name: String, age: i64 }
2947    /// let some: Vec<Contact> = client.read_table_rows(
2948    ///     TablePath::new("//tmp/contacts").columns(["name", "age"]).range(0..100),
2949    /// )?;
2950    /// # Ok(())
2951    /// # }
2952    /// ```
2953    ///
2954    /// # Errors
2955    ///
2956    /// Returns [`ClientError`] if the request fails, the stream is truncated,
2957    /// or a row does not match `T`.
2958    pub fn read_table_rows<T: serde::de::DeserializeOwned>(
2959        &self,
2960        path: impl Into<TablePath>,
2961    ) -> Result<Vec<T>> {
2962        let path = path.into();
2963        decode_rows(&self.read_table(&path)?, &path.to_string())
2964    }
2965
2966    /// Reads a node, or an attribute, into a Rust type.
2967    ///
2968    /// [`Client::get`] hands back a [`YsonValue`] to walk; this hands back the
2969    /// shape you were going to walk it into:
2970    ///
2971    /// ```no_run
2972    /// # use ytsaurus_client::Client;
2973    /// # fn main() -> Result<(), ytsaurus_client::ClientError> {
2974    /// # let client = Client::from_env()?;
2975    /// #[derive(serde::Deserialize)]
2976    /// struct Cluster {
2977    ///     #[serde(rename = "type")]
2978    ///     node_type: String,
2979    ///     creation_time: String,
2980    ///     account: String,
2981    /// }
2982    ///
2983    /// let root: Cluster = client.get_as("//@")?;
2984    /// println!("the cluster was created at {}", root.creation_time);
2985    /// # Ok(())
2986    /// # }
2987    /// ```
2988    ///
2989    /// Attributes the type does not mention are ignored, which is what makes
2990    /// `//@` — a node with dozens of them — worth asking about at all.
2991    ///
2992    /// # Errors
2993    ///
2994    /// Returns [`ClientError`] if the request fails or the answer does not fit
2995    /// `T`.
2996    pub fn get_as<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
2997        let params = yson_build::map([("path", yson_build::string(path))]);
2998        let body = self.transport.call(
2999            Method::Get,
3000            "get",
3001            &params,
3002            Payload::None,
3003            Repeatable::Freely,
3004        )?;
3005
3006        // Decoded straight out of the response, envelope and all. Going through
3007        // `get` would build a whole `YsonValue` tree, encode it back to bytes
3008        // and decode those into `T` — three passes over the document and two
3009        // copies of it in memory, where one pass does the same job. Invisible
3010        // for `//@`; not for a large attribute or a subtree.
3011        let envelope: Envelope<T> =
3012            from_slice(&body, YsonFormat::Text).map_err(|e| ClientError::Decode {
3013                command: "get".to_owned(),
3014                reason: format!(
3015                    "{path}: the answer does not fit the type asked for: {e}; body was {}",
3016                    crate::error::truncate(&String::from_utf8_lossy(&body), 200)
3017                ),
3018            })?;
3019
3020        Ok(envelope.value)
3021    }
3022
3023    /// Reads a table as a stream, without holding it.
3024    ///
3025    /// The same bytes [`Client::read_table`] returns — a binary YSON list
3026    /// fragment — arriving as they come off the connection, so the table's size
3027    /// stops being the program's memory ceiling.
3028    ///
3029    /// What comes out is what a job reads on fd 0, so the same decoder handles
3030    /// both:
3031    ///
3032    /// ```no_run
3033    /// # use ytsaurus_client::Client;
3034    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
3035    /// # let client = Client::from_env()?;
3036    /// let mut reader = ytsaurus_job::JobReader::binary(client.read_table_streaming("//tmp/big")?);
3037    ///
3038    /// let mut rows = 0_u64;
3039    /// while let Some(event) = reader.next_event()? {
3040    ///     if matches!(event, ytsaurus_job::Event::Row(_)) {
3041    ///         rows += 1;
3042    ///     }
3043    /// }
3044    /// # Ok(())
3045    /// # }
3046    /// ```
3047    ///
3048    /// [`Client::read_table`] checks that what came back is a complete
3049    /// fragment; this cannot, because it never has the whole thing. A fragment
3050    /// cut short instead leaves a record that does not parse, and the decoder
3051    /// fails on it — see [`TableReader`] for why that is the same protection
3052    /// rather than none.
3053    ///
3054    /// The path can carry a read selection — [`TablePath::columns`] and
3055    /// [`TablePath::range`] — which is worth the most here of anywhere: a
3056    /// streaming read exists because the table is too big to hold, and a
3057    /// selection is how most of it never arrives at all.
3058    ///
3059    /// # Errors
3060    ///
3061    /// Returns [`ClientError`] if the request fails. Failures *during* the read
3062    /// arrive from the reader, not from here.
3063    pub fn read_table_streaming(&self, path: impl Into<TablePath>) -> Result<TableReader> {
3064        let path = path.into();
3065        refuse_mixed_selection_on_read(&path)?;
3066        let params = yson_build::map([
3067            ("path", path.to_yson()),
3068            ("output_format", yson_build::binary_yson_format()),
3069        ]);
3070        let body = self.transport.open(Method::Get, "read_table", &params)?;
3071        Ok(TableReader::new(body))
3072    }
3073
3074    /// Writes a table from a stream, without holding it.
3075    ///
3076    /// `rows` is read to its end and sent as it is read, so the rows can come
3077    /// from a file, a pipe, or something that generates them — anything that is
3078    /// a `Read`. The bytes are a binary YSON list fragment, exactly as
3079    /// [`Client::write_table`] expects them.
3080    ///
3081    /// ```no_run
3082    /// # use ytsaurus_client::Client;
3083    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
3084    /// # let client = Client::from_env()?;
3085    /// client.create("table", "//tmp/big")?;
3086    /// client.write_table_streaming("//tmp/big", std::fs::File::open("rows.yson")?)?;
3087    /// # Ok(())
3088    /// # }
3089    /// ```
3090    ///
3091    /// This is one attempt and can never be more: a reader that has been
3092    /// consumed cannot be sent again. That agrees with the retry rules — heavy
3093    /// commands are not repeated — and a transaction is what makes such a write
3094    /// safe to fail.
3095    ///
3096    /// # Errors
3097    ///
3098    /// Returns [`ClientError::Config`] if the path carries a read selection —
3099    /// see [`Client::write_table`] — or [`ClientError`] if the request fails,
3100    /// including when `rows` itself fails to read.
3101    pub fn write_table_streaming(
3102        &self,
3103        path: impl Into<TablePath>,
3104        mut rows: impl std::io::Read,
3105    ) -> Result<()> {
3106        let path = path.into();
3107        refuse_selection_on_write(&path)?;
3108        let params = yson_build::map([
3109            ("path", path.to_yson()),
3110            ("input_format", yson_build::binary_yson_format()),
3111        ]);
3112        self.transport
3113            .upload(Method::Put, "write_table", &params, &mut rows)?;
3114        Ok(())
3115    }
3116
3117    // ---------------------------------------------------------- operations
3118
3119    /// Starts a map operation, returning its ID.
3120    ///
3121    /// # Errors
3122    ///
3123    /// Returns [`ClientError`] if the request fails.
3124    pub fn start_map(&self, spec: &MapSpec) -> Result<String> {
3125        refuse_skiff_table_mismatch(spec.skiff_table_mismatch())?;
3126        self.start_operation(OperationType::Map, &spec.to_yson())
3127    }
3128
3129    /// Starts a map-reduce operation, returning its ID.
3130    ///
3131    /// # Errors
3132    ///
3133    /// Returns [`ClientError`] if the request fails.
3134    pub fn start_map_reduce(&self, spec: &MapReduceSpec) -> Result<String> {
3135        refuse_skiff_table_mismatch(spec.skiff_table_mismatch())?;
3136        self.start_operation(OperationType::MapReduce, &spec.to_yson())
3137    }
3138
3139    /// Starts a reduce operation over sorted input, returning its ID.
3140    ///
3141    /// The input tables must already be sorted by a column set beginning with
3142    /// the spec's `reduce_by`; the cluster refuses the operation otherwise.
3143    /// [`Client::start_sort`] is how they get that way.
3144    ///
3145    /// # Errors
3146    ///
3147    /// Returns [`ClientError`] if the request fails.
3148    pub fn start_reduce(&self, spec: &ReduceSpec) -> Result<String> {
3149        refuse_skiff_table_mismatch(spec.skiff_table_mismatch())?;
3150        self.start_operation(OperationType::Reduce, &spec.to_yson())
3151    }
3152
3153    /// Starts a sort operation, returning its ID.
3154    ///
3155    /// # Errors
3156    ///
3157    /// Returns [`ClientError`] if the request fails.
3158    pub fn start_sort(&self, spec: &SortSpec) -> Result<String> {
3159        self.start_operation(OperationType::Sort, &spec.to_yson())
3160    }
3161
3162    /// Starts a vanilla operation, returning its ID.
3163    ///
3164    /// Jobs with no input tables: a distributed process, a side-car
3165    /// computation, anything that is not a transformation of a table.
3166    ///
3167    /// # Errors
3168    ///
3169    /// Returns [`ClientError::Config`] if two tasks share a name, and
3170    /// [`ClientError`] if the request fails.
3171    pub fn start_vanilla(&self, spec: &VanillaSpec) -> Result<String> {
3172        // Refused here rather than sent: the spec keys tasks by name, so the
3173        // cluster would take two tasks called the same thing as one, run half
3174        // the jobs, and complete. A silent half-run is worse than a rejected
3175        // launch.
3176        if let Some(name) = spec.duplicate_task() {
3177            return Err(ClientError::Config(format!(
3178                "two vanilla tasks are both called {name:?}; a spec keys its tasks \
3179                 by name, so the second would replace the first and its jobs would \
3180                 never run"
3181            )));
3182        }
3183
3184        refuse_skiff_table_mismatch(spec.skiff_table_mismatch())?;
3185        self.start_operation(OperationType::Vanilla, &spec.to_yson())
3186    }
3187
3188    /// Starts a merge operation, returning its ID.
3189    ///
3190    /// A [`MergeMode::Sorted`] merge does **not** need
3191    /// [`MergeSpec::with_merge_by`]: measured against a cluster, one sent
3192    /// without it is accepted and the key is taken from the sort columns the
3193    /// inputs already carry, with the output coming back sorted by them.
3194    /// Naming the columns is how to merge by fewer of them than the inputs are
3195    /// sorted by, or to state the assumption where a reader can see it.
3196    ///
3197    /// # Errors
3198    ///
3199    /// Returns [`ClientError`] if the request fails — including when a sorted
3200    /// merge's inputs are not sorted, which only the cluster can tell.
3201    pub fn start_merge(&self, spec: &MergeSpec) -> Result<String> {
3202        self.start_operation(OperationType::Merge, &spec.to_yson())
3203    }
3204
3205    /// Starts an erase operation, returning its ID.
3206    ///
3207    /// # Errors
3208    ///
3209    /// Returns [`ClientError`] if the request fails.
3210    pub fn start_erase(&self, spec: &EraseSpec) -> Result<String> {
3211        self.start_operation(OperationType::Erase, &spec.to_yson())
3212    }
3213
3214    /// Starts a remote-copy operation, returning its ID.
3215    ///
3216    /// # Errors
3217    ///
3218    /// Returns [`ClientError`] if the request fails.
3219    pub fn start_remote_copy(&self, spec: &RemoteCopySpec) -> Result<String> {
3220        self.start_operation(OperationType::RemoteCopy, &spec.to_yson())
3221    }
3222
3223    /// Starts an operation from a spec built by hand.
3224    ///
3225    /// The escape hatch for anything [`MapSpec`] and [`MapReduceSpec`] do not
3226    /// model; build the spec with [`yson_build`].
3227    ///
3228    /// # Errors
3229    ///
3230    /// Returns [`ClientError`] if the request fails.
3231    pub fn start_operation(&self, kind: OperationType, spec: &YsonValue) -> Result<String> {
3232        self.start_operation_inner(kind, spec, None)
3233    }
3234
3235    /// Starts an operation under a mutation ID you control.
3236    ///
3237    /// `start_operation` already tags its own retries with a fresh
3238    /// [`MutationId`], so a retried start never leaves two operations running.
3239    /// This is for the guarantee a single process cannot give itself: persist
3240    /// the ID, and after a crash the same call returns the operation that was
3241    /// already started instead of starting a second one.
3242    ///
3243    /// The cluster remembers a mutation ID for five to ten minutes, so this is
3244    /// a guard against a crash-and-restart, not a permanent key.
3245    ///
3246    /// # Errors
3247    ///
3248    /// Returns [`ClientError`] if the request fails.
3249    pub fn start_operation_with(
3250        &self,
3251        kind: OperationType,
3252        spec: &YsonValue,
3253        mutation_id: &MutationId,
3254    ) -> Result<String> {
3255        self.start_operation_inner(kind, spec, Some(mutation_id))
3256    }
3257
3258    fn start_operation_inner(
3259        &self,
3260        kind: OperationType,
3261        spec: &YsonValue,
3262        mutation_id: Option<&MutationId>,
3263    ) -> Result<String> {
3264        let params = yson_build::map([
3265            ("operation_type", yson_build::string(kind.as_str())),
3266            ("spec", spec.clone()),
3267        ]);
3268        let body = self.transport.call_with(
3269            Method::Post,
3270            "start_operation",
3271            &params,
3272            Payload::None,
3273            Repeatable::WithMutationId,
3274            mutation_id,
3275        )?;
3276
3277        let value = self.value_field(&body, "operation_id")?;
3278        match &value.node {
3279            YsonNode::String(bytes) => Ok(String::from_utf8_lossy(bytes).into_owned()),
3280            other => Err(ClientError::Decode {
3281                command: "start_operation".to_owned(),
3282                reason: format!("operation_id is not a string: {other:?}"),
3283            }),
3284        }
3285    }
3286
3287    /// Stops an operation that is still running.
3288    ///
3289    /// The counterpart to starting one, and the reason it is worth having: a
3290    /// launcher that gives up — an interrupted `wait_for_operation`, a failed
3291    /// step further down the script — otherwise leaves the operation running on
3292    /// the cluster, spending quota on a result nobody will read.
3293    ///
3294    /// `reason` is put in the operation's error document, under the cluster's
3295    /// own `Operation aborted by user request`, so whoever finds the aborted
3296    /// operation later is told who stopped it and why. Pass `None` to say
3297    /// nothing.
3298    ///
3299    /// By the time this returns the operation is already `aborted`: the call
3300    /// takes a few hundred milliseconds, and the state has changed within it.
3301    /// The `aborting` state exists but no caller of this can observe it.
3302    ///
3303    /// **This is not idempotent, unlike [`Transaction::abort`].** Once the
3304    /// scheduler has let go of an operation it answers `No such operation`, and
3305    /// it lets go as soon as the first abort is accepted — so a second abort is
3306    /// an error rather than a shrug, even for an operation that was still
3307    /// running a moment ago. An operation that finished *by itself* can still
3308    /// be aborted for the short while the scheduler keeps it, so this is not a
3309    /// reliable way to ask whether one has finished either.
3310    ///
3311    /// **Sent once, and never retried**, which is the other side of the same
3312    /// coin. `abort_operation` is a scheduler command and the master's mutation
3313    /// cache does not cover it: a retry after a lost answer would be told `No
3314    /// such operation` and would report a successful abort as a failed one.
3315    /// A transport error here means the request may or may not have arrived,
3316    /// and the honest thing is to say so rather than to guess.
3317    ///
3318    /// # Errors
3319    ///
3320    /// Returns [`ClientError`] if the request fails, including when the
3321    /// scheduler no longer has the operation.
3322    pub fn abort_operation(&self, id: &str, reason: Option<&str>) -> Result<()> {
3323        let mut params = yson_build::map([("operation_id", yson_build::string(id))]);
3324        if let Some(reason) = reason {
3325            yson_build::insert(&mut params, "abort_message", yson_build::string(reason));
3326        }
3327
3328        self.transport.call(
3329            Method::Post,
3330            "abort_operation",
3331            &params,
3332            Payload::None,
3333            // Not `WithMutationId`, though this is a mutating command: that
3334            // deduplication lives in the master and this request goes to the
3335            // scheduler. Verified — a second send of the same mutation ID,
3336            // flagged as a retry, is answered `No such operation` rather than
3337            // with the first response. A retry would turn an abort that worked
3338            // into an error the caller believes.
3339            Repeatable::Never,
3340        )?;
3341        Ok(())
3342    }
3343
3344    /// Pauses a running operation.
3345    ///
3346    /// Its jobs stop being scheduled; what is already running keeps running
3347    /// unless `abort_running_jobs` says otherwise, in which case the work those
3348    /// jobs had done is lost and will be done again after
3349    /// [`Client::resume_operation`].
3350    ///
3351    /// **Suspension is not a state.** A suspended operation still answers
3352    /// `running` to [`Client::operation_state`] — the cluster reports it in a
3353    /// separate `suspended` attribute, which is what
3354    /// [`Client::operation_suspended`] reads. Verified on a local cluster, and
3355    /// it is the sort of thing a poll loop gets wrong forever.
3356    ///
3357    /// **Unlike its counterpart, this one is idempotent**: suspending a
3358    /// suspended operation answers `{}`, so it is retried like a read. That
3359    /// holds only while the scheduler still has the operation — once it has let
3360    /// go, this answers `No such operation` like every other command here.
3361    ///
3362    /// # Errors
3363    ///
3364    /// Returns [`ClientError`] if the request fails, including when the
3365    /// scheduler no longer has the operation.
3366    pub fn suspend_operation(&self, id: &str, abort_running_jobs: bool) -> Result<()> {
3367        let params = yson_build::map([
3368            ("operation_id", yson_build::string(id)),
3369            (
3370                "abort_running_jobs",
3371                yson_build::boolean(abort_running_jobs),
3372            ),
3373        ]);
3374        self.transport.call(
3375            Method::Post,
3376            "suspend_operation",
3377            &params,
3378            Payload::None,
3379            // Mutating, and repeated anyway: a second suspend of a suspended
3380            // operation is accepted, so a retry after a lost answer says the
3381            // same thing twice rather than turning a success into an error.
3382            // That is exactly what `abort_operation` cannot do — an abort makes
3383            // the scheduler let go, so its retry is guaranteed to fail.
3384            Repeatable::Freely,
3385        )?;
3386        Ok(())
3387    }
3388
3389    /// Lets a suspended operation run again.
3390    ///
3391    /// **Sent once, and never retried.** Where [`Client::suspend_operation`] is
3392    /// idempotent, this is not: an operation that is not suspended answers code
3393    /// 201, `Operation is in "running" state`. A retry after a lost answer would
3394    /// therefore report a resume that worked as a failure — the same trap
3395    /// [`Client::abort_operation`] describes.
3396    ///
3397    /// # Errors
3398    ///
3399    /// Returns [`ClientError`] if the request fails, including when the
3400    /// operation was not suspended.
3401    pub fn resume_operation(&self, id: &str) -> Result<()> {
3402        let params = yson_build::map([("operation_id", yson_build::string(id))]);
3403        self.transport.call(
3404            Method::Post,
3405            "resume_operation",
3406            &params,
3407            Payload::None,
3408            Repeatable::Never,
3409        )?;
3410        Ok(())
3411    }
3412
3413    /// Finishes an operation early, keeping what it has produced.
3414    ///
3415    /// The difference from [`Client::abort_operation`]: an aborted operation's
3416    /// output tables are discarded, a completed one's are published. This is how
3417    /// a long-running vanilla operation is stopped *successfully* — it ends as
3418    /// `completed`, and [`Client::wait_for_operation`] returns `Ok`.
3419    ///
3420    /// **Sent once, and never retried**, for the reason
3421    /// [`Client::abort_operation`] gives: the second one is answered `No such
3422    /// operation`, so a retry turns a completion that worked into an error.
3423    ///
3424    /// # Errors
3425    ///
3426    /// Returns [`ClientError`] if the request fails, including when the
3427    /// scheduler no longer has the operation.
3428    pub fn complete_operation(&self, id: &str) -> Result<()> {
3429        let params = yson_build::map([("operation_id", yson_build::string(id))]);
3430        self.transport.call(
3431            Method::Post,
3432            "complete_operation",
3433            &params,
3434            Payload::None,
3435            Repeatable::Never,
3436        )?;
3437        Ok(())
3438    }
3439
3440    /// Changes a running operation's scheduling parameters.
3441    ///
3442    /// The pool it competes in and the share it gets, while it runs — the one
3443    /// thing about a started operation that is not fixed. See
3444    /// [`OperationParameters`].
3445    ///
3446    /// ```no_run
3447    /// # use ytsaurus_client::{Client, OperationParameters};
3448    /// # fn main() -> Result<(), ytsaurus_client::ClientError> {
3449    /// # let client = Client::from_env()?;
3450    /// # let id = String::new();
3451    /// client.update_operation_parameters(
3452    ///     &id,
3453    ///     &OperationParameters::new().with_pool("interactive").with_weight(2.0),
3454    /// )?;
3455    /// # Ok(())
3456    /// # }
3457    /// ```
3458    ///
3459    /// The parameters go in the request's parameters, not its body: the
3460    /// cluster's registry declares this command's input as `null`, whatever the
3461    /// command reference says. It answers with an empty body rather than the
3462    /// `{}` its neighbours send.
3463    ///
3464    /// Repeated freely, because it assigns rather than increments: sending the
3465    /// same update twice leaves the operation where the first one put it. As
3466    /// with [`Client::suspend_operation`], that holds only while the scheduler
3467    /// still has the operation — if the answer to the first send is lost and
3468    /// the operation ends during the backoff, the retry is answered `No such
3469    /// operation` and this returns an error for an update that was applied.
3470    ///
3471    /// # Errors
3472    ///
3473    /// Returns [`ClientError::Config`] if `parameters` would change nothing —
3474    /// the cluster accepts an empty update and does nothing, which hides the
3475    /// mistake where it was made — and [`ClientError`] if the request fails.
3476    pub fn update_operation_parameters(
3477        &self,
3478        id: &str,
3479        parameters: &OperationParameters,
3480    ) -> Result<()> {
3481        if parameters.is_empty() {
3482            return Err(ClientError::Config(
3483                "update_operation_parameters was given nothing to change; the \
3484                 cluster answers 200 and does nothing, so this is refused here \
3485                 instead"
3486                    .to_owned(),
3487            ));
3488        }
3489
3490        let params = yson_build::map([
3491            ("operation_id", yson_build::string(id)),
3492            ("parameters", parameters.to_yson()),
3493        ]);
3494        self.transport.call(
3495            Method::Post,
3496            "update_operation_parameters",
3497            &params,
3498            Payload::None,
3499            Repeatable::Freely,
3500        )?;
3501        Ok(())
3502    }
3503
3504    /// Lists operations the cluster knows about.
3505    ///
3506    /// ```no_run
3507    /// # use ytsaurus_client::{Client, OperationFilter};
3508    /// # fn main() -> Result<(), ytsaurus_client::ClientError> {
3509    /// # let client = Client::from_env()?;
3510    /// let mine = client.list_operations(
3511    ///     &OperationFilter::new().with_user("robot-loader").with_state("running"),
3512    /// )?;
3513    ///
3514    /// for operation in &mine.operations {
3515    ///     println!("{} {} {}", operation.id, operation.kind, operation.state);
3516    /// }
3517    /// # Ok(())
3518    /// # }
3519    /// ```
3520    ///
3521    /// The scheduler only holds operations it has not let go of. Anything older
3522    /// lives in the operations archive, which
3523    /// [`OperationFilter::with_archive`] asks for — and which a local cluster
3524    /// does not have.
3525    ///
3526    /// # Errors
3527    ///
3528    /// Returns [`ClientError`] if the request fails or the response cannot be
3529    /// decoded.
3530    pub fn list_operations(&self, filter: &OperationFilter) -> Result<OperationList> {
3531        let body = self.transport.call(
3532            Method::Get,
3533            "list_operations",
3534            &filter.to_yson(),
3535            Payload::None,
3536            Repeatable::Freely,
3537        )?;
3538
3539        // No `{value=…}` envelope, and no one-key envelope either: the answer
3540        // is a dict of `operations` plus counters, which is why this reads the
3541        // document rather than unwrapping it.
3542        operation::parse_operations(&self.strip_envelope(&body, "list_operations")?)
3543    }
3544
3545    /// An operation's event log.
3546    ///
3547    /// **Empty on a cluster with no operations archive.** The command is
3548    /// registered everywhere and answers with an empty list there, rather than
3549    /// with an error — verified on a local cluster, where it is always empty.
3550    ///
3551    /// # Errors
3552    ///
3553    /// Returns [`ClientError`] if the request fails or the response cannot be
3554    /// decoded.
3555    pub fn list_operation_events(&self, id: &str) -> Result<Vec<OperationEvent>> {
3556        let params = yson_build::map([("operation_id", yson_build::string(id))]);
3557        let body = self.transport.call(
3558            Method::Get,
3559            "list_operation_events",
3560            &params,
3561            Payload::None,
3562            Repeatable::Freely,
3563        )?;
3564
3565        // A bare list, with none of the one-key envelope the rest of API v4
3566        // uses — the same surprise the file-cache commands hold. An envelope
3567        // is read too; see `operation::parse_events` for why that is not
3568        // over-caution.
3569        operation::parse_events(&self.strip_envelope(&body, "list_operation_events")?)
3570    }
3571
3572    /// A handle on an operation that is already running.
3573    ///
3574    /// The reattach door — C++'s `AttachOperation`, Go's `Track(id)`. Nothing is
3575    /// sent: an id and a client is all an [`Operation`] is, so this cannot fail
3576    /// and does not check that the operation exists. The first command through
3577    /// the handle finds that out.
3578    ///
3579    /// ```no_run
3580    /// # use ytsaurus_client::Client;
3581    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
3582    /// # let client = Client::from_env()?;
3583    /// // A supervisor restarts and picks up where it left off.
3584    /// let op = client.attach_operation(std::fs::read_to_string("run.id")?);
3585    /// op.wait()?;
3586    /// # Ok(())
3587    /// # }
3588    /// ```
3589    ///
3590    /// **The id is trimmed**, for the reason the token file is: the documented
3591    /// way to get one here is out of a file, `echo $ID > run.id` writes a
3592    /// newline, and an id carrying one is answered `No such operation` by an
3593    /// error that never mentions whitespace.
3594    #[must_use]
3595    pub fn attach_operation(&self, id: impl Into<String>) -> Operation {
3596        let mut id = id.into();
3597        if id.trim().len() != id.len() {
3598            id = id.trim().to_owned();
3599        }
3600        Operation::new(self.clone(), id)
3601    }
3602
3603    /// The whole document the cluster keeps about an operation.
3604    ///
3605    /// `attributes` names what to fetch — `state`, `progress`, `result`,
3606    /// `runtime_parameters`, `spec`. **An empty slice asks for everything**,
3607    /// which is rarely what anyone wants: the full document for a trivial
3608    /// vanilla operation measured 119 KB on a local cluster, most of it the
3609    /// resolved spec and the progress tree. Naming attributes is the normal
3610    /// case, and the narrow readers — [`Client::operation_state`],
3611    /// [`Client::job_statistics`], [`Client::operation_result_error`] — are each
3612    /// one attribute of this.
3613    ///
3614    /// ```no_run
3615    /// # use ytsaurus_client::Client;
3616    /// # fn main() -> Result<(), ytsaurus_client::ClientError> {
3617    /// # let client = Client::from_env()?;
3618    /// # let id = String::new();
3619    /// let doc = client.get_operation(&id, &["state", "start_time", "suspended"])?;
3620    /// # Ok(())
3621    /// # }
3622    /// ```
3623    ///
3624    /// # Errors
3625    ///
3626    /// Returns [`ClientError`] if the request fails or the answer cannot be
3627    /// decoded.
3628    pub fn get_operation(&self, id: &str, attributes: &[&str]) -> Result<YsonValue> {
3629        self.get_operation_inner(
3630            yson_build::map([("operation_id", yson_build::string(id))]),
3631            attributes,
3632        )
3633    }
3634
3635    /// The same, for an operation found by the alias its spec gave it.
3636    ///
3637    /// An alias is a name a launcher chooses — `*nightly-load` — set in the
3638    /// spec's `alias` field, and the leading `*` is the cluster's requirement,
3639    /// not this crate's. Without it, an alias set at launch could never be
3640    /// looked up again.
3641    ///
3642    /// The request carries `include_runtime`, because the cluster refuses the
3643    /// lookup without it: *"Operation alias cannot be resolved without using
3644    /// runtime information"*. That also bounds what this can find — an alias is
3645    /// resolved from what the scheduler still holds, falling back to the
3646    /// operations archive, so an alias whose operation finished long ago is
3647    /// found only on an installation that has an archive.
3648    ///
3649    /// # Errors
3650    ///
3651    /// Returns [`ClientError`] if the request fails — including when no
3652    /// operation has that alias — or if the answer cannot be decoded.
3653    pub fn get_operation_by_alias(&self, alias: &str, attributes: &[&str]) -> Result<YsonValue> {
3654        self.get_operation_inner(
3655            yson_build::map([
3656                ("operation_alias", yson_build::string(alias)),
3657                ("include_runtime", yson_build::boolean(true)),
3658            ]),
3659            attributes,
3660        )
3661    }
3662
3663    fn get_operation_inner(&self, params: YsonValue, attributes: &[&str]) -> Result<YsonValue> {
3664        let body = self.get_operation_body(params, attributes)?;
3665        self.strip_envelope(&body, "get_operation")
3666    }
3667
3668    /// The bytes of a `get_operation` answer, before they are parsed.
3669    ///
3670    /// Split out for [`Client::operation_error`], which reports the raw body
3671    /// when it cannot be parsed — the one caller for which a decode failure is
3672    /// not the end of the story.
3673    fn get_operation_body(&self, mut params: YsonValue, attributes: &[&str]) -> Result<Vec<u8>> {
3674        // Omitted rather than sent empty: `attributes=[]` is a request for no
3675        // attributes at all, and the cluster answers `{}` to it. Leaving the
3676        // parameter out is how the whole document is asked for.
3677        if !attributes.is_empty() {
3678            yson_build::insert(
3679                &mut params,
3680                "attributes",
3681                yson_build::list(attributes.iter().map(yson_build::string)),
3682            );
3683        }
3684
3685        self.transport.call(
3686            Method::Get,
3687            "get_operation",
3688            &params,
3689            Payload::None,
3690            Repeatable::Freely,
3691        )
3692    }
3693
3694    /// Fetches an operation's current state, e.g. `running` or `completed`.
3695    ///
3696    /// **A suspended operation still reports `running`.** See
3697    /// [`Client::operation_suspended`], or [`Client::operation_status`] for
3698    /// both in one request.
3699    ///
3700    /// # Errors
3701    ///
3702    /// Returns [`ClientError`] if the request fails.
3703    pub fn operation_state(&self, id: &str) -> Result<String> {
3704        operation::state_of(&self.get_operation(id, &["state"])?)
3705    }
3706
3707    /// Whether an operation is paused.
3708    ///
3709    /// The question [`Client::operation_state`] does not answer: the cluster
3710    /// keeps suspension in its own attribute and leaves the state at `running`,
3711    /// so a loop that watches the state alone will wait out a paused operation
3712    /// without ever saying why.
3713    ///
3714    /// **An operation whose document does not carry the attribute is not
3715    /// suspended**, rather than an error: the scheduler reports it for what it
3716    /// still holds, and one resolved out of the operations archive may not
3717    /// carry it at all.
3718    ///
3719    /// # Errors
3720    ///
3721    /// Returns [`ClientError`] if the request fails, or if the attribute is
3722    /// there and is not a boolean.
3723    pub fn operation_suspended(&self, id: &str) -> Result<bool> {
3724        operation::suspended_of(&self.get_operation(id, &["suspended"])?)
3725    }
3726
3727    /// An operation's state and whether it is paused, in one request.
3728    ///
3729    /// The pair a poll loop actually needs. Asking them separately is two
3730    /// round trips for two attributes of one document, and a loop that asks
3731    /// only for the state cannot tell a running operation from a paused one —
3732    /// they both say `running`.
3733    ///
3734    /// ```no_run
3735    /// # use ytsaurus_client::Client;
3736    /// # fn main() -> Result<(), ytsaurus_client::ClientError> {
3737    /// # let client = Client::from_env()?;
3738    /// # let id = String::new();
3739    /// let status = client.operation_status(&id)?;
3740    /// if status.suspended {
3741    ///     println!("paused — it will sit at {} until it is resumed", status.state);
3742    /// }
3743    /// # Ok(())
3744    /// # }
3745    /// ```
3746    ///
3747    /// # Errors
3748    ///
3749    /// Returns [`ClientError`] if the request fails or the answer cannot be
3750    /// decoded.
3751    pub fn operation_status(&self, id: &str) -> Result<OperationStatus> {
3752        let document = self.get_operation(id, &["state", "suspended"])?;
3753        Ok(OperationStatus {
3754            state: operation::state_of(&document)?,
3755            suspended: operation::suspended_of(&document)?,
3756        })
3757    }
3758
3759    /// The custom statistics an operation's jobs reported.
3760    ///
3761    /// Returns the `custom` subtree of the operation's job statistics, keyed by
3762    /// the names the jobs used. Each leaf is an aggregate — `sum`, `count`,
3763    /// `min`, `max` — over the jobs that reported it, so a per-row counter
3764    /// comes back as one number for the whole operation.
3765    /// [`Client::statistic_sum`] pulls a single total out of it.
3766    ///
3767    /// Empty if no job reported anything.
3768    ///
3769    /// # Errors
3770    ///
3771    /// Returns [`ClientError`] if the request fails.
3772    pub fn custom_statistics(&self, operation_id: &str) -> Result<YsonValue> {
3773        let all = self.job_statistics(operation_id)?;
3774        Ok(jobs::field(&all, "custom").cloned().unwrap_or(YsonValue {
3775            attributes: None,
3776            node: YsonNode::Map(std::collections::BTreeMap::new()),
3777        }))
3778    }
3779
3780    /// Everything the scheduler recorded about an operation's jobs.
3781    ///
3782    /// The whole `job_statistics` tree, custom and built-in alike.
3783    /// [`Client::job_statistic_sum`] is the way to read one number out of it;
3784    /// this is for looking around, which is how anyone finds out what a cluster
3785    /// actually reports.
3786    ///
3787    /// # Errors
3788    ///
3789    /// Returns [`ClientError`] if the request fails.
3790    pub fn job_statistics(&self, operation_id: &str) -> Result<YsonValue> {
3791        Ok(operation::statistics_of(
3792            &self.get_operation(operation_id, &["progress"])?,
3793        ))
3794    }
3795
3796    /// The total of one **built-in** job statistic, e.g. `time/exec`.
3797    ///
3798    /// The cluster's own statistics **nest** by path component, where a custom
3799    /// name keeps its slash as one key — the two are stored differently, which
3800    /// is why they are read differently:
3801    ///
3802    /// ```text
3803    /// custom:    {"rows/rejected" = {"$"  = {completed = {map = {sum=3}}}}}
3804    /// built-in:  {time = {exec    = {"$$" = {completed = {map = {sum=744}}}}}}
3805    /// ```
3806    ///
3807    /// Note the separator differs too — `$$` rather than `$`. Both are
3808    /// accepted here, because that difference is not something a caller should
3809    /// have to know.
3810    ///
3811    /// Totalled over `completed` jobs across job types, as
3812    /// [`Client::statistic_sum`] does, and `None` when the cluster reports
3813    /// nothing under that path — which is not the same as zero. A local cluster
3814    /// reports nothing under `user_job/cpu`, for instance.
3815    ///
3816    /// # Errors
3817    ///
3818    /// Returns [`ClientError`] if the request fails.
3819    pub fn job_statistic_sum(&self, operation_id: &str, path: &str) -> Result<Option<i64>> {
3820        let statistics = self.job_statistics(operation_id)?;
3821
3822        let mut node = &statistics;
3823        for component in path.split('/') {
3824            match jobs::field(node, component) {
3825                Some(next) => node = next,
3826                None => return Ok(None),
3827            }
3828        }
3829        Ok(completed_total(node))
3830    }
3831
3832    /// The total of one custom statistic over an operation's completed jobs.
3833    ///
3834    /// `name` is exactly what the job called it, slashes included: the cluster
3835    /// keeps `rows/rejected` as one key rather than nesting it.
3836    ///
3837    /// Only `completed` jobs are counted. An aborted job's work is done again
3838    /// by its replacement, so including it would count the same rows twice.
3839    /// Job *types* are summed together, so a map-reduce reporting one name from
3840    /// both phases gives the operation's total.
3841    ///
3842    /// `None` means no job reported that name — which is not the same as zero.
3843    ///
3844    /// # Errors
3845    ///
3846    /// Returns [`ClientError`] if the request fails.
3847    pub fn statistic_sum(&self, operation_id: &str, name: &str) -> Result<Option<i64>> {
3848        let statistics = self.custom_statistics(operation_id)?;
3849        Ok(jobs::field(&statistics, name).and_then(completed_total))
3850    }
3851
3852    /// Polls until the operation reaches a terminal state.
3853    ///
3854    /// **A suspended operation never reaches one**, and this says so rather
3855    /// than sitting there: suspension is not a state, so a paused operation
3856    /// goes on answering `running` for as long as it is paused. The progress
3857    /// line reports it, which is the difference between a wait that looks hung
3858    /// and one that names what it is waiting for. Resuming it — from another
3859    /// process, or from the one that paused it — is what ends the wait.
3860    ///
3861    /// # Errors
3862    ///
3863    /// Returns [`ClientError::OperationFailed`] if it ends as anything other
3864    /// than `completed`, or [`ClientError`] if polling itself fails.
3865    pub fn wait_for_operation(&self, id: &str) -> Result<()> {
3866        let started = Instant::now();
3867        let mut last_reported = String::new();
3868
3869        loop {
3870            // Both attributes, in one request: a loop that watched the state
3871            // alone could not tell a paused operation from a running one, and
3872            // waiting for a resume that nobody knows is needed is the failure
3873            // this whole pair of readers exists to prevent.
3874            let OperationStatus { state, suspended } = self.operation_status(id)?;
3875
3876            let reported = if suspended {
3877                format!("{state}, suspended")
3878            } else {
3879                state.clone()
3880            };
3881            if reported != last_reported {
3882                eprintln!(
3883                    "operation {id}: {reported} ({:.0}s)",
3884                    started.elapsed().as_secs_f64()
3885                );
3886                last_reported = reported;
3887            }
3888
3889            match state.as_str() {
3890                "completed" => return Ok(()),
3891                "failed" | "aborted" => {
3892                    // The diagnostics go through a client that does not retry.
3893                    // Up to four more requests are about to be sent to explain
3894                    // a failure the caller already knows about, and an
3895                    // unhealthy cluster is exactly when they fail: under the
3896                    // default policy `list_jobs` alone can spend ten minutes on
3897                    // backoff before giving up, and every step here is
3898                    // best-effort, so the wait buys nothing but a program that
3899                    // looks hung after the operation has already ended.
3900                    let quick = self.without_retries();
3901                    return Err(ClientError::OperationFailed {
3902                        id: id.to_owned(),
3903                        state,
3904                        error: quick.operation_error(id),
3905                        jobs: quick.failed_jobs(id),
3906                    });
3907                }
3908                _ => std::thread::sleep(self.poll_interval),
3909            }
3910        }
3911    }
3912
3913    /// Why an operation ended as it did, in the cluster's words.
3914    ///
3915    /// `None` for one that succeeded, and for one that has not finished. This
3916    /// is what [`ClientError::OperationFailed`] carries, and what reads back
3917    /// the `reason` given to [`Client::abort_operation`]: the reason is folded
3918    /// into the operation's error document rather than kept beside it, so this
3919    /// is how to find out who stopped an operation and why.
3920    ///
3921    /// Flattened to the outer message plus the innermost one, because the outer
3922    /// message of a YTsaurus error is a category and the cause is at the bottom.
3923    ///
3924    /// # Errors
3925    ///
3926    /// Returns [`ClientError`] if the operation cannot be looked up, or if its
3927    /// answer cannot be decoded.
3928    pub fn operation_result_error(&self, id: &str) -> Result<Option<String>> {
3929        // Asked for through `get_operation`, not through Cypress: an operation
3930        // is not a node under //sys/operations on every cluster, and a local
3931        // one answers `has no child with key` for an id that certainly exists.
3932        Ok(operation::result_error_of(
3933            &self.get_operation(id, &["result"])?,
3934        ))
3935    }
3936
3937    /// Best-effort fetch of a failed operation's error document.
3938    ///
3939    /// Prefers the flattened message. Falls back to the raw document, because a
3940    /// clumsy error still beats an empty one if the response shape ever moves.
3941    ///
3942    /// Used while building [`ClientError::OperationFailed`], where a failure to
3943    /// fetch must never replace the failure being reported — which is why this
3944    /// swallows errors and [`Client::operation_result_error`], which has a
3945    /// caller to answer to, does not.
3946    fn operation_error(&self, id: &str) -> Option<String> {
3947        // The raw body, not the parsed document: the fallback below is for the
3948        // case where the shape moved, and a body that does not parse at all —
3949        // an HTML page from an intermediary, a truncated stream — is the
3950        // farthest it can move. Parsing first would throw away the only
3951        // evidence in exactly the case the fallback exists for.
3952        let body = self
3953            .get_operation_body(
3954                yson_build::map([("operation_id", yson_build::string(id))]),
3955                &["result"],
3956            )
3957            .ok()?;
3958
3959        let summary = self
3960            .strip_envelope(&body, "get_operation")
3961            .ok()
3962            .and_then(|document| {
3963                jobs::field(&document, "result")
3964                    .and_then(|result| jobs::error_summary(jobs::field(result, "error")?))
3965            });
3966
3967        // Whatever the cluster said, rather than nothing: a clumsy error beats
3968        // an empty one if the response shape ever moves.
3969        summary.or_else(|| Some(crate::error::truncate(&String::from_utf8_lossy(&body), 600)))
3970    }
3971
3972    // ---------------------------------------------------------------- jobs
3973
3974    /// Lists an operation's jobs.
3975    ///
3976    /// `state` filters by job state — `failed`, `completed`, `running`, … — and
3977    /// `limit` caps how many come back.
3978    ///
3979    /// The YTsaurus documentation warns that `list_jobs` can put significant
3980    /// load on a cluster and asks that it not be part of a workflow without an
3981    /// administrator's approval. This client calls it once per failed
3982    /// operation, with a small limit; keep to that shape.
3983    ///
3984    /// # Errors
3985    ///
3986    /// Returns [`ClientError`] if the request fails or the response is not the
3987    /// documented `{jobs=[…]}`.
3988    pub fn list_jobs(
3989        &self,
3990        operation_id: &str,
3991        state: Option<&str>,
3992        limit: u32,
3993    ) -> Result<Vec<JobInfo>> {
3994        let mut params = yson_build::map([
3995            ("operation_id", yson_build::string(operation_id)),
3996            ("limit", yson_build::int(i64::from(limit))),
3997        ]);
3998        if let Some(state) = state {
3999            yson_build::insert(&mut params, "state", yson_build::string(state));
4000        }
4001
4002        let body = self.transport.call(
4003            Method::Get,
4004            "list_jobs",
4005            &params,
4006            Payload::None,
4007            Repeatable::Freely,
4008        )?;
4009
4010        let envelope = self.strip_envelope(&body, "list_jobs")?;
4011        Ok(jobs::parse_jobs(&self.field_of(&envelope, "jobs")?))
4012    }
4013
4014    /// Fetches one job of an operation.
4015    ///
4016    /// What [`Client::list_jobs`] reports for a job it lists, asked for by id —
4017    /// and the way to look at a job whose id came from somewhere else, a log
4018    /// line or the web interface, without listing every job of the operation.
4019    ///
4020    /// The cluster answers with the job document **unwrapped**, and calls the id
4021    /// `job_id` where `list_jobs` calls it `id`; both are read here, so the
4022    /// [`JobInfo`] that comes back is the same shape either way.
4023    ///
4024    /// # Errors
4025    ///
4026    /// Returns [`ClientError`] if the request fails, or if the answer names no
4027    /// job — which is what an unknown job id looks like.
4028    pub fn get_job(&self, operation_id: &str, job_id: &str) -> Result<JobInfo> {
4029        let params = yson_build::map([
4030            ("operation_id", yson_build::string(operation_id)),
4031            ("job_id", yson_build::string(job_id)),
4032        ]);
4033        let body = self.transport.call(
4034            Method::Get,
4035            "get_job",
4036            &params,
4037            Payload::None,
4038            Repeatable::Freely,
4039        )?;
4040
4041        let document = self.strip_envelope(&body, "get_job")?;
4042        jobs::parse_job(&document).ok_or_else(|| ClientError::Decode {
4043            command: "get_job".to_owned(),
4044            reason: "the answer names no job".to_owned(),
4045        })
4046    }
4047
4048    /// Streams the input a job was given.
4049    ///
4050    /// The rows the cluster fed to that one job, in the format its spec asked
4051    /// for — which is how a job that failed on one row is reproduced on a
4052    /// desk rather than on the cluster.
4053    ///
4054    /// This is a *heavy* command whose answer is the data, so it streams:
4055    /// nothing here holds the job's input, and on an installation that
4056    /// separates light and heavy proxies it is sent to the heavy one.
4057    ///
4058    /// **A job with no input never answers.** Measured against a local cluster:
4059    /// the request for a vanilla job's input sat for 30 seconds without a byte.
4060    /// A vanilla operation has no input tables, so there is nothing for the
4061    /// cluster to send and it does not say so; ask this only of a job that reads
4062    /// something.
4063    ///
4064    /// # Errors
4065    ///
4066    /// Returns [`ClientError`] if the request fails. Failures *during* the read
4067    /// arrive from the reader, for the reason [`ResponseReader`] describes.
4068    pub fn get_job_input(&self, operation_id: &str, job_id: &str) -> Result<ResponseReader> {
4069        let params = yson_build::map([
4070            ("operation_id", yson_build::string(operation_id)),
4071            ("job_id", yson_build::string(job_id)),
4072        ]);
4073        let body = self.transport.open(Method::Get, "get_job_input", &params)?;
4074        Ok(ResponseReader::new(body))
4075    }
4076
4077    /// Fetches what a job wrote to stderr.
4078    ///
4079    /// Returns raw bytes: stderr is whatever the process wrote, not necessarily
4080    /// UTF-8. Empty if the cluster saved nothing — stderr is kept for failed
4081    /// jobs and, when the spec asks for it, for successful ones.
4082    ///
4083    /// This is a *heavy* command, so on an installation that separates light
4084    /// and heavy proxies it goes to the heavy one, like a table read.
4085    ///
4086    /// # Errors
4087    ///
4088    /// Returns [`ClientError`] if the request fails.
4089    pub fn get_job_stderr(&self, operation_id: &str, job_id: &str) -> Result<Vec<u8>> {
4090        let params = yson_build::map([
4091            ("operation_id", yson_build::string(operation_id)),
4092            ("job_id", yson_build::string(job_id)),
4093        ]);
4094        self.transport.call(
4095            Method::Get,
4096            "get_job_stderr",
4097            &params,
4098            Payload::None,
4099            Repeatable::Heavy,
4100        )
4101    }
4102
4103    /// Best-effort report of why an operation's jobs failed.
4104    ///
4105    /// Every step here may fail quietly. This runs while an error is being
4106    /// built, and a diagnostic that replaces the failure it was explaining is
4107    /// worse than no diagnostic at all.
4108    fn failed_jobs(&self, operation_id: &str) -> Vec<JobFailure> {
4109        if !self.job_diagnostics {
4110            return Vec::new();
4111        }
4112
4113        self.list_jobs(operation_id, Some("failed"), REPORTED_JOBS)
4114            .unwrap_or_default()
4115            .iter()
4116            .take(REPORTED_JOBS as usize)
4117            .map(|job| JobFailure {
4118                id: job.id.clone(),
4119                address: job.address.clone(),
4120                error: job.error.clone(),
4121                stderr: self.stderr_excerpt(operation_id, job),
4122            })
4123            .collect()
4124    }
4125
4126    /// The tail of a job's stderr, bounded and decoded lossily.
4127    ///
4128    /// Asks unconditionally rather than skipping jobs whose `stderr_size` is
4129    /// zero: the local cluster reported `1` for a job whose stderr was several
4130    /// hundred bytes, so the field cannot be trusted to mean "nothing to
4131    /// fetch". One request against losing the whole diagnostic is a good trade
4132    /// on a path that only runs when an operation has already failed.
4133    fn stderr_excerpt(&self, operation_id: &str, job: &JobInfo) -> Option<String> {
4134        let raw = self.get_job_stderr(operation_id, &job.id).ok()?;
4135        if raw.is_empty() {
4136            return None;
4137        }
4138        Some(crate::error::tail(
4139            &String::from_utf8_lossy(&raw),
4140            STDERR_EXCERPT,
4141        ))
4142    }
4143
4144    // ------------------------------------------------------------------ raw
4145
4146    /// Sends a command this crate does not model, and hands back the answer.
4147    ///
4148    /// Every other method here is a command the crate has an opinion about:
4149    /// parameters built for you, the response decoded into a type. This is the
4150    /// door to the rest of API v4 — the commands this crate has not grown yet,
4151    /// and the ones it never will. It is the same door
4152    /// [`Client::start_operation`] opens for a hand-built spec, widened from
4153    /// one command to all of them, and it means the answer to "can I do X
4154    /// against my cluster?" stops being "fork the crate".
4155    ///
4156    /// `params` is the `X-YT-Parameters` dict — build it with [`yson_build`].
4157    /// `payload` is the request body, for a command that takes one. What comes
4158    /// back is the response body, exactly as the proxy sent it; API v4 wraps a
4159    /// structured answer in a one-key dict, so most commands answer
4160    /// `{key=…}` in text YSON.
4161    ///
4162    /// ```no_run
4163    /// # use ytsaurus_client::{Client, Method, yson_build};
4164    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
4165    /// let client = Client::from_env()?;
4166    ///
4167    /// // `get_supported_features` is not modelled here and takes no
4168    /// // parameters. It answers with what this cluster's build can do —
4169    /// // codecs, compression, primitive types — which is exactly the question
4170    /// // a crate that models a quarter of the API cannot answer for you.
4171    /// let body = client.raw_command(
4172    ///     Method::Get,
4173    ///     "get_supported_features",
4174    ///     &yson_build::empty_map(),
4175    ///     None,
4176    /// )?;
4177    ///
4178    /// println!("{}", String::from_utf8_lossy(&body));
4179    /// # Ok(())
4180    /// # }
4181    /// ```
4182    ///
4183    /// # What this still does for you
4184    ///
4185    /// Everything that is not about the command's meaning: the token, the
4186    /// timeout, TLS, the header encoding, the `X-YT-Error` check that turns a
4187    /// cluster failure into a [`ClientError::Cluster`] with the innermost
4188    /// message — and the client's transaction. A raw command is stamped with
4189    /// `transaction_id` like every other, so a command sent through
4190    /// [`Transaction`] is *in* that transaction rather than quietly outside it.
4191    /// The exceptions are the same: a command that names its own transaction
4192    /// keeps it, and the scheduler commands are not stamped at all.
4193    ///
4194    /// # What it does not
4195    ///
4196    /// **It is sent once, and to the configured address.** A command this crate
4197    /// does not model cannot be assumed non-mutating, and a retry that applied
4198    /// an unknown mutation twice would be a far worse failure than one lost to
4199    /// a flaky proxy — so the default is [`Repeatable::Never`] and the retry
4200    /// policy is ignored here, whatever it says.
4201    ///
4202    /// `Never` is the safe answer for *repeating*, and it is the wrong answer
4203    /// for *routing*: it sends the command to the address the client was
4204    /// configured with, which on an installation that separates proxy roles is
4205    /// a control proxy that will not serve a heavy one. A raw `write_file` sent
4206    /// this way is refused with `Control proxy may not serve heavy requests
4207    /// with input data`, and a raw `read_file` is answered with a 307 to a data
4208    /// proxy. [`Client::raw_command_with`] is where a caller who knows the
4209    /// command is heavy says [`Repeatable::Heavy`] and gets both halves of that
4210    /// answer at once.
4211    ///
4212    /// The streaming doors need no such care:
4213    /// [`Client::raw_command_streaming`] and [`Client::raw_command_upload`] are
4214    /// heavy by construction, because streaming *is* the heavy shape.
4215    ///
4216    /// Nor does it know the verb: see [`Method`] for the cluster's own rule for
4217    /// picking one.
4218    ///
4219    /// # Errors
4220    ///
4221    /// Returns [`ClientError::Config`] if `command` is not a bare command name,
4222    /// if `params` is not a YSON dict — every command's parameters are one, and
4223    /// the client adds to them — or if a body is passed with [`Method::Get`],
4224    /// which carries none, so it would be dropped in silence. Otherwise
4225    /// [`ClientError`] as any command fails.
4226    pub fn raw_command(
4227        &self,
4228        method: Method,
4229        command: &str,
4230        params: &YsonValue,
4231        payload: Option<&[u8]>,
4232    ) -> Result<Vec<u8>> {
4233        self.raw_command_with(method, command, params, payload, Repeatable::Never, None)
4234    }
4235
4236    /// As [`Client::raw_command`], saying how the command may be repeated.
4237    ///
4238    /// The judgement this needs is the cluster's, not a guess: a command
4239    /// declares whether it mutates and whether it is heavy, and [`Repeatable`]
4240    /// is how that reaches the retry policy. [`Repeatable::Freely`] for a read,
4241    /// [`Repeatable::WithMutationId`] for a light mutation the master's
4242    /// mutation cache covers, [`Repeatable::Heavy`] for one that moves table or
4243    /// file data — which also sends it to a proxy that will accept one —
4244    /// [`Repeatable::Never`] otherwise.
4245    ///
4246    /// "Light and mutating" is not by itself enough for a mutation ID: the
4247    /// cache lives in the master, and a command that goes to the **scheduler**
4248    /// is not covered by it. Verified for `abort_operation` — a second send of
4249    /// the same ID, flagged as a retry, is answered `No such operation` rather
4250    /// than with the first response, so the retry turns an abort that worked
4251    /// into an error the caller believes. Whether every scheduler command
4252    /// behaves that way was not checked; treat it as the working assumption
4253    /// and prefer `Never` when in doubt.
4254    ///
4255    /// `mutation_id` is for the guarantee a single process cannot give itself:
4256    /// persist it, and after a crash the same call is deduplicated against the
4257    /// one that already ran instead of applying twice. See [`MutationId`].
4258    ///
4259    /// An ID given here is stamped on the request **whatever `repeatable`
4260    /// says**, including under [`Repeatable::Never`] — the two answer different
4261    /// questions. `repeatable` decides whether *this* call may be sent twice;
4262    /// a mutation ID decides whether a *later* call, from a process that has
4263    /// since restarted, is recognised as the same mutation. A command that must
4264    /// not be retried in-process can still be worth making replayable across
4265    /// one, and this is how.
4266    ///
4267    /// # Errors
4268    ///
4269    /// As [`Client::raw_command`].
4270    pub fn raw_command_with(
4271        &self,
4272        method: Method,
4273        command: &str,
4274        params: &YsonValue,
4275        payload: Option<&[u8]>,
4276        repeatable: Repeatable,
4277        mutation_id: Option<&MutationId>,
4278    ) -> Result<Vec<u8>> {
4279        check_command_name(command)?;
4280        refuse_non_dict_parameters(command, params)?;
4281        refuse_body_on_get(method, command, payload.is_some())?;
4282
4283        let payload = match payload {
4284            Some(bytes) => Payload::Bytes(bytes),
4285            None => Payload::None,
4286        };
4287
4288        self.transport
4289            .call_with(method, command, params, payload, repeatable, mutation_id)
4290    }
4291
4292    /// Sends a command this crate does not model and hands back its response
4293    /// **unread**.
4294    ///
4295    /// For a command whose answer is the data — `read_blob_table`, anything
4296    /// the cluster declares heavy on the way out. [`Client::raw_command`]
4297    /// would put all of it in memory first, which for those is the thing worth
4298    /// avoiding.
4299    ///
4300    /// ```no_run
4301    /// # use ytsaurus_client::{Client, Method, yson_build};
4302    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
4303    /// # let client = Client::from_env()?;
4304    /// // `read_file` has a method now — `Client::read_file_streaming` is
4305    /// // this call with the parameters written down — and it stays as the
4306    /// // example because its wire shape is verified against a cluster, where
4307    /// // an unmodelled command's here would be a guess. The door sends any
4308    /// // command the same way.
4309    /// let mut file = client.raw_command_streaming(
4310    ///     Method::Get,
4311    ///     "read_file",
4312    ///     &yson_build::map([("path", yson_build::string("//tmp/worker"))]),
4313    /// )?;
4314    ///
4315    /// std::io::copy(&mut file, &mut std::fs::File::create("worker")?)?;
4316    /// # Ok(())
4317    /// # }
4318    /// ```
4319    ///
4320    /// Sent once, and never retried: this is the shape a heavy command takes,
4321    /// and the documentation is explicit that heavy commands are not repeated.
4322    /// It is also sent **to a heavy proxy**, for the same reason and without
4323    /// asking — a response that is the data is [`Repeatable::Heavy`] whatever
4324    /// the command turns out to be called. The request carries no body —
4325    /// [`Client::raw_command_upload`] is the other direction.
4326    ///
4327    /// The streaming timeout applies, so the transfer itself is not on the
4328    /// request clock; see [`Client::with_timeout`].
4329    ///
4330    /// # Errors
4331    ///
4332    /// Returns [`ClientError::Config`] if `command` is not a bare command name,
4333    /// and [`ClientError`] if the request fails. Failures *during* the read
4334    /// arrive from the reader, not from here — and a body cut short by a
4335    /// mid-stream failure ends quietly, for the reason [`ResponseReader`]
4336    /// describes.
4337    pub fn raw_command_streaming(
4338        &self,
4339        method: Method,
4340        command: &str,
4341        params: &YsonValue,
4342    ) -> Result<ResponseReader> {
4343        check_command_name(command)?;
4344        refuse_non_dict_parameters(command, params)?;
4345        let body = self.transport.open(method, command, params)?;
4346        Ok(ResponseReader::new(body))
4347    }
4348
4349    /// Sends a command this crate does not model, streaming its request body.
4350    ///
4351    /// The counterpart of [`Client::raw_command_streaming`], for a command that
4352    /// takes an input data stream — the PUT commands, in the cluster's own
4353    /// rule. `body` is read to its end and sent as it is read, so what is
4354    /// uploaded never has to fit in memory.
4355    ///
4356    /// This is one attempt and can never be more: a reader that has been
4357    /// consumed cannot be sent again. A transaction is what makes such a write
4358    /// safe to fail. And it goes to a heavy proxy, as
4359    /// [`Client::raw_command_streaming`] does and for the same reason.
4360    ///
4361    /// # Errors
4362    ///
4363    /// Returns [`ClientError::Config`] if `command` is not a bare command name,
4364    /// or if the verb is [`Method::Get`], which carries no body. Otherwise
4365    /// [`ClientError`] if the request fails, including when `body` itself fails
4366    /// to read.
4367    pub fn raw_command_upload(
4368        &self,
4369        method: Method,
4370        command: &str,
4371        params: &YsonValue,
4372        mut body: impl std::io::Read,
4373    ) -> Result<Vec<u8>> {
4374        check_command_name(command)?;
4375        refuse_non_dict_parameters(command, params)?;
4376        refuse_body_on_get(method, command, true)?;
4377        self.transport.upload(method, command, params, &mut body)
4378    }
4379
4380    // -------------------------------------------------------------- helpers
4381
4382    /// A copy of this client that sends each request once.
4383    ///
4384    /// For best-effort work — the diagnostics on a failed operation — where
4385    /// waiting out a backoff cannot improve the answer, and where the delay
4386    /// lands after the caller's real result is already decided.
4387    fn without_retries(&self) -> Self {
4388        self.clone().with_retries(RetryPolicy::none())
4389    }
4390
4391    /// API v4 wraps every structured response in a dict. Unwraps one level.
4392    fn strip_envelope(&self, body: &[u8], command: &str) -> Result<YsonValue> {
4393        from_slice(body, YsonFormat::Text).map_err(|e| ClientError::Decode {
4394            command: command.to_owned(),
4395            reason: format!(
4396                "{e}; body was {}",
4397                crate::error::truncate(&String::from_utf8_lossy(body), 200)
4398            ),
4399        })
4400    }
4401
4402    fn field_of(&self, value: &YsonValue, key: &str) -> Result<YsonValue> {
4403        match &value.node {
4404            YsonNode::Map(m) => m
4405                .get(key.as_bytes())
4406                .cloned()
4407                .ok_or_else(|| ClientError::Decode {
4408                    command: key.to_owned(),
4409                    reason: format!(
4410                        "response has no {key:?}; keys were {:?}",
4411                        m.keys()
4412                            .map(|k| String::from_utf8_lossy(k).into_owned())
4413                            .collect::<Vec<_>>()
4414                    ),
4415                }),
4416            other => Err(ClientError::Decode {
4417                command: key.to_owned(),
4418                reason: format!("expected a dict, got {other:?}"),
4419            }),
4420        }
4421    }
4422
4423    fn value_field(&self, body: &[u8], key: &str) -> Result<YsonValue> {
4424        let envelope = self.strip_envelope(body, key)?;
4425        self.field_of(&envelope, key)
4426    }
4427}
4428
4429/// A `create` inside the cache that was refused, or a `create` that failed.
4430///
4431/// Only ever called with the failure of one of the two creates
4432/// [`Client::upload_into_cache`] makes, both of which write into the cache
4433/// directory — which is what makes "denied" mean "no cache here" rather than
4434/// "denied something".
4435fn refused_or_reported(error: ClientError) -> Result<Cached> {
4436    if denied(&error, "create") {
4437        return Ok(Cached::Refused(error));
4438    }
4439    Err(error)
4440}
4441
4442/// Whether `error` is the cluster refusing `command` on ACL grounds.
4443///
4444/// Both halves matter, and dropping either is how this would come to swallow
4445/// something it should report. The code alone catches every `Access denied` a
4446/// launch can earn, including ones no fallback addresses; the command alone
4447/// catches a create that failed because the path is a table, or because
4448/// somebody else holds a lock — failures a second attempt elsewhere would not
4449/// fix and a caller needs to hear about.
4450///
4451/// The code is looked for **anywhere in the document**, as
4452/// [`retry::is_retriable`] and `transaction_is_gone` look for theirs: an outer
4453/// code is often a category — `Error resolving path`, `Request retries failed`
4454/// — with the reason nested under it. Every transcript of this failure seen so
4455/// far is flat, so the walk changes nothing that has been observed; it is here
4456/// because the flat reading is the one that silently stops working the day a
4457/// proxy wraps the answer, and a fallback that stopped firing would show up as
4458/// a launch that used to work.
4459fn denied(error: &ClientError, command: &str) -> bool {
4460    matches!(
4461        error,
4462        ClientError::Cluster {
4463            command: failed,
4464            code,
4465            raw,
4466            ..
4467        } if failed == command
4468            && (*code == ACCESS_DENIED || retry::raw_contains_code(raw, &[ACCESS_DENIED]))
4469    )
4470}
4471
4472/// Refuses a command name that would address something other than a command.
4473///
4474/// A name goes straight into `/api/v4/{command}`, and every modelled command
4475/// puts a literal there. The raw door takes one from a caller, so a name
4476/// carrying `/`, `?`, `#` or whitespace could reach a different path, append a
4477/// query string, or truncate the URL — none of which the caller would see,
4478/// because what came back would still be a plausible answer from *something*.
4479///
4480/// Command names in the driver's registry are lowercase words joined by
4481/// underscores, so this accepts a superset of them and nothing that changes the
4482/// shape of the URL. A name this refuses that a future cluster accepts is a
4483/// one-line change here; the reverse is a bug nobody can see.
4484fn check_command_name(command: &str) -> Result<()> {
4485    if command.is_empty() {
4486        return Err(ClientError::Config(
4487            "a raw command needs a command name, e.g. \"get_supported_features\"".to_owned(),
4488        ));
4489    }
4490
4491    if let Some(bad) = command
4492        .chars()
4493        .find(|c| !c.is_ascii_alphanumeric() && *c != '_')
4494    {
4495        return Err(ClientError::Config(format!(
4496            "{command:?} is not a command name: it contains {bad:?}, and the name \
4497             goes into the request path as it is. A command is a bare name like \
4498             \"get_supported_features\" — the path it acts on is a parameter."
4499        )));
4500    }
4501
4502    Ok(())
4503}
4504
4505/// Refuses parameters that are not a dict.
4506///
4507/// `X-YT-Parameters` is a dict on every command, including the ones that take
4508/// none — [`yson_build::empty_map`] is the spelling for those. The client also
4509/// *adds* to what it is given: a transaction id, a mutation id and its retry
4510/// flag are all inserted into the caller's parameters on the way out, and
4511/// inserting into a value that is not a dict panics. A caller who passes a list
4512/// or a string here has made a mistake the cluster would report in its own
4513/// words at best, and which would otherwise abort their process.
4514fn refuse_non_dict_parameters(command: &str, params: &YsonValue) -> Result<()> {
4515    if !matches!(params.node, YsonNode::Map(_)) {
4516        return Err(ClientError::Config(format!(
4517            "{command}: command parameters are a YSON dict, and this is a \
4518             {:?}. A command that takes no parameters sends `yson_build::empty_map()`.",
4519            params.node
4520        )));
4521    }
4522    Ok(())
4523}
4524
4525/// Refuses a request body on a verb that does not carry one.
4526///
4527/// `Transport::dispatch` sends a GET through `ureq`'s bodiless builder, which
4528/// is right — every GET command has an empty input stream by definition. A
4529/// caller who passes a payload anyway has picked the wrong verb, and the body
4530/// would otherwise be dropped without a word. See [`Method`] for the rule that
4531/// decides which verb a command wants.
4532fn refuse_body_on_get(method: Method, command: &str, has_body: bool) -> Result<()> {
4533    if has_body && matches!(method, Method::Get) {
4534        return Err(ClientError::Config(format!(
4535            "{command}: a GET carries no request body, so the payload would be \
4536             dropped in silence. A command with an input data stream is a PUT."
4537        )));
4538    }
4539    Ok(())
4540}
4541
4542/// One variable, as the process has it.
4543///
4544/// The whole of what [`Client::from_env`] adds to [`Client::from_lookup`], and
4545/// deliberately nothing else: trimming and the empty-is-unset rule live in
4546/// `from_lookup`, on the path every caller and every test takes.
4547fn environment_value(name: &str) -> Option<String> {
4548    std::env::var(name).ok()
4549}
4550
4551/// A bare cluster name completed by the suffix this machine was given.
4552///
4553/// A bare cluster name — `hume` — is the ordinary spelling at an installation
4554/// whose clusters all sit under one domain, and it is the one thing this client
4555/// could not take: `Transport::new` puts `https://` in front of whatever it is
4556/// handed, and `https://hume` resolves nowhere unless a resolver search list
4557/// happens to complete it. The Go SDK completes it in `yt/go/config.go` — no
4558/// colon, no dot, not `localhost`, then a suffix — and the same gate is used
4559/// here.
4560///
4561/// **The suffix is not compiled in.** Go's is, because that SDK ships with one
4562/// installation in mind; this client does not, so the suffix comes from
4563/// `YT_PROXY_SUFFIX` and there is no expansion at all without it. Leading and
4564/// trailing dots come off: `.yt.example.net`, `yt.example.net` and
4565/// `yt.example.net.` are all how a person writes one, and a trailing dot left
4566/// on would make a name that connects and then fails every domain comparison
4567/// in [`crate::Client::with_heavy_proxies_under`]'s neighbourhood.
4568///
4569/// The gate is what keeps it from touching anything else. A colon means a scheme
4570/// or a port — `http://localhost:8000` has both — a dot means a name that
4571/// already resolves or is meant to, and anything *carrying* `localhost` is this
4572/// machine whatever else is set. That last test is `contains`, exactly as Go
4573/// writes it, so a cluster genuinely named `mylocalhostcluster` is left alone;
4574/// spelling it out is the price of matching the gate this was ported from.
4575///
4576/// This also makes the label rule in `http::same_domain` reachable **without a
4577/// resolver search list**: the rule matches a dotless `YT_PROXY` as a label of
4578/// the discovered name, and until now the only way to have a dotless `YT_PROXY`
4579/// that connected at all was for the machine's DNS configuration to complete it.
4580fn expanded_proxy(proxy: &str, suffix: Option<&str>) -> String {
4581    let proxy = proxy.trim();
4582    let Some(suffix) = suffix else {
4583        return proxy.to_owned();
4584    };
4585
4586    if proxy.contains(':') || proxy.contains('.') || proxy.contains("localhost") {
4587        return proxy.to_owned();
4588    }
4589    format!("{proxy}.{}", suffix.trim_matches('.'))
4590}
4591
4592/// The domains out of `YT_HEAVY_PROXY_DOMAINS`.
4593///
4594/// Comma **or** whitespace: a list in a shell profile is written one way by
4595/// whoever thinks of it as a list and the other by whoever thinks of it as
4596/// arguments, and neither is worth an error message. Empty entries fall out
4597/// here, and [`Client::with_heavy_proxies_under`] drops anything left over.
4598fn split_domains(value: &str) -> Vec<String> {
4599    value
4600        .split([',', ' ', '\t', '\n'])
4601        .map(str::trim)
4602        .filter(|domain| !domain.is_empty())
4603        .map(str::to_owned)
4604        .collect()
4605}
4606
4607/// Whether a variable spells yes.
4608///
4609/// The three spellings a shell profile uses, without case. Anything else is
4610/// **not** a yes, including `0` and `false` — a flag this client cannot read is
4611/// a flag it has not been given, and guessing at `on`, `y` or `enabled` would
4612/// mean guessing at what `off`, `n` and `disabled` should do to a knob that is
4613/// already off.
4614fn truthy(value: &str) -> bool {
4615    matches!(
4616        value.trim().to_ascii_lowercase().as_str(),
4617        "1" | "true" | "yes"
4618    )
4619}
4620
4621/// Finds a token the way the `yt` CLI finds one.
4622///
4623/// `YT_TOKEN`, then `YT_TOKEN_PATH`, then `~/.yt/token` — first one that has
4624/// something in it wins. Nothing here fails: a cluster that wants no token is
4625/// ordinary, and so is a home directory with no `.yt` in it.
4626fn token_from_environment() -> Option<String> {
4627    if let Some(token) = std::env::var("YT_TOKEN").ok().and_then(clean_token) {
4628        return Some(token);
4629    }
4630
4631    if let Ok(path) = std::env::var("YT_TOKEN_PATH")
4632        && let Some(token) = read_token_file(std::path::Path::new(&path))
4633    {
4634        return Some(token);
4635    }
4636
4637    let home = std::env::var("HOME")
4638        .or_else(|_| std::env::var("USERPROFILE"))
4639        .ok()?;
4640    read_token_file(&std::path::Path::new(&home).join(".yt").join("token"))
4641}
4642
4643/// Reads a token out of a file, if there is one to read.
4644fn read_token_file(path: &std::path::Path) -> Option<String> {
4645    std::fs::read_to_string(path).ok().and_then(clean_token)
4646}
4647
4648/// A token with the whitespace taken off, or nothing if that leaves nothing.
4649///
4650/// The trailing newline is the point: `echo token > ~/.yt/token` writes one,
4651/// and a header carrying it fails authentication with an error that never
4652/// mentions the newline.
4653fn clean_token(raw: String) -> Option<String> {
4654    let trimmed = raw.trim();
4655    (!trimmed.is_empty()).then(|| trimmed.to_owned())
4656}
4657
4658/// Decodes a binary YSON list fragment into typed rows.
4659///
4660/// Shared by [`Client::read_table_rows`] and the tests that check what the row
4661/// encoder produced, so the two halves of the round trip are the same code.
4662fn decode_rows<T: serde::de::DeserializeOwned>(bytes: &[u8], path: &str) -> Result<Vec<T>> {
4663    let mut rows = Vec::new();
4664    let mut stream = ytsaurus_yson::StreamDeserializer::<T>::new(bytes, true);
4665
4666    loop {
4667        match stream.next_item() {
4668            Ok(Some(row)) => rows.push(row),
4669            Ok(None) => return Ok(rows),
4670            Err(e) => {
4671                return Err(ClientError::Decode {
4672                    command: "read_table".to_owned(),
4673                    reason: format!("{path}: row {}: {e}", rows.len()),
4674                });
4675            }
4676        }
4677    }
4678}
4679
4680/// Reads the child names out of a `list` answer.
4681///
4682/// A truncated answer is an error rather than a short list. The cluster says so
4683/// with `<incomplete=%true>` — an *attribute* on the list, not an error — and a
4684/// caller who does not look gets a listing that is quietly missing entries.
4685fn child_names(value: &YsonValue, path: &str) -> Result<Vec<String>> {
4686    if matches!(
4687        value.attr("incomplete").map(|v| &v.node),
4688        Some(YsonNode::Boolean(true))
4689    ) {
4690        return Err(ClientError::Decode {
4691            command: "list".to_owned(),
4692            reason: format!(
4693                "{path} has more children than the cluster would list at once, so the \
4694                 answer it gave is not all of them"
4695            ),
4696        });
4697    }
4698
4699    let YsonNode::List(items) = &value.node else {
4700        return Err(ClientError::Decode {
4701            command: "list".to_owned(),
4702            reason: format!("{path}: the answer is not a list: {:?}", value.node),
4703        });
4704    };
4705
4706    items
4707        .iter()
4708        .map(|item| match &item.node {
4709            YsonNode::String(bytes) => Ok(String::from_utf8_lossy(bytes).into_owned()),
4710            other => Err(ClientError::Decode {
4711                command: "list".to_owned(),
4712                reason: format!("{path}: a child name is not a string: {other:?}"),
4713            }),
4714        })
4715        .collect()
4716}
4717
4718/// Totals one custom statistic over the jobs that completed.
4719///
4720/// The cluster files a statistic as `$` → job state → job type → the
4721/// aggregate, so the number a user means by "how many rows did we reject" is
4722/// the `sum` of the `completed` jobs, added across job types. Captured from a
4723/// local cluster:
4724///
4725/// ```text
4726/// {"rows/rejected"={"$"={completed={map={count=1;max=3;min=3;sum=3}}}}}
4727/// ```
4728///
4729/// A flatter shape is accepted too, so a cluster that reports a bare aggregate
4730/// still yields a number rather than nothing.
4731fn completed_total(statistic: &YsonValue) -> Option<i64> {
4732    // `$` under a custom statistic, `$$` under a built-in one. The cluster
4733    // spells the same idea two ways depending on which tree you are in.
4734    let by_state = jobs::field(statistic, "$").or_else(|| jobs::field(statistic, "$$"));
4735    let Some(by_state) = by_state else {
4736        return jobs::field(statistic, "sum").and_then(YsonValue::as_i64);
4737    };
4738
4739    let completed = jobs::field(by_state, "completed")?;
4740    let YsonNode::Map(by_type) = &completed.node else {
4741        return None;
4742    };
4743
4744    let mut total: Option<i64> = None;
4745    for per_type in by_type.values() {
4746        if let Some(sum) = jobs::field(per_type, "sum").and_then(YsonValue::as_i64) {
4747            total = Some(total.unwrap_or(0) + sum);
4748        }
4749    }
4750    total
4751}
4752
4753/// Verifies that `data` is a whole binary YSON list fragment.
4754///
4755/// Walks record boundaries without decoding, so the cost is a scan rather than
4756/// a parse of the whole table.
4757#[cfg(test)]
4758fn check_complete_fragment(data: &[u8]) -> std::result::Result<(), String> {
4759    check_complete_yson_fragment(data, YsonFormat::Binary)
4760}
4761
4762/// Verifies that `data` is a whole YSON list fragment in `format`.
4763fn check_complete_yson_fragment(
4764    mut data: &[u8],
4765    format: YsonFormat,
4766) -> std::result::Result<(), String> {
4767    use ytsaurus_yson::{Scan, scan_value};
4768
4769    let total = data.len();
4770    loop {
4771        while data.first() == Some(&b';') || data.first().is_some_and(u8::is_ascii_whitespace) {
4772            data = &data[1..];
4773        }
4774        if data.is_empty() {
4775            return Ok(());
4776        }
4777
4778        match scan_value(data, format) {
4779            Ok(Scan::Complete { len }) => data = &data[len..],
4780            Ok(Scan::Incomplete) => {
4781                return Err(format!(
4782                    "the response ends inside a record — {} of {total} bytes consumed; \
4783                     the stream was cut short",
4784                    total - data.len()
4785                ));
4786            }
4787            Err(e) => {
4788                return Err(format!(
4789                    "the response is not valid {format:?} YSON at byte {}: {e}",
4790                    total - data.len()
4791                ));
4792            }
4793        }
4794    }
4795}
4796
4797fn unsupported_data_format() -> ClientError {
4798    ClientError::Config(
4799        "this ytsaurus-client version does not support the selected data format".to_owned(),
4800    )
4801}
4802
4803/// Builds the rich table path a direct Skiff table read/write requires.
4804///
4805/// The Go SDK derives this `columns` projection from the single table schema;
4806/// without it the positional tuple has no explicit column selection. Job I/O
4807/// differs: its format may have several schemas and uses the Variant16 table
4808/// prefix, so it is deliberately configured through operation specs instead.
4809///
4810/// The path's own attributes are kept: a Skiff write to an appending
4811/// [`TablePath`] has to append, exactly as the YSON one does.
4812/// Refuses a spec whose Skiff format does not describe the tables it will meet.
4813///
4814/// Refused here rather than sent, for the reason the duplicate-task check
4815/// above is: the cluster's answer to this is a rejected operation at best, and
4816/// at worst a job that reads a table its format does not describe and fails
4817/// part-way through, having already written output that now has to be cleaned
4818/// up.
4819fn refuse_skiff_table_mismatch(mismatch: Option<String>) -> Result<()> {
4820    match mismatch {
4821        Some(reason) => Err(ClientError::Config(reason)),
4822        None => Ok(()),
4823    }
4824}
4825
4826/// Refuses a write whose path carries a read selection, before it is sent.
4827///
4828/// The cluster ignores `columns` and `ranges` on a write and replaces the
4829/// whole table with a 200 — measured on a local cluster, where
4830/// `write_table_rows("//tmp/t[#0:#2]", rows)` replaced everything and
4831/// reported success. Refusing locally is the only version of this that the
4832/// caller ever hears about; the [rich YPath
4833/// reference](https://ytsaurus.tech/docs/en/user-guide/storage/ypath) agrees
4834/// on the scope, listing both attributes as recognized by the *read*
4835/// commands. The rule and the string-syntax half of it live on
4836/// [`TablePath`].
4837fn refuse_selection_on_write(path: &TablePath) -> Result<()> {
4838    match path.write_refusal() {
4839        Some(reason) => Err(ClientError::Config(reason)),
4840        None => Ok(()),
4841    }
4842}
4843
4844/// Refuses a read that spells the *same kind* of selection twice — once in
4845/// the path string, once through the typed API. Measured, the typed attribute
4846/// wins and the caller's string half is discarded at 200, so the filter they
4847/// wrote into the path simply never happens and nothing says so. Rows against
4848/// columns compose and are sent; a string opening with `<…>` is refused
4849/// because this client cannot parse the block to see which attribute it names.
4850fn refuse_mixed_selection_on_read(path: &TablePath) -> Result<()> {
4851    match path.read_refusal() {
4852        Some(reason) => Err(ClientError::Config(reason)),
4853        None => Ok(()),
4854    }
4855}
4856
4857fn skiff_table_path(path: &TablePath, format: &SkiffFormat) -> Result<YsonValue> {
4858    if path.selected_columns().is_some() {
4859        return Err(ClientError::Config(format!(
4860            "{}: a Skiff table read's columns are its format's fields, so \
4861             TablePath::columns cannot also apply — put the projection in the \
4862             Skiff schema, or read YSON",
4863            path.as_str()
4864        )));
4865    }
4866    // The same rule for the *string* spelling, which the typed check above
4867    // cannot see: this function synthesises a `columns` attribute out of the
4868    // format's fields whether the caller asked for one or not, so `//tmp/t{a}`
4869    // is a doubled column selection even though nothing typed was set.
4870    // Measured, the synthesised attribute wins — `<columns=[n]>"//tmp/t{k}"`
4871    // came back as column `n` — so the Skiff tuple stays aligned with its
4872    // schema and nothing is decoded wrong; what is lost is the caller's own
4873    // `{a}`, discarded at 200 with no mention. Only the *column* half is a
4874    // conflict: a string-spelled row range answers a different question and
4875    // composes, as `<columns=[n]>"//tmp/t[#0:#2]"` confirmed by returning rows
4876    // 0-1 carrying only `n`. A leading `<…>` is refused too, for the reason
4877    // `selection_conflict` documents — the block cannot be read from here.
4878    if let Some(reason) = path.selection_conflict(
4879        true,
4880        false,
4881        "the Skiff format's fields become",
4882        "the Skiff read adds",
4883    ) {
4884        return Err(ClientError::Config(reason));
4885    }
4886    if format.table_schemas().len() != 1 {
4887        return Err(ClientError::Config(format!(
4888            "Skiff table I/O requires exactly one table schema, got {}",
4889            format.table_schemas().len()
4890        )));
4891    }
4892    let schema = format.table_schema(0).map_err(|error| {
4893        ClientError::Config(format!(
4894            "Skiff table I/O has an invalid table schema: {error}"
4895        ))
4896    })?;
4897    let columns = schema
4898        .children
4899        .iter()
4900        .map(|column| {
4901            let name = column.name.as_deref().ok_or_else(|| {
4902                ClientError::Config("Skiff table I/O schema has an unnamed column".to_owned())
4903            })?;
4904            if matches!(name, "$key_switch" | "$row_index" | "$range_index") {
4905                return Err(ClientError::Config(format!(
4906                    "Skiff table I/O schema contains job-only system column {name}"
4907                )));
4908            }
4909            Ok(yson_build::string(name))
4910        })
4911        .collect::<Result<Vec<_>>>()?;
4912
4913    // The path renders its own attributes — append, and any row ranges —
4914    // and the format's field list joins them as `columns`. Ranges are rows,
4915    // columns are the tuple shape; they answer different questions and
4916    // combine freely.
4917    let mut value = path.to_yson();
4918    value
4919        .attributes
4920        .get_or_insert_with(std::collections::BTreeMap::new)
4921        .insert(b"columns".to_vec(), yson_build::list(columns));
4922    Ok(value)
4923}
4924
4925/// Checks that a returned or submitted Skiff stream is a whole number of rows.
4926///
4927/// Walks the rows without building them: `skip_row` applies the same framing,
4928/// schema and limit checks the decoder does — including the per-blob bound —
4929/// and allocates nothing. Decoding instead would build a `Value` tree for
4930/// every row of the caller's whole table only to drop it, which on the write
4931/// path is a second copy of the table in memory before the request is even
4932/// made. The YSON counterpart walks record boundaries the same way.
4933fn check_complete_skiff_stream(
4934    data: &[u8],
4935    format: &SkiffFormat,
4936) -> std::result::Result<(), String> {
4937    let mut decoder = SkiffDecoder::new(data, format.clone());
4938    while decoder
4939        .skip_row()
4940        .map_err(|error| format!("not a complete Skiff stream: {error}"))?
4941        .is_some()
4942    {}
4943    Ok(())
4944}
4945
4946#[cfg(test)]
4947mod tests {
4948    use std::{
4949        io::{Read, Write},
4950        net::{TcpListener, TcpStream},
4951        thread,
4952        time::Duration,
4953    };
4954
4955    use super::*;
4956    use ytsaurus_skiff::{Encoder as SkiffEncoder, Schema, SchemaRef, Value, WireType};
4957
4958    /// A real `get_operation` answer, captured from the local cluster for an
4959    /// operation that was completed early.
4960    const GET_OPERATION: &str = include_str!("../tests/fixtures/get_operation.yson");
4961
4962    /// The narrow readers are each one attribute of `get_operation`, and each
4963    /// assumes where that attribute sits. A response shape is a guess until
4964    /// something runs against a real answer, so this calls the readers
4965    /// themselves — the ones `operation_state`, `operation_suspended`,
4966    /// `operation_status` and `operation_result_error` are — on a document a
4967    /// cluster sent. Re-implementing the field access here instead would pass
4968    /// just as happily after a reader started looking somewhere else.
4969    ///
4970    /// Three of the four attributes: the capture does not include `progress`,
4971    /// so `job_statistics` is pinned separately below against a shape that is
4972    /// stated to be a guess rather than pretending otherwise.
4973    #[test]
4974    fn the_narrow_readers_agree_with_a_document_a_cluster_sent() {
4975        let document = from_slice(GET_OPERATION.as_bytes(), YsonFormat::Text).expect("valid YSON");
4976
4977        assert_eq!(
4978            operation::state_of(&document).expect("the capture carries a state"),
4979            "completed"
4980        );
4981        assert!(
4982            !operation::suspended_of(&document).expect("and a boolean beside it"),
4983            "suspension is read from its own attribute, not from the state"
4984        );
4985
4986        // The case `operation_result_error` exists to get right: an operation
4987        // that succeeded still has an error document, code 0 with an empty
4988        // message. Reporting that as `Some("")` would fire on every success.
4989        assert_eq!(
4990            operation::result_error_of(&document),
4991            None,
4992            "a completed operation's code-0 error document is not a failure"
4993        );
4994    }
4995
4996    /// The deepest of the four guesses — `progress` → `job_statistics` — and
4997    /// the one the captured document cannot pin, because it was fetched
4998    /// without `progress`. Written out here so the assumption is at least
4999    /// visible and breaks a test when the reader stops matching it.
5000    #[test]
5001    fn job_statistics_are_read_from_under_progress() {
5002        let document = from_slice(
5003            br#"{"progress"={"job_statistics"={"time"={"exec"={"$$"={"completed"={"map"={"sum"=744}}}}}}}}"#,
5004            YsonFormat::Text,
5005        )
5006        .expect("valid YSON");
5007
5008        let statistics = operation::statistics_of(&document);
5009        assert!(
5010            jobs::field(&statistics, "time").is_some(),
5011            "the subtree, not the progress node that holds it: {statistics:?}"
5012        );
5013
5014        // And the empty answer, which is what an operation that has not run a
5015        // job yet gives — distinct from a failure to find the attribute.
5016        let empty = from_slice(br#"{"progress"={}}"#, YsonFormat::Text).expect("valid YSON");
5017        assert!(matches!(
5018            operation::statistics_of(&empty).node,
5019            YsonNode::Map(ref m) if m.is_empty()
5020        ));
5021    }
5022
5023    /// The client inserts a transaction id, a mutation id and a retry flag
5024    /// into the parameters it is handed, and inserting into anything that is
5025    /// not a dict panics. A caller's mistake must be an error rather than the
5026    /// end of their process.
5027    #[test]
5028    fn raw_parameters_that_are_not_a_dict_are_refused() {
5029        let client = Client::new("http://localhost:8000").with_retries(RetryPolicy::none());
5030        let not_a_dict = yson_build::list([yson_build::string("get_supported_features")]);
5031
5032        let refused = client.raw_command(Method::Get, "get_supported_features", &not_a_dict, None);
5033        assert!(
5034            matches!(refused, Err(ClientError::Config(_))),
5035            "a list of parameters is a mistake to report, not to panic on"
5036        );
5037        assert!(refuse_non_dict_parameters("c", &yson_build::empty_map()).is_ok());
5038    }
5039
5040    /// An id that came out of a file the way the documentation shows keeps its
5041    /// newline, and the cluster answers a whitespace-carrying id with an error
5042    /// that never mentions whitespace.
5043    #[test]
5044    fn an_attached_id_is_trimmed() {
5045        let client = Client::new("http://localhost:8000");
5046        assert_eq!(client.attach_operation("1-2-3-4\n").id(), "1-2-3-4");
5047        assert_eq!(client.attach_operation("  1-2-3-4  ").id(), "1-2-3-4");
5048        assert_eq!(client.attach_operation("1-2-3-4").id(), "1-2-3-4");
5049    }
5050
5051    #[test]
5052    fn a_get_answer_decodes_straight_into_the_type_asked_for() {
5053        // What `get_as` does with the response body, without a cluster to ask.
5054        // The point of the envelope struct: one pass over the document, and
5055        // attributes the type does not mention are skipped rather than
5056        // collected — which is what makes `//@`, with dozens of them, worth
5057        // asking about at all.
5058        #[derive(serde::Deserialize)]
5059        struct Node {
5060            account: String,
5061            #[serde(rename = "type")]
5062            node_type: String,
5063        }
5064
5065        let body = br#"{"value"={"account"="tmp";"type"="table";"chunk_count"=3}}"#;
5066        let envelope: Envelope<Node> = from_slice(body, YsonFormat::Text).expect("decodes");
5067
5068        assert_eq!(envelope.value.account, "tmp");
5069        assert_eq!(envelope.value.node_type, "table");
5070    }
5071
5072    #[test]
5073    fn an_answer_that_does_not_fit_the_type_is_an_error_rather_than_a_default() {
5074        #[derive(serde::Deserialize)]
5075        struct Node {
5076            #[allow(dead_code)]
5077            account: String,
5078        }
5079
5080        // No `account` at all: silently defaulting it would hand the caller a
5081        // node that does not exist.
5082        let body = br#"{"value"={"type"="table"}}"#;
5083        assert!(from_slice::<Envelope<Node>>(body, YsonFormat::Text).is_err());
5084    }
5085
5086    #[test]
5087    fn a_complete_fragment_is_accepted() {
5088        // {a=1};{a=1}
5089        let one = b"{\x01\x02a=\x02\x02}";
5090        let mut two = one.to_vec();
5091        two.push(b';');
5092        two.extend_from_slice(one);
5093
5094        assert!(check_complete_fragment(b"").is_ok());
5095        assert!(check_complete_fragment(one).is_ok());
5096        assert!(check_complete_fragment(&two).is_ok());
5097    }
5098
5099    #[test]
5100    fn a_truncated_fragment_is_rejected() {
5101        let full = b"{\x01\x02a=\x02\x02}";
5102        for cut in 1..full.len() {
5103            let err = check_complete_fragment(&full[..cut])
5104                .expect_err("a cut record must not pass as complete");
5105            assert!(
5106                err.contains("cut short") || err.contains("not valid"),
5107                "{err}"
5108            );
5109        }
5110    }
5111
5112    fn skiff_format() -> SkiffFormat {
5113        SkiffFormat::new(vec![SchemaRef::Inline(Schema::tuple([
5114            Schema::named("found", WireType::Uint64),
5115            Schema::named("rcl", WireType::String32),
5116        ]))])
5117        .expect("a named tuple is a direct-table format")
5118    }
5119
5120    #[test]
5121    fn skiff_table_path_selects_schema_columns() {
5122        let value = skiff_table_path(&TablePath::from("//tmp/table"), &skiff_format()).unwrap();
5123        let rendered = ytsaurus_yson::to_string(&value, YsonFormat::Text).unwrap();
5124        assert_eq!(rendered, r#"<columns=[found;rcl]>"//tmp/table""#);
5125    }
5126
5127    #[test]
5128    fn a_skiff_path_refuses_a_column_selection_spelled_into_its_string() {
5129        // The branch's own invariant — one spelling of a selection per path —
5130        // has a hole here that it has nowhere else: this function
5131        // *synthesises* a `columns` attribute out of the format's fields, so
5132        // there is a second column selection whether the caller typed one or
5133        // not, and the typed check above cannot see a string-spelled first
5134        // one. Measured, the synthesised attribute wins —
5135        // `<columns=[n]>"//tmp/t{k}"` answered with column `n` — so the tuple
5136        // stays aligned with the schema and no value is decoded wrong. What
5137        // is lost is the caller's own `{found}`, silently discarded at 200,
5138        // which is the trap: the filter they wrote simply never happened.
5139        let refused = skiff_table_path(&TablePath::from("//tmp/table{found}"), &skiff_format());
5140        assert!(
5141            matches!(&refused, Err(ClientError::Config(reason)) if reason.contains("already selects columns")),
5142            "a string column selection was not refused: {refused:?}"
5143        );
5144        // A leading attribute block is refused one step removed: the cluster
5145        // takes it happily (`<ranges=[…0:2]>"<columns=[n]>//tmp/t"` composed
5146        // at 200), but this client cannot read the block to know whether it
5147        // names `columns` too, and if it does the synthesised one wins in
5148        // silence.
5149        for path in [
5150            "<columns=[found]>//tmp/table",
5151            "<primary_medium=default>//tmp/table",
5152        ] {
5153            let refused = skiff_table_path(&TablePath::from(path), &skiff_format());
5154            assert!(
5155                matches!(&refused, Err(ClientError::Config(reason)) if reason.contains("cannot tell whether")),
5156                "{path} was not refused: {refused:?}"
5157            );
5158        }
5159
5160        // A *row* range is not a column selection. Measured on the cluster,
5161        // `<columns=[n]>//tmp/t[#0:#2]` answers 200 with rows 0-1 carrying
5162        // only `n` — the two attributes answer different questions — so the
5163        // string spelling of a range goes through, as it does for read_table.
5164        let ranged = skiff_table_path(&TablePath::from("//tmp/table[#0:#2]"), &skiff_format())
5165            .expect("a string row range is not a column selection");
5166        assert_eq!(
5167            ytsaurus_yson::to_string(&ranged, YsonFormat::Text).unwrap(),
5168            r#"<columns=[found;rcl]>"//tmp/table[#0:#2]""#
5169        );
5170        // And so is a typed one, which renders its own `ranges` alongside.
5171        assert!(
5172            skiff_table_path(&TablePath::from("//tmp/table").range(0..2), &skiff_format()).is_ok()
5173        );
5174
5175        // An escaped bracket is part of a node name, and that table is
5176        // readable as Skiff like any other.
5177        assert!(skiff_table_path(&TablePath::from(r"//tmp/t\[x\]"), &skiff_format()).is_ok());
5178        assert!(skiff_table_path(&TablePath::from(r"//tmp/t\{x\}"), &skiff_format()).is_ok());
5179    }
5180
5181    #[test]
5182    fn skiff_stream_completeness_uses_the_declared_schema() {
5183        let schema = skiff_format().table_schema(0).unwrap().clone();
5184        let mut encoder = SkiffEncoder::new(Vec::new(), schema).unwrap();
5185        encoder
5186            .write(&Value::Tuple(vec![
5187                Value::Uint64(7),
5188                Value::Bytes(b"ok".to_vec()),
5189            ]))
5190            .unwrap();
5191        let complete = encoder.into_inner().unwrap();
5192
5193        assert!(check_complete_skiff_stream(&complete, &skiff_format()).is_ok());
5194        for cut in 1..complete.len() {
5195            assert!(
5196                check_complete_skiff_stream(&complete[..cut], &skiff_format()).is_err(),
5197                "cut at {cut} must not pass"
5198            );
5199        }
5200    }
5201
5202    #[test]
5203    fn direct_skiff_table_format_rejects_multi_table_and_job_controls() {
5204        let multiple = SkiffFormat::new(vec![
5205            SchemaRef::Inline(Schema::tuple([Schema::named("a", WireType::Uint64)])),
5206            SchemaRef::Inline(Schema::tuple([Schema::named("b", WireType::Uint64)])),
5207        ])
5208        .unwrap();
5209        assert!(matches!(
5210            skiff_table_path(&TablePath::from("//tmp/table"), &multiple),
5211            Err(ClientError::Config(_))
5212        ));
5213
5214        let job_control =
5215            SkiffFormat::new(vec![SchemaRef::Inline(Schema::tuple([Schema::named(
5216                "$key_switch",
5217                WireType::Boolean,
5218            )]))])
5219            .unwrap();
5220        assert!(matches!(
5221            skiff_table_path(&TablePath::from("//tmp/table"), &job_control),
5222            Err(ClientError::Config(_))
5223        ));
5224    }
5225
5226    #[test]
5227    fn skiff_table_calls_use_schema_format_columns_and_raw_streams() {
5228        let schema = skiff_format().table_schema(0).unwrap().clone();
5229        let mut encoder = SkiffEncoder::new(Vec::new(), schema).unwrap();
5230        encoder
5231            .write(&Value::Tuple(vec![
5232                Value::Uint64(7),
5233                Value::Bytes(b"ok".to_vec()),
5234            ]))
5235            .unwrap();
5236        let stream = encoder.into_inner().unwrap();
5237
5238        let (proxy, write_request) = one_request_proxy(Vec::new());
5239        Client::new(&proxy)
5240            .write_table_with_format("//tmp/write", &stream, &DataFormat::skiff(skiff_format()))
5241            .unwrap();
5242        let write_request = write_request.join().unwrap();
5243        assert!(write_request.starts_with(b"PUT /api/v4/write_table HTTP/1.1\r\n"));
5244        let write_headers = String::from_utf8_lossy(&write_request);
5245        assert!(
5246            write_headers.contains("input_format=<table_skiff_schemas="),
5247            "{write_headers}"
5248        );
5249        assert!(
5250            write_headers.contains(r#"path=<columns=[found;rcl]>"//tmp/write""#),
5251            "{write_headers}"
5252        );
5253        assert!(write_request.ends_with(&stream));
5254
5255        let (proxy, read_request) = one_request_proxy(stream.clone());
5256        let received = Client::new(&proxy)
5257            .read_table_with_format("//tmp/read", &DataFormat::skiff(skiff_format()))
5258            .unwrap();
5259        let read_request = read_request.join().unwrap();
5260        assert!(read_request.starts_with(b"GET /api/v4/read_table HTTP/1.1\r\n"));
5261        let read_headers = String::from_utf8_lossy(&read_request);
5262        assert!(
5263            read_headers.contains("output_format=<table_skiff_schemas="),
5264            "{read_headers}"
5265        );
5266        assert!(
5267            read_headers.contains(r#"path=<columns=[found;rcl]>"//tmp/read""#),
5268            "{read_headers}"
5269        );
5270        assert_eq!(received, stream);
5271    }
5272
5273    #[test]
5274    fn shared_yson_table_format_uses_the_requested_yson_encoding() {
5275        let (proxy, request) = one_request_proxy(Vec::new());
5276        Client::new(&proxy)
5277            .write_table_with_format("//tmp/write", b"{value=one};", &DataFormat::text_yson())
5278            .unwrap();
5279
5280        let request = request.join().unwrap();
5281        let request = String::from_utf8_lossy(&request);
5282        assert!(
5283            request.contains("input_format=<format=text>yson"),
5284            "{request}"
5285        );
5286    }
5287
5288    #[test]
5289    fn a_raw_command_goes_where_it_says_with_the_parameters_it_was_given() {
5290        let (proxy, request) = one_request_proxy(br#"{"value"={};}"#.to_vec());
5291        let body = Client::new(&proxy)
5292            .raw_command(
5293                Method::Get,
5294                "get_supported_features",
5295                &yson_build::empty_map(),
5296                None,
5297            )
5298            .expect("sends");
5299
5300        let request = request.join().unwrap();
5301        assert!(
5302            request.starts_with(b"GET /api/v4/get_supported_features HTTP/1.1\r\n"),
5303            "{}",
5304            String::from_utf8_lossy(&request)
5305        );
5306
5307        let headers = String::from_utf8_lossy(&request);
5308        assert!(headers.contains("x-yt-parameters: {}"), "{headers}");
5309        // Handed back as it arrived. A raw command has no idea what the answer
5310        // means, and decoding it would be this crate guessing.
5311        assert_eq!(body, br#"{"value"={};}"#);
5312    }
5313
5314    #[test]
5315    fn a_raw_command_carries_its_payload_and_its_transaction() {
5316        let (proxy, request) = one_request_proxy(Vec::new());
5317        Client::new(&proxy)
5318            .with_transaction("3-5d231-10001-db88")
5319            .raw_command(
5320                Method::Put,
5321                "write_file",
5322                &yson_build::map([("path", yson_build::string("//tmp/f"))]),
5323                Some(b"payload"),
5324            )
5325            .expect("sends");
5326
5327        let request = request.join().unwrap();
5328        let headers = String::from_utf8_lossy(&request);
5329
5330        assert!(
5331            request.starts_with(b"PUT /api/v4/write_file HTTP/1.1\r\n"),
5332            "{headers}"
5333        );
5334        assert!(request.ends_with(b"payload"), "{headers}");
5335        // The whole point of routing this through `Transport` rather than
5336        // handing out a bare `ureq` agent: a raw command inside a transaction
5337        // is *in* it, not quietly beside it.
5338        assert!(
5339            headers.contains(r#"transaction_id="3-5d231-10001-db88""#),
5340            "{headers}"
5341        );
5342    }
5343
5344    #[test]
5345    fn a_raw_command_is_sent_once_unless_the_caller_says_otherwise() {
5346        // A command this crate does not model cannot be assumed idempotent, so
5347        // the default ignores the retry policy. Proved by serving one request
5348        // from a listener that would accept a second: a retried request would
5349        // hang here rather than fail.
5350        let (proxy, request) = one_request_proxy(Vec::new());
5351        let client = Client::new(&proxy).with_retries(RetryPolicy::none());
5352        client
5353            .raw_command(Method::Post, "concatenate", &yson_build::empty_map(), None)
5354            .expect("sends");
5355        request.join().unwrap();
5356    }
5357
5358    #[test]
5359    fn a_mutation_id_is_sent_even_when_the_command_is_not_retried() {
5360        // The two answer different questions: `Repeatable` decides whether
5361        // *this* call may go twice, a mutation ID whether a *later* call from a
5362        // restarted process is recognised as the same mutation. A command too
5363        // dangerous to retry in-process can still be worth making replayable
5364        // across one, so the ID must not be dropped along with the retries.
5365        let id = MutationId::new().as_retry();
5366        let (proxy, request) = one_request_proxy(Vec::new());
5367        Client::new(&proxy)
5368            .raw_command_with(
5369                Method::Post,
5370                "concatenate",
5371                &yson_build::empty_map(),
5372                None,
5373                Repeatable::Never,
5374                Some(&id),
5375            )
5376            .expect("sends");
5377
5378        let request = request.join().unwrap();
5379        let sent = sent_parameters(&request);
5380
5381        assert_eq!(
5382            parameter(&sent, "mutation_id").and_then(YsonValue::as_str),
5383            Some(id.as_str()),
5384            "{}",
5385            String::from_utf8_lossy(&request)
5386        );
5387        // And it admits to being a replay, which is what the cluster refuses a
5388        // duplicate for not doing.
5389        assert_eq!(
5390            parameter(&sent, "retry").map(|v| &v.node),
5391            Some(&YsonNode::Boolean(true)),
5392            "{}",
5393            String::from_utf8_lossy(&request)
5394        );
5395    }
5396
5397    /// The `X-YT-Parameters` document of a captured request, decoded.
5398    ///
5399    /// Reading the value rather than its spelling, because the spelling of a
5400    /// *generated* value is not stable. The text YSON writer leaves a string
5401    /// unquoted when it looks like an identifier — first byte a letter or `_`,
5402    /// the rest alphanumeric or `_-.`, see `ser::is_safe_unquoted` — and a
5403    /// mutation ID is a hex GUID printed with no leading zeros. So
5404    /// `ebd6e011-…` goes on the wire bare and `3f2a1b-…` goes on it quoted,
5405    /// decided by the first hex digit: **measured at 39.8 % unquoted over
5406    /// 100 000 IDs**, which is what an assertion on either spelling would have
5407    /// cost in flakes. Both spell the same string and the cluster takes both —
5408    /// the `idempotent` example deduplicated a replay whose ID went unquoted.
5409    fn sent_parameters(request: &[u8]) -> YsonValue {
5410        let head = String::from_utf8_lossy(request);
5411        let line = head
5412            .lines()
5413            .find(|line| {
5414                line.split_once(':')
5415                    .is_some_and(|(name, _)| name.eq_ignore_ascii_case("x-yt-parameters"))
5416            })
5417            .unwrap_or_else(|| panic!("no X-YT-Parameters header in:\n{head}"));
5418
5419        let value = line
5420            .split_once(':')
5421            .expect("the header has a value")
5422            .1
5423            .trim();
5424        from_slice(value.as_bytes(), YsonFormat::Text)
5425            .unwrap_or_else(|e| panic!("parameters are not text YSON ({e}): {value}"))
5426    }
5427
5428    /// One entry of a decoded parameter document.
5429    ///
5430    /// `YsonValue` indexes with a panicking `Index`, and a panic here would
5431    /// throw away the request the assertion wants to print.
5432    fn parameter<'a>(params: &'a YsonValue, key: &str) -> Option<&'a YsonValue> {
5433        match &params.node {
5434            YsonNode::Map(m) => m.get(key.as_bytes()),
5435            _ => None,
5436        }
5437    }
5438
5439    #[test]
5440    fn a_command_name_that_would_change_the_url_is_refused() {
5441        // The name goes into `/api/v4/{command}` as it is. A caller that got
5442        // one from configuration must not be able to address `//sys` or append
5443        // a query string, because the answer would still look like an answer.
5444        let client = Client::new("http://localhost:8000");
5445        for bad in [
5446            "",
5447            "get/../../hosts",
5448            "get?x=1",
5449            "get#frag",
5450            "get value",
5451            "get%2f",
5452        ] {
5453            let error = client
5454                .raw_command(Method::Get, bad, &yson_build::empty_map(), None)
5455                .expect_err(&format!("{bad:?} was accepted as a command name"));
5456            assert!(matches!(error, ClientError::Config(_)), "{bad:?}: {error}");
5457        }
5458
5459        assert!(check_command_name("get_supported_features").is_ok());
5460        assert!(check_command_name("start_tx").is_ok());
5461        // A digit is fine: `v3`-era names carry them and a future command may.
5462        assert!(check_command_name("read_table_partition2").is_ok());
5463    }
5464
5465    #[test]
5466    fn a_payload_on_a_get_is_refused_rather_than_dropped() {
5467        // `dispatch` sends a GET through ureq's bodiless builder, so the bytes
5468        // would go nowhere and the request would succeed. Silent is the one
5469        // thing it must not be.
5470        let error = Client::new("http://localhost:8000")
5471            .raw_command(
5472                Method::Get,
5473                "read_table",
5474                &yson_build::empty_map(),
5475                Some(b"x"),
5476            )
5477            .expect_err("a GET with a body is a mistake");
5478        assert!(matches!(error, ClientError::Config(_)), "{error}");
5479
5480        assert!(refuse_body_on_get(Method::Get, "get", false).is_ok());
5481        assert!(refuse_body_on_get(Method::Put, "write_file", true).is_ok());
5482        assert!(refuse_body_on_get(Method::Post, "create", true).is_ok());
5483    }
5484
5485    #[test]
5486    fn read_file_refuses_a_body_it_will_not_hold() {
5487        // `http`'s own tests drive `Transport::send` at a small cap; this is
5488        // the method a caller actually calls, all the way through — parameters,
5489        // heavy routing, `retry::run`, `after_heavy`, and the size check that
5490        // would otherwise have swallowed the verdict.
5491        //
5492        // The cap the transport was built with is what decides it, which is
5493        // exactly what a hardcoded `RESPONSE_LIMIT` at the read would not be:
5494        // 40 000 bytes of zeros are half a gigabyte short of the real ceiling,
5495        // so a `send` that ignored the field would sail past this and fail
5496        // later, on the size `get` this listener never answers — a different
5497        // error, from a request that should never have been sent.
5498        let (proxy, served) = one_gzip_request_proxy(vec![0_u8; 40_000]);
5499        let mut client = Client::new(&proxy);
5500        client.transport.set_response_limit(4_096);
5501
5502        let error = client
5503            .read_file("//tmp/f")
5504            .expect_err("40 000 bytes past a 4 096-byte ceiling");
5505
5506        assert!(
5507            matches!(error, ClientError::ResponseTooLarge { limit: 4_096, .. }),
5508            "{error:?}"
5509        );
5510
5511        // Named, numbered, and pointed at the half that would have worked.
5512        let message = error.to_string();
5513        assert!(message.contains("read_file"), "{message}");
5514        assert!(message.contains("4096"), "{message}");
5515        assert!(message.contains("read_file_streaming"), "{message}");
5516
5517        // One request, and it was the read: refused where the bytes arrive,
5518        // not after a second round trip.
5519        let request = served.join().unwrap();
5520        assert!(
5521            request.starts_with(b"GET /api/v4/read_file HTTP/1.1\r\n"),
5522            "{}",
5523            String::from_utf8_lossy(&request)
5524        );
5525    }
5526
5527    /// `one_request_proxy`, with the body gzipped and announced as such.
5528    ///
5529    /// The wire and the `Vec` are only different quantities when something
5530    /// compresses them, and the cap's whole claim is about which of the two it
5531    /// counts. Every request this client sends asks for gzip already.
5532    fn one_gzip_request_proxy(payload: Vec<u8>) -> (String, thread::JoinHandle<Vec<u8>>) {
5533        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
5534        encoder.write_all(&payload).unwrap();
5535        let body = encoder.finish().unwrap();
5536
5537        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
5538        let address = listener.local_addr().unwrap();
5539        let task = thread::spawn(move || {
5540            let (mut stream, _) = listener.accept().unwrap();
5541            stream
5542                .set_read_timeout(Some(Duration::from_secs(5)))
5543                .unwrap();
5544            let request = read_http_request(&mut stream);
5545            let response = format!(
5546                "HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\n\
5547                 Connection: close\r\n\r\n",
5548                body.len()
5549            );
5550            stream.write_all(response.as_bytes()).unwrap();
5551            stream.write_all(&body).unwrap();
5552            request
5553        });
5554        (format!("http://{address}"), task)
5555    }
5556
5557    fn one_request_proxy(body: Vec<u8>) -> (String, thread::JoinHandle<Vec<u8>>) {
5558        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
5559        let address = listener.local_addr().unwrap();
5560        let task = thread::spawn(move || {
5561            let (mut stream, _) = listener.accept().unwrap();
5562            stream
5563                .set_read_timeout(Some(Duration::from_secs(5)))
5564                .unwrap();
5565            let request = read_http_request(&mut stream);
5566            let response = format!(
5567                "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
5568                body.len()
5569            );
5570            stream.write_all(response.as_bytes()).unwrap();
5571            stream.write_all(&body).unwrap();
5572            request
5573        });
5574        (format!("http://{address}"), task)
5575    }
5576
5577    fn read_http_request(stream: &mut TcpStream) -> Vec<u8> {
5578        let mut request = Vec::new();
5579        let mut buffer = [0; 1024];
5580        let expected = loop {
5581            let read = stream.read(&mut buffer).unwrap();
5582            assert!(read != 0, "client closed before sending a complete request");
5583            request.extend_from_slice(&buffer[..read]);
5584            let Some(headers_end) = request.windows(4).position(|window| window == b"\r\n\r\n")
5585            else {
5586                continue;
5587            };
5588            let headers = String::from_utf8_lossy(&request[..headers_end + 4]);
5589            let content_length = headers
5590                .lines()
5591                .find_map(|line| {
5592                    let (name, value) = line.split_once(':')?;
5593                    name.eq_ignore_ascii_case("content-length")
5594                        .then_some(value.trim())
5595                })
5596                .and_then(|value| value.parse::<usize>().ok())
5597                .unwrap_or(0);
5598            break headers_end + 4 + content_length;
5599        };
5600        while request.len() < expected {
5601            let read = stream.read(&mut buffer).unwrap();
5602            assert!(read != 0, "client closed before sending its request body");
5603            request.extend_from_slice(&buffer[..read]);
5604        }
5605        request
5606    }
5607
5608    #[test]
5609    fn truncation_after_a_whole_record_is_rejected() {
5610        let one = b"{\x01\x02a=\x02\x02}";
5611        let mut data = one.to_vec();
5612        data.push(b';');
5613        data.extend_from_slice(&one[..4]); // second record cut short
5614
5615        let err = check_complete_fragment(&data).expect_err("must reject");
5616        assert!(err.contains("cut short"), "{err}");
5617    }
5618
5619    #[test]
5620    fn a_token_file_written_with_echo_still_works() {
5621        // `echo token > ~/.yt/token` is how these files get written, and the
5622        // newline it leaves would fail authentication with an error that never
5623        // mentions a newline.
5624        let path = std::env::temp_dir().join(format!(
5625            "ytsaurus-rs-token-{}-{:?}",
5626            std::process::id(),
5627            std::thread::current().id()
5628        ));
5629        std::fs::write(&path, "  secret-token\n").expect("writes");
5630
5631        assert_eq!(read_token_file(&path).as_deref(), Some("secret-token"));
5632
5633        std::fs::write(&path, "\n \n").expect("writes");
5634        assert_eq!(read_token_file(&path), None, "whitespace is not a token");
5635
5636        std::fs::remove_file(&path).ok();
5637        assert_eq!(
5638            read_token_file(&path),
5639            None,
5640            "a missing file is no token, not an error"
5641        );
5642    }
5643
5644    #[test]
5645    fn a_listing_is_the_names_in_the_order_given() {
5646        let value = from_slice(br#"["t1";"t2";]"#, YsonFormat::Text).expect("valid YSON");
5647        assert_eq!(child_names(&value, "//tmp/x").unwrap(), ["t1", "t2"]);
5648    }
5649
5650    #[test]
5651    fn a_truncated_listing_is_an_error_rather_than_a_short_list() {
5652        // What `max_size` produces, and what a node with too many children
5653        // produces on its own. The marker is an attribute on the list, so a
5654        // caller who does not look gets a listing quietly missing entries.
5655        let value =
5656            from_slice(br#"<"incomplete"=%true;>["t1";]"#, YsonFormat::Text).expect("valid YSON");
5657
5658        let err = child_names(&value, "//tmp/x").expect_err("must not pass as a listing");
5659        assert!(err.to_string().contains("not all of them"), "{err}");
5660    }
5661
5662    /// What a local cluster answers `exists` with, captured verbatim.
5663    const EXISTS_RESPONSE: &[u8] = br#"{"value"=%false;}"#;
5664
5665    #[test]
5666    fn an_exists_answer_is_read_out_of_the_value_key() {
5667        let client = Client::new("http://localhost:8000");
5668
5669        let value = client
5670            .value_field(EXISTS_RESPONSE, "value")
5671            .expect("the answer is an envelope around `value`");
5672        assert!(matches!(value.node, YsonNode::Boolean(false)));
5673
5674        // The command's own name is not a key in its answer. Looking for it
5675        // there failed every call to `exists` with a decode error, for as long
5676        // as nothing in the crate called `exists`.
5677        assert!(client.value_field(EXISTS_RESPONSE, "exists").is_err());
5678    }
5679
5680    /// The exact document a local cluster returned for a job that reported
5681    /// three statistics.
5682    const CUSTOM_STATISTICS: &str = r#"{
5683        "bytes/read" = {"$" = {completed = {map = {count=1;max=147;min=147;sum=147}}}};
5684        "rows/read" = {"$" = {completed = {map = {count=1;max=7;min=7;sum=7}}}};
5685        "rows/rejected" = {"$" = {completed = {map = {count=1;max=3;min=3;sum=3}}}};
5686    }"#;
5687
5688    fn statistics() -> YsonValue {
5689        from_slice(CUSTOM_STATISTICS.as_bytes(), YsonFormat::Text).expect("valid YSON")
5690    }
5691
5692    #[test]
5693    fn a_statistic_totals_over_completed_jobs() {
5694        let all = statistics();
5695
5696        // The name keeps its slash: the cluster stores it as one key rather
5697        // than nesting it, which a path-walking lookup would miss entirely.
5698        assert_eq!(
5699            jobs::field(&all, "rows/rejected").and_then(completed_total),
5700            Some(3)
5701        );
5702        assert_eq!(
5703            jobs::field(&all, "bytes/read").and_then(completed_total),
5704            Some(147)
5705        );
5706        assert_eq!(jobs::field(&all, "rows").and_then(completed_total), None);
5707    }
5708
5709    #[test]
5710    fn job_types_are_summed_and_other_states_are_not() {
5711        // A map-reduce reports one name from both phases; an aborted job's
5712        // work is redone by its replacement, so counting it would double.
5713        let value = from_slice(
5714            br#"{"$" = {
5715                    completed = {map = {sum=10}; partition_reduce = {sum=5}};
5716                    aborted   = {map = {sum=99}};
5717                }}"#,
5718            YsonFormat::Text,
5719        )
5720        .expect("valid YSON");
5721
5722        assert_eq!(completed_total(&value), Some(15));
5723    }
5724
5725    #[test]
5726    fn a_flat_aggregate_still_yields_a_number() {
5727        let value =
5728            from_slice(b"{count=1;max=7;min=7;sum=7}", YsonFormat::Text).expect("valid YSON");
5729        assert_eq!(completed_total(&value), Some(7));
5730    }
5731
5732    #[test]
5733    fn an_operation_whose_jobs_all_failed_totals_nothing() {
5734        let value = from_slice(br#"{"$" = {failed = {map = {sum=4}}}}"#, YsonFormat::Text)
5735            .expect("valid YSON");
5736        assert_eq!(completed_total(&value), None);
5737    }
5738
5739    #[test]
5740    fn from_env_explains_itself_when_unconfigured() {
5741        // Not asserting on process env, only that the message is actionable.
5742        let err = ClientError::Config("YT_PROXY is not set".to_owned());
5743        assert!(err.to_string().contains("YT_PROXY"));
5744    }
5745
5746    /// `Client::from_env` against a fixed environment, with nothing global
5747    /// touched. A plain lookup and nothing more: trimming and empty-is-unset
5748    /// belong to `from_lookup`, and a helper that repeated them here would be
5749    /// the thing the tests below were pinning.
5750    fn from_environment(vars: &[(&str, &str)]) -> Result<Client> {
5751        Client::from_lookup(|name| {
5752            vars.iter()
5753                .find(|(key, _)| *key == name)
5754                .map(|(_, value)| (*value).to_owned())
5755        })
5756    }
5757
5758    #[test]
5759    fn each_variable_reaches_the_setting_it_names() {
5760        // The mapping itself, which review is the only other thing that checks:
5761        // swap two of these names and every other test in the crate still
5762        // passes.
5763        let client = from_environment(&[
5764            ("YT_PROXY", "hume"),
5765            ("YT_PROXY_SUFFIX", ".yt.example.net"),
5766            ("YT_HEAVY_PROXY_DOMAINS", "proxy-zone.net, other-zone.net"),
5767            ("YT_FILE_CACHE", "//tmp/mine/cache"),
5768        ])
5769        .expect("YT_PROXY is set");
5770
5771        assert_eq!(
5772            client.transport.configured_address(),
5773            "https://hume.yt.example.net"
5774        );
5775        assert_eq!(client.file_cache, "//tmp/mine/cache");
5776        // The whole rendering, not a substring of it: `Only([…])` holds the
5777        // same two names as `Under { … }`, so wiring the variable to
5778        // `with_heavy_proxies_in` would pass a `contains` check — which is
5779        // exactly the swap this test is here to catch. Brittle on purpose.
5780        assert_eq!(
5781            client.transport.heavy_hosts_debug(),
5782            r#"Under { domains: ["proxy-zone.net", "other-zone.net"], ignored: [] }"#
5783        );
5784    }
5785
5786    #[test]
5787    fn a_machine_that_sets_nothing_gets_the_defaults() {
5788        // The invariant the whole feature rests on: four new variables, and a
5789        // client built where none of them is set is the client this crate
5790        // shipped before they existed.
5791        let bare = from_environment(&[("YT_PROXY", "http://localhost:8000")])
5792            .expect("YT_PROXY is set")
5793            .transport;
5794        let new = Client::new("http://localhost:8000").transport;
5795
5796        assert_eq!(bare.configured_address(), new.configured_address());
5797        assert_eq!(bare.heavy_hosts_debug(), new.heavy_hosts_debug());
5798        assert_eq!(
5799            from_environment(&[("YT_PROXY", "http://localhost:8000")])
5800                .expect("YT_PROXY is set")
5801                .file_cache,
5802            Client::new("http://localhost:8000").file_cache
5803        );
5804    }
5805
5806    #[test]
5807    fn the_wider_heavy_proxy_setting_wins_however_it_was_exported() {
5808        // Both set is a machine where somebody tried the domain and then gave
5809        // up on the rule. Reading them in export order would make that machine
5810        // behave differently depending on which line of the profile came last.
5811        let hosts = from_environment(&[
5812            ("YT_PROXY", "https://cluster.example.net"),
5813            ("YT_HEAVY_PROXY_DOMAINS", "proxy-zone.net"),
5814            ("YT_HEAVY_PROXIES_ANYWHERE", "1"),
5815        ])
5816        .expect("YT_PROXY is set")
5817        .transport
5818        .heavy_hosts_debug();
5819
5820        assert!(hosts.contains("Anywhere"), "{hosts}");
5821
5822        // And anything that is not one of the three spellings of yes leaves the
5823        // rule where the domains put it.
5824        let hosts = from_environment(&[
5825            ("YT_PROXY", "https://cluster.example.net"),
5826            ("YT_HEAVY_PROXY_DOMAINS", "proxy-zone.net"),
5827            ("YT_HEAVY_PROXIES_ANYWHERE", "0"),
5828        ])
5829        .expect("YT_PROXY is set")
5830        .transport
5831        .heavy_hosts_debug();
5832
5833        assert_eq!(
5834            hosts,
5835            r#"Under { domains: ["proxy-zone.net"], ignored: [] }"#
5836        );
5837    }
5838
5839    #[test]
5840    fn a_variable_set_to_nothing_is_a_variable_that_is_not_set() {
5841        // `export YT_FILE_CACHE=` in a profile is how a knob gets turned back
5842        // off, and taking it literally would point the cache at `""`. The rule
5843        // lives in `from_lookup` rather than in the lookup, so this exercises
5844        // the same code `from_env` runs.
5845        let client = from_environment(&[
5846            ("YT_PROXY", "  https://cluster.example.net  "),
5847            ("YT_FILE_CACHE", "   "),
5848            ("YT_HEAVY_PROXY_DOMAINS", ""),
5849            ("YT_PROXY_SUFFIX", ""),
5850        ])
5851        .expect("YT_PROXY is set");
5852
5853        assert_eq!(
5854            client.transport.configured_address(),
5855            "https://cluster.example.net",
5856            "and a value that is set is trimmed"
5857        );
5858        assert_eq!(client.file_cache, Client::new("x").file_cache);
5859        assert_eq!(client.transport.heavy_hosts_debug(), "SameDomain");
5860    }
5861
5862    #[test]
5863    fn a_proxy_set_to_nothing_is_a_proxy_that_is_not_set() {
5864        // `export YT_PROXY=` is how a profile turns one off, and the message
5865        // that says what to export is the right answer to it. Taken literally
5866        // — and with a suffix set — it would instead address
5867        // `https://.yt.example.net`, which looks like a name and resolves
5868        // nowhere.
5869        let err = from_environment(&[("YT_PROXY", "   "), ("YT_PROXY_SUFFIX", ".yt.example.net")])
5870            .expect_err("an empty proxy is not a proxy");
5871
5872        assert!(err.to_string().contains("YT_PROXY is not set"), "{err}");
5873    }
5874
5875    #[test]
5876    fn a_bare_cluster_name_is_completed_only_when_a_suffix_says_so() {
5877        // The ordinary spelling wherever an installation's clusters share one
5878        // domain, and the one this client turned into `https://hume`.
5879        assert_eq!(
5880            expanded_proxy("hume", Some(".yt.example.net")),
5881            "hume.yt.example.net"
5882        );
5883        // Written without the leading dot by whoever thinks of it as a domain,
5884        // and with a trailing one by whoever thinks of it as an FQDN. A
5885        // trailing dot left on connects and then fails every domain
5886        // comparison, which is worse than not connecting.
5887        for suffix in ["yt.example.net", "yt.example.net.", " .yt.example.net "] {
5888            assert_eq!(
5889                expanded_proxy("hume", Some(suffix.trim())),
5890                "hume.yt.example.net",
5891                "{suffix:?}"
5892            );
5893        }
5894        // No suffix, no expansion: the suffix is not compiled in, because this
5895        // client is not one installation's.
5896        assert_eq!(expanded_proxy("hume", None), "hume");
5897    }
5898
5899    #[test]
5900    fn a_name_that_needs_no_completing_is_left_alone() {
5901        // Go's gate, kept: a colon is a scheme or a port, a dot is a name that
5902        // already means something, and `localhost` is this machine whatever
5903        // else is set.
5904        for proxy in [
5905            "http://localhost:8000",
5906            "localhost",
5907            "hume.yt.example.net",
5908            "cluster.example.net",
5909            "10.0.0.7",
5910            "hume:80",
5911            // The surprising half of Go's gate, spelled out because it is
5912            // `contains` and not equality: a cluster whose own name carries
5913            // `localhost` is never completed.
5914            "mylocalhostcluster",
5915        ] {
5916            assert_eq!(expanded_proxy(proxy, Some(".yt.example.net")), proxy);
5917        }
5918    }
5919
5920    #[test]
5921    fn domains_are_read_as_a_list_however_they_were_written() {
5922        assert_eq!(
5923            split_domains("proxy-zone.net, sas.proxy-zone.net"),
5924            ["proxy-zone.net", "sas.proxy-zone.net"]
5925        );
5926        assert_eq!(
5927            split_domains("proxy-zone.net sas.proxy-zone.net"),
5928            ["proxy-zone.net", "sas.proxy-zone.net"]
5929        );
5930        // A trailing comma is how a list gets edited, not a domain called "".
5931        assert_eq!(split_domains("proxy-zone.net,,"), ["proxy-zone.net"]);
5932        assert!(split_domains("  ,  ").is_empty());
5933    }
5934
5935    #[test]
5936    fn only_the_three_spellings_of_yes_are_yes() {
5937        for value in ["1", "true", "TRUE", "yes", " Yes "] {
5938            assert!(truthy(value), "{value}");
5939        }
5940        // A knob that is already off has nothing to gain from guessing, and
5941        // reading `0` as a yes is the way a variable meant to disable something
5942        // enables it.
5943        for value in ["0", "false", "no", "on", "enabled", ""] {
5944            assert!(!truthy(value), "{value}");
5945        }
5946    }
5947}