diff --git a/Cargo.lock b/Cargo.lock index 7432f0ca..ab2134fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1103,6 +1103,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "uuid", ] [[package]] @@ -1287,7 +1288,6 @@ dependencies = [ "karva_static", "karva_version", "tracing", - "uuid", "which", ] diff --git a/crates/karva/tests/it/basic.rs b/crates/karva/tests/it/basic.rs index 541dcd0d..a9dffb57 100644 --- a/crates/karva/tests/it/basic.rs +++ b/crates/karva/tests/it/basic.rs @@ -2233,7 +2233,7 @@ def test_static_env(): assert os.environ["KARVA"] == "1" assert os.environ["KARVA_WORKER_ID"] == "0" assert re.fullmatch( - r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + r"\d+-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", os.environ["KARVA_RUN_ID"], ), os.environ["KARVA_RUN_ID"] assert os.environ["KARVA_WORKSPACE_ROOT"] == os.getcwd() diff --git a/crates/karva/tests/it/common/mod.rs b/crates/karva/tests/it/common/mod.rs index e8c264eb..91b02ef3 100644 --- a/crates/karva/tests/it/common/mod.rs +++ b/crates/karva/tests/it/common/mod.rs @@ -65,7 +65,10 @@ impl TestContext { settings.add_filter(r"\[\s*\d+\.\d+s\]", "[TIME]"); settings.add_filter(r"(\s|\()(\d+m )?(\d+\.)?\d+(ms|s)", "$1[TIME]"); settings.add_filter(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", "[DATETIME]"); - settings.add_filter(r"run-\d+", "run-[TIMESTAMP]"); + settings.add_filter( + r"run-\d+(-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?", + "run-[TIMESTAMP]", + ); settings.add_filter(r"[-─]{30,}", "[LONG-LINE]"); settings.add_filter(r"karva \d+\.\d+\.\d+[a-zA-Z0-9._-]*", "karva [VERSION]"); settings.add_filter(r"karva\.exe", "karva"); diff --git a/crates/karva_cache/Cargo.toml b/crates/karva_cache/Cargo.toml index e1f76518..dc0ac555 100644 --- a/crates/karva_cache/Cargo.toml +++ b/crates/karva_cache/Cargo.toml @@ -17,6 +17,7 @@ camino = { workspace = true } ruff_db = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +uuid = { workspace = true } [dev-dependencies] insta = { workspace = true } diff --git a/crates/karva_cache/src/cache.rs b/crates/karva_cache/src/cache.rs index c413b6f7..3332855e 100644 --- a/crates/karva_cache/src/cache.rs +++ b/crates/karva_cache/src/cache.rs @@ -341,9 +341,10 @@ mod tests { let cache_dir = Utf8PathBuf::try_from(tmp.path().to_path_buf()).unwrap(); let run_hash = RunHash::from_existing("run-500"); + let run_name = run_hash.dir_name(); - create_cache_with_stats(tmp.path(), "run-500", 0, r#"{"passed": 3, "failed": 1}"#); - create_cache_with_stats(tmp.path(), "run-500", 1, r#"{"passed": 2, "skipped": 1}"#); + create_cache_with_stats(tmp.path(), &run_name, 0, r#"{"passed": 3, "failed": 1}"#); + create_cache_with_stats(tmp.path(), &run_name, 1, r#"{"passed": 2, "skipped": 1}"#); let cache = RunCache::new(&cache_dir, &run_hash); let results = cache.aggregate_results().unwrap(); @@ -359,7 +360,7 @@ mod tests { let cache_dir = Utf8PathBuf::try_from(tmp.path().to_path_buf()).unwrap(); let run_hash = RunHash::from_existing("run-600"); - let run_dir = tmp.path().join("run-600"); + let run_dir = tmp.path().join(run_hash.dir_name()); fs::create_dir_all(&run_dir).unwrap(); let cache = RunCache::new(&cache_dir, &run_hash); @@ -524,7 +525,7 @@ mod tests { let cache_dir = Utf8PathBuf::try_from(tmp.path().to_path_buf()).unwrap(); let run_hash = RunHash::from_existing("run-700"); - let run_dir = tmp.path().join("run-700"); + let run_dir = tmp.path().join(run_hash.dir_name()); let worker0 = run_dir.join("worker-0"); let worker1 = run_dir.join("worker-1"); fs::create_dir_all(&worker0).unwrap(); diff --git a/crates/karva_cache/src/hash.rs b/crates/karva_cache/src/hash.rs index 78e6e7a5..273841ed 100644 --- a/crates/karva_cache/src/hash.rs +++ b/crates/karva_cache/src/hash.rs @@ -1,42 +1,59 @@ use std::fmt; use std::time::{SystemTime, UNIX_EPOCH}; -use serde::{Deserialize, Serialize}; +use uuid::Uuid; use crate::RUN_PREFIX; -/// A unique identifier for a test run based on a millisecond timestamp. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +/// A unique identifier for a test run. +/// +/// Combines a millisecond timestamp (for chronological ordering of cache +/// directories) with a UUID v4 (for uniqueness across dense CI matrices and +/// for correlating logs across worker processes). Serialized as +/// `-`; the cache directory adds the `run-` prefix. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct RunHash { timestamp: u128, + uuid: Uuid, } impl RunHash { - /// Creates a new hash from the current system time. + /// Creates a new identifier for the current invocation. pub fn current_time() -> Self { let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("System time is before UNIX epoch") .as_millis(); - Self { timestamp } + Self { + timestamp, + uuid: Uuid::new_v4(), + } } - /// Parses a hash from an existing run directory name (e.g. `run-1234`). + /// Parses a hash from an existing run directory name (e.g. + /// `run-1234-`) or its bare `-` form. /// - /// Falls back to timestamp `0` if the input cannot be parsed. + /// Falls back to a zero timestamp and nil UUID if the input cannot be + /// parsed; this keeps callers from having to handle malformed legacy + /// directories that may exist on disk. pub fn from_existing(hash: &str) -> Self { - let timestamp = hash - .strip_prefix(RUN_PREFIX) - .unwrap_or(hash) - .parse() - .unwrap_or(0); - Self { timestamp } + let inner = hash.strip_prefix(RUN_PREFIX).unwrap_or(hash); + let (ts_str, uuid_str) = inner.split_once('-').unwrap_or((inner, "")); + let timestamp = ts_str.parse().unwrap_or(0); + let uuid = Uuid::parse_str(uuid_str).unwrap_or(Uuid::nil()); + Self { timestamp, uuid } } - /// Returns the string representation used as a directory name (e.g. `run-1234`). + /// Returns the bare `-` form. This is the value exposed to + /// tests as `KARVA_RUN_ID` and passed between processes. pub fn inner(&self) -> String { - format!("{RUN_PREFIX}{}", self.timestamp) + format!("{}-{}", self.timestamp, self.uuid) + } + + /// Returns the directory name used in the cache (`run--`). + pub fn dir_name(&self) -> String { + format!("{RUN_PREFIX}{}", self.inner()) } /// Returns the underlying timestamp, used for ordering runs chronologically. @@ -47,7 +64,7 @@ impl RunHash { impl fmt::Display for RunHash { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.inner()) + write!(f, "{}", self.dir_name()) } } @@ -58,16 +75,23 @@ mod tests { #[test] fn current_time_produces_valid_hash() { let hash = RunHash::current_time(); - let inner = hash.inner(); - assert!(inner.starts_with("run-")); + let dir = hash.dir_name(); + assert!(dir.starts_with("run-")); assert!(hash.sort_key() > 0); + assert!(dir.contains('-')); + } + + #[test] + fn from_existing_roundtrips_with_dir_name() { + let original = RunHash::current_time(); + let restored = RunHash::from_existing(&original.dir_name()); + assert_eq!(original, restored); } #[test] fn from_existing_roundtrips_with_inner() { let original = RunHash::current_time(); - let inner = original.inner(); - let restored = RunHash::from_existing(&inner); + let restored = RunHash::from_existing(&original.inner()); assert_eq!(original, restored); } @@ -83,17 +107,29 @@ mod tests { assert_eq!(hash.sort_key(), 0); } + #[test] + fn from_existing_handles_legacy_timestamp_only_dir() { + let hash = RunHash::from_existing("run-42"); + assert_eq!(hash.sort_key(), 42); + } + #[test] fn sort_key_reflects_timestamp_ordering() { - let earlier = RunHash::from_existing("run-100"); - let later = RunHash::from_existing("run-200"); + let earlier = RunHash::from_existing("run-100-00000000-0000-4000-8000-000000000000"); + let later = RunHash::from_existing("run-200-00000000-0000-4000-8000-000000000000"); assert!(earlier.sort_key() < later.sort_key()); } #[test] - fn display_matches_inner() { - let hash = RunHash::from_existing("run-42"); - assert_eq!(hash.to_string(), hash.inner()); - assert_eq!(hash.to_string(), "run-42"); + fn display_matches_dir_name() { + let hash = RunHash::current_time(); + assert_eq!(hash.to_string(), hash.dir_name()); + } + + #[test] + fn two_invocations_produce_distinct_hashes_even_at_same_ms() { + let a = RunHash::current_time(); + let b = RunHash::current_time(); + assert_ne!(a, b); } } diff --git a/crates/karva_runner/Cargo.toml b/crates/karva_runner/Cargo.toml index 322e73bb..a94098c4 100644 --- a/crates/karva_runner/Cargo.toml +++ b/crates/karva_runner/Cargo.toml @@ -29,7 +29,6 @@ ctrlc = { workspace = true } fastrand = { workspace = true } ignore = { workspace = true } tracing = { workspace = true } -uuid = { workspace = true } which = { workspace = true } [lints] diff --git a/crates/karva_runner/src/orchestration.rs b/crates/karva_runner/src/orchestration.rs index 593603dd..3989e715 100644 --- a/crates/karva_runner/src/orchestration.rs +++ b/crates/karva_runner/src/orchestration.rs @@ -319,7 +319,6 @@ pub fn run_parallel_tests( args, num_workers, profile: config.profile.as_deref().unwrap_or("default"), - run_id: &uuid::Uuid::new_v4().to_string(), worker_binary: &worker_binary, coverage_enabled: !project.settings().coverage().sources.is_empty(), }; diff --git a/crates/karva_runner/src/worker_args.rs b/crates/karva_runner/src/worker_args.rs index 315cbbc1..953016da 100644 --- a/crates/karva_runner/src/worker_args.rs +++ b/crates/karva_runner/src/worker_args.rs @@ -19,7 +19,6 @@ pub struct WorkerSpawn<'a> { pub args: &'a SubTestCommand, pub num_workers: usize, pub profile: &'a str, - pub run_id: &'a str, pub worker_binary: &'a Utf8PathBuf, pub coverage_enabled: bool, } @@ -29,7 +28,7 @@ pub fn worker_command(spawn: &WorkerSpawn, worker_id: usize, partition: &Partiti let mut cmd = Command::new(spawn.worker_binary); cmd.arg("--cache-dir") .arg(spawn.cache_dir) - .arg("--run-hash") + .arg("--run-id") .arg(spawn.run_hash.inner()) .arg("--worker-id") .arg(worker_id.to_string()) @@ -38,7 +37,7 @@ pub fn worker_command(spawn: &WorkerSpawn, worker_id: usize, partition: &Partiti .env("PYTHONUNBUFFERED", "1") .env(WorkerEnvVars::KARVA, "1") .env(WorkerEnvVars::KARVA_WORKER_ID, worker_id.to_string()) - .env(WorkerEnvVars::KARVA_RUN_ID, spawn.run_id) + .env(WorkerEnvVars::KARVA_RUN_ID, spawn.run_hash.inner()) .env( WorkerEnvVars::KARVA_WORKSPACE_ROOT, spawn.project.cwd().as_str(), diff --git a/crates/karva_static/src/lib.rs b/crates/karva_static/src/lib.rs index 4938928e..0c91e96a 100644 --- a/crates/karva_static/src/lib.rs +++ b/crates/karva_static/src/lib.rs @@ -77,9 +77,13 @@ env_vars! { /// parallel workers without coordination. pub const KARVA_WORKER_ID: &'static str = "KARVA_WORKER_ID"; - /// Unique identifier (UUID) for a single `karva test` invocation, - /// shared by every worker. Useful for correlating logs and external - /// artifacts produced across multiple worker processes. + /// Unique identifier for a single `karva test` invocation, shared by + /// every worker. Encodes `-`: a millisecond Unix timestamp + /// followed by a UUID v4. The timestamp prefix sorts chronologically + /// across runs; the UUID makes the id unique even when multiple jobs + /// start in the same millisecond. The same value names the run's + /// cache directory under `.karva_cache/run--`, which makes + /// correlating logs to cached artifacts straightforward. pub const KARVA_RUN_ID: &'static str = "KARVA_RUN_ID"; /// Absolute path to the directory Karva resolved as the project root. diff --git a/crates/karva_worker/src/cli.rs b/crates/karva_worker/src/cli.rs index 246b6c8d..cade981d 100644 --- a/crates/karva_worker/src/cli.rs +++ b/crates/karva_worker/src/cli.rs @@ -31,8 +31,9 @@ struct Args { cache_dir: Utf8PathBuf, /// Unique identifier for this test run, used for cache coordination. + /// Encodes `-`; the cache directory adds the `run-` prefix. #[arg(long)] - run_hash: String, + run_id: String, /// Numeric identifier for this worker in a parallel test run. #[arg(long)] @@ -162,7 +163,7 @@ fn run(f: impl FnOnce(Vec) -> Vec) -> anyhow::Result-`: a millisecond Unix timestamp +followed by a UUID v4. The timestamp prefix sorts chronologically +across runs; the UUID makes the id unique even when multiple jobs +start in the same millisecond. The same value names the run's +cache directory under `.karva_cache/run--`, which makes +correlating logs to cached artifacts straightforward. ### `KARVA_WORKSPACE_ROOT`