use std::process::ExitCode;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use ytsaurus_client::{Client, ClientError, Column, ColumnType, TablePath, TableSchema};
use ytsaurus_yson::YsonFormat;
const BASE: &str = "//tmp/ytsaurus_rs_append";
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("\nappend failed: {e}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<(), ClientError> {
let client = Client::from_env()?;
let log = format!("{BASE}/log");
step("Preparing Cypress");
client.remove_tree(BASE)?;
client.create("map_node", BASE)?;
client.create("table", &log)?;
done(BASE);
step("Three writes to the same table");
client.write_table_rows(&log, entries(0..3))?;
check(
"a plain write puts 3 rows there",
client.row_count(&log)? == 3,
)?;
client.write_table_rows(&log, entries(3..5))?;
check(
"a second plain write replaces them: 2 rows",
client.row_count(&log)? == 2,
)?;
client.write_table_rows(TablePath::new(&log).append(), entries(5..9))?;
check(
"an appending write adds to them: 6 rows",
client.row_count(&log)? == 6,
)?;
let kept: Vec<Entry> = client.read_table_rows(&log)?;
let survived = kept.len() == 6 && kept[0].n == 3 && kept[1].n == 4 && kept[5].n == 8;
check(
&format!("and the rows already there are the ones that were: {survived}"),
survived,
)?;
step("Appending to a table that is sorted");
let sorted = format!("{BASE}/sorted");
let schema = TableSchema::new([
Column::new("n", ColumnType::Int64).required().key(),
Column::new("payload", ColumnType::Utf8).required(),
]);
client.create_table(&sorted, &schema)?;
client.write_table_rows(&sorted, entries(10..13))?;
client.write_table_rows(TablePath::new(&sorted).append(), entries(13..16))?;
check(
"6 rows, and the table is still sorted",
client.row_count(&sorted)? == 6 && is_sorted(&client, &sorted)?,
)?;
match client.write_table_rows(TablePath::new(&sorted).append(), entries(0..1)) {
Ok(()) => {
eprintln!(" FAIL a key smaller than the last was appended to a sorted table");
return Err(ClientError::Config(
"the cluster did not enforce sort order on append".to_owned(),
));
}
Err(e) => {
check(
"and a key smaller than the last is refused",
e.to_string().contains("Sort order violation"),
)?;
println!(" {}", first_line(&e.to_string()));
}
}
step("Appending to a table that does not exist");
match client.write_table_rows(
TablePath::new(format!("{BASE}/missing")).append(),
entries(0..1),
) {
Ok(()) => {
return Err(ClientError::Config(
"appending created a table, which it is not supposed to do".to_owned(),
));
}
Err(e) => {
check(
"refused: the table has to exist first",
e.to_string()
.contains("Error getting basic attributes of user objects"),
)?;
println!(" {}", first_line(&e.to_string()));
}
}
step("Appending to something that is not a table");
let file = format!("{BASE}/afile");
client.create("file", &file)?;
client.write_file(&file, b"not a table")?;
match client.write_table_rows(TablePath::new(&file).append(), entries(0..1)) {
Ok(()) => {
return Err(ClientError::Config(
"rows were appended to a file node".to_owned(),
));
}
Err(e) => {
check(
"a file node is refused by type, not by parse error",
e.to_string().contains(r#"expected "table", actual "file""#),
)?;
println!(" {}", first_line(&e.to_string()));
}
}
step("Zero rows");
let empty = format!("{BASE}/empty");
client.create("table", &empty)?;
client.write_table_rows(&empty, entries(0..4))?;
client.write_table_rows(TablePath::new(&empty).append(), entries(0..0))?;
check(
"appending no rows leaves the 4 that were there",
client.row_count(&empty)? == 4,
)?;
client.write_table_rows(&empty, entries(0..0))?;
check(
"writing no rows empties the table: 0",
client.row_count(&empty)? == 0,
)?;
step("The other two writers");
let others = format!("{BASE}/others");
client.create("table", &others)?;
client.write_table_streaming(&others, std::io::Cursor::new(encoded(entries(0..2))))?;
client.write_table_streaming(
TablePath::new(&others).append(),
std::io::Cursor::new(encoded(entries(2..5))),
)?;
check(
"write_table_streaming appends: 2 then 3 makes 5",
client.row_count(&others)? == 5,
)?;
client.write_table(TablePath::new(&others).append(), &encoded(entries(5..6)))?;
check(
"write_table appends too: 6",
client.row_count(&others)? == 6,
)?;
step("Appending inside a transaction");
let staged = format!("{BASE}/staged");
client.create("table", &staged)?;
client.write_table_rows(&staged, entries(0..3))?;
{
let tx = client.start_transaction()?;
tx.client()
.write_table_rows(TablePath::new(&staged).append(), entries(3..6))?;
check(
"inside the transaction the table has 6 rows",
tx.client().row_count(&staged)? == 6,
)?;
check(
"outside it, still 3",
client.row_count(&staged)? == 3 && client.read_table_rows::<Entry>(&staged)?.len() == 3,
)?;
tx.abort()?;
}
check(
"and after the abort the appended rows are gone: 3",
client.row_count(&staged)? == 3,
)?;
{
let tx = client.start_transaction()?;
tx.client()
.write_table_rows(TablePath::new(&staged).append(), entries(3..6))?;
tx.commit()?;
}
check(
"a committed one keeps them: 6",
client.row_count(&staged)? == 6,
)?;
step("Four writers at once");
let shared = format!("{BASE}/shared");
client.create("table", &shared)?;
check(
"four concurrent appends of 100 rows: all 400 land",
raced(&client, &shared, true)? == 400,
)?;
let exclusive = format!("{BASE}/exclusive");
client.create("table", &exclusive)?;
check(
"four concurrent replaces leave one writer's 100",
raced(&client, &exclusive, false)? == 100,
)?;
step("What a reader sees before an append commits");
let inflight = format!("{BASE}/inflight");
client.create("table", &inflight)?;
client.write_table_rows(&inflight, entries(0..10))?;
let body = encoded(entries(10..40));
let tx = client.start_transaction()?;
let (path, writing) = (inflight.clone(), tx.client().clone());
let upload = std::thread::spawn(move || {
writing.write_table_streaming(TablePath::new(&path).append(), Trickle::new(body))
});
let mut seen = Vec::new();
while !upload.is_finished() {
seen.push(client.row_count(&inflight)?);
std::thread::sleep(Duration::from_millis(100));
}
upload.join().expect("the upload thread finished")?;
check(
&readings_report(&seen),
!seen.is_empty() && seen.iter().all(|&n| n == 10),
)?;
tx.commit()?;
check("and 40 once it commits", client.row_count(&inflight)? == 40)?;
benchmark(&client)?;
println!("\nA path is a YSON value, not a string, and `<append=%true>` is what it says.");
println!("Tables left at {BASE}");
Ok(())
}
fn benchmark(client: &Client) -> Result<(), ClientError> {
let rows = number("YT_APPEND_ROWS", 60_000);
let chunks = number("YT_APPEND_CHUNKS", 12).max(1);
let per_chunk = (rows / chunks).max(1);
step(&format!(
"Writing {rows} rows in {chunks} pieces, both ways"
));
let appended = format!("{BASE}/bench_append");
client.create("table", &appended)?;
let by_append = time(|| {
for chunk in 0..chunks {
let from = chunk * per_chunk;
client.write_table_rows(
TablePath::new(&appended).append(),
entries(from..from + per_chunk),
)?;
}
Ok(())
})?;
let sent_appending = per_chunk * chunks;
let rewritten = format!("{BASE}/bench_rewrite");
client.create("table", &rewritten)?;
let by_rewrite = time(|| {
for chunk in 0..chunks {
client.write_table_rows(&rewritten, entries(0..(chunk + 1) * per_chunk))?;
}
Ok(())
})?;
let sent_rewriting = per_chunk * chunks * (chunks + 1) / 2;
check(
"both tables ended up the same size",
client.row_count(&appended)? == client.row_count(&rewritten)?,
)?;
println!(
" appending {:>6.2}s {:>9} rows sent",
by_append.as_secs_f64(),
sent_appending
);
println!(
" rewriting {:>6.2}s {:>9} rows sent ({:.1}× the data)",
by_rewrite.as_secs_f64(),
sent_rewriting,
sent_rewriting as f64 / sent_appending as f64
);
check(
&format!("appending sent {sent_appending} rows against {sent_rewriting}"),
sent_appending < sent_rewriting,
)?;
Ok(())
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct Entry {
n: i64,
payload: String,
}
fn entries(range: std::ops::Range<u64>) -> impl Iterator<Item = Entry> {
range.map(|n| Entry {
n: n as i64,
payload: format!("entry {n:08} 0123456789abcdef0123456789abcdef"),
})
}
fn encoded(rows: impl Iterator<Item = Entry>) -> Vec<u8> {
let mut out = Vec::new();
for row in rows {
out.extend_from_slice(&ytsaurus_yson::to_vec(&row, YsonFormat::Binary).expect("encodes"));
out.push(b';');
}
out
}
fn raced(client: &Client, path: &str, appending: bool) -> Result<i64, ClientError> {
let mut writers = Vec::new();
for worker in 0..4_u64 {
let (client, path) = (client.clone(), path.to_owned());
writers.push(std::thread::spawn(move || {
let rows = entries(worker * 100..worker * 100 + 100);
if appending {
client.write_table_rows(TablePath::new(&path).append(), rows)
} else {
client.write_table_rows(&path, rows)
}
}));
}
let refused = writers
.into_iter()
.map(|w| w.join().expect("the writer thread finished"))
.filter(Result::is_err)
.count();
println!(" {refused} of the 4 were refused");
client.row_count(path)
}
struct Trickle {
data: Vec<u8>,
at: usize,
}
impl Trickle {
fn new(data: Vec<u8>) -> Self {
Self { data, at: 0 }
}
}
impl std::io::Read for Trickle {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.at >= self.data.len() {
return Ok(0);
}
std::thread::sleep(Duration::from_millis(200));
let n = buf.len().min(64).min(self.data.len() - self.at);
buf[..n].copy_from_slice(&self.data[self.at..self.at + n]);
self.at += n;
Ok(n)
}
}
fn is_sorted(client: &Client, path: &str) -> Result<bool, ClientError> {
client.get_as::<bool>(&format!("{path}/@sorted"))
}
fn time(work: impl FnOnce() -> Result<(), ClientError>) -> Result<Duration, ClientError> {
let started = Instant::now();
work()?;
Ok(started.elapsed())
}
fn number(name: &str, default: u64) -> u64 {
std::env::var(name)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
fn first_line(message: &str) -> &str {
message.lines().next().unwrap_or(message)
}
fn readings_report(readings: &[i64]) -> String {
format!("readings before the transaction commits: {readings:?}")
}
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}")))
}
#[cfg(test)]
mod tests {
use super::readings_report;
#[test]
fn readings_report_keeps_every_observation() {
assert_eq!(
readings_report(&[10, 10, 40]),
"readings before the transaction commits: [10, 10, 40]"
);
}
}