Skip to main content

ic_testkit/artifacts/
wasm_batch.rs

1use std::{
2    collections::HashSet,
3    path::PathBuf,
4    time::{Duration, Instant},
5};
6
7use super::wasm_cache::{
8    SharedIncrementalTargetMaintenanceConfig, SharedIncrementalTargetMaintenanceOutcome,
9    SharedIncrementalTargetPrunePolicy, WasmBuildBatchInputMetrics, WasmBuildBatchInputResolver,
10    WasmBuildCacheMode, WasmBuildError, WasmBuildOutcome, WasmBuildProgressConfig,
11    WasmBuildProgressEvent, WasmBuildSpec, WasmBuildTimings, build_wasm_canisters_cached_in_batch,
12    build_wasm_canisters_cached_in_batch_with_progress,
13};
14
15/// Orchestration shared by every entry in one independent Wasm build batch.
16#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
17pub struct WasmBuildBatchConfig {
18    shared_incremental_maintenance: Option<SharedIncrementalTargetMaintenanceConfig>,
19}
20
21/// Ordered outcomes and failures from a collect-all Wasm build batch.
22#[derive(Debug)]
23pub struct WasmBuildBatchReport {
24    results: Vec<Result<WasmBuildOutcome, WasmBuildError>>,
25    entry_elapsed: Vec<Duration>,
26    input_resolution: WasmBuildBatchInputMetrics,
27    total: Duration,
28}
29
30/// Aggregate counters and successful-acquisition timings for a Wasm build batch.
31#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
32pub struct WasmBuildBatchMetrics {
33    specifications: usize,
34    succeeded: usize,
35    failed: usize,
36    built: usize,
37    reused: usize,
38    input_resolution_runs: usize,
39    input_resolution_reuses: usize,
40    successful_timings: WasmBuildTimings,
41    total: Duration,
42}
43
44/// Structured progress for an independent sequence of exact Wasm builds.
45#[non_exhaustive]
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub enum WasmBuildBatchProgressEvent {
48    /// One independently resolved build specification is about to start.
49    BuildStarted {
50        /// Zero-based position in the supplied specification slice.
51        index: usize,
52        /// Total number of supplied specifications.
53        total: usize,
54    },
55    /// Progress forwarded from one independent build.
56    BuildProgress {
57        /// Zero-based position in the supplied specification slice.
58        index: usize,
59        /// Event emitted by that build.
60        event: WasmBuildProgressEvent,
61    },
62    /// One independent build completed successfully.
63    BuildFinished {
64        /// Zero-based position in the supplied specification slice.
65        index: usize,
66    },
67    /// One independent build failed.
68    BuildFailed {
69        /// Zero-based position in the supplied specification slice.
70        index: usize,
71    },
72}
73
74impl WasmBuildBatchReport {
75    /// Per-specification results in the supplied order.
76    pub fn results(&self) -> &[Result<WasmBuildOutcome, WasmBuildError>] {
77        &self.results
78    }
79
80    /// Wall-clock time retained for every entry, including failed entries.
81    ///
82    /// Values use the supplied specification order and include batch-owned
83    /// validation plus the complete acquisition attempt for that entry.
84    #[must_use]
85    pub fn entry_elapsed(&self) -> &[Duration] {
86        &self.entry_elapsed
87    }
88
89    /// Consume the report and return its ordered per-specification results.
90    #[must_use]
91    pub fn into_results(self) -> Vec<Result<WasmBuildOutcome, WasmBuildError>> {
92        self.results
93    }
94
95    /// Successful outcomes with their specification indexes.
96    pub fn outcomes(&self) -> impl Iterator<Item = (usize, &WasmBuildOutcome)> {
97        self.results
98            .iter()
99            .enumerate()
100            .filter_map(|(index, result)| result.as_ref().ok().map(|outcome| (index, outcome)))
101    }
102
103    /// Failures with their specification indexes.
104    pub fn failures(&self) -> impl Iterator<Item = (usize, &WasmBuildError)> {
105        self.results
106            .iter()
107            .enumerate()
108            .filter_map(|(index, result)| result.as_ref().err().map(|error| (index, error)))
109    }
110
111    /// Integrated shared-target maintenance outcomes with their build indexes.
112    ///
113    /// Batch-owned maintenance contributes at most one outcome for each
114    /// distinct configured shared-target path.
115    pub fn shared_incremental_maintenance_outcomes(
116        &self,
117    ) -> impl Iterator<Item = (usize, &SharedIncrementalTargetMaintenanceOutcome)> {
118        self.outcomes().filter_map(|(index, outcome)| {
119            outcome
120                .record()
121                .shared_incremental_maintenance()
122                .map(|maintenance| (index, maintenance))
123        })
124    }
125
126    /// Complete wall-clock time for the sequential collect-all batch.
127    #[must_use]
128    pub const fn total(&self) -> Duration {
129        self.total
130    }
131
132    /// Whether every specification completed successfully.
133    #[must_use]
134    pub fn is_success(&self) -> bool {
135        self.results.iter().all(Result::is_ok)
136    }
137
138    /// Aggregate outcome, input-resolution reuse, and timing counters.
139    #[must_use]
140    pub fn metrics(&self) -> WasmBuildBatchMetrics {
141        let mut metrics = WasmBuildBatchMetrics {
142            specifications: self.results.len(),
143            input_resolution_runs: self.input_resolution.runs,
144            input_resolution_reuses: self.input_resolution.reuses,
145            total: self.total,
146            ..WasmBuildBatchMetrics::default()
147        };
148        for result in &self.results {
149            match result {
150                Ok(outcome) => {
151                    metrics.succeeded += 1;
152                    if outcome.is_reused() {
153                        metrics.reused += 1;
154                    } else {
155                        metrics.built += 1;
156                    }
157                    metrics.successful_timings = metrics
158                        .successful_timings
159                        .saturating_add(outcome.record().timings());
160                }
161                Err(_) => metrics.failed += 1,
162            }
163        }
164        metrics
165    }
166}
167
168impl WasmBuildBatchMetrics {
169    /// Number of supplied specifications.
170    #[must_use]
171    pub const fn specifications(self) -> usize {
172        self.specifications
173    }
174
175    /// Number of successful specifications.
176    #[must_use]
177    pub const fn succeeded(self) -> usize {
178        self.succeeded
179    }
180
181    /// Number of failed specifications.
182    #[must_use]
183    pub const fn failed(self) -> usize {
184        self.failed
185    }
186
187    /// Number of newly built Wasm artifact sets.
188    #[must_use]
189    pub const fn built(self) -> usize {
190        self.built
191    }
192
193    /// Number of Wasm artifact sets reused from the exact cache.
194    #[must_use]
195    pub const fn reused(self) -> usize {
196        self.reused
197    }
198
199    /// Number of workspace/toolchain input-resolution snapshots performed.
200    #[must_use]
201    pub const fn input_resolution_runs(self) -> usize {
202        self.input_resolution_runs
203    }
204
205    /// Number of specifications resolved by reusing another batch snapshot.
206    #[must_use]
207    pub const fn input_resolution_reuses(self) -> usize {
208        self.input_resolution_reuses
209    }
210
211    /// Sum of timings from successful acquisitions.
212    #[must_use]
213    pub const fn successful_timings(self) -> WasmBuildTimings {
214        self.successful_timings
215    }
216
217    /// Complete wall-clock time for the sequential batch.
218    #[must_use]
219    pub const fn total(self) -> Duration {
220        self.total
221    }
222}
223
224impl WasmBuildBatchConfig {
225    /// Create batch orchestration without batch-owned target maintenance.
226    #[must_use]
227    pub const fn new() -> Self {
228        Self {
229            shared_incremental_maintenance: None,
230        }
231    }
232
233    /// Maintain each distinct shared target once through its first batch entry.
234    #[must_use]
235    pub const fn with_shared_incremental_target_maintenance(
236        mut self,
237        config: SharedIncrementalTargetMaintenanceConfig,
238    ) -> Self {
239        self.shared_incremental_maintenance = Some(config);
240        self
241    }
242
243    /// Strictly maintain each distinct shared target at most once per interval.
244    #[must_use]
245    pub const fn with_shared_incremental_target_maintenance_at_most_every(
246        self,
247        policy: SharedIncrementalTargetPrunePolicy,
248        minimum_interval: Duration,
249    ) -> Self {
250        self.with_shared_incremental_target_maintenance(
251            SharedIncrementalTargetMaintenanceConfig::new(policy, minimum_interval),
252        )
253    }
254
255    /// Batch-owned shared-target maintenance, when configured.
256    #[must_use]
257    pub const fn shared_incremental_target_maintenance(
258        self,
259    ) -> Option<SharedIncrementalTargetMaintenanceConfig> {
260        self.shared_incremental_maintenance
261    }
262}
263
264impl std::fmt::Display for WasmBuildBatchReport {
265    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266        let metrics = self.metrics();
267        write!(
268            formatter,
269            "builds={} succeeded={} failed={} built={} reused={} input_resolution_runs={} input_resolution_reuses={} successful_timings=({}) total={:?}",
270            metrics.specifications(),
271            metrics.succeeded(),
272            metrics.failed(),
273            metrics.built(),
274            metrics.reused(),
275            metrics.input_resolution_runs(),
276            metrics.input_resolution_reuses(),
277            metrics.successful_timings(),
278            metrics.total(),
279        )
280    }
281}
282
283/// Build every Wasm specification as an independent Cargo invocation.
284///
285/// Specifications run sequentially and every result is retained. Each entry
286/// keeps its own package set, profile arguments, feature resolution,
287/// fingerprint, locks, and cache policy. Packages are never combined into one
288/// Cargo command because doing so can unify shared dependency features.
289#[must_use]
290pub fn build_wasm_canisters_cached_batch(specs: &[WasmBuildSpec]) -> WasmBuildBatchReport {
291    build_wasm_canisters_cached_batch_with_config(specs, WasmBuildBatchConfig::new())
292}
293
294/// Build an independent Wasm batch with shared batch orchestration.
295///
296/// Batch-owned maintenance is attached only to the first specification for
297/// each distinct configured shared-target path. Isolated specifications are
298/// unaffected. An entry mixing batch-owned and per-spec integrated maintenance
299/// reports an indexed error without preventing later entries from running.
300#[must_use]
301pub fn build_wasm_canisters_cached_batch_with_config(
302    specs: &[WasmBuildSpec],
303    config: WasmBuildBatchConfig,
304) -> WasmBuildBatchReport {
305    let mut resolver = WasmBuildBatchInputResolver::new(specs);
306    let mut report = build_wasm_batch(specs, config, |spec, index| {
307        build_wasm_canisters_cached_in_batch(spec, index, &mut resolver)
308    });
309    report.input_resolution = resolver.metrics();
310    report
311}
312
313/// Build an independent Wasm batch while forwarding structured progress.
314///
315/// The same observation configuration is applied to every entry. Batch events
316/// identify the originating specification without altering the standalone
317/// build semantics.
318#[must_use]
319pub fn build_wasm_canisters_cached_batch_with_progress<F>(
320    specs: &[WasmBuildSpec],
321    config: WasmBuildProgressConfig,
322    observer: F,
323) -> WasmBuildBatchReport
324where
325    F: FnMut(WasmBuildBatchProgressEvent),
326{
327    build_wasm_canisters_cached_batch_with_config_and_progress(
328        specs,
329        WasmBuildBatchConfig::new(),
330        config,
331        observer,
332    )
333}
334
335/// Build a configured independent Wasm batch while forwarding structured progress.
336#[must_use]
337pub fn build_wasm_canisters_cached_batch_with_config_and_progress<F>(
338    specs: &[WasmBuildSpec],
339    batch_config: WasmBuildBatchConfig,
340    progress_config: WasmBuildProgressConfig,
341    mut observer: F,
342) -> WasmBuildBatchReport
343where
344    F: FnMut(WasmBuildBatchProgressEvent),
345{
346    let count = specs.len();
347    let mut resolver = WasmBuildBatchInputResolver::new(specs);
348    let mut report = build_wasm_batch(specs, batch_config, |spec, index| {
349        observer(WasmBuildBatchProgressEvent::BuildStarted {
350            index,
351            total: count,
352        });
353        let result = build_wasm_canisters_cached_in_batch_with_progress(
354            spec,
355            index,
356            &mut resolver,
357            progress_config,
358            |event| observer(WasmBuildBatchProgressEvent::BuildProgress { index, event }),
359        );
360        observer(match result {
361            Ok(_) => WasmBuildBatchProgressEvent::BuildFinished { index },
362            Err(_) => WasmBuildBatchProgressEvent::BuildFailed { index },
363        });
364        result
365    });
366    report.input_resolution = resolver.metrics();
367    report
368}
369
370fn build_wasm_batch<F>(
371    specs: &[WasmBuildSpec],
372    config: WasmBuildBatchConfig,
373    mut build: F,
374) -> WasmBuildBatchReport
375where
376    F: FnMut(&WasmBuildSpec, usize) -> Result<WasmBuildOutcome, WasmBuildError>,
377{
378    let started = Instant::now();
379    let mut results = Vec::with_capacity(specs.len());
380    let mut entry_elapsed = Vec::with_capacity(specs.len());
381    let mut maintenance = BatchMaintenanceTracker::new(config.shared_incremental_maintenance);
382    for (index, spec) in specs.iter().enumerate() {
383        let entry_started = Instant::now();
384        if config.shared_incremental_maintenance.is_some()
385            && spec.shared_incremental_target_maintenance().is_some()
386        {
387            results.push(Err(batch_maintenance_ownership_error()));
388            entry_elapsed.push(entry_started.elapsed());
389            continue;
390        }
391        let configured = maintenance.prepare_spec(spec);
392        results.push(build(configured.as_ref().unwrap_or(spec), index));
393        entry_elapsed.push(entry_started.elapsed());
394    }
395    WasmBuildBatchReport {
396        results,
397        entry_elapsed,
398        input_resolution: WasmBuildBatchInputMetrics::default(),
399        total: started.elapsed(),
400    }
401}
402
403struct BatchMaintenanceTracker {
404    config: Option<SharedIncrementalTargetMaintenanceConfig>,
405    configured_targets: HashSet<PathBuf>,
406}
407
408impl BatchMaintenanceTracker {
409    fn new(config: Option<SharedIncrementalTargetMaintenanceConfig>) -> Self {
410        Self {
411            config,
412            configured_targets: HashSet::new(),
413        }
414    }
415
416    fn prepare_spec(&mut self, spec: &WasmBuildSpec) -> Option<WasmBuildSpec> {
417        let config = self.config?;
418        debug_assert!(spec.shared_incremental_target_maintenance().is_none());
419        let WasmBuildCacheMode::SharedIncremental { target_dir } = spec.cache_mode() else {
420            return None;
421        };
422        if !self.configured_targets.insert(target_dir.clone()) {
423            return None;
424        }
425        Some(
426            spec.clone()
427                .with_shared_incremental_target_maintenance(config),
428        )
429    }
430}
431
432fn batch_maintenance_ownership_error() -> WasmBuildError {
433    WasmBuildError::InvalidSpec {
434        message:
435            "batch-owned shared-target maintenance cannot be combined with per-spec maintenance"
436                .to_owned(),
437    }
438}
439
440#[cfg(test)]
441mod tests;