Skip to content

Commit 0662118

Browse files
feat: accept a mix of entrypoint and exec targets in project config
1 parent 5c13e6b commit 0662118

10 files changed

Lines changed: 641 additions & 385 deletions

File tree

schemas/codspeed.schema.json

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,18 +80,32 @@
8080
}
8181
},
8282
"Target": {
83-
"description": "A benchmark target to execute",
83+
"description": "A benchmark target to execute.\n\nEither `exec` or `entrypoint` must be specified (mutually exclusive).",
8484
"type": "object",
85-
"required": [
86-
"exec"
87-
],
8885
"properties": {
86+
"entrypoint": {
87+
"description": "Command with built-in benchmark harness (mutually exclusive with `exec`)",
88+
"type": [
89+
"string",
90+
"null"
91+
]
92+
},
8993
"exec": {
90-
"description": "Command to execute",
91-
"type": "string"
94+
"description": "Command measured by exec-harness (mutually exclusive with `entrypoint`)",
95+
"type": [
96+
"string",
97+
"null"
98+
]
99+
},
100+
"id": {
101+
"description": "Optional id to run a subset of targets (e.g. `codspeed run --bench my_id`)",
102+
"type": [
103+
"string",
104+
"null"
105+
]
92106
},
93107
"name": {
94-
"description": "Optional name for this target",
108+
"description": "Optional name for this target (display purposes only)",
95109
"type": [
96110
"string",
97111
"null"

src/cli/exec/mod.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ pub const DEFAULT_REPOSITORY_NAME: &str = "local-runs";
2121
pub const EXEC_HARNESS_COMMAND: &str = "exec-harness";
2222
pub const EXEC_HARNESS_VERSION: &str = "1.2.0";
2323

24+
#[cfg(test)]
25+
pub fn wrap_with_exec_harness(
26+
walltime_args: &exec_harness::walltime::WalltimeExecutionArgs,
27+
command: &[String],
28+
) -> String {
29+
shell_words::join(
30+
std::iter::once(EXEC_HARNESS_COMMAND)
31+
.chain(walltime_args.to_cli_args().iter().map(|s| s.as_str()))
32+
.chain(command.iter().map(|s| s.as_str())),
33+
)
34+
}
35+
2436
#[derive(Args, Debug)]
2537
pub struct ExecArgs {
2638
#[command(flatten)]
@@ -80,7 +92,7 @@ fn build_orchestrator_config(
8092
.map(|repo| RepositoryOverride::from_arg(repo, args.shared.provider))
8193
.transpose()?,
8294
working_directory: args.shared.working_directory,
83-
target,
95+
targets: vec![target],
8496
modes,
8597
instruments: Instruments { mongodb: None }, // exec doesn't support MongoDB
8698
perf_unwinding_mode: args.shared.perf_run_args.perf_unwinding_mode,

src/cli/exec/multi_targets.rs

Lines changed: 28 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,10 @@
11
use super::EXEC_HARNESS_COMMAND;
2+
use crate::executor::config::BenchmarkTarget;
23
use crate::prelude::*;
34
use crate::project_config::Target;
45
use crate::project_config::WalltimeOptions;
56
use exec_harness::BenchmarkCommand;
67

7-
/// Convert targets from project config to exec-harness JSON input format
8-
pub fn targets_to_exec_harness_json(
9-
targets: &[Target],
10-
default_walltime: Option<&WalltimeOptions>,
11-
) -> Result<String> {
12-
let inputs: Vec<BenchmarkCommand> = targets
13-
.iter()
14-
.map(|target| {
15-
// Parse the exec string into command parts
16-
let command = shell_words::split(&target.exec)
17-
.with_context(|| format!("Failed to parse command: {}", target.exec))?;
18-
19-
// Merge target-specific walltime options with defaults
20-
let target_walltime = target.options.as_ref().and_then(|o| o.walltime.as_ref());
21-
let walltime_args = merge_walltime_options(default_walltime, target_walltime);
22-
23-
Ok(BenchmarkCommand {
24-
command,
25-
name: target.name.clone(),
26-
walltime_args,
27-
})
28-
})
29-
.collect::<Result<Vec<_>>>()?;
30-
31-
serde_json::to_string(&inputs).context("Failed to serialize targets to JSON")
32-
}
33-
348
/// Merge default walltime options with target-specific overrides
359
fn merge_walltime_options(
3610
default: Option<&WalltimeOptions>,
@@ -66,13 +40,35 @@ fn walltime_options_to_args(
6640
}
6741
}
6842

69-
/// Build a shell command string that pipes targets JSON to exec-harness via stdin
70-
pub fn build_pipe_command(
43+
/// Convert project config targets into [`BenchmarkTarget`] instances.
44+
///
45+
/// Exec targets are each converted to a `BenchmarkTarget::Exec`.
46+
/// Entrypoint targets are each converted to a `BenchmarkTarget::Entrypoint`.
47+
pub fn build_benchmark_targets(
7148
targets: &[Target],
7249
default_walltime: Option<&WalltimeOptions>,
73-
) -> Result<String> {
74-
let json = targets_to_exec_harness_json(targets, default_walltime)?;
75-
Ok(build_pipe_command_from_json(&json))
50+
) -> Result<Vec<BenchmarkTarget>> {
51+
targets
52+
.iter()
53+
.map(|target| match (&target.exec, &target.entrypoint) {
54+
(Some(exec), None) => {
55+
let command = shell_words::split(exec)
56+
.with_context(|| format!("Failed to parse command: {exec}"))?;
57+
let target_walltime = target.options.as_ref().and_then(|o| o.walltime.as_ref());
58+
let walltime_args = merge_walltime_options(default_walltime, target_walltime);
59+
Ok(BenchmarkTarget::Exec {
60+
command,
61+
name: target.name.clone(),
62+
walltime_args,
63+
})
64+
}
65+
(None, Some(entrypoint)) => Ok(BenchmarkTarget::Entrypoint {
66+
command: entrypoint.clone(),
67+
name: target.name.clone(),
68+
}),
69+
_ => bail!("Benchmark target must have exactly one of 'exec' or 'entrypoint' set"),
70+
})
71+
.collect()
7672
}
7773

7874
/// Build a shell command string that pipes BenchmarkTarget::Exec variants to exec-harness via stdin

src/cli/run/mod.rs

Lines changed: 13 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ impl RunArgs {
9595

9696
fn build_orchestrator_config(
9797
args: RunArgs,
98-
target: executor::BenchmarkTarget,
98+
targets: Vec<executor::BenchmarkTarget>,
9999
) -> Result<OrchestratorConfig> {
100100
let instruments = Instruments::try_from(&args)?;
101101
let modes = args.shared.resolve_modes()?;
@@ -115,7 +115,7 @@ fn build_orchestrator_config(
115115
.map(|repo| RepositoryOverride::from_arg(repo, args.shared.provider))
116116
.transpose()?,
117117
working_directory: args.shared.working_directory,
118-
target,
118+
targets,
119119
modes,
120120
instruments,
121121
perf_unwinding_mode: args.shared.perf_run_args.perf_unwinding_mode,
@@ -178,11 +178,14 @@ pub async fn run(
178178

179179
match run_target {
180180
RunTarget::SingleCommand(args) => {
181-
let target = executor::BenchmarkTarget::Entrypoint {
182-
command: args.command.join(" "),
183-
name: None,
184-
};
185-
let config = build_orchestrator_config(args, target)?;
181+
let command = args.command.join(" ");
182+
let config = build_orchestrator_config(
183+
args,
184+
vec![executor::BenchmarkTarget::Entrypoint {
185+
command,
186+
name: None,
187+
}],
188+
)?;
186189
let orchestrator =
187190
executor::Orchestrator::new(config, codspeed_config, api_client).await?;
188191

@@ -205,28 +208,9 @@ pub async fn run(
205208
targets,
206209
default_walltime,
207210
} => {
208-
let pipe_command =
209-
super::exec::multi_targets::build_pipe_command(targets, default_walltime)?;
210-
// Wrap as Entrypoint since the pipe command string already includes exec-harness
211-
let target = executor::BenchmarkTarget::Entrypoint {
212-
command: pipe_command,
213-
name: None,
214-
};
215-
let config = build_orchestrator_config(args, target)?;
216-
217-
// Ensure exec-harness is installed since config targets use it
218-
crate::binary_installer::ensure_binary_installed(
219-
super::exec::EXEC_HARNESS_COMMAND,
220-
super::exec::EXEC_HARNESS_VERSION,
221-
|| {
222-
format!(
223-
"https://github.com/CodSpeedHQ/codspeed/releases/download/exec-harness-v{}/exec-harness-installer.sh",
224-
super::exec::EXEC_HARNESS_VERSION
225-
)
226-
},
227-
)
228-
.await?;
229-
211+
let benchmark_targets =
212+
super::exec::multi_targets::build_benchmark_targets(targets, default_walltime)?;
213+
let config = build_orchestrator_config(args, benchmark_targets)?;
230214
super::exec::execute_config(config, api_client, codspeed_config, setup_cache_dir)
231215
.await?;
232216
}

src/executor/config.rs

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ pub enum SimulationTool {
4343
/// Run-level configuration owned by the orchestrator.
4444
///
4545
/// Holds all parameters that are constant across benchmark targets within a run,
46-
/// plus the target(s) to execute.
46+
/// plus the list of targets to execute.
4747
/// Constructed from CLI arguments and passed to [`Orchestrator::new`].
4848
/// Use [`OrchestratorConfig::executor_config_for_command`] to produce a per-execution [`ExecutorConfig`].
4949
#[derive(Debug, Clone)]
@@ -53,7 +53,7 @@ pub struct OrchestratorConfig {
5353
pub repository_override: Option<RepositoryOverride>,
5454
pub working_directory: Option<String>,
5555

56-
pub target: BenchmarkTarget,
56+
pub targets: Vec<BenchmarkTarget>,
5757

5858
pub modes: Vec<RunnerMode>,
5959
pub instruments: Instruments,
@@ -132,6 +132,25 @@ impl OrchestratorConfig {
132132
self.token = token;
133133
}
134134

135+
/// Compute the total number of executor runs that will be performed.
136+
///
137+
/// All `Exec` targets are combined into a single invocation, while each
138+
/// `Entrypoint` target runs independently. Both are multiplied by the
139+
/// number of configured modes.
140+
pub fn expected_run_parts_count(&self) -> u32 {
141+
let has_exec = self
142+
.targets
143+
.iter()
144+
.any(|t| matches!(t, BenchmarkTarget::Exec { .. }));
145+
let entrypoint_count = self
146+
.targets
147+
.iter()
148+
.filter(|t| matches!(t, BenchmarkTarget::Entrypoint { .. }))
149+
.count();
150+
let invocation_count = (if has_exec { 1 } else { 0 }) + entrypoint_count;
151+
(invocation_count * self.modes.len()) as u32
152+
}
153+
135154
/// Produce a per-execution [`ExecutorConfig`] for the given command and mode.
136155
pub fn executor_config_for_command(&self, command: String) -> ExecutorConfig {
137156
ExecutorConfig {
@@ -166,10 +185,10 @@ impl OrchestratorConfig {
166185
token: None,
167186
repository_override: None,
168187
working_directory: None,
169-
target: BenchmarkTarget::Entrypoint {
188+
targets: vec![BenchmarkTarget::Entrypoint {
170189
command: String::new(),
171190
name: None,
172-
},
191+
}],
173192
modes: vec![RunnerMode::Simulation],
174193
instruments: Instruments::test(),
175194
perf_unwinding_mode: None,
@@ -197,6 +216,109 @@ impl ExecutorConfig {
197216
mod tests {
198217
use super::*;
199218

219+
#[test]
220+
fn test_expected_run_parts_count() {
221+
use crate::runner_mode::RunnerMode;
222+
223+
let base = OrchestratorConfig::test();
224+
225+
// Single entrypoint, single mode → 1
226+
let config = OrchestratorConfig {
227+
targets: vec![BenchmarkTarget::Entrypoint {
228+
command: "cmd".into(),
229+
name: None,
230+
}],
231+
modes: vec![RunnerMode::Simulation],
232+
..base.clone()
233+
};
234+
assert_eq!(config.expected_run_parts_count(), 1);
235+
236+
// Two entrypoints, single mode → 2
237+
let config = OrchestratorConfig {
238+
targets: vec![
239+
BenchmarkTarget::Entrypoint {
240+
command: "cmd1".into(),
241+
name: None,
242+
},
243+
BenchmarkTarget::Entrypoint {
244+
command: "cmd2".into(),
245+
name: None,
246+
},
247+
],
248+
modes: vec![RunnerMode::Simulation],
249+
..base.clone()
250+
};
251+
assert_eq!(config.expected_run_parts_count(), 2);
252+
253+
// Multiple exec targets count as one invocation, single mode → 1
254+
let config = OrchestratorConfig {
255+
targets: vec![
256+
BenchmarkTarget::Exec {
257+
command: vec!["exec1".into()],
258+
name: None,
259+
walltime_args: Default::default(),
260+
},
261+
BenchmarkTarget::Exec {
262+
command: vec!["exec2".into()],
263+
name: None,
264+
walltime_args: Default::default(),
265+
},
266+
],
267+
modes: vec![RunnerMode::Simulation],
268+
..base.clone()
269+
};
270+
assert_eq!(config.expected_run_parts_count(), 1);
271+
272+
// Mix of exec and entrypoint, single mode → 2
273+
let config = OrchestratorConfig {
274+
targets: vec![
275+
BenchmarkTarget::Exec {
276+
command: vec!["exec1".into()],
277+
name: None,
278+
walltime_args: Default::default(),
279+
},
280+
BenchmarkTarget::Entrypoint {
281+
command: "cmd".into(),
282+
name: None,
283+
},
284+
],
285+
modes: vec![RunnerMode::Simulation],
286+
..base.clone()
287+
};
288+
assert_eq!(config.expected_run_parts_count(), 2);
289+
290+
// Single entrypoint, two modes → 2
291+
#[allow(deprecated)]
292+
let config = OrchestratorConfig {
293+
targets: vec![BenchmarkTarget::Entrypoint {
294+
command: "cmd".into(),
295+
name: None,
296+
}],
297+
modes: vec![RunnerMode::Simulation, RunnerMode::Walltime],
298+
..base.clone()
299+
};
300+
assert_eq!(config.expected_run_parts_count(), 2);
301+
302+
// Mix of exec and entrypoint, two modes → 4
303+
#[allow(deprecated)]
304+
let config = OrchestratorConfig {
305+
targets: vec![
306+
BenchmarkTarget::Exec {
307+
command: vec!["exec1".into()],
308+
name: None,
309+
walltime_args: Default::default(),
310+
},
311+
BenchmarkTarget::Entrypoint {
312+
command: "cmd".into(),
313+
name: None,
314+
},
315+
],
316+
modes: vec![RunnerMode::Simulation, RunnerMode::Walltime],
317+
..base.clone()
318+
};
319+
assert_eq!(config.expected_run_parts_count(), 4);
320+
}
321+
200322
#[test]
201323
fn test_repository_override_from_arg() {
202324
let override_result =

0 commit comments

Comments
 (0)