Skip to content
VLSI Mentor

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 onthe submit command returns at once with a job id
the run happens on this machinethe run happens on another machine, later
waitpid tells you when it ended and howyou must ask the scheduler, or look for files the job wrote
the runner and the run live and die togetherthe runner can die and the jobs keep running
a launch either starts or fails right awaya 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.

Azvya Education Pvt. Ltd.VLSI Mentor
farm/bsub
#!/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.

Azvya Education Pvt. Ltd.VLSI Mentor
farm/bjobs
#!/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};
}
Azvya Education Pvt. Ltd.VLSI Mentor
farm/qsub
#!/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:

Azvya Education Pvt. Ltd.VLSI Mentor
naive_submit.pl
#!/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/;
Azvya Education Pvt. Ltd.VLSI Mentor
Captured output
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_s1

Four runs went in. Look at what came out:

  • The scheduler's refusal went to standard error, and backticks capture only standard output. So $out was empty for the two refused runs, and the refusal message scrolled past on the terminal, where no program reads it.
  • $jid was undef, 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. So rand_s2 is not in the map at all: as far as any later stage knows, it was never planned. And rand_s3 is 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:

Azvya Education Pvt. Ltd.VLSI Mentor
banner_parse.pl
#!/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/;
Azvya Education Pvt. Ltd.VLSI Mentor
Captured output
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:

SchedulerSubmit commandLine to parsePattern
LSFbsubJob <4101> is submitted to queue <normal>.^Job <(\d+)> is submitted to queue <\S+>\.$
Grid EngineqsubYour job 4101 ("name") has been submitted^Your job (\d+)
Grid Engineqsub -terse4101^(\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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/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

This 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 -o file 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 done last. An empty file whose existence means "this job reached its end". Chapter 25.6 waits for exactly these files. Creating it after status means that whenever done exists, status is 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:

Azvya Education Pvt. Ltd.VLSI Mentor
crash_demo.pl
#!/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";
}
Azvya Education Pvt. Ltd.VLSI Mentor
Captured output
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, piped open). 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) (from IO::Handle) makes Perl write each print immediately. 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:

Azvya Education Pvt. Ltd.VLSI Mentor
submit.pl
#!/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:

  • %SCHED holds 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.
  • capture runs the submit command without a shell, as a list, and collects standard output and standard error. It uses the special form open(my $p, "-|") with no command: that forks, and in the child the handle $p is connected to the child's standard output. The child points its standard error at the same place and then execs the submit command. The parent reads everything, and close $p waits for the child and sets $?. (Chapter 15.11 covers piped open.)
  • 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

Azvya Education Pvt. Ltd.VLSI Mentor
farm_status.pl
#!/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.)

Azvya Education Pvt. Ltd.VLSI Mentor
submit_demo.pl
#!/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");
Azvya Education Pvt. Ltd.VLSI Mentor
Captured output
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=0

The 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:

TopicWhy 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 arrayssubmitting 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 retriesa 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 killa 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 machinesthe 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, then status, then done.
  • 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.