Skip to main content

ytsaurus_client/
lib.rs

1//! A thin [YTsaurus](https://ytsaurus.tech) client: enough of the HTTP API v4
2//! to run a Rust worker without a Python installation.
3//!
4//! It is deliberately small. It does what launching a job needs — create a
5//! node, upload the worker, write and read tables, start an operation and wait
6//! for it — and nothing else. For everything beyond that, the `yt` CLI remains
7//! the right tool.
8//!
9//! # Launching a job
10//!
11//! ```no_run
12//! use ytsaurus_client::{Client, MapSpec};
13//!
14//! # fn main() -> Result<(), ytsaurus_client::ClientError> {
15//! let client = Client::from_env()?;
16//!
17//! // Upload the worker, marked executable so the node can run it.
18//! client.upload_worker("target/.../my_job", "//tmp/my_job")?;
19//!
20//! let spec = MapSpec::new("./my_job", ["//tmp/input"], ["//tmp/output"])
21//!     .with_local_file("//tmp/my_job")
22//!     .with_memory_limit(512 * 1024 * 1024);
23//!
24//! let id = client.start_map(&spec)?;
25//! client.wait_for_operation(&id)?;
26//! # Ok(())
27//! # }
28//! ```
29//!
30//! # Configuration
31//!
32//! [`Client::from_env`] reads `YT_PROXY` for the cluster address and `YT_TOKEN`
33//! for the token, matching the `yt` CLI. A bare host is assumed to be HTTPS; a
34//! local cluster is reached as `http://localhost:8000`.
35//!
36//! # What this does not do
37//!
38//! Heavy commands are documented to require asking `/hosts` for a dedicated
39//! proxy, and this client does not: it sends everything to the address it was
40//! given. That is correct for a local cluster and for any deployment behind a
41//! balancer, but on a large installation an upload may be refused with 503. See
42//! [`Client::heavy_proxy`] for the escape hatch.
43
44#![warn(missing_docs)]
45
46use std::time::{Duration, Instant};
47
48/// Errors.
49pub mod error;
50mod http;
51mod spec;
52/// Constructors for YSON documents, for specs this crate does not model.
53pub mod yson_build;
54
55pub use crate::error::{ClientError, Result};
56pub use crate::spec::{MapReduceSpec, MapSpec, OperationType};
57
58use crate::http::{Method, Payload, Transport};
59use ytsaurus_yson::{YsonFormat, YsonNode, YsonValue, from_slice};
60
61/// Default request timeout.
62const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
63
64/// How often [`Client::wait_for_operation`] asks the cluster for progress.
65const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
66
67/// A connection to one YTsaurus cluster.
68#[derive(Debug, Clone)]
69pub struct Client {
70    transport: Transport,
71    poll_interval: Duration,
72}
73
74impl Client {
75    /// Connects to `proxy`, with no token.
76    ///
77    /// `proxy` may be a bare host (`cluster.example.com`, assumed HTTPS) or
78    /// carry a scheme (`http://localhost:8000`).
79    #[must_use]
80    pub fn new(proxy: &str) -> Self {
81        Self {
82            transport: Transport::new(proxy, None, DEFAULT_TIMEOUT),
83            poll_interval: DEFAULT_POLL_INTERVAL,
84        }
85    }
86
87    /// Connects to `proxy` using `token` for authentication.
88    #[must_use]
89    pub fn with_token(proxy: &str, token: impl Into<String>) -> Self {
90        Self {
91            transport: Transport::new(proxy, Some(token.into()), DEFAULT_TIMEOUT),
92            poll_interval: DEFAULT_POLL_INTERVAL,
93        }
94    }
95
96    /// Connects using `YT_PROXY` and, if set, `YT_TOKEN`.
97    ///
98    /// # Errors
99    ///
100    /// Returns [`ClientError::Config`] if `YT_PROXY` is not set.
101    pub fn from_env() -> Result<Self> {
102        let proxy = std::env::var("YT_PROXY").map_err(|_| {
103            ClientError::Config(
104                "YT_PROXY is not set; export it (for a local cluster: \
105                 YT_PROXY=http://localhost:8000) or use Client::new"
106                    .to_owned(),
107            )
108        })?;
109
110        let token = std::env::var("YT_TOKEN")
111            .ok()
112            .filter(|t| !t.trim().is_empty());
113        Ok(match token {
114            Some(token) => Self::with_token(&proxy, token),
115            None => Self::new(&proxy),
116        })
117    }
118
119    /// Overrides how often [`Client::wait_for_operation`] polls.
120    #[must_use]
121    pub fn with_poll_interval(mut self, interval: Duration) -> Self {
122        self.poll_interval = interval;
123        self
124    }
125
126    /// Returns the least-loaded heavy proxy the cluster reports, if any.
127    ///
128    /// Large installations separate light and heavy proxies and answer heavy
129    /// commands on a light proxy with 503. Point a second [`Client`] at this
130    /// address to do uploads there. A local cluster returns nothing useful, and
131    /// none of this is needed for it.
132    ///
133    /// # Errors
134    ///
135    /// Returns [`ClientError`] if the request fails.
136    pub fn heavy_proxy(&self) -> Result<Option<String>> {
137        let url = format!("{}/hosts", self.transport.base());
138        let body = ureq::get(&url)
139            .call()
140            .map_err(|e| ClientError::Transport {
141                command: "hosts".to_owned(),
142                source: Box::new(e),
143            })?
144            .body_mut()
145            .read_to_string()
146            .map_err(|e| ClientError::Decode {
147                command: "hosts".to_owned(),
148                reason: e.to_string(),
149            })?;
150
151        let hosts: Vec<String> = serde_json::from_str(&body).unwrap_or_default();
152        Ok(hosts.into_iter().next())
153    }
154
155    // ------------------------------------------------------------- Cypress
156
157    /// Whether a Cypress node exists.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`ClientError`] if the request fails.
162    pub fn exists(&self, path: &str) -> Result<bool> {
163        let params = yson_build::map([("path", yson_build::string(path))]);
164        let body = self
165            .transport
166            .call(Method::Get, "exists", &params, Payload::None)?;
167        Ok(matches!(
168            self.value_field(&body, "exists")?.node,
169            YsonNode::Boolean(true)
170        ))
171    }
172
173    /// Creates a Cypress node, e.g. `table`, `file` or `map_node`.
174    ///
175    /// Creates missing parents and succeeds if the node already exists.
176    ///
177    /// # Errors
178    ///
179    /// Returns [`ClientError`] if the request fails.
180    pub fn create(&self, node_type: &str, path: &str) -> Result<()> {
181        let params = yson_build::map([
182            ("path", yson_build::string(path)),
183            ("type", yson_build::string(node_type)),
184            ("recursive", yson_build::boolean(true)),
185            ("ignore_existing", yson_build::boolean(true)),
186        ]);
187        self.transport
188            .call(Method::Post, "create", &params, Payload::None)?;
189        Ok(())
190    }
191
192    /// Removes a Cypress node. Succeeds if it is already absent.
193    ///
194    /// # Errors
195    ///
196    /// Returns [`ClientError`] if the request fails.
197    pub fn remove(&self, path: &str) -> Result<()> {
198        let params = yson_build::map([
199            ("path", yson_build::string(path)),
200            ("recursive", yson_build::boolean(true)),
201            ("force", yson_build::boolean(true)),
202        ]);
203        self.transport
204            .call(Method::Post, "remove", &params, Payload::None)?;
205        Ok(())
206    }
207
208    /// Reads a node attribute, such as `@row_count`.
209    ///
210    /// # Errors
211    ///
212    /// Returns [`ClientError`] if the request fails.
213    pub fn get(&self, path: &str) -> Result<YsonValue> {
214        let params = yson_build::map([("path", yson_build::string(path))]);
215        let body = self
216            .transport
217            .call(Method::Get, "get", &params, Payload::None)?;
218        self.value_field(&body, "value")
219    }
220
221    /// Number of rows in a table.
222    ///
223    /// # Errors
224    ///
225    /// Returns [`ClientError`] if the request fails or the attribute is absent.
226    pub fn row_count(&self, path: &str) -> Result<i64> {
227        let value = self.get(&format!("{path}/@row_count"))?;
228        value.as_i64().ok_or_else(|| ClientError::Decode {
229            command: "get".to_owned(),
230            reason: format!("{path}/@row_count is not an integer"),
231        })
232    }
233
234    // ---------------------------------------------------------------- data
235
236    /// Uploads a local file to Cypress, marking it executable.
237    ///
238    /// This is what makes a worker runnable on a node: without the `executable`
239    /// attribute YTsaurus copies the binary but refuses to exec it, and the job
240    /// fails with a permission error that does not mention the attribute.
241    ///
242    /// # Errors
243    ///
244    /// Returns [`ClientError`] if the file cannot be read or the upload fails.
245    pub fn upload_worker(&self, local: impl AsRef<std::path::Path>, remote: &str) -> Result<()> {
246        let local = local.as_ref();
247        let bytes = std::fs::read(local).map_err(|source| ClientError::Io {
248            path: local.display().to_string(),
249            source,
250        })?;
251
252        self.create("file", remote)?;
253        self.write_file(remote, &bytes)?;
254        self.set_attribute(remote, "executable", yson_build::boolean(true))
255    }
256
257    /// Writes raw bytes to a Cypress file, replacing its contents.
258    ///
259    /// # Errors
260    ///
261    /// Returns [`ClientError`] if the request fails.
262    pub fn write_file(&self, path: &str, contents: &[u8]) -> Result<()> {
263        let params = yson_build::map([("path", yson_build::string(path))]);
264        self.transport
265            .call(Method::Put, "write_file", &params, Payload::Bytes(contents))?;
266        Ok(())
267    }
268
269    /// Sets a node attribute.
270    ///
271    /// # Errors
272    ///
273    /// Returns [`ClientError`] if the request fails.
274    pub fn set_attribute(&self, path: &str, name: &str, value: YsonValue) -> Result<()> {
275        let encoded =
276            ytsaurus_yson::to_vec(&value, YsonFormat::Binary).map_err(|e| ClientError::Decode {
277                command: "set".to_owned(),
278                reason: format!("could not encode the attribute: {e}"),
279            })?;
280
281        let params = yson_build::map([
282            ("path", yson_build::string(format!("{path}/@{name}"))),
283            ("input_format", yson_build::binary_yson_format()),
284        ]);
285        self.transport
286            .call(Method::Put, "set", &params, Payload::Bytes(&encoded))?;
287        Ok(())
288    }
289
290    /// Writes rows to a table, replacing its contents.
291    ///
292    /// `rows` must be a binary YSON list fragment — exactly what a
293    /// `ytsaurus-job` worker writes.
294    ///
295    /// # Errors
296    ///
297    /// Returns [`ClientError`] if the request fails.
298    pub fn write_table(&self, path: &str, rows: &[u8]) -> Result<()> {
299        let params = yson_build::map([
300            ("path", yson_build::string(path)),
301            ("input_format", yson_build::binary_yson_format()),
302        ]);
303        self.transport
304            .call(Method::Put, "write_table", &params, Payload::Bytes(rows))?;
305        Ok(())
306    }
307
308    /// Reads a whole table as a binary YSON list fragment.
309    ///
310    /// Reads it into memory: this is for results a launcher inspects, not for
311    /// bulk export.
312    ///
313    /// The result is checked to be a complete list fragment. That is not
314    /// pedantry — the proxy reports a mid-stream failure in a trailer this
315    /// client cannot see (see the `http` module), so a truncated body is the
316    /// symptom that *is* detectable, and returning it as success would hand the
317    /// caller a silently short table.
318    ///
319    /// # Errors
320    ///
321    /// Returns [`ClientError`] if the request fails or the stream is truncated.
322    pub fn read_table(&self, path: &str) -> Result<Vec<u8>> {
323        let params = yson_build::map([
324            ("path", yson_build::string(path)),
325            ("output_format", yson_build::binary_yson_format()),
326        ]);
327        let body = self
328            .transport
329            .call(Method::Get, "read_table", &params, Payload::None)?;
330
331        check_complete_fragment(&body).map_err(|reason| ClientError::Decode {
332            command: "read_table".to_owned(),
333            reason: format!("{path}: {reason}"),
334        })?;
335
336        Ok(body)
337    }
338
339    // ---------------------------------------------------------- operations
340
341    /// Starts a map operation, returning its ID.
342    ///
343    /// # Errors
344    ///
345    /// Returns [`ClientError`] if the request fails.
346    pub fn start_map(&self, spec: &MapSpec) -> Result<String> {
347        self.start_operation(OperationType::Map, &spec.to_yson())
348    }
349
350    /// Starts a map-reduce operation, returning its ID.
351    ///
352    /// # Errors
353    ///
354    /// Returns [`ClientError`] if the request fails.
355    pub fn start_map_reduce(&self, spec: &MapReduceSpec) -> Result<String> {
356        self.start_operation(OperationType::MapReduce, &spec.to_yson())
357    }
358
359    /// Starts an operation from a spec built by hand.
360    ///
361    /// The escape hatch for anything [`MapSpec`] and [`MapReduceSpec`] do not
362    /// model; build the spec with [`yson_build`].
363    ///
364    /// # Errors
365    ///
366    /// Returns [`ClientError`] if the request fails.
367    pub fn start_operation(&self, kind: OperationType, spec: &YsonValue) -> Result<String> {
368        let params = yson_build::map([
369            ("operation_type", yson_build::string(kind.as_str())),
370            ("spec", spec.clone()),
371        ]);
372        let body = self
373            .transport
374            .call(Method::Post, "start_operation", &params, Payload::None)?;
375
376        let value = self.value_field(&body, "operation_id")?;
377        match &value.node {
378            YsonNode::String(bytes) => Ok(String::from_utf8_lossy(bytes).into_owned()),
379            other => Err(ClientError::Decode {
380                command: "start_operation".to_owned(),
381                reason: format!("operation_id is not a string: {other:?}"),
382            }),
383        }
384    }
385
386    /// Fetches an operation's current state, e.g. `running` or `completed`.
387    ///
388    /// # Errors
389    ///
390    /// Returns [`ClientError`] if the request fails.
391    pub fn operation_state(&self, id: &str) -> Result<String> {
392        let params = yson_build::map([
393            ("operation_id", yson_build::string(id)),
394            (
395                "attributes",
396                yson_build::list([yson_build::string("state")]),
397            ),
398        ]);
399        let body = self
400            .transport
401            .call(Method::Get, "get_operation", &params, Payload::None)?;
402
403        let value = self.field_of(&self.strip_envelope(&body, "get_operation")?, "state")?;
404        match &value.node {
405            YsonNode::String(bytes) => Ok(String::from_utf8_lossy(bytes).into_owned()),
406            other => Err(ClientError::Decode {
407                command: "get_operation".to_owned(),
408                reason: format!("state is not a string: {other:?}"),
409            }),
410        }
411    }
412
413    /// Polls until the operation reaches a terminal state.
414    ///
415    /// # Errors
416    ///
417    /// Returns [`ClientError::OperationFailed`] if it ends as anything other
418    /// than `completed`, or [`ClientError`] if polling itself fails.
419    pub fn wait_for_operation(&self, id: &str) -> Result<()> {
420        let started = Instant::now();
421        let mut last_state = String::new();
422
423        loop {
424            let state = self.operation_state(id)?;
425
426            if state != last_state {
427                eprintln!(
428                    "operation {id}: {state} ({:.0}s)",
429                    started.elapsed().as_secs_f64()
430                );
431                last_state.clone_from(&state);
432            }
433
434            match state.as_str() {
435                "completed" => return Ok(()),
436                "failed" | "aborted" => {
437                    return Err(ClientError::OperationFailed {
438                        id: id.to_owned(),
439                        state,
440                        error: self.operation_error(id),
441                    });
442                }
443                _ => std::thread::sleep(self.poll_interval),
444            }
445        }
446    }
447
448    /// Best-effort fetch of a failed operation's error document.
449    fn operation_error(&self, id: &str) -> Option<String> {
450        let params = yson_build::map([
451            ("operation_id", yson_build::string(id)),
452            (
453                "attributes",
454                yson_build::list([yson_build::string("result")]),
455            ),
456        ]);
457        let body = self
458            .transport
459            .call(Method::Get, "get_operation", &params, Payload::None)
460            .ok()?;
461        Some(crate::error::truncate(&String::from_utf8_lossy(&body), 600))
462    }
463
464    // -------------------------------------------------------------- helpers
465
466    /// API v4 wraps every structured response in a dict. Unwraps one level.
467    fn strip_envelope(&self, body: &[u8], command: &str) -> Result<YsonValue> {
468        from_slice(body, YsonFormat::Text).map_err(|e| ClientError::Decode {
469            command: command.to_owned(),
470            reason: format!(
471                "{e}; body was {}",
472                crate::error::truncate(&String::from_utf8_lossy(body), 200)
473            ),
474        })
475    }
476
477    fn field_of(&self, value: &YsonValue, key: &str) -> Result<YsonValue> {
478        match &value.node {
479            YsonNode::Map(m) => m
480                .get(key.as_bytes())
481                .cloned()
482                .ok_or_else(|| ClientError::Decode {
483                    command: key.to_owned(),
484                    reason: format!(
485                        "response has no {key:?}; keys were {:?}",
486                        m.keys()
487                            .map(|k| String::from_utf8_lossy(k).into_owned())
488                            .collect::<Vec<_>>()
489                    ),
490                }),
491            other => Err(ClientError::Decode {
492                command: key.to_owned(),
493                reason: format!("expected a dict, got {other:?}"),
494            }),
495        }
496    }
497
498    fn value_field(&self, body: &[u8], key: &str) -> Result<YsonValue> {
499        let envelope = self.strip_envelope(body, key)?;
500        self.field_of(&envelope, key)
501    }
502}
503
504/// Verifies that `data` is a whole binary YSON list fragment.
505///
506/// Walks record boundaries without decoding, so the cost is a scan rather than
507/// a parse of the whole table.
508fn check_complete_fragment(mut data: &[u8]) -> std::result::Result<(), String> {
509    use ytsaurus_yson::{Scan, scan_value};
510
511    let total = data.len();
512    loop {
513        while data.first() == Some(&b';') || data.first().is_some_and(u8::is_ascii_whitespace) {
514            data = &data[1..];
515        }
516        if data.is_empty() {
517            return Ok(());
518        }
519
520        match scan_value(data, YsonFormat::Binary) {
521            Ok(Scan::Complete { len }) => data = &data[len..],
522            Ok(Scan::Incomplete) => {
523                return Err(format!(
524                    "the response ends inside a record — {} of {total} bytes consumed; \
525                     the stream was cut short",
526                    total - data.len()
527                ));
528            }
529            Err(e) => {
530                return Err(format!(
531                    "the response is not valid binary YSON at byte {}: {e}",
532                    total - data.len()
533                ));
534            }
535        }
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    #[test]
544    fn a_complete_fragment_is_accepted() {
545        // {a=1};{a=1}
546        let one = b"{\x01\x02a=\x02\x02}";
547        let mut two = one.to_vec();
548        two.push(b';');
549        two.extend_from_slice(one);
550
551        assert!(check_complete_fragment(b"").is_ok());
552        assert!(check_complete_fragment(one).is_ok());
553        assert!(check_complete_fragment(&two).is_ok());
554    }
555
556    #[test]
557    fn a_truncated_fragment_is_rejected() {
558        let full = b"{\x01\x02a=\x02\x02}";
559        for cut in 1..full.len() {
560            let err = check_complete_fragment(&full[..cut])
561                .expect_err("a cut record must not pass as complete");
562            assert!(
563                err.contains("cut short") || err.contains("not valid"),
564                "{err}"
565            );
566        }
567    }
568
569    #[test]
570    fn truncation_after_a_whole_record_is_rejected() {
571        let one = b"{\x01\x02a=\x02\x02}";
572        let mut data = one.to_vec();
573        data.push(b';');
574        data.extend_from_slice(&one[..4]); // second record cut short
575
576        let err = check_complete_fragment(&data).expect_err("must reject");
577        assert!(err.contains("cut short"), "{err}");
578    }
579
580    #[test]
581    fn from_env_explains_itself_when_unconfigured() {
582        // Not asserting on process env, only that the message is actionable.
583        let err = ClientError::Config("YT_PROXY is not set".to_owned());
584        assert!(err.to_string().contains("YT_PROXY"));
585    }
586}