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