1use ytsaurus_yson::{YsonNode, YsonValue};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct JobInfo {
22 pub id: String,
24 pub state: String,
26 pub address: Option<String>,
28 pub error: Option<String>,
30 pub stderr_size: Option<u64>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct JobFailure {
42 pub id: String,
44 pub address: Option<String>,
46 pub error: Option<String>,
48 pub stderr: Option<String>,
50}
51
52pub(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
63pub(crate) fn parse_job(job: &YsonValue) -> Option<JobInfo> {
69 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
81pub(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
102pub(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
110pub(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
119fn 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 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 assert_eq!(jobs[1].state, "running");
188 assert_eq!(jobs[1].error, None);
189 assert_eq!(jobs[1].stderr_size, None);
190 }
191
192 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 assert_eq!(jobs[0].stderr_size, Some(1));
219 }
220
221 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 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}