Skip to content

Commit f3fcd31

Browse files
committed
feat(upload): add a run index suffix
1 parent 4262c75 commit f3fcd31

File tree

4 files changed

+199
-2
lines changed

4 files changed

+199
-2
lines changed

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: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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}.json`
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+
#[derive(serde::Serialize, serde::Deserialize, Default)]
19+
struct StateFile {
20+
#[serde(default)]
21+
run_index: u32,
22+
}
23+
24+
impl RunIndexState {
25+
/// Creates a new `RunIndexState` for the given run and run part.
26+
///
27+
/// # Arguments
28+
/// * `repository_root_path` - The root path of the repository
29+
/// * `run_id` - The CI run identifier (e.g., GitHub Actions run ID)
30+
/// * `run_part_id` - The run part identifier (job name + matrix info)
31+
pub fn new(repository_root_path: &str, run_id: &str, run_part_id: &str) -> Self {
32+
// Hash the run_part_id to avoid filesystem-unsafe characters
33+
// (run_part_id can contain JSON with colons, braces, quotes, etc.)
34+
let run_part_id_hash = sha256::digest(run_part_id);
35+
let state_file_path = PathBuf::from(repository_root_path)
36+
.join(".codspeed")
37+
.join("run-state")
38+
.join(run_id)
39+
.join(format!("{}.json", &run_part_id_hash[..16]));
40+
41+
Self { state_file_path }
42+
}
43+
44+
/// Returns the current index and increments it for the next call.
45+
///
46+
/// If the state file doesn't exist, starts at 0.
47+
/// The incremented value is persisted for subsequent calls.
48+
pub fn get_and_increment(&self) -> Result<u32> {
49+
// Create parent directories if needed
50+
if let Some(parent) = self.state_file_path.parent() {
51+
fs::create_dir_all(parent)?;
52+
}
53+
54+
// Read current state (default to empty if file doesn't exist)
55+
let mut state: StateFile = if self.state_file_path.exists() {
56+
let content = fs::read_to_string(&self.state_file_path)?;
57+
serde_json::from_str(&content).unwrap_or_default()
58+
} else {
59+
StateFile::default()
60+
};
61+
62+
let current = state.run_index;
63+
64+
// Update and write back
65+
state.run_index = current + 1;
66+
fs::write(&self.state_file_path, serde_json::to_string_pretty(&state)?)?;
67+
68+
Ok(current)
69+
}
70+
}
71+
72+
#[cfg(test)]
73+
mod tests {
74+
use super::*;
75+
use tempfile::TempDir;
76+
77+
#[test]
78+
fn test_get_and_increment_starts_at_zero() {
79+
let temp_dir = TempDir::new().unwrap();
80+
let state = RunIndexState::new(
81+
temp_dir.path().to_str().unwrap(),
82+
"run-123",
83+
"my_job-{\"shard\":1}",
84+
);
85+
86+
assert_eq!(state.get_and_increment().unwrap(), 0);
87+
}
88+
89+
#[test]
90+
fn test_get_and_increment_increments() {
91+
let temp_dir = TempDir::new().unwrap();
92+
let state = RunIndexState::new(
93+
temp_dir.path().to_str().unwrap(),
94+
"run-123",
95+
"my_job-{\"shard\":1}",
96+
);
97+
98+
assert_eq!(state.get_and_increment().unwrap(), 0);
99+
assert_eq!(state.get_and_increment().unwrap(), 1);
100+
assert_eq!(state.get_and_increment().unwrap(), 2);
101+
}
102+
103+
#[test]
104+
fn test_different_run_part_ids_have_separate_counters() {
105+
let temp_dir = TempDir::new().unwrap();
106+
let repo_path = temp_dir.path().to_str().unwrap();
107+
108+
let state1 = RunIndexState::new(repo_path, "run-123", "job_a");
109+
let state2 = RunIndexState::new(repo_path, "run-123", "job_b");
110+
111+
assert_eq!(state1.get_and_increment().unwrap(), 0);
112+
assert_eq!(state2.get_and_increment().unwrap(), 0);
113+
assert_eq!(state1.get_and_increment().unwrap(), 1);
114+
assert_eq!(state2.get_and_increment().unwrap(), 1);
115+
}
116+
117+
#[test]
118+
fn test_different_run_ids_have_separate_counters() {
119+
let temp_dir = TempDir::new().unwrap();
120+
let repo_path = temp_dir.path().to_str().unwrap();
121+
122+
let state1 = RunIndexState::new(repo_path, "run-123", "my_job");
123+
let state2 = RunIndexState::new(repo_path, "run-456", "my_job");
124+
125+
assert_eq!(state1.get_and_increment().unwrap(), 0);
126+
assert_eq!(state2.get_and_increment().unwrap(), 0);
127+
assert_eq!(state1.get_and_increment().unwrap(), 1);
128+
assert_eq!(state2.get_and_increment().unwrap(), 1);
129+
}
130+
131+
#[test]
132+
fn test_state_persists_across_new_instances() {
133+
let temp_dir = TempDir::new().unwrap();
134+
let repo_path = temp_dir.path().to_str().unwrap();
135+
136+
{
137+
let state = RunIndexState::new(repo_path, "run-123", "my_job");
138+
assert_eq!(state.get_and_increment().unwrap(), 0);
139+
}
140+
141+
{
142+
let state = RunIndexState::new(repo_path, "run-123", "my_job");
143+
assert_eq!(state.get_and_increment().unwrap(), 1);
144+
}
145+
}
146+
147+
#[test]
148+
fn test_creates_directory_structure() {
149+
let temp_dir = TempDir::new().unwrap();
150+
let repo_path = temp_dir.path().to_str().unwrap();
151+
152+
let state = RunIndexState::new(repo_path, "run-123", "my_job");
153+
state.get_and_increment().unwrap();
154+
155+
// Verify the directory structure was created
156+
let codspeed_dir = temp_dir.path().join(".codspeed");
157+
assert!(codspeed_dir.exists());
158+
assert!(codspeed_dir.join("run-state").exists());
159+
assert!(codspeed_dir.join("run-state").join("run-123").exists());
160+
}
161+
}

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)