tropel_engine/engine.rs
1use crate::input::{resolve_input_or_driver, ResolvedInput};
2use crate::outputs::spawn_extension_output;
3use crate::pacing::parse_duration_str;
4use crate::summary::build_summary_data;
5use crate::vu_loop::{run_driver_vus, run_scenario_vus};
6use crate::worker::VUWorkerPool;
7
8/// One entry of the per-scenario config list threaded into `run_scenario_vus`.
9struct ScenarioConfigEntry {
10 name: String,
11 execution: ExecutionConfig,
12 env: HashMap<String, String>,
13 tags: HashMap<String, String>,
14 start_delay: Duration,
15 input_path: String,
16 exec: Option<String>,
17}
18use std::collections::HashMap;
19use std::sync::Arc;
20use std::time::{Duration, Instant};
21use tokio::sync::broadcast;
22use tropel_core::config::{ExecutionConfig, JobConfig, OutputConfig, ScenarioConfig};
23use tropel_ext::registry::ExtensionRegistry;
24use tropel_metrics::collector::MetricsCollector;
25use tropel_metrics::thresholds::validate_thresholds;
26use tropel_report::{
27 create_reporter, InfluxdbOutput, JsonStreamOutput, OtlpOutput, PrometheusRemoteWriteOutput,
28 Reporter, StatsdOutput, StreamingStdoutOutput, TagPolicy,
29};
30use tropel_sdk::traits::Protocol;
31use tropel_sdk::types::Sample;
32use tropel_sdk::{Result, TropelError};
33
34/// Capacity of the streaming-output broadcast ring. Sized for ~2.5s of
35/// samples at ~100k samples/s so consumer stalls don't drop live data
36/// (see the ring construction in `Engine::run`).
37const SAMPLE_STREAM_CAPACITY: usize = 1 << 18; // 262_144
38
39/// The engine orchestrates a complete load test job.
40pub struct Engine {
41 extension_registry: ExtensionRegistry,
42 /// TR-309: optional periodic-snapshot sink. Distributed agents set this
43 /// so the controller sees progress mid-run instead of being blind until
44 /// the engine completes. `None` → the single-node path (no periodic
45 /// snapshot task spawned).
46 snapshot_tx: Option<tokio::sync::mpsc::Sender<tropel_metrics::collector::MetricsSnapshot>>,
47}
48
49impl Engine {
50 pub fn new(registry: ExtensionRegistry) -> Self {
51 Self {
52 extension_registry: registry,
53 snapshot_tx: None,
54 }
55 }
56
57 /// TR-309: enable periodic snapshots to the given channel. The engine
58 /// spawns a task that calls `metrics.snapshot()` on the aggregator every
59 /// 2 s (the single-node flush cadence) and forwards it.
60 pub fn with_snapshot_sink(
61 mut self,
62 tx: tokio::sync::mpsc::Sender<tropel_metrics::collector::MetricsSnapshot>,
63 ) -> Self {
64 self.snapshot_tx = Some(tx);
65 self
66 }
67
68 pub async fn run(&self, config: &JobConfig) -> Result<EngineResult> {
69 // Forget any unit declarations from a previous run in this process
70 // (backlog line 32) — the registry must never leak across runs.
71 tropel_metrics::time_metrics::clear();
72 tropel_metrics::OUTPUT_SAMPLES_DROPPED.store(0, std::sync::atomic::Ordering::Relaxed);
73 tropel_metrics::AGGREGATOR_SAMPLES_DROPPED.store(0, std::sync::atomic::Ordering::Relaxed);
74
75 let registry = Arc::new(self.extension_registry.clone());
76 let format_hint = config.input_type.clone();
77 let metrics = Arc::new(MetricsCollector::new());
78
79 // Latency histogram ceiling (None = auto-resize, no clipping). Applied
80 // before any samples are recorded so every MetricSet uses it.
81 metrics
82 .set_histogram_max(config.http.histogram_max_ms)
83 .await;
84
85 // TR-309: if the agent set a snapshot channel, spawn a periodic task
86 // that polls the aggregator every 2 s (the single-node flush cadence)
87 // and forwards the snapshot. The task runs on the current runtime.
88 if let Some(snapshot_tx) = self.snapshot_tx.clone() {
89 let metrics_clone = metrics.clone();
90 tokio::spawn(async move {
91 let mut interval = tokio::time::interval(std::time::Duration::from_secs(2));
92 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
93 loop {
94 interval.tick().await;
95 let snap = metrics_clone.snapshot().await;
96 if snapshot_tx.send(snap).await.is_err() {
97 // Receiver dropped (run finished) — stop.
98 break;
99 }
100 }
101 });
102 }
103
104 let num_workers = std::thread::available_parallelism()
105 .map(|n| n.get())
106 .unwrap_or(4);
107 let pool = Arc::new(VUWorkerPool::new(num_workers));
108 tracing::info!(
109 "VU worker pool: {} threads (available cores: {})",
110 num_workers,
111 num_workers
112 );
113
114 let mut http_config = config.http.clone();
115 let mut tls_config = config.tls.clone();
116 let mut thresholds = config.thresholds.clone();
117 // One shared read-only copy of the iteration dataset; every VU clones
118 // the Arc (not the Vec of rows), so memory is O(dataset) not
119 // O(VUs × dataset). Rows are only cloned individually when an
120 // iteration actually consumes one.
121 let data_rows = std::sync::Arc::new(config.iteration_data.clone());
122 let test_start = Instant::now();
123
124 // Script-declared options (k6 `export const options`). The safety
125 // controls — thresholds, DNS, hosts, blacklist, rps,
126 // discardResponseBodies, summaryTrendStats — are merged ALWAYS, even
127 // when the load profile comes from the CLI (the standard CI shape:
128 // thresholds in the script, --vus/--duration on the CLI; the old gate
129 // skipped the whole block and every script safety control evaporated).
130 // Only the script's own load profile (execution/scenarios) is gated
131 // on execution_explicit so explicit CLI/config profiles win.
132 let script_load_profile_allowed = !config.execution_explicit && config.scenarios.is_empty();
133 let mut declared_scenarios: Option<HashMap<String, ScenarioConfig>> = None;
134 let mut declared_execution: Option<ExecutionConfig> = None;
135 let mut declared_trend_stats: Option<Vec<String>> = None;
136 // TR-313: read the script ONCE, share the bytes across the
137 // declared_options resolution AND the scenario-loop resolution
138 // (which used to re-read the file independently). The bytes are
139 // also cached for the scenario tasks.
140 let script_bytes = std::fs::read(&config.input).ok();
141 let script_bytes_ref = script_bytes.as_deref();
142 {
143 let input_path = std::path::Path::new(&config.input);
144 if let (Some(bytes), Ok(ResolvedInput::Driver(driver))) = (
145 script_bytes_ref,
146 resolve_input_or_driver(
147 &config.input,
148 config.input_type.as_deref(),
149 ®istry,
150 &config.env,
151 script_bytes_ref,
152 ),
153 ) {
154 // Backlog line 153: a script that DECLARES malformed options
155 // (e.g. a type mismatch in `stages`) aborts the run instead of
156 // silently falling back to the CLI profile — k6 hard-errors.
157 let declared = driver
158 .declared_options(bytes, Some(input_path), &config.env)
159 .await?;
160 if let Some(decl) = declared {
161 // Script-declared global body handling (k6
162 // `options.discardResponseBodies`) applies to the HTTP
163 // client when the job didn't set one explicitly.
164 if let Some(discard) = decl.discard_response_bodies {
165 http_config.discard_response_bodies = discard;
166 tracing::info!(
167 "Script-declared discardResponseBodies={} applied to HTTP client",
168 discard
169 );
170 }
171 // Script-declared summary trend stats (k6
172 // `options.summaryTrendStats`) configure the summary.
173 if let Some(stats) = decl.summary_trend_stats {
174 if !stats.is_empty() {
175 tracing::info!(
176 "Script-declared summaryTrendStats applied: {:?}",
177 stats
178 );
179 declared_trend_stats = Some(stats);
180 }
181 }
182 // Script-declared HTTP/DNS options (k6 `options.dns`,
183 // `noConnectionReuse`, `rps`, `hosts`, `blacklistIPs`)
184 // fold into the HTTP client config.
185 if let Some(ttl) = decl.dns_ttl {
186 http_config.dns_ttl = Some(ttl);
187 }
188 if let Some(sel) = decl.dns_select {
189 http_config.dns_select = Some(sel);
190 }
191 if let Some(pol) = decl.dns_policy {
192 http_config.dns_policy = Some(pol);
193 }
194 if let Some(no) = decl.no_connection_reuse {
195 http_config.no_connection_reuse = no;
196 }
197 if let Some(no) = decl.no_vu_connection_reuse {
198 http_config.no_vu_connection_reuse = no;
199 }
200 if let Some(rps) = decl.rps {
201 http_config.rps = Some(rps);
202 }
203 if let Some(hosts) = decl.hosts {
204 http_config.hosts = hosts;
205 }
206 if let Some(bl) = decl.blacklist_ips {
207 http_config.blacklist_ips = bl;
208 }
209 // Script-declared TLS verification skip (k6
210 // `options.insecureSkipTLSVerify`) — the most common
211 // staging idiom. Applied to the HTTP client's TLS config;
212 // like the sibling script-declared HTTP/DNS options, a
213 // declared value takes precedence over the config-file
214 // default (CLI has no --insecure flag).
215 if let Some(skip) = decl.insecure_skip_tls_verify {
216 tls_config.insecure_skip_verify = skip;
217 tracing::info!(
218 "Script-declared insecureSkipTLSVerify={} applied to TLS config",
219 skip
220 );
221 }
222 if http_config.dns_ttl.is_some()
223 || http_config.dns_select.is_some()
224 || http_config.dns_policy.is_some()
225 || http_config.no_connection_reuse
226 || http_config.rps.is_some()
227 || !http_config.hosts.is_empty()
228 || !http_config.blacklist_ips.is_empty()
229 {
230 tracing::info!("Script-declared DNS/HTTP options applied");
231 }
232 // Merge script-declared thresholds (CLI/config keys win on
233 // collision — CLI keys are "threshold_N", so no clash).
234 // Skip when --no-thresholds is set.
235 if !config.no_thresholds {
236 for (k, v) in &decl.thresholds {
237 thresholds.entry(k.clone()).or_insert_with(|| v.clone());
238 }
239 }
240 if script_load_profile_allowed {
241 if let Some(scs) = decl.scenarios {
242 if !scs.is_empty() {
243 tracing::info!(
244 "Using script-declared scenarios: {}",
245 scs.keys().cloned().collect::<Vec<_>>().join(", ")
246 );
247 declared_scenarios = Some(scs);
248 }
249 } else if let Some(exec) = decl.execution {
250 tracing::info!("Using script-declared execution: {:?}", exec);
251 declared_execution = Some(exec);
252 }
253 } else if decl.scenarios.is_some() || decl.execution.is_some() {
254 // Explicit CLI/config profile wins; the script's own
255 // load profile is intentionally ignored (its safety
256 // options above are still applied).
257 tracing::debug!(
258 "CLI/config load profile wins — script-declared load profile ignored"
259 );
260 }
261 }
262 }
263 }
264
265 // Fail closed at startup (k6 behavior): a malformed threshold
266 // expression must abort the run with a clear config error BEFORE any
267 // load is generated — never silently pass at the end (the old
268 // evaluator returned `(true, …)` for unparseable input, so a typo'd
269 // metric or a bogus operator reported green).
270 validate_thresholds(&thresholds).map_err(TropelError::Config)?;
271
272 // Global RPS limiter (k6 `options.rps`): created ONCE per run and
273 // shared by every VU across every scenario, so the cap is global.
274 let rps_limiter: Option<Arc<tropel_http::RpsLimiter>> = http_config
275 .rps
276 .map(|r| Arc::new(tropel_http::RpsLimiter::new(r)));
277 if rps_limiter.is_some() {
278 tracing::info!(
279 "Global RPS cap: {} req/s (shared across all VUs)",
280 http_config.rps.unwrap_or(0.0)
281 );
282 }
283
284 // Streaming outputs. Size the broadcast ring to comfortably cover the
285 // expected sample rate: a 10k buffer held only ~100ms of samples at
286 // ~100k samples/s, so ANY consumer stall longer than that dropped
287 // live samples (streaming outputs were lossy by design). 2^18 slots
288 // hold ~2.5s of peak-rate samples — short consumer hiccups (GC, a
289 // flush that batches 10k) no longer lose data. Each slot is a small
290 // fixed-size struct (the tags HashMap is heap-allocated), so the
291 // preallocated ring is a few tens of MB worst case.
292 let mut output_handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
293
294 // TR-313: allocate the ~2^18-slot broadcast ring ONLY when a
295 // streaming output will actually consume it. The old code allocated
296 // it unconditionally then set the sink to `None` when no output
297 // existed — the 24 MiB ring was wasted on every run with no output.
298 let has_stdout = config.output.reporters.iter().any(|r| r == "stdout");
299 let prometheus_via_extension = config.output.reporters.iter().any(|r| r == "prometheus")
300 && self
301 .extension_registry
302 .list_outputs()
303 .iter()
304 .any(|o| o == "prometheus");
305 let has_streaming_output = has_stdout
306 || (config.output.prometheus_remote_write_url.is_some() && !prometheus_via_extension)
307 || config.output.otlp_endpoint.is_some()
308 || config.output.json_stream.is_some()
309 || config.output.statsd_addr.is_some()
310 || config.output.influxdb_addr.is_some()
311 || config.output.reporters.iter().any(|r| {
312 create_reporter(r, None).is_none()
313 && self.extension_registry.get_output(r).is_some()
314 });
315
316 let sample_tx = if has_streaming_output {
317 let (tx, _) = broadcast::channel::<Sample>(SAMPLE_STREAM_CAPACITY);
318 metrics.set_sample_sink(Some(tx.clone()));
319 Some(tx)
320 } else {
321 metrics.set_sample_sink(None);
322 None
323 };
324
325 // Planned wall-clock length of the run (incl. grace) for the live
326 // progress bar's 100% target. Resolved with the SAME precedence the
327 // scenario_configs below use (declared scenarios → config scenarios →
328 // declared/single execution), so the bar fills to the right total.
329 let progress_total: Option<Duration> = {
330 let consider = |exec: &ExecutionConfig, start: Duration| -> Option<Duration> {
331 exec.total_duration().map(|d| d + start)
332 };
333 // Note: `.flatten().max()` skips unbounded scenarios (None) — a
334 // run mixing a bounded and an externally-controlled scenario
335 // targets the longest bounded end while the unbounded one keeps
336 // running (the bar then just stays at 100% elapsed-only).
337 if let Some(scs) = &declared_scenarios {
338 scs.values()
339 .filter_map(|sc| {
340 let start = parse_duration_str(&sc.start_time).unwrap_or(Duration::ZERO);
341 consider(&sc.execution, start)
342 })
343 .max()
344 } else if !config.scenarios.is_empty() {
345 config
346 .scenarios
347 .values()
348 .filter_map(|sc| {
349 let start = parse_duration_str(&sc.start_time).unwrap_or(Duration::ZERO);
350 consider(&sc.execution, start)
351 })
352 .max()
353 } else {
354 consider(
355 declared_execution.as_ref().unwrap_or(&config.execution),
356 Duration::ZERO,
357 )
358 }
359 };
360
361 let has_stdout = config.output.reporters.iter().any(|r| r == "stdout");
362 if has_stdout {
363 let rx = sample_tx
364 .as_ref()
365 .expect("sample_tx exists when stdout is enabled")
366 .subscribe();
367 let handle = StreamingStdoutOutput::spawn(rx, progress_total);
368 output_handles.push(handle);
369 }
370 // Shared tag-forwarding policy for the network outputs: bounds label
371 // cardinality at the backend (allowlist + per-sample cap).
372 let tag_policy = TagPolicy {
373 allowlist: config.output.tag_allowlist.clone(),
374 max_tags: config.output.max_tags_per_sample,
375 };
376
377 // Prometheus remote-write and OTLP outputs (streaming, best-effort).
378 // When the user drives prometheus through the extension output
379 // (`--reporter prometheus`), the extension handles it — skip the
380 // built-in path so samples are not pushed twice. Only skip when the
381 // extension output is actually registered: a custom binary built
382 // without tropel-x-prometheus must not silently lose its stream.
383 if let Some(url) = &config.output.prometheus_remote_write_url {
384 if !prometheus_via_extension {
385 let rx = sample_tx
386 .as_ref()
387 .expect("sample_tx exists when prometheus is enabled")
388 .subscribe();
389 let handle =
390 PrometheusRemoteWriteOutput::spawn(rx, url.clone(), tag_policy.clone());
391 output_handles.push(handle);
392 }
393 }
394 if let Some(endpoint) = &config.output.otlp_endpoint {
395 let rx = sample_tx
396 .as_ref()
397 .expect("sample_tx exists when otlp is enabled")
398 .subscribe();
399 let handle = OtlpOutput::spawn(rx, endpoint.clone(), tag_policy.clone());
400 output_handles.push(handle);
401 }
402 // JSON-stream (NDJSON file) output.
403 if let Some(path) = &config.output.json_stream {
404 let rx = sample_tx
405 .as_ref()
406 .expect("sample_tx exists when json-stream is enabled")
407 .subscribe();
408 let handle = JsonStreamOutput::spawn(rx, path.clone());
409 output_handles.push(handle);
410 }
411 // StatsD / Datadog output (UDP datagrams).
412 if let Some(addr) = &config.output.statsd_addr {
413 let rx = sample_tx
414 .as_ref()
415 .expect("sample_tx exists when statsd is enabled")
416 .subscribe();
417 let handle = StatsdOutput::spawn(rx, addr.clone(), tag_policy.clone());
418 output_handles.push(handle);
419 }
420 // InfluxDB output (line protocol over UDP).
421 if let Some(addr) = &config.output.influxdb_addr {
422 let rx = sample_tx
423 .as_ref()
424 .expect("sample_tx exists when influxdb is enabled")
425 .subscribe();
426 let handle = InfluxdbOutput::spawn(rx, addr.clone(), tag_policy.clone());
427 output_handles.push(handle);
428 }
429 // Registered extension outputs: any configured reporter name that
430 // resolves to an extension output (e.g. the `prometheus` reference
431 // extension) is driven from the sample stream — emit() per batch,
432 // flush() when the stream closes. This replaces the old
433 // "extension reporter not supported" dead end.
434 for name in &config.output.reporters {
435 if create_reporter(name, None).is_some() {
436 continue; // built-in reporters handled above / at the end
437 }
438 if let Some(mut ext) = self.extension_registry.get_output(name) {
439 ext.configure(&config.output);
440 let rx = sample_tx
441 .as_ref()
442 .expect("sample_tx exists when an extension output is enabled")
443 .subscribe();
444 let handle = spawn_extension_output(rx, ext);
445 output_handles.push(handle);
446 }
447 }
448 // Build scenario configs. Script-declared scenarios/execution (from a
449 // k6 `export const options`) take precedence over the default profile
450 // but not over explicit user config (execution_explicit check above).
451 let scenario_configs: Vec<ScenarioConfigEntry> = if let Some(scs) = declared_scenarios {
452 scs.iter()
453 .map(|(name, sc)| {
454 let start_delay = parse_duration_str(&sc.start_time).unwrap_or(Duration::ZERO);
455 let input_path = sc.input.clone().unwrap_or_else(|| config.input.clone());
456 ScenarioConfigEntry {
457 name: name.clone(),
458 execution: sc.execution.clone(),
459 env: sc.env.clone(),
460 tags: sc.tags.clone(),
461 start_delay,
462 input_path,
463 exec: sc.exec.clone(),
464 }
465 })
466 .collect()
467 } else if !config.scenarios.is_empty() {
468 config
469 .scenarios
470 .iter()
471 .map(|(name, sc)| {
472 let start_delay = parse_duration_str(&sc.start_time).unwrap_or(Duration::ZERO);
473 let input_path = sc.input.clone().unwrap_or_else(|| config.input.clone());
474 ScenarioConfigEntry {
475 name: name.clone(),
476 execution: sc.execution.clone(),
477 env: sc.env.clone(),
478 tags: sc.tags.clone(),
479 start_delay,
480 input_path,
481 exec: sc.exec.clone(),
482 }
483 })
484 .collect()
485 } else {
486 let exec = declared_execution.unwrap_or_else(|| config.execution.clone());
487 vec![ScenarioConfigEntry {
488 name: "default".to_string(),
489 execution: exec,
490 env: HashMap::new(),
491 tags: HashMap::new(),
492 start_delay: Duration::ZERO,
493 input_path: config.input.clone(),
494 exec: None,
495 }]
496 };
497
498 // Apply the execution segment (k6 executionSegment /
499 // executionSegmentSequence): each scenario's workload is scaled
500 // deterministically to this node's share [from, to). An invalid
501 // segment spec is a hard config error — better to fail before any
502 // VU starts than to run the full workload on every node.
503 let segment = match &config.execution_segment {
504 Some(spec) => match tropel_core::segment::ExecutionSegment::parse(
505 spec,
506 config.execution_segment_sequence.as_deref(),
507 ) {
508 Ok(seg) => {
509 tracing::info!(
510 "Execution segment [{:.3}, {:.3}) — running {:.1}% of the workload",
511 seg.from(),
512 seg.to(),
513 seg.fraction() * 100.0
514 );
515 Some(seg)
516 }
517 Err(e) => return Err(e),
518 },
519 None => None,
520 };
521
522 // TR-221: express this node's share of an ARRIVAL-RATE workload by
523 // striping the global iteration sequence (k6's model) instead of by
524 // scaling the rate. Scaling gives every node its own independent rate
525 // from its own t=0, so N nodes' arrivals BUNCH — three nodes at
526 // 50/25/25 all fire together every 200 ms instead of filling a 50 ms
527 // cadence. Striping skips the global ticks this segment doesn't own,
528 // so the merged sequence is indistinguishable from one node at the
529 // full rate (`constant_arrival_rate.go:316-330`).
530 //
531 // This needs the FULL sequence: a lone segment cannot be striped
532 // consistently, because each node would fill the missing pieces
533 // differently and two nodes would claim the same global ticks. k6 has
534 // the same requirement — that is what `executionSegmentSequence` is
535 // for. Without it we fall back to rate scaling and say so.
536 let mut arrival_stripe: Option<tropel_core::segment::ArrivalStripe> = None;
537 let mut rate_mode = tropel_core::segment::RateSegmentation::ScaleRate;
538 if let Some(seg) = &segment {
539 match config.execution_segment_sequence.as_deref() {
540 Some(seq_spec) => {
541 let seq = tropel_core::segment::ExecutionSegmentSequence::parse(seq_spec)?;
542 // `ExecutionSegment::parse` already rejected a segment
543 // that is not a consecutive pair of this sequence, so the
544 // lookup cannot miss — but a silent mis-index would put
545 // the node on another node's stripe, so it errors.
546 let idx = seq.index_of(seg).ok_or_else(|| {
547 tropel_sdk::TropelError::Config(format!(
548 "execution segment [{:.6}, {:.6}) is not a segment of \
549 execution_segment_sequence '{seq_spec}'",
550 seg.from(),
551 seg.to()
552 ))
553 })?;
554 let wrapper = tropel_core::segment::ExecutionSegmentSequenceWrapper::new(&seq)?;
555 let (start, offsets, lcd) = wrapper.get_striped_offsets(idx);
556 tracing::info!(
557 "TR-221: arrival-rate striping active — segment {} of {} owns global \
558 tick {start}, then offsets {offsets:?}, repeating every {lcd} ticks. \
559 Arrival rates stay GLOBAL; the stripe is the segment share.",
560 idx + 1,
561 seq.0.len()
562 );
563 arrival_stripe = Some(wrapper.arrival_stripe(idx));
564 rate_mode = tropel_core::segment::RateSegmentation::GlobalRate;
565 }
566 None => {
567 tracing::warn!(
568 "execution_segment was given without execution_segment_sequence — \
569 arrival-rate scenarios fall back to per-node rate scaling, so arrivals \
570 BUNCH instead of interleaving across nodes (TR-221). Pass the same \
571 --execution-segment-sequence to every node to interleave."
572 );
573 }
574 }
575 }
576
577 let scenario_configs: Vec<ScenarioConfigEntry> = scenario_configs
578 .into_iter()
579 .map(|mut sc| {
580 if let Some(seg) = &segment {
581 sc.execution = seg.apply_with(&sc.execution, rate_mode);
582 }
583 sc
584 })
585 .collect();
586
587 // Apply summary presentation config (trend stats + effective
588 // thresholds) to the collector BEFORE the VU loop starts. The 2 s
589 // abort coordinator polls `results()` mid-run (backlog line 59a);
590 // when the config only arrived after the loop, `retain_histograms`
591 // was false for EVERY mid-run `abortOnFail` evaluation, so a
592 // non-tracked `p(75)` fell back to the nearest tracked bucket (p90)
593 // and healthy runs were aborted. Setting it here means the exact
594 // histograms are retained for the whole run.
595 let summary_trend_stats =
596 declared_trend_stats.unwrap_or_else(tropel_metrics::collector::k6_default_trend_stats);
597 metrics
598 .set_summary_config(summary_trend_stats, thresholds.clone())
599 .await;
600
601 tracing::info!(
602 "Starting Tropel load test: {} scenario(s)",
603 scenario_configs.len()
604 );
605
606 // TR-505: effective VU reporting — warn at startup when the MAX_WORKERS cap
607 // (10 000) or cgroup pids limit will reduce concurrency.
608 let peak_requested: u64 = {
609 fn peak_for_exec(exec: &ExecutionConfig) -> u64 {
610 match exec {
611 ExecutionConfig::ConstantVus { vus, .. } => *vus as u64,
612 ExecutionConfig::RampingVus {
613 stages, start_vus, ..
614 } => {
615 let max_stage = stages.iter().map(|s| s.target as u64).max().unwrap_or(0);
616 (*start_vus as u64).max(max_stage)
617 }
618 ExecutionConfig::ConstantArrivalRate { max_vus, .. } => *max_vus as u64,
619 ExecutionConfig::RampingArrivalRate { max_vus, .. } => *max_vus as u64,
620 ExecutionConfig::SharedIterations { vus, .. } => *vus as u64,
621 ExecutionConfig::PerVUIterations { vus, .. } => *vus as u64,
622 ExecutionConfig::ExternallyControlled { max_vus, .. } => *max_vus as u64,
623 }
624 }
625 // Concurrent scenarios run together — worst-case is sum of peaks.
626 // Sequential scenarios (staggered start_time) would over-estimate,
627 // but a startup warning that is slightly eager is safer than one
628 // that misses a real cap.
629 scenario_configs
630 .iter()
631 .map(|sc| peak_for_exec(&sc.execution))
632 .sum()
633 };
634 let effective = VUWorkerPool::effective_concurrency(peak_requested);
635 // Computed ONCE here and carried to the reporters on `MetricsResult`
636 // (`effective_vus_reason`). `tropel-report` sits below this crate and
637 // cannot read `VUWorkerPool::MAX_WORKERS`, so any cap named there is a
638 // second copy that goes stale — it already did, printing
639 // `MAX_WORKERS=4096` for the whole life of the 10 000 worker pool.
640 let mut effective_reason: Option<String> = None;
641 if peak_requested > effective {
642 let pids = VUWorkerPool::pids_limit();
643 let reason = if pids
644 .is_some_and(|lim| lim < peak_requested && lim < VUWorkerPool::MAX_WORKERS as u64)
645 {
646 format!(
647 "cgroup pids.max={} (Docker --pids-limit / Kubernetes pids.max)",
648 pids.unwrap()
649 )
650 } else {
651 format!("MAX_WORKERS={}", VUWorkerPool::MAX_WORKERS)
652 };
653 effective_reason = Some(reason.clone());
654 tracing::warn!(
655 "TR-505: requested {} VUs but only {} effective ({}). Co-located VUs share a single-threaded runtime and block each other — throughput will be that of {} VUs.",
656 peak_requested, effective, reason, effective
657 );
658 } else if peak_requested > 0 {
659 tracing::info!(
660 "TR-505: requested {} VUs, effective {} (MAX_WORKERS={}, pids={})",
661 peak_requested,
662 effective,
663 VUWorkerPool::MAX_WORKERS,
664 VUWorkerPool::pids_limit()
665 .map(|v| v.to_string())
666 .unwrap_or_else(|| "unlimited".to_string())
667 );
668 }
669
670 let mut scenario_handles = Vec::new();
671
672 for sc in &scenario_configs {
673 let sc_name = sc.name.clone();
674 let exec_cfg = sc.execution.clone();
675 let sc_env = sc.env.clone();
676 let sc_tags = sc.tags.clone();
677 let input_path = sc.input_path.clone();
678 let sc_exec = sc.exec.clone();
679 let start_delay = sc.start_delay;
680 let metrics = metrics.clone();
681 let pool = pool.clone();
682 let http_cfg = http_config.clone();
683 let tls_cfg = tls_config.clone();
684 let thresholds = thresholds.clone();
685 let data_rows = data_rows.clone();
686 let base_env = config.env.clone();
687 let registry_sc = registry.clone();
688 let fmt_hint = format_hint.clone();
689 let control_port = config.control_port;
690 let rps_limiter_sc = rps_limiter.clone();
691 // TR-313: the scenario task reuses the bytes read at startup
692 // instead of re-reading the script file.
693 let script_bytes_sc = script_bytes.clone();
694 // TR-221: each scenario's arrival-rate executor walks its OWN
695 // cursor over the global sequence (k6 gives every executor its
696 // own SegmentedIndex), so clone rather than share.
697 let arrival_stripe_sc = arrival_stripe.clone();
698
699 let handle = tokio::spawn(async move {
700 let resolved = resolve_input_or_driver(
701 &input_path,
702 fmt_hint.as_deref(),
703 ®istry_sc,
704 &base_env,
705 script_bytes_sc.as_deref(),
706 );
707 let resolved = match resolved {
708 Ok(r) => r,
709 Err(e) => {
710 tracing::error!(
711 "Scenario '{}': failed to resolve input '{}': {}",
712 sc_name,
713 input_path,
714 e
715 );
716 return (0, 0, None);
717 }
718 };
719
720 let sc_name_log = sc_name.clone();
721 // Instantiate every registered protocol once per scenario and
722 // share the scheme-keyed map across VUs so ANY non-HTTP URL
723 // scheme (`grpc://`, `ws://`, or a third-party one) dispatches
724 // to its registered protocol (the runner's scheme lookup).
725 let protocols: Arc<HashMap<String, Arc<dyn Protocol>>> =
726 Arc::new(registry_sc.instantiate_protocols());
727 let (init_failures, script_failures, abort_message) = match resolved {
728 ResolvedInput::Scenario(scenario, format_id) => {
729 run_scenario_vus(
730 sc_name,
731 start_delay,
732 sc_env,
733 sc_tags,
734 base_env,
735 exec_cfg,
736 scenario,
737 metrics,
738 pool,
739 http_cfg,
740 tls_cfg,
741 thresholds,
742 data_rows,
743 test_start,
744 protocols,
745 control_port,
746 rps_limiter_sc,
747 &input_path,
748 // TR-501: the resolving adapter's id decides
749 // which shims each VU materialises.
750 &format_id,
751 arrival_stripe_sc,
752 )
753 .await
754 }
755 ResolvedInput::Driver(driver) => {
756 run_driver_vus(
757 sc_name,
758 start_delay,
759 sc_env,
760 sc_tags,
761 base_env,
762 exec_cfg,
763 sc_exec,
764 driver,
765 metrics,
766 pool,
767 http_cfg,
768 tls_cfg,
769 thresholds,
770 data_rows,
771 test_start,
772 &input_path,
773 registry_sc,
774 control_port,
775 rps_limiter_sc,
776 // Backlog line 230: the driver path now gets the
777 // same registry-instantiated protocol map as the
778 // declarative path (clone — the Scenario arm
779 // moves the original).
780 protocols.clone(),
781 arrival_stripe_sc,
782 )
783 .await
784 }
785 };
786
787 tracing::info!("Scenario '{}': completed", sc_name_log);
788 (init_failures, script_failures, abort_message)
789 });
790
791 scenario_handles.push(handle);
792 }
793
794 // Any VU that failed to START (e.g. WASM driver pool exhaustion)
795 // means the requested load was not delivered. Count them so the CLI
796 // can fail the run loudly AFTER the summary/teardown run — returning
797 // Err here would skip the end-of-run reporting (and the output
798 // teardown) exactly when the user most needs to see what DID run.
799 // Script failures (backlog line 98) are counted the same way: a run
800 // where every script throws must exit non-zero.
801 let mut total_vu_init_failures = 0u32;
802 let mut total_script_failures = 0u64;
803 let mut total_abort_message: Option<String> = None;
804 for handle in scenario_handles {
805 match handle.await {
806 Ok((init_failures, script_failures, abort_message)) => {
807 total_vu_init_failures += init_failures;
808 total_script_failures += script_failures;
809 // First VU to abort wins; later scenarios are idempotent.
810 if abort_message.is_some() {
811 total_abort_message = total_abort_message.or(abort_message);
812 }
813 }
814 // P0 (backlog): a PANICKED scenario task (e.g. worker-pool
815 // thread/runtime creation failing under fd exhaustion) used
816 // to be silently discarded by `if let Ok` — the run printed
817 // a green summary from partial data and exited 0. Count it
818 // as a VU init failure so the run fails loudly instead.
819 Err(join_err) => {
820 tracing::error!("Scenario task panicked: {join_err}");
821 total_vu_init_failures = total_vu_init_failures.saturating_add(1);
822 }
823 }
824 }
825
826 // The broadcast channel only closes when ALL senders drop. The
827 // collector drops its clone in `set_sample_sink(None)`, but our
828 // local `sample_tx` was alive until the end of this function — so
829 // the streaming output task never saw `RecvError::Closed` and the
830 // `handle.await` below hung, in EVERY run (the 0-req runs merely
831 // exposed it most visibly). Dropping it here terminates the
832 // output tasks and lets the run finish.
833 metrics.set_sample_sink(None);
834 drop(sample_tx);
835 for handle in output_handles {
836 match handle.await {
837 Ok(()) => {}
838 Err(e) => {
839 if e.is_panic() {
840 tracing::error!("Output task panicked: {e} — some samples may be lost");
841 }
842 }
843 }
844 }
845
846 // Raw snapshot (build_results now clones the summary config into
847 // every result, so ordering no longer matters — captured here only
848 // for the lossless distributed merge). Distributed agents ship this
849 // to a controller; single-node runs never consume it, so skip the
850 // serialize cost.
851 let snapshot = if config.distributed_worker {
852 metrics.snapshot().await
853 } else {
854 tropel_metrics::collector::MetricsSnapshot::default()
855 };
856 let mut results = metrics.results().await;
857 // Stamp the wall-clock run duration so reporters can emit k6-style
858 // per-second rates (`http_reqs: 136 13.56/s`) — k6's Counter summary
859 // carries `rate` = count / elapsed seconds (backlog line 154).
860 results.run_duration = test_start.elapsed();
861 // TR-105: stamp the failure counters onto the result BEFORE any
862 // reporter renders, so the stdout banner's PASS/FAIL verdict uses the
863 // same numbers the CLI exit code will (vu_init_failures / script_
864 // failures were only known to the CLI — the banner printed PASS on
865 // runs that exited 1).
866 results.vu_init_failures = total_vu_init_failures;
867 results.script_failures = total_script_failures;
868 // TR-505: stamp requested vs effective so the summary can report the gap
869 // between what was asked and what the worker pool can deliver (4096 cap /
870 // pids.max). This is the cheapest honesty win — a run requesting 10k
871 // must not print "10 000 VUs" when it delivered 4 096.
872 results.requested_vus = peak_requested;
873 results.effective_vus = effective;
874 results.effective_vus_reason = effective_reason.clone();
875
876 // Distributed workers (`tropel-agent`) skip ALL end-of-run output —
877 // the controller owns the summary, handleSummary, and reporters —
878 // and only ship their raw snapshot back for central merging.
879 if !config.distributed_worker {
880 let reporters = self.create_reporters(&config.output);
881 for reporter in &reporters {
882 if let Err(e) = reporter.report(&results).await {
883 tracing::error!("Reporter '{}' failed: {} — continuing with remaining reporters and handleSummary", reporter.name(), e);
884 }
885 }
886
887 // k6 `handleSummary(data)`: let the script emit custom summaries
888 // (JSON/HTML/JUnit). Runs after the run with the aggregated data;
889 // returned files are written (`stdout` prints). Falls back to
890 // `--summary-export` when the script declares no handleSummary.
891 emit_handle_summary(config, ®istry, &results, &thresholds, test_start).await;
892 }
893
894 Ok(EngineResult {
895 metrics: results,
896 snapshot,
897 // The effective threshold set — CLI/config thresholds merged with
898 // any script-declared (k6 options) thresholds. The CLI reports
899 // against THIS set so k6 SLOs appear in the end-of-run summary,
900 // not just mid-run abort checks.
901 effective_thresholds: thresholds,
902 // Number of VUs that failed to START (driver init / HTTP client
903 // creation). Non-zero means the requested load was NOT delivered;
904 // the CLI treats this as a hard failure (non-zero exit) after the
905 // summary prints.
906 vu_init_failures: total_vu_init_failures,
907 // Number of script executions (prerequest/test/driver iteration)
908 // that errored during the run. Non-zero means scripts kept
909 // failing; the CLI exits non-zero (backlog line 98).
910 script_failures: total_script_failures,
911 // TR-244: `exec.test.abort(msg?)` message, if any — the CLI maps
912 // it to k6's exit code 108.
913 abort_message: total_abort_message,
914 })
915 }
916
917 fn create_reporters(&self, config: &OutputConfig) -> Vec<Box<dyn Reporter>> {
918 let mut reporters: Vec<Box<dyn Reporter>> = Vec::new();
919 for name in &config.reporters {
920 if let Some(reporter) = create_reporter(name, config.output_file.as_deref()) {
921 reporters.push(reporter);
922 } else if self
923 .extension_registry
924 .list_outputs()
925 .iter()
926 .any(|o| o == name)
927 {
928 // Extension outputs are driven from the sample stream during
929 // the run (see Engine::run) — they are not end-of-run
930 // reporters, so there is nothing to create here.
931 tracing::debug!("Extension output '{}' driven as a streaming output", name);
932 } else {
933 tracing::warn!("Unknown reporter: {}", name);
934 }
935 }
936 reporters
937 }
938
939 pub fn extension_registry(&self) -> &ExtensionRegistry {
940 &self.extension_registry
941 }
942}
943
944impl Default for Engine {
945 fn default() -> Self {
946 Self::new(ExtensionRegistry::new())
947 }
948}
949
950// handleSummary(data) — script-emitted custom summaries
951
952/// Invoke the script's `handleSummary(data)` (k6) after the run and
953/// write the returned files (`stdout` key prints to stdout). When the
954/// script declares no handleSummary, honor `--summary-export` by
955/// writing the default summary data object as JSON. Best-effort — a
956/// failing script summary never fails the run.
957///
958/// Public so the distributed controller can emit the same summaries from
959/// its MERGED result (previously `report_and_thresholds` only ran
960/// stdout/json/csv and silently dropped summary_export and handleSummary).
961pub async fn emit_handle_summary(
962 config: &JobConfig,
963 registry: &ExtensionRegistry,
964 results: &tropel_metrics::collector::MetricsResult,
965 thresholds: &HashMap<String, tropel_core::config::ThresholdConfig>,
966 test_start: Instant,
967) {
968 let summary_value = build_summary_data(results, thresholds, test_start);
969 let summary_json = serde_json::to_string(&summary_value).unwrap_or_default();
970
971 // Resolve a driver for the input (k6 scripts declare handleSummary).
972 // If no driver resolves (e.g. a Postman/HAR declarative collection),
973 // there is no script to call — fall through to --summary-export.
974 let input_path = std::path::Path::new(&config.input);
975 let bytes = std::fs::read(&config.input).ok();
976 let driver = bytes.as_ref().and_then(|b| {
977 if let Some(fmt) = &config.input_type {
978 registry.resolve_driver_by_id(fmt)
979 } else {
980 registry.resolve_driver(b)
981 }
982 });
983
984 let mut handled = false;
985 if let (Some(driver), Some(bytes)) = (driver, bytes.as_deref()) {
986 if let Some(files) = driver
987 .handle_summary(bytes, Some(input_path), &summary_json, &config.env)
988 .await
989 {
990 // k6 semantics: a script-defined handleSummary REPLACES the
991 // default summary entirely — even a stdout-only map suppresses
992 // the --summary-export fallback.
993 handled = true;
994 for (name, content) in files {
995 if name == "stdout" {
996 println!("{content}");
997 } else if let Err(e) = std::fs::write(&name, content) {
998 tracing::warn!("handleSummary failed to write '{name}': {e}");
999 } else {
1000 tracing::info!("handleSummary wrote '{name}'");
1001 }
1002 }
1003 }
1004 }
1005
1006 // Fallback: --summary-export writes the default JSON summary when no
1007 // script handleSummary produced any output.
1008 if !handled {
1009 if let Some(path) = &config.output.summary_export {
1010 let pretty = serde_json::to_string_pretty(&summary_value).unwrap_or_default();
1011 if let Err(e) = std::fs::write(path, pretty) {
1012 tracing::warn!("Failed to write summary export to '{:?}': {}", path, e);
1013 } else {
1014 tracing::info!("Summary exported to '{:?}'", path);
1015 }
1016 }
1017 }
1018}
1019// Result type
1020
1021#[derive(Debug)]
1022pub struct EngineResult {
1023 pub metrics: tropel_metrics::collector::MetricsResult,
1024 /// Raw serializable snapshot of the aggregated series (hdr-histogram V2
1025 /// bytes for Trend metrics). Distributed agents ship this to a
1026 /// controller; single-node runs can ignore it.
1027 pub snapshot: tropel_metrics::collector::MetricsSnapshot,
1028 /// The thresholds actually applied to the run: the job's own thresholds
1029 /// merged with any script-declared thresholds (e.g. from a k6
1030 /// `export const options`). Consumers should report/evaluate against this
1031 /// set rather than the raw `JobConfig.thresholds`.
1032 pub effective_thresholds: HashMap<String, tropel_core::config::ThresholdConfig>,
1033 /// Number of VUs that failed to START (driver init / HTTP client
1034 /// creation). Non-zero means the requested load was NOT delivered — the
1035 /// CLI exits non-zero after printing the summary.
1036 pub vu_init_failures: u32,
1037 /// Number of script executions (prerequest/test scripts, driver
1038 /// iterations) that errored during the run. Non-zero means scripts kept
1039 /// failing — the CLI exits non-zero after printing the summary (backlog
1040 /// line 98: script failures used to be swallowed — warn only, no failed
1041 /// check, exit 0).
1042 pub script_failures: u64,
1043 /// Message from `exec.test.abort(msg?)`, if any. Set when a VU called
1044 /// abort during the run. The CLI uses this to exit with k6's exit code
1045 /// 108 (ScriptAborted) instead of a generic non-zero (TR-244).
1046 pub abort_message: Option<String>,
1047}