Skip to content

Commit 3f9ac0a

Browse files
committed
feat(upload): add a run index suffix
1 parent 8a39cf5 commit 3f9ac0a

4 files changed

Lines changed: 192 additions & 2 deletions

File tree

src/run/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use std::path::PathBuf;
1616
pub mod check_system;
1717
pub mod helpers;
1818
pub(crate) mod poll_results;
19+
pub mod run_index_state;
1920
pub(crate) mod uploader;
2021

2122
pub mod logger;

src/run/run_index_state.rs

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
use crate::prelude::*;
2+
use std::fs;
3+
use std::path::PathBuf;
4+
5+
/// Manages a counter file to track upload index within a CI job.
6+
///
7+
/// This is used to differentiate multiple uploads in the same CI job execution
8+
/// (e.g., running both simulation and memory benchmarks in the same job).
9+
///
10+
/// State is stored at: `{repository_root}/.codspeed/run-state/{run_id}/{run_part_id_hash}`
11+
///
12+
/// When a job is retried, it gets a fresh environment, so the counter resets to 0,
13+
/// which ensures the `run_part_id` remains the same for each upload position.
14+
pub struct RunIndexState {
15+
state_file_path: PathBuf,
16+
}
17+
18+
impl RunIndexState {
19+
/// Creates a new `RunIndexState` for the given run and run part.
20+
///
21+
/// # Arguments
22+
/// * `repository_root_path` - The root path of the repository
23+
/// * `run_id` - The CI run identifier (e.g., GitHub Actions run ID)
24+
/// * `run_part_id` - The run part identifier (job name + matrix info)
25+
pub fn new(repository_root_path: &str, run_id: &str, run_part_id: &str) -> Self {
26+
// Hash the run_part_id to avoid filesystem-unsafe characters
27+
// (run_part_id can contain JSON with colons, braces, quotes, etc.)
28+
let run_part_id_hash = sha256::digest(run_part_id);
29+
let state_file_path = PathBuf::from(repository_root_path)
30+
.join(".codspeed")
31+
.join("run-state")
32+
.join(run_id)
33+
.join(&run_part_id_hash[..16]); // Use first 16 chars of hash for brevity
34+
35+
Self { state_file_path }
36+
}
37+
38+
/// Returns the current index and increments it for the next call.
39+
///
40+
/// If the state file doesn't exist, starts at 0.
41+
/// The incremented value is persisted for subsequent calls.
42+
pub fn get_and_increment(&self) -> Result<u32> {
43+
// Create parent directories if needed
44+
if let Some(parent) = self.state_file_path.parent() {
45+
fs::create_dir_all(parent)?;
46+
}
47+
48+
// Read current value (default to 0 if file doesn't exist)
49+
let current = if self.state_file_path.exists() {
50+
fs::read_to_string(&self.state_file_path)?
51+
.trim()
52+
.parse::<u32>()
53+
.unwrap_or(0)
54+
} else {
55+
0
56+
};
57+
58+
// Write incremented value for next call
59+
fs::write(&self.state_file_path, (current + 1).to_string())?;
60+
61+
Ok(current)
62+
}
63+
}
64+
65+
#[cfg(test)]
66+
mod tests {
67+
use super::*;
68+
use tempfile::TempDir;
69+
70+
#[test]
71+
fn test_get_and_increment_starts_at_zero() {
72+
let temp_dir = TempDir::new().unwrap();
73+
let state = RunIndexState::new(
74+
temp_dir.path().to_str().unwrap(),
75+
"run-123",
76+
"my_job-{\"shard\":1}",
77+
);
78+
79+
assert_eq!(state.get_and_increment().unwrap(), 0);
80+
}
81+
82+
#[test]
83+
fn test_get_and_increment_increments() {
84+
let temp_dir = TempDir::new().unwrap();
85+
let state = RunIndexState::new(
86+
temp_dir.path().to_str().unwrap(),
87+
"run-123",
88+
"my_job-{\"shard\":1}",
89+
);
90+
91+
assert_eq!(state.get_and_increment().unwrap(), 0);
92+
assert_eq!(state.get_and_increment().unwrap(), 1);
93+
assert_eq!(state.get_and_increment().unwrap(), 2);
94+
}
95+
96+
#[test]
97+
fn test_different_run_part_ids_have_separate_counters() {
98+
let temp_dir = TempDir::new().unwrap();
99+
let repo_path = temp_dir.path().to_str().unwrap();
100+
101+
let state1 = RunIndexState::new(repo_path, "run-123", "job_a");
102+
let state2 = RunIndexState::new(repo_path, "run-123", "job_b");
103+
104+
assert_eq!(state1.get_and_increment().unwrap(), 0);
105+
assert_eq!(state2.get_and_increment().unwrap(), 0);
106+
assert_eq!(state1.get_and_increment().unwrap(), 1);
107+
assert_eq!(state2.get_and_increment().unwrap(), 1);
108+
}
109+
110+
#[test]
111+
fn test_different_run_ids_have_separate_counters() {
112+
let temp_dir = TempDir::new().unwrap();
113+
let repo_path = temp_dir.path().to_str().unwrap();
114+
115+
let state1 = RunIndexState::new(repo_path, "run-123", "my_job");
116+
let state2 = RunIndexState::new(repo_path, "run-456", "my_job");
117+
118+
assert_eq!(state1.get_and_increment().unwrap(), 0);
119+
assert_eq!(state2.get_and_increment().unwrap(), 0);
120+
assert_eq!(state1.get_and_increment().unwrap(), 1);
121+
assert_eq!(state2.get_and_increment().unwrap(), 1);
122+
}
123+
124+
#[test]
125+
fn test_state_persists_across_new_instances() {
126+
let temp_dir = TempDir::new().unwrap();
127+
let repo_path = temp_dir.path().to_str().unwrap();
128+
129+
{
130+
let state = RunIndexState::new(repo_path, "run-123", "my_job");
131+
assert_eq!(state.get_and_increment().unwrap(), 0);
132+
}
133+
134+
{
135+
let state = RunIndexState::new(repo_path, "run-123", "my_job");
136+
assert_eq!(state.get_and_increment().unwrap(), 1);
137+
}
138+
}
139+
140+
#[test]
141+
fn test_creates_directory_structure() {
142+
let temp_dir = TempDir::new().unwrap();
143+
let repo_path = temp_dir.path().to_str().unwrap();
144+
145+
let state = RunIndexState::new(repo_path, "run-123", "my_job");
146+
state.get_and_increment().unwrap();
147+
148+
// Verify the directory structure was created
149+
let codspeed_dir = temp_dir.path().join(".codspeed");
150+
assert!(codspeed_dir.exists());
151+
assert!(codspeed_dir.join("run-state").exists());
152+
assert!(codspeed_dir.join("run-state").join("run-123").exists());
153+
}
154+
}

src/run_environment/interfaces.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use serde::{Deserialize, Serialize};
2-
use serde_json::Value;
2+
use serde_json::{Value, json};
33
use std::collections::BTreeMap;
44

55
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Default)]
@@ -91,6 +91,19 @@ pub struct RunPart {
9191
pub metadata: BTreeMap<String, Value>,
9292
}
9393

94+
impl RunPart {
95+
/// Returns a new `RunPart` with the run index suffix appended to `run_part_id`.
96+
///
97+
/// This is used to differentiate multiple uploads within the same CI job execution.
98+
/// The suffix follows the same structured format as other metadata: `-{"run-index":N}`
99+
pub fn with_run_index(mut self, run_index: u32) -> Self {
100+
self.run_part_id = format!("{}-{{\"run-index\":{}}}", self.run_part_id, run_index);
101+
self.metadata
102+
.insert("run-index".to_string(), json!(run_index));
103+
self
104+
}
105+
}
106+
94107
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
95108
#[serde(rename_all = "camelCase")]
96109
pub struct Sender {

src/run_environment/provider.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use crate::api_client::CodSpeedAPIClient;
66
use crate::executor::{Config, ExecutorName};
77
use crate::prelude::*;
88
use crate::run::check_system::SystemInfo;
9+
use crate::run::run_index_state::RunIndexState;
910
use crate::run::uploader::{
1011
LATEST_UPLOAD_METADATA_VERSION, ProfileArchive, Runner, UploadMetadata,
1112
};
@@ -96,6 +97,27 @@ pub trait RunEnvironmentProvider {
9697

9798
let commit_hash = self.get_commit_hash(&run_environment_metadata.repository_root_path)?;
9899

100+
// Apply run index suffix to run_part if applicable.
101+
// This differentiates multiple uploads within the same CI job execution
102+
// (e.g., running both simulation and memory benchmarks in the same job).
103+
let run_part = match self.get_run_provider_run_part() {
104+
Some(run_part) => {
105+
let run_index_state = RunIndexState::new(
106+
&run_environment_metadata.repository_root_path,
107+
&run_part.run_id,
108+
&run_part.run_part_id,
109+
);
110+
match run_index_state.get_and_increment() {
111+
Ok(run_index) => Some(run_part.with_run_index(run_index)),
112+
Err(e) => {
113+
warn!("Failed to track run index: {e}. Continuing with index 0.");
114+
Some(run_part.with_run_index(0))
115+
}
116+
}
117+
}
118+
None => None,
119+
};
120+
99121
Ok(UploadMetadata {
100122
version: Some(LATEST_UPLOAD_METADATA_VERSION),
101123
tokenless: config.token.is_none(),
@@ -112,7 +134,7 @@ pub trait RunEnvironmentProvider {
112134
system_info: system_info.clone(),
113135
},
114136
run_environment: self.get_run_environment(),
115-
run_part: self.get_run_provider_run_part(),
137+
run_part,
116138
})
117139
}
118140

0 commit comments

Comments
 (0)