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
1 change: 1 addition & 0 deletions src/run/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use std::path::PathBuf;
pub mod check_system;
pub mod helpers;
pub(crate) mod poll_results;
pub mod run_index_state;
pub(crate) mod uploader;

pub mod logger;
Expand Down
161 changes: 161 additions & 0 deletions src/run/run_index_state.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
use crate::prelude::*;
use std::fs;
use std::path::PathBuf;

/// Manages a counter file to track upload index within a CI job.
///
/// This is used to differentiate multiple uploads in the same CI job execution
/// (e.g., running both simulation and memory benchmarks in the same job).
///
/// State is stored at: `{repository_root}/.codspeed/run-state/{run_id}/{run_part_id_hash}.json`
///
/// When a job is retried, it gets a fresh environment, so the counter resets to 0,
/// which ensures the `run_part_id` remains the same for each upload position.
pub struct RunIndexState {
state_file_path: PathBuf,
}

#[derive(serde::Serialize, serde::Deserialize, Default)]
struct StateFile {
#[serde(default)]
run_index: u32,
}

impl RunIndexState {
/// Creates a new `RunIndexState` for the given run and run part.
///
/// # Arguments
/// * `repository_root_path` - The root path of the repository
/// * `run_id` - The CI run identifier (e.g., GitHub Actions run ID)
/// * `run_part_id` - The run part identifier (job name + matrix info)
pub fn new(repository_root_path: &str, run_id: &str, run_part_id: &str) -> Self {
// Hash the run_part_id to avoid filesystem-unsafe characters
// (run_part_id can contain JSON with colons, braces, quotes, etc.)
let run_part_id_hash = sha256::digest(run_part_id);
let state_file_path = PathBuf::from(repository_root_path)
.join(".codspeed")
.join("run-state")
.join(run_id)
.join(format!("{}.json", &run_part_id_hash[..16]));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a particular reason we truncate this hash ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


Self { state_file_path }
}

/// Returns the current index and increments it for the next call.
///
/// If the state file doesn't exist, starts at 0.
/// The incremented value is persisted for subsequent calls.
pub fn get_and_increment(&self) -> Result<u32> {
// Create parent directories if needed
if let Some(parent) = self.state_file_path.parent() {
fs::create_dir_all(parent)?;
}

// Read current state (default to empty if file doesn't exist)
let mut state: StateFile = if self.state_file_path.exists() {
let content = fs::read_to_string(&self.state_file_path)?;
serde_json::from_str(&content).unwrap_or_default()
} else {
StateFile::default()
};

let current = state.run_index;

// Update and write back
state.run_index = current + 1;
fs::write(&self.state_file_path, serde_json::to_string_pretty(&state)?)?;

Ok(current)
}
}

#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;

#[test]
fn test_get_and_increment_starts_at_zero() {
let temp_dir = TempDir::new().unwrap();
let state = RunIndexState::new(
temp_dir.path().to_str().unwrap(),
"run-123",
"my_job-{\"shard\":1}",
);

assert_eq!(state.get_and_increment().unwrap(), 0);
}

#[test]
fn test_get_and_increment_increments() {
let temp_dir = TempDir::new().unwrap();
let state = RunIndexState::new(
temp_dir.path().to_str().unwrap(),
"run-123",
"my_job-{\"shard\":1}",
);

assert_eq!(state.get_and_increment().unwrap(), 0);
assert_eq!(state.get_and_increment().unwrap(), 1);
assert_eq!(state.get_and_increment().unwrap(), 2);
}

#[test]
fn test_different_run_part_ids_have_separate_counters() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_str().unwrap();

let state1 = RunIndexState::new(repo_path, "run-123", "job_a");
let state2 = RunIndexState::new(repo_path, "run-123", "job_b");

assert_eq!(state1.get_and_increment().unwrap(), 0);
assert_eq!(state2.get_and_increment().unwrap(), 0);
assert_eq!(state1.get_and_increment().unwrap(), 1);
assert_eq!(state2.get_and_increment().unwrap(), 1);
}

#[test]
fn test_different_run_ids_have_separate_counters() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_str().unwrap();

let state1 = RunIndexState::new(repo_path, "run-123", "my_job");
let state2 = RunIndexState::new(repo_path, "run-456", "my_job");

assert_eq!(state1.get_and_increment().unwrap(), 0);
assert_eq!(state2.get_and_increment().unwrap(), 0);
assert_eq!(state1.get_and_increment().unwrap(), 1);
assert_eq!(state2.get_and_increment().unwrap(), 1);
}

#[test]
fn test_state_persists_across_new_instances() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_str().unwrap();

{
let state = RunIndexState::new(repo_path, "run-123", "my_job");
assert_eq!(state.get_and_increment().unwrap(), 0);
}

{
let state = RunIndexState::new(repo_path, "run-123", "my_job");
assert_eq!(state.get_and_increment().unwrap(), 1);
}
}

#[test]
fn test_creates_directory_structure() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_str().unwrap();

let state = RunIndexState::new(repo_path, "run-123", "my_job");
state.get_and_increment().unwrap();

// Verify the directory structure was created
let codspeed_dir = temp_dir.path().join(".codspeed");
assert!(codspeed_dir.exists());
assert!(codspeed_dir.join("run-state").exists());
assert!(codspeed_dir.join("run-state").join("run-123").exists());
}
}
15 changes: 14 additions & 1 deletion src/run_environment/interfaces.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_json::{Value, json};
use std::collections::BTreeMap;

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

impl RunPart {
/// Returns a new `RunPart` with the run index suffix appended to `run_part_id`.
///
/// This is used to differentiate multiple uploads within the same CI job execution.
/// The suffix follows the same structured format as other metadata: `-{"run-index":N}`
pub fn with_run_index(mut self, run_index: u32) -> Self {
self.run_part_id = format!("{}-{{\"run-index\":{}}}", self.run_part_id, run_index);
self.metadata
.insert("run-index".to_string(), json!(run_index));
self
}
}

#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Sender {
Expand Down
24 changes: 23 additions & 1 deletion src/run_environment/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::api_client::CodSpeedAPIClient;
use crate::executor::{Config, ExecutorName};
use crate::prelude::*;
use crate::run::check_system::SystemInfo;
use crate::run::run_index_state::RunIndexState;
use crate::run::uploader::{
LATEST_UPLOAD_METADATA_VERSION, ProfileArchive, Runner, UploadMetadata,
};
Expand Down Expand Up @@ -96,6 +97,27 @@ pub trait RunEnvironmentProvider {

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

// Apply run index suffix to run_part if applicable.
// This differentiates multiple uploads within the same CI job execution
// (e.g., running both simulation and memory benchmarks in the same job).
let run_part = match self.get_run_provider_run_part() {
Some(run_part) => {
let run_index_state = RunIndexState::new(
&run_environment_metadata.repository_root_path,
&run_part.run_id,
&run_part.run_part_id,
);
match run_index_state.get_and_increment() {
Ok(run_index) => Some(run_part.with_run_index(run_index)),
Err(e) => {
warn!("Failed to track run index: {e}. Continuing with index 0.");
Some(run_part.with_run_index(0))
}
}
}
None => None,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could use .map on self.get_run_provider_run_part() to avoid the match arm None => None

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

};

Ok(UploadMetadata {
version: Some(LATEST_UPLOAD_METADATA_VERSION),
tokenless: config.token.is_none(),
Expand All @@ -112,7 +134,7 @@ pub trait RunEnvironmentProvider {
system_info: system_info.clone(),
},
run_environment: self.get_run_environment(),
run_part: self.get_run_provider_run_part(),
run_part,
})
}

Expand Down
Loading