1#![warn(missing_docs)]
45
46use std::time::{Duration, Instant};
47
48pub mod error;
50mod http;
51mod spec;
52pub 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
61const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
63
64const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
66
67#[derive(Debug, Clone)]
69pub struct Client {
70 transport: Transport,
71 poll_interval: Duration,
72}
73
74impl Client {
75 #[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 #[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 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 #[must_use]
121 pub fn with_poll_interval(mut self, interval: Duration) -> Self {
122 self.poll_interval = interval;
123 self
124 }
125
126 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 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", ¶ms, Payload::None)?;
167 Ok(matches!(
168 self.value_field(&body, "exists")?.node,
169 YsonNode::Boolean(true)
170 ))
171 }
172
173 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", ¶ms, Payload::None)?;
189 Ok(())
190 }
191
192 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", ¶ms, Payload::None)?;
205 Ok(())
206 }
207
208 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", ¶ms, Payload::None)?;
218 self.value_field(&body, "value")
219 }
220
221 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 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 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", ¶ms, Payload::Bytes(contents))?;
266 Ok(())
267 }
268
269 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", ¶ms, Payload::Bytes(&encoded))?;
287 Ok(())
288 }
289
290 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", ¶ms, Payload::Bytes(rows))?;
305 Ok(())
306 }
307
308 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", ¶ms, 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 pub fn start_map(&self, spec: &MapSpec) -> Result<String> {
347 self.start_operation(OperationType::Map, &spec.to_yson())
348 }
349
350 pub fn start_map_reduce(&self, spec: &MapReduceSpec) -> Result<String> {
356 self.start_operation(OperationType::MapReduce, &spec.to_yson())
357 }
358
359 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", ¶ms, 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 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", ¶ms, 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 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 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", ¶ms, Payload::None)
460 .ok()?;
461 Some(crate::error::truncate(&String::from_utf8_lossy(&body), 600))
462 }
463
464 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
504fn 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 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]); 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 let err = ClientError::Config("YT_PROXY is not set".to_owned());
584 assert!(err.to_string().contains("YT_PROXY"));
585 }
586}