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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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", ¶ms)?;
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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", ¶ms, &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 ¶ms,
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", ¶ms)?;
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", ¶ms, &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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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", ¶ms)?;
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 ¶ms,
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", ¬_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 ¶ms.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}