diff --git a/lib/api_projects/src/benchmarks.rs b/lib/api_projects/src/benchmarks.rs index 9e29b7d400..6585d6a5db 100644 --- a/lib/api_projects/src/benchmarks.rs +++ b/lib/api_projects/src/benchmarks.rs @@ -1,3 +1,5 @@ +use std::cell::RefCell; + use bencher_endpoint::{ CorsResponse, Delete, Endpoint, Get, Patch, Post, ResponseCreated, ResponseDeleted, ResponseOk, TotalCount, @@ -15,7 +17,11 @@ use bencher_schema::{ model::{ project::{ QueryProject, - benchmark::{QueryBenchmark, UpdateBenchmark}, + benchmark::{ + QueryBenchmark, UpdateBenchmark, aliases_by_benchmark_id, + list_aliases_for_benchmark, replace_benchmark_aliases, + validate_benchmark_aliases_uniqueness, + }, }, user::{ auth::{AuthUser, BearerToken}, @@ -25,8 +31,8 @@ use bencher_schema::{ public_conn, schema, write_conn, }; use diesel::{ - BelongingToDsl as _, BoolExpressionMethods as _, ExpressionMethods as _, QueryDsl as _, - RunQueryDsl as _, TextExpressionMethods as _, + BelongingToDsl as _, BoolExpressionMethods as _, Connection as _, ExpressionMethods as _, + QueryDsl as _, RunQueryDsl as _, TextExpressionMethods as _, dsl::exists, }; use dropshot::{HttpError, Path, Query, RequestContext, TypedBody, endpoint}; use schemars::JsonSchema; @@ -130,17 +136,28 @@ async fn get_ls_inner( (&query_project, &pagination_params, &query_params) ))?; + let ids: Vec<_> = benchmarks.iter().map(|b| b.id).collect(); + let alias_map = + aliases_by_benchmark_id(public_conn!(context, public_user), query_project.id, &ids) + .map_err(resource_not_found_err!( + Benchmark, + (&query_project, &pagination_params, &query_params) + ))?; + // Drop connection lock before iterating let json_benchmarks = benchmarks .into_iter() - .map(|benchmark| benchmark.into_json_for_project(&query_project)) + .map(|benchmark| { + let aliases = alias_map.get(&benchmark.id).cloned().unwrap_or_default(); + benchmark.into_json_for_project_with_aliases(&query_project, aliases) + }) .collect(); let total_count = get_ls_query(&query_project, &pagination_params, &query_params) .count() .get_result::(public_conn!(context, public_user)) .map_err(resource_not_found_err!( - Plot, + Benchmark, (&query_project, &pagination_params, &query_params) ))? .try_into()?; @@ -156,14 +173,27 @@ fn get_ls_query<'q>( let mut query = QueryBenchmark::belonging_to(&query_project).into_boxed(); if let Some(name) = query_params.name.as_ref() { - query = query.filter(schema::benchmark::name.eq(name)); + let alias_match = exists( + schema::benchmark_alias::table + .filter(schema::benchmark_alias::benchmark_id.eq(schema::benchmark::id)) + .filter(schema::benchmark_alias::project_id.eq(query_project.id)) + .filter(schema::benchmark_alias::alias.eq(name.as_ref())), + ); + query = query.filter(schema::benchmark::name.eq(name).or(alias_match)); } if let Some(search) = query_params.search.as_ref() { + let alias_search = exists( + schema::benchmark_alias::table + .filter(schema::benchmark_alias::benchmark_id.eq(schema::benchmark::id)) + .filter(schema::benchmark_alias::project_id.eq(query_project.id)) + .filter(schema::benchmark_alias::alias.like(search)), + ); query = query.filter( schema::benchmark::name .like(search) .or(schema::benchmark::slug.like(search)) - .or(schema::benchmark::uuid.like(search)), + .or(schema::benchmark::uuid.like(search)) + .or(alias_search), ); } @@ -222,9 +252,8 @@ async fn post_inner( Permission::Create, )?; - QueryBenchmark::create(context, query_project.id, json_benchmark) - .await - .map(|benchmark| benchmark.into_json_for_project(&query_project)) + let benchmark = QueryBenchmark::create(context, query_project.id, json_benchmark).await?; + benchmark.into_json_for_project(auth_conn!(context), &query_project) } #[derive(Deserialize, JsonSchema)] @@ -286,14 +315,15 @@ async fn get_one_inner( public_user, )?; - QueryBenchmark::belonging_to(&query_project) + let benchmark_param = path_params.benchmark.clone(); + let benchmark = QueryBenchmark::belonging_to(&query_project) .filter(QueryBenchmark::eq_resource_id(&path_params.benchmark)) .first::(public_conn!(context, public_user)) - .map(|benchmark| benchmark.into_json_for_project(&query_project)) .map_err(resource_not_found_err!( Benchmark, - (&query_project, path_params.benchmark) - )) + (&query_project, benchmark_param.clone()) + ))?; + benchmark.into_json_for_project(public_conn!(context, public_user), &query_project) } /// Update a benchmark @@ -342,18 +372,76 @@ async fn patch_inner( query_project.id, &path_params.benchmark, )?; + + let effective_name = json_benchmark + .name + .clone() + .unwrap_or_else(|| query_benchmark.name.clone()); + let update_benchmark = UpdateBenchmark::from(json_benchmark.clone()); - diesel::update(schema::benchmark::table.filter(schema::benchmark::id.eq(query_benchmark.id))) + let conn = write_conn!(context); + let validation_err: RefCell> = RefCell::new(None); + let txn_result = conn.transaction(|conn| -> diesel::QueryResult<()> { + if let Some(list) = json_benchmark.aliases.as_ref() { + validate_benchmark_aliases_uniqueness( + conn, + query_project.id, + Some(query_benchmark.id), + &effective_name, + list, + ) + .map_err(|e| { + *validation_err.borrow_mut() = Some(e); + diesel::result::Error::RollbackTransaction + })?; + } else if json_benchmark.name.is_some() { + let current = list_aliases_for_benchmark(conn, query_benchmark.id).map_err(|e| { + *validation_err.borrow_mut() = + Some(resource_not_found_err!(Benchmark, &query_benchmark)(e)); + diesel::result::Error::RollbackTransaction + })?; + validate_benchmark_aliases_uniqueness( + conn, + query_project.id, + Some(query_benchmark.id), + &effective_name, + ¤t, + ) + .map_err(|e| { + *validation_err.borrow_mut() = Some(e); + diesel::result::Error::RollbackTransaction + })?; + } + + diesel::update( + schema::benchmark::table.filter(schema::benchmark::id.eq(query_benchmark.id)), + ) .set(&update_benchmark) - .execute(write_conn!(context)) - .map_err(resource_conflict_err!( - Benchmark, - (&query_benchmark, &json_benchmark) - ))?; + .execute(conn)?; + + if let Some(list) = json_benchmark.aliases.as_ref() { + replace_benchmark_aliases(conn, query_project.id, query_benchmark.id, list)?; + } + + Ok(()) + }); + + match txn_result { + Ok(()) => {}, + Err(e) => { + if let Some(he) = validation_err.into_inner() { + return Err(he); + } + return Err(resource_conflict_err!( + Benchmark, + (&query_benchmark, &json_benchmark) + )(e)); + }, + } - QueryBenchmark::get(auth_conn!(context), query_benchmark.id) - .map(|benchmark| benchmark.into_json_for_project(&query_project)) - .map_err(resource_not_found_err!(Benchmark, query_benchmark)) + let benchmark = QueryBenchmark::get(auth_conn!(context), query_benchmark.id) + .map_err(resource_not_found_err!(Benchmark, query_benchmark))?; + benchmark.into_json_for_project(auth_conn!(context), &query_project) } /// Delete a benchmark diff --git a/lib/api_projects/src/metrics.rs b/lib/api_projects/src/metrics.rs index 45f984d4c4..322f10e20f 100644 --- a/lib/api_projects/src/metrics.rs +++ b/lib/api_projects/src/metrics.rs @@ -210,7 +210,7 @@ fn metric_query_json( ) -> Result { let branch = branch.into_json_for_head(conn, project, &head, Some(version))?; let testbed = testbed.into_json_for_spec(conn, project, spec_id)?; - let benchmark = benchmark.into_json_for_project(project); + let benchmark = benchmark.into_json_for_project(conn, project)?; let measure = measure.into_json_for_project(project); let (threshold, alert) = threshold_model_alert(project, tma); diff --git a/lib/api_projects/src/perf/mod.rs b/lib/api_projects/src/perf/mod.rs index 2a44fe0e0b..2f4ddc68a3 100644 --- a/lib/api_projects/src/perf/mod.rs +++ b/lib/api_projects/src/perf/mod.rs @@ -548,7 +548,7 @@ fn new_perf_metrics( Ok(JsonPerfMetrics { branch: branch.into_json_for_head(conn, project, &head, None)?, testbed: testbed.into_json_for_spec(conn, project, spec_id)?, - benchmark: benchmark.into_json_for_project(project), + benchmark: benchmark.into_json_for_project(conn, project)?, measure: measure.into_json_for_project(project), metrics: vec![metric], }) diff --git a/lib/bencher_client/src/lib.rs b/lib/bencher_client/src/lib.rs index acb1956ba7..51b625b6a4 100644 --- a/lib/bencher_client/src/lib.rs +++ b/lib/bencher_client/src/lib.rs @@ -1,5 +1,3 @@ -#![expect(clippy::multiple_inherent_impl, reason = "codegen")] - mod codegen { #![expect( unused_qualifications, diff --git a/lib/bencher_json/src/project/benchmark.rs b/lib/bencher_json/src/project/benchmark.rs index f34988685f..3878fc3049 100644 --- a/lib/bencher_json/src/project/benchmark.rs +++ b/lib/bencher_json/src/project/benchmark.rs @@ -29,6 +29,9 @@ pub struct JsonNewBenchmark { /// If the provided or generated slug is already in use, a unique slug will be generated. /// Maximum length is 64 characters. pub slug: Option, + /// Additional exact-match strings for this benchmark within the project. + #[serde(default)] + pub aliases: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -45,6 +48,9 @@ pub struct JsonBenchmark { pub project: ProjectUuid, pub name: BenchmarkName, pub slug: BenchmarkSlug, + /// Additional exact-match strings (the primary [`Self::name`] is always a matcher). + #[serde(default)] + pub aliases: Vec, pub created: DateTime, pub modified: DateTime, pub archived: Option, @@ -67,4 +73,6 @@ pub struct JsonUpdateBenchmark { pub slug: Option, /// Set whether the benchmark is archived. pub archived: Option, + /// When set, replaces all additional aliases for this benchmark (full replace). + pub aliases: Option>, } diff --git a/lib/bencher_schema/migrations/2026-03-24-140000_benchmark_alias/down.sql b/lib/bencher_schema/migrations/2026-03-24-140000_benchmark_alias/down.sql new file mode 100644 index 0000000000..2f1642887a --- /dev/null +++ b/lib/bencher_schema/migrations/2026-03-24-140000_benchmark_alias/down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS index_benchmark_alias_benchmark; +DROP TABLE IF EXISTS benchmark_alias; diff --git a/lib/bencher_schema/migrations/2026-03-24-140000_benchmark_alias/up.sql b/lib/bencher_schema/migrations/2026-03-24-140000_benchmark_alias/up.sql new file mode 100644 index 0000000000..98bab93e78 --- /dev/null +++ b/lib/bencher_schema/migrations/2026-03-24-140000_benchmark_alias/up.sql @@ -0,0 +1,10 @@ +CREATE TABLE benchmark_alias ( + id INTEGER PRIMARY KEY NOT NULL, + project_id INTEGER NOT NULL, + benchmark_id INTEGER NOT NULL, + alias TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE, + FOREIGN KEY (benchmark_id) REFERENCES benchmark (id) ON DELETE CASCADE, + UNIQUE (project_id, alias) +); +CREATE INDEX index_benchmark_alias_benchmark ON benchmark_alias (benchmark_id); diff --git a/lib/bencher_schema/src/error.rs b/lib/bencher_schema/src/error.rs index 631bb1e86d..9486eeeb88 100644 --- a/lib/bencher_schema/src/error.rs +++ b/lib/bencher_schema/src/error.rs @@ -25,6 +25,7 @@ pub enum BencherResource { HeadVersion, Testbed, Benchmark, + BenchmarkAlias, Measure, Metric, Threshold, @@ -68,6 +69,7 @@ impl fmt::Display for BencherResource { Self::HeadVersion => "Head Version", Self::Testbed => "Testbed", Self::Benchmark => "Benchmark", + Self::BenchmarkAlias => "Benchmark Alias", Self::Measure => "Measure", Self::Metric => "Metric", Self::Threshold => "Threshold", diff --git a/lib/bencher_schema/src/model/project/benchmark/alias.rs b/lib/bencher_schema/src/model/project/benchmark/alias.rs new file mode 100644 index 0000000000..bba6ea39d4 --- /dev/null +++ b/lib/bencher_schema/src/model/project/benchmark/alias.rs @@ -0,0 +1,163 @@ +use std::collections::{HashMap, HashSet}; + +use bencher_json::BenchmarkName; +use diesel::{ExpressionMethods as _, QueryDsl as _, RunQueryDsl as _}; +use dropshot::HttpError; + +use super::BenchmarkId; +use crate::{ + context::DbConnection, + error::{BencherResource, bad_request_error, resource_conflict_error}, + model::project::ProjectId, + schema::{benchmark, benchmark_alias}, +}; + +#[derive(Debug, diesel::Insertable)] +#[diesel(table_name = benchmark_alias)] +pub struct InsertBenchmarkAlias { + pub project_id: ProjectId, + pub benchmark_id: BenchmarkId, + pub alias: BenchmarkName, +} + +/// Validates alias strings for a benchmark: no duplicates, none equal the primary name, and no +/// collision with another benchmark's `name` or `alias` in the same project. +pub fn validate_benchmark_aliases_uniqueness( + conn: &mut DbConnection, + project_id: ProjectId, + exclude_benchmark_id: Option, + primary_name: &BenchmarkName, + aliases: &[BenchmarkName], +) -> Result<(), HttpError> { + if aliases.is_empty() { + return Ok(()); + } + + let mut seen = HashSet::new(); + for alias in aliases { + let key = alias.as_ref(); + if !seen.insert(key) { + return Err(bad_request_error(format!( + "Duplicate benchmark alias: {alias}" + ))); + } + if alias == primary_name { + return Err(bad_request_error( + "A benchmark alias must not match the benchmark's primary name", + )); + } + } + + let mut name_query = benchmark::table + .filter(benchmark::project_id.eq(project_id)) + .filter(benchmark::name.eq_any(aliases)) + .into_boxed(); + if let Some(ex) = exclude_benchmark_id { + name_query = name_query.filter(benchmark::id.ne(ex)); + } + let conflicting_names: Vec = name_query + .select(benchmark::name) + .load(conn) + .map_err(|e| resource_conflict_error(BencherResource::BenchmarkAlias, (project_id,), e))?; + + let mut alias_query = benchmark_alias::table + .filter(benchmark_alias::project_id.eq(project_id)) + .filter(benchmark_alias::alias.eq_any(aliases)) + .into_boxed(); + if let Some(ex) = exclude_benchmark_id { + alias_query = alias_query.filter(benchmark_alias::benchmark_id.ne(ex)); + } + let conflicting_aliases: Vec = alias_query + .select(benchmark_alias::alias) + .load(conn) + .map_err(|e| resource_conflict_error(BencherResource::BenchmarkAlias, (project_id,), e))?; + + let name_set: HashSet = conflicting_names + .into_iter() + .map(|n| n.as_ref().to_owned()) + .collect(); + let alias_set: HashSet = conflicting_aliases + .into_iter() + .map(|a| a.as_ref().to_owned()) + .collect(); + + for alias in aliases { + let key = alias.as_ref(); + if name_set.contains(key) { + return Err(resource_conflict_error( + BencherResource::BenchmarkAlias, + (project_id, alias.clone()), + format!("Conflicts with another benchmark's name: {alias}"), + )); + } + if alias_set.contains(key) { + return Err(resource_conflict_error( + BencherResource::BenchmarkAlias, + (project_id, alias.clone()), + format!("Conflicts with another benchmark's alias: {alias}"), + )); + } + } + Ok(()) +} + +/// Deletes all aliases for a benchmark and inserts the given list. Caller must validate first. +pub fn replace_benchmark_aliases( + conn: &mut DbConnection, + project_id: ProjectId, + benchmark_id: BenchmarkId, + aliases: &[BenchmarkName], +) -> diesel::QueryResult<()> { + diesel::delete(benchmark_alias::table.filter(benchmark_alias::benchmark_id.eq(benchmark_id))) + .execute(conn)?; + + if aliases.is_empty() { + return Ok(()); + } + + let rows: Vec = aliases + .iter() + .map(|alias| InsertBenchmarkAlias { + project_id, + benchmark_id, + alias: alias.clone(), + }) + .collect(); + diesel::insert_into(benchmark_alias::table) + .values(&rows) + .execute(conn)?; + Ok(()) +} + +pub fn list_aliases_for_benchmark( + conn: &mut DbConnection, + benchmark_id: BenchmarkId, +) -> diesel::QueryResult> { + benchmark_alias::table + .filter(benchmark_alias::benchmark_id.eq(benchmark_id)) + .order(benchmark_alias::id.asc()) + .select(benchmark_alias::alias) + .load(conn) +} + +pub fn aliases_by_benchmark_id( + conn: &mut DbConnection, + project_id: ProjectId, + benchmark_ids: &[BenchmarkId], +) -> diesel::QueryResult>> { + if benchmark_ids.is_empty() { + return Ok(HashMap::new()); + } + let rows: Vec<(BenchmarkId, BenchmarkName)> = benchmark_alias::table + .filter(benchmark_alias::project_id.eq(project_id)) + .filter(benchmark_alias::benchmark_id.eq_any(benchmark_ids)) + .order(benchmark_alias::id.asc()) + .select((benchmark_alias::benchmark_id, benchmark_alias::alias)) + .load(conn)?; + + let mut map: HashMap> = HashMap::new(); + for (bid, alias) in rows { + map.entry(bid).or_default().push(alias); + } + Ok(map) +} diff --git a/lib/bencher_schema/src/model/project/benchmark.rs b/lib/bencher_schema/src/model/project/benchmark/mod.rs similarity index 55% rename from lib/bencher_schema/src/model/project/benchmark.rs rename to lib/bencher_schema/src/model/project/benchmark/mod.rs index 79e421b3ab..a7ffc7dd76 100644 --- a/lib/bencher_schema/src/model/project/benchmark.rs +++ b/lib/bencher_schema/src/model/project/benchmark/mod.rs @@ -1,18 +1,24 @@ +mod alias; + +use std::cell::RefCell; + use bencher_json::{ BenchmarkName, BenchmarkNameId, BenchmarkSlug, BenchmarkUuid, DateTime, JsonBenchmark, NameId, project::benchmark::{JsonNewBenchmark, JsonUpdateBenchmark}, }; -use diesel::{Connection as _, ExpressionMethods as _, QueryDsl as _, RunQueryDsl as _}; +use diesel::{ + BoolExpressionMethods as _, Connection as _, ExpressionMethods as _, QueryDsl as _, + RunQueryDsl as _, dsl::exists, +}; use dropshot::HttpError; use super::{ProjectId, QueryProject}; use crate::{ auth_conn, context::{ApiContext, DbConnection}, - error::{BencherResource, assert_parentage, resource_conflict_err}, + error::{BencherResource, assert_parentage, resource_conflict_err, resource_not_found_err}, macros::{ fn_get::{fn_from_uuid, fn_get, fn_get_id, fn_get_uuid}, - name_id::{fn_eq_name_id, fn_from_name_id}, resource_id::{fn_eq_resource_id, fn_from_resource_id}, slug::ok_slug, sql::last_insert_rowid, @@ -21,6 +27,11 @@ use crate::{ write_conn, }; +pub use alias::{ + aliases_by_benchmark_id, list_aliases_for_benchmark, replace_benchmark_aliases, + validate_benchmark_aliases_uniqueness, +}; + crate::macros::typed_id::typed_id!(BenchmarkId); #[derive( @@ -49,14 +60,44 @@ impl QueryBenchmark { BenchmarkResourceId ); - fn_eq_name_id!(ResourceName, benchmark, BenchmarkNameId); - fn_from_name_id!(benchmark, Benchmark, BenchmarkNameId); - fn_get!(benchmark, BenchmarkId); fn_get_id!(benchmark, BenchmarkId, BenchmarkUuid); fn_get_uuid!(benchmark, BenchmarkId, BenchmarkUuid); fn_from_uuid!(project_id, ProjectId, benchmark, BenchmarkUuid, Benchmark); + pub fn from_name_id( + conn: &mut DbConnection, + project_id: ProjectId, + name_id: &BenchmarkNameId, + ) -> Result { + match name_id { + NameId::Uuid(uuid) => schema::benchmark::table + .filter(schema::benchmark::project_id.eq(project_id)) + .filter(schema::benchmark::uuid.eq(uuid.to_string())) + .first::(conn) + .map_err(resource_not_found_err!(Benchmark, (project_id, name_id))), + NameId::Slug(slug) => schema::benchmark::table + .filter(schema::benchmark::project_id.eq(project_id)) + .filter(schema::benchmark::slug.eq(slug.to_string())) + .first::(conn) + .map_err(resource_not_found_err!(Benchmark, (project_id, name_id))), + NameId::Name(name) => { + let primary = name.as_ref().to_owned(); + let alias_match = exists( + schema::benchmark_alias::table + .filter(schema::benchmark_alias::benchmark_id.eq(schema::benchmark::id)) + .filter(schema::benchmark_alias::project_id.eq(project_id)) + .filter(schema::benchmark_alias::alias.eq(primary.clone())), + ); + schema::benchmark::table + .filter(schema::benchmark::project_id.eq(project_id)) + .filter(schema::benchmark::name.eq(primary).or(alias_match)) + .first::(conn) + .map_err(resource_not_found_err!(Benchmark, (project_id, name_id))) + }, + } + } + pub async fn get_or_create( context: &ApiContext, project_id: ProjectId, @@ -95,8 +136,13 @@ impl QueryBenchmark { NameId::Slug(slug) => JsonNewBenchmark { name: slug.clone().into(), slug: Some(slug), + aliases: None, + }, + NameId::Name(name) => JsonNewBenchmark { + name, + slug: None, + aliases: None, }, - NameId::Name(name) => JsonNewBenchmark { name, slug: None }, }; Self::create(context, project_id, json_benchmark).await @@ -110,22 +156,69 @@ impl QueryBenchmark { #[cfg(feature = "plus")] InsertBenchmark::rate_limit(context, project_id).await?; + let JsonNewBenchmark { + name, + slug, + aliases, + } = json_benchmark; + let aliases = aliases.unwrap_or_default(); let insert_benchmark = - InsertBenchmark::from_json(auth_conn!(context), project_id, json_benchmark); + InsertBenchmark::from_json(auth_conn!(context), project_id, name, slug); + let insert_for_err = insert_benchmark.clone(); let conn = write_conn!(context); - conn.transaction(|conn| { + let validation_err: RefCell> = RefCell::new(None); + let query_result = conn.transaction(|conn| -> diesel::QueryResult { + validate_benchmark_aliases_uniqueness( + conn, + project_id, + None, + &insert_benchmark.name, + &aliases, + ) + .map_err(|e| { + *validation_err.borrow_mut() = Some(e); + diesel::result::Error::RollbackTransaction + })?; + diesel::insert_into(schema::benchmark::table) .values(&insert_benchmark) .execute(conn)?; - diesel::select(last_insert_rowid()).get_result(conn) - }) - .map_err(resource_conflict_err!(Benchmark, &insert_benchmark)) - .map(|id| insert_benchmark.into_query(id)) + let id = diesel::select(last_insert_rowid()).get_result::(conn)?; + replace_benchmark_aliases(conn, project_id, id, &aliases)?; + Ok(insert_benchmark.into_query(id)) + }); + + match query_result { + Ok(q) => Ok(q), + Err(e) => { + if let Some(he) = validation_err.into_inner() { + Err(he) + } else { + Err(resource_conflict_err!(Benchmark, &insert_for_err)(e)) + } + }, + } } - pub fn into_json_for_project(self, project: &QueryProject) -> JsonBenchmark { + pub fn into_json_for_project( + self, + conn: &mut DbConnection, + project: &QueryProject, + ) -> Result { + let benchmark_id = self.id; + let aliases = list_aliases_for_benchmark(conn, benchmark_id) + .map_err(resource_not_found_err!(Benchmark, benchmark_id))?; + Ok(self.into_json_for_project_with_aliases(project, aliases)) + } + + pub fn into_json_for_project_with_aliases( + self, + project: &QueryProject, + aliases: Vec, + ) -> JsonBenchmark { let Self { + id: _, uuid, project_id, name, @@ -133,7 +226,6 @@ impl QueryBenchmark { created, modified, archived, - .. } = self; assert_parentage( BencherResource::Project, @@ -146,6 +238,7 @@ impl QueryBenchmark { project: project.uuid, name, slug, + aliases, created, modified, archived, @@ -153,7 +246,7 @@ impl QueryBenchmark { } } -#[derive(Debug, diesel::Insertable)] +#[derive(Debug, Clone, diesel::Insertable)] #[diesel(table_name = benchmark_table)] pub struct InsertBenchmark { pub uuid: BenchmarkUuid, @@ -194,9 +287,9 @@ impl InsertBenchmark { fn from_json( conn: &mut DbConnection, project_id: ProjectId, - benchmark: JsonNewBenchmark, + name: BenchmarkName, + slug: Option, ) -> Self { - let JsonNewBenchmark { name, slug } = benchmark; let slug = ok_slug!(conn, project_id, &name, slug, benchmark, QueryBenchmark); let timestamp = DateTime::now(); Self { @@ -226,6 +319,7 @@ impl From for UpdateBenchmark { name, slug, archived, + aliases: _, } = update; let modified = DateTime::now(); let archived = archived.map(|archived| archived.then_some(modified)); @@ -244,6 +338,7 @@ impl UpdateBenchmark { name: None, slug: None, archived: Some(false), + aliases: None, } .into() } @@ -253,9 +348,9 @@ impl UpdateBenchmark { mod tests { use diesel::{Connection as _, ExpressionMethods as _, QueryDsl as _, RunQueryDsl as _}; - use bencher_json::DateTime; + use bencher_json::{BenchmarkName, BenchmarkNameId, DateTime}; - use super::BenchmarkId; + use super::{BenchmarkId, QueryBenchmark, validate_benchmark_aliases_uniqueness}; use crate::{ macros::sql::last_insert_rowid, schema, @@ -388,6 +483,199 @@ mod tests { assert_eq!(inserted_id, outside_id); } + fn bench_name(s: &str) -> BenchmarkName { + s.parse().expect("benchmark name") + } + + #[test] + fn validate_rejects_duplicate_alias_in_request() { + let mut conn = setup_test_db(); + let base = create_base_entities(&mut conn); + let primary = bench_name("Primary"); + let dup = bench_name("dup"); + assert!( + validate_benchmark_aliases_uniqueness( + &mut conn, + base.project_id, + None, + &primary, + &[dup.clone(), dup.clone()], + ) + .is_err() + ); + } + + #[test] + fn validate_rejects_alias_matching_primary_name() { + let mut conn = setup_test_db(); + let base = create_base_entities(&mut conn); + let primary = bench_name("Primary"); + assert!( + validate_benchmark_aliases_uniqueness( + &mut conn, + base.project_id, + None, + &primary, + std::slice::from_ref(&primary), + ) + .is_err() + ); + } + + #[test] + fn validate_rejects_alias_conflicting_with_other_benchmark_name() { + let mut conn = setup_test_db(); + let base = create_base_entities(&mut conn); + create_benchmark( + &mut conn, + base.project_id, + "00000000-0000-0000-0000-000000000030", + "Other", + "other", + ); + let primary = bench_name("Primary"); + let conflict = bench_name("Other"); + assert!( + validate_benchmark_aliases_uniqueness( + &mut conn, + base.project_id, + None, + &primary, + &[conflict], + ) + .is_err() + ); + } + + #[test] + fn validate_rejects_alias_conflicting_with_other_benchmark_alias() { + let mut conn = setup_test_db(); + let base = create_base_entities(&mut conn); + let other_id = create_benchmark( + &mut conn, + base.project_id, + "00000000-0000-0000-0000-000000000030", + "Other", + "other", + ); + diesel::insert_into(schema::benchmark_alias::table) + .values(( + schema::benchmark_alias::project_id.eq(base.project_id), + schema::benchmark_alias::benchmark_id.eq(other_id), + schema::benchmark_alias::alias.eq("legacy"), + )) + .execute(&mut conn) + .expect("insert alias"); + let primary = bench_name("Primary"); + let conflict = bench_name("legacy"); + assert!( + validate_benchmark_aliases_uniqueness( + &mut conn, + base.project_id, + None, + &primary, + &[conflict], + ) + .is_err() + ); + } + + #[test] + fn validate_allows_exclude_current_benchmark_on_update() { + let mut conn = setup_test_db(); + let base = create_base_entities(&mut conn); + let benchmark_id = create_benchmark( + &mut conn, + base.project_id, + "00000000-0000-0000-0000-000000000020", + "Primary", + "primary", + ); + diesel::insert_into(schema::benchmark_alias::table) + .values(( + schema::benchmark_alias::project_id.eq(base.project_id), + schema::benchmark_alias::benchmark_id.eq(benchmark_id), + schema::benchmark_alias::alias.eq("legacy"), + )) + .execute(&mut conn) + .expect("insert alias"); + let primary = bench_name("Primary"); + let legacy = bench_name("legacy"); + validate_benchmark_aliases_uniqueness( + &mut conn, + base.project_id, + Some(benchmark_id), + &primary, + &[legacy], + ) + .expect("same benchmark may keep its alias"); + } + + #[test] + fn from_name_id_resolves_by_alias() { + let mut conn = setup_test_db(); + let base = create_base_entities(&mut conn); + let benchmark_id = create_benchmark( + &mut conn, + base.project_id, + "00000000-0000-0000-0000-000000000020", + "Primary", + "primary", + ); + diesel::insert_into(schema::benchmark_alias::table) + .values(( + schema::benchmark_alias::project_id.eq(base.project_id), + schema::benchmark_alias::benchmark_id.eq(benchmark_id), + schema::benchmark_alias::alias.eq("legacy::bench"), + )) + .execute(&mut conn) + .expect("Failed to insert benchmark alias"); + + let name_id: BenchmarkNameId = "legacy::bench".parse().expect("parse name id"); + let found = QueryBenchmark::from_name_id(&mut conn, base.project_id, &name_id) + .expect("resolve by alias"); + assert_eq!(found.id, benchmark_id); + assert_eq!(found.name.as_ref(), "Primary"); + } + + #[test] + fn from_name_id_resolves_by_uuid() { + let mut conn = setup_test_db(); + let base = create_base_entities(&mut conn); + let benchmark_id = create_benchmark( + &mut conn, + base.project_id, + "00000000-0000-0000-0000-000000000021", + "UuidBench", + "uuid-bench", + ); + let name_id: BenchmarkNameId = "00000000-0000-0000-0000-000000000021" + .parse() + .expect("parse uuid name id"); + let found = QueryBenchmark::from_name_id(&mut conn, base.project_id, &name_id) + .expect("resolve by uuid"); + assert_eq!(found.id, benchmark_id); + assert_eq!(found.name.as_ref(), "UuidBench"); + } + + #[test] + fn from_name_id_resolves_by_slug() { + let mut conn = setup_test_db(); + let base = create_base_entities(&mut conn); + let benchmark_id = create_benchmark( + &mut conn, + base.project_id, + "00000000-0000-0000-0000-000000000022", + "SlugBench", + "slug-bench", + ); + let name_id: BenchmarkNameId = "slug-bench".parse().expect("parse slug name id"); + let found = QueryBenchmark::from_name_id(&mut conn, base.project_id, &name_id) + .expect("resolve by slug"); + assert_eq!(found.id, benchmark_id); + assert_eq!(found.name.as_ref(), "SlugBench"); + } + #[test] fn benchmark_unarchive() { let mut conn = setup_test_db(); diff --git a/lib/bencher_schema/src/model/project/report/mod.rs b/lib/bencher_schema/src/model/project/report/mod.rs index a4bf0a53f5..a1d0a06ac7 100644 --- a/lib/bencher_schema/src/model/project/report/mod.rs +++ b/lib/bencher_schema/src/model/project/report/mod.rs @@ -1,7 +1,9 @@ +use std::collections::{HashMap, HashSet}; + #[cfg(feature = "plus")] use bencher_json::runner::job::{JobUuid, JsonNewRunJob}; use bencher_json::{ - DateTime, JsonNewReport, JsonReport, ReportUuid, + BenchmarkName, DateTime, JsonNewReport, JsonReport, ReportUuid, project::report::{ Adapter, Iteration, JsonReportAlerts, JsonReportMeasure, JsonReportResult, JsonReportResults, JsonReportSettings, ReportIdempotencyKey, @@ -33,7 +35,7 @@ use crate::{ model::{ project::{ ProjectId, QueryProject, - benchmark::QueryBenchmark, + benchmark::{BenchmarkId, QueryBenchmark, aliases_by_benchmark_id}, branch::version::{InsertVersion, QueryVersion}, measure::QueryMeasure, testbed::{QueryTestbed, ResolvedTestbed, TestbedId}, @@ -529,7 +531,7 @@ fn get_report_results( project: &QueryProject, report_id: ReportId, ) -> Result { - schema::report_benchmark::table + let results = schema::report_benchmark::table .filter(schema::report_benchmark::report_id.eq(report_id)) .inner_join(schema::benchmark::table) .inner_join(view::metric_boundary::table @@ -574,14 +576,21 @@ fn get_report_results( ).nullable(), )) .load::(conn) - .map(|results| into_report_results_json(log, project, results)) - .map_err(resource_not_found_err!(ReportBenchmark, project)) + .map_err(resource_not_found_err!(ReportBenchmark, project))?; + + let id_set: HashSet = results.iter().map(|(_, b, _, _, _)| b.id).collect(); + let ids: Vec = id_set.into_iter().collect(); + let alias_map = aliases_by_benchmark_id(conn, project.id, &ids) + .map_err(resource_not_found_err!(ReportBenchmark, project))?; + + Ok(into_report_results_json(log, project, results, &alias_map)) } fn into_report_results_json( log: &Logger, project: &QueryProject, results: Vec, + alias_map: &HashMap>, ) -> JsonReportResults { let mut report_results = Vec::new(); let mut report_iteration = Vec::new(); @@ -636,9 +645,13 @@ fn into_report_results_json( if let Some(result) = report_result.as_mut() { result.measures.push(report_measure); } else { + let aliases = alias_map + .get(&query_benchmark.id) + .cloned() + .unwrap_or_default(); report_result = Some(JsonReportResult { iteration, - benchmark: query_benchmark.into_json_for_project(project), + benchmark: query_benchmark.into_json_for_project_with_aliases(project, aliases), measures: vec![report_measure], }); } diff --git a/lib/bencher_schema/src/model/project/threshold/alert.rs b/lib/bencher_schema/src/model/project/threshold/alert.rs index 54503dde2d..b44493f78c 100644 --- a/lib/bencher_schema/src/model/project/threshold/alert.rs +++ b/lib/bencher_schema/src/model/project/threshold/alert.rs @@ -173,7 +173,7 @@ impl QueryAlert { uuid, report: report_uuid, iteration, - benchmark: query_benchmark.into_json_for_project(project), + benchmark: query_benchmark.into_json_for_project(conn, project)?, metric: query_metric.into_json(), threshold, boundary: query_boundary.into_json(), diff --git a/lib/bencher_schema/src/model/runner/job.rs b/lib/bencher_schema/src/model/runner/job.rs index 0ef6b91f7b..e5147dbab5 100644 --- a/lib/bencher_schema/src/model/runner/job.rs +++ b/lib/bencher_schema/src/model/runner/job.rs @@ -147,7 +147,7 @@ impl QueryJob { query_report.adapter, settings, plan_kind, - #[cfg(feature = "otel")] + #[cfg(all(feature = "plus", feature = "otel"))] self.priority, &query_project, ) diff --git a/lib/bencher_schema/src/schema.rs b/lib/bencher_schema/src/schema.rs index a9fe4eda07..f5e942835b 100644 --- a/lib/bencher_schema/src/schema.rs +++ b/lib/bencher_schema/src/schema.rs @@ -24,6 +24,15 @@ diesel::table! { } } +diesel::table! { + benchmark_alias (id) { + id -> Integer, + project_id -> Integer, + benchmark_id -> Integer, + alias -> Text, + } +} + diesel::table! { boundary (id) { id -> Integer, @@ -415,6 +424,8 @@ diesel::table! { diesel::joinable!(alert -> boundary (boundary_id)); diesel::joinable!(benchmark -> project (project_id)); +diesel::joinable!(benchmark_alias -> benchmark (benchmark_id)); +diesel::joinable!(benchmark_alias -> project (project_id)); diesel::joinable!(boundary -> metric (metric_id)); diesel::joinable!(boundary -> model (model_id)); diesel::joinable!(boundary -> threshold (threshold_id)); @@ -466,6 +477,7 @@ diesel::joinable!(version -> project (project_id)); diesel::allow_tables_to_appear_in_same_query!( alert, benchmark, + benchmark_alias, boundary, branch, head, diff --git a/plus/bencher_runner/src/up/mod.rs b/plus/bencher_runner/src/up/mod.rs index ad0e196d2e..8473b9c2c1 100644 --- a/plus/bencher_runner/src/up/mod.rs +++ b/plus/bencher_runner/src/up/mod.rs @@ -343,12 +343,22 @@ fn install_signal_handlers() { /// Uses `libc::signal()` directly since `nix` is not available on macOS. #[cfg(not(target_os = "linux"))] fn install_signal_handlers() { + #[expect( + unsafe_code, + clippy::fn_to_numeric_cast_any, + reason = "libc::signal expects sighandler_t; double cast satisfies function-casts-as-integer" + )] // SAFETY: `signal_handler` only performs `AtomicBool::store` with // `Ordering::SeqCst`, which is async-signal-safe per POSIX. - #[expect(unsafe_code, clippy::fn_to_numeric_cast_any)] unsafe { - libc::signal(libc::SIGINT, signal_handler as libc::sighandler_t); - libc::signal(libc::SIGTERM, signal_handler as libc::sighandler_t); + libc::signal( + libc::SIGINT, + signal_handler as *const () as libc::sighandler_t, + ); + libc::signal( + libc::SIGTERM, + signal_handler as *const () as libc::sighandler_t, + ); } } diff --git a/services/api/openapi.json b/services/api/openapi.json index 61378251e5..52913a0551 100644 --- a/services/api/openapi.json +++ b/services/api/openapi.json @@ -11086,6 +11086,14 @@ "JsonBenchmark": { "type": "object", "properties": { + "aliases": { + "description": "Additional exact-match strings (the primary [`Self::name`] is always a matcher).", + "default": [], + "type": "array", + "items": { + "$ref": "#/components/schemas/BenchmarkName" + } + }, "archived": { "nullable": true, "allOf": [ @@ -12225,6 +12233,15 @@ "JsonNewBenchmark": { "type": "object", "properties": { + "aliases": { + "nullable": true, + "description": "Additional exact-match strings for this benchmark within the project.", + "default": null, + "type": "array", + "items": { + "$ref": "#/components/schemas/BenchmarkName" + } + }, "name": { "description": "The name of the benchmark. Maximum length is 1,024 characters.", "allOf": [ @@ -15912,6 +15929,14 @@ "JsonUpdateBenchmark": { "type": "object", "properties": { + "aliases": { + "nullable": true, + "description": "When set, replaces all additional aliases for this benchmark (full replace).", + "type": "array", + "items": { + "$ref": "#/components/schemas/BenchmarkName" + } + }, "archived": { "nullable": true, "description": "Set whether the benchmark is archived.", diff --git a/services/cli/src/bencher/sub/project/archive/dimension.rs b/services/cli/src/bencher/sub/project/archive/dimension.rs index 9dada1828e..2eb799e964 100644 --- a/services/cli/src/bencher/sub/project/archive/dimension.rs +++ b/services/cli/src/bencher/sub/project/archive/dimension.rs @@ -220,6 +220,7 @@ impl Dimension { name: None, slug: None, archived: Some(action.into()), + aliases: None, }; backend .send(|client| async move { diff --git a/services/cli/src/bencher/sub/project/benchmark/create.rs b/services/cli/src/bencher/sub/project/benchmark/create.rs index b9ae458e84..292734bca2 100644 --- a/services/cli/src/bencher/sub/project/benchmark/create.rs +++ b/services/cli/src/bencher/sub/project/benchmark/create.rs @@ -1,4 +1,4 @@ -use bencher_client::types::JsonNewBenchmark; +use bencher_client::types::{BenchmarkName as ClientBenchmarkName, JsonNewBenchmark}; use bencher_json::{BenchmarkName, BenchmarkSlug, ProjectResourceId}; use crate::{ @@ -12,6 +12,7 @@ pub struct Create { pub project: ProjectResourceId, pub name: BenchmarkName, pub slug: Option, + pub alias: Option>, pub backend: AuthBackend, } @@ -23,12 +24,14 @@ impl TryFrom for Create { project, name, slug, + alias, backend, } = create; Ok(Self { project, name, slug, + alias, backend: backend.try_into()?, }) } @@ -36,10 +39,24 @@ impl TryFrom for Create { impl From for JsonNewBenchmark { fn from(create: Create) -> Self { - let Create { name, slug, .. } = create; + let Create { + name, slug, alias, .. + } = create; Self { name: name.into(), slug: slug.map(Into::into), + aliases: alias.and_then(|values| { + if values.is_empty() { + None + } else { + Some( + values + .into_iter() + .map(|a| ClientBenchmarkName(String::from(a.as_ref()))) + .collect(), + ) + } + }), } } } diff --git a/services/cli/src/bencher/sub/project/benchmark/update.rs b/services/cli/src/bencher/sub/project/benchmark/update.rs index 441274fe61..a24aaec270 100644 --- a/services/cli/src/bencher/sub/project/benchmark/update.rs +++ b/services/cli/src/bencher/sub/project/benchmark/update.rs @@ -1,4 +1,4 @@ -use bencher_client::types::JsonUpdateBenchmark; +use bencher_client::types::{BenchmarkName as ClientBenchmarkName, JsonUpdateBenchmark}; use bencher_json::{BenchmarkName, BenchmarkResourceId, BenchmarkSlug, ProjectResourceId}; use crate::{ @@ -14,6 +14,7 @@ pub struct Update { pub name: Option, pub slug: Option, pub archived: Option, + pub alias: Option>, pub backend: AuthBackend, } @@ -26,6 +27,7 @@ impl TryFrom for Update { benchmark, name, slug, + alias, archived, backend, } = create; @@ -35,6 +37,7 @@ impl TryFrom for Update { name, slug, archived: archived.into(), + alias, backend: backend.try_into()?, }) } @@ -46,12 +49,25 @@ impl From for JsonUpdateBenchmark { name, slug, archived, + alias, .. } = update; Self { name: name.map(Into::into), slug: slug.map(Into::into), archived, + aliases: alias.and_then(|values| { + if values.is_empty() { + None + } else { + Some( + values + .into_iter() + .map(|a| ClientBenchmarkName(String::from(a.as_ref()))) + .collect(), + ) + } + }), } } } diff --git a/services/cli/src/parser/project/benchmark.rs b/services/cli/src/parser/project/benchmark.rs index f7f0ff78ff..d1738d66d5 100644 --- a/services/cli/src/parser/project/benchmark.rs +++ b/services/cli/src/parser/project/benchmark.rs @@ -66,6 +66,10 @@ pub struct CliBenchmarkCreate { #[clap(long)] pub slug: Option, + /// Additional exact-match alias (repeat for multiple) + #[clap(long = "alias")] + pub alias: Option>, + #[clap(flatten)] pub backend: CliBackend, } @@ -98,6 +102,10 @@ pub struct CliBenchmarkUpdate { #[clap(long)] pub slug: Option, + /// Replace additional aliases (repeat for multiple). Omit to leave aliases unchanged. + #[clap(long = "alias")] + pub alias: Option>, + #[clap(flatten)] pub archived: CliArchived, diff --git a/services/console/src/types/bencher.ts b/services/console/src/types/bencher.ts index 66c6407553..427cdc5e7f 100644 --- a/services/console/src/types/bencher.ts +++ b/services/console/src/types/bencher.ts @@ -204,6 +204,8 @@ export interface JsonBenchmark { project: Uuid; name: BenchmarkName; slug: Slug; + /** Additional exact-match strings (the primary [`Self::name`] is always a matcher). */ + aliases?: BenchmarkName[]; created: string; modified: string; archived?: string;