use std::collections::BTreeMap;
use std::process::ExitCode;
use ytsaurus_client::{Client, ClientError, MapReduceSpec, MapSpec};
const BASE: &str = "//tmp/ytsaurus_rs_e2e";
const WORKER_DIR: &str = "target/x86_64-unknown-linux-musl/release-worker";
const FIXTURES: &str = "tests/e2e/fixtures";
const LINES: [&str; 4] = [
"the quick brown fox",
"jumps over the lazy dog",
"the fox and the dog",
"quick quick fox",
];
const EXPECTED: [(&str, i64); 9] = [
("and", 1),
("brown", 1),
("dog", 2),
("fox", 3),
("jumps", 1),
("lazy", 1),
("over", 1),
("quick", 3),
("the", 4),
];
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("\ne2e failed: {e}");
let mut source = std::error::Error::source(&e);
while let Some(cause) = source {
eprintln!(" caused by: {cause}");
source = cause.source();
}
ExitCode::FAILURE
}
}
}
fn run() -> Result<(), ClientError> {
let client = Client::from_env()?;
step("Preflight");
for worker in ["cat", "wordcount"] {
let path = format!("{WORKER_DIR}/{worker}");
if !std::path::Path::new(&path).exists() {
return Err(ClientError::Config(format!(
"{path} is missing; build it with: scripts/build-worker.sh cat wordcount"
)));
}
}
let rows0 = fixture("table_rows_0.bin")?;
let rows1 = fixture("table_rows_1.bin")?;
done(&format!(
"workers built, fixtures read ({} and {} bytes)",
rows0.len(),
rows1.len()
));
step("Preparing Cypress");
client.remove_tree(BASE)?;
client.create("map_node", BASE)?;
client.upload_worker(format!("{WORKER_DIR}/cat"), &format!("{BASE}/cat"))?;
client.upload_worker(
format!("{WORKER_DIR}/wordcount"),
&format!("{BASE}/wordcount"),
)?;
done(&format!("{BASE}, with both workers uploaded"));
identity(&client, &rows0)?;
table_switching(&client, &rows0, &rows1)?;
wordcount(&client)?;
println!("\nAll end-to-end checks passed, and nothing Python ran.");
println!("Cypress tree left at {BASE}; remove it with Client::remove_tree.");
Ok(())
}
fn identity(client: &Client, rows: &[u8]) -> Result<(), ClientError> {
step("Running cat as a map operation");
client.create("table", &format!("{BASE}/input"))?;
client.write_table(format!("{BASE}/input"), rows)?;
client.create("table", &format!("{BASE}/output"))?;
let spec = MapSpec::new(
"./cat",
[format!("{BASE}/input")],
[format!("{BASE}/output")],
)
.with_local_file(format!("{BASE}/cat"))
.with_memory_limit(512 * 1024 * 1024);
let id = client.start_map(&spec)?;
client.wait_for_operation(&id)?;
done(&format!("operation {id} finished"));
step("Comparing input and output byte-for-byte");
let before = client.read_table(format!("{BASE}/input"))?;
let after = client.read_table(format!("{BASE}/output"))?;
check(
&format!("identical ({} bytes)", before.len()),
before == after,
)
}
fn table_switching(client: &Client, rows0: &[u8], rows1: &[u8]) -> Result<(), ClientError> {
step("Two input tables, two output tables, with table switching");
for (table, rows) in [("in0", rows0), ("in1", rows1)] {
client.create("table", &format!("{BASE}/{table}"))?;
client.write_table(format!("{BASE}/{table}"), rows)?;
}
for table in ["out0", "out1"] {
client.create("table", &format!("{BASE}/{table}"))?;
}
let spec = MapSpec::new(
"./cat --tables 2",
[format!("{BASE}/in0"), format!("{BASE}/in1")],
[format!("{BASE}/out0"), format!("{BASE}/out1")],
)
.with_local_file(format!("{BASE}/cat"))
.with_memory_limit(512 * 1024 * 1024)
.with_input_table_index();
let id = client.start_map(&spec)?;
client.wait_for_operation(&id)?;
for i in 0..2 {
let input = client.read_table(format!("{BASE}/in{i}"))?;
let output = client.read_table(format!("{BASE}/out{i}"))?;
check(
&format!("table {i} identical ({} bytes)", input.len()),
input == output,
)?;
}
Ok(())
}
fn wordcount(client: &Client) -> Result<(), ClientError> {
step("Wordcount map-reduce");
client.create("table", &format!("{BASE}/lines"))?;
client.write_table_rows(
format!("{BASE}/lines"),
LINES.iter().map(|text| Line { text }),
)?;
client.create("table", &format!("{BASE}/counts"))?;
let spec = MapReduceSpec::new(
"./wordcount reduce",
[format!("{BASE}/lines")],
[format!("{BASE}/counts")],
["word"],
)
.with_mapper("./wordcount map")
.with_local_file(format!("{BASE}/wordcount"))
.with_memory_limit(512 * 1024 * 1024);
let id = client.start_map_reduce(&spec)?;
client.wait_for_operation(&id)?;
done(&format!("operation {id} finished"));
let counted: BTreeMap<String, i64> = client
.read_table_rows::<Total>(format!("{BASE}/counts"))?
.into_iter()
.map(|row| (row.word, row.count))
.collect();
let expected: BTreeMap<String, i64> = EXPECTED
.iter()
.map(|(word, count)| ((*word).to_owned(), *count))
.collect();
if counted != expected {
eprintln!(" got {counted:?}");
eprintln!(" expected {expected:?}");
}
check(
&format!("wordcount matches the reference ({} words)", counted.len()),
counted == expected,
)
}
#[derive(serde::Serialize)]
struct Line<'a> {
text: &'a str,
}
#[derive(serde::Deserialize)]
struct Total {
word: String,
count: i64,
}
fn fixture(name: &str) -> Result<Vec<u8>, ClientError> {
let path = format!("{FIXTURES}/{name}");
std::fs::read(&path).map_err(|e| {
ClientError::Config(format!(
"cannot read {path}: {e}. Run from the repository root; the \
fixtures are committed, and `tests/e2e/generate_fixtures.py` \
rebuilds them."
))
})
}
fn step(what: &str) {
println!("\n== {what}");
}
fn done(what: &str) {
println!(" ok {what}");
}
fn check(what: &str, passed: bool) -> Result<(), ClientError> {
if passed {
done(what);
return Ok(());
}
eprintln!(" FAIL {what}");
Err(ClientError::Config(format!("check failed: {what}")))
}