Skip to content

Commit 7a0b2c9

Browse files
authored
fix(spur-cli): sort squeue output and honor -S/--sort (#524)
* fix(spur-cli): sort squeue output and honor -S/--sort squeue returned jobs in controller HashMap order and silently ignored -S/--sort. Sort client-side (as Slurm does): parse comma-separated sort fields with +/- direction prefixes, apply Slurm's default P,t,-p (plus a jobid tiebreak) when -S is absent, and allow -S -i to parse. State sort follows Slurm's base state order; unknown sort fields are rejected. * fix(spur-cli): add TIME_LEFT sort comparator and avoid per-compare string clones squeue -S L was accepted but sorted as a no-op because compare_field had no 'L' arm and resolve_job_field returns "?" for it. Add a time-left comparator (time_limit - run_time, unlimited last). Also compare the common string specs (partition/user/name/account/qos) by borrowing the proto fields directly instead of cloning through resolve_job_field on every comparison. * fix(spur-cli): hoist squeue sort parse and share state sort-rank via spur-core Parse the -S/--sort spec before any network I/O so an invalid spec reports "Invalid sort specification" immediately instead of a downstream connect failure, and a typo no longer costs a full get_jobs fetch. Move the job-state sort ordering into JobState::sort_rank() in spur-core next to code()/display() so squeue no longer keeps a fourth hand-maintained copy of the state list. The rank stays an explicit match because the enum's declaration order differs from Slurm's sort order (SUSPENDED after RUNNING); a spur-core test guards that. Also from review: extract the single-char sort spec via an explicit match instead of a side-effecting filter, add disable_help_flag so squeue's -h stays --noheader and a parser test exercises allow_hyphen_values (-S -i), drop a duplicated call-site comment, and correct the TIME_LEFT helper doc comment. * fix(spur-cli): restore squeue --help as long-only flag disable_help_flag dropped both -h and --help; re-add --help via ArgAction::Help so -h stays --noheader while --help still prints help.
1 parent ebed254 commit 7a0b2c9

2 files changed

Lines changed: 378 additions & 4 deletions

File tree

crates/spur-cli/src/squeue.rs

Lines changed: 345 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ use crate::format_engine;
99

1010
/// View information about jobs in the scheduling queue.
1111
#[derive(Parser, Debug)]
12-
#[command(name = "squeue", about = "View the job queue")]
12+
// -h is squeue's --noheader (Slurm convention), so disable clap's auto -h and
13+
// re-add --help below as long-only.
14+
#[command(
15+
name = "squeue",
16+
about = "View the job queue",
17+
disable_help_flag = true
18+
)]
1319
pub struct SqueueArgs {
1420
/// Show only jobs for this user
1521
#[arg(short = 'u', long)]
@@ -47,8 +53,12 @@ pub struct SqueueArgs {
4753
#[arg(short = 'h', long)]
4854
pub noheader: bool,
4955

50-
/// Sort by field
51-
#[arg(short = 'S', long)]
56+
/// Print help
57+
#[arg(long, action = clap::ArgAction::Help)]
58+
pub help: Option<bool>,
59+
60+
/// Sort by field(s), comma-separated, each optionally prefixed with + (asc) or - (desc)
61+
#[arg(short = 'S', long, allow_hyphen_values = true)]
5262
pub sort: Option<String>,
5363

5464
/// Controller address
@@ -84,6 +94,13 @@ pub async fn main_with_args(args: Vec<String>) -> Result<()> {
8494
None => default_squeue_states(),
8595
};
8696

97+
// Parse the sort spec before any network I/O so an invalid -S surfaces its own error
98+
// rather than a downstream connect failure.
99+
let sort_keys = match args.sort.as_deref() {
100+
Some(s) => parse_sort_arg(s)?,
101+
None => default_sort_keys(),
102+
};
103+
87104
// Parse job ID filter
88105
let job_ids = args
89106
.jobs
@@ -113,7 +130,8 @@ pub async fn main_with_args(args: Vec<String>) -> Result<()> {
113130
.await
114131
.context("failed to get jobs")?;
115132

116-
let jobs = response.into_inner().jobs;
133+
let mut jobs = response.into_inner().jobs;
134+
sort_jobs(&mut jobs, &sort_keys);
117135

118136
// Print header
119137
if !args.noheader {
@@ -173,6 +191,132 @@ fn resolve_job_field(job: &spur_proto::proto::JobInfo, spec: char) -> String {
173191
}
174192
}
175193

194+
/// One field in a sort spec, with direction.
195+
#[derive(Debug, Clone, Copy, PartialEq)]
196+
struct SortKey {
197+
spec: char,
198+
descending: bool,
199+
}
200+
201+
/// Slurm's documented default job sort: partition asc, state asc, priority desc.
202+
/// A trailing jobid asc keeps equal-priority jobs deterministically ordered.
203+
fn default_sort_keys() -> Vec<SortKey> {
204+
vec![
205+
SortKey {
206+
spec: 'P',
207+
descending: false,
208+
},
209+
SortKey {
210+
spec: 't',
211+
descending: false,
212+
},
213+
SortKey {
214+
spec: 'p',
215+
descending: true,
216+
},
217+
SortKey {
218+
spec: 'i',
219+
descending: false,
220+
},
221+
]
222+
}
223+
224+
/// Parse `-S` / `--sort`: comma-separated fields, each optionally prefixed with
225+
/// `+` (asc, default) or `-` (desc). Reuses the format-spec letters.
226+
fn parse_sort_arg(s: &str) -> Result<Vec<SortKey>> {
227+
let mut keys = Vec::new();
228+
for token in s.split(',').map(str::trim).filter(|t| !t.is_empty()) {
229+
let (descending, rest) = match token.strip_prefix('-') {
230+
Some(r) => (true, r),
231+
None => (false, token.strip_prefix('+').unwrap_or(token)),
232+
};
233+
let mut chars = rest.chars();
234+
let spec = match (chars.next(), chars.next()) {
235+
(Some(c), None) => c,
236+
_ => anyhow::bail!("Invalid sort specification: {token}"),
237+
};
238+
if format_engine::squeue_header(spec) == "?" {
239+
anyhow::bail!("Invalid sort specification: {token}");
240+
}
241+
keys.push(SortKey { spec, descending });
242+
}
243+
if keys.is_empty() {
244+
anyhow::bail!("Invalid sort specification: (empty)");
245+
}
246+
Ok(keys)
247+
}
248+
249+
fn sort_jobs(jobs: &mut [spur_proto::proto::JobInfo], keys: &[SortKey]) {
250+
jobs.sort_by(|a, b| {
251+
for key in keys {
252+
let ord = compare_field(a, b, key.spec);
253+
let ord = if key.descending { ord.reverse() } else { ord };
254+
if ord != std::cmp::Ordering::Equal {
255+
return ord;
256+
}
257+
}
258+
std::cmp::Ordering::Equal
259+
});
260+
}
261+
262+
/// Compare two jobs on a single field. Numeric fields compare numerically;
263+
/// timestamps by epoch seconds; everything else lexically on the rendered value.
264+
fn compare_field(
265+
a: &spur_proto::proto::JobInfo,
266+
b: &spur_proto::proto::JobInfo,
267+
spec: char,
268+
) -> std::cmp::Ordering {
269+
match spec {
270+
'i' | 'A' => a.job_id.cmp(&b.job_id),
271+
'p' => a.priority.cmp(&b.priority),
272+
'D' => a.num_nodes.cmp(&b.num_nodes),
273+
'C' => a.cpus_per_task.cmp(&b.cpus_per_task),
274+
't' | 'T' => state_sort_rank(a.state).cmp(&state_sort_rank(b.state)),
275+
'M' => run_time_secs(a).cmp(&run_time_secs(b)),
276+
'l' => time_limit_secs(a).cmp(&time_limit_secs(b)),
277+
'L' => time_left_secs(a).cmp(&time_left_secs(b)),
278+
'S' => ts_secs(a.start_time.as_ref()).cmp(&ts_secs(b.start_time.as_ref())),
279+
'V' => ts_secs(a.submit_time.as_ref()).cmp(&ts_secs(b.submit_time.as_ref())),
280+
'e' => ts_secs(a.end_time.as_ref()).cmp(&ts_secs(b.end_time.as_ref())),
281+
'P' => a.partition.cmp(&b.partition),
282+
'u' => a.user.cmp(&b.user),
283+
'j' | 'n' => a.name.cmp(&b.name),
284+
'a' => a.account.cmp(&b.account),
285+
'q' => a.qos.cmp(&b.qos),
286+
_ => resolve_job_field(a, spec).cmp(&resolve_job_field(b, spec)),
287+
}
288+
}
289+
290+
fn state_sort_rank(state: i32) -> u8 {
291+
spur_core::job::JobState::from_proto_i32(state)
292+
.map(|s| s.sort_rank())
293+
.unwrap_or(u8::MAX)
294+
}
295+
296+
fn run_time_secs(job: &spur_proto::proto::JobInfo) -> i64 {
297+
job.run_time.as_ref().map(|d| d.seconds).unwrap_or(0)
298+
}
299+
300+
fn time_limit_secs(job: &spur_proto::proto::JobInfo) -> i64 {
301+
job.time_limit
302+
.as_ref()
303+
.map(|d| d.seconds)
304+
.unwrap_or(i64::MAX)
305+
}
306+
307+
fn ts_secs(ts: Option<&prost_types::Timestamp>) -> i64 {
308+
ts.map(|t| t.seconds).unwrap_or(0)
309+
}
310+
311+
/// Unlimited time limit sorts last, matching `-S L`.
312+
fn time_left_secs(job: &spur_proto::proto::JobInfo) -> i64 {
313+
let limit = time_limit_secs(job);
314+
if limit == i64::MAX {
315+
return i64::MAX;
316+
}
317+
limit.saturating_sub(run_time_secs(job))
318+
}
319+
176320
fn state_code(state: i32) -> String {
177321
spur_core::job::JobState::from_proto_i32(state)
178322
.map(|s| s.code().to_string())
@@ -273,6 +417,26 @@ mod tests {
273417
use super::*;
274418
use spur_proto::proto::JobState as P;
275419

420+
#[test]
421+
fn sort_flag_accepts_hyphen_prefixed_value() {
422+
// -S -i must parse as a descending sort spec, not a flag (allow_hyphen_values).
423+
let args = SqueueArgs::try_parse_from(["squeue", "-S", "-i"]).unwrap();
424+
assert_eq!(args.sort.as_deref(), Some("-i"));
425+
}
426+
427+
#[test]
428+
fn long_help_flag_is_preserved() {
429+
// -h is reclaimed for --noheader, but --help must still print help.
430+
let err = SqueueArgs::try_parse_from(["squeue", "--help"]).unwrap_err();
431+
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
432+
}
433+
434+
#[test]
435+
fn short_h_is_noheader_not_help() {
436+
let args = SqueueArgs::try_parse_from(["squeue", "-h"]).unwrap();
437+
assert!(args.noheader);
438+
}
439+
276440
#[test]
277441
fn default_squeue_states_includes_completing() {
278442
let states = default_squeue_states();
@@ -311,4 +475,181 @@ mod tests {
311475
assert!(parse_states_arg("").is_err());
312476
assert!(parse_states_arg(" , ").is_err());
313477
}
478+
479+
fn job(id: u32, partition: &str, state: P, priority: u32) -> spur_proto::proto::JobInfo {
480+
spur_proto::proto::JobInfo {
481+
job_id: id,
482+
partition: partition.into(),
483+
state: state as i32,
484+
priority,
485+
..Default::default()
486+
}
487+
}
488+
489+
fn ids(jobs: &[spur_proto::proto::JobInfo]) -> Vec<u32> {
490+
jobs.iter().map(|j| j.job_id).collect()
491+
}
492+
493+
#[test]
494+
fn parse_sort_arg_directions() {
495+
let keys = parse_sort_arg("P,-p,+i").unwrap();
496+
assert_eq!(keys.len(), 3);
497+
assert_eq!(
498+
keys[0],
499+
SortKey {
500+
spec: 'P',
501+
descending: false
502+
}
503+
);
504+
assert_eq!(
505+
keys[1],
506+
SortKey {
507+
spec: 'p',
508+
descending: true
509+
}
510+
);
511+
assert_eq!(
512+
keys[2],
513+
SortKey {
514+
spec: 'i',
515+
descending: false
516+
}
517+
);
518+
}
519+
520+
#[test]
521+
fn parse_sort_arg_rejects_bad_tokens() {
522+
assert!(parse_sort_arg("").is_err());
523+
assert!(parse_sort_arg("ii").is_err());
524+
assert!(parse_sort_arg("-").is_err());
525+
}
526+
527+
#[test]
528+
fn sort_ascending_and_descending_jobid() {
529+
let mut jobs = vec![
530+
job(70, "default", P::JobRunning, 100),
531+
job(72, "default", P::JobRunning, 100),
532+
job(71, "default", P::JobRunning, 100),
533+
];
534+
sort_jobs(&mut jobs, &parse_sort_arg("i").unwrap());
535+
assert_eq!(ids(&jobs), vec![70, 71, 72]);
536+
sort_jobs(&mut jobs, &parse_sort_arg("-i").unwrap());
537+
assert_eq!(ids(&jobs), vec![72, 71, 70]);
538+
}
539+
540+
#[test]
541+
fn default_sort_matches_slurm_p_t_negp() {
542+
// partition asc, then state asc (PD<R), then priority desc, then jobid asc
543+
let mut jobs = vec![
544+
job(10, "b", P::JobRunning, 100),
545+
job(11, "a", P::JobRunning, 50),
546+
job(12, "a", P::JobPending, 200),
547+
job(13, "a", P::JobRunning, 50),
548+
];
549+
sort_jobs(&mut jobs, &default_sort_keys());
550+
assert_eq!(ids(&jobs), vec![12, 11, 13, 10]);
551+
}
552+
553+
#[test]
554+
fn sort_by_priority_desc_then_jobid_tiebreak() {
555+
let mut jobs = vec![
556+
job(70, "default", P::JobPending, 100),
557+
job(71, "default", P::JobPending, 300),
558+
job(72, "default", P::JobPending, 300),
559+
];
560+
sort_jobs(&mut jobs, &parse_sort_arg("-p,i").unwrap());
561+
assert_eq!(ids(&jobs), vec![71, 72, 70]);
562+
}
563+
564+
#[test]
565+
fn parse_sort_arg_rejects_unknown_spec() {
566+
let err = parse_sort_arg("x").unwrap_err();
567+
assert!(err.to_string().contains("Invalid sort specification"));
568+
assert!(parse_sort_arg("-z").is_err());
569+
assert!(parse_sort_arg("i,x").is_err());
570+
}
571+
572+
#[test]
573+
fn state_sort_places_suspended_after_running() {
574+
let mut jobs = vec![
575+
job(1, "default", P::JobFailed, 0),
576+
job(2, "default", P::JobSuspended, 0),
577+
job(3, "default", P::JobRunning, 0),
578+
job(4, "default", P::JobPending, 0),
579+
];
580+
sort_jobs(&mut jobs, &parse_sort_arg("t").unwrap());
581+
assert_eq!(ids(&jobs), vec![4, 3, 2, 1]);
582+
}
583+
584+
#[test]
585+
fn sort_by_timelimit_puts_unlimited_last() {
586+
let mut a = job(1, "default", P::JobRunning, 0);
587+
a.time_limit = Some(prost_types::Duration {
588+
seconds: 600,
589+
nanos: 0,
590+
});
591+
let b = job(2, "default", P::JobRunning, 0);
592+
let mut c = job(3, "default", P::JobRunning, 0);
593+
c.time_limit = Some(prost_types::Duration {
594+
seconds: 60,
595+
nanos: 0,
596+
});
597+
let mut jobs = vec![a, b, c];
598+
sort_jobs(&mut jobs, &parse_sort_arg("l").unwrap());
599+
assert_eq!(ids(&jobs), vec![3, 1, 2]);
600+
}
601+
602+
#[test]
603+
fn sort_by_submit_time_ascending() {
604+
let mut a = job(1, "default", P::JobPending, 0);
605+
a.submit_time = Some(prost_types::Timestamp {
606+
seconds: 300,
607+
nanos: 0,
608+
});
609+
let mut b = job(2, "default", P::JobPending, 0);
610+
b.submit_time = Some(prost_types::Timestamp {
611+
seconds: 100,
612+
nanos: 0,
613+
});
614+
let mut jobs = vec![a, b];
615+
sort_jobs(&mut jobs, &parse_sort_arg("V").unwrap());
616+
assert_eq!(ids(&jobs), vec![2, 1]);
617+
}
618+
619+
#[test]
620+
fn sort_by_partition_orders_lexically() {
621+
let mut jobs = vec![
622+
job(1, "gamma", P::JobRunning, 0),
623+
job(2, "alpha", P::JobRunning, 0),
624+
job(3, "beta", P::JobRunning, 0),
625+
];
626+
sort_jobs(&mut jobs, &parse_sort_arg("P").unwrap());
627+
assert_eq!(ids(&jobs), vec![2, 3, 1]);
628+
}
629+
630+
#[test]
631+
fn sort_by_time_left_puts_unlimited_last() {
632+
let mut a = job(1, "default", P::JobRunning, 0);
633+
a.time_limit = Some(prost_types::Duration {
634+
seconds: 600,
635+
nanos: 0,
636+
});
637+
a.run_time = Some(prost_types::Duration {
638+
seconds: 60,
639+
nanos: 0,
640+
});
641+
let b = job(2, "default", P::JobRunning, 0);
642+
let mut c = job(3, "default", P::JobRunning, 0);
643+
c.time_limit = Some(prost_types::Duration {
644+
seconds: 600,
645+
nanos: 0,
646+
});
647+
c.run_time = Some(prost_types::Duration {
648+
seconds: 500,
649+
nanos: 0,
650+
});
651+
let mut jobs = vec![a, b, c];
652+
sort_jobs(&mut jobs, &parse_sort_arg("L").unwrap());
653+
assert_eq!(ids(&jobs), vec![3, 1, 2]);
654+
}
314655
}

0 commit comments

Comments
 (0)