Perl Part II · Module 25 · Regression Automation
Job Submission to LSF or SGE
A regression of a thousand runs does not run on one machine. It is submitted to a compute farm, through a scheduler such as LSF or Grid Engine, and the runs happen on machines the runner never sees. What the runner gets back from each submission is a single number: a job id. This chapter is about getting that number reliably and never losing the link between it and the run it belongs to.
Advanced27 min readPerlPart IIRegression
Part II · Module 25 · Chapter 25.5 · Job Submission to LSF or SGE
1. What Changes on a Farm
In Chapter 25.4 the runner started each simulation itself and waited for it. On a farm, the runner instead asks a scheduler to run a job. The scheduler queues it, picks a machine when one is free, runs it there, and keeps its own records. Several things change at once:
| Locally (Chapter 25.4) | On a farm |
|---|---|
fork gives you a process id you can wait on | the submit command returns at once with a job id |
| the run happens on this machine | the run happens on another machine, later |
waitpid tells you when it ended and how | you must ask the scheduler, or look for files the job wrote |
| the runner and the run live and die together | the runner can die and the jobs keep running |
| a launch either starts or fails right away | a submission can be refused (bad queue, quota, policy) |
The job id is now the only link between "a run I planned" and "a job somewhere on the farm". Every later question — is it finished, did it fail, should it be killed — starts from that id. So the core of this chapter is three duties: get the id reliably, store it immediately, and store it in a form that survives the runner.
2. The Farm on This Page Is a Mock
There is no LSF, Grid Engine or other scheduler on the machine these examples were run on. So this chapter uses a mock farm: three small Perl scripts that imitate the parts of a scheduler the runner talks to. They are shown in full, so you can see exactly what is and is not being imitated.
What the mocks copy from the real tools is the submission line each vendor documents: LSF's bsub prints Job <ID> is submitted to queue <QUEUE>., and Grid Engine's qsub prints Your job ID ("NAME") has been submitted, or just the id with -terse. Everything else — the error messages, the bjobs columns, the timing — is the mock's own and is not a description of any real scheduler. The jobs themselves are real: each one runs the Module 25 bench on the real simulator.
#!/usr/bin/perl
# farm/bsub -- a MOCK of LSF's bsub, for this module's examples only.
# It accepts -q QUEUE, -J NAME, -o FILE; the rest of the words are the job's
# command. It prints the line LSF documents for a successful submission,
# Job <ID> is submitted to queue <QUEUE>.
# and runs the job in the background on this machine. It is NOT LSF.
# If MOCK_SITE_BANNER is set, it first prints that text, the way many sites
# wrap bsub in a script that prints its own notice.
use strict;
use warnings;
use FindBin qw($Bin);
use POSIX qw(setsid);
my %QUEUES = map { $_ => 1 } qw(normal long);
my ($queue, $name, $out) = ("normal", "", "/dev/null");
while (@ARGV && $ARGV[0] =~ /^-/) {
my $o = shift;
if ($o eq "-q") { $queue = shift }
elsif ($o eq "-J") { $name = shift }
elsif ($o eq "-o") { $out = shift }
else { print STDERR "mock bsub: unknown option $o\n"; exit 255 }
}
@ARGV or do { print STDERR "mock bsub: no command given\n"; exit 255 };
$QUEUES{$queue} or do {
print STDERR "mock bsub: queue '$queue' does not exist; job not submitted\n";
exit 255;
};
my $state = "$Bin/state";
mkdir $state;
open my $lk, "+>>", "$state/next_id" or die "mock bsub: $!\n";
flock $lk, 2; seek $lk, 0, 0;
my $id = <$lk> // 4100; chomp $id; $id++;
truncate $lk, 0; print {$lk} "$id\n"; close $lk;
my $cmd = join " ", @ARGV; # the mock keeps the command as ONE string
write_state($id, "PEND");
open my $meta, ">", "$state/$id.job" or die;
print {$meta} "name=$name\nqueue=$queue\ncmd=$cmd\n";
close $meta;
my $pid = fork // die "mock bsub: fork: $!\n";
if ($pid == 0) { # the "execution host"
setsid();
open STDIN, "<", "/dev/null";
open STDOUT, ">", $out or exit 1;
open STDERR, ">&", \*STDOUT;
select(undef, undef, undef, 0.2); # dispatch delay
write_state($id, "RUN");
system("/bin/sh", "-c", $cmd); # run through a shell, as a job script would
write_state($id, $? == 0 ? "DONE" : "EXIT", $? >> 8);
exit 0;
}
print "$ENV{MOCK_SITE_BANNER}\n" if $ENV{MOCK_SITE_BANNER};
print "Job <$id> is submitted to queue <$queue>.\n";
exit 0;
sub write_state {
my ($j, $s, $code) = @_;
open my $f, ">", "$state/$j.stat.tmp" or die;
print {$f} defined $code ? "$s $code\n" : "$s\n";
close $f;
rename "$state/$j.stat.tmp", "$state/$j.stat";
}The mock gives each job an id starting at 4101, records its state in farm/state/ (PEND, RUN, then DONE or EXIT, which are the state names LSF uses), and runs the command in the background, through a shell, with its output going to the -o file.
#!/usr/bin/perl
# farm/bjobs -- a MOCK of LSF's bjobs: one row per job id, or every job whose
# name matches -J NAME ('*' matches anything). Prints JOBID, STAT and JOB_NAME only (real bjobs
# prints more columns). It is NOT LSF.
use strict;
use warnings;
use FindBin qw($Bin);
my $state = "$Bin/state";
my $want_name;
if (@ARGV >= 2 && $ARGV[0] eq "-J") { shift; $want_name = shift }
my @ids = @ARGV;
if (defined $want_name) {
@ids = ();
for my $f (sort glob "$state/*.job") {
my ($id) = $f =~ m{/(\d+)\.job$};
my %m = map { /^(\w+)=(.*)$/ } do { open my $h, "<", $f or die; <$h> };
my $re = join ".*", map { quotemeta } split /\*/, $want_name, -1;
push @ids, $id if $m{name} =~ /^$re$/;
}
@ids or do { print STDERR "mock bjobs: no job matches $want_name\n"; exit 255 };
}
print "JOBID STAT JOB_NAME\n";
for my $id (@ids) {
open my $s, "<", "$state/$id.stat" or do { print STDERR "mock bjobs: no job $id\n"; next };
my ($stat) = split ' ', scalar <$s>;
my %m = map { /^(\w+)=(.*)$/ } do { open my $h, "<", "$state/$id.job" or die; <$h> };
printf "%-7s %-5s %s\n", $id, $stat, $m{name};
}#!/usr/bin/perl
# farm/qsub -- a MOCK of Grid Engine's qsub, for this module's examples only.
# Accepts -q QUEUE, -N NAME, -o FILE, -b y, -terse; the rest is the command. Prints the
# line Grid Engine documents, Your job ID ("NAME") has been submitted
# or, with -terse, only the job id. Jobs run through the mock bsub's backend.
# It is NOT Grid Engine.
use strict;
use warnings;
use FindBin qw($Bin);
my ($queue, $name, $out, $terse) = ("normal", "", "/dev/null", 0);
while (@ARGV && $ARGV[0] =~ /^-/) {
my $o = shift;
if ($o eq "-q") { $queue = shift }
elsif ($o eq "-N") { $name = shift }
elsif ($o eq "-o") { $out = shift }
elsif ($o eq "-b") { shift } # -b y: the command is a binary
elsif ($o eq "-terse") { $terse = 1 }
else { print STDERR "mock qsub: unknown option $o\n"; exit 1 }
}
my %QUEUES = map { $_ => 1 } qw(normal long);
$QUEUES{$queue} or do {
print STDERR "mock qsub: queue '$queue' does not exist; job not submitted\n";
exit 1;
};
open my $p, "-|", "$Bin/bsub", "-q", $queue, "-J", $name, "-o", $out, @ARGV or exit 1;
my $said = do { local $/; <$p> };
close $p;
my ($id) = $said =~ /^Job <(\d+)>/ or exit 1;
print $terse ? "$id\n" : "Your job $id (\"$name\") has been submitted\n";The Grid Engine mock checks the queue and then reuses the LSF mock to run the job, printing Grid Engine's message instead. That is enough to show the one thing that differs between schedulers from the runner's point of view: the text you parse the id from.
3. The First Version, Measured
Here is submission the way it is often first written: backticks, a string, and one regex — very close to the registry's example for this chapter. Two of the four runs have a mistyped queue name, which is an ordinary mistake in a per-test option:
#!/usr/bin/perl
# naive_submit.pl -- farm submission as it is usually first written.
use strict;
use warnings;
open STDERR, ">&", \*STDOUT or die; # show warnings where they happen
$| = 1;
system("bin/build") == 0 or die "build failed\n";
my @runs = (["smoke_s1", "normal", "+test=smoke +seed=1"],
["rand_s2", "lng", "+test=rand +seed=2"], # a typo'd queue
["corner_s1", "normal", "+test=corner +seed=1"],
["rand_s3", "lnog", "+test=rand +seed=3"]); # another typo
my %job;
for my $r (@runs) {
my ($run, $queue, $args) = @$r;
mkdir "runs"; mkdir "runs/$run";
my $out = `farm/bsub -q $queue -J $run -o runs/$run/sim.log "bin/simv $args"`;
my ($jid) = $out =~ /Job <(\d+)>/;
$job{$jid} = "runs/$run";
}
print "\n%job holds ", scalar(keys %job), " entries for ", scalar(@runs), " runs:\n";
printf " %-6s => %s\n", ($_ eq "" ? '""' : $_), $job{$_} for sort keys %job;
sleep 1 while `farm/bjobs @{[ grep { length } keys %job ]} 2>/dev/null` =~ /\b(?:PEND|RUN)\b/;mock bsub: queue 'lng' does not exist; job not submitted
Use of uninitialized value $jid in hash element at naive_submit.pl line 20.
mock bsub: queue 'lnog' does not exist; job not submitted
Use of uninitialized value $jid in hash element at naive_submit.pl line 20.
%job holds 3 entries for 4 runs:
"" => runs/rand_s3
4101 => runs/smoke_s1
4102 => runs/corner_s1Four runs went in. Look at what came out:
- The scheduler's refusal went to standard error, and backticks capture only standard output. So
$outwas empty for the two refused runs, and the refusal message scrolled past on the terminal, where no program reads it. $jidwasundef, and$job{$jid}used it as a hash key. Perl warned —Use of uninitialized value $jid in hash element— and used the empty string as the key.- Both refused runs wrote to the same key
"". The second one overwrote the first. Sorand_s2is not in the map at all: as far as any later stage knows, it was never planned. Andrand_s3is filed under a job id that does not exist.
Every later stage of the runner reads this map. The waiting stage will wait for three jobs, one of which is called "". The report will have three rows. rand_s2 — a run whose seed hits the adder's bug — has disappeared without an error, and the only trace is a warning line that someone has to notice in a scrolling terminal.
The fix has three parts, and the submitter in Section 7 does all of them: capture standard error together with standard output, check the submit command's exit status, and never store a job id you did not actually find. A refused submission is a result — NOT SUBMITTED, with the scheduler's reason — and it must appear in the report like any other.
4. Finding the Job Id
The documented submission line is the contract. Parse that line, anchored at both ends, and nothing else. A looser pattern works until the day the output contains something else — and on real farms it often does, because sites commonly wrap bsub in a script that prints its own notices first:
#!/usr/bin/perl
# banner_parse.pl -- two ways to find the job id when the site's bsub wrapper
# prints a notice first.
use strict;
use warnings;
$ENV{MOCK_SITE_BANNER} = "site policy: jobs longer than 4h must use queue long";
my $out = `farm/bsub -J demo -o /dev/null bin/simv +test=smoke`;
print "bsub printed:\n", map { " $_\n" } split /\n/, $out;
my ($first_number) = $out =~ /(\d+)/;
my ($anchored) = $out =~ /^Job <(\d+)> is submitted to queue <\S+>\.$/m;
print "first number in the output: $first_number\n";
print "the documented line's id: $anchored\n";
sleep 1 while `farm/bjobs $anchored` =~ /\b(?:PEND|RUN)\b/;bsub printed:
site policy: jobs longer than 4h must use queue long
Job <4101> is submitted to queue <normal>.
first number in the output: 4
the documented line's id: 4101"The first number in the output" gave 4, from "4h" in the site's notice. A runner using that would store job 4 — somebody else's job, or nobody's — and later wait on it, or kill it. The anchored pattern found the documented line and the right id.
The lines to anchor on, one per scheduler:
| Scheduler | Submit command | Line to parse | Pattern |
|---|---|---|---|
| LSF | bsub | Job <4101> is submitted to queue <normal>. | ^Job <(\d+)> is submitted to queue <\S+>\.$ |
| Grid Engine | qsub | Your job 4101 ("name") has been submitted | ^Your job (\d+) |
| Grid Engine | qsub -terse | 4101 | ^(\d+)$ |
Prefer a "terse" or "parsable" mode when your scheduler has one, because then the whole output is the id. Either way, keep the pattern in the same table as the submit command, so that supporting another scheduler means adding a row, not hunting through the code. And confirm the exact line against your own site's scheduler, because site wrappers can change what users see.
5. Submit a Script, Not a Command Line
Chapter 25.4 showed what a shell does to a command line built as a string. A scheduler is one more place where that happens: the job's command is recorded as text and started later, on another machine — in the mock, by handing the text to /bin/sh, and on real farms usually through a shell as well. Every quoting problem from Chapter 25.4 comes back, now with the extra difficulty that the shell runs on a machine you cannot see and the failure shows up hours later.
There is a simple way out: submit a script. The runner writes everything the job must do into a small file in the run directory, and submits only that file's path — one word, made of letters, digits, /, _ and ., which no shell can misread:
#!/bin/sh
# what the farm runs: the command, its log, its status, then 'done'
cd "$(dirname "$0")" || exit 2
./cmd.sh > sim.log 2>&1
echo "exit=$?" > status
: > doneThis job.sh reuses the cmd.sh idea from Chapter 25.4 (the quoted, re-runnable command) and adds what a farm job needs:
- It writes its own
sim.log. The scheduler's-ofile is the scheduler's: on LSF, for example, it also receives a report about the job from the scheduler itself. Keep the simulator's log in a file that contains only the simulator's output. - It writes
status. On a farm the runner is not the simulator's parent and never sees its$?. The job script is the parent, so it records the exit status. (Because it is a shell, a simulator killed by a signal arrives as 128 + N, as Chapter 25.4 measured. That is one reason the scheduler's own record of the job is a second witness worth keeping.) - It creates
donelast. An empty file whose existence means "this job reached its end". Chapter 25.6 waits for exactly these files. Creating it afterstatusmeans that wheneverdoneexists,statusis already complete.
6. The Job Map Must Survive the Runner
The runner keeps a map from job id to run. If the map lives only in a hash in memory, it lives exactly as long as the runner process. On a farm that is a real problem: runners run for hours, on login machines that get rebooted, in terminal sessions that get closed, under CI systems that kill jobs that run too long. When the runner dies, the jobs keep running on the farm — and without the map, nobody knows which job is which run.
So the map is written to a file, one line per job, at the moment of submission. How "at the moment" is not as obvious as it looks. Here a runner submits three jobs, writing each id to its map file, and then dies from signal 9 — the signal that gives a process no chance to clean up:
#!/usr/bin/perl
# crash_demo.pl -- a runner dies (signal 9) after submitting three jobs.
# Does its job map survive? Can the jobs be found again?
use strict;
use warnings;
use IO::Handle;
system("bin/build") == 0 or die "build failed\n";
for my $variant ("buffered", "autoflush") {
my $regr = "r_$variant";
my $pid = fork // die "fork: $!\n";
if ($pid == 0) { # the runner
open my $map, ">", "$regr.jobs.tsv" or die;
$map->autoflush(1) if $variant eq "autoflush";
for my $n (1 .. 3) {
my $out = `farm/bsub -J $regr.smoke_s$n -o /dev/null bin/simv +test=smoke +seed=$n`;
my ($jid) = $out =~ /^Job <(\d+)> is submitted/m or die;
print {$map} "$jid\tsmoke_s$n\n";
}
kill "KILL", $$; # the runner dies here
}
waitpid($pid, 0);
my $sig = $? & 127; # copy it before the next command
open my $m, "<", "$regr.jobs.tsv" or die;
my @lines = <$m>;
my @found = grep { /^\d/ } `farm/bjobs -J '$regr.*'`;
printf "%-9s runner died by signal %d; its map has %d line(s); the farm has %d job(s) named %s.*\n",
$variant, $sig, scalar @lines, scalar @found, $regr;
}
sleep 1 while `farm/bjobs -J 'r_*'` =~ /\b(?:PEND|RUN)\b/; # let the jobs finish
print "\nwhat r_buffered.jobs.tsv kept:\n";
open my $kept, "<", "r_buffered.jobs.tsv" or die;
print " $_" while <$kept>;
print "\nrecovering the full map from the job names:\n";
for (`farm/bjobs -J 'r_buffered.*'`) {
next unless /^(\d+)\s+(\S+)\s+r_buffered\.(\S+)$/;
print " job $1 -> run $3 ($2)\n";
}buffered runner died by signal 9; its map has 2 line(s); the farm has 3 job(s) named r_buffered.*
autoflush runner died by signal 9; its map has 3 line(s); the farm has 3 job(s) named r_autoflush.*
what r_buffered.jobs.tsv kept:
4101 smoke_s1
4102 smoke_s2
recovering the full map from the job names:
job 4101 -> run smoke_s1 (DONE)
job 4102 -> run smoke_s2 (DONE)
job 4103 -> run smoke_s3 (DONE)The three jobs were on the farm in both cases. What differed was the map:
- With normal buffering, the map kept two lines of three. Perl collects output in a buffer and writes it to the file in large chunks. Signal 9 destroys the process — and its buffer — immediately. Why were two lines saved, not zero? Because Perl flushes every output buffer before it starts a child process (backticks,
system,fork, pipedopen). Each new submission started a child, which flushed the lines written before it. Only the line written after the last submission was still in the buffer when the runner died. So the size of the loss depends on what the runner happened to do next — which is exactly the kind of behaviour you do not want to depend on. - With
autoflush, every line reached the file as it was written.$map->autoflush(1)(fromIO::Handle) makes Perl write eachprintimmediately. The cost is a few more small writes, which for one line per submitted job is nothing.
And notice the recovery at the end. The buffered runner's map was incomplete, but the jobs had names: r_buffered.smoke_s1 and so on. Because each name contains the regression id and the run id, asking the scheduler for every job whose name starts with the regression id rebuilt the complete map. That is the second line of defence, and it costs nothing at submission time: name every job <regression>.<run>. A name without the regression id would match last night's jobs too.
There is one more lesson in this script, and it came from writing it. The first version printed runner died by signal 0 — because it read $? after running farm/bjobs in backticks, which replaced it. That is precisely the mistake Chapter 25.4 warned about, and the fix was the same: copy $? on the line after waitpid.
7. The Submitter
Here is the submitter. It writes cmd.sh and job.sh for each run, submits job.sh through a scheduler table, records each job id immediately in an autoflushed map, and reports refused submissions as results:
#!/usr/bin/perl
# submit.pl -- submit planned runs to a farm and keep the job-id -> run map.
# Usage: perl submit.pl [lsf|sge]
use strict;
use warnings;
use IO::Handle;
use File::Path qw(make_path remove_tree);
my $REGR = "r20261013"; # this regression's id, used in job names
# Everything scheduler-specific lives in this table.
my %SCHED = (
lsf => {
argv => sub { my ($r, $name, $q) = @_;
("farm/bsub", "-q", $q, "-J", $name, "-o", "$r->{dir}/farm.out",
"$r->{dir}/job.sh") },
id => qr/^Job <(\d+)> is submitted to queue <\S+>\.$/,
},
sge => {
argv => sub { my ($r, $name, $q) = @_;
("farm/qsub", "-terse", "-q", $q, "-N", $name, "-o", "$r->{dir}/farm.out",
"-b", "y", "$r->{dir}/job.sh") },
id => qr/^(\d+)$/,
},
);
my $sched = shift // "lsf";
my $S = $SCHED{$sched} or die "unknown scheduler '$sched'\n";
my @plan = (
{ id => "smoke_s1", args => ["+test=smoke", "+seed=1"] },
{ id => "corner_s1", args => ["+test=corner", "+seed=1"] },
{ id => "rand_s2", args => ["+test=rand", "+seed=2"] },
{ id => "rand_s3", args => ["+test=rand", "+seed=3"], queue => "lng" }, # a typo
{ id => "rand_s4", args => ["+test=rand", "+seed=4"] },
);
# Run a command WITHOUT a shell; return its status and everything it printed,
# standard error included.
sub capture {
my @cmd = @_;
my $pid = open(my $p, "-|") // die "fork: $!\n";
if ($pid == 0) {
open STDERR, ">&", \*STDOUT or exit 126;
{ no warnings "exec"; exec { $cmd[0] } @cmd }
print "cannot run $cmd[0]: $!\n";
exit 127;
}
my @out = <$p>;
close $p; # sets $? to the command's status
return ($?, @out);
}
sub write_script {
my ($path, @lines) = @_;
open my $f, ">", $path or die "$path: $!\n";
print {$f} map { "$_\n" } @lines;
close $f or die "$path: $!\n";
chmod 0755, $path;
}
system("bin/build") == 0 or die "build failed\n";
remove_tree("runs");
make_path("runs");
open my $map, ">", "runs/jobs.tsv" or die "runs/jobs.tsv: $!\n";
$map->autoflush(1); # each line reaches the disk as written
print {$map} "job_id\trun_id\tjob_name\n";
my $failed = 0;
for my $r (@plan) {
$r->{dir} = "runs/$r->{id}";
make_path($r->{dir});
write_script("$r->{dir}/cmd.sh", "#!/bin/sh",
"cd \"\$(dirname \"\$0\")/../..\" || exit 2",
"exec bin/simv @{ $r->{args} }");
write_script("$r->{dir}/job.sh", "#!/bin/sh",
"# what the farm runs: the command, its log, its status, then 'done'",
"cd \"\$(dirname \"\$0\")\" || exit 2",
"./cmd.sh > sim.log 2>&1",
"echo \"exit=\$?\" > status",
": > done");
my $name = "$REGR.$r->{id}";
my ($st, @said) = capture($S->{argv}->($r, $name, $r->{queue} // "normal"));
my ($jid) = map { /$S->{id}/ ? $1 : () } @said;
if ($st != 0 || !defined $jid) {
my $why = join " | ", map { s/\s+\z//r } @said;
printf "%-10s NOT SUBMITTED (status %d): %s\n", $r->{id}, $st >> 8, $why;
$failed++;
next;
}
print {$map} "$jid\t$r->{id}\t$name\n";
printf "%-10s job %s\n", $r->{id}, $jid;
}
close $map or die "runs/jobs.tsv: $!\n";
printf "%d submitted, %d not submitted; map in runs/jobs.tsv\n",
@plan - $failed, $failed;
exit($failed ? 1 : 0);The parts that do the work:
%SCHEDholds everything scheduler-specific: how to build the submit command for a run, and the pattern that finds the id in what it prints. The rest of the script never mentions LSF or Grid Engine. Supporting a third scheduler is a new entry in the table.captureruns the submit command without a shell, as a list, and collects standard output and standard error. It uses the special formopen(my $p, "-|")with no command: that forks, and in the child the handle$pis connected to the child's standard output. The child points its standard error at the same place and thenexecs the submit command. The parent reads everything, andclose $pwaits for the child and sets$?. (Chapter 15.11 covers pipedopen.)- Two checks, both required. A submission counts only if the command exited 0 and the documented line was found. Either one alone has a failure mode: a site wrapper can exit 0 after printing a refusal, and a scheduler could print an id and then report an error.
- The map line is written immediately, to an autoflushed file, with a header line, so that the file is self-describing and safe against the crash in Section 6.
- A refused submission is reported with the scheduler's own words and counted. The script exits 1 if anything was refused, because a regression with missing runs is not a complete regression.
8. Using It, on Both Schedulers
#!/usr/bin/perl
# farm_status.pl -- join the job map with the farm's view of each job.
use strict;
use warnings;
open my $m, "<", "runs/jobs.tsv" or die "runs/jobs.tsv: $!\n";
my $header = <$m>;
my %run_of = map { chomp; my ($j, $r) = split /\t/; ($j => $r) } <$m>;
my @ids = sort { $a <=> $b } keys %run_of;
sleep 1 while `farm/bjobs @ids` =~ /\b(?:PEND|RUN)\b/; # (Chapter 25.6 bounds this)
my @rows = `farm/bjobs @ids`;
my @col = split ' ', shift @rows; # find columns BY NAME
my %at = map { $col[$_] => $_ } 0 .. $#col;
printf "%-10s %-6s %-5s %s\n", "RUN", "JOB", "STAT", "IN THE RUN DIRECTORY";
for (@rows) {
my @f = split ' ';
my $jid = $f[ $at{JOBID} ];
my $run = $run_of{$jid} // "?";
my $dir = "runs/$run";
my $st = -e "$dir/status" ? do { open my $s, "<", "$dir/status"; scalar <$s> } : "no status\n";
printf "%-10s %-6s %-5s %s%s", $run, $jid, $f[ $at{STAT} ],
(-e "$dir/done" ? "done, " : "NOT done, "), $st;
}farm_status.pl shows what the map is for. It reads runs/jobs.tsv, asks the scheduler about every job id in it, and joins the two: the scheduler's state next to what the job left in its run directory. It finds the JOBID and STAT columns by name from the header line, not by position, because real bjobs output has more columns than the mock's and their order is not something to hard-code. (Its wait loop has no time limit. Chapter 25.6 is about exactly that.)
#!/usr/bin/perl
# submit_demo.pl -- submit the plan to the (mock) LSF, then ask where each run is.
use strict;
use warnings;
$| = 1;
system($^X, "submit.pl", "lsf");
print "(submit.pl exit ", $? >> 8, ")\n\n";
system($^X, "farm_status.pl");
print "\n--- the same plan, submitted to the (mock) Grid Engine ---\n";
system($^X, "submit.pl", "sge");
print "(submit.pl exit ", $? >> 8, ")\n\n";
system($^X, "farm_status.pl");smoke_s1 job 4101
corner_s1 job 4102
rand_s2 job 4103
rand_s3 NOT SUBMITTED (status 255): mock bsub: queue 'lng' does not exist; job not submitted
rand_s4 job 4104
4 submitted, 1 not submitted; map in runs/jobs.tsv
(submit.pl exit 1)
RUN JOB STAT IN THE RUN DIRECTORY
smoke_s1 4101 DONE done, exit=0
corner_s1 4102 DONE done, exit=0
rand_s2 4103 DONE done, exit=0
rand_s4 4104 DONE done, exit=0
--- the same plan, submitted to the (mock) Grid Engine ---
smoke_s1 job 4105
corner_s1 job 4106
rand_s2 job 4107
rand_s3 NOT SUBMITTED (status 1): mock qsub: queue 'lng' does not exist; job not submitted
rand_s4 job 4108
4 submitted, 1 not submitted; map in runs/jobs.tsv
(submit.pl exit 1)
RUN JOB STAT IN THE RUN DIRECTORY
smoke_s1 4105 DONE done, exit=0
corner_s1 4106 DONE done, exit=0
rand_s2 4107 DONE done, exit=0
rand_s4 4108 DONE done, exit=0The same plan, through two different schedulers, gives the same map and the same report. The refused run is reported with the scheduler's reason in both cases — two different messages, one decision. The four submitted jobs finished, and each run directory holds done and its status.
Notice that corner_s1 and rand_s2 show exit=0. Both contain TEST FAILED in their logs; the simulator exits 0 regardless, as Chapter 25.1 showed. The farm view says the jobs ran to completion. Whether the design passed is the collect stage's question, in Chapter 25.7.
9. What a Real Farm Adds
The mock covers the part of the problem that every scheduler shares: submit, get an id, keep the map. A real farm adds more, and it is better to know the list than to discover it:
| Topic | Why the runner cares |
|---|---|
| Resource requests (cores, memory, licences, run-time limits) | a job without the right request may sit in PEND forever, or be killed by the scheduler for using too much memory |
| Job arrays | submitting a thousand separate jobs is slow and some sites limit it; one array submission with a thousand elements is the usual answer, and each element then needs its own index → run mapping |
| Submission limits and retries | a busy scheduler can refuse or time out; a runner should retry a refused submission a bounded number of times and then report it, never loop forever |
| The scheduler's own kill | a job over its time or memory limit is ended by the scheduler, not by the runner; the job script may never write done (Chapter 25.6 and 25.7) |
| Different machines | the execution host may have a different PATH, a different tool version, or no access to a directory the runner can see; Chapter 26.8 covers the environment, Chapter 25.9 records it |
Each scheduler documents these options differently, so they are not shown as code here. The design from this chapter carries over unchanged: a table entry per scheduler, a script per job, and a map line per submission.
10. Exercises
1. The refusal that exits 0. Change the mock bsub so that for queue night it prints a refusal message on standard output and exits 0. Run naive_submit.pl and submit.pl against it. Which one reports the run correctly, and which of submit.pl's two checks catches it?
2. A third scheduler. Add an entry to %SCHED for a scheduler whose submit command prints Submitted batch job 4101 (the line Slurm's sbatch documents). Write a mock for it in the style of farm/qsub, and run the plan through it.
3. Rebuild the map. Delete runs/jobs.tsv after a submission and write recover_map.pl, which rebuilds it from farm/bjobs -J '<regression>.*'. What information is in the original map that the job names alone cannot give back? Change the job name format so that the recovery is complete.
4. Duplicate submission. A runner is restarted after a crash and submits the whole plan again. Now every run has two jobs. Design the check, using the map and the job names, that lets a restarted runner submit only the runs that have no job yet. What should it do about a run whose first job is still running?
5. Retry policy. Write submit_with_retry, which retries a refused submission up to three times with a growing pause, but never retries a refusal caused by a bad queue name. How does it tell the two kinds of refusal apart, given that you should not rely on the exact wording of error messages?
11. What To Remember
- On a farm, the job id is the only link between a planned run and a running job. Get it reliably, store it at once, and make the store survive the runner.
- Measured: the first version turned two refused submissions into one empty hash key; one run vanished from the map and the other was filed under a job id that does not exist.
- Capture standard error, check the exit status, and store an id only if you found one. A refused submission is a result, reported with the scheduler's reason.
- Parse the documented submission line, anchored. Measured: "the first number" picked 4 out of a site notice.
- Submit a script, not a command line. One plain path survives any shell on any machine; the script writes
sim.log, thenstatus, thendone. - Write the map one line per job, autoflushed. Measured: a runner killed by signal 9 kept 2 of 3 lines buffered — Perl flushes before starting each child, so only the last line was lost — and 3 of 3 with
autoflush. - Name every job
<regression>.<run>, so a lost map can be rebuilt from the scheduler. - Keep everything scheduler-specific in one table.
- The farm says a job ran to completion — not that the design passed.
Chapter 25.6 waits for the submitted jobs: a bounded loop that polls for done files and the scheduler's state, gives every job a deadline, and turns a job that never finishes into a reported timeout instead of a regression that stays open all night.
Where this fits
Part of the Perl curriculum.
