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`, `Failed to run query` —
84/// and the cause is at the bottom of `inner_errors`. Both are useful, so both
85/// are kept, and everything between them is dropped along with the attributes:
86/// a cluster error tree is mostly pids, thread names and trace ids, and the one
87/// thing a reader needs is the sentence at the bottom of it.
88///
89/// This is what [`JobInfo::error`](crate::JobInfo::error) and the operation
90/// errors are built from. It is public because a caller using
91/// [`Client::raw_command`](crate::Client::raw_command) gets the same shape of
92/// answer from any command the crate does not model, and had no way to read it
93/// — every escape-hatch caller was reinventing this, worse. Printing the tree
94/// instead is how a failed command costs an hour.
95///
96/// `None` when the document has no `message` at all, which is not the same as
97/// an empty one: a successful query answers `{code=0;message=""}`, and that
98/// summarises to `Some("")` rather than to nothing.
99///
100/// # Examples
101///
102/// ```
103/// use ytsaurus_client::error_summary;
104/// use ytsaurus_yson::{YsonFormat, from_slice};
105///
106/// let answer = br#"{code=1;message="Failed to run query";
107///     attributes={host=localhost;pid=693};
108///     inner_errors=[{code=1;message="Execution";
109///         inner_errors=[{code=1205;message="Memory limit exceeded"}]}]}"#;
110/// let error = from_slice(answer, YsonFormat::Text).expect("the fixture parses");
111///
112/// assert_eq!(
113///     error_summary(&error).as_deref(),
114///     Some("Failed to run query: Memory limit exceeded"),
115/// );
116/// ```
117pub fn error_summary(error: &YsonValue) -> Option<String> {
118    let top = text(field(error, "message")?)?;
119    match innermost_message(error) {
120        Some(inner) if inner != top => Some(format!("{top}: {inner}")),
121        _ => Some(top),
122    }
123}
124
125fn innermost_message(error: &YsonValue) -> Option<String> {
126    let YsonNode::List(inner) = &field(error, "inner_errors")?.node else {
127        return None;
128    };
129    let first = inner.first()?;
130    innermost_message(first).or_else(|| field(first, "message").and_then(text))
131}
132
133/// A dict entry, without the panic `YsonValue`'s `Index` would give.
134pub(crate) fn field<'a>(value: &'a YsonValue, key: &str) -> Option<&'a YsonValue> {
135    match &value.node {
136        YsonNode::Map(m) => m.get(key.as_bytes()),
137        _ => None,
138    }
139}
140
141/// A string field. YSON strings are byte strings, so this decodes lossily
142/// rather than refusing a name the cluster is happy with.
143pub(crate) fn text(value: &YsonValue) -> Option<String> {
144    match &value.node {
145        YsonNode::String(bytes) => Some(String::from_utf8_lossy(bytes).into_owned()),
146        _ => None,
147    }
148}
149
150/// A byte count, which the cluster sends unsigned but which nothing forbids
151/// arriving signed.
152fn count(value: &YsonValue) -> Option<u64> {
153    match value.node {
154        YsonNode::Int64(v) => u64::try_from(v).ok(),
155        YsonNode::Uint64(v) => Some(v),
156        _ => None,
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use ytsaurus_yson::{YsonFormat, from_slice};
164
165    fn parse(text: &str) -> YsonValue {
166        from_slice(text.as_bytes(), YsonFormat::Text).expect("valid YSON")
167    }
168
169    /// The response shape from the command reference, trimmed to the fields
170    /// this client reads.
171    const LIST_JOBS_RESPONSE: &str = r#"{
172        "jobs" = [
173            {
174                "id" = "55aff293-7ef14284-3fe0384-3e07";
175                "type" = "map";
176                "state" = "failed";
177                "address" = "hostname.net:9012";
178                "fail_context_size" = 973230u;
179                "stderr_size" = 1024u;
180                "error" = {
181                    "code" = 1205;
182                    "message" = "User job failed";
183                    "inner_errors" = [
184                        {
185                            "code" = 10000;
186                            "message" = "Process exited with code 101";
187                        };
188                    ];
189                };
190            };
191            {
192                "id" = "69ae20a7-887b25ab-3fe0384-3cff";
193                "type" = "map";
194                "state" = "running";
195                "address" = "hostname.net:9012";
196            };
197        ];
198        "state_counts" = {"running" = 1; "failed" = 1};
199    }"#;
200
201    #[test]
202    fn reads_the_documented_list_jobs_response() {
203        let response = parse(LIST_JOBS_RESPONSE);
204        let jobs = parse_jobs(field(&response, "jobs").expect("has jobs"));
205
206        assert_eq!(jobs.len(), 2);
207        assert_eq!(jobs[0].id, "55aff293-7ef14284-3fe0384-3e07");
208        assert_eq!(jobs[0].state, "failed");
209        assert_eq!(jobs[0].address.as_deref(), Some("hostname.net:9012"));
210        assert_eq!(jobs[0].stderr_size, Some(1024));
211        assert_eq!(
212            jobs[0].error.as_deref(),
213            Some("User job failed: Process exited with code 101")
214        );
215
216        // A running job has no error and no saved stderr, and the absence must
217        // stay distinguishable from "the cluster saved nothing".
218        assert_eq!(jobs[1].state, "running");
219        assert_eq!(jobs[1].error, None);
220        assert_eq!(jobs[1].stderr_size, None);
221    }
222
223    /// A real `list_jobs` response, captured from the local cluster after
224    /// running `cargo run -p ytsaurus-client --example diagnose`. The
225    /// documented shape above is what the reference promises; this is what a
226    /// cluster actually sends, which is not the same thing — it carries
227    /// `attributes` maps full of `u64`s, an entity `cypress_job_count`, and a
228    /// `brief_statistics` with YSON attributes on it.
229    const CAPTURED: &str = include_str!("../tests/fixtures/list_jobs_failed.yson");
230
231    #[test]
232    fn reads_a_response_captured_from_a_cluster() {
233        let response = parse(CAPTURED);
234        let jobs = parse_jobs(field(&response, "jobs").expect("has jobs"));
235
236        assert_eq!(jobs.len(), 1);
237        assert_eq!(jobs[0].id, "3dc650de-c17d51d2-10384-1000001");
238        assert_eq!(jobs[0].state, "failed");
239        assert_eq!(jobs[0].address.as_deref(), Some("localhost:24403"));
240        assert_eq!(
241            jobs[0].error.as_deref(),
242            Some("User job failed: Process terminated by signal 6"),
243            "the signal is the whole point — signal 6 is a Rust panic under \
244             panic=abort, and only the inner error names it"
245        );
246
247        // The cluster said one byte; the job's stderr was several hundred. The
248        // client asks for stderr regardless, and this is why.
249        assert_eq!(jobs[0].stderr_size, Some(1));
250    }
251
252    /// A real `get_job` answer, captured from the local cluster. It differs
253    /// from a `list_jobs` entry in two ways that matter: there is no
254    /// `{jobs=[…]}` around it, and the id is called `job_id`.
255    const GET_JOB: &str = include_str!("../tests/fixtures/get_job.yson");
256
257    #[test]
258    fn reads_a_single_job_captured_from_a_cluster() {
259        let job = parse_job(&parse(GET_JOB)).expect("the answer names a job");
260
261        assert_eq!(job.id, "c1b61a6-156b50eb-10384-1000001");
262        assert_eq!(job.state, "running");
263        assert_eq!(job.address.as_deref(), Some("localhost:24403"));
264        assert_eq!(
265            job.error, None,
266            "a job that has not failed has no error to report"
267        );
268    }
269
270    #[test]
271    fn an_answer_that_names_no_job_is_not_a_job() {
272        assert!(parse_job(&parse(r#"{"state"="running"}"#)).is_none());
273    }
274
275    #[test]
276    fn a_job_without_an_id_is_dropped() {
277        let response = parse(r#"{"jobs" = [{"state" = "failed"}; {"id" = "a-b-c-d"}]}"#);
278        let jobs = parse_jobs(field(&response, "jobs").expect("has jobs"));
279
280        assert_eq!(jobs.len(), 1, "the entry with no id must not be reported");
281        assert_eq!(jobs[0].id, "a-b-c-d");
282    }
283
284    #[test]
285    fn a_response_without_a_job_list_yields_nothing() {
286        assert!(parse_jobs(&parse(r#"{"jobs" = #}"#)["jobs"]).is_empty());
287        assert!(parse_jobs(&parse(r#""not a list""#)).is_empty());
288    }
289
290    #[test]
291    fn the_summary_reaches_the_innermost_message() {
292        let error = parse(
293            r#"{
294                "message" = "Operation failed";
295                "inner_errors" = [
296                    {
297                        "message" = "User job failed";
298                        "inner_errors" = [{"message" = "Process exited with code 1"}];
299                    };
300                ];
301            }"#,
302        );
303        assert_eq!(
304            error_summary(&error).as_deref(),
305            Some("Operation failed: Process exited with code 1")
306        );
307    }
308
309    #[test]
310    fn a_flat_error_is_not_repeated() {
311        let error = parse(r#"{"message" = "User job failed"; "inner_errors" = []}"#);
312        assert_eq!(error_summary(&error).as_deref(), Some("User job failed"));
313    }
314
315    #[test]
316    fn an_error_without_a_message_has_no_summary() {
317        assert_eq!(error_summary(&parse(r#"{"code" = 1}"#)), None);
318    }
319
320    #[test]
321    fn a_non_utf8_field_is_kept_lossily() {
322        // A job address the cluster is happy with but Rust would not accept as
323        // a `&str`. Dropping the job over it would lose the failure.
324        let job = YsonValue {
325            attributes: None,
326            node: YsonNode::Map(
327                [
328                    (b"id".to_vec(), string_value(b"a-b-c-d")),
329                    (b"address".to_vec(), string_value(&[0xFF, 0xFE])),
330                ]
331                .into_iter()
332                .collect(),
333            ),
334        };
335
336        let parsed = parse_job(&job).expect("an id is all it takes");
337        assert_eq!(parsed.address.as_deref(), Some("\u{FFFD}\u{FFFD}"));
338    }
339
340    fn string_value(bytes: &[u8]) -> YsonValue {
341        YsonValue {
342            attributes: None,
343            node: YsonNode::String(bytes.to_vec()),
344        }
345    }
346
347    #[test]
348    fn a_signed_byte_count_is_accepted_and_a_negative_one_is_not() {
349        assert_eq!(count(&parse("1024")), Some(1024));
350        assert_eq!(count(&parse("1024u")), Some(1024));
351        assert_eq!(count(&parse("-1")), None);
352        assert_eq!(count(&parse(r#""1024""#)), None);
353    }
354}