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