From f3fcd3192c9f04b71d41d12f1966eb8eca8d9e8d Mon Sep 17 00:00:00 2001 From: fargito Date: Mon, 19 Jan 2026 13:21:33 +0100 Subject: [PATCH] feat(upload): add a run index suffix --- src/run/mod.rs | 1 + src/run/run_index_state.rs | 161 ++++++++++++++++++++++++++++++ src/run_environment/interfaces.rs | 15 ++- src/run_environment/provider.rs | 24 ++++- 4 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 src/run/run_index_state.rs diff --git a/src/run/mod.rs b/src/run/mod.rs index 2b65ea96..4c173abe 100644 --- a/src/run/mod.rs +++ b/src/run/mod.rs @@ -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; diff --git a/src/run/run_index_state.rs b/src/run/run_index_state.rs new file mode 100644 index 00000000..64da373e --- /dev/null +++ b/src/run/run_index_state.rs @@ -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])); + + 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 { + // 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()); + } +} diff --git a/src/run_environment/interfaces.rs b/src/run_environment/interfaces.rs index 9054c5ac..46e94db6 100644 --- a/src/run_environment/interfaces.rs +++ b/src/run_environment/interfaces.rs @@ -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)] @@ -91,6 +91,19 @@ pub struct RunPart { pub metadata: BTreeMap, } +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 { diff --git a/src/run_environment/provider.rs b/src/run_environment/provider.rs index 192360d9..f6b3075e 100644 --- a/src/run_environment/provider.rs +++ b/src/run_environment/provider.rs @@ -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, }; @@ -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, + }; + Ok(UploadMetadata { version: Some(LATEST_UPLOAD_METADATA_VERSION), tokenless: config.token.is_none(), @@ -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, }) }