Skip to content

Commit 1a3e5a0

Browse files
committed
Gate any metric of any grid point, and let every matching threshold fire
A threshold gated one thing: the `value` scalar of every grid point of its measure. A benchmark now reports as many grid points as it has parameter sets and as many named scalars as its harness measured, and neither was addressable. A project that wanted `p99` watched on the one configuration where it matters had nowhere to say so. A threshold gains two optional fields. `metric` is the name it gates, and a threshold that names none gates the conventional `value` name: a threshold always gates exactly one name and a bare one never gates all of them. `parameters` is a filter over grid points, a list of parameter sets that is an OR across the list and a subset match within each set, and a threshold with no filter gates every grid point. Both defaults are what every existing threshold already does, so no existing row moves and no project's alert volume changes. Every threshold that matches a metric row runs. There is no winner: not in gating, not in display. A grid point that a bare threshold and a filtered threshold both match earns a boundary from each and, on a regression, an alert from each. That is the design and it is pinned by a test, because a row that two people asked to be watched is a row two people hear about. Identity is the three dimensions plus the two new fields, under the null semantics they carry. SQLite treats nulls as distinct in a unique index, so two bare thresholds on one branch, testbed, and measure would no longer collide under a plain unique key over the five columns. The key is declared over the effective values instead, `COALESCE(metric, 'value')` and `COALESCE(parameters, x'')`, and the wire canonicalizes into them: an explicit `value` and an absent name are one threshold, an empty filter and an absent one are one threshold, and a filter has one spelling because its sets sort by their RFC 8785 canonical bytes and duplicates collapse. A threshold gates the sample it names. The historical query behind detection filters on the threshold's metric name, so a threshold on `p99` is tested against `p99` rows and never against the `value` rows beside them, and the per grid point separation stays exactly as it was. `boundary` keys on `(metric_id, threshold_id)` rather than on `metric_id` alone, because a metric row may now carry a boundary per threshold that gated it. Both tables are rebuilt with their unique keys built after the copy, which is what keeps the rebuild at the cost of the scan. `JsonAlert` gains `value`, the scalar the alert fired on, and its `metric` triple becomes optional: the triple is a convention over the `value` name, so an alert on any other name has none. Every alert that a threshold could raise before this carries the triple exactly as it did. The gated name is readable at `alert.threshold.metric`. The deprecated singular `threshold`, `boundary`, and `alert` fields carry the bare threshold's gate and no other's, everywhere they appear. That is precisely what a caller from before named gating has always been shown: a row that only a named or filtered threshold gates reports no gate in them at all. Where a list of boundaries is returned, it is ordered by threshold creation time, oldest first, with the UUID breaking a tie. The in-report `thresholds.models` map is unchanged and still addresses the bare threshold: a map that names a measure and a model says nothing about a name or a grid point, so it neither creates nor resets anything narrower.
1 parent 96aae30 commit 1a3e5a0

30 files changed

Lines changed: 2592 additions & 270 deletions

File tree

lib/api_projects/src/metrics.rs

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ use bencher_schema::{
2727
schema,
2828
};
2929
use diesel::{
30-
ExpressionMethods as _, JoinOnDsl as _, NullableExpressionMethods as _, QueryDsl as _,
31-
RunQueryDsl as _, SelectableHelper as _, query_builder::QueryFragment,
32-
query_dsl::methods::LoadQuery, sqlite::Sqlite,
30+
BoolExpressionMethods as _, ExpressionMethods as _, JoinOnDsl as _,
31+
NullableExpressionMethods as _, QueryDsl as _, RunQueryDsl as _, SelectableHelper as _,
32+
query_builder::QueryFragment, query_dsl::methods::LoadQuery, sqlite::Sqlite,
3333
};
3434
use dropshot::{HttpError, Path, RequestContext, endpoint};
3535
use schemars::JsonSchema;
@@ -178,13 +178,17 @@ fn metric_query(
178178
schema::report::spec_id,
179179
QueryMetric::as_select(),
180180
(
181+
// The column order is `QueryThreshold`'s field order, because that is
182+
// what a tuple selection deserializes into, positionally.
181183
(
182184
schema::threshold::id,
183185
schema::threshold::uuid,
184186
schema::threshold::project_id,
185-
schema::threshold::measure_id,
186187
schema::threshold::branch_id,
187188
schema::threshold::testbed_id,
189+
schema::threshold::measure_id,
190+
schema::threshold::metric,
191+
schema::threshold::parameters,
188192
schema::threshold::model_id,
189193
schema::threshold::created,
190194
schema::threshold::modified,
@@ -224,11 +228,31 @@ fn metric_query(
224228
)
225229
.nullable(),
226230
))
227-
// The UUID is unique, so at most one row can match. The limit is what
228-
// `first` renders, kept here because the query is built apart from its run.
231+
// The bare threshold's row first, then one row, because the deprecated gate
232+
// this response reports is the bare threshold's. See `metric_gate`.
233+
.order(bare_threshold_first())
229234
.limit(1)
230235
}
231236

237+
/// Sort the bare threshold's row first.
238+
///
239+
/// A metric row may carry a boundary per threshold that gated it, and this query
240+
/// keeps one row. The one it keeps is the bare threshold's, which is what the
241+
/// deprecated singular fields have always carried.
242+
type BareThresholdFirst = diesel::dsl::Desc<
243+
diesel::dsl::And<
244+
diesel::dsl::IsNull<schema::threshold::metric>,
245+
diesel::dsl::IsNull<schema::threshold::parameters>,
246+
>,
247+
>;
248+
249+
fn bare_threshold_first() -> BareThresholdFirst {
250+
schema::threshold::metric
251+
.is_null()
252+
.and(schema::threshold::parameters.is_null())
253+
.desc()
254+
}
255+
232256
/// The gate on the addressed row: the threshold that gated it, the model that
233257
/// threshold ran, the boundary it produced, and any alert that boundary raised.
234258
type MetricGate = (
@@ -255,6 +279,14 @@ type MetricQuery = (
255279
Option<MetricGate>,
256280
);
257281

282+
/// The gate this response reports: the bare threshold's, and no other's.
283+
///
284+
/// A metric row may be gated by several thresholds now, so one of them has to be the
285+
/// one these three fields carry, and it is the bare one: the `value` name of every
286+
/// grid point, which is the only kind of threshold there was when these fields were
287+
/// the whole story. A row that only a named or filtered threshold gates reports no
288+
/// gate here, which is exactly what a caller from before named gating would have
289+
/// seen for it.
258290
fn metric_gate(
259291
project: &QueryProject,
260292
gate: Option<MetricGate>,
@@ -263,15 +295,16 @@ fn metric_gate(
263295
Option<JsonBoundary>,
264296
Option<JsonPerfAlert>,
265297
) {
266-
if let Some((query_threshold, query_model, query_boundary, query_alert)) = gate {
267-
let threshold =
268-
Some(query_threshold.into_threshold_model_json_for_project(project, query_model));
269-
let boundary = Some(query_boundary.into_json());
270-
let alert = query_alert.map(QueryAlert::into_perf_json);
271-
(threshold, boundary, alert)
272-
} else {
273-
(None, None, None)
274-
}
298+
let Some((query_threshold, query_model, query_boundary, query_alert)) =
299+
gate.filter(|(query_threshold, _, _, _)| query_threshold.identity().is_bare())
300+
else {
301+
return (None, None, None);
302+
};
303+
let threshold =
304+
Some(query_threshold.into_threshold_model_json_for_project(project, query_model));
305+
let boundary = Some(query_boundary.into_json());
306+
let alert = query_alert.map(QueryAlert::into_perf_json);
307+
(threshold, boundary, alert)
275308
}
276309

277310
/// The metric triple, for a `value` row and for nothing else.

lib/api_projects/src/perf/mod.rs

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -541,13 +541,17 @@ fn perf_query(
541541
schema::version::hash,
542542
QueryMetric::as_select(),
543543
(
544+
// The column order is `QueryThreshold`'s field order, because that is
545+
// what a tuple selection deserializes into, positionally.
544546
(
545547
schema::threshold::id,
546548
schema::threshold::uuid,
547549
schema::threshold::project_id,
548-
schema::threshold::measure_id,
549550
schema::threshold::branch_id,
550551
schema::threshold::testbed_id,
552+
schema::threshold::measure_id,
553+
schema::threshold::metric,
554+
schema::threshold::parameters,
551555
schema::threshold::model_id,
552556
schema::threshold::created,
553557
schema::threshold::modified,
@@ -686,6 +690,7 @@ fn into_perf_metrics(
686690
},
687691
value_uuid: None,
688692
metrics: BTreeMap::new(),
693+
bare_gate: None,
689694
});
690695
}
691696
let Some(pending) = line.last_mut() else {
@@ -747,6 +752,9 @@ struct PendingMetric {
747752
/// metric triple carries.
748753
value_uuid: Option<MetricUuid>,
749754
metrics: BTreeMap<MetricName, JsonMetricEntry>,
755+
/// The bare threshold's gate on this point, if a bare threshold gated it: what
756+
/// the deprecated singular `threshold`, `boundary`, and `alert` fields carry.
757+
bare_gate: Option<JsonPerfBoundary>,
750758
}
751759

752760
impl PendingMetric {
@@ -764,22 +772,36 @@ impl PendingMetric {
764772
self.value_uuid = Some(uuid);
765773
}
766774

775+
let perf_boundary = gate.map(
776+
|(query_threshold, query_model, query_boundary, query_alert)| {
777+
// The deprecated singular gate is the bare threshold's, and no
778+
// other's: the `value` name of every grid point, which is the only
779+
// kind of threshold there was when those fields were the whole story.
780+
let is_bare = query_threshold.identity().is_bare();
781+
let perf_boundary = JsonPerfBoundary {
782+
threshold: query_threshold
783+
.into_threshold_model_json_for_project(project, query_model),
784+
boundary: query_boundary.into_json(),
785+
alert: query_alert.map(QueryAlert::into_perf_json),
786+
};
787+
(is_bare, perf_boundary)
788+
},
789+
);
790+
if let Some((true, perf_boundary)) = perf_boundary.as_ref() {
791+
self.bare_gate = Some(perf_boundary.clone());
792+
}
793+
767794
// A named scalar repeats across rows only when several thresholds gated it,
768795
// and the boundaries list is what absorbs that.
769796
let entry = self.metrics.entry(name).or_insert(JsonMetricEntry {
770797
value: value.into(),
771798
boundaries: None,
772799
});
773-
if let Some((query_threshold, query_model, query_boundary, query_alert)) = gate {
800+
if let Some((_, perf_boundary)) = perf_boundary {
774801
entry
775802
.boundaries
776803
.get_or_insert_with(Vec::new)
777-
.push(JsonPerfBoundary {
778-
threshold: query_threshold
779-
.into_threshold_model_json_for_project(project, query_model),
780-
boundary: query_boundary.into_json(),
781-
alert: query_alert.map(QueryAlert::into_perf_json),
782-
});
804+
.push(perf_boundary);
783805
}
784806
}
785807

@@ -797,9 +819,18 @@ impl PendingMetric {
797819
end_time,
798820
version,
799821
value_uuid,
800-
metrics,
822+
mut metrics,
823+
bare_gate,
801824
} = self;
802825

826+
// Several thresholds may gate one named scalar, so the list is put in one
827+
// order: the threshold creation order, oldest first.
828+
for entry in metrics.values_mut() {
829+
if let Some(boundaries) = entry.boundaries.as_mut() {
830+
boundaries.sort_by_key(|gate| gate.threshold.boundary_order());
831+
}
832+
}
833+
803834
let value = metrics.get(&MetricName::value());
804835
let metric = value_uuid.zip(value).map(|(uuid, value)| JsonMetricTriple {
805836
uuid,
@@ -811,10 +842,7 @@ impl PendingMetric {
811842
.get(&MetricName::upper_value())
812843
.map(|entry| entry.value),
813844
});
814-
// The deprecated singular gate is the one that gated the `value` row, which is
815-
// the only kind of threshold there is today. A measure that names no `value`
816-
// has nothing for it to have gated.
817-
let (threshold, boundary, alert) = value.map_or((None, None, None), deprecated_gate);
845+
let (threshold, boundary, alert) = deprecated_gate(bare_gate);
818846

819847
JsonPerfMetric {
820848
report,
@@ -837,20 +865,20 @@ type DeprecatedGate = (
837865
Option<JsonPerfAlert>,
838866
);
839867

840-
fn deprecated_gate(value: &JsonMetricEntry) -> DeprecatedGate {
841-
let Some(perf_boundary) = value
842-
.boundaries
843-
.as_ref()
844-
.and_then(|boundaries| boundaries.first())
845-
else {
846-
return (None, None, None);
847-
};
848-
let JsonPerfBoundary {
868+
/// The deprecated singular gate: the bare threshold's, and no other's.
869+
///
870+
/// A point that only a named or filtered threshold gates reports no gate here, which
871+
/// is exactly what a caller from before named gating would have seen for it.
872+
fn deprecated_gate(bare_gate: Option<JsonPerfBoundary>) -> DeprecatedGate {
873+
let Some(JsonPerfBoundary {
849874
threshold,
850875
boundary,
851876
alert,
852-
} = perf_boundary;
853-
(Some(threshold.clone()), Some(*boundary), alert.clone())
877+
}) = bare_gate
878+
else {
879+
return (None, None, None);
880+
};
881+
(Some(threshold), Some(boundary), alert)
854882
}
855883

856884
#[cfg(test)]

lib/api_projects/src/thresholds.rs

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ use bencher_schema::{
2323
branch::QueryBranch,
2424
measure::QueryMeasure,
2525
testbed::QueryTestbed,
26-
threshold::{InsertThreshold, QueryThreshold, ThresholdSpec, model::QueryModel},
26+
threshold::{
27+
InsertThreshold, QueryThreshold, ThresholdDimensions, ThresholdIdentity,
28+
ThresholdSpec, model::QueryModel,
29+
},
2730
},
2831
user::{
2932
actor::{ApiActor, PubProjectBearerToken},
@@ -247,7 +250,11 @@ type BoxedQuery<'q> = diesel::internal::table_macro::BoxedSelectStatement<
247250
/// Create a threshold for a project.
248251
/// The user must have `create` permissions for the project,
249252
/// or provide a valid project key for the project.
250-
/// There can only be one threshold for any unique combination of: branch, testbed, and measure.
253+
/// There can only be one threshold for any unique combination of:
254+
/// branch, testbed, measure, metric name, and parameters filter.
255+
/// A threshold that names no metric gates the conventional `value` name,
256+
/// and a threshold with no parameters filter gates every grid point.
257+
/// Every threshold that matches a metric row runs: there is no winner among them.
251258
#[endpoint {
252259
method = POST,
253260
path = "/v0/projects/{project}/thresholds",
@@ -307,13 +314,22 @@ pub async fn post_inner(
307314
let measure_id =
308315
QueryMeasure::from_name_id(auth_conn!(context), project_id, &json_threshold.measure)?.id;
309316

310-
// Create the new threshold
317+
// Create the new threshold. What it gates beyond its dimensions is canonicalized
318+
// here, so an explicit `value` and an absent metric are one threshold, and so are
319+
// an empty filter and an absent one.
320+
let identity = ThresholdIdentity::new(
321+
json_threshold.metric.clone(),
322+
json_threshold.parameters.clone(),
323+
);
311324
let threshold_id = InsertThreshold::from_model(
312325
context,
313326
project_id,
314-
branch_id,
315-
testbed_id,
316-
measure_id,
327+
ThresholdDimensions {
328+
branch_id,
329+
testbed_id,
330+
measure_id,
331+
},
332+
identity,
317333
json_threshold.model,
318334
)
319335
.await?;

lib/api_projects/tests/metrics.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -884,9 +884,13 @@ async fn metrics_get_gated_value_row() {
884884
let alert = &alerts.0[0];
885885
assert_eq!(metric["alert"]["uuid"], serde_json::json!(alert.uuid));
886886
assert_eq!(metric["boundary"], serde_json::json!(alert.boundary));
887+
let alert_metric = alert
888+
.metric
889+
.as_ref()
890+
.expect("the alert gated a `value` row, so it carries the triple");
887891
assert_eq!(
888892
metric["metric"]["uuid"],
889-
serde_json::json!(alert.metric.uuid),
893+
serde_json::json!(alert_metric.uuid),
890894
"the alert is on the addressed row",
891895
);
892896

0 commit comments

Comments
 (0)