#![warn(missing_docs)]
use std::time::{Duration, Instant};
pub mod error;
mod http;
mod spec;
pub mod yson_build;
pub use crate::error::{ClientError, Result};
pub use crate::spec::{MapReduceSpec, MapSpec, OperationType};
use crate::http::{Method, Payload, Transport};
use ytsaurus_yson::{YsonFormat, YsonNode, YsonValue, from_slice};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
#[derive(Debug, Clone)]
pub struct Client {
transport: Transport,
poll_interval: Duration,
}
impl Client {
#[must_use]
pub fn new(proxy: &str) -> Self {
Self {
transport: Transport::new(proxy, None, DEFAULT_TIMEOUT),
poll_interval: DEFAULT_POLL_INTERVAL,
}
}
#[must_use]
pub fn with_token(proxy: &str, token: impl Into<String>) -> Self {
Self {
transport: Transport::new(proxy, Some(token.into()), DEFAULT_TIMEOUT),
poll_interval: DEFAULT_POLL_INTERVAL,
}
}
pub fn from_env() -> Result<Self> {
let proxy = std::env::var("YT_PROXY").map_err(|_| {
ClientError::Config(
"YT_PROXY is not set; export it (for a local cluster: \
YT_PROXY=http://localhost:8000) or use Client::new"
.to_owned(),
)
})?;
let token = std::env::var("YT_TOKEN")
.ok()
.filter(|t| !t.trim().is_empty());
Ok(match token {
Some(token) => Self::with_token(&proxy, token),
None => Self::new(&proxy),
})
}
#[must_use]
pub fn with_poll_interval(mut self, interval: Duration) -> Self {
self.poll_interval = interval;
self
}
pub fn heavy_proxy(&self) -> Result<Option<String>> {
let url = format!("{}/hosts", self.transport.base());
let body = ureq::get(&url)
.call()
.map_err(|e| ClientError::Transport {
command: "hosts".to_owned(),
source: Box::new(e),
})?
.body_mut()
.read_to_string()
.map_err(|e| ClientError::Decode {
command: "hosts".to_owned(),
reason: e.to_string(),
})?;
let hosts: Vec<String> = serde_json::from_str(&body).unwrap_or_default();
Ok(hosts.into_iter().next())
}
pub fn exists(&self, path: &str) -> Result<bool> {
let params = yson_build::map([("path", yson_build::string(path))]);
let body = self
.transport
.call(Method::Get, "exists", ¶ms, Payload::None)?;
Ok(matches!(
self.value_field(&body, "exists")?.node,
YsonNode::Boolean(true)
))
}
pub fn create(&self, node_type: &str, path: &str) -> Result<()> {
let params = yson_build::map([
("path", yson_build::string(path)),
("type", yson_build::string(node_type)),
("recursive", yson_build::boolean(true)),
("ignore_existing", yson_build::boolean(true)),
]);
self.transport
.call(Method::Post, "create", ¶ms, Payload::None)?;
Ok(())
}
pub fn remove(&self, path: &str) -> Result<()> {
let params = yson_build::map([
("path", yson_build::string(path)),
("recursive", yson_build::boolean(true)),
("force", yson_build::boolean(true)),
]);
self.transport
.call(Method::Post, "remove", ¶ms, Payload::None)?;
Ok(())
}
pub fn get(&self, path: &str) -> Result<YsonValue> {
let params = yson_build::map([("path", yson_build::string(path))]);
let body = self
.transport
.call(Method::Get, "get", ¶ms, Payload::None)?;
self.value_field(&body, "value")
}
pub fn row_count(&self, path: &str) -> Result<i64> {
let value = self.get(&format!("{path}/@row_count"))?;
value.as_i64().ok_or_else(|| ClientError::Decode {
command: "get".to_owned(),
reason: format!("{path}/@row_count is not an integer"),
})
}
pub fn upload_worker(&self, local: impl AsRef<std::path::Path>, remote: &str) -> Result<()> {
let local = local.as_ref();
let bytes = std::fs::read(local).map_err(|source| ClientError::Io {
path: local.display().to_string(),
source,
})?;
self.create("file", remote)?;
self.write_file(remote, &bytes)?;
self.set_attribute(remote, "executable", yson_build::boolean(true))
}
pub fn write_file(&self, path: &str, contents: &[u8]) -> Result<()> {
let params = yson_build::map([("path", yson_build::string(path))]);
self.transport
.call(Method::Put, "write_file", ¶ms, Payload::Bytes(contents))?;
Ok(())
}
pub fn set_attribute(&self, path: &str, name: &str, value: YsonValue) -> Result<()> {
let encoded =
ytsaurus_yson::to_vec(&value, YsonFormat::Binary).map_err(|e| ClientError::Decode {
command: "set".to_owned(),
reason: format!("could not encode the attribute: {e}"),
})?;
let params = yson_build::map([
("path", yson_build::string(format!("{path}/@{name}"))),
("input_format", yson_build::binary_yson_format()),
]);
self.transport
.call(Method::Put, "set", ¶ms, Payload::Bytes(&encoded))?;
Ok(())
}
pub fn write_table(&self, path: &str, rows: &[u8]) -> Result<()> {
let params = yson_build::map([
("path", yson_build::string(path)),
("input_format", yson_build::binary_yson_format()),
]);
self.transport
.call(Method::Put, "write_table", ¶ms, Payload::Bytes(rows))?;
Ok(())
}
pub fn read_table(&self, path: &str) -> Result<Vec<u8>> {
let params = yson_build::map([
("path", yson_build::string(path)),
("output_format", yson_build::binary_yson_format()),
]);
let body = self
.transport
.call(Method::Get, "read_table", ¶ms, Payload::None)?;
check_complete_fragment(&body).map_err(|reason| ClientError::Decode {
command: "read_table".to_owned(),
reason: format!("{path}: {reason}"),
})?;
Ok(body)
}
pub fn start_map(&self, spec: &MapSpec) -> Result<String> {
self.start_operation(OperationType::Map, &spec.to_yson())
}
pub fn start_map_reduce(&self, spec: &MapReduceSpec) -> Result<String> {
self.start_operation(OperationType::MapReduce, &spec.to_yson())
}
pub fn start_operation(&self, kind: OperationType, spec: &YsonValue) -> Result<String> {
let params = yson_build::map([
("operation_type", yson_build::string(kind.as_str())),
("spec", spec.clone()),
]);
let body = self
.transport
.call(Method::Post, "start_operation", ¶ms, Payload::None)?;
let value = self.value_field(&body, "operation_id")?;
match &value.node {
YsonNode::String(bytes) => Ok(String::from_utf8_lossy(bytes).into_owned()),
other => Err(ClientError::Decode {
command: "start_operation".to_owned(),
reason: format!("operation_id is not a string: {other:?}"),
}),
}
}
pub fn operation_state(&self, id: &str) -> Result<String> {
let params = yson_build::map([
("operation_id", yson_build::string(id)),
(
"attributes",
yson_build::list([yson_build::string("state")]),
),
]);
let body = self
.transport
.call(Method::Get, "get_operation", ¶ms, Payload::None)?;
let value = self.field_of(&self.strip_envelope(&body, "get_operation")?, "state")?;
match &value.node {
YsonNode::String(bytes) => Ok(String::from_utf8_lossy(bytes).into_owned()),
other => Err(ClientError::Decode {
command: "get_operation".to_owned(),
reason: format!("state is not a string: {other:?}"),
}),
}
}
pub fn wait_for_operation(&self, id: &str) -> Result<()> {
let started = Instant::now();
let mut last_state = String::new();
loop {
let state = self.operation_state(id)?;
if state != last_state {
eprintln!(
"operation {id}: {state} ({:.0}s)",
started.elapsed().as_secs_f64()
);
last_state.clone_from(&state);
}
match state.as_str() {
"completed" => return Ok(()),
"failed" | "aborted" => {
return Err(ClientError::OperationFailed {
id: id.to_owned(),
state,
error: self.operation_error(id),
});
}
_ => std::thread::sleep(self.poll_interval),
}
}
}
fn operation_error(&self, id: &str) -> Option<String> {
let params = yson_build::map([
("operation_id", yson_build::string(id)),
(
"attributes",
yson_build::list([yson_build::string("result")]),
),
]);
let body = self
.transport
.call(Method::Get, "get_operation", ¶ms, Payload::None)
.ok()?;
Some(crate::error::truncate(&String::from_utf8_lossy(&body), 600))
}
fn strip_envelope(&self, body: &[u8], command: &str) -> Result<YsonValue> {
from_slice(body, YsonFormat::Text).map_err(|e| ClientError::Decode {
command: command.to_owned(),
reason: format!(
"{e}; body was {}",
crate::error::truncate(&String::from_utf8_lossy(body), 200)
),
})
}
fn field_of(&self, value: &YsonValue, key: &str) -> Result<YsonValue> {
match &value.node {
YsonNode::Map(m) => m
.get(key.as_bytes())
.cloned()
.ok_or_else(|| ClientError::Decode {
command: key.to_owned(),
reason: format!(
"response has no {key:?}; keys were {:?}",
m.keys()
.map(|k| String::from_utf8_lossy(k).into_owned())
.collect::<Vec<_>>()
),
}),
other => Err(ClientError::Decode {
command: key.to_owned(),
reason: format!("expected a dict, got {other:?}"),
}),
}
}
fn value_field(&self, body: &[u8], key: &str) -> Result<YsonValue> {
let envelope = self.strip_envelope(body, key)?;
self.field_of(&envelope, key)
}
}
fn check_complete_fragment(mut data: &[u8]) -> std::result::Result<(), String> {
use ytsaurus_yson::{Scan, scan_value};
let total = data.len();
loop {
while data.first() == Some(&b';') || data.first().is_some_and(u8::is_ascii_whitespace) {
data = &data[1..];
}
if data.is_empty() {
return Ok(());
}
match scan_value(data, YsonFormat::Binary) {
Ok(Scan::Complete { len }) => data = &data[len..],
Ok(Scan::Incomplete) => {
return Err(format!(
"the response ends inside a record — {} of {total} bytes consumed; \
the stream was cut short",
total - data.len()
));
}
Err(e) => {
return Err(format!(
"the response is not valid binary YSON at byte {}: {e}",
total - data.len()
));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_complete_fragment_is_accepted() {
let one = b"{\x01\x02a=\x02\x02}";
let mut two = one.to_vec();
two.push(b';');
two.extend_from_slice(one);
assert!(check_complete_fragment(b"").is_ok());
assert!(check_complete_fragment(one).is_ok());
assert!(check_complete_fragment(&two).is_ok());
}
#[test]
fn a_truncated_fragment_is_rejected() {
let full = b"{\x01\x02a=\x02\x02}";
for cut in 1..full.len() {
let err = check_complete_fragment(&full[..cut])
.expect_err("a cut record must not pass as complete");
assert!(
err.contains("cut short") || err.contains("not valid"),
"{err}"
);
}
}
#[test]
fn truncation_after_a_whole_record_is_rejected() {
let one = b"{\x01\x02a=\x02\x02}";
let mut data = one.to_vec();
data.push(b';');
data.extend_from_slice(&one[..4]);
let err = check_complete_fragment(&data).expect_err("must reject");
assert!(err.contains("cut short"), "{err}");
}
#[test]
fn from_env_explains_itself_when_unconfigured() {
let err = ClientError::Config("YT_PROXY is not set".to_owned());
assert!(err.to_string().contains("YT_PROXY"));
}
}