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 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
133pub(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
141pub(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
150fn 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 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 assert_eq!(jobs[1].state, "running");
219 assert_eq!(jobs[1].error, None);
220 assert_eq!(jobs[1].stderr_size, None);
221 }
222
223 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 assert_eq!(jobs[0].stderr_size, Some(1));
250 }
251
252 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 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}