From 4b61e32752247eed4301265b5e6c1e38306559f2 Mon Sep 17 00:00:00 2001 From: Louisvranderick <73151698+Louisvranderick@users.noreply.github.com> Date: Thu, 26 Mar 2026 21:37:57 -0400 Subject: [PATCH 1/4] Add benchmark aliases for multiple exact-match identifiers Add a `benchmark_alias` table so benchmarks can be resolved by alternative names in addition to their primary name, slug, or UUID. This lets users rename benchmarks over time without losing continuity. - New `benchmark_alias` migration with UNIQUE(project_id, alias) and cascading deletes - Split `benchmark.rs` into `benchmark/mod.rs` + `benchmark/alias.rs` - Single-query name resolution via OR + EXISTS in `from_name_id` - TOCTOU-safe alias validation inside write transactions - Batched alias loading to avoid N+1 in list and report endpoints - CLI `--alias` flag on `benchmark create` and `benchmark update` - Generated OpenAPI spec and TypeScript types updated Closes #674 Made-with: Cursor --- lib/api_projects/src/benchmarks.rs | 132 ++++++-- lib/api_projects/src/metrics.rs | 2 +- lib/api_projects/src/perf/mod.rs | 2 +- lib/bencher_json/src/project/benchmark.rs | 8 + .../down.sql | 2 + .../2026-03-24-140000_benchmark_alias/up.sql | 10 + lib/bencher_schema/src/error.rs | 2 + .../src/model/project/benchmark/alias.rs | 163 ++++++++++ .../{benchmark.rs => benchmark/mod.rs} | 283 ++++++++++++++++-- .../src/model/project/report/mod.rs | 25 +- .../src/model/project/threshold/alert.rs | 2 +- lib/bencher_schema/src/schema.rs | 12 + services/api/openapi.json | 25 ++ .../bencher/sub/project/archive/dimension.rs | 1 + .../bencher/sub/project/benchmark/create.rs | 22 +- .../bencher/sub/project/benchmark/update.rs | 16 +- services/cli/src/parser/project/benchmark.rs | 8 + services/console/src/types/bencher.ts | 2 + 18 files changed, 665 insertions(+), 52 deletions(-) create mode 100644 lib/bencher_schema/migrations/2026-03-24-140000_benchmark_alias/down.sql create mode 100644 lib/bencher_schema/migrations/2026-03-24-140000_benchmark_alias/up.sql create mode 100644 lib/bencher_schema/src/model/project/benchmark/alias.rs rename lib/bencher_schema/src/model/project/{benchmark.rs => benchmark/mod.rs} (60%) diff --git a/lib/api_projects/src/benchmarks.rs b/lib/api_projects/src/benchmarks.rs index 9e29b7d400..4d8e2cd38c 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,10 +136,21 @@ 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) @@ -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_json/src/project/benchmark.rs b/lib/bencher_json/src/project/benchmark.rs index f34988685f..e1d7e4211c 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)] @@ -48,6 +51,9 @@ pub struct JsonBenchmark { pub created: DateTime, pub modified: DateTime, pub archived: Option, + /// Additional exact-match strings (the primary [`Self::name`] is always a matcher). + #[serde(default)] + pub aliases: Vec, } impl fmt::Display for JsonBenchmark { @@ -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 60% rename from lib/bencher_schema/src/model/project/benchmark.rs rename to lib/bencher_schema/src/model/project/benchmark/mod.rs index 79e421b3ab..1100f050c5 100644 --- a/lib/bencher_schema/src/model/project/benchmark.rs +++ b/lib/bencher_schema/src/model/project/benchmark/mod.rs @@ -1,18 +1,25 @@ +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}, + name_id::fn_eq_name_id, resource_id::{fn_eq_resource_id, fn_from_resource_id}, slug::ok_slug, sql::last_insert_rowid, @@ -21,6 +28,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( @@ -50,13 +62,39 @@ impl QueryBenchmark { ); 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(_) | NameId::Slug(_) => schema::benchmark::table + .filter(schema::benchmark::project_id.eq(project_id)) + .filter(Self::eq_name_id(name_id)) + .first::(conn) + .map_err(resource_not_found_err!(Benchmark, (project_id, name_id))), + NameId::Name(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(project_id)) + .filter(schema::benchmark_alias::alias.eq(name.as_ref())), + ); + schema::benchmark::table + .filter(schema::benchmark::project_id.eq(project_id)) + .filter(schema::benchmark::name.eq(name.as_ref()).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 +133,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 +153,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, + 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(self, project: &QueryProject) -> JsonBenchmark { + pub fn into_json_for_project_with_aliases( + self, + project: &QueryProject, + aliases: Vec, + ) -> JsonBenchmark { let Self { + id: _, uuid, project_id, name, @@ -133,7 +223,6 @@ impl QueryBenchmark { created, modified, archived, - .. } = self; assert_parentage( BencherResource::Project, @@ -149,11 +238,12 @@ impl QueryBenchmark { created, modified, archived, + aliases, } } } -#[derive(Debug, diesel::Insertable)] +#[derive(Debug, Clone, diesel::Insertable)] #[diesel(table_name = benchmark_table)] pub struct InsertBenchmark { pub uuid: BenchmarkUuid, @@ -194,9 +284,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 +316,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 +335,7 @@ impl UpdateBenchmark { name: None, slug: None, archived: Some(false), + aliases: None, } .into() } @@ -253,9 +345,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 +480,161 @@ 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 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 50c7902d03..3a97c1f8d9 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, @@ -34,7 +36,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}, @@ -491,7 +493,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 @@ -536,14 +538,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(); @@ -598,9 +607,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/schema.rs b/lib/bencher_schema/src/schema.rs index 33363297b2..a1fbd5a121 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, @@ -414,6 +423,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)); @@ -465,6 +476,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/services/api/openapi.json b/services/api/openapi.json index 856e5c7407..0ffe3346c6 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": [ @@ -15903,6 +15920,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..4c22853f59 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 aliases: Vec, pub backend: AuthBackend, } @@ -23,12 +24,14 @@ impl TryFrom for Create { project, name, slug, + aliases, backend, } = create; Ok(Self { project, name, slug, + aliases, backend: backend.try_into()?, }) } @@ -36,10 +39,25 @@ impl TryFrom for Create { impl From for JsonNewBenchmark { fn from(create: Create) -> Self { - let Create { name, slug, .. } = create; + let Create { + name, + slug, + aliases, + .. + } = create; Self { name: name.into(), slug: slug.map(Into::into), + aliases: if aliases.is_empty() { + None + } else { + Some( + aliases + .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..9952eea87b 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 aliases: Vec, pub backend: AuthBackend, } @@ -26,6 +27,7 @@ impl TryFrom for Update { benchmark, name, slug, + aliases, archived, backend, } = create; @@ -35,6 +37,7 @@ impl TryFrom for Update { name, slug, archived: archived.into(), + aliases, backend: backend.try_into()?, }) } @@ -46,12 +49,23 @@ impl From for JsonUpdateBenchmark { name, slug, archived, + aliases, .. } = update; Self { name: name.map(Into::into), slug: slug.map(Into::into), archived, + aliases: if aliases.is_empty() { + None + } else { + Some( + aliases + .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..4461e82f54 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 aliases: Vec, + #[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 aliases: Vec, + #[clap(flatten)] pub archived: CliArchived, diff --git a/services/console/src/types/bencher.ts b/services/console/src/types/bencher.ts index 66c6407553..ad5cacf79b 100644 --- a/services/console/src/types/bencher.ts +++ b/services/console/src/types/bencher.ts @@ -207,6 +207,8 @@ export interface JsonBenchmark { created: string; modified: string; archived?: string; + /** Additional exact-match strings (the primary [`Self::name`] is always a matcher). */ + aliases?: BenchmarkName[]; } export interface JsonMetric { From e680a56bfb4ead039d08149d7641996081d08c05 Mon Sep 17 00:00:00 2001 From: Louisvranderick <73151698+Louisvranderick@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:02:07 -0400 Subject: [PATCH 2/4] fix(benchmark-alias): CLI alias field, JsonBenchmark order, cfg fixes - CLI: singular alias with Option>; preserve wire JSON - Reorder JsonBenchmark (aliases after slug); regenerate bencher.ts - Refactor QueryBenchmark::from_name_id; fix plus report/job priority handling - Fix benchmark list total_count error mapping; batch alias loading for GET list Made-with: Cursor --- lib/api_projects/src/benchmarks.rs | 2 +- lib/bencher_json/src/project/benchmark.rs | 6 +- .../src/model/project/benchmark/mod.rs | 57 ++++++++++++++++--- .../src/model/project/report/mod.rs | 10 ++-- lib/bencher_schema/src/model/runner/job.rs | 1 + .../bencher/sub/project/benchmark/create.rs | 33 ++++++----- .../bencher/sub/project/benchmark/update.rs | 30 +++++----- services/cli/src/parser/project/benchmark.rs | 4 +- services/console/src/types/bencher.ts | 4 +- 9 files changed, 96 insertions(+), 51 deletions(-) diff --git a/lib/api_projects/src/benchmarks.rs b/lib/api_projects/src/benchmarks.rs index 4d8e2cd38c..6585d6a5db 100644 --- a/lib/api_projects/src/benchmarks.rs +++ b/lib/api_projects/src/benchmarks.rs @@ -157,7 +157,7 @@ async fn get_ls_inner( .count() .get_result::(public_conn!(context, public_user)) .map_err(resource_not_found_err!( - Plot, + Benchmark, (&query_project, &pagination_params, &query_params) ))? .try_into()?; diff --git a/lib/bencher_json/src/project/benchmark.rs b/lib/bencher_json/src/project/benchmark.rs index e1d7e4211c..3878fc3049 100644 --- a/lib/bencher_json/src/project/benchmark.rs +++ b/lib/bencher_json/src/project/benchmark.rs @@ -48,12 +48,12 @@ pub struct JsonBenchmark { pub project: ProjectUuid, pub name: BenchmarkName, pub slug: BenchmarkSlug, - pub created: DateTime, - pub modified: DateTime, - pub archived: Option, /// 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, } impl fmt::Display for JsonBenchmark { diff --git a/lib/bencher_schema/src/model/project/benchmark/mod.rs b/lib/bencher_schema/src/model/project/benchmark/mod.rs index 1100f050c5..a7ffc7dd76 100644 --- a/lib/bencher_schema/src/model/project/benchmark/mod.rs +++ b/lib/bencher_schema/src/model/project/benchmark/mod.rs @@ -19,7 +19,6 @@ use crate::{ 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, resource_id::{fn_eq_resource_id, fn_from_resource_id}, slug::ok_slug, sql::last_insert_rowid, @@ -61,8 +60,6 @@ impl QueryBenchmark { BenchmarkResourceId ); - fn_eq_name_id!(ResourceName, benchmark, BenchmarkNameId); - fn_get!(benchmark, BenchmarkId); fn_get_id!(benchmark, BenchmarkId, BenchmarkUuid); fn_get_uuid!(benchmark, BenchmarkId, BenchmarkUuid); @@ -74,21 +71,27 @@ impl QueryBenchmark { name_id: &BenchmarkNameId, ) -> Result { match name_id { - NameId::Uuid(_) | NameId::Slug(_) => schema::benchmark::table + 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(Self::eq_name_id(name_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(name.as_ref())), + .filter(schema::benchmark_alias::alias.eq(primary.clone())), ); schema::benchmark::table .filter(schema::benchmark::project_id.eq(project_id)) - .filter(schema::benchmark::name.eq(name.as_ref()).or(alias_match)) + .filter(schema::benchmark::name.eq(primary).or(alias_match)) .first::(conn) .map_err(resource_not_found_err!(Benchmark, (project_id, name_id))) }, @@ -235,10 +238,10 @@ impl QueryBenchmark { project: project.uuid, name, slug, + aliases, created, modified, archived, - aliases, } } } @@ -635,6 +638,44 @@ mod tests { 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 3a97c1f8d9..51c7c5936f 100644 --- a/lib/bencher_schema/src/model/project/report/mod.rs +++ b/lib/bencher_schema/src/model/project/report/mod.rs @@ -146,8 +146,10 @@ impl QueryReport { let NewRunReport { report: mut json_report, - #[cfg(feature = "plus")] + #[cfg(all(feature = "otel", feature = "plus"))] is_claimed, + #[cfg(all(feature = "plus", not(all(feature = "otel", feature = "plus"))))] + is_claimed: _, #[cfg(feature = "plus")] testbed: run_testbed, #[cfg(feature = "plus")] @@ -156,7 +158,7 @@ impl QueryReport { job: new_run_job, } = new_run_report; - #[cfg(feature = "plus")] + #[cfg(all(feature = "otel", feature = "plus"))] let priority = plan_kind.priority(is_claimed); #[cfg(all(feature = "otel", feature = "plus"))] @@ -335,7 +337,7 @@ impl QueryReport { json_settings, #[cfg(feature = "plus")] plan_kind, - #[cfg(feature = "plus")] + #[cfg(all(feature = "otel", feature = "plus"))] priority, #[cfg(feature = "plus")] query_project, @@ -436,7 +438,7 @@ impl QueryReport { adapter: Adapter, settings: JsonReportSettings, #[cfg(feature = "plus")] plan_kind: PlanKind, - #[cfg(feature = "plus")] priority: bencher_json::Priority, + #[cfg(all(feature = "otel", feature = "plus"))] priority: bencher_json::Priority, #[cfg(feature = "plus")] query_project: &QueryProject, ) -> Result<(), HttpError> { #[cfg(feature = "plus")] diff --git a/lib/bencher_schema/src/model/runner/job.rs b/lib/bencher_schema/src/model/runner/job.rs index 7ec26d6fb7..661479fcd3 100644 --- a/lib/bencher_schema/src/model/runner/job.rs +++ b/lib/bencher_schema/src/model/runner/job.rs @@ -147,6 +147,7 @@ impl QueryJob { query_report.adapter, settings, plan_kind, + #[cfg(all(feature = "otel", feature = "plus"))] self.priority, &query_project, ) diff --git a/services/cli/src/bencher/sub/project/benchmark/create.rs b/services/cli/src/bencher/sub/project/benchmark/create.rs index 4c22853f59..292734bca2 100644 --- a/services/cli/src/bencher/sub/project/benchmark/create.rs +++ b/services/cli/src/bencher/sub/project/benchmark/create.rs @@ -12,7 +12,7 @@ pub struct Create { pub project: ProjectResourceId, pub name: BenchmarkName, pub slug: Option, - pub aliases: Vec, + pub alias: Option>, pub backend: AuthBackend, } @@ -24,14 +24,14 @@ impl TryFrom for Create { project, name, slug, - aliases, + alias, backend, } = create; Ok(Self { project, name, slug, - aliases, + alias, backend: backend.try_into()?, }) } @@ -40,24 +40,23 @@ impl TryFrom for Create { impl From for JsonNewBenchmark { fn from(create: Create) -> Self { let Create { - name, - slug, - aliases, - .. + name, slug, alias, .. } = create; Self { name: name.into(), slug: slug.map(Into::into), - aliases: if aliases.is_empty() { - None - } else { - Some( - aliases - .into_iter() - .map(|a| ClientBenchmarkName(String::from(a.as_ref()))) - .collect(), - ) - }, + 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 9952eea87b..a24aaec270 100644 --- a/services/cli/src/bencher/sub/project/benchmark/update.rs +++ b/services/cli/src/bencher/sub/project/benchmark/update.rs @@ -14,7 +14,7 @@ pub struct Update { pub name: Option, pub slug: Option, pub archived: Option, - pub aliases: Vec, + pub alias: Option>, pub backend: AuthBackend, } @@ -27,7 +27,7 @@ impl TryFrom for Update { benchmark, name, slug, - aliases, + alias, archived, backend, } = create; @@ -37,7 +37,7 @@ impl TryFrom for Update { name, slug, archived: archived.into(), - aliases, + alias, backend: backend.try_into()?, }) } @@ -49,23 +49,25 @@ impl From for JsonUpdateBenchmark { name, slug, archived, - aliases, + alias, .. } = update; Self { name: name.map(Into::into), slug: slug.map(Into::into), archived, - aliases: if aliases.is_empty() { - None - } else { - Some( - aliases - .into_iter() - .map(|a| ClientBenchmarkName(String::from(a.as_ref()))) - .collect(), - ) - }, + 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 4461e82f54..d1738d66d5 100644 --- a/services/cli/src/parser/project/benchmark.rs +++ b/services/cli/src/parser/project/benchmark.rs @@ -68,7 +68,7 @@ pub struct CliBenchmarkCreate { /// Additional exact-match alias (repeat for multiple) #[clap(long = "alias")] - pub aliases: Vec, + pub alias: Option>, #[clap(flatten)] pub backend: CliBackend, @@ -104,7 +104,7 @@ pub struct CliBenchmarkUpdate { /// Replace additional aliases (repeat for multiple). Omit to leave aliases unchanged. #[clap(long = "alias")] - pub aliases: Vec, + 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 ad5cacf79b..427cdc5e7f 100644 --- a/services/console/src/types/bencher.ts +++ b/services/console/src/types/bencher.ts @@ -204,11 +204,11 @@ 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; - /** Additional exact-match strings (the primary [`Self::name`] is always a matcher). */ - aliases?: BenchmarkName[]; } export interface JsonMetric { From 7a67bd3a12c8569d50fc8f443bf60adce45a564b Mon Sep 17 00:00:00 2001 From: Louisvranderick <73151698+Louisvranderick@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:39:17 -0400 Subject: [PATCH 3/4] chore: rustfmt report/mod.rs (fix CI Cargo Format) Made-with: Cursor --- lib/bencher_schema/src/model/project/report/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/bencher_schema/src/model/project/report/mod.rs b/lib/bencher_schema/src/model/project/report/mod.rs index a39be0e42f..a1d0a06ac7 100644 --- a/lib/bencher_schema/src/model/project/report/mod.rs +++ b/lib/bencher_schema/src/model/project/report/mod.rs @@ -151,11 +151,11 @@ impl QueryReport { #[cfg(feature = "plus")] is_claimed, #[cfg(feature = "plus")] - testbed: run_testbed, + testbed: run_testbed, #[cfg(feature = "plus")] spec_reset, #[cfg(feature = "plus")] - job: new_run_job, + job: new_run_job, } = new_run_report; // Idempotency check: if a key is provided, look for an existing report From 1b4d46494fdc370e47d190205371cc62b6c80f95 Mon Sep 17 00:00:00 2001 From: Louisvranderick <73151698+Louisvranderick@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:17:37 -0400 Subject: [PATCH 4/4] fix: satisfy Clippy 1.94 on macOS signal handler and bencher_client - Use pointer double-cast for libc::signal and document unsafe for function-casts-as-integer / fn_to_numeric_cast_any. - Drop stale crate-level expect(multiple_inherent_impl); lint no longer fires. Made-with: Cursor --- lib/bencher_client/src/lib.rs | 2 -- plus/bencher_runner/src/up/mod.rs | 16 +++++++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) 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/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, + ); } }