Skip to main content

ytsaurus_client/
jobs.rs

1//! Job-level diagnostics for a failed operation.
2//!
3//! An operation that fails reports a state and a category — `failed`, "User job
4//! failed". The reason is in what the job itself printed before it died, and
5//! that takes two more commands: `list_jobs` names the jobs that failed, and
6//! `get_job_stderr` returns what each one wrote. Without them the only way to
7//! learn anything is the web UI.
8//!
9//! Both are documented in the
10//! [command reference](https://ytsaurus.tech/docs/en/api/commands): `list_jobs`
11//! is light and returns a structured `{jobs=[…]}`, `get_job_stderr` is heavy and
12//! returns the stderr as raw bytes.
13
14use ytsaurus_yson::{YsonNode, YsonValue};
15
16/// One job of an operation, as `list_jobs` reports it.
17///
18/// A subset of the cluster's `TJob`: the fields needed to name a job and say
19/// why it failed.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct JobInfo {
22    /// Job ID, in the form [`Client::get_job_stderr`](crate::Client::get_job_stderr) expects.
23    pub id: String,
24    /// Job state: `failed`, `completed`, `running`, `aborted`, …
25    pub state: String,
26    /// The exec node that ran it, as `host:port`.
27    pub address: Option<String>,
28    /// The error that ended the job, flattened to one line.
29    pub error: Option<String>,
30    /// How much stderr the cluster says it saved.
31    ///
32    /// A hint, not a fact: a local cluster reported `1` for a job whose stderr
33    /// was several hundred bytes, so this is not a length to size anything by.
34    /// `None` means the field was absent.
35    pub stderr_size: Option<u64>,
36}
37
38/// A failed job and what it printed, as carried by
39/// [`ClientError::OperationFailed`](crate::ClientError::OperationFailed).
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct JobFailure {
42    /// Job ID, for `get_job_stderr` or the web UI.
43    pub id: String,
44    /// The exec node that ran it, as `host:port`.
45    pub address: Option<String>,
46    /// The error that ended the job, flattened to one line.
47    pub error: Option<String>,
48    /// The tail of the job's stderr, bounded and decoded lossily.
49    pub stderr: Option<String>,
50}
51
52/// Reads the `jobs` list of a `list_jobs` response.
53///
54/// A job whose ID is missing or unreadable is dropped: there is nothing to ask
55/// the cluster about it, and an anonymous entry in an error message is noise.
56pub(crate) fn parse_jobs(jobs: &YsonValue) -> Vec<JobInfo> {
57    let YsonNode::List(items) = &jobs.node else {
58        return Vec::new();
59    };
60    items.iter().filter_map(parse_job).collect()
61}
62
63/// Reads one job, from either `list_jobs` or `get_job`.
64///
65/// `get_job` answers the job document **unwrapped** — no `{jobs=[…]}` around it
66/// — and calls the id `job_id` where `list_jobs` calls it `id`. Both are read
67/// here, so one parser serves both commands.
68pub(crate) fn parse_job(job: &YsonValue) -> Option<JobInfo> {
69    // `list_jobs` calls it `id`, `get_job` calls it `job_id`. Same value.
70    let id = text(field(job, "id").or_else(|| field(job, "job_id"))?)?;
71
72    Some(JobInfo {
73        id,
74        state: field(job, "state").and_then(text).unwrap_or_default(),
75        address: field(job, "address").and_then(text),
76        error: field(job, "error").and_then(error_summary),
77        stderr_size: field(job, "stderr_size").and_then(count),
78    })
79}
80
81/// Flattens a YTsaurus error document to one line.
82///
83/// The outer message is a category ("User job failed"); the cause is at the
84/// bottom of `inner_errors` ("Process exited with code 1"). Both are useful, so
85/// both are kept.
86pub(crate) fn error_summary(error: &YsonValue) -> Option<String> {
87    let top = text(field(error, "message")?)?;
88    match innermost_message(error) {
89        Some(inner) if inner != top => Some(format!("{top}: {inner}")),
90        _ => Some(top),
91    }
92}
93
94fn innermost_message(error: &YsonValue) -> Option<String> {
95    let YsonNode::List(inner) = &field(error, "inner_errors")?.node else {
96        return None;
97    };
98    let first = inner.first()?;
99    innermost_message(first).or_else(|| field(first, "message").and_then(text))
100}
101
102/// A dict entry, without the panic `YsonValue`'s `Index` would give.
103pub(crate) fn field<'a>(value: &'a YsonValue, key: &str) -> Option<&'a YsonValue> {
104    match &value.node {
105        YsonNode::Map(m) => m.get(key.as_bytes()),
106        _ => None,
107    }
108}
109
110/// A string field. YSON strings are byte strings, so this decodes lossily
111/// rather than refusing a name the cluster is happy with.
112pub(crate) fn text(value: &YsonValue) -> Option<String> {
113    match &value.node {
114        YsonNode::String(bytes) => Some(String::from_utf8_lossy(bytes).into_owned()),
115        _ => None,
116    }
117}
118
119/// A byte count, which the cluster sends unsigned but which nothing forbids
120/// arriving signed.
121fn count(value: &YsonValue) -> Option<u64> {
122    match value.node {
123        YsonNode::Int64(v) => u64::try_from(v).ok(),
124        YsonNode::Uint64(v) => Some(v),
125        _ => None,
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use ytsaurus_yson::{YsonFormat, from_slice};
133
134    fn parse(text: &str) -> YsonValue {
135        from_slice(text.as_bytes(), YsonFormat::Text).expect("valid YSON")
136    }
137
138    /// The response shape from the command reference, trimmed to the fields
139    /// this client reads.
140    const LIST_JOBS_RESPONSE: &str = r#"{
141        "jobs" = [
142            {
143                "id" = "55aff293-7ef14284-3fe0384-3e07";
144                "type" = "map";
145                "state" = "failed";
146                "address" = "hostname.net:9012";
147                "fail_context_size" = 973230u;
148                "stderr_size" = 1024u;
149                "error" = {
150                    "code" = 1205;
151                    "message" = "User job failed";
152                    "inner_errors" = [
153                        {
154                            "code" = 10000;
155                            "message" = "Process exited with code 101";
156                        };
157                    ];
158                };
159            };
160            {
161                "id" = "69ae20a7-887b25ab-3fe0384-3cff";
162                "type" = "map";
163                "state" = "running";
164                "address" = "hostname.net:9012";
165            };
166        ];
167        "state_counts" = {"running" = 1; "failed" = 1};
168    }"#;
169
170    #[test]
171    fn reads_the_documented_list_jobs_response() {
172        let response = parse(LIST_JOBS_RESPONSE);
173        let jobs = parse_jobs(field(&response, "jobs").expect("has jobs"));
174
175        assert_eq!(jobs.len(), 2);
176        assert_eq!(jobs[0].id, "55aff293-7ef14284-3fe0384-3e07");
177        assert_eq!(jobs[0].state, "failed");
178        assert_eq!(jobs[0].address.as_deref(), Some("hostname.net:9012"));
179        assert_eq!(jobs[0].stderr_size, Some(1024));
180        assert_eq!(
181            jobs[0].error.as_deref(),
182            Some("User job failed: Process exited with code 101")
183        );
184
185        // A running job has no error and no saved stderr, and the absence must
186        // stay distinguishable from "the cluster saved nothing".
187        assert_eq!(jobs[1].state, "running");
188        assert_eq!(jobs[1].error, None);
189        assert_eq!(jobs[1].stderr_size, None);
190    }
191
192    /// A real `list_jobs` response, captured from the local cluster after
193    /// running `cargo run -p ytsaurus-client --example diagnose`. The
194    /// documented shape above is what the reference promises; this is what a
195    /// cluster actually sends, which is not the same thing — it carries
196    /// `attributes` maps full of `u64`s, an entity `cypress_job_count`, and a
197    /// `brief_statistics` with YSON attributes on it.
198    const CAPTURED: &str = include_str!("../tests/fixtures/list_jobs_failed.yson");
199
200    #[test]
201    fn reads_a_response_captured_from_a_cluster() {
202        let response = parse(CAPTURED);
203        let jobs = parse_jobs(field(&response, "jobs").expect("has jobs"));
204
205        assert_eq!(jobs.len(), 1);
206        assert_eq!(jobs[0].id, "3dc650de-c17d51d2-10384-1000001");
207        assert_eq!(jobs[0].state, "failed");
208        assert_eq!(jobs[0].address.as_deref(), Some("localhost:24403"));
209        assert_eq!(
210            jobs[0].error.as_deref(),
211            Some("User job failed: Process terminated by signal 6"),
212            "the signal is the whole point — signal 6 is a Rust panic under \
213             panic=abort, and only the inner error names it"
214        );
215
216        // The cluster said one byte; the job's stderr was several hundred. The
217        // client asks for stderr regardless, and this is why.
218        assert_eq!(jobs[0].stderr_size, Some(1));
219    }
220
221    /// A real `get_job` answer, captured from the local cluster. It differs
222    /// from a `list_jobs` entry in two ways that matter: there is no
223    /// `{jobs=[…]}` around it, and the id is called `job_id`.
224    const GET_JOB: &str = include_str!("../tests/fixtures/get_job.yson");
225
226    #[test]
227    fn reads_a_single_job_captured_from_a_cluster() {
228        let job = parse_job(&parse(GET_JOB)).expect("the answer names a job");
229
230        assert_eq!(job.id, "c1b61a6-156b50eb-10384-1000001");
231        assert_eq!(job.state, "running");
232        assert_eq!(job.address.as_deref(), Some("localhost:24403"));
233        assert_eq!(
234            job.error, None,
235            "a job that has not failed has no error to report"
236        );
237    }
238
239    #[test]
240    fn an_answer_that_names_no_job_is_not_a_job() {
241        assert!(parse_job(&parse(r#"{"state"="running"}"#)).is_none());
242    }
243
244    #[test]
245    fn a_job_without_an_id_is_dropped() {
246        let response = parse(r#"{"jobs" = [{"state" = "failed"}; {"id" = "a-b-c-d"}]}"#);
247        let jobs = parse_jobs(field(&response, "jobs").expect("has jobs"));
248
249        assert_eq!(jobs.len(), 1, "the entry with no id must not be reported");
250        assert_eq!(jobs[0].id, "a-b-c-d");
251    }
252
253    #[test]
254    fn a_response_without_a_job_list_yields_nothing() {
255        assert!(parse_jobs(&parse(r#"{"jobs" = #}"#)["jobs"]).is_empty());
256        assert!(parse_jobs(&parse(r#""not a list""#)).is_empty());
257    }
258
259    #[test]
260    fn the_summary_reaches_the_innermost_message() {
261        let error = parse(
262            r#"{
263                "message" = "Operation failed";
264                "inner_errors" = [
265                    {
266                        "message" = "User job failed";
267                        "inner_errors" = [{"message" = "Process exited with code 1"}];
268                    };
269                ];
270            }"#,
271        );
272        assert_eq!(
273            error_summary(&error).as_deref(),
274            Some("Operation failed: Process exited with code 1")
275        );
276    }
277
278    #[test]
279    fn a_flat_error_is_not_repeated() {
280        let error = parse(r#"{"message" = "User job failed"; "inner_errors" = []}"#);
281        assert_eq!(error_summary(&error).as_deref(), Some("User job failed"));
282    }
283
284    #[test]
285    fn an_error_without_a_message_has_no_summary() {
286        assert_eq!(error_summary(&parse(r#"{"code" = 1}"#)), None);
287    }
288
289    #[test]
290    fn a_non_utf8_field_is_kept_lossily() {
291        // A job address the cluster is happy with but Rust would not accept as
292        // a `&str`. Dropping the job over it would lose the failure.
293        let job = YsonValue {
294            attributes: None,
295            node: YsonNode::Map(
296                [
297                    (b"id".to_vec(), string_value(b"a-b-c-d")),
298                    (b"address".to_vec(), string_value(&[0xFF, 0xFE])),
299                ]
300                .into_iter()
301                .collect(),
302            ),
303        };
304
305        let parsed = parse_job(&job).expect("an id is all it takes");
306        assert_eq!(parsed.address.as_deref(), Some("\u{FFFD}\u{FFFD}"));
307    }
308
309    fn string_value(bytes: &[u8]) -> YsonValue {
310        YsonValue {
311            attributes: None,
312            node: YsonNode::String(bytes.to_vec()),
313        }
314    }
315
316    #[test]
317    fn a_signed_byte_count_is_accepted_and_a_negative_one_is_not() {
318        assert_eq!(count(&parse("1024")), Some(1024));
319        assert_eq!(count(&parse("1024u")), Some(1024));
320        assert_eq!(count(&parse("-1")), None);
321        assert_eq!(count(&parse(r#""1024""#)), None);
322    }
323}