use std::time::Duration;
use crate::error::{ClientError, Result};
#[cfg(feature = "tracing")]
pub(crate) fn attempt<T>(
command: &str,
attempt: u32,
action: impl FnOnce() -> Result<T>,
) -> Result<T> {
let span = tracing::info_span!(
"ytsaurus.command",
command = %command,
attempt,
elapsed_ms = tracing::field::Empty,
);
let _entered = span.enter();
let started = std::time::Instant::now();
let result = action();
span.record("elapsed_ms", started.elapsed().as_secs_f64() * 1e3);
if let Err(error) = &result {
tracing::debug!(error = %error, "the attempt failed");
}
result
}
#[cfg(not(feature = "tracing"))]
pub(crate) fn attempt<T>(
_command: &str,
_attempt: u32,
action: impl FnOnce() -> Result<T>,
) -> Result<T> {
action()
}
#[cfg(feature = "tracing")]
pub(crate) fn retrying(command: &str, error: &ClientError, wait: Duration, attempt: u32, of: u32) {
tracing::warn!(
command = %command,
attempt,
of,
retry_in_s = wait.as_secs_f64(),
error = %error,
"the command failed; retrying"
);
if let Some(line) = stderr_fallback(command, error, wait, attempt, of) {
eprintln!("{line}");
}
}
#[cfg(feature = "tracing")]
fn stderr_fallback(
command: &str,
error: &ClientError,
wait: Duration,
attempt: u32,
of: u32,
) -> Option<String> {
unheard().then(|| retry_message(command, error, wait, attempt, of))
}
#[cfg(feature = "tracing")]
fn unheard() -> bool {
tracing::dispatcher::get_default(tracing::Dispatch::is::<tracing::subscriber::NoSubscriber>)
}
#[cfg(not(feature = "tracing"))]
pub(crate) fn retrying(command: &str, error: &ClientError, wait: Duration, attempt: u32, of: u32) {
eprintln!("{}", retry_message(command, error, wait, attempt, of));
}
pub(crate) fn cache_refused(cache: &str, error: &ClientError) {
#[cfg(feature = "tracing")]
tracing::warn!(
cache = %cache,
error = %error,
"the file cache cannot be written to; uploading the worker uncached"
);
if let Some(line) = cache_fallback(cache, error) {
eprintln!("{line}");
}
}
#[cfg(feature = "tracing")]
fn cache_fallback(cache: &str, error: &ClientError) -> Option<String> {
unheard().then(|| cache_message(cache, error))
}
#[cfg(not(feature = "tracing"))]
fn cache_fallback(cache: &str, error: &ClientError) -> Option<String> {
Some(cache_message(cache, error))
}
fn cache_message(cache: &str, error: &ClientError) -> String {
format!(
"ytsaurus-client: the file cache at {cache} cannot be written to \
({error}); uploading the worker uncached, which re-sends it on every \
launch. Client::with_file_cache — or YT_FILE_CACHE, for a client built \
by Client::from_env — points it at a path you can write to."
)
}
fn retry_message(
command: &str,
error: &ClientError,
wait: Duration,
attempt: u32,
of: u32,
) -> String {
format!(
"ytsaurus-client: {command} failed ({error}); \
retrying in {:.1}s (attempt {attempt} of {of})",
wait.as_secs_f64()
)
}
const NAMED_REFUSALS: usize = 3;
#[cfg(feature = "tracing")]
pub(crate) fn declined(configured: &str, refused: &[String]) {
tracing::warn!(
configured = %configured,
refused = refused.len(),
names = %refused.join("; "),
"no proxy from /hosts was used; heavy commands stay on the configured address"
);
let listening = !tracing::dispatcher::get_default(
tracing::Dispatch::is::<tracing::subscriber::NoSubscriber>,
);
if !listening {
eprintln!("{}", declined_message(configured, refused));
}
}
#[cfg(not(feature = "tracing"))]
pub(crate) fn declined(configured: &str, refused: &[String]) {
eprintln!("{}", declined_message(configured, refused));
}
fn declined_message(configured: &str, refused: &[String]) -> String {
let named = refused
.iter()
.take(NAMED_REFUSALS)
.cloned()
.collect::<Vec<_>>()
.join("; ");
let rest = refused.len().saturating_sub(NAMED_REFUSALS);
let and_more = if rest > 0 {
format!("; and {rest} more")
} else {
String::new()
};
format!(
"ytsaurus-client: /hosts named {} heavy {}, and none was used, \
so heavy commands go to {configured} — which is what an installation \
with separate proxy roles refuses. {named}{and_more}. \
Client::with_heavy_proxies_under([…]) — YT_HEAVY_PROXY_DOMAINS — names \
the domain they are in; Client::with_heavy_proxies_in([…]) names the \
proxies themselves; Client::with_heavy_proxies_anywhere(true) — \
YT_HEAVY_PROXIES_ANYWHERE=1 — takes the rule away.",
refused.len(),
if refused.len() == 1 {
"proxy"
} else {
"proxies"
},
)
}
#[cfg(test)]
mod message_tests {
use super::*;
fn unavailable() -> ClientError {
ClientError::Cluster {
command: "get".to_owned(),
code: 105,
message: "Master is not connected".to_owned(),
raw: r#"{"code":105}"#.to_owned(),
}
}
#[test]
fn the_message_names_the_command_the_reason_the_wait_and_the_try() {
let line = retry_message(
"start_operation",
&unavailable(),
Duration::from_millis(1500),
2,
5,
);
assert!(line.contains("start_operation"), "{line}");
assert!(line.contains("Master is not connected"), "{line}");
assert!(line.contains("1.5s"), "the wait is not in seconds: {line}");
assert!(line.contains("attempt 2 of 5"), "{line}");
}
#[test]
fn the_cache_warning_names_the_path_the_refusal_and_the_way_out() {
let denied = ClientError::Cluster {
command: "create".to_owned(),
code: 901,
message: "Access denied for user \"robot\": \"write | modify_children\" \
permission for node //tmp/yt_wrapper/file_storage/new_cache \
is not allowed by any matching ACE"
.to_owned(),
raw: r#"{"code":901}"#.to_owned(),
};
let line = cache_message("//tmp/yt_wrapper/file_storage/new_cache", &denied);
assert!(
line.contains("//tmp/yt_wrapper/file_storage/new_cache"),
"{line}"
);
assert!(line.contains("Access denied"), "{line}");
assert!(line.contains("Client::with_file_cache"), "{line}");
assert!(line.contains("every launch"), "{line}");
}
#[test]
fn the_cache_warning_is_owed_to_stderr_when_nothing_else_carries_it() {
let denied = ClientError::Cluster {
command: "create".to_owned(),
code: 901,
message: "Access denied for user \"robot\"".to_owned(),
raw: r#"{"code":901}"#.to_owned(),
};
let line = cache_fallback("//tmp/mine/cache", &denied)
.expect("no subscriber is installed, so stderr is the only way to say it");
assert!(line.contains("//tmp/mine/cache"), "{line}");
assert!(line.contains("Access denied"), "{line}");
assert!(line.contains("Client::with_file_cache"), "{line}");
}
#[test]
fn the_declined_message_names_the_reasons_and_the_way_out() {
let line = declined_message(
"https://hume",
&[
r#""n0008-sas.hume.yt.example.net" is not under the domain of hume"#.to_owned(),
r#""" is not a host name"#.to_owned(),
],
);
assert!(line.contains("https://hume"), "{line}");
assert!(line.contains("n0008-sas.hume.yt.example.net"), "{line}");
assert!(line.contains("not under the domain of hume"), "{line}");
assert!(line.contains("with_heavy_proxies_under"), "{line}");
assert!(line.contains("with_heavy_proxies_in"), "{line}");
assert!(line.contains("with_heavy_proxies_anywhere"), "{line}");
assert!(line.contains("YT_HEAVY_PROXY_DOMAINS"), "{line}");
assert!(line.contains("YT_HEAVY_PROXIES_ANYWHERE"), "{line}");
}
#[test]
fn a_long_refusal_list_is_counted_rather_than_recited() {
let many: Vec<String> = (0..12)
.map(|i| format!("{i:?} is not a host name"))
.collect();
let line = declined_message("https://hume", &many);
assert!(line.contains("/hosts named 12 heavy proxies"), "{line}");
assert!(line.contains("and 9 more"), "{line}");
assert!(!line.contains(r#""11""#), "{line}");
}
#[test]
fn the_wait_is_rounded_rather_than_spelled_out() {
let line = retry_message(
"get",
&unavailable(),
Duration::from_nanos(1_234_567_891),
1,
3,
);
assert!(line.contains("1.2s"), "{line}");
assert!(!line.contains("1.234"), "{line}");
}
}
#[cfg(all(test, feature = "tracing"))]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
use tracing::field::{Field, Visit};
use tracing::span::{Attributes, Id, Record};
use tracing::{Event, Metadata, Subscriber};
use crate::retry::{Repeatable, RetryPolicy};
#[derive(Default)]
struct Recorder(Mutex<Vec<String>>);
impl Recorder {
fn note(&self, kind: &str, meta: &Metadata<'_>, fields: impl FnOnce(&mut Fields<'_>)) {
let mut line = format!("{kind} {} {}", meta.level(), meta.name());
fields(&mut Fields(&mut line));
self.0.lock().expect("not poisoned").push(line);
}
fn lines(&self) -> Vec<String> {
self.0.lock().expect("not poisoned").clone()
}
}
struct Fields<'a>(&'a mut String);
impl Visit for Fields<'_> {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
use std::fmt::Write;
let _ = write!(self.0, " {}={value:?}", field.name());
}
}
impl Subscriber for Recorder {
fn enabled(&self, _metadata: &Metadata<'_>) -> bool {
true
}
fn new_span(&self, span: &Attributes<'_>) -> Id {
self.note("span", span.metadata(), |fields| span.record(fields));
Id::from_u64(1)
}
fn record(&self, _span: &Id, values: &Record<'_>) {
let mut line = String::from("record");
values.record(&mut Fields(&mut line));
self.0.lock().expect("not poisoned").push(line);
}
fn record_follows_from(&self, _span: &Id, _follows: &Id) {}
fn event(&self, event: &Event<'_>) {
self.note("event", event.metadata(), |fields| {
event.record(fields);
});
}
fn enter(&self, _span: &Id) {}
fn exit(&self, _span: &Id) {}
}
fn recorded(work: impl FnOnce()) -> Vec<String> {
let recorder = Arc::new(Recorder::default());
tracing::subscriber::with_default(Arc::clone(&recorder), work);
recorder.lines()
}
fn unavailable() -> ClientError {
ClientError::Cluster {
command: "get".to_owned(),
code: 105,
message: "Master is not connected".to_owned(),
raw: r#"{"code":105}"#.to_owned(),
}
}
fn spans(lines: &[String]) -> Vec<&String> {
lines
.iter()
.filter(|line| line.starts_with("span INFO ytsaurus.command"))
.collect()
}
#[test]
fn an_attempt_is_a_span_naming_the_command_the_try_and_the_time() {
let mut tries = 0;
let lines = recorded(|| {
crate::retry::run(
RetryPolicy::new(3, Duration::ZERO, Duration::ZERO),
Repeatable::Freely,
"start_operation",
|_| {
tries += 1;
if tries < 3 {
Err(unavailable())
} else {
Ok(())
}
},
)
.expect("the third attempt succeeds");
});
let spans = spans(&lines);
assert_eq!(spans.len(), 3, "one span per attempt: {lines:?}");
for (index, span) in spans.iter().enumerate() {
assert!(span.contains("command=start_operation"), "{span}");
assert!(span.contains(&format!("attempt={}", index + 1)), "{span}");
}
assert_eq!(
lines
.iter()
.filter(|line| line.contains("elapsed_ms="))
.count(),
3,
"every attempt is timed: {lines:?}"
);
}
#[test]
fn the_span_is_at_info_and_the_retry_at_warn() {
let lines = recorded(|| {
crate::retry::run(
RetryPolicy::new(2, Duration::ZERO, Duration::ZERO),
Repeatable::Freely,
"get",
|_| Err::<(), _>(unavailable()),
)
.expect_err("nothing here succeeds");
});
assert!(
lines
.iter()
.any(|l| l.starts_with("span INFO ytsaurus.command")),
"the command span is not at INFO: {lines:?}"
);
assert!(
lines
.iter()
.any(|l| l.starts_with("event WARN") && l.contains("retrying")),
"the retry is not at WARN: {lines:?}"
);
}
#[test]
fn the_send_once_commands_get_a_span_of_their_own() {
let transport = crate::http::Transport::new(
"http://127.0.0.1:1",
None,
std::time::Duration::from_millis(200),
);
let params = crate::yson_build::map([("path", crate::yson_build::string("//tmp/t"))]);
let reading = recorded(|| {
transport
.open(crate::http::Method::Get, "read_table", ¶ms)
.expect_err("nothing is listening");
});
assert!(
spans(&reading)
.iter()
.any(|span| span.contains("command=read_table")),
"read_table opened no span: {reading:?}"
);
let writing = recorded(|| {
let mut rows: &[u8] = b"";
transport
.upload(crate::http::Method::Put, "write_table", ¶ms, &mut rows)
.expect_err("nothing is listening");
});
assert!(
spans(&writing)
.iter()
.any(|span| span.contains("command=write_table")),
"write_table opened no span: {writing:?}"
);
}
#[test]
fn a_retry_says_so_through_tracing_instead_of_on_stderr() {
let lines = recorded(|| {
crate::retry::run(
RetryPolicy::new(3, Duration::ZERO, Duration::ZERO),
Repeatable::Freely,
"get",
|_| Err::<(), _>(unavailable()),
)
.expect_err("nothing here succeeds");
});
let retries: Vec<&String> = lines
.iter()
.filter(|line| line.starts_with("event") && line.contains("retrying"))
.collect();
assert_eq!(retries.len(), 2, "three attempts, two retries: {lines:?}");
assert!(retries[0].contains("command=get"), "{}", retries[0]);
assert!(retries[0].contains("attempt=1"), "{}", retries[0]);
assert!(retries[0].contains("of=3"), "{}", retries[0]);
assert!(retries[1].contains("attempt=2"), "{}", retries[1]);
assert!(retries[1].contains("of=3"), "{}", retries[1]);
assert!(
retries[0].contains("Master is not connected"),
"the reason is what makes the message worth having: {}",
retries[0]
);
}
#[test]
fn the_stderr_message_survives_the_feature_being_turned_on_for_us() {
let fallback = || stderr_fallback("get", &unavailable(), Duration::from_secs(2), 1, 3);
let unheard = fallback().expect("nothing is listening, so stderr is the fallback");
assert!(unheard.contains("get"), "{unheard}");
assert!(unheard.contains("attempt 1 of 3"), "{unheard}");
assert!(unheard.contains("Master is not connected"), "{unheard}");
let heard = tracing::subscriber::with_default(Arc::new(Recorder::default()), fallback);
assert_eq!(
heard, None,
"a subscriber is installed and the message would be printed twice"
);
}
#[test]
fn an_unusable_file_cache_is_a_warning_here_too() {
let denied = ClientError::Cluster {
command: "create".to_owned(),
code: 901,
message: "Access denied for user \"robot\"".to_owned(),
raw: r#"{"code":901}"#.to_owned(),
};
let lines = recorded(|| cache_refused("//tmp/yt_wrapper/file_storage/new_cache", &denied));
let warning = lines
.iter()
.find(|line| line.starts_with("event WARN"))
.unwrap_or_else(|| panic!("nothing was said about the cache: {lines:?}"));
assert!(
warning.contains("uploading the worker uncached"),
"{warning}"
);
assert!(
warning.contains("cache=//tmp/yt_wrapper/file_storage/new_cache"),
"{warning}"
);
assert!(warning.contains("Access denied"), "{warning}");
assert_eq!(
tracing::subscriber::with_default(Arc::new(Recorder::default()), || cache_fallback(
"//tmp/yt_wrapper/file_storage/new_cache",
&denied
)),
None,
"a subscriber is installed and the warning would be printed twice"
);
}
#[test]
fn a_declined_hosts_answer_is_a_warning_with_the_names_in_it() {
let lines = recorded(|| {
declined(
"https://hume",
&[r#""n0008-sas.hume.yt.example.net" is not under the domain of hume"#.to_owned()],
);
});
let warning = lines
.iter()
.find(|line| line.starts_with("event WARN"))
.unwrap_or_else(|| panic!("no warning was emitted: {lines:?}"));
assert!(warning.contains("configured=https://hume"), "{warning}");
assert!(warning.contains("refused=1"), "{warning}");
assert!(
warning.contains("n0008-sas.hume.yt.example.net"),
"the names are the whole point of the event: {warning}"
);
}
#[test]
fn a_quiet_policy_is_quiet_in_this_spelling_too() {
let policy = RetryPolicy::new(3, Duration::ZERO, Duration::ZERO);
let attempt_it = |policy: RetryPolicy| {
recorded(move || {
crate::retry::run(policy, Repeatable::Freely, "get", |_| {
Err::<(), _>(unavailable())
})
.expect_err("nothing here succeeds");
})
};
assert!(
!attempt_it(policy.quiet())
.iter()
.any(|line| line.contains("retrying")),
"a quiet policy announced its retries"
);
assert!(
attempt_it(policy.loud())
.iter()
.any(|line| line.contains("retrying")),
"a loud policy said nothing"
);
assert!(
attempt_it(policy.quiet())
.iter()
.filter(|line| line.starts_with("span INFO ytsaurus.command"))
.count()
== 3,
"a quiet policy stopped opening spans"
);
}
}