use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use ytsaurus_client::{
Client, ClientError, DataFormat, Key, Method, OperationFilter, OperationParameters,
RetryPolicy, RowRange, SkiffFormat, SkiffSchema, SkiffSchemaRef, SkiffWireType, TablePath,
TraceContext, yson_build,
};
fn capture(request_from: impl FnOnce(&str)) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("binds");
let address = listener.local_addr().expect("has an address");
let served = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accepts");
let mut reader = BufReader::new(stream.try_clone().expect("clones"));
let mut head = String::new();
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => break,
Ok(_) if line == "\r\n" => break,
Ok(_) => head.push_str(&line),
Err(_) => break,
}
}
if let Some(length) = content_length(&head) {
let mut body = vec![0_u8; length];
reader.read_exact(&mut body).ok();
} else if head.to_lowercase().contains("transfer-encoding: chunked") {
drain_chunked(&mut reader);
}
let body = br#"{"value"=%true}"#;
let mut reply = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/x-yt-yson-text\r\n\r\n",
body.len()
)
.into_bytes();
reply.extend_from_slice(body);
stream.write_all(&reply).expect("replies");
stream.flush().ok();
head
});
request_from(&format!("http://{address}"));
served.join().expect("the listener thread finished")
}
fn drain_chunked(reader: &mut BufReader<std::net::TcpStream>) {
loop {
let mut header = String::new();
if reader.read_line(&mut header).is_err() {
return;
}
let size = usize::from_str_radix(header.trim(), 16).unwrap_or(0);
if size == 0 {
let mut trailer = String::new();
reader.read_line(&mut trailer).ok();
return;
}
let mut chunk = vec![0_u8; size + 2]; if reader.read_exact(&mut chunk).is_err() {
return;
}
}
}
fn content_length(head: &str) -> Option<usize> {
head.lines()
.find(|line| line.to_lowercase().starts_with("content-length:"))
.and_then(|line| line.split(':').nth(1))
.and_then(|value| value.trim().parse().ok())
}
fn header_value(head: &str, name: &str) -> Option<String> {
head.lines()
.find(|line| {
line.to_lowercase()
.starts_with(&format!("{}:", name.to_lowercase()))
})
.map(|line| line[line.find(':').unwrap_or(0) + 1..].trim().to_owned())
}
fn parameters(head: &str) -> String {
head.lines()
.find(|line| line.to_lowercase().starts_with("x-yt-parameters:"))
.map(|line| line[line.find(':').unwrap_or(0) + 1..].trim().to_owned())
.unwrap_or_default()
}
#[test]
fn a_plain_write_replaces_and_says_nothing_about_it() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
client.write_table("//tmp/out", b"").expect("writes");
});
assert!(
parameters(&head).contains(r#"path="//tmp/out""#),
"the path is not a bare string:\n{head}"
);
assert!(
!parameters(&head).contains("append"),
"a replacing write mentioned append:\n{head}"
);
}
#[test]
fn an_appending_write_carries_the_attribute_on_the_path() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
client
.write_table(TablePath::new("//tmp/out").append(), b"")
.expect("writes");
});
assert!(
parameters(&head).contains(r#"path=<append=%true>"//tmp/out""#),
"the path does not carry the attribute:\n{head}"
);
}
#[test]
fn all_three_writers_can_append() {
let row = std::collections::BTreeMap::from([("n", 1_i64)]);
let heads = [
(
"write_table",
capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
client
.write_table(TablePath::new("//tmp/out").append(), b"")
.expect("writes");
}),
),
(
"write_table_rows",
capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
client
.write_table_rows(TablePath::new("//tmp/out").append(), [row])
.expect("writes");
}),
),
(
"write_table_streaming",
capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
client
.write_table_streaming(
TablePath::new("//tmp/out").append(),
std::io::Cursor::new(Vec::new()),
)
.expect("writes");
}),
),
];
for (command, head) in heads {
assert!(
parameters(&head).contains(r#"path=<append=%true>"//tmp/out""#),
"{command} sent a path without the attribute:\n{head}"
);
}
}
#[test]
fn a_column_selection_travels_as_an_attribute_on_the_path() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
let _ = client.read_table(TablePath::new("//tmp/wide").columns(["host", "status"]));
});
assert!(
head.starts_with("GET /api/v4/read_table"),
"not a read_table:\n{head}"
);
assert!(
parameters(&head).contains(r#"path=<columns=[host;status]>"//tmp/wide""#),
"the selection is not on the path:\n{head}"
);
}
#[test]
fn a_row_range_travels_in_the_documented_limits() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
let _ = client.read_table(TablePath::new("//tmp/t").range(0..2));
});
assert!(
parameters(&head).contains(
r#"path=<ranges=[{lower_limit={row_index=0};upper_limit={row_index=2}}]>"//tmp/t""#
),
"the range is not on the path:\n{head}"
);
}
#[test]
fn key_bounds_travel_in_the_clusters_representation() {
let plain = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
let _ = client.read_table(
TablePath::new("//tmp/sorted")
.range(RowRange::keys(Key::from("alice")..Key::from("bob"))),
);
});
assert!(
parameters(&plain).contains(
r#"path=<ranges=[{lower_limit={key=[alice]};upper_limit={key=[bob]}}]>"//tmp/sorted""#
),
"an inclusive..exclusive key range is not the plain key form:\n{plain}"
);
let bounds = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
let _ = client.read_table(TablePath::new("//tmp/sorted").range(RowRange::keys((
std::ops::Bound::Excluded(Key::from("alice")),
std::ops::Bound::Included(Key::from("bob")),
))));
});
assert!(
parameters(&bounds).contains(
r#"path=<ranges=[{lower_limit={key_bound=[">";[alice]]};upper_limit={key_bound=["<=";[bob]]}}]>"//tmp/sorted""#
),
"the other two inclusivities are not key_bound:\n{bounds}"
);
}
#[test]
fn an_exact_key_read_asks_in_the_clusters_word_for_it() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
let _ = client.read_table(
TablePath::new("//tmp/sorted").range(RowRange::exact_key(Key::from("alice"))),
);
});
assert!(
parameters(&head).contains(r#"path=<ranges=[{exact={key=[alice]}}]>"//tmp/sorted""#),
"the exact selector is not on the path:\n{head}"
);
}
#[test]
fn all_three_readers_carry_the_selection() {
let selected = || TablePath::new("//tmp/wide").columns(["host"]).range(0..100);
let expected = r#"path=<columns=[host];ranges=[{lower_limit={row_index=0};upper_limit={row_index=100}}]>"//tmp/wide""#;
let heads = [
(
"read_table",
capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
let _ = client.read_table(selected());
}),
),
(
"read_table_rows",
capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
let _ =
client.read_table_rows::<std::collections::BTreeMap<String, i64>>(selected());
}),
),
(
"read_table_streaming",
capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
let _ = client.read_table_streaming(selected());
}),
),
];
for (command, head) in heads {
assert!(
parameters(&head).contains(expected),
"{command} sent a path without the selection:\n{head}"
);
}
}
#[test]
fn a_skiff_read_merges_a_row_range_with_its_schema_columns() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
let _ = client.read_skiff_table(TablePath::new("//tmp/t").range(0..2), &one_column());
});
assert!(
parameters(&head).contains(
r#"path=<columns=[n];ranges=[{lower_limit={row_index=0};upper_limit={row_index=2}}]>"//tmp/t""#
),
"the Skiff read lost half the selection, or moved it off the path:\n{head}"
);
}
#[test]
fn a_skiff_read_refuses_a_column_selection_spelled_into_the_string() {
let client = Client::new(&nowhere()).with_retries(RetryPolicy::none());
for path in [
"//tmp/t{n}",
"<columns=[n]>//tmp/t",
"<primary_medium=default>//tmp/t",
] {
let error = client
.read_skiff_table(path, &one_column())
.expect_err("refused");
assert!(
matches!(&error, ClientError::Config(_)),
"{path} was not refused locally: {error}"
);
}
}
#[test]
fn a_skiff_read_sends_a_string_spelled_row_range() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
let _ = client.read_skiff_table("//tmp/t[#0:#2]", &one_column());
});
assert!(
parameters(&head).contains(r#"path=<columns=[n]>"//tmp/t[#0:#2]""#),
"the string-spelled range did not reach the cluster intact:\n{head}"
);
}
#[test]
fn a_skiff_read_refuses_a_second_column_selection() {
let client = Client::new(&nowhere()).with_retries(RetryPolicy::none());
let error = client
.read_skiff_table(TablePath::new("//tmp/t").columns(["n"]), &one_column())
.expect_err("refused");
assert!(
matches!(&error, ClientError::Config(reason) if reason.contains("columns")),
"not the local refusal: {error}"
);
}
#[test]
fn a_write_with_a_read_selection_is_refused_before_anything_is_sent() {
let client = Client::new(&nowhere()).with_retries(RetryPolicy::none());
let row = std::collections::BTreeMap::from([("n", 1_i64)]);
let refusals: Vec<(&str, ClientError)> = vec![
(
"write_table with columns",
client
.write_table(TablePath::new("//tmp/out").columns(["a"]), b"")
.expect_err("refused"),
),
(
"write_table with a range",
client
.write_table(TablePath::new("//tmp/out").range(0..2), b"")
.expect_err("refused"),
),
(
"write_table_rows with a range",
client
.write_table_rows(TablePath::new("//tmp/out").range(0..2), [row])
.expect_err("refused"),
),
(
"write_table_streaming with columns",
client
.write_table_streaming(
TablePath::new("//tmp/out").columns(["a"]),
std::io::Cursor::new(Vec::new()),
)
.expect_err("refused"),
),
(
"write_skiff_table with a range",
client
.write_skiff_table(TablePath::new("//tmp/out").range(0..2), b"", &one_column())
.expect_err("refused"),
),
];
for (writer, error) in refusals {
assert!(
matches!(&error, ClientError::Config(_)),
"{writer} did not refuse locally: {error}"
);
}
}
#[test]
fn a_write_path_string_spelling_a_selection_is_refused() {
let client = Client::new(&nowhere()).with_retries(RetryPolicy::none());
for path in ["//tmp/t[#0:#2]", "//tmp/t{a,b}", "<append=%true>//tmp/t"] {
let error = client.write_table(path, b"").expect_err("refused");
assert!(
matches!(&error, ClientError::Config(_)),
"{path} was not refused locally: {error}"
);
}
}
#[test]
fn an_escaped_bracket_is_a_node_name_and_still_writable() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
client.write_table(r"//tmp/t\[x\]", b"").expect("writes");
});
assert!(
head.starts_with("PUT /api/v4/write_table"),
"the write was not sent:\n{head}"
);
}
#[test]
fn a_read_keeps_passing_a_string_spelled_path_through() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
let _ = client.read_table("//tmp/t[#0:#2]");
});
assert!(
parameters(&head).contains(r#"path="//tmp/t[#0:#2]""#),
"the string-spelled path was rewritten:\n{head}"
);
}
#[test]
fn a_read_refuses_a_selection_spelled_twice() {
let client = Client::new(&nowhere()).with_retries(RetryPolicy::none());
let doubled = || TablePath::new("//tmp/t[#0:#2]").range(0..2);
let refusals: Vec<(&str, ClientError)> = vec![
(
"read_table",
client.read_table(doubled()).expect_err("refused"),
),
(
"read_table_rows",
client
.read_table_rows::<std::collections::BTreeMap<String, i64>>(doubled())
.expect_err("refused"),
),
(
"read_table_streaming",
match client.read_table_streaming(doubled()) {
Ok(_) => panic!("read_table_streaming did not refuse a doubled selection"),
Err(error) => error,
},
),
(
"read_skiff_table",
client
.read_skiff_table(TablePath::new("//tmp/t[#0:#2]").range(0..2), &one_column())
.expect_err("refused"),
),
];
for (reader, error) in refusals {
assert!(
matches!(&error, ClientError::Config(_)),
"{reader} did not refuse locally: {error}"
);
}
}
#[test]
fn a_read_refuses_a_range_asking_for_rows_no_table_has() {
let client = Client::new(&nowhere()).with_retries(RetryPolicy::none());
let (from, to) = (5_i64, 3_i64);
let refusals: Vec<(&str, ClientError)> = vec![
(
"rows(5..3)",
client
.read_table(TablePath::new("//tmp/t").range(from..to))
.expect_err("refused"),
),
(
"rows(-5..2), which the cluster would have read as rows(0..2)",
client
.read_table(TablePath::new("//tmp/t").range(-5..2))
.expect_err("refused"),
),
(
"keys(b..a), the same mistake in the other selector",
client
.read_table(
TablePath::new("//tmp/t").range(RowRange::keys(Key::from("b")..Key::from("a"))),
)
.expect_err("refused"),
),
];
for (selection, error) in refusals {
assert!(
matches!(&error, ClientError::Config(_)),
"{selection} did not refuse locally: {error}"
);
}
}
#[test]
fn a_read_sends_an_empty_column_selection() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
let _ = client.read_table(
TablePath::new("//tmp/t")
.columns(Vec::<String>::new())
.range(0..2),
);
});
assert!(
parameters(&head).contains(
r#"path=<columns=[];ranges=[{lower_limit={row_index=0};upper_limit={row_index=2}}]>"//tmp/t""#
),
"the empty projection did not travel as an empty `columns` attribute:\n{head}"
);
}
#[test]
fn an_abort_reason_cannot_break_out_of_its_header() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
client
.abort_operation(
"1-2-3-4",
Some("he said \"stop\"\r\nX-Forged: yes\nand meant it"),
)
.expect("aborts");
});
let params = parameters(&head);
assert!(
params.contains(r#"abort_message="he said \"stop\"\r\nX-Forged: yes\nand meant it""#),
"the reason was not escaped as YSON text:\n{head}"
);
assert!(
!head.lines().any(|line| line.starts_with("X-Forged")),
"the reason smuggled a header into the request:\n{head}"
);
assert_eq!(
head.lines()
.filter(|line| line.to_lowercase().starts_with("x-yt-parameters:"))
.count(),
1,
"{head}"
);
}
#[test]
fn an_abort_carries_its_reason_and_no_mutation_id() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
client
.abort_operation("1-2-3-4", Some("stopped by the test"))
.expect("aborts");
});
let params = parameters(&head);
assert!(head.starts_with("POST /api/v4/abort_operation"), "{head}");
assert!(params.contains(r#"operation_id="1-2-3-4""#), "{params}");
assert!(
params.contains(r#"abort_message="stopped by the test""#),
"{params}"
);
assert!(!params.contains("mutation_id"), "{params}");
assert!(!params.contains("retry="), "{params}");
}
#[test]
fn an_abort_without_a_reason_sends_no_empty_message() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
client.abort_operation("1-2-3-4", None).expect("aborts");
});
assert!(
!parameters(&head).contains("abort_message"),
"{}",
parameters(&head)
);
}
#[test]
fn every_request_asks_for_a_compressed_answer() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
assert_eq!(client.exists("//tmp").ok(), Some(true));
});
let lowercase = head.to_lowercase();
assert!(
lowercase.contains("accept-encoding: gzip"),
"the request did not ask for compression:\n{head}"
);
}
#[test]
fn the_parameters_travel_as_a_header_and_not_a_query_string() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
let _ = client.exists("//tmp/some/path");
});
assert!(
head.starts_with("GET /api/v4/exists HTTP/1.1"),
"the command is the path, with nothing appended:\n{head}"
);
assert!(
head.contains(r#"x-yt-parameters: {path="//tmp/some/path"}"#)
|| head.contains(r#"X-YT-Parameters: {path="//tmp/some/path"}"#),
"parameters are not in the header the protocol names:\n{head}"
);
assert!(
head.to_lowercase()
.contains("x-yt-header-format: <format=text>yson"),
"the header format must say how the other headers are encoded:\n{head}"
);
}
#[test]
fn a_token_is_carried_as_an_oauth_authorization() {
let head = capture(|proxy| {
let client = Client::with_token(proxy, "secret-token").with_retries(RetryPolicy::none());
let _ = client.exists("//tmp");
});
assert!(
head.contains("authorization: OAuth secret-token")
|| head.contains("Authorization: OAuth secret-token"),
"the token is not on the request:\n{head}"
);
}
#[test]
fn an_unauthenticated_client_sends_no_authorization_at_all() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
let _ = client.exists("//tmp");
});
assert!(
!head.to_lowercase().contains("authorization:"),
"an unauthenticated client sent an authorization header:\n{head}"
);
}
#[test]
fn a_trace_context_travels_as_a_traceparent_header() {
let context = TraceContext::parse("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
.expect("a W3C traceparent");
let head = capture(|proxy| {
let client = Client::new(proxy)
.with_retries(RetryPolicy::none())
.with_trace_context(&context);
assert_eq!(
client.traceparent(),
Some("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
);
let _ = client.exists("//tmp");
});
let value = header_value(&head, "traceparent");
assert_eq!(
value.as_deref(),
Some("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
"the trace context is not on the request as sent:\n{head}"
);
}
#[test]
fn a_tracestate_travels_beside_the_traceparent() {
let context = TraceContext::parse("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
.expect("a W3C traceparent")
.with_tracestate("vendora=t61rcWkgMzE,vendorb=x9");
let head = capture(|proxy| {
let client = Client::new(proxy)
.with_retries(RetryPolicy::none())
.with_trace_context(&context);
assert_eq!(client.tracestate(), Some("vendora=t61rcWkgMzE,vendorb=x9"));
let _ = client.exists("//tmp");
});
assert_eq!(
header_value(&head, "tracestate").as_deref(),
Some("vendora=t61rcWkgMzE,vendorb=x9"),
"the tracestate was dropped on the way to the cluster:\n{head}"
);
assert_eq!(
header_value(&head, "traceparent").as_deref(),
Some("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
"a tracestate must not displace the traceparent it belongs to:\n{head}"
);
}
#[test]
fn a_traced_client_sends_no_tracestate_it_was_not_given() {
let context = TraceContext::parse("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
.expect("a W3C traceparent");
let head = capture(|proxy| {
let client = Client::new(proxy)
.with_retries(RetryPolicy::none())
.with_trace_context(&context);
let _ = client.exists("//tmp");
});
assert!(
!head.to_lowercase().contains("tracestate"),
"a tracestate appeared from nowhere:\n{head}"
);
}
#[test]
fn a_transaction_inherits_the_clients_trace() {
let context = TraceContext::parse("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
.expect("a W3C traceparent");
let head = capture(|proxy| {
let client = Client::new(proxy)
.with_retries(RetryPolicy::none())
.with_trace_context(&context);
let _ = client.start_transaction();
});
assert!(
head.starts_with("POST /api/v4/start_transaction"),
"the captured request is not the transaction start:\n{head}"
);
assert_eq!(
header_value(&head, "traceparent").as_deref(),
Some("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
"a transaction started from a traced client left the trace:\n{head}"
);
}
#[test]
fn a_client_without_a_trace_context_sends_no_traceparent() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
let _ = client.exists("//tmp");
});
assert!(
!head.to_lowercase().contains("traceparent"),
"an untraced client sent a trace context:\n{head}"
);
}
#[test]
fn the_hosts_lookup_carries_the_trace_like_a_command_does() {
let context = TraceContext::parse("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
.expect("a W3C traceparent");
let head = capture(|proxy| {
let client = Client::with_token(proxy, "secret-token")
.with_retries(RetryPolicy::none())
.with_trace_context(&context);
let _ = client.heavy_proxy();
});
assert!(
head.starts_with("GET /hosts HTTP/1.1"),
"the lookup is not the documented one:\n{head}"
);
assert_eq!(
header_value(&head, "traceparent").as_deref(),
Some("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
"the hosts lookup dropped the trace context:\n{head}"
);
assert_eq!(
header_value(&head, "authorization").as_deref(),
Some("OAuth secret-token"),
"the hosts lookup dropped the token:\n{head}"
);
}
#[test]
fn a_raw_command_is_dressed_like_every_other_command() {
let head = capture(|proxy| {
let client = Client::with_token(proxy, "secret-token").with_retries(RetryPolicy::none());
let _ = client.raw_command(
Method::Get,
"get_supported_features",
&yson_build::empty_map(),
None,
);
});
let lowercase = head.to_lowercase();
assert!(
head.starts_with("GET /api/v4/get_supported_features HTTP/1.1"),
"the command name is the path, with nothing appended:\n{head}"
);
assert!(
lowercase.contains("authorization: oauth secret-token"),
"a raw command must carry the token:\n{head}"
);
assert!(
lowercase.contains("x-yt-header-format: <format=text>yson")
&& lowercase.contains("x-yt-parameters: {}"),
"a raw command must encode its parameters the way the protocol says:\n{head}"
);
assert!(
lowercase.contains("accept-encoding: gzip"),
"a raw command must ask for compression like the rest:\n{head}"
);
}
#[test]
fn a_multi_table_skiff_write_is_refused_before_anything_is_sent() {
let client = Client::new("http://127.0.0.1:1").with_retries(RetryPolicy::none());
let two_tables = SkiffFormat::new(vec![
SkiffSchemaRef::Inline(SkiffSchema::tuple([SkiffSchema::named(
"a",
SkiffWireType::Uint64,
)])),
SkiffSchemaRef::Inline(SkiffSchema::tuple([SkiffSchema::named(
"b",
SkiffWireType::Uint64,
)])),
])
.expect("two named tuples are a valid format");
let error = client
.write_table_with_format(
TablePath::from("//tmp/out"),
b"\x00",
&DataFormat::skiff(two_tables),
)
.expect_err("direct table I/O takes exactly one table schema");
assert!(matches!(error, ClientError::Config(_)), "{error:?}");
assert!(
error.to_string().contains("exactly one table schema"),
"{error}"
);
}
#[test]
fn a_suspend_says_what_to_do_with_the_running_jobs() {
for abort_running_jobs in [false, true] {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
client
.suspend_operation("1-2-3-4", abort_running_jobs)
.expect("suspends");
});
let params = parameters(&head);
assert!(head.starts_with("POST /api/v4/suspend_operation"), "{head}");
assert!(params.contains(r#"operation_id="1-2-3-4""#), "{params}");
assert!(
params.contains(&format!("abort_running_jobs=%{abort_running_jobs}")),
"{params}"
);
}
}
#[test]
fn the_scheduler_commands_carry_no_mutation_id() {
let heads = [
(
"suspend_operation",
capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
client
.suspend_operation("1-2-3-4", false)
.expect("suspends");
}),
),
(
"resume_operation",
capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
client.resume_operation("1-2-3-4").expect("resumes");
}),
),
(
"complete_operation",
capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
client.complete_operation("1-2-3-4").expect("completes");
}),
),
];
for (command, head) in heads {
let params = parameters(&head);
assert!(
head.starts_with(&format!("POST /api/v4/{command}")),
"a mutating command is a POST: {head}"
);
assert!(!params.contains("mutation_id"), "{command}: {params}");
assert!(!params.contains("retry="), "{command}: {params}");
}
}
#[test]
fn updated_parameters_travel_in_the_header_and_not_in_the_body() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
client
.update_operation_parameters(
"1-2-3-4",
&OperationParameters::new()
.with_pool("fast")
.with_weight(2.5),
)
.expect("updates");
});
let params = parameters(&head);
assert!(
head.starts_with("POST /api/v4/update_operation_parameters"),
"{head}"
);
assert!(params.contains(r#"operation_id="1-2-3-4""#), "{params}");
assert!(
params.contains("parameters={pool=fast;weight=2.5}"),
"the parameters are one nested dict, and the weight is a double: {params}"
);
assert!(
!head.to_lowercase().contains("content-length: ")
|| head.to_lowercase().contains("content-length: 0"),
"the command takes no body:\n{head}"
);
}
#[test]
fn an_update_that_changes_nothing_is_refused_before_it_is_sent() {
let client = Client::new("http://127.0.0.1:1").with_retries(RetryPolicy::none());
let error = client
.update_operation_parameters("1-2-3-4", &OperationParameters::new())
.expect_err("an empty update is not a request worth sending");
assert!(matches!(error, ClientError::Config(_)), "{error:?}");
}
#[test]
fn an_alias_lookup_asks_for_runtime_information() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
let _ = client.get_operation_by_alias("*nightly", &["state"]);
});
let params = parameters(&head);
assert!(head.starts_with("GET /api/v4/get_operation"), "{head}");
assert!(params.contains(r#"operation_alias="*nightly""#), "{params}");
assert!(params.contains("include_runtime=%true"), "{params}");
assert!(params.contains("attributes=[state]"), "{params}");
assert!(
!params.contains("operation_id"),
"an alias lookup names no id: {params}"
);
}
#[test]
fn the_whole_operation_document_is_asked_for_by_naming_no_attributes() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
let _ = client.get_operation("1-2-3-4", &[]);
});
let params = parameters(&head);
assert_eq!(params, r#"{operation_id="1-2-3-4"}"#);
}
#[test]
fn a_filtered_listing_sends_its_filter_and_nothing_else() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
let _ = client.list_operations(
&OperationFilter::new()
.with_user("robot-loader")
.with_state("running")
.with_limit(20),
);
});
assert!(head.starts_with("GET /api/v4/list_operations"), "{head}");
assert_eq!(
parameters(&head),
"{limit=20;state=running;user=robot-loader}"
);
}
#[test]
fn a_job_is_asked_for_by_operation_and_job() {
let head = capture(|proxy| {
let client = Client::new(proxy).with_retries(RetryPolicy::none());
let _ = client.get_job("1-2-3-4", "5-6-7-8");
});
assert!(head.starts_with("GET /api/v4/get_job "), "{head}");
assert_eq!(
parameters(&head),
r#"{job_id="5-6-7-8";operation_id="1-2-3-4"}"#
);
}
struct Proxy {
address: std::net::SocketAddr,
seen: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
}
#[derive(Clone)]
enum Hosts {
List(String),
Absent,
Status(u16),
Hang,
Slow(std::time::Duration, String),
Sequence(Vec<(u16, String)>),
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Role {
Any,
Control,
}
const HEAVY_COMMANDS: &[&str] = &[
"write_table",
"write_file",
"read_table",
"read_file",
"get_job_input",
"get_job_stderr",
];
impl Proxy {
fn new(hosts: Option<String>) -> Self {
Self::answering(hosts.map_or(Hosts::Absent, Hosts::List), 200)
}
fn answering(hosts: Hosts, commands: u16) -> Self {
Self::in_role(hosts, commands, Role::Any)
}
fn control(hosts: String) -> Self {
Self::in_role(Hosts::List(hosts), 200, Role::Control)
}
fn in_role(hosts: Hosts, commands: u16, role: Role) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("binds");
let address = listener.local_addr().expect("has an address");
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let served = std::sync::Arc::clone(&seen);
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { return };
let hosts = hosts.clone();
let seen = std::sync::Arc::clone(&served);
std::thread::spawn(move || serve(stream, hosts, commands, role, seen));
}
});
Self { address, seen }
}
fn url(&self) -> String {
format!("http://{}", self.address)
}
fn host(&self) -> String {
self.address.to_string()
}
fn requests(&self) -> Vec<String> {
self.seen
.lock()
.expect("nothing panicked holding it")
.iter()
.map(|head| head.lines().next().unwrap_or_default().to_owned())
.collect()
}
fn heads(&self) -> Vec<String> {
self.seen
.lock()
.expect("nothing panicked holding it")
.clone()
}
}
fn serve(
mut stream: std::net::TcpStream,
hosts: Hosts,
commands: u16,
role: Role,
seen: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
) {
let mut reader = BufReader::new(stream.try_clone().expect("clones"));
let mut hung = Vec::new();
loop {
let mut head = String::new();
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => return,
Ok(_) if line == "\r\n" => break,
Ok(_) => head.push_str(&line),
Err(_) => return,
}
}
if head.is_empty() {
return;
}
if let Some(length) = content_length(&head) {
let mut body = vec![0_u8; length];
if reader.read_exact(&mut body).is_err() {
return;
}
} else if head.to_lowercase().contains("transfer-encoding: chunked") {
drain_chunked(&mut reader);
}
let asked_where_to_go = head.starts_with("GET /hosts ");
let heavy = HEAVY_COMMANDS
.iter()
.any(|command| head.contains(&format!("/api/v4/{command} ")));
let asked_so_far = {
let mut seen = seen.lock().expect("nothing panicked holding it");
seen.push(head);
seen.iter()
.filter(|head| head.starts_with("GET /hosts "))
.count()
};
let answer = if asked_where_to_go {
match &hosts {
Hosts::List(list) => reply(200, list.as_bytes()),
Hosts::Absent => reply(404, b""),
Hosts::Status(status) => reply(*status, b""),
Hosts::Hang => {
hung.push(stream.try_clone().expect("clones"));
continue;
}
Hosts::Slow(delay, list) => {
std::thread::sleep(*delay);
reply(200, list.as_bytes())
}
Hosts::Sequence(answers) => {
let (status, body) = &answers[(asked_so_far - 1).min(answers.len() - 1)];
reply(*status, body.as_bytes())
}
}
} else if role == Role::Control && heavy {
refusal()
} else {
reply(commands, br#"{"value"=%true}"#)
};
if stream.write_all(&answer).is_err() {
return;
}
stream.flush().ok();
}
}
fn reply(status: u16, body: &[u8]) -> Vec<u8> {
let mut reply = format!(
"HTTP/1.1 {status} .\r\nContent-Length: {}\r\nContent-Type: application/x-yt-yson-text\r\n\r\n",
body.len()
)
.into_bytes();
reply.extend_from_slice(body);
reply
}
fn refusal() -> Vec<u8> {
let error =
r#"{"code":1,"message":"Control proxy may not serve heavy requests with input data"}"#;
format!("HTTP/1.1 503 .\r\nContent-Length: 0\r\nRetry-After: 60\r\nX-YT-Error: {error}\r\n\r\n")
.into_bytes()
}
fn nowhere() -> String {
for port in 1u16..=16 {
let address = format!("127.0.0.1:{port}");
let socket = address.parse().expect("a valid loopback address");
match TcpStream::connect_timeout(&socket, std::time::Duration::from_millis(200)) {
Err(error) if error.kind() == std::io::ErrorKind::ConnectionRefused => return address,
_ => continue,
}
}
panic!("no reserved loopback port refused a connection; cannot address nowhere");
}
fn discovering(proxy: &Proxy) -> Client {
Client::new(&proxy.url())
.with_retries(RetryPolicy::none())
.with_proxy_discovery(true)
}
#[test]
fn a_heavy_command_goes_to_the_proxy_the_cluster_names() {
let heavy = Proxy::new(None);
let control = Proxy::new(Some(format!(r#"["{}"]"#, heavy.host())));
Client::with_token(&control.url(), "secret-token")
.with_retries(RetryPolicy::none())
.with_proxy_discovery(true)
.write_table("//tmp/t", b"")
.expect("writes");
assert_eq!(
control.requests(),
["GET /hosts HTTP/1.1"],
"the configured address served the upload itself"
);
assert_eq!(heavy.requests(), ["PUT /api/v4/write_table HTTP/1.1"]);
let head = &heavy.heads()[0];
assert_eq!(
header_value(head, "authorization").as_deref(),
Some("OAuth secret-token"),
"the upload reached the heavy proxy without its token:\n{head}"
);
assert!(
parameters(head).contains(r#"path="//tmp/t""#),
"the upload lost its parameters on the way:\n{head}"
);
}
fn one_column() -> SkiffFormat {
SkiffFormat::new(vec![SkiffSchemaRef::Inline(SkiffSchema::tuple([
SkiffSchema::named("n", SkiffWireType::Int64),
]))])
.expect("one named tuple is a valid format")
}
#[test]
fn every_heavy_shape_goes_there_and_the_cluster_is_asked_once() {
let heavy = Proxy::new(None);
let control = Proxy::new(Some(format!(r#"["{}"]"#, heavy.host())));
let client = discovering(&control);
let skiff = one_column();
client.write_table("//tmp/t", b"").expect("buffered write");
client
.write_table_rows(
"//tmp/t",
[std::collections::BTreeMap::from([("n", 1_i64)])],
)
.expect("streamed write");
client.write_file("//tmp/f", b"x").expect("file write");
client
.write_skiff_table("//tmp/t", b"", &skiff)
.expect("skiff write");
let _ = client.read_table("//tmp/t");
let _ = client.read_table_streaming("//tmp/t");
let _ = client.read_skiff_table("//tmp/t", &skiff);
let _ = client.read_file("//tmp/f");
let _ = client.read_file_streaming("//tmp/f");
let _ = client.get_job_stderr("1-2-3-4", "5-6-7-8");
assert_eq!(
control.requests(),
["GET /hosts HTTP/1.1", "GET /api/v4/get HTTP/1.1"],
"a heavy command was served by the control proxy"
);
assert_eq!(
heavy.requests(),
[
"PUT /api/v4/write_table HTTP/1.1",
"PUT /api/v4/write_table HTTP/1.1",
"PUT /api/v4/write_file HTTP/1.1",
"PUT /api/v4/write_table HTTP/1.1",
"GET /api/v4/read_table HTTP/1.1",
"GET /api/v4/read_table HTTP/1.1",
"GET /api/v4/read_table HTTP/1.1",
"GET /api/v4/read_file HTTP/1.1",
"GET /api/v4/read_file HTTP/1.1",
"GET /api/v4/get_job_stderr HTTP/1.1",
]
);
}
#[test]
fn a_light_command_stays_where_the_client_was_pointed() {
let heavy = Proxy::new(None);
let control = Proxy::new(Some(format!(r#"["{}"]"#, heavy.host())));
let client = discovering(&control);
let _ = client.exists("//tmp");
let _ = client.create("table", "//tmp/t");
let _ = client.abort_operation("1-2-3-4", None);
assert_eq!(
control.requests(),
[
"GET /api/v4/exists HTTP/1.1",
"POST /api/v4/create HTTP/1.1",
"POST /api/v4/abort_operation HTTP/1.1",
]
);
assert!(
heavy.requests().is_empty(),
"a light command was routed away: {:?}",
heavy.requests()
);
}
#[test]
fn a_cluster_that_names_no_heavy_proxy_keeps_serving_the_uploads_itself() {
let control = Proxy::new(Some("[]".to_owned()));
let client = discovering(&control);
client.write_table("//tmp/t", b"").expect("writes");
client.write_file("//tmp/f", b"x").expect("writes");
assert_eq!(
control.requests(),
[
"GET /hosts HTTP/1.1",
"PUT /api/v4/write_table HTTP/1.1",
"PUT /api/v4/write_file HTTP/1.1",
]
);
}
#[test]
fn a_cluster_with_no_hosts_endpoint_is_not_asked_before_every_upload() {
let control = Proxy::new(None);
let client = discovering(&control);
client.write_table("//tmp/t", b"").expect("writes");
client.write_table("//tmp/t", b"").expect("writes");
assert_eq!(
control.requests(),
[
"GET /hosts HTTP/1.1",
"PUT /api/v4/write_table HTTP/1.1",
"PUT /api/v4/write_table HTTP/1.1",
]
);
}
#[test]
fn a_heavy_proxy_that_cannot_be_reached_gives_the_configured_address_back() {
let dead = nowhere();
let control = Proxy::new(Some(format!(r#"["{dead}"]"#)));
let client = discovering(&control);
let first = client.write_table("//tmp/t", b"");
let second = client.write_table("//tmp/t", b"");
assert!(first.is_err(), "nothing was listening on {dead}");
assert!(
second.is_ok(),
"the second upload was sent to the dead host too: {second:?}"
);
assert_eq!(
control.requests(),
["GET /hosts HTTP/1.1", "PUT /api/v4/write_table HTTP/1.1"],
"the client kept resolving an address it could not reach"
);
}
#[test]
fn a_failure_at_a_discovered_proxy_says_which_one() {
let dead = nowhere();
let control = Proxy::new(Some(format!(r#"["{dead}"]"#)));
let error = discovering(&control)
.write_table("//tmp/t", b"")
.expect_err("nothing was listening");
assert!(
error
.to_string()
.starts_with(&format!("write_table at {dead}:")),
"the failure did not name the proxy it went to: {error}"
);
}
#[test]
fn a_failure_at_the_configured_address_is_not_dressed_up_as_a_routed_one() {
let heavy = Proxy::answering(Hosts::Absent, 503);
let control = Proxy::answering(Hosts::List(format!(r#"["{}"]"#, heavy.host())), 503);
let client = discovering(&control);
let chosen = client.write_table("//tmp/t", b"").expect_err("503");
let given = client.write_table("//tmp/t", b"").expect_err("503");
assert!(
chosen
.to_string()
.starts_with(&format!("write_table at {}:", heavy.host())),
"{chosen}"
);
assert!(
given.to_string().starts_with("write_table:"),
"a failure at the address the caller gave was reported as a routed one: {given}"
);
}
#[test]
fn a_settled_lookup_survives_a_failed_upload() {
let control = Proxy::answering(Hosts::Absent, 503);
let client = discovering(&control).with_hosts_retry_after(std::time::Duration::ZERO);
assert!(client.write_table("//tmp/t", b"").is_err());
assert!(client.write_table("//tmp/t", b"").is_err());
assert_eq!(
control.requests(),
[
"GET /hosts HTTP/1.1",
"PUT /api/v4/write_table HTTP/1.1",
"PUT /api/v4/write_table HTTP/1.1",
],
"a settled lookup was restarted by a failure that had nothing to do with it"
);
}
#[test]
fn a_lookup_that_did_not_settle_is_asked_again_and_a_settled_one_is_not() {
let settled = Proxy::answering(Hosts::Absent, 200);
let client = discovering(&settled).with_hosts_retry_after(std::time::Duration::ZERO);
client.write_table("//tmp/t", b"").expect("writes");
client.write_table("//tmp/t", b"").expect("writes");
assert_eq!(
settled.requests(),
[
"GET /hosts HTTP/1.1",
"PUT /api/v4/write_table HTTP/1.1",
"PUT /api/v4/write_table HTTP/1.1",
],
"a settled answer was asked about again"
);
let refused = Proxy::new(Some(r#"["n0132-sas.somewhere-else.net"]"#.to_owned()));
let client = discovering(&refused).with_hosts_retry_after(std::time::Duration::ZERO);
client.write_table("//tmp/t", b"").expect("writes");
client.write_table("//tmp/t", b"").expect("writes");
assert_eq!(
refused.requests(),
[
"GET /hosts HTTP/1.1",
"PUT /api/v4/write_table HTTP/1.1",
"PUT /api/v4/write_table HTTP/1.1",
],
"an answer that was declined in full was asked for again"
);
let unsettled = Proxy::answering(Hosts::Status(503), 200);
let client = discovering(&unsettled).with_hosts_retry_after(std::time::Duration::ZERO);
client.write_table("//tmp/t", b"").expect("writes");
client.write_table("//tmp/t", b"").expect("writes");
assert_eq!(
unsettled.requests(),
[
"GET /hosts HTTP/1.1",
"PUT /api/v4/write_table HTTP/1.1",
"GET /hosts HTTP/1.1",
"PUT /api/v4/write_table HTTP/1.1",
],
"a lookup that failed for a reason that might pass was never repeated"
);
}
#[test]
fn the_control_proxy_in_these_tests_refuses_a_heavy_command_as_a_real_one_does() {
let control = Proxy::control("[]".to_owned());
let error = Client::new(&control.url())
.with_retries(RetryPolicy::none())
.write_table("//tmp/t", b"")
.expect_err("a control proxy refuses a heavy request with input data");
assert!(
error
.to_string()
.contains("Control proxy may not serve heavy requests with input data"),
"{error}"
);
assert!(
error.to_string().contains("with_proxy_discovery"),
"the refusal says nothing about the routing that would have avoided it: {error}"
);
}
#[test]
fn a_proxy_that_stumbles_is_dropped_from_the_pool_not_for_the_control_proxy() {
let bad = Proxy::answering(Hosts::Absent, 503);
let good = Proxy::new(None);
let control = Proxy::control(format!(r#"["{}", "{}"]"#, bad.host(), good.host()));
let client = discovering(&control);
let mut failures = 0;
for _ in 0..64 {
if client.write_table("//tmp/t", b"").is_err() {
failures += 1;
break;
}
}
assert_eq!(failures, 1, "the bad host was never picked in 64 writes");
for _ in 0..8 {
client
.write_table("//tmp/t", b"")
.expect("a write after the drop still reached the dropped host");
}
assert_eq!(
control.requests(),
["GET /hosts HTTP/1.1"],
"an upload went back to the control proxy, which is what refuses one"
);
assert_eq!(
bad.requests(),
["PUT /api/v4/write_table HTTP/1.1"],
"the host that failed was not dropped after its first failure"
);
}
#[test]
fn heavy_commands_spread_across_the_pool_rather_than_piling_onto_the_first() {
let (a, b, c) = (Proxy::new(None), Proxy::new(None), Proxy::new(None));
let control = Proxy::control(format!(
r#"["{}", "{}", "{}"]"#,
a.host(),
b.host(),
c.host()
));
let client = discovering(&control);
std::thread::scope(|scope| {
for _ in 0..64 {
scope.spawn(|| client.write_table("//tmp/t", b"").expect("writes"));
}
});
assert_eq!(
control.requests(),
["GET /hosts HTTP/1.1"],
"a fresh answer was re-asked, or an upload went to the control proxy"
);
for (name, proxy) in [("first", &a), ("second", &b), ("third", &c)] {
assert!(
!proxy.requests().is_empty(),
"64 uploads over a pool of three never once landed on the {name} host"
);
}
}
#[test]
fn a_stale_answer_is_refreshed_by_the_next_heavy_command_and_a_fresh_one_is_not() {
let heavy = Proxy::new(None);
let stale = Proxy::new(Some(format!(r#"["{}"]"#, heavy.host())));
let client = discovering(&stale).with_host_list_refresh_interval(std::time::Duration::ZERO);
client.write_table("//tmp/t", b"").expect("writes");
client.write_table("//tmp/t", b"").expect("writes");
assert_eq!(
stale.requests(),
["GET /hosts HTTP/1.1", "GET /hosts HTTP/1.1"],
"a stale answer was not refreshed before the next heavy command"
);
assert_eq!(heavy.requests().len(), 2);
let heavy = Proxy::new(None);
let fresh = Proxy::new(Some(format!(r#"["{}"]"#, heavy.host())));
let client =
discovering(&fresh).with_host_list_refresh_interval(std::time::Duration::from_secs(3600));
client.write_table("//tmp/t", b"").expect("writes");
client.write_table("//tmp/t", b"").expect("writes");
assert_eq!(
fresh.requests(),
["GET /hosts HTTP/1.1"],
"an answer well inside its interval was asked about again"
);
assert_eq!(heavy.requests().len(), 2);
}
const TEST_INTERVAL: std::time::Duration = std::time::Duration::from_millis(300);
fn outlive_the_interval() {
std::thread::sleep(TEST_INTERVAL + std::time::Duration::from_millis(100));
}
#[test]
fn a_refresh_that_fails_keeps_the_pool_and_waits_out_another_interval() {
let heavy = Proxy::new(None);
let flaky = Proxy::answering(
Hosts::Sequence(vec![
(200, format!(r#"["{}"]"#, heavy.host())),
(503, String::new()),
]),
200,
);
let client = discovering(&flaky)
.with_host_list_refresh_interval(TEST_INTERVAL)
.with_hosts_retry_after(std::time::Duration::ZERO);
client.write_table("//tmp/t", b"").expect("writes");
outlive_the_interval();
client
.write_table("//tmp/t", b"")
.expect("a failed refresh dropped the pool");
client
.write_table("//tmp/t", b"")
.expect("a failed refresh dropped the pool");
assert_eq!(
flaky.requests(),
["GET /hosts HTTP/1.1", "GET /hosts HTTP/1.1"],
"a refresh that failed was retried in front of the very next upload"
);
assert_eq!(
heavy.requests().len(),
3,
"an upload left the pool while the refresh was the only thing failing"
);
}
#[test]
fn a_refresh_adopts_the_answer_it_fetched() {
let (first, second) = (Proxy::new(None), Proxy::new(None));
let moving = Proxy::answering(
Hosts::Sequence(vec![
(200, format!(r#"["{}"]"#, first.host())),
(200, format!(r#"["{}"]"#, second.host())),
]),
200,
);
let client = discovering(&moving).with_host_list_refresh_interval(std::time::Duration::ZERO);
client.write_table("//tmp/t", b"").expect("writes");
client.write_table("//tmp/t", b"").expect("writes");
assert_eq!(
first.requests(),
["PUT /api/v4/write_table HTTP/1.1"],
"the first answer went unused, or was never given up"
);
assert_eq!(
second.requests(),
["PUT /api/v4/write_table HTTP/1.1"],
"the refreshed answer was fetched and thrown away"
);
}
#[test]
fn a_dropped_host_is_restored_by_the_next_refresh() {
let bad = Proxy::answering(Hosts::Absent, 503);
let good = Proxy::new(None);
let control = Proxy::control(format!(r#"["{}", "{}"]"#, bad.host(), good.host()));
let client = discovering(&control).with_host_list_refresh_interval(TEST_INTERVAL);
let mut failures = 0;
for _ in 0..64 {
if client.write_table("//tmp/t", b"").is_err() {
failures += 1;
break;
}
}
assert_eq!(failures, 1, "the bad host was never picked before the drop");
outlive_the_interval();
for _ in 0..64 {
if client.write_table("//tmp/t", b"").is_err() {
failures += 1;
break;
}
}
assert_eq!(
failures, 2,
"the dropped host was never restored by the refresh"
);
assert_eq!(
bad.requests().len(),
2,
"restoration reached the bad host more (or less) than the drop accounts for"
);
}
#[test]
fn a_refresh_that_answers_nobody_keeps_the_pool_in_hand() {
let heavy = Proxy::new(None);
let briefly_empty = Proxy::answering(
Hosts::Sequence(vec![
(200, format!(r#"["{}"]"#, heavy.host())),
(200, "[]".to_owned()),
]),
200,
);
let client =
discovering(&briefly_empty).with_host_list_refresh_interval(std::time::Duration::ZERO);
client.write_table("//tmp/t", b"").expect("writes");
client.write_table("//tmp/t", b"").expect("writes");
client.write_table("//tmp/t", b"").expect("writes");
assert_eq!(
heavy.requests().len(),
3,
"an empty refresh answer took the pool down with it"
);
}
#[test]
fn an_emptied_pool_asks_the_cluster_again_after_the_window() {
let dead = nowhere();
let control = Proxy::new(Some(format!(r#"["{dead}"]"#)));
let client = discovering(&control).with_hosts_retry_after(std::time::Duration::ZERO);
let first = client.write_table("//tmp/t", b"");
assert!(first.is_err(), "nothing was listening on {dead}");
let _ = client.write_table("//tmp/t", b"");
let asked = control
.requests()
.iter()
.filter(|line| line.starts_with("GET /hosts"))
.count();
assert_eq!(asked, 2, "the fallback never ended");
}
#[test]
fn an_interval_of_forever_disables_the_refresh() {
let heavy = Proxy::new(None);
let control = Proxy::new(Some(format!(r#"["{}"]"#, heavy.host())));
let client = discovering(&control).with_host_list_refresh_interval(std::time::Duration::MAX);
client.write_table("//tmp/t", b"").expect("writes");
client.write_table("//tmp/t", b"").expect("writes");
assert_eq!(
control.requests(),
["GET /hosts HTTP/1.1"],
"an interval of forever still refreshed"
);
assert_eq!(heavy.requests().len(), 2);
}
#[test]
fn a_cluster_that_named_nobody_is_asked_again_an_interval_later() {
let heavy = Proxy::new(None);
let recovering = Proxy::answering(
Hosts::Sequence(vec![
(200, "[]".to_owned()),
(200, format!(r#"["{}"]"#, heavy.host())),
]),
200,
);
let client = discovering(&recovering).with_host_list_refresh_interval(TEST_INTERVAL);
client.write_table("//tmp/t", b"").expect("writes");
client.write_table("//tmp/t", b"").expect("writes");
assert!(
heavy.requests().is_empty(),
"an answer well inside its interval was given up early"
);
outlive_the_interval();
client.write_table("//tmp/t", b"").expect("writes");
assert_eq!(
heavy.requests(),
["PUT /api/v4/write_table HTTP/1.1"],
"a cluster that named nobody was never asked again"
);
}
#[test]
fn a_proxy_that_refuses_heavy_work_is_given_up_like_one_that_cannot_be_reached() {
let good = Proxy::new(None);
let wrong_role = Proxy::control("[]".to_owned());
let control = Proxy::control(format!(r#"["{}", "{}"]"#, wrong_role.host(), good.host()));
let client = discovering(&control);
let mut failures = 0;
for _ in 0..64 {
if client.write_table("//tmp/t", b"").is_err() {
failures += 1;
break;
}
}
assert_eq!(failures, 1, "the wrong-role host was never picked");
for _ in 0..8 {
client
.write_table("//tmp/t", b"")
.expect("a write after the drop still reached the refusing host");
}
assert_eq!(
wrong_role.requests().len(),
1,
"the refusing host was not dropped after its first refusal"
);
assert_eq!(control.requests(), ["GET /hosts HTTP/1.1"]);
}
#[test]
fn a_refusal_at_the_configured_address_says_what_would_have_routed_it() {
let elsewhere = Proxy::new(None);
let named = format!("localhost:{}", elsewhere.address.port());
let control = Proxy::control(format!(r#"["{named}"]"#));
let error = discovering(&control)
.write_table("//tmp/t", b"")
.expect_err("a control proxy refuses a heavy request with input data");
assert!(
error.to_string().contains("with_heavy_proxies_anywhere"),
"the refusal does not say that a name was declined: {error}"
);
assert!(
elsewhere.requests().is_empty(),
"the token went to a host the caller never named: {:?}",
elsewhere.requests()
);
}
#[test]
fn a_list_of_proxies_written_out_by_hand_is_what_is_used() {
let allowed = Proxy::new(None);
let refused = Proxy::new(None);
let control = Proxy::control(format!(
r#"["localhost:{}", "localhost:{}"]"#,
refused.address.port(),
allowed.address.port()
));
Client::new(&control.url())
.with_retries(RetryPolicy::none())
.with_proxy_discovery(true)
.with_heavy_proxies_in([format!("localhost:{}", allowed.address.port())])
.write_table("//tmp/t", b"")
.expect("writes");
assert_eq!(control.requests(), ["GET /hosts HTTP/1.1"]);
assert_eq!(allowed.requests(), ["PUT /api/v4/write_table HTTP/1.1"]);
assert!(
refused.requests().is_empty(),
"a name outside the list was used: {:?}",
refused.requests()
);
}
#[test]
fn the_lookup_budget_can_be_raised_and_not_only_lowered() {
let heavy = Proxy::new(None);
let slow = Hosts::Slow(
std::time::Duration::from_millis(1200),
format!(r#"["{}"]"#, heavy.host()),
);
let control = Proxy::answering(slow, 200);
Client::new(&control.url())
.with_retries(RetryPolicy::none())
.with_proxy_discovery(true)
.write_table("//tmp/t", b"")
.expect("writes");
assert!(
heavy.requests().is_empty(),
"the default budget waited out a cluster it is meant to give up on"
);
Client::new(&control.url())
.with_retries(RetryPolicy::none())
.with_proxy_discovery(true)
.with_hosts_timeout(std::time::Duration::from_secs(3))
.write_table("//tmp/t", b"")
.expect("writes");
assert_eq!(
heavy.requests(),
["PUT /api/v4/write_table HTTP/1.1"],
"the budget could not be raised, so this cluster is unroutable"
);
}
#[test]
fn a_mistake_at_the_heavy_proxy_does_not_send_the_client_back_to_ask() {
let heavy = Proxy::answering(Hosts::Absent, 404);
let control = Proxy::new(Some(format!(r#"["{}"]"#, heavy.host())));
let client = discovering(&control);
assert!(client.write_table("//tmp/t", b"").is_err());
assert!(client.write_table("//tmp/t", b"").is_err());
assert_eq!(control.requests(), ["GET /hosts HTTP/1.1"]);
assert_eq!(
heavy.requests(),
[
"PUT /api/v4/write_table HTTP/1.1",
"PUT /api/v4/write_table HTTP/1.1",
],
"the client threw away a working address because a command was wrong"
);
}
#[test]
fn a_discovery_off_client_never_touches_the_routing_state() {
let control = Proxy::answering(Hosts::List(r#"["heavy.example.net"]"#.to_owned()), 503);
let client = Client::new(&control.url())
.with_retries(RetryPolicy::none())
.with_proxy_discovery(false);
assert!(client.write_table("//tmp/t", b"").is_err());
assert!(client.write_table("//tmp/t", b"").is_err());
assert_eq!(
control.requests(),
[
"PUT /api/v4/write_table HTTP/1.1",
"PUT /api/v4/write_table HTTP/1.1",
]
);
}
#[test]
fn a_blank_name_in_the_answer_is_passed_over_rather_than_believed() {
let heavy = Proxy::new(None);
let control = Proxy::new(Some(format!(r#"["", " ", "{}"]"#, heavy.host())));
let client = discovering(&control);
client.write_table("//tmp/t", b"").expect("writes");
assert_eq!(control.requests(), ["GET /hosts HTTP/1.1"]);
assert_eq!(heavy.requests(), ["PUT /api/v4/write_table HTTP/1.1"]);
}
#[test]
fn an_answer_that_is_not_a_list_of_host_names_settles_the_question() {
for nonsense in [
"not json at all",
r#"{"hosts": ["n0132-sas.example.net"]}"#,
"[1, 2, 3]",
"null",
] {
let control = Proxy::answering(Hosts::List(nonsense.to_owned()), 200);
let client = discovering(&control);
client.write_table("//tmp/t", b"").expect("writes");
client.write_table("//tmp/t", b"").expect("writes");
assert_eq!(
control.requests(),
[
"GET /hosts HTTP/1.1",
"PUT /api/v4/write_table HTTP/1.1",
"PUT /api/v4/write_table HTTP/1.1",
],
"{nonsense:?} was asked about twice, or routed somewhere"
);
}
}
#[test]
fn a_host_outside_the_configured_domain_is_refused() {
let elsewhere = Proxy::new(None);
let named = format!("localhost:{}", elsewhere.address.port());
let control = Proxy::new(Some(format!(r#"["{named}"]"#)));
discovering(&control)
.write_table("//tmp/t", b"")
.expect("writes");
assert_eq!(
control.requests(),
["GET /hosts HTTP/1.1", "PUT /api/v4/write_table HTTP/1.1"],
);
assert!(
elsewhere.requests().is_empty(),
"the token went to a host the caller never named: {:?}",
elsewhere.requests()
);
}
#[test]
fn an_installation_that_really_does_answer_elsewhere_can_opt_in() {
let heavy = Proxy::new(None);
let named = format!("localhost:{}", heavy.address.port());
let control = Proxy::new(Some(format!(r#"["{named}"]"#)));
Client::new(&control.url())
.with_retries(RetryPolicy::none())
.with_proxy_discovery(true)
.with_heavy_proxies_anywhere(true)
.write_table("//tmp/t", b"")
.expect("writes");
assert_eq!(control.requests(), ["GET /hosts HTTP/1.1"]);
assert_eq!(heavy.requests(), ["PUT /api/v4/write_table HTTP/1.1"]);
}
#[test]
fn the_lookup_has_its_own_budget_and_a_heavy_command_does_not_wait_out_the_clients() {
for (what, hosts) in [
("503", Hosts::Status(503)),
("no answer at all", Hosts::Hang),
] {
let control = Proxy::answering(hosts, 200);
let client = Client::new(&control.url()).with_proxy_discovery(true);
let started = std::time::Instant::now();
client.write_table("//tmp/t", b"").expect("writes");
let elapsed = started.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(5),
"a heavy command waited {elapsed:?} on a /hosts that answered {what}"
);
}
}
#[test]
fn waiting_threads_do_not_queue_up_behind_a_failing_lookup() {
let control = Proxy::answering(Hosts::Hang, 200);
let client = Client::new(&control.url()).with_proxy_discovery(true);
let started = std::time::Instant::now();
std::thread::scope(|scope| {
for _ in 0..8 {
scope.spawn(|| client.write_table("//tmp/t", b"").expect("writes"));
}
});
let elapsed = started.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(5),
"eight threads took {elapsed:?}, which is a queue rather than one lookup"
);
let asked = control
.requests()
.iter()
.filter(|line| line.starts_with("GET /hosts"))
.count();
assert!(asked <= 2, "{asked} lookups for one question");
}
#[test]
fn eight_threads_against_a_healthy_cluster_still_ask_exactly_once() {
let heavy = Proxy::new(None);
let control = Proxy::new(Some(format!(r#"["{}"]"#, heavy.host())));
let client = discovering(&control);
std::thread::scope(|scope| {
for _ in 0..8 {
scope.spawn(|| client.write_table("//tmp/t", b"").expect("writes"));
}
});
assert_eq!(control.requests(), ["GET /hosts HTTP/1.1"]);
assert_eq!(heavy.requests().len(), 8);
}
#[test]
fn a_local_cluster_is_never_asked_where_to_send_a_heavy_command() {
let control = Proxy::new(Some(r#"["heavy.example.net"]"#.to_owned()));
Client::new(&control.url())
.with_retries(RetryPolicy::none())
.write_table("//tmp/t", b"")
.expect("writes");
assert_eq!(control.requests(), ["PUT /api/v4/write_table HTTP/1.1"]);
}