Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions lib/api_projects/src/reports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ async fn post_inner(
)?;
let new_run_report = NewRunReport {
report: json_report,
idempotency_key: None,
#[cfg(feature = "plus")]
is_claimed: true,
#[cfg(feature = "plus")]
Expand Down
1 change: 1 addition & 0 deletions lib/api_run/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ diesel.workspace = true
http.workspace = true
serde_json.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
uuid.workspace = true

[lints]
workspace = true
2 changes: 2 additions & 0 deletions lib/api_run/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ use http as _;
use serde_json as _;
#[cfg(test)]
use tokio as _;
#[cfg(test)]
use uuid as _;

mod run;

Expand Down
5 changes: 4 additions & 1 deletion lib/api_run/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ async fn post_inner(
context: &ApiContext,
public_user: &PublicUser,
#[cfg(feature = "plus")] headers: &http::HeaderMap,
#[cfg_attr(not(feature = "plus"), expect(unused_mut))] mut json_run: JsonNewRun,
mut json_run: JsonNewRun,
) -> Result<JsonReport, HttpError> {
match public_user {
PublicUser::Public(remote_ip) => {
Expand Down Expand Up @@ -165,8 +165,11 @@ async fn post_inner(

slog::info!(log, "New run requested"; "project" => ?query_project, "run" => ?json_run);

let idempotency_key = json_run.idempotency_key.take();

let new_run_report = NewRunReport {
report: json_run.into(),
idempotency_key,
#[cfg(feature = "plus")]
is_claimed,
#[cfg(feature = "plus")]
Expand Down
175 changes: 175 additions & 0 deletions lib/api_run/tests/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1672,3 +1672,178 @@ async fn run_post_with_job_nonexistent_tag_fails() {
// OCI digest resolution should fail for nonexistent tag
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

// POST /v0/run - idempotency key returns same report on retry
#[tokio::test]
async fn run_post_idempotency_key_returns_same_report() {
let server = TestServer::new().await;
let user = server.signup("Test User", "idempotency@example.com").await;
let org = server.create_org(&user, "Idempotency Org").await;
let project = server
.create_project(&user, &org, "Idempotency Project")
.await;

let project_slug: &str = project.slug.as_ref();
let idempotency_key = uuid::Uuid::new_v4().to_string();
let body = serde_json::json!({
"project": project_slug,
"idempotency_key": idempotency_key,
"branch": "main",
"testbed": "localhost",
"start_time": "2024-01-01T00:00:00Z",
"end_time": "2024-01-01T00:01:00Z",
"results": [bmf_results().to_string()]
});

// First submission
let resp1 = server
.client
.post(server.api_url("/v0/run"))
.header(
bencher_json::AUTHORIZATION,
bencher_json::bearer_header(&user.token),
)
.json(&body)
.send()
.await
.expect("First request failed");
assert_eq!(resp1.status(), StatusCode::CREATED);
let report1: JsonReport = resp1.json().await.expect("Failed to parse first response");

// Second submission with same idempotency key returns same report
let resp2 = server
.client
.post(server.api_url("/v0/run"))
.header(
bencher_json::AUTHORIZATION,
bencher_json::bearer_header(&user.token),
)
.json(&body)
.send()
.await
.expect("Second request failed");
assert_eq!(resp2.status(), StatusCode::CREATED);
let report2: JsonReport = resp2.json().await.expect("Failed to parse second response");

assert_eq!(report1.uuid, report2.uuid);
}

// POST /v0/run - different idempotency keys create different reports
#[tokio::test]
async fn run_post_different_idempotency_keys_create_different_reports() {
let server = TestServer::new().await;
let user = server
.signup("Test User", "idempotency_diff@example.com")
.await;
let org = server.create_org(&user, "Idempotency Diff Org").await;
let project = server
.create_project(&user, &org, "Idempotency Diff Project")
.await;

let project_slug: &str = project.slug.as_ref();
let bmf = bmf_results().to_string();

let body1 = serde_json::json!({
"project": project_slug,
"idempotency_key": uuid::Uuid::new_v4().to_string(),
"branch": "main",
"testbed": "localhost",
"start_time": "2024-01-01T00:00:00Z",
"end_time": "2024-01-01T00:01:00Z",
"results": [bmf]
});

let body2 = serde_json::json!({
"project": project_slug,
"idempotency_key": uuid::Uuid::new_v4().to_string(),
"branch": "main",
"testbed": "localhost",
"start_time": "2024-01-01T00:00:00Z",
"end_time": "2024-01-01T00:01:00Z",
"results": [bmf]
});

let resp1 = server
.client
.post(server.api_url("/v0/run"))
.header(
bencher_json::AUTHORIZATION,
bencher_json::bearer_header(&user.token),
)
.json(&body1)
.send()
.await
.expect("First request failed");
assert_eq!(resp1.status(), StatusCode::CREATED);
let report1: JsonReport = resp1.json().await.expect("Failed to parse first response");

let resp2 = server
.client
.post(server.api_url("/v0/run"))
.header(
bencher_json::AUTHORIZATION,
bencher_json::bearer_header(&user.token),
)
.json(&body2)
.send()
.await
.expect("Second request failed");
assert_eq!(resp2.status(), StatusCode::CREATED);
let report2: JsonReport = resp2.json().await.expect("Failed to parse second response");

assert_ne!(report1.uuid, report2.uuid);
}

// POST /v0/run - no idempotency key always creates new reports
#[tokio::test]
async fn run_post_no_idempotency_key_creates_new_reports() {
let server = TestServer::new().await;
let user = server
.signup("Test User", "no_idempotency@example.com")
.await;
let org = server.create_org(&user, "No Idempotency Org").await;
let project = server
.create_project(&user, &org, "No Idempotency Project")
.await;

let project_slug: &str = project.slug.as_ref();
let body = serde_json::json!({
"project": project_slug,
"branch": "main",
"testbed": "localhost",
"start_time": "2024-01-01T00:00:00Z",
"end_time": "2024-01-01T00:01:00Z",
"results": [bmf_results().to_string()]
});

let resp1 = server
.client
.post(server.api_url("/v0/run"))
.header(
bencher_json::AUTHORIZATION,
bencher_json::bearer_header(&user.token),
)
.json(&body)
.send()
.await
.expect("First request failed");
assert_eq!(resp1.status(), StatusCode::CREATED);
let report1: JsonReport = resp1.json().await.expect("Failed to parse first response");

let resp2 = server
.client
.post(server.api_url("/v0/run"))
.header(
bencher_json::AUTHORIZATION,
bencher_json::bearer_header(&user.token),
)
.json(&body)
.send()
.await
.expect("Second request failed");
assert_eq!(resp2.status(), StatusCode::CREATED);
let report2: JsonReport = resp2.json().await.expect("Failed to parse second response");

// Without idempotency key, each submission creates a new report
assert_ne!(report1.uuid, report2.uuid);
}
1 change: 1 addition & 0 deletions lib/bencher_json/src/project/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use crate::{
use super::{branch::JsonUpdateStartPoint, threshold::JsonThresholdModel};

crate::typed_uuid::typed_uuid!(ReportUuid);
crate::typed_uuid::typed_uuid!(ReportIdempotencyKey);

#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
Expand Down
8 changes: 7 additions & 1 deletion lib/bencher_json/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use crate::{
BranchNameId, ProjectResourceId, TestbedNameId,
project::{
branch::JsonUpdateStartPoint,
report::{JsonReportSettings, JsonReportThresholds},
report::{JsonReportSettings, JsonReportThresholds, ReportIdempotencyKey},
},
};

Expand All @@ -27,6 +27,11 @@ pub struct JsonNewRun {
/// Project UUID or slug.
/// If the project is not provided or does not exist, it will be created.
pub project: Option<ProjectResourceId>,
/// Optional idempotency key for deduplicating run submissions.
/// If provided, a duplicate submission with the same key within the same project
/// will return the existing report instead of creating a new one.
#[serde(skip_serializing_if = "Option::is_none")]
pub idempotency_key: Option<ReportIdempotencyKey>,
/// Branch UUID, slug, or name.
/// If the branch is not provided or does not exist, it will be created.
pub branch: Option<BranchNameId>,
Expand Down Expand Up @@ -78,6 +83,7 @@ impl From<JsonNewRun> for JsonNewReport {
fn from(run: JsonNewRun) -> Self {
let JsonNewRun {
project: _,
idempotency_key: _,
branch,
hash,
start_point,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
PRAGMA foreign_keys = off;

DROP INDEX IF EXISTS index_report_idempotency_key;

CREATE TABLE down_report (
id INTEGER PRIMARY KEY NOT NULL,
uuid TEXT NOT NULL UNIQUE,
user_id INTEGER,
project_id INTEGER NOT NULL,
head_id INTEGER NOT NULL,
version_id INTEGER NOT NULL,
testbed_id INTEGER NOT NULL,
spec_id INTEGER,
adapter INTEGER NOT NULL,
start_time BIGINT NOT NULL,
end_time BIGINT NOT NULL,
created BIGINT NOT NULL,
FOREIGN KEY (user_id) REFERENCES user (id),
FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE,
FOREIGN KEY (head_id) REFERENCES head (id),
FOREIGN KEY (version_id) REFERENCES version (id),
FOREIGN KEY (testbed_id) REFERENCES testbed (id),
FOREIGN KEY (spec_id) REFERENCES spec (id) ON DELETE SET NULL
);

INSERT INTO down_report(
id, uuid, user_id, project_id, head_id,
version_id, testbed_id, spec_id, adapter, start_time, end_time, created
)
SELECT id, uuid, user_id, project_id, head_id,
version_id, testbed_id, spec_id, adapter, start_time, end_time, created
FROM report;

DROP TABLE report;
ALTER TABLE down_report RENAME TO report;

CREATE INDEX index_report_testbed_end_time ON report(testbed_id, end_time);
CREATE INDEX index_report_version ON report(version_id, end_time);
CREATE INDEX index_report_project_end_time ON report(project_id, end_time);
CREATE INDEX index_report_project_created ON report(project_id, created);
CREATE INDEX index_report_version_testbed ON report(version_id, testbed_id);
CREATE INDEX index_report_spec ON report(spec_id);
CREATE INDEX index_report_head ON report(head_id);

PRAGMA foreign_keys = on;
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
PRAGMA foreign_keys = off;

CREATE TABLE up_report (
id INTEGER PRIMARY KEY NOT NULL,
uuid TEXT NOT NULL UNIQUE,
idempotency_key TEXT,
user_id INTEGER,
project_id INTEGER NOT NULL,
head_id INTEGER NOT NULL,
version_id INTEGER NOT NULL,
testbed_id INTEGER NOT NULL,
spec_id INTEGER,
adapter INTEGER NOT NULL,
start_time BIGINT NOT NULL,
end_time BIGINT NOT NULL,
created BIGINT NOT NULL,
FOREIGN KEY (user_id) REFERENCES user (id),
FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE,
FOREIGN KEY (head_id) REFERENCES head (id),
FOREIGN KEY (version_id) REFERENCES version (id),
FOREIGN KEY (testbed_id) REFERENCES testbed (id),
FOREIGN KEY (spec_id) REFERENCES spec (id) ON DELETE SET NULL
);

INSERT INTO up_report(
id, uuid, idempotency_key, user_id, project_id, head_id,
version_id, testbed_id, spec_id, adapter, start_time, end_time, created
)
SELECT id, uuid, null, user_id, project_id, head_id,
version_id, testbed_id, spec_id, adapter, start_time, end_time, created
FROM report;

DROP TABLE report;
ALTER TABLE up_report RENAME TO report;

-- Recreate all existing indexes
CREATE INDEX index_report_testbed_end_time ON report(testbed_id, end_time);
CREATE INDEX index_report_version ON report(version_id, end_time);
CREATE INDEX index_report_project_end_time ON report(project_id, end_time);
CREATE INDEX index_report_project_created ON report(project_id, created);
CREATE INDEX index_report_version_testbed ON report(version_id, testbed_id);
CREATE INDEX index_report_spec ON report(spec_id);
CREATE INDEX index_report_head ON report(head_id);
-- New partial unique index for idempotency
CREATE UNIQUE INDEX index_report_idempotency_key
ON report(project_id, idempotency_key)
WHERE idempotency_key IS NOT NULL;

PRAGMA foreign_keys = on;
Loading
Loading