Skip to content

Commit 2ad64f8

Browse files
committed
Declare gated thresholds in the v1 report payload
A report has always carried its thresholds as a map of measure to model, and a map key names a measure and nothing else. Every threshold a report could declare was therefore the bare one: the conventional `value` name of every grid point. A pipeline that wanted `p99` watched, or wanted one corner of its grid watched, had to reach for the thresholds endpoint and then keep it in step with the run by hand. At BMF version 1 `thresholds.models` is a list. An entry is `parameters`, `measure`, `metric`, and `model`: the dimensions a threshold hangs off that the report does not already state, in their canonical order, and the model to gate with. `measure` is the same name, slug, or UUID the map key is today, created if the project has never seen it. An absent `metric` is the conventional `value` name and an absent `parameters` gates every grid point, so an entry naming only a measure and a model is the map pair written out longhand. One measure may carry several entries, and each one creates or updates the threshold with that identity under the report's branch and testbed, through the same null collapse and canonical filter storage every other writer goes through. A threshold the report declares gates the very report that declared it. Nothing moved to make that true: thresholds are resolved before results are parsed, which is where they already were. It is worth saying out loud because it is what makes the list worth having, and it is pinned by a test whose history is five ungated reports and whose alerts all belong to thresholds that did not exist when the request arrived. The declared version says which shape to expect and the shape is checked rather than guessed at. A list at version 0 and a map at version 1 are both a 400 naming the version that was declared and the shape it calls for. A payload that declares a version has stated something exact and gets exact validation, and the project's version gate still fires first, so a project that does not accept version 1 turns the payload away for that reason and never reads the list at all. `reset` reaches exactly as far as the shape can address. A version 0 map can only name bare thresholds, so it takes a model away from bare thresholds and nothing else, which is what it already did. A legacy run cannot strip a named or filtered threshold it has no way to spell, and the pipelines sending `reset` today are exactly the ones that cannot spell them. A version 1 list can name every identity, so `reset` reaches every threshold on the branch and testbed that the entries did not name, including a payload that names none at all. One identity declared twice in one payload is one threshold: the position is where it was first written and the model is what it was last told, resolved without an error. Two spellings of one filter are one identity, because a filter canonicalizes before it is compared. Two shapes behind one key is a place where error quality quietly dies. The obvious spelling, an untagged enum, buffers the input, tries each variant, and on failure says only that nothing matched, so a misspelled model test in a version 0 map would come back as "data did not match any variant" rather than as the field and the variants it could have been. The shape is known from the first token, so it is decided by looking rather than by trying: every error a version 0 client got before this layer it gets after it, byte for byte, and a malformed version 1 entry is named by its position and its field. The one message that moves is the one that has to, where `models` is neither shape and what it expects now names both. Deleting a parameter set learns about thresholds. A threshold whose filter names a set is a reference to that row, so the set cannot go out from under it, and the refusal says which threshold to delete first. Naming is canonical equality and nothing looser: a filter of `{"a":1}` matches the grid point `{"a":1,"b":2}` without naming it, because a filter names only the keys it cares about and is a predicate over values rather than a pointer at a row. That set can be deleted and the filter still says what it said. The comparison runs in Rust over the project's filtered thresholds, because canonical equality is what the canonical form defines and that form is written in Rust. Nothing caps that read: it is small because a filtered threshold is a rare thing to write and deleting a parameter set is a rare thing to ask for, not because a limit says so. The delete and the check share one transaction with the delete first, so a set that a report still references is refused for that reason and the client is sent to the results rather than to a threshold it would have to delete anyway. The CLI is unchanged: it declares bare thresholds, which is what the version 0 map spells, and naming a metric or a grid point from the command line is a separate piece of work.
1 parent 5d16149 commit 2ad64f8

8 files changed

Lines changed: 1834 additions & 75 deletions

File tree

lib/api_projects/src/parameters.rs

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,20 @@ use bencher_endpoint::{
44
};
55
use bencher_json::{
66
BenchmarkResourceId, JsonDirection, JsonPagination, JsonParameter, JsonParameters,
7-
ParameterUuid, ProjectResourceId,
7+
ParameterFilter, ParameterSet, ParameterUuid, ProjectResourceId, ThresholdUuid,
88
project::parameter::{JsonNewParameter, JsonUpdateParameter},
99
};
1010
use bencher_rbac::project::Permission;
1111
use bencher_schema::{
1212
actor_conn, auth_conn,
13-
context::ApiContext,
13+
context::{ApiContext, DbConnection},
1414
error::{
1515
conflict_error, resource_conflict_err, resource_not_found_err, with_auth_hint,
1616
with_token_hint,
1717
},
1818
model::{
1919
project::{
20-
QueryProject,
20+
ProjectId, QueryProject,
2121
benchmark::QueryBenchmark,
2222
parameter::{QueryParameter, UpdateParameter},
2323
},
@@ -26,7 +26,7 @@ use bencher_schema::{
2626
auth::{AuthUser, BearerToken},
2727
},
2828
},
29-
schema, write_conn,
29+
schema, write_conn, write_transaction,
3030
};
3131
use diesel::{BelongingToDsl as _, ExpressionMethods as _, QueryDsl as _, RunQueryDsl as _};
3232
use dropshot::{HttpError, Path, Query, RequestContext, TypedBody, endpoint};
@@ -407,6 +407,12 @@ pub async fn patch_inner(
407407
/// Delete a parameter set for a benchmark.
408408
/// The user must have `delete` permissions for the project.
409409
/// All reports that use this parameter must be deleted first!
410+
/// All thresholds that use this parameter must be deleted first!
411+
///
412+
/// A threshold uses a parameter set when its `parameters` filter names that exact
413+
/// set. A filter that merely matches the set, because the set pins every key the
414+
/// filter names and more besides, is a predicate over values rather than a reference
415+
/// to this row, and it does not stand in the way.
410416
///
411417
/// A benchmark's empty parameter set cannot be deleted.
412418
/// The empty set is structural: every benchmark is born with exactly one, and
@@ -465,9 +471,74 @@ async fn delete_inner(
465471
)));
466472
}
467473

468-
diesel::delete(schema::parameter::table.filter(schema::parameter::id.eq(query_parameter.id)))
469-
.execute(write_conn!(context))
470-
.map_err(resource_conflict_err!(Parameter, &query_parameter))?;
474+
// The delete goes first and the threshold check goes second, both inside one
475+
// transaction, so a set that a report still references is refused for that
476+
// reason: the foreign key fires on the delete itself and the report refusal is
477+
// the one the client reads. A set nothing reports is deleted and then put back
478+
// if a threshold names it, which is what keeps the two refusals in that order
479+
// without either of them growing a query the other already does.
480+
let mut blocking_threshold = None;
481+
let deleted = write_transaction!(context, |conn| {
482+
diesel::delete(
483+
schema::parameter::table.filter(schema::parameter::id.eq(query_parameter.id)),
484+
)
485+
.execute(conn)?;
486+
487+
blocking_threshold = threshold_naming_set(conn, query_project.id, &query_parameter.set)?;
488+
if blocking_threshold.is_some() {
489+
return Err(diesel::result::Error::RollbackTransaction);
490+
}
491+
diesel::QueryResult::Ok(())
492+
});
493+
494+
match (deleted, blocking_threshold) {
495+
(Ok(()), _) => Ok(()),
496+
(Err(diesel::result::Error::RollbackTransaction), Some(threshold)) => {
497+
Err(conflict_error(format!(
498+
"All thresholds that use this parameter must be deleted first! Threshold ({threshold}) gates the parameter set ({parameter}) of benchmark ({benchmark}).",
499+
parameter = query_parameter.set,
500+
benchmark = query_benchmark.uuid,
501+
)))
502+
},
503+
(Err(e), _) => Err(resource_conflict_err!(Parameter, &query_parameter)(e)),
504+
}
505+
}
471506

472-
Ok(())
507+
/// The first threshold in the project whose filter names this exact parameter set,
508+
/// if there is one.
509+
///
510+
/// A filter names a set by canonical equality and only by canonical equality. A
511+
/// filter that merely matches the set, say `{"a":1}` against the grid point
512+
/// `{"a":1,"b":2}`, is a predicate over values rather than a reference to a row, and
513+
/// deleting the row it happens to match takes nothing out from under it.
514+
///
515+
/// The comparison runs here rather than in SQL because canonical equality is what
516+
/// the canonical form defines and that form is written in Rust. Only the thresholds
517+
/// carrying a filter at all are read, which is a small share of a project's
518+
/// thresholds and empty for every project that has never written one, and deleting a
519+
/// parameter set is a rare administrative request rather than anything on the ingest
520+
/// path. Nothing here caps the read, so a project that one day holds a great many
521+
/// filtered thresholds is what would move this into SQL.
522+
fn threshold_naming_set(
523+
conn: &mut DbConnection,
524+
project_id: ProjectId,
525+
set: &ParameterSet,
526+
) -> diesel::QueryResult<Option<ThresholdUuid>> {
527+
let canonical = set.canonical();
528+
Ok(schema::threshold::table
529+
.filter(schema::threshold::project_id.eq(project_id))
530+
.filter(schema::threshold::parameters.is_not_null())
531+
.order(schema::threshold::id.asc())
532+
.select((schema::threshold::uuid, schema::threshold::parameters))
533+
.load::<(ThresholdUuid, Option<ParameterFilter>)>(conn)?
534+
.into_iter()
535+
.find(|(_, parameters)| {
536+
parameters.as_ref().is_some_and(|parameters| {
537+
parameters
538+
.sets()
539+
.iter()
540+
.any(|set| set.canonical() == canonical)
541+
})
542+
})
543+
.map(|(uuid, _)| uuid))
473544
}

lib/api_projects/tests/parameters.rs

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3781,3 +3781,258 @@ async fn start_point_clone_carries_what_each_threshold_gates() {
37813781
let source = list_thresholds(&server, &fixture, Some("main")).await;
37823782
assert_eq!(source.len(), 2, "the start point is unchanged: {source:?}");
37833783
}
3784+
3785+
/// Delete a threshold through the thresholds endpoint.
3786+
async fn delete_threshold(
3787+
server: &TestServer,
3788+
fixture: &Fixture,
3789+
threshold: &str,
3790+
) -> (StatusCode, String) {
3791+
let resp = server
3792+
.client
3793+
.delete(server.api_url(&format!(
3794+
"/v0/projects/{}/thresholds/{threshold}",
3795+
fixture.project_slug
3796+
)))
3797+
.header(
3798+
bencher_json::AUTHORIZATION,
3799+
bencher_json::bearer_header(&fixture.token),
3800+
)
3801+
.send()
3802+
.await
3803+
.expect("Request failed");
3804+
let status = resp.status();
3805+
let body = resp.text().await.expect("Failed to read the response");
3806+
(status, body)
3807+
}
3808+
3809+
/// The UUID a threshold response carries.
3810+
fn threshold_uuid(threshold: &serde_json::Value) -> String {
3811+
threshold
3812+
.get("uuid")
3813+
.and_then(serde_json::Value::as_str)
3814+
.expect("the threshold carries its uuid")
3815+
.to_owned()
3816+
}
3817+
3818+
/// One report of one grid point, taken back out again.
3819+
///
3820+
/// A parameter set is only ever minted by a report, and a report that still
3821+
/// references it refuses the delete on its own. Deleting the report leaves the set
3822+
/// behind with nothing pointing at it, which is the state where a threshold's claim
3823+
/// on it is the only thing left to see.
3824+
async fn unreferenced_grid_point(
3825+
server: &TestServer,
3826+
fixture: &Fixture,
3827+
set: &serde_json::Value,
3828+
) -> (String, JsonParameter) {
3829+
let json_report = report(
3830+
server,
3831+
fixture,
3832+
1,
3833+
vec![v1(
3834+
"bench",
3835+
&[entry(
3836+
set,
3837+
&serde_json::json!({ "latency": { "value": 1.0 } }),
3838+
)],
3839+
)],
3840+
None,
3841+
None,
3842+
)
3843+
.await;
3844+
let report_uuid = json_report
3845+
.get("uuid")
3846+
.and_then(serde_json::Value::as_str)
3847+
.expect("the report carries its uuid")
3848+
.to_owned();
3849+
3850+
let benchmark = only_benchmark(server, fixture).await;
3851+
let wanted = parameters(&serde_json::to_string(set).expect("the set serializes"));
3852+
let grid_point = parameter_list(server, fixture, &benchmark, "")
3853+
.await
3854+
.into_iter()
3855+
.find(|parameter| parameter.set == wanted)
3856+
.expect("the reported grid point");
3857+
3858+
let (status, body) = delete_report(server, fixture, &report_uuid).await;
3859+
assert_eq!(status, StatusCode::NO_CONTENT, "DELETE report: {body}");
3860+
3861+
(benchmark, grid_point)
3862+
}
3863+
3864+
// A threshold that names a parameter set in its filter is a reference to it, so the
3865+
// set cannot be deleted out from under it. Deleting the threshold is what makes the
3866+
// set deletable, exactly as deleting a report is one level up.
3867+
#[tokio::test]
3868+
async fn parameter_delete_refuses_while_a_threshold_names_it() {
3869+
let server = TestServer::new().await;
3870+
let fixture = fixture(&server, "delete-threshold").await;
3871+
let (benchmark, grid_point) =
3872+
unreferenced_grid_point(&server, &fixture, &serde_json::json!({ "size_mb": 16 })).await;
3873+
3874+
let threshold = create_threshold(
3875+
&server,
3876+
&fixture,
3877+
None,
3878+
Some(serde_json::json!([{ "size_mb": 16 }])),
3879+
)
3880+
.await;
3881+
3882+
let (status, body) = delete_parameter(
3883+
&server,
3884+
&fixture,
3885+
&benchmark,
3886+
&fixture.token,
3887+
&grid_point.uuid,
3888+
)
3889+
.await;
3890+
assert_eq!(
3891+
status,
3892+
StatusCode::CONFLICT,
3893+
"a threshold names the set: {body}"
3894+
);
3895+
assert!(
3896+
body.contains("All thresholds that use this parameter must be deleted first!"),
3897+
"the refusal says what to delete first: {body}"
3898+
);
3899+
3900+
let mut conn = server.db_conn();
3901+
assert!(
3902+
parameter_row_id(&mut conn, &grid_point.uuid).is_some(),
3903+
"the refused delete put the parameter set back"
3904+
);
3905+
drop(conn);
3906+
3907+
let (status, body) = delete_threshold(&server, &fixture, &threshold_uuid(&threshold)).await;
3908+
assert_eq!(status, StatusCode::NO_CONTENT, "DELETE threshold: {body}");
3909+
3910+
let (status, body) = delete_parameter(
3911+
&server,
3912+
&fixture,
3913+
&benchmark,
3914+
&fixture.token,
3915+
&grid_point.uuid,
3916+
)
3917+
.await;
3918+
assert_eq!(
3919+
status,
3920+
StatusCode::NO_CONTENT,
3921+
"nothing names the set any more: {body}"
3922+
);
3923+
3924+
let mut conn = server.db_conn();
3925+
assert!(
3926+
parameter_row_id(&mut conn, &grid_point.uuid).is_none(),
3927+
"the parameter set is gone"
3928+
);
3929+
}
3930+
3931+
// Matching a set is not naming it. A filter of `{"size_mb": 16}` matches the grid
3932+
// point `{"size_mb": 16, "os": "linux"}` because a filter names only the keys it
3933+
// cares about, but it is a predicate over values rather than a reference to that
3934+
// row: the grid point can go and the filter still says what it said.
3935+
#[tokio::test]
3936+
async fn parameter_delete_allows_a_filter_that_only_matches_it() {
3937+
let server = TestServer::new().await;
3938+
let fixture = fixture(&server, "delete-subset").await;
3939+
let (benchmark, grid_point) = unreferenced_grid_point(
3940+
&server,
3941+
&fixture,
3942+
&serde_json::json!({ "os": "linux", "size_mb": 16 }),
3943+
)
3944+
.await;
3945+
3946+
create_threshold(
3947+
&server,
3948+
&fixture,
3949+
None,
3950+
Some(serde_json::json!([{ "size_mb": 16 }])),
3951+
)
3952+
.await;
3953+
3954+
let (status, body) = delete_parameter(
3955+
&server,
3956+
&fixture,
3957+
&benchmark,
3958+
&fixture.token,
3959+
&grid_point.uuid,
3960+
)
3961+
.await;
3962+
assert_eq!(
3963+
status,
3964+
StatusCode::NO_CONTENT,
3965+
"a filter that merely matches does not stand in the way: {body}"
3966+
);
3967+
3968+
let mut conn = server.db_conn();
3969+
assert!(
3970+
parameter_row_id(&mut conn, &grid_point.uuid).is_none(),
3971+
"the parameter set is gone"
3972+
);
3973+
}
3974+
3975+
// When a report and a threshold both point at a set, the report is the one the
3976+
// refusal names. The results have to go first either way, and telling a client
3977+
// about the threshold while its reports still reference the set would send it to
3978+
// the wrong place.
3979+
#[tokio::test]
3980+
async fn parameter_delete_reports_the_report_reference_first() {
3981+
let server = TestServer::new().await;
3982+
let fixture = fixture(&server, "delete-precedence").await;
3983+
3984+
report(
3985+
&server,
3986+
&fixture,
3987+
1,
3988+
vec![v1(
3989+
"bench",
3990+
&[entry(
3991+
&serde_json::json!({ "size_mb": 16 }),
3992+
&serde_json::json!({ "latency": { "value": 1.0 } }),
3993+
)],
3994+
)],
3995+
None,
3996+
None,
3997+
)
3998+
.await;
3999+
4000+
let benchmark = only_benchmark(&server, &fixture).await;
4001+
let grid_point = parameter_list(&server, &fixture, &benchmark, "")
4002+
.await
4003+
.into_iter()
4004+
.find(|parameter| parameter.set == parameters(r#"{"size_mb":16}"#))
4005+
.expect("the reported grid point");
4006+
4007+
create_threshold(
4008+
&server,
4009+
&fixture,
4010+
None,
4011+
Some(serde_json::json!([{ "size_mb": 16 }])),
4012+
)
4013+
.await;
4014+
4015+
let (status, body) = delete_parameter(
4016+
&server,
4017+
&fixture,
4018+
&benchmark,
4019+
&fixture.token,
4020+
&grid_point.uuid,
4021+
)
4022+
.await;
4023+
assert_eq!(
4024+
status,
4025+
StatusCode::CONFLICT,
4026+
"both point at the set: {body}"
4027+
);
4028+
assert!(
4029+
!body.contains("All thresholds that use this parameter must be deleted first!"),
4030+
"the report reference is the one that fires: {body}"
4031+
);
4032+
4033+
let mut conn = server.db_conn();
4034+
assert!(
4035+
parameter_row_id(&mut conn, &grid_point.uuid).is_some(),
4036+
"the parameter set is still there"
4037+
);
4038+
}

0 commit comments

Comments
 (0)