Perl Part II · Module 25 · Regression Automation
Collecting Results When Some Jobs Never Return
Every large regression is incomplete in some way. A job hangs and is killed, the farm refuses one, another never starts before the deadline, and one leaves a log that stops halfway through a line. The collect stage must still produce a report in which every planned run has a row, a verdict and a reason. This chapter builds that stage, and measures how far the quick versions are from the truth.
Advanced27 min readPerlPart IIRegression
Part II · Module 25 · Chapter 25.7 · Collecting Results When Some Jobs Never Return
1. The Problem
Chapters 25.4 to 25.6 launched runs, submitted them to a farm and waited for them. Some finished. Some did not, and each of those stopped in its own way. Now the runner has to say what happened to all of them.
The tempting approach is to look at the logs that exist and classify each one. That answers a different question — "what do the logs I can find say?" — and it gets the regression's real question wrong in two directions: runs that left no log disappear from the report, and logs that are not from this regression appear in it.
So the rule for this chapter is: the report has exactly one row for every planned run. Not for every log, not for every job, not for every directory. The plan is the list of questions; the collect stage must answer each one, and "this run has no verdict, and here is why" is a valid answer.
2. What the Runner Kept
The collector can only use evidence the earlier stages wrote down. Here is everything this module's runner keeps, and where each piece comes from:
| File | Written by | Says |
|---|---|---|
runs/plan.tsv | expansion (25.3), before anything runs | every run that should happen: id, test, seed |
runs/jobs.tsv | submission (25.5) | every run the farm accepted, with its job id |
runs/wait.tsv | the waiter (25.6) | why the runner stopped waiting for each job |
runs/<id>/sim.log | the job (25.4, 25.5) | what the simulator printed |
runs/<id>/status | the job script | the simulator's exit status, and which regression wrote it |
runs/<id>/done | the job script, last | the job script reached its end |
runs/REGRESSION | the runner | this regression's id |
Two of these are worth noticing. wait.tsv exists because Chapter 25.6 insisted on recording a reason every time a job left the wait loop — "TIMEOUT", "ended without done file", "never started". That record is now the strongest evidence the collector has for the runs that did not finish, because it is the runner's account of its own actions. And status carries the regression id, so that a file from a previous regression can be recognised as such. Section 5 shows why that matters.
3. A Realistic Run Area
To test a collector, you need a run area where every kind of ending has happened at least once — and you need to know the right answer for each run before you start. This script builds one for a regression called r2, using the real simulator for every log:
#!/usr/bin/perl
# stage.pl -- build the run area that Chapters 25.4 to 25.6 leave behind for
# regression r2, with every log produced by the real simulator. Each run ends
# in one of the ways those chapters measured.
use strict;
use warnings;
use File::Path qw(make_path remove_tree);
use Time::HiRes qw(sleep);
my $REGR = "r2";
# id, test, seed, extra args, how it ended
my @plan = (
[qw(smoke_s1 smoke 1), "", "finished"],
[qw(corner_s1 corner 1), "", "finished"],
[qw(rand_s2 rand 2), "", "finished"],
[qw(rand_s3 rand 3), "", "finished"],
[qw(bogus_s1 bogus 1), "", "finished"],
[qw(stall_s1 stall 1), "", "timeout"], # the waiter killed it (TERM)
[qw(stall_s2 stall 2), "", "farm_kill"], # the farm killed it (KILL)
[qw(rand_s5 rand 5), "+n=400000", "farm_term"], # the farm stopped it (TERM)
[qw(smoke_s9 smoke 9), "", "never_started"],
[qw(rand_s4 rand 4), "", "not_submitted"],
);
sub sim { # run the bench into a log; maybe stop it
my ($log, $sig, @args) = @_;
my $pid = fork // die "fork: $!\n";
if ($pid == 0) {
open STDOUT, ">", $log or exit 126;
open STDERR, ">&", \*STDOUT or exit 126;
exec "bin/simv", @args;
exit 127;
}
if ($sig) { sleep 0.8; kill $sig, $pid }
waitpid($pid, 0);
return $? >> 8;
}
sub put {
my ($path, $text) = @_;
open my $f, ">", $path or die "$path: $!\n";
print {$f} $text;
close $f;
}
system("bin/build") == 0 or die "build failed\n";
system("bin/simv +test=smoke > /dev/null"); # warm-up: the first launch is the slowest
remove_tree("runs");
make_path("runs");
# Yesterday (regression r1) used the same fixed seeds, so two of its run
# directories have today's names -- and one directory today's plan no longer has.
for my $old (["smoke_s9", 9], ["old_s7", 7]) {
my ($id, $seed) = @$old;
make_path("runs/$id");
my $exit = sim("runs/$id/sim.log", undef, "+test=smoke", "+seed=$seed");
put("runs/$id/status", "exit=$exit\nregr=r1\n");
put("runs/$id/done", "");
}
my ($plan, $jobs, $wait) = ("", "", "");
my $job = 4100;
for my $p (@plan) {
my ($id, $test, $seed, $extra, $how) = @$p;
my @args = ("+test=$test", "+seed=$seed", $extra ? $extra : ());
my $dir = "runs/$id";
make_path($dir);
put("$dir/cmd.sh", "#!/bin/sh\ncd \"\$(dirname \"\$0\")/../..\" || exit 2\nexec bin/simv @args\n");
$plan .= "$id\t$test\t$seed\n";
next if $how eq "not_submitted"; # refused: no job, no log
$jobs .= ++$job . "\t$id\n";
if ($how eq "never_started") { $wait .= "$id\tREGRESSION DEADLINE: never started\n"; next }
if ($how eq "finished") {
my $exit = sim("$dir/sim.log", undef, @args);
put("$dir/status", "exit=$exit\nregr=$REGR\n");
put("$dir/done", "");
$wait .= "$id\tfinished (done file)\n";
} elsif ($how eq "timeout") {
sim("$dir/sim.log", "TERM", @args); # job.sh died too: no status, no done
$wait .= "$id\tTIMEOUT: ran over 2s\n";
} elsif ($how eq "farm_kill") {
sim("$dir/sim.log", "KILL", @args);
$wait .= "$id\tended without done file\n";
} elsif ($how eq "farm_term") {
sim("$dir/sim.log", "TERM", @args);
$wait .= "$id\tended without done file\n";
}
}
put("runs/plan.tsv", "run_id\ttest\tseed\n$plan");
put("runs/jobs.tsv", "job_id\trun_id\n$jobs");
put("runs/wait.tsv", "run_id\twhy\n$wait");
put("runs/REGRESSION", "$REGR\n");
print "staged regression $REGR: ", scalar(@plan), " planned runs\n";Each run ends in a way an earlier chapter measured:
| Run | How it ended | The right answer |
|---|---|---|
smoke_s1, rand_s3 | finished, TEST PASSED | pass |
corner_s1, rand_s2 | finished, TEST FAILED | fail |
bogus_s1 | finished; the bench called $fatal | fail (the bench refused the test) |
stall_s1 | hung; the waiter killed it with TERM | no verdict: timeout |
stall_s2 | hung; the farm killed it with KILL | no verdict |
rand_s5 | long run; the farm stopped it with TERM | no verdict — but its log already shows errors |
smoke_s9 | never started before the regression deadline | no verdict — and its directory holds yesterday's files |
rand_s4 | the farm refused the submission | no verdict |
smoke_s9 is the interesting one. Chapter 25.3 recommended a fixed base seed for merge-gating regressions, so that the same seeds run every time. A consequence is that run ids repeat: yesterday's regression also had a smoke_s9, and if its directory was not cleared, its files are still there — including a done file and a log that says TEST PASSED. There is also old_s7, a directory from yesterday for a run that today's plan does not contain at all.
4. What a Stopped Simulator Leaves in Its Log
Before classifying anything, it is worth seeing what the logs of stopped runs actually contain. The same simulation, stopped after 0.8 seconds by TERM and by KILL; stopped by TERM during its start-up; and the hung test stopped by KILL:
#!/usr/bin/perl
# buffer_demo.pl -- the same simulation stopped after 0.8 s by TERM and by
# KILL, stopped by TERM during its start-up, and a hung test stopped by KILL:
# what reaches the log?
use strict;
use warnings;
use Time::HiRes qw(sleep);
system("bin/build") == 0 or die "build failed\n";
system("bin/simv +test=smoke > /dev/null"); # warm-up: the first launch is the slowest
sub stopped_log {
my ($sig, $after, @args) = @_;
my $pid = fork // die "fork: $!\n";
if ($pid == 0) {
open STDOUT, ">", "stopped.log" or exit 126;
open STDERR, ">&", \*STDOUT or exit 126;
exec "bin/simv", @args;
exit 127;
}
sleep $after;
kill $sig, $pid;
waitpid($pid, 0);
my $signal = $? & 127;
open my $fh, "<", "stopped.log" or die;
my @l = <$fh>;
return { signal => $signal,
bytes => -s "stopped.log",
errors => scalar(grep { /^ERROR:/ } @l),
header => scalar(grep { /^TEST \S+ SEED/ } @l),
verdict => scalar(grep { /^TEST (?:PASSED|FAILED)/ } @l) };
}
my $t = stopped_log("TERM", 0.8, "+test=rand", "+seed=5", "+n=400000");
my $k = stopped_log("KILL", 0.8, "+test=rand", "+seed=5", "+n=400000");
my $e = stopped_log("TERM", 0.002, "+test=rand", "+seed=5", "+n=400000");
my $s = stopped_log("KILL", 0.8, "+test=stall");
printf "TERM: header line %s, ERROR lines: %s, verdict line: %s\n",
$t->{header} ? "present" : "MISSING", $t->{errors} > 100 ? "more than 100" : $t->{errors},
$t->{verdict} ? "yes" : "no";
printf "KILL: size is a whole number of 4096-byte blocks: %s; verdict line: %s\n",
$k->{bytes} % 4096 == 0 ? "yes" : "no", $k->{verdict} ? "yes" : "no";
printf "TERM during start-up: ended by signal %d, %d bytes in the log\n", $e->{signal}, $e->{bytes};
printf "KILL on the hung test: %d bytes; its header line, printed at time 0: %s\n",
$s->{bytes}, $s->{header} ? "present" : "MISSING";TERM: header line present, ERROR lines: more than 100, verdict line: no
KILL: size is a whole number of 4096-byte blocks: yes; verdict line: no
TERM during start-up: ended by signal 15, 0 bytes in the log
KILL on the hung test: 0 bytes; its header line, printed at time 0: MISSINGWhen the simulator's output goes to a file, it does not write each line the moment it is printed. It collects output in a buffer and writes the buffer when it is full — on this machine, in steps of 4,096 bytes. What happens to the last, partly filled buffer depends on how the process ends:
TERM: IcarusvvpcatchesTERMand exits normally (Chapter 26.7 measured that), so it writes out its buffer. The log has everything printed up to that moment — but no verdict line, because the test never reached its end.KILL: the process is destroyed instantly. The buffer is lost. The log holds only whole 4,096-byte blocks; everything printed after the last full block is gone, and the last line in the file is usually cut in the middle.TERMduring start-up: in the first few hundredths of a second, beforevvphas installed its handler,TERMsimply kills it — the third line shows signal 15 and an empty log. A runner that stops a job just after launching it getsKILL's result, notTERM's. (This was found the hard way: one of this chapter's own test runs, in a fresh directory where the first launch was slow, produced an emptyTERMlog. The scripts now make one warm-up run first.)KILLon a run that printed less than one block: the log is empty. The hung test printed itsTEST stall SEED 2header at simulation time 0, and that line is not in the file.
This changes how a zero-byte log should be read. Chapter 21.8 read zero bytes as "the job died at startup", and that is one cause. With a buffering simulator there is a second: a run that printed hundreds of lines and was then killed can leave nothing at all. The collector cannot tell those apart from the log alone — and does not need to, because the waiter recorded which runs were killed. That is the theme of this chapter: the log is one witness, not the only one.
5. Two Quick Collectors, Measured
Here are the two collectors most people write. Both use the same classifier, so the only difference between them is which runs they look at:
# classify.pl -- the log-only classifier both quick collectors share:
# a verdict line wins, otherwise errors mean FAIL, otherwise UNKNOWN.
use strict;
use warnings;
sub classify {
my ($log) = @_;
open my $fh, "<", $log or return "NO_LOG";
my $text = do { local $/; <$fh> } // "";
return "PASS" if $text =~ /^TEST PASSED$/m;
return "FAIL" if $text =~ /^(?:TEST FAILED|ERROR:)/m;
return "UNKNOWN";
}
1;#!/usr/bin/perl
# glob_collect.pl -- collect by looking at whatever logs are in the run area.
use strict;
use warnings;
require "./classify.pl";
my %n;
for my $log (sort glob "runs/*/sim.log") {
my ($id) = $log =~ m{^runs/([^/]+)/};
my $v = classify($log);
printf " %-10s %s\n", $id, $v;
$n{$v}++;
}
my $rows = 0; $rows += $_ for values %n;
printf " %d rows; pass rate %d%%\n", $rows, 100 * ($n{PASS} // 0) / $rows;#!/usr/bin/perl
# plan_collect.pl -- the registry's loop: every PLANNED run, log or NO_LOG.
use strict;
use warnings;
require "./classify.pl";
open my $p, "<", "runs/plan.tsv" or die; <$p>;
my %n;
while (<$p>) {
my ($id) = split /\t/;
my $log = "runs/$id/sim.log";
my $v = -e $log ? classify($log) : "NO_LOG";
printf " %-10s %s\n", $id, $v;
$n{$v}++;
}
my $rows = 0; $rows += $_ for values %n;
printf " %d rows; pass rate %d%%\n", $rows, 100 * ($n{PASS} // 0) / $rows;The second one is the registry's example for this chapter: every planned run, classified if it has a log, NO_LOG if not. Then the full collector, which the rest of this chapter builds. All three run on the same staged area:
#!/usr/bin/perl
# collect_demo.pl -- stage one regression's run area, then collect it three ways.
use strict;
use warnings;
$| = 1;
system("$^X stage.pl") == 0 or die "stage.pl failed\n";
print "\n--- 1. every log found in the run area ---\n";
system($^X, "glob_collect.pl");
print "\n--- 2. every planned run, log or NO_LOG ---\n";
system($^X, "plan_collect.pl");
print "\n--- 3. every planned run, all of the evidence ---\n";
system($^X, "collect.pl");
print "(collect.pl exit ", $? >> 8, ")\n";staged regression r2: 10 planned runs
--- 1. every log found in the run area ---
bogus_s1 UNKNOWN
corner_s1 FAIL
old_s7 PASS
rand_s2 FAIL
rand_s3 PASS
rand_s5 FAIL
smoke_s1 PASS
smoke_s9 PASS
stall_s1 UNKNOWN
stall_s2 UNKNOWN
10 rows; pass rate 40%
--- 2. every planned run, log or NO_LOG ---
smoke_s1 PASS
corner_s1 FAIL
rand_s2 FAIL
rand_s3 PASS
bogus_s1 UNKNOWN
stall_s1 UNKNOWN
stall_s2 UNKNOWN
rand_s5 FAIL
smoke_s9 PASS
rand_s4 NO_LOG
10 rows; pass rate 30%
--- 3. every planned run, all of the evidence ---
RUN VERDICT REASON
smoke_s1 PASS TEST PASSED, 0 errors, exit 0
corner_s1 FAIL 1 ERROR line(s)
rand_s2 FAIL 1 ERROR line(s)
rand_s3 PASS TEST PASSED, 0 errors, exit 0
bogus_s1 FATAL the bench stopped itself (exit 1)
stall_s1 TIMEOUT stopped by the waiter (TIMEOUT: ran over 2s)
stall_s2 EMPTY_LOG zero bytes; waiter: ended without done file
rand_s5 KILLED ended before its job script did; the log already shows ERROR lines
smoke_s9 NOT_STARTED the regression deadline came first (its directory holds files from regression r1: ignored)
rand_s4 NOT_SUBMITTED the farm never accepted it
PASS 2 FAIL 3 NO VERDICT 5 (10 planned, 10 reported)
rules no run reached this time: NO_LOG WRONG_RUN NO_VERDICT
regression INCOMPLETE: 3 failed, 5 without a verdict
(collect.pl exit 1)The true result, from the table in Section 3, is 2 passes out of 10 planned runs: 20%. The quick collectors reported 40% and 30%. Here is where each one went wrong:
Collector 1, the glob, has ten rows, but they are not the ten planned runs:
- It reported
old_s7— a run that is not in today's plan — as a PASS. Yesterday's file walked into today's report. - It reported
smoke_s9as PASS: yesterday's log, in a directory with today's name. Today, that run never started. - It left out
rand_s4completely. The farm refused it, so it has no log, so the glob never saw it. - It lumped three very different situations into
UNKNOWN: the bench refusing a test, a timeout, and an empty log.
Two extra passes in the numerator, one missing run in the denominator. The row count happened to come out at ten, which is exactly the kind of coincidence that makes a wrong report look right.
Collector 2, the plan, fixed the denominator: every planned run has a row, and rand_s4 is NO_LOG. But it still reported smoke_s9 as PASS, because the log in that directory says TEST PASSED — and nothing in the log says it is from yesterday. And it still cannot say why anything is UNKNOWN.
Collector 3 has the right number of rows, the right verdict for each one, a reason on every row, and a count of 2 / 3 / 5 that adds up to the plan.
6. Evidence, Not Just Logs
The difference between collector 2 and collector 3 is not the classifier. It is that collector 3 gathers all the evidence about a run before deciding, into one hash:
my %e = (
job => ..., # did the farm accept it? (jobs.tsv)
wait => ..., # why did the waiter stop? (wait.tsv)
exit => ..., # the simulator's exit status (status)
done => ..., # did the job script finish? (done)
log => ..., # is there a log, and its size
header => ..., # what the log says was run
passed => ..., failed => ..., fatal => ..., errors => ...,
asked => ..., # what the plan says should have run
);And it checks first that the run's files belong to this regression. The job script writes regr=r2 into status. If the directory's status says regr=r1, every file in that directory is treated as absent, and the reason says so: its directory holds files from regression r1: ignored. That single line is what turned smoke_s9 from a false PASS into the truth. (Chapter 17.9 covers stale artifacts in general; a regression id written by the job itself avoids comparing file timestamps from different machines' clocks, which Chapter 25.6 warned against.)
The better fix is upstream: the runner should empty or refuse a reused run directory before launching into it, as Chapter 25.4's launcher does. But the collector must not depend on every earlier stage having been perfect. It is the last line of defence, so it checks.
7. The Verdict Ladder
The decision is a table of rules, in the style of Chapter 21.8: each rule is a name, a test, and a function that writes the reason. The first rule whose test is true decides.
| # | Verdict | Test | Category |
|---|---|---|---|
| 1 | NOT_SUBMITTED | no job id | no verdict |
| 2 | NOT_STARTED | the waiter recorded "never started" | no verdict |
| 3 | NO_LOG | no sim.log | no verdict |
| 4 | EMPTY_LOG | zero bytes | no verdict |
| 5 | TIMEOUT | the waiter killed it | no verdict |
| 6 | KILLED | no done: ended before its job script did | no verdict |
| 7 | FATAL | the bench stopped itself | fail |
| 8 | WRONG_RUN | the log's header is not what was asked for | no verdict |
| 9 | FAIL | TEST FAILED or ERROR lines | fail |
| 10 | NO_VERDICT | finished, but no TEST PASSED line | no verdict |
| 11 | PASS | nothing else applied | pass |
Why this order:
- Rules 1 and 2 come from the runner's own records, and they explain why there is no other evidence. A run that was never submitted cannot have a log; if one is there, it is not this run's.
- Rules 3 to 6 follow Chapter 21.8's order: existence of the log, then its size, then
TIMEOUTbefore the general "did not finish" (KILLED), because the general rule matches everything the specific one does. Here,TIMEOUTis decided by the waiter's record, not by guessing from wall-clock times in the log. FATALbeforeFAIL, so that "the bench refused to run" is not reported as an ordinary assertion failure.WRONG_RUNbeforeFAILandPASS: a verdict about the wrong run is not a verdict about this one (Chapter 25.1's header check).PASSis last and has no condition. It is reached only when every reason not to pass has been ruled out. Chapter 21.2 measured what happens whenPASSis a condition instead: a crashed log with zero errors satisfies "no errors".
The categories are what a summary counts: PASS, FAIL, and NO VERDICT for every run whose outcome the regression did not establish. Keeping the third category separate is the point of the chapter. A regression with "3 failed" and a regression with "3 failed and 5 unknown" are different situations, and a report that merges them into one number hides the second.
8. The Collector
#!/usr/bin/perl
# collect.pl -- one verdict, with a reason, for EVERY planned run, from all the
# evidence the runner kept; write runs/results.tsv; exit with a status that
# says whether the regression is complete.
# exit 0 every run passed
# exit 1 at least one run failed
# exit 3 no run failed, but some runs have no verdict
use strict;
use warnings;
use List::Util qw(first);
my $REGR = do { open my $f, "<", "runs/REGRESSION" or die "runs/REGRESSION: $!\n"; <$f> };
chomp $REGR;
sub read_tsv { # first column => rest of the row
my ($file) = @_;
open my $fh, "<", $file or die "$file: $!\n";
<$fh>; # header
return map { chomp; my ($k, @rest) = split /\t/; ($k => \@rest) } <$fh>;
}
my @plan = do { # plan order is report order
open my $p, "<", "runs/plan.tsv" or die "runs/plan.tsv: $!\n";
<$p>;
map { chomp; my ($id, $test, $seed) = split /\t/; { id => $id, test => $test, seed => $seed } } <$p>;
};
my %jobs = read_tsv("runs/jobs.tsv"); # job id => [run id]
my %job_of = map { $_->[0] => 1 } values %jobs;
my %wait = read_tsv("runs/wait.tsv"); # run id => [why waiting ended]
my %why = map { $_ => $wait{$_}[0] } keys %wait;
sub evidence { # everything we know about one run
my ($r) = @_;
my $dir = "runs/$r->{id}";
my %e = (job => $job_of{ $r->{id} }, wait => $why{ $r->{id} } // "", note => "",
asked => "TEST $r->{test} SEED $r->{seed}");
my %st = -e "$dir/status" ? do { open my $s, "<", "$dir/status"; map { /^(\w+)=(.*)$/ } <$s> } : ();
if (defined $st{regr} && $st{regr} ne $REGR) { # yesterday's files
$e{note} = " (its directory holds files from regression $st{regr}: ignored)";
return \%e; # trust none of them
}
$e{exit} = $st{exit};
$e{done} = -e "$dir/done";
if (open my $fh, "<", "$dir/sim.log") {
$e{log} = 1;
$e{size} = -s "$dir/sim.log";
while (my $l = <$fh>) {
$e{header} //= $1 if $l =~ /^(TEST \S+ SEED \S+)$/;
$e{passed} = 1 if $l =~ /^TEST PASSED$/;
$e{failed} = 1 if $l =~ /^TEST FAILED/;
$e{fatal} = 1 if $l =~ /^FATAL:/;
$e{errors}++ if $l =~ /^ERROR:/;
}
}
return \%e;
}
# The verdict ladder: the first rule whose test is true decides.
my @RULES = (
[ NOT_SUBMITTED => sub { !$_[0]{job} }, sub { "the farm never accepted it" } ],
[ NOT_STARTED => sub { $_[0]{wait} =~ /never started/ }, sub { "the regression deadline came first" } ],
[ NO_LOG => sub { !$_[0]{log} }, sub { "no log was written" } ],
[ EMPTY_LOG => sub { !$_[0]{size} }, sub { "zero bytes; waiter: $_[0]{wait}" } ],
[ TIMEOUT => sub { $_[0]{wait} =~ /^TIMEOUT|was running/ }, sub { "stopped by the waiter ($_[0]{wait})" } ],
[ KILLED => sub { !$_[0]{done} }, sub { "ended before its job script did"
. ($_[0]{errors} ? "; the log already shows ERROR lines" : "") } ],
[ FATAL => sub { $_[0]{fatal} }, sub { "the bench stopped itself (exit $_[0]{exit})" } ],
[ WRONG_RUN => sub { ($_[0]{header} // "") ne $_[0]{asked} }, sub { "log says '" . ($_[0]{header} // "") . "'" } ],
[ FAIL => sub { $_[0]{failed} || $_[0]{errors} }, sub { ($_[0]{errors} // 0) . " ERROR line(s)" } ],
[ NO_VERDICT => sub { !$_[0]{passed} }, sub { "finished without a TEST PASSED line" } ],
[ PASS => sub { 1 }, sub { "TEST PASSED, 0 errors, exit $_[0]{exit}" } ],
);
my %CATEGORY = (PASS => "PASS", FAIL => "FAIL", FATAL => "FAIL"); # all else: NO VERDICT
my (%n, %fired);
open my $out, ">", "runs/results.tsv" or die "runs/results.tsv: $!\n";
print {$out} "run_id\ttest\tseed\tverdict\tcategory\treason\n";
printf "%-10s %-13s %s\n", "RUN", "VERDICT", "REASON";
for my $r (@plan) {
my $e = evidence($r);
my $rule = first { $_->[1]->($e) } @RULES; # stops at the first true rule
my ($verdict, $reason) = ($rule->[0], $rule->[2]->($e) . $e->{note});
my $cat = $CATEGORY{$verdict} // "NO VERDICT";
$reason =~ s/[\t\n]/ /g; # keep the TSV one line per run
print {$out} join("\t", @$r{qw(id test seed)}, $verdict, $cat, $reason), "\n";
printf "%-10s %-13s %s\n", $r->{id}, $verdict, $reason;
$n{$cat}++;
$fired{$verdict}++;
}
close $out or die "runs/results.tsv: $!\n";
my $reported = 0; $reported += $_ for values %n;
die "BUG: ", scalar @plan, " planned but $reported reported\n" unless $reported == @plan;
printf "\nPASS %d FAIL %d NO VERDICT %d (%d planned, %d reported)\n",
map({ $n{$_} // 0 } "PASS", "FAIL", "NO VERDICT"), scalar @plan, $reported;
my @unused = map { $_->[0] } grep { !$fired{ $_->[0] } } @RULES;
print "rules no run reached this time: @unused\n" if @unused;
my $status = $n{FAIL} ? 1 : $n{"NO VERDICT"} ? 3 : 0;
print $status == 0 ? "regression COMPLETE: all passed\n"
: "regression ", ($n{"NO VERDICT"} ? "INCOMPLETE" : "COMPLETE"),
": ", ($n{FAIL} // 0), " failed, ", ($n{"NO VERDICT"} // 0), " without a verdict\n";
exit $status;Its output is the third block in Section 5. Walk through the parts that matter:
@plandrives everything. The loop is over planned runs, in plan order. A directory that is not in the plan (old_s7) is never visited, and a planned run with no directory still gets a row.evidencegathers before deciding, and drops everything from a directory written by another regression.first { ... } @RULES(fromList::Util) returns the first rule whose test is true and stops there. The first version of this collector usedmy ($rule) = grep { ... } @RULES, which looks equivalent — the first element ofgrep's result is the first matching rule — butgrepruns every test on every run. Rules written for complete evidence then ran on evidence they were never meant to see, anduse warningsreported an undefined value. The answer happened to be right; the program was not.- Every row gets a reason, built by the rule's own function, so the reason always matches the rule that fired.
- The count is checked. If the number of reported rows ever differs from the number of planned runs, the collector dies with a message that starts
BUG:, because that can only be a defect in the collector itself. - Unreached rules are listed. In this staged regression,
NO_LOG,WRONG_RUNandNO_VERDICTwere never the deciding rule. That does not mean they are wrong; it means this run area did not test them. Saying so is how the collector avoids claiming more than its test showed. Exercise 1 reaches them. runs/results.tsvis the machine-readable result: one line per planned run, with the test, the seed, the verdict, the category and the reason. Tabs and newlines are removed from the reason so that each run is exactly one line. Chapter 25.8 reads this file.
9. Evidence of Failure, and Absence of Evidence
Look at rand_s5. The farm stopped it before it finished, so it has no verdict line and no done file — the collector calls it KILLED. But its log already contains ERROR lines. The design did misbehave in that run; we just do not know everything it would have done.
The ladder puts KILLED before FAIL, following Chapter 21.8's rule that completion is checked before severity. The information is not lost: the reason says the log already shows ERROR lines. Some teams prefer to promote such a run to FAIL, on the grounds that evidence of failure is decisive even when the run is incomplete. That is a reasonable policy. Two things are not reasonable: calling it PASS, and reporting it without saying that it was cut short.
The general principle behind both choices is the same, and it is worth stating plainly:
- Evidence of failure is evidence, even from an incomplete run. An
ERRORline means something went wrong. - Absence of failure is not evidence of passing. An empty log, a log with only a header, a log cut off by
KILL— none of them shows an error, and none of them shows a pass. That is whyPASSmust be reached by elimination, and whyEMPTY_LOGandKILLEDcome before it.
10. The Exit Status
The collector's exit status is what a CI job or a Makefile sees, so it has to carry the difference between "failed" and "incomplete" too. The scheme in the script's header:
| Exit | Meaning |
|---|---|
| 0 | every planned run passed — the regression is complete and clean |
| 1 | at least one run failed |
| 3 | nothing failed, but some runs have no verdict — the regression is incomplete |
Status 2 is left free because Chapter 15.4 reserves it for usage errors. Status 3 matters more than it looks: without it, a nightly regression in which half the runs timed out and the other half passed would exit 0, and the CI dashboard would be green. With it, "incomplete" is a third colour that someone has to look at. Here the staged regression exits 1, because three runs failed — and the summary line still says it is incomplete, so the person who opens the report knows that five more runs need attention.
11. Exercises
1. Reach the unreached rules. Add three runs to stage.pl: one whose done and status exist but whose sim.log was deleted (reaches NO_LOG), one launched with a seed that wraps (Chapter 25.3: 4294967298) so its header disagrees with the plan (reaches WRONG_RUN), and one whose log ends without a verdict line although done exists. Run the collector and check that "rules no run reached" disappears.
2. A second witness for a timeout. Delete wait.tsv and run collect.pl again. Which verdicts change, and to what? Which runs can no longer be explained, and what would you add to the run directory so that a timeout could be recognised without the waiter's record?
3. Promote cut-short failures. Change the ladder so that a run with ERROR lines is FAIL even if it did not finish, and add (cut short) to its reason. Which row changes? Write two sentences for your team explaining which policy you chose and why.
4. Prove the stale check can fail. Remove the regr check from evidence and run the collector. Confirm that smoke_s9 becomes a false PASS. Then put the check back and confirm it is caught again. A check you have never seen fail has not been tested.
5. The 9am summary. Write summary.pl, which reads runs/results.tsv and prints at most ten lines: the counts by category, then the failing runs, then the runs with no verdict grouped by verdict. It must still say how many runs were planned.
12. What To Remember
- One row for every planned run — not for every log, job or directory.
- Measured: the true pass rate was 20%; a glob over the run area said 40%, and a loop over the plan that trusted logs said 30%.
- A glob counts yesterday's directories and loses runs that left no log.
- A log cannot tell you it is stale. Have the job write the regression id, and ignore files that carry another one.
- Measured:
KILLleaves only whole 4,096-byte blocks of the log here, and a hung test's log was empty — its header, printed at time 0, was lost.TERMduring start-up did the same. Zero bytes is not only "died at startup". - Gather all the evidence first — the plan, the job map, the waiter's record,
status,done, the log — then decide with an ordered rule table whose last rule isPASS. - Use
first, notgrep, to pick the deciding rule. - Keep three categories: PASS, FAIL, NO VERDICT, and exit 0, 1 or 3 so that "incomplete" cannot be green.
- Evidence of failure counts even from an incomplete run; absence of failure never counts as a pass.
Chapter 25.8 reads runs/results.tsv from this regression and builds the next one from it: only the runs that did not pass, each with the same seed, so that a failure can be confirmed — or shown to have gone away — without rerunning everything.
Where this fits
Part of the Perl curriculum.
