Skip to content

Name the alert's grid point and read boundaries off the base tables - #1017

Draft
epompeii wants to merge 6 commits into
u/ep/parameters-api/project-gatefrom
u/ep/parameters-api/alerts
Draft

Name the alert's grid point and read boundaries off the base tables#1017
epompeii wants to merge 6 commits into
u/ep/parameters-api/project-gatefrom
u/ep/parameters-api/alerts

Conversation

@epompeii

Copy link
Copy Markdown
Member

The field

JsonAlert gains parameter, the grid point the alert fired on. It sits between benchmark and metric, because that is the order the dimensions run in: branch, testbed, benchmark, parameters, measure, metric.

Two grid points of one benchmark under one threshold used to raise two alerts that read identically: same benchmark, same measure, same threshold, nothing to tell them apart. Now the alert names its grid point.

Every surface that returns an alert carries it:

  • the alerts list, GET /v0/projects/{project}/alerts
  • the alert detail, GET /v0/projects/{project}/alerts/{alert}, and its PATCH response
  • the alerts a report response embeds

The perf response's JsonPerfAlert is unchanged. It is a slim reference, and the line it hangs off already names its grid point.

The reader moves

Both alert readers move off the metric_boundary view and onto the base tables. An alert names its boundary, the boundary names its metric row, and that row names the report it landed in and the grid point it was measured under, so each hop is an identifier seek off the hop before it.

The metric triple is assembled from the value row the boundary was computed for plus its lower_value and upper_value siblings, which is the same assembly the metric endpoint does. That assembly is now shared rather than written twice.

The move is what the next layer needs. The view carries at most one boundary per metric row, and a metric row is about to be allowed several, so a reader that reached its boundary through the view's left join would fan one alert out into one per boundary.

No reader joins a boundary through the view

git grep -n 'view::metric_boundary'

One match, in services/console/src/chunks/engineering/sqlite-performance-tuning/bonus-bug-rust-left-join.mdx: a code sample quoted in a blog post, not a query. There is no other reference anywhere in the tree.

The view itself stays, and the migration that holds its column list unchanged stays pinned to it. Its Rust model is gone, because nothing reads it now.

Compatibility

Every field an alert carried before carries exactly what it carried before. metric included: it is still required, and it is still the triple built around the row the boundary was computed for.

The claim is stated as a fixture rather than asserted. A two grid point history under one threshold is pinned key set and values both, against what the previous shape gave that same fixture: the key set is the old one plus parameter, and every old key holds its old value. Three more fixtures pin the triple for a metric row with both bounds, one bound, and no bounds. The report response's embedded alerts and the alerts endpoint's alerts are pinned equal for the same report.

cargo gen-types regenerates the OpenAPI spec and the TypeScript types.

@epompeii
epompeii force-pushed the u/ep/parameters-api/alerts branch from 9bba446 to 5459312 Compare August 26, 2026 08:14
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

馃惏 Bencher Report

ProjectBencher
Branchu/ep/parameters-api/alerts
Testbedintel-v1
Click to view all benchmark results
BenchmarkLatencyBenchmark Result
microseconds (碌s)
(Result 螖%)
Upper Boundary
microseconds (碌s)
(Limit %)
Adapter::Json馃搱 view plot
馃毞 view threshold
5.26 碌s
(+10.48%)Baseline: 4.76 碌s
5.56 碌s
(94.55%)
Adapter::Magic (JSON)馃搱 view plot
馃毞 view threshold
5.08 碌s
(+9.94%)Baseline: 4.62 碌s
5.34 碌s
(95.06%)
Adapter::Magic (Rust)馃搱 view plot
馃毞 view threshold
27.89 碌s
(+6.94%)Baseline: 26.08 碌s
28.42 碌s
(98.16%)
Adapter::Rust馃搱 view plot
馃毞 view threshold
4.66 碌s
(+26.26%)Baseline: 3.69 碌s
5.31 碌s
(87.71%)
Adapter::RustBench馃搱 view plot
馃毞 view threshold
4.66 碌s
(+26.46%)Baseline: 3.68 碌s
5.29 碌s
(88.15%)
馃惏 View full continuous benchmarking report in Bencher

@epompeii
epompeii force-pushed the u/ep/parameters-api/alerts branch from 5459312 to e79420c Compare August 26, 2026 09:10
@epompeii
epompeii force-pushed the u/ep/parameters-api/alerts branch from e79420c to 96aae30 Compare August 27, 2026 03:46
Everett Pompeii and others added 6 commits August 27, 2026 05:11
The perf query answered per benchmark and returned one scalar per point. A
benchmark now has grid points, and a measure now has named scalars, so it
answers per grid point and returns all of them.

`parameters` is a new query parameter: a comma separated list of URL encoded
parameter sets. A grid point is queried when at least one of them is a subset
of its set, which makes the filter an OR across its elements and an AND within
one. Leaving it off queries every grid point. The filter resolves to parameter
set row identifiers in Rust, over the benchmark's own sets, so what reaches SQL
is an indexed lookup and never a JSON predicate.

A line is now keyed per grid point. `JsonPerfMetrics` carries the parameter set
it plots, between the benchmark and the measure, because that is the order the
dimensions run in: branch, testbed, benchmark, parameters, measure, metric. Two
grid points of one benchmark are two lines.

Each point carries `metrics`, every named scalar the measure ingested, keyed by
name. Each entry carries its value and the thresholds that gated it, with the
boundary each produced and any alert it raised. That list is a list from birth,
because a later layer lets several thresholds gate one named scalar.

The deprecated fields keep saying what they always said. `metric` is still the
triple, rebuilt from the `value` row and its `lower_value` and `upper_value`
siblings, and the singular `threshold`, `boundary`, and `alert` are still the
ones that gated the `value` row. A project that only ever reported the triple
gets back every field it got back before, unchanged.

`metric` is optional, following the report response. A measure that names no
`value` still measured something, and it is stored, billed, and queryable, so
it is still a point on the line: it carries its named scalars in `metrics` and
leaves the triple absent rather than disappearing from the response that serves
it. Nothing an older client can produce reaches that case, so the field is
present for every one of them. The plot leaves such a point out of the line it
draws, because a measurement with no point estimate has nothing to place on the
axis.

Reads move off the `metric_boundary` view onto the `metric` table. All of a grid
point's named scalars for one measure sit together on the index over the report
benchmark, the measure, and the name, so one bounded range read returns every
one of them, where the view had to seek each conventional name separately and
could only ever return those three. The boundary, its threshold, its model, and
its alert are chained flat with explicit `ON` clauses: SQLite cannot flatten a
compound right operand of an outer join, and the nested form makes it scan the
whole boundary table once per request.

The migration byte compatibility test drops its perf leg. It captures responses
on both sides of the metric migration, which only works for a reader that goes
through the view, and the report response already sat out for the same reason.
What the perf response owes older clients is pinned where that response lives.
The image endpoint takes `parameters`, the same comma separated list of URL
encoded parameter sets the query endpoint takes, spelled the same way and read by
the same code path. The image query struct mirrors the query struct field for
field, doc comment included, so the image is the plot of the query it mirrors.

A line is a grid point, so the key names the grid point when the benchmark name no
longer says which line is which. A benchmark whose every line in the image plots
the empty parameter set keeps the bare benchmark name it has always had. One non
empty set among a benchmark's lines names them all, the empty set among them
included, which reads `{}`. The set is spelled in its canonical form and follows
the benchmark name on the same row, so the wrapping the key text already does for
a long benchmark name loses the tail of the set spelling first and the name never.

A project that never reported a parameter set is drawn from the inputs it was
always drawn from: every line of every such benchmark is labeled exactly as
before, which the fixtures pin, and nothing else on the draw path moved.

A point whose measure named no `value` has no point estimate to place on the axis,
so the plot leaves it out of the line. That already held; a fixture now pins it.
`GET /v0/projects/{project}/metrics/{metric}` resolved through the
`metric_boundary` view, which drives on `name = 'value'`, so the UUID of a
`lower_value`, an `upper_value`, or any other named scalar was a 404 even though
the report response hands those UUIDs out. The lookup moves onto the `metric`
table, so every metric row UUID resolves.

The response now describes the addressed row. It gains `name` and `value`, the
addressed row's own name and scalar, and `parameter`, the grid point it was
measured under. `metric` becomes optional and is present only when the addressed
row is the `value` row: reconstructing the triple around a bound or a named
scalar would assert numbers the address does not name. `threshold`, `boundary`,
and `alert` describe the boundary attached to the addressed row itself.

A `value` row address is unchanged but for those three additions, which a
fixture pins key by key.
A report payload can now say what format it is written in. `bmf_version` is a
top level key of the payload, next to `results`, and it accepts exactly 0 and 1.
An absent key is version 0, so every payload that ingests today ingests
unchanged. Any other value is refused with a message that names the accepted
versions.

At version 1 the `json` adapter node tries its `json_v1` leaf first and falls
back to `json_v0`; at version 0 it tries them in today's order. That reorders
the attempts rather than filtering the payload, so version 1 refuses no v0 shape
and version 0 still ingests a v1 shape through the second attempt. `magic`
inherits the preference through the node, an explicitly named leaf is unaffected
because it is already an exact statement, and no non-JSON adapter reads the key.

An empty payload is now foldable at any version. It is the one payload both
leaves claim, so at version 1 it parses as v1, and since fold is all or nothing
across the array it would otherwise disable fold for every v0 iteration beside
it, changing the number of metric rows a report writes. The fold refusal exists
because a pooled statistic cannot be recomputed from per iteration values, and a
payload that reported nothing has none, so it has nothing to refuse over.

`BmfVersion` is a validated newtype in `bencher_valid` that replaces the enum of
the same name in `bencher_adapter`. The payload's declared version and the
version a leaf parsed are two values of one type, and they have to be able to
differ: a payload that declares version 1 may still hold v0 results.

`/v0/run` carries the key because `JsonNewRun` gains the field and the
conversion into the report payload forwards it. A job based run does not: its
results are the runner's own output rather than the submitted payload.

The v1 thresholds shape and the project gate are later layers, and the CLI gains
no flag here.
Each project now declares the highest BMF payload version it accepts. The field is
`bmf_version` on the project, it defaults to 0 everywhere, and it is visible to
everyone that can see the project. Only a server admin can move it: a PATCH that
carries the field from anyone else is forbidden, and a PATCH that does not carry it
is the patch it has always been. The gate is a plain setting, so an admin can lower
it again just as easily.

The gate is a maximum, not an exact match. A project at version 1 still ingests a
payload that declares version 0 and a payload that declares nothing at all, so
raising the gate refuses nothing that ingested before it moved.

Ingest checks the gate twice, once for each way a payload states its version, and
both refusals are the same class and name both versions, the payload's and the
project's. The `bmf_version` key a payload declares is checked before anything is
created for the report, because later layers hang payload shapes off the declared
version and none of them should reach a project that does not accept them. The
version the results actually parsed as is checked after parsing, because a payload
can reach a v1 leaf without declaring anything: the `json_v1` adapter names the leaf
outright, and the `magic` and `json` nodes fall back to it. Without the second check
the gate would be decorative.

That second check is a deliberate behavior change for those two undeclared paths on
a project still at version 0. They are not a documented way to send v1 results, and
a gate a payload can walk around is not a gate.

Report ingest, `/v0/run`, and a job based run share one check rather than three
copies of it. A job declares nothing, since its results are the runner's own output
rather than a submitted payload, but the project gate still applies to what those
results parse as.
An alert said which benchmark and which measure it fired on, and a benchmark
now has grid points, so two grid points of one benchmark under one threshold
raised two alerts that read identically. `JsonAlert` gains `parameter`, the
grid point the alert fired on, between the benchmark and the metric, because
that is the order the dimensions run in. Every surface that returns an alert
carries it: the alerts list, the alert detail, and the alerts a report
response embeds. The perf response's slim alert reference is unchanged; the
line it hangs off already names its grid point.

Both alert readers move off the `metric_boundary` view and onto the base
tables. An alert names its boundary, the boundary names its metric row, and
that row names the report it landed in and the grid point it was measured
under, so each hop is an identifier seek off the hop before it. The metric
triple comes from the `value` row the boundary was computed for plus its
`lower_value` and `upper_value` siblings, the same assembly the metric
endpoint does, now shared rather than written twice.

The move is what a later layer needs. The view carries at most one boundary
per metric row, and a metric row is about to be allowed several, so a reader
that reached its boundary through the view would fan out one alert into one
per boundary. No reader joins a boundary through the view any more.

Every field an alert carried before carries exactly what it carried before,
`metric` included: it is still required, and it is still the triple built
around the row the boundary was computed for. The alert responses are pinned
against that shape by fixture, key set and values both.

The view itself stays, and the migration that holds its column list unchanged
stays pinned to it. Its Rust model goes, because nothing reads it now, and the
migration test reads the view directly rather than through an endpoint that no
longer does.
@epompeii
epompeii force-pushed the u/ep/parameters-api/alerts branch from 96aae30 to 46545ce Compare August 27, 2026 05:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant