Add support for tracking custom metrics

Builds can now emit metrics that Hydra will store in its database and
render as time series via flot charts. Typical applications are to
keep track of performance indicators, coverage percentages, artifact
sizes, and so on.

For example, a coverage build can emit the coverage percentage as
follows:

  echo "lineCoverage $pct %" > $out/nix-support/hydra-metrics

Graphs of all metrics for a job can be seen at

  http://.../job/<project>/<jobset>/<job>#tabs-charts

Specific metrics are also visible at

  http://.../job/<project>/<jobset>/<job>/metric/<metric>

The latter URL also allows getting the data in JSON format (e.g. via
"curl -H 'Accept: application/json'").
This commit is contained in:
Eelco Dolstra 2015-07-31 00:57:30 +02:00
parent 8092149a9f
commit 4d26546d3c
18 changed files with 437 additions and 30 deletions

View file

@ -7,6 +7,21 @@
using namespace nix; using namespace nix;
static std::tuple<bool, string> secureRead(Path fileName)
{
auto fail = std::make_tuple(false, "");
if (!pathExists(fileName)) return fail;
try {
/* For security, resolve symlinks. */
fileName = canonPath(fileName, true);
if (!isInStore(fileName)) return fail;
return std::make_tuple(true, readFile(fileName));
} catch (Error & e) { return fail; }
}
BuildOutput getBuildOutput(std::shared_ptr<StoreAPI> store, const Derivation & drv) BuildOutput getBuildOutput(std::shared_ptr<StoreAPI> store, const Derivation & drv)
{ {
BuildOutput res; BuildOutput res;
@ -40,22 +55,12 @@ BuildOutput getBuildOutput(std::shared_ptr<StoreAPI> store, const Derivation & d
Path failedFile = output + "/nix-support/failed"; Path failedFile = output + "/nix-support/failed";
if (pathExists(failedFile)) res.failed = true; if (pathExists(failedFile)) res.failed = true;
Path productsFile = output + "/nix-support/hydra-build-products"; auto file = secureRead(output + "/nix-support/hydra-build-products");
if (!pathExists(productsFile)) continue; if (!std::get<0>(file)) continue;
explicitProducts = true; explicitProducts = true;
/* For security, resolve symlinks. */ for (auto & line : tokenizeString<Strings>(std::get<1>(file), "\n")) {
try {
productsFile = canonPath(productsFile, true);
} catch (Error & e) { continue; }
if (!isInStore(productsFile)) continue;
string contents;
try {
contents = readFile(productsFile);
} catch (Error & e) { continue; }
for (auto & line : tokenizeString<Strings>(contents, "\n")) {
BuildProduct product; BuildProduct product;
Regex::Subs subs; Regex::Subs subs;
@ -122,5 +127,19 @@ BuildOutput getBuildOutput(std::shared_ptr<StoreAPI> store, const Derivation & d
// FIXME: validate release name // FIXME: validate release name
} }
/* Get metrics. */
for (auto & output : outputs) {
auto file = secureRead(output + "/nix-support/hydra-metrics");
for (auto & line : tokenizeString<Strings>(std::get<1>(file), "\n")) {
auto fields = tokenizeString<std::vector<std::string>>(line);
if (fields.size() < 2) continue;
BuildMetric metric;
metric.name = fields[0]; // FIXME: validate
metric.value = atof(fields[1].c_str()); // FIXME
metric.unit = fields.size() >= 3 ? fields[2] : "";
res.metrics[metric.name] = metric;
}
}
return res; return res;
} }

View file

@ -15,6 +15,12 @@ struct BuildProduct
BuildProduct() { } BuildProduct() { }
}; };
struct BuildMetric
{
std::string name, unit;
double value;
};
struct BuildOutput struct BuildOutput
{ {
/* Whether this build has failed with output, i.e., the build /* Whether this build has failed with output, i.e., the build
@ -27,6 +33,8 @@ struct BuildOutput
unsigned long long closureSize = 0, size = 0; unsigned long long closureSize = 0, size = 0;
std::list<BuildProduct> products; std::list<BuildProduct> products;
std::map<std::string, BuildMetric> metrics;
}; };
BuildOutput getBuildOutput(std::shared_ptr<nix::StoreAPI> store, const nix::Derivation & drv); BuildOutput getBuildOutput(std::shared_ptr<nix::StoreAPI> store, const nix::Derivation & drv);

View file

@ -238,6 +238,19 @@ void State::markSucceededBuild(pqxx::work & txn, Build::ptr build,
(product.defaultPath).exec(); (product.defaultPath).exec();
} }
for (auto & metric : res.metrics) {
txn.parameterized
("insert into BuildMetrics (build, name, unit, value, project, jobset, job, timestamp) values ($1, $2, $3, $4, $5, $6, $7, $8)")
(build->id)
(metric.second.name)
(metric.second.unit, metric.second.unit != "")
(metric.second.value)
(build->projectName)
(build->jobsetName)
(build->jobName)
(build->timestamp).exec();
}
nrBuildsDone++; nrBuildsDone++;
} }

View file

@ -64,7 +64,7 @@ void State::getQueuedBuilds(Connection & conn, std::shared_ptr<StoreAPI> store,
{ {
pqxx::work txn(conn); pqxx::work txn(conn);
auto res = txn.parameterized("select id, project, jobset, job, drvPath, maxsilent, timeout from Builds where id > $1 and finished = 0 order by id")(lastBuildId).exec(); auto res = txn.parameterized("select id, project, jobset, job, drvPath, maxsilent, timeout, timestamp from Builds where id > $1 and finished = 0 order by id")(lastBuildId).exec();
for (auto const & row : res) { for (auto const & row : res) {
auto builds_(builds.lock()); auto builds_(builds.lock());
@ -76,9 +76,12 @@ void State::getQueuedBuilds(Connection & conn, std::shared_ptr<StoreAPI> store,
auto build = std::make_shared<Build>(); auto build = std::make_shared<Build>();
build->id = id; build->id = id;
build->drvPath = row["drvPath"].as<string>(); build->drvPath = row["drvPath"].as<string>();
build->fullJobName = row["project"].as<string>() + ":" + row["jobset"].as<string>() + ":" + row["job"].as<string>(); build->projectName = row["project"].as<string>();
build->jobsetName = row["jobset"].as<string>();
build->jobName = row["job"].as<string>();
build->maxSilentTime = row["maxsilent"].as<int>(); build->maxSilentTime = row["maxsilent"].as<int>();
build->buildTimeout = row["timeout"].as<int>(); build->buildTimeout = row["timeout"].as<int>();
build->timestamp = row["timestamp"].as<time_t>();
newBuilds.emplace(std::make_pair(build->drvPath, build)); newBuilds.emplace(std::make_pair(build->drvPath, build));
} }
@ -89,7 +92,7 @@ void State::getQueuedBuilds(Connection & conn, std::shared_ptr<StoreAPI> store,
std::function<void(Build::ptr)> createBuild; std::function<void(Build::ptr)> createBuild;
createBuild = [&](Build::ptr build) { createBuild = [&](Build::ptr build) {
printMsg(lvlTalkative, format("loading build %1% (%2%)") % build->id % build->fullJobName); printMsg(lvlTalkative, format("loading build %1% (%2%)") % build->id % build->fullJobName());
nrAdded++; nrAdded++;
if (!store->isValidPath(build->drvPath)) { if (!store->isValidPath(build->drvPath)) {

View file

@ -38,6 +38,7 @@ typedef enum {
bssFailed = 1, bssFailed = 1,
bssAborted = 4, bssAborted = 4,
bssTimedOut = 7, bssTimedOut = 7,
bssCachedFailure = 8,
bssUnsupported = 9, bssUnsupported = 9,
bssBusy = 100, // not stored bssBusy = 100, // not stored
} BuildStepStatus; } BuildStepStatus;
@ -67,12 +68,18 @@ struct Build
BuildID id; BuildID id;
nix::Path drvPath; nix::Path drvPath;
std::map<std::string, nix::Path> outputs; std::map<std::string, nix::Path> outputs;
std::string fullJobName; std::string projectName, jobsetName, jobName;
time_t timestamp;
unsigned int maxSilentTime, buildTimeout; unsigned int maxSilentTime, buildTimeout;
std::shared_ptr<Step> toplevel; std::shared_ptr<Step> toplevel;
std::atomic_bool finishedInDB{false}; std::atomic_bool finishedInDB{false};
std::string fullJobName()
{
return projectName + ":" + jobsetName + ":" + jobName;
}
}; };

View file

@ -77,6 +77,9 @@ sub overview : Chained('job') PathPart('') Args(0) {
, jobset => $c->stash->{jobset}->name , jobset => $c->stash->{jobset}->name
, job => $c->stash->{job}->name , job => $c->stash->{job}->name
})->count == 1 if $c->user_exists; })->count == 1 if $c->user_exists;
$c->stash->{metrics} = [ $job->buildmetrics->search(
{ }, { select => ["name"], distinct => 1, order_by => "timestamp desc", }) ];
} }
@ -110,6 +113,20 @@ sub output_sizes : Chained('job') PathPart('output-sizes') Args(0) {
} }
sub metric : Chained('job') PathPart('metric') Args(1) {
my ($self, $c, $metricName) = @_;
$c->stash->{template} = 'metric.tt';
$c->stash->{metricName} = $metricName;
my @res = $c->stash->{job}->buildmetrics->search(
{ name => $metricName },
{ order_by => "timestamp", columns => [ "build", "name", "timestamp", "value", "unit" ] });
$self->status_ok($c, entity => [ map { { id => $_->get_column("build"), timestamp => $_ ->timestamp, value => $_->value, unit => $_->unit } } @res ]);
}
# Hydra::Base::Controller::ListBuilds needs this. # Hydra::Base::Controller::ListBuilds needs this.
sub get_builds : Chained('job') PathPart('') CaptureArgs(0) { sub get_builds : Chained('job') PathPart('') CaptureArgs(0) {
my ($self, $c) = @_; my ($self, $c) = @_;

View file

@ -0,0 +1,187 @@
use utf8;
package Hydra::Schema::BuildMetrics;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
=head1 NAME
Hydra::Schema::BuildMetrics
=cut
use strict;
use warnings;
use base 'DBIx::Class::Core';
=head1 COMPONENTS LOADED
=over 4
=item * L<Hydra::Component::ToJSON>
=back
=cut
__PACKAGE__->load_components("+Hydra::Component::ToJSON");
=head1 TABLE: C<BuildMetrics>
=cut
__PACKAGE__->table("BuildMetrics");
=head1 ACCESSORS
=head2 build
data_type: 'integer'
is_foreign_key: 1
is_nullable: 0
=head2 name
data_type: 'text'
is_nullable: 0
=head2 unit
data_type: 'text'
is_nullable: 1
=head2 value
data_type: 'double precision'
is_nullable: 0
=head2 project
data_type: 'text'
is_foreign_key: 1
is_nullable: 0
=head2 jobset
data_type: 'text'
is_foreign_key: 1
is_nullable: 0
=head2 job
data_type: 'text'
is_foreign_key: 1
is_nullable: 0
=head2 timestamp
data_type: 'integer'
is_nullable: 0
=cut
__PACKAGE__->add_columns(
"build",
{ data_type => "integer", is_foreign_key => 1, is_nullable => 0 },
"name",
{ data_type => "text", is_nullable => 0 },
"unit",
{ data_type => "text", is_nullable => 1 },
"value",
{ data_type => "double precision", is_nullable => 0 },
"project",
{ data_type => "text", is_foreign_key => 1, is_nullable => 0 },
"jobset",
{ data_type => "text", is_foreign_key => 1, is_nullable => 0 },
"job",
{ data_type => "text", is_foreign_key => 1, is_nullable => 0 },
"timestamp",
{ data_type => "integer", is_nullable => 0 },
);
=head1 PRIMARY KEY
=over 4
=item * L</build>
=item * L</name>
=back
=cut
__PACKAGE__->set_primary_key("build", "name");
=head1 RELATIONS
=head2 build
Type: belongs_to
Related object: L<Hydra::Schema::Builds>
=cut
__PACKAGE__->belongs_to(
"build",
"Hydra::Schema::Builds",
{ id => "build" },
{ is_deferrable => 0, on_delete => "CASCADE", on_update => "NO ACTION" },
);
=head2 job
Type: belongs_to
Related object: L<Hydra::Schema::Jobs>
=cut
__PACKAGE__->belongs_to(
"job",
"Hydra::Schema::Jobs",
{ jobset => "jobset", name => "job", project => "project" },
{ is_deferrable => 0, on_delete => "NO ACTION", on_update => "CASCADE" },
);
=head2 jobset
Type: belongs_to
Related object: L<Hydra::Schema::Jobsets>
=cut
__PACKAGE__->belongs_to(
"jobset",
"Hydra::Schema::Jobsets",
{ name => "jobset", project => "project" },
{ is_deferrable => 0, on_delete => "NO ACTION", on_update => "CASCADE" },
);
=head2 project
Type: belongs_to
Related object: L<Hydra::Schema::Projects>
=cut
__PACKAGE__->belongs_to(
"project",
"Hydra::Schema::Projects",
{ name => "project" },
{ is_deferrable => 0, on_delete => "NO ACTION", on_update => "CASCADE" },
);
# Created by DBIx::Class::Schema::Loader v0.07043 @ 2015-07-30 16:52:20
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:qoPm5/le+sVHigW4Dmum2Q
sub json_hint {
return { columns => ['value', 'unit'] };
}
1;

View file

@ -341,6 +341,21 @@ __PACKAGE__->has_many(
undef, undef,
); );
=head2 buildmetrics
Type: has_many
Related object: L<Hydra::Schema::BuildMetrics>
=cut
__PACKAGE__->has_many(
"buildmetrics",
"Hydra::Schema::BuildMetrics",
{ "foreign.build" => "self.id" },
undef,
);
=head2 buildoutputs =head2 buildoutputs
Type: has_many Type: has_many
@ -535,8 +550,8 @@ __PACKAGE__->many_to_many(
); );
# Created by DBIx::Class::Schema::Loader v0.07043 @ 2015-07-30 16:03:55 # Created by DBIx::Class::Schema::Loader v0.07043 @ 2015-07-30 16:52:20
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:EwxiaQpqbdzI9RvU0uUtLQ # DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:Y2lDtgY8EBLOuCHAI8fWRQ
__PACKAGE__->has_many( __PACKAGE__->has_many(
"dependents", "dependents",
@ -630,6 +645,7 @@ my %hint = (
buildoutputs => 'name', buildoutputs => 'name',
buildinputs_builds => 'name', buildinputs_builds => 'name',
buildproducts => 'productnr', buildproducts => 'productnr',
buildmetrics => 'name',
} }
); );

View file

@ -81,6 +81,25 @@ __PACKAGE__->set_primary_key("project", "jobset", "name");
=head1 RELATIONS =head1 RELATIONS
=head2 buildmetrics
Type: has_many
Related object: L<Hydra::Schema::BuildMetrics>
=cut
__PACKAGE__->has_many(
"buildmetrics",
"Hydra::Schema::BuildMetrics",
{
"foreign.job" => "self.name",
"foreign.jobset" => "self.jobset",
"foreign.project" => "self.project",
},
undef,
);
=head2 builds =head2 builds
Type: has_many Type: has_many
@ -150,7 +169,7 @@ __PACKAGE__->has_many(
); );
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2014-09-29 19:41:42 # Created by DBIx::Class::Schema::Loader v0.07043 @ 2015-07-30 16:52:20
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:lnZSd0gDXgLk8WQeAFqByA # DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:vDAo9bzLca+QWfhOb9OLMg
1; 1;

View file

@ -184,6 +184,24 @@ __PACKAGE__->set_primary_key("project", "name");
=head1 RELATIONS =head1 RELATIONS
=head2 buildmetrics
Type: has_many
Related object: L<Hydra::Schema::BuildMetrics>
=cut
__PACKAGE__->has_many(
"buildmetrics",
"Hydra::Schema::BuildMetrics",
{
"foreign.jobset" => "self.name",
"foreign.project" => "self.project",
},
undef,
);
=head2 builds =head2 builds
Type: has_many Type: has_many
@ -320,8 +338,8 @@ __PACKAGE__->has_many(
); );
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2014-04-23 23:13:51 # Created by DBIx::Class::Schema::Loader v0.07043 @ 2015-07-30 16:52:20
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:CO0aE+jrjB+UrwGRzWZLlw # DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:Coci9FdBAvUO9T3st2NEqA
my %hint = ( my %hint = (
columns => [ columns => [

View file

@ -106,6 +106,21 @@ __PACKAGE__->set_primary_key("name");
=head1 RELATIONS =head1 RELATIONS
=head2 buildmetrics
Type: has_many
Related object: L<Hydra::Schema::BuildMetrics>
=cut
__PACKAGE__->has_many(
"buildmetrics",
"Hydra::Schema::BuildMetrics",
{ "foreign.project" => "self.name" },
undef,
);
=head2 builds =head2 builds
Type: has_many Type: has_many
@ -267,8 +282,8 @@ Composing rels: L</projectmembers> -> username
__PACKAGE__->many_to_many("usernames", "projectmembers", "username"); __PACKAGE__->many_to_many("usernames", "projectmembers", "username");
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2014-04-23 23:13:08 # Created by DBIx::Class::Schema::Loader v0.07043 @ 2015-07-30 16:52:20
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:fkd9ruEoVSBGIktmAj4u4g # DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:67kWIE0IGmEJTvOIATAKaw
my %hint = ( my %hint = (
columns => [ columns => [

View file

@ -106,7 +106,7 @@
[% END %] [% END %]
</ul> </ul>
</li> </li>
<li class="active"><a href="#tabs-summary" data-toggle="tab">Summary</a></li> <li class="active"><a href="#tabs-summary" data-toggle="tab">Summary</a></li>
[% IF isAggregate %]<li><a href="#tabs-constituents" data-toggle="tab">Constituents</a></li>[% END %] [% IF isAggregate %]<li><a href="#tabs-constituents" data-toggle="tab">Constituents</a></li>[% END %]
<li><a href="#tabs-details" data-toggle="tab">Details</a></li> <li><a href="#tabs-details" data-toggle="tab">Details</a></li>
@ -385,6 +385,25 @@
</tr> </tr>
[% END %] [% END %]
</table> </table>
[% IF build.finished && build.buildmetrics %]
<h3>Metrics</h3>
<table class="table table-small table-striped table-hover clickable-rows">
<thead>
<tr><th>Name</th><th>Value</th></tr>
</thead>
<tbody>
[% FOREACH metric IN build.buildmetrics %]
<tr>
<td><tt><a class="row-link" href="[% c.uri_for('/job' project.name jobset.name job.name 'metric' metric.name) %]">[%HTML.escape(metric.name)%]</a></tt></td>
<td>[%metric.value%][%metric.unit%]</td>
</tr>
[% END %]
</tbody>
</table>
[% END %]
</div> </div>
<div id="tabs-buildinputs" class="tab-pane"> <div id="tabs-buildinputs" class="tab-pane">

View file

@ -566,12 +566,14 @@ BLOCK createChart %]
success: function(data) { success: function(data) {
var ids = []; var ids = [];
var d = []; var d = [];
var max = 0; var maxTime = 0;
var minTime = Number.MAX_SAFE_INTEGER;
data.forEach(function(x) { data.forEach(function(x) {
var t = x.timestamp * 1000; var t = x.timestamp * 1000;
ids[t] = x.id; ids[t] = x.id;
d.push([t, x.value [% IF yaxis == "mib" %] / (1024.0 * 1024.0)[% END %]]); d.push([t, x.value [% IF yaxis == "mib" %] / (1024.0 * 1024.0)[% END %]]);
max = Math.max(t, max); maxTime = Math.max(t, maxTime);
minTime = Math.min(t, minTime);
}); });
var options = { var options = {
@ -634,7 +636,7 @@ BLOCK createChart %]
}); });
// Zoom in to the last two months by default. // Zoom in to the last two months by default.
plot.setSelection({ xaxis: { from: max - 60 * 24 * 60 * 60 * 1000, to: max } }); plot.setSelection({ xaxis: { from: Math.max(minTime, maxTime - 60 * 24 * 60 * 60 * 1000), to: maxTime } });
} }
}); });
}); });

View file

@ -98,6 +98,14 @@ removed or had an evaluation error.</div>
[% INCLUDE createChart id="output-size" yaxis="mib" dataUrl=c.uri_for('/job' project.name jobset.name job.name 'output-sizes') %] [% INCLUDE createChart id="output-size" yaxis="mib" dataUrl=c.uri_for('/job' project.name jobset.name job.name 'output-sizes') %]
[% FOREACH metric IN metrics %]
<h3>Metric: <tt>[%HTML.escape(metric.name)%]</tt></h3>
[% INCLUDE createChart id="metric-${metric.name}" dataUrl=c.uri_for('/job' project.name jobset.name job.name 'metric' metric.name) %]
[% END %]
</div> </div>
<div id="tabs-links" class="tab-pane"> <div id="tabs-links" class="tab-pane">

7
src/root/metric.tt Normal file
View file

@ -0,0 +1,7 @@
[% WRAPPER layout.tt title="Job metric $metricName" %]
[% PROCESS common.tt %]
[% INCLUDE includeFlot %]
[% INCLUDE createChart id="chart" dataUrl=c.req.uri %]
[% END %]

View file

@ -29,6 +29,10 @@ table.productList {
border-spacing: 0em 1em; border-spacing: 0em 1em;
} }
table.table-small {
width: auto !important;
}
span:target > span.dep-tree-line { span:target > span.dep-tree-line {
font-style: italic; font-style: italic;
font-weight: bold; font-weight: bold;

View file

@ -324,6 +324,28 @@ create table BuildProducts (
); );
create table BuildMetrics (
build integer not null,
name text not null,
unit text,
value double precision not null,
-- Denormalisation for performance: copy some columns from the
-- corresponding build.
project text not null,
jobset text not null,
job text not null,
timestamp integer not null,
primary key (build, name),
foreign key (build) references Builds(id) on delete cascade,
foreign key (project) references Projects(name) on update cascade,
foreign key (project, jobset) references Jobsets(project, name) on update cascade,
foreign key (project, jobset, job) references Jobs(project, jobset, name) on update cascade
);
-- Cache for inputs of type "path" (used for testing Hydra), storing -- Cache for inputs of type "path" (used for testing Hydra), storing
-- the SHA-256 hash and store path for each source path. Also stores -- the SHA-256 hash and store path for each source path. Also stores
-- the timestamp when we first saw the path have these contents, which -- the timestamp when we first saw the path have these contents, which
@ -590,6 +612,7 @@ create trigger NrBuildsFinished after insert or update or delete on Builds
create index IndexBuildInputsOnBuild on BuildInputs(build); create index IndexBuildInputsOnBuild on BuildInputs(build);
create index IndexBuildInputsOnDependency on BuildInputs(dependency); create index IndexBuildInputsOnDependency on BuildInputs(dependency);
create index IndexBuildMetricsOnJobTimestamp on BuildMetrics(project, jobset, job, timestamp desc);
create index IndexBuildProducstOnBuildAndType on BuildProducts(build, type); create index IndexBuildProducstOnBuildAndType on BuildProducts(build, type);
create index IndexBuildProductsOnBuild on BuildProducts(build); create index IndexBuildProductsOnBuild on BuildProducts(build);
create index IndexBuildStepsOnBusy on BuildSteps(busy) where busy = 1; create index IndexBuildStepsOnBusy on BuildSteps(busy) where busy = 1;

22
src/sql/upgrade-39.sql Normal file
View file

@ -0,0 +1,22 @@
create table BuildMetrics (
build integer not null,
name text not null,
unit text,
value double precision not null,
-- Denormalisation for performance: copy some columns from the
-- corresponding build.
project text not null,
jobset text not null,
job text not null,
timestamp integer not null,
primary key (build, name),
foreign key (build) references Builds(id) on delete cascade,
foreign key (project) references Projects(name) on update cascade,
foreign key (project, jobset) references Jobsets(project, name) on update cascade,
foreign key (project, jobset, job) references Jobs(project, jobset, name) on update cascade
);
create index IndexBuildMetricsOnJobTimestamp on BuildMetrics(project, jobset, job, timestamp desc);