Skip to content

Commit c60ccf8

Browse files
lvaroquiclaude
andcommitted
feat(upload): split profile archive uploads into concurrent parts
S3 rejects single uploads above 5 GiB, and a single connection to S3 only reaches about 20-25 MiB/s on GitHub-hosted runners, so large profile archives were slow or impossible to upload. Archives of 64 MiB and more are now sent as an S3 multipart upload, in about two parts per concurrent upload, each between 16 MiB and 256 MiB. The md5 of every part is computed in the same pass as the archive md5 and sent in the upload metadata (version 12) as `profileMultipart`. The API then answers with `multipartUploadUrls`: the parts are uploaded 8 at a time (overridable with `CODSPEED_UPLOAD_CONCURRENCY`), each with its own retries, then the upload is completed with the part ETags in order. This applies to both on-disk and in-memory (gzip) archives. Walltime profile folders above 5 GiB are no longer gzipped on disk to fit in a single request, and the maximum archive size goes from 5 GiB to 15 GiB. Archives are now hashed while streaming on the blocking thread pool, instead of being read whole into memory on the async runtime. Closes COD-3700 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 214c040 commit c60ccf8

11 files changed

Lines changed: 895 additions & 193 deletions

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ uuid = { version = "1.23.1", features = ["v4"] }
6969
which = "8.0.2"
7070
crc32fast = "1.5.0"
7171
samply = { path = "crates/samply-codspeed/samply" }
72+
bytes = "1"
7273

7374
# Memory profiling (memtrack) and the capability handling around it are Linux-only.
7475
[target.'cfg(target_os = "linux")'.dependencies]

‎src/run_environment/provider.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,9 @@ pub trait RunEnvironmentProvider {
136136
tokenless: api_client.token().is_none(),
137137
repository_provider: self.get_repository_provider(),
138138
run_environment_metadata,
139-
profile_md5: profile_archive.hash.clone(),
139+
profile_md5: profile_archive.md5.clone(),
140140
profile_encoding: profile_archive.content.encoding(),
141+
profile_multipart: profile_archive.multipart.clone(),
141142
commit_hash,
142143
allow_empty: config.allow_empty,
143144
runner: Runner {

‎src/upload/interfaces.rs‎

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use crate::instruments::InstrumentName;
55
use crate::run_environment::{RepositoryProvider, RunEnvironment, RunEnvironmentMetadata, RunPart};
66
use crate::system::SystemInfo;
77

8-
pub const LATEST_UPLOAD_METADATA_VERSION: u32 = 11;
8+
pub const LATEST_UPLOAD_METADATA_VERSION: u32 = 12;
99

1010
#[derive(Serialize, Debug)]
1111
#[serde(rename_all = "camelCase")]
@@ -15,6 +15,8 @@ pub struct UploadMetadata {
1515
pub tokenless: bool,
1616
pub profile_md5: String,
1717
pub profile_encoding: Option<String>,
18+
#[serde(skip_serializing_if = "Option::is_none")]
19+
pub profile_multipart: Option<ProfileMultipart>,
1820
pub runner: Runner,
1921
pub run_environment: RunEnvironment,
2022
pub run_part: Option<RunPart>,
@@ -24,6 +26,18 @@ pub struct UploadMetadata {
2426
pub run_environment_metadata: RunEnvironmentMetadata,
2527
}
2628

29+
/// Layout of a profile archive uploaded as an S3 multipart upload, in consecutive
30+
/// `part_size` chunks, the last one holding the remainder. S3 requires parts of 5 MiB
31+
/// to 5 GiB (the last one excepted from the minimum), and at most 10,000 of them.
32+
#[derive(Serialize, Debug, Clone, PartialEq)]
33+
#[serde(rename_all = "camelCase")]
34+
pub struct ProfileMultipart {
35+
pub size: u64,
36+
pub part_size: u64,
37+
/// Base64 md5 of each part, in upload order
38+
pub part_md5s: Vec<String>,
39+
}
40+
2741
#[derive(Serialize, Debug)]
2842
#[serde(rename_all = "camelCase")]
2943
pub struct Runner {
@@ -46,12 +60,75 @@ pub struct Runner {
4660
#[serde(rename_all = "camelCase")]
4761
pub struct UploadData {
4862
pub status: String,
49-
pub upload_url: String,
63+
#[serde(flatten)]
64+
pub target: UploadTarget,
5065
pub run_id: String,
5166
}
5267

68+
#[derive(Deserialize, Serialize, Debug)]
69+
#[serde(rename_all = "camelCase")]
70+
pub enum UploadTarget {
71+
/// Set for archives uploaded in a single request
72+
UploadUrl(String),
73+
/// Set for archives uploaded as an S3 multipart upload
74+
MultipartUploadUrls(MultipartUploadUrls),
75+
}
76+
77+
#[derive(Deserialize, Serialize, Debug)]
78+
#[serde(rename_all = "camelCase")]
79+
pub struct MultipartUploadUrls {
80+
/// Presigned S3 `UploadPart` URLs, one per part, in upload order
81+
pub part_urls: Vec<String>,
82+
/// Presigned S3 `CompleteMultipartUpload` URL
83+
pub complete_url: String,
84+
}
85+
5386
#[derive(Deserialize, Debug)]
5487
#[serde(rename_all = "camelCase")]
5588
pub struct UploadError {
5689
pub error: String,
5790
}
91+
92+
#[cfg(test)]
93+
mod tests {
94+
use super::*;
95+
96+
#[test]
97+
fn parses_single_upload_response() {
98+
let upload_data: UploadData = serde_json::from_str(
99+
r#"{"status": "success", "uploadUrl": "https://upload", "runId": "run-id"}"#,
100+
)
101+
.unwrap();
102+
103+
assert_eq!(upload_data.run_id, "run-id");
104+
let UploadTarget::UploadUrl(upload_url) = upload_data.target else {
105+
panic!("expected a single upload URL, got {:?}", upload_data.target);
106+
};
107+
assert_eq!(upload_url, "https://upload");
108+
}
109+
110+
#[test]
111+
fn parses_multipart_upload_response() {
112+
let upload_data: UploadData = serde_json::from_str(
113+
r#"{
114+
"status": "success",
115+
"runId": "run-id",
116+
"multipartUploadUrls": {
117+
"partUrls": ["https://part/1", "https://part/2"],
118+
"completeUrl": "https://complete"
119+
}
120+
}"#,
121+
)
122+
.unwrap();
123+
124+
assert_eq!(upload_data.run_id, "run-id");
125+
let UploadTarget::MultipartUploadUrls(urls) = upload_data.target else {
126+
panic!(
127+
"expected multipart upload URLs, got {:?}",
128+
upload_data.target
129+
);
130+
};
131+
assert_eq!(urls.part_urls, ["https://part/1", "https://part/2"]);
132+
assert_eq!(urls.complete_url, "https://complete");
133+
}
134+
}

‎src/upload/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ mod interfaces;
33
pub mod poll_results;
44
mod profile_archive;
55
mod run_index_state;
6+
mod s3;
67
mod upload_metadata;
78
mod uploader;
89

0 commit comments

Comments
 (0)