Skip to main content

ic_testkit/artifacts/
wasm_batch.rs

1use std::{
2    collections::{HashMap, 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/// Caller-labeled specification for one exact Wasm batch entry.
22///
23/// The label is report and progress identity only; it does not alter the
24/// underlying exact Wasm fingerprint or cache key.
25#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct LabeledWasmBuildSpec {
27    label: String,
28    spec: WasmBuildSpec,
29}
30
31/// Ordered outcomes and failures from a collect-all Wasm build batch.
32#[derive(Debug)]
33pub struct WasmBuildBatchReport {
34    entries: Vec<WasmBuildBatchEntry>,
35    input_resolution: WasmBuildBatchInputMetrics,
36    total: Duration,
37}
38
39/// One ordered caller-labeled result from a Wasm build batch.
40#[derive(Debug)]
41pub struct WasmBuildBatchEntry {
42    index: usize,
43    label: String,
44    result: Result<WasmBuildOutcome, WasmBuildError>,
45    entry_elapsed: Duration,
46}
47
48/// One successful Wasm batch entry.
49#[derive(Clone, Copy, Debug)]
50pub struct WasmBuildBatchOutcomeEntry<'a> {
51    index: usize,
52    label: &'a str,
53    outcome: &'a WasmBuildOutcome,
54    entry_elapsed: Duration,
55}
56
57/// One failed Wasm batch entry with its retained wall-clock time.
58#[derive(Clone, Copy, Debug)]
59pub struct WasmBuildBatchFailure<'a> {
60    index: usize,
61    label: &'a str,
62    error: &'a WasmBuildError,
63    entry_elapsed: Duration,
64}
65
66/// One integrated shared-target maintenance outcome from a Wasm batch.
67#[derive(Clone, Copy, Debug)]
68pub struct WasmBuildBatchMaintenanceEntry<'a> {
69    index: usize,
70    label: &'a str,
71    outcome: &'a SharedIncrementalTargetMaintenanceOutcome,
72}
73
74/// Structural error that prevents a labeled Wasm batch from starting.
75#[non_exhaustive]
76#[derive(Clone, Debug, Eq, PartialEq)]
77pub enum WasmBuildBatchContractError {
78    /// An entry label was empty.
79    EmptyLabel {
80        /// Zero-based position of the invalid entry.
81        index: usize,
82    },
83    /// Two entries used the same label.
84    DuplicateLabel {
85        /// Duplicated caller label.
86        label: String,
87        /// Position where the label first appeared.
88        first_index: usize,
89        /// Position where the label was repeated.
90        duplicate_index: usize,
91    },
92}
93
94impl LabeledWasmBuildSpec {
95    /// Attach a caller-owned stable label to one Wasm build specification.
96    #[must_use]
97    pub fn new(label: impl Into<String>, spec: WasmBuildSpec) -> Self {
98        Self {
99            label: label.into(),
100            spec,
101        }
102    }
103
104    /// Caller-owned report and progress label.
105    #[must_use]
106    pub fn label(&self) -> &str {
107        &self.label
108    }
109
110    /// Underlying exact Wasm build specification.
111    #[must_use]
112    pub const fn spec(&self) -> &WasmBuildSpec {
113        &self.spec
114    }
115
116    /// Consume the entry into its label and Wasm build specification.
117    #[must_use]
118    pub fn into_parts(self) -> (String, WasmBuildSpec) {
119        (self.label, self.spec)
120    }
121}
122
123impl WasmBuildBatchEntry {
124    /// Zero-based position in the supplied labeled specification slice.
125    #[must_use]
126    pub const fn index(&self) -> usize {
127        self.index
128    }
129
130    /// Caller-owned stable label.
131    #[must_use]
132    pub fn label(&self) -> &str {
133        &self.label
134    }
135
136    /// Structured success or failure for this entry.
137    pub const fn result(&self) -> Result<&WasmBuildOutcome, &WasmBuildError> {
138        self.result.as_ref()
139    }
140
141    /// Successful Wasm outcome, when this entry succeeded.
142    #[must_use]
143    pub fn outcome(&self) -> Option<&WasmBuildOutcome> {
144        self.result.as_ref().ok()
145    }
146
147    /// Structured build failure, when this entry failed.
148    #[must_use]
149    pub fn error(&self) -> Option<&WasmBuildError> {
150        self.result.as_ref().err()
151    }
152
153    /// Complete wall-clock time retained for this entry.
154    #[must_use]
155    pub const fn entry_elapsed(&self) -> Duration {
156        self.entry_elapsed
157    }
158
159    /// Whether this entry completed successfully.
160    #[must_use]
161    pub const fn is_success(&self) -> bool {
162        self.result.is_ok()
163    }
164
165    /// Consume the entry into its ordered identity, result, and wall time.
166    pub fn into_parts(
167        self,
168    ) -> (
169        usize,
170        String,
171        Result<WasmBuildOutcome, WasmBuildError>,
172        Duration,
173    ) {
174        (self.index, self.label, self.result, self.entry_elapsed)
175    }
176}
177
178impl<'a> WasmBuildBatchOutcomeEntry<'a> {
179    /// Zero-based position in the supplied labeled specification slice.
180    #[must_use]
181    pub const fn index(self) -> usize {
182        self.index
183    }
184
185    /// Caller-owned stable label.
186    #[must_use]
187    pub const fn label(self) -> &'a str {
188        self.label
189    }
190
191    /// Successful Wasm build outcome.
192    #[must_use]
193    pub const fn outcome(self) -> &'a WasmBuildOutcome {
194        self.outcome
195    }
196
197    /// Complete wall-clock time retained for this successful entry.
198    #[must_use]
199    pub const fn entry_elapsed(self) -> Duration {
200        self.entry_elapsed
201    }
202}
203
204impl<'a> WasmBuildBatchFailure<'a> {
205    /// Zero-based position in the supplied specification slice.
206    #[must_use]
207    pub const fn index(self) -> usize {
208        self.index
209    }
210
211    /// Caller-owned stable label.
212    #[must_use]
213    pub const fn label(self) -> &'a str {
214        self.label
215    }
216
217    /// Structured acquisition failure.
218    #[must_use]
219    pub const fn error(self) -> &'a WasmBuildError {
220        self.error
221    }
222
223    /// Complete wall-clock time retained for this failed entry.
224    #[must_use]
225    pub const fn entry_elapsed(self) -> Duration {
226        self.entry_elapsed
227    }
228}
229
230impl<'a> WasmBuildBatchMaintenanceEntry<'a> {
231    /// Zero-based position in the supplied labeled specification slice.
232    #[must_use]
233    pub const fn index(self) -> usize {
234        self.index
235    }
236
237    /// Caller-owned stable label.
238    #[must_use]
239    pub const fn label(self) -> &'a str {
240        self.label
241    }
242
243    /// Structured shared-target maintenance outcome.
244    #[must_use]
245    pub const fn outcome(self) -> &'a SharedIncrementalTargetMaintenanceOutcome {
246        self.outcome
247    }
248}
249
250/// Aggregate counters and successful-acquisition timings for a Wasm build batch.
251#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
252pub struct WasmBuildBatchMetrics {
253    specifications: usize,
254    succeeded: usize,
255    failed: usize,
256    built: usize,
257    reused: usize,
258    input_resolution_runs: usize,
259    input_resolution_reuses: usize,
260    successful_timings: WasmBuildTimings,
261    total: Duration,
262}
263
264/// Structured progress for an independent sequence of exact Wasm builds.
265#[non_exhaustive]
266#[derive(Clone, Debug, Eq, PartialEq)]
267pub enum WasmBuildBatchProgressEvent {
268    /// One independently resolved build specification is about to start.
269    BuildStarted {
270        /// Zero-based position in the supplied specification slice.
271        index: usize,
272        /// Caller-owned stable label.
273        label: String,
274        /// Total number of supplied specifications.
275        total: usize,
276    },
277    /// Progress forwarded from one independent build.
278    BuildProgress {
279        /// Zero-based position in the supplied specification slice.
280        index: usize,
281        /// Caller-owned stable label.
282        label: String,
283        /// Event emitted by that build.
284        event: WasmBuildProgressEvent,
285    },
286    /// One independent build completed successfully.
287    BuildFinished {
288        /// Zero-based position in the supplied specification slice.
289        index: usize,
290        /// Caller-owned stable label.
291        label: String,
292    },
293    /// One independent build failed.
294    BuildFailed {
295        /// Zero-based position in the supplied specification slice.
296        index: usize,
297        /// Caller-owned stable label.
298        label: String,
299    },
300}
301
302impl WasmBuildBatchReport {
303    /// Ordered labeled entries.
304    #[must_use]
305    pub fn entries(&self) -> &[WasmBuildBatchEntry] {
306        &self.entries
307    }
308
309    /// Consume the report into its ordered labeled entries.
310    #[must_use]
311    pub fn into_entries(self) -> Vec<WasmBuildBatchEntry> {
312        self.entries
313    }
314
315    /// Structured successful entries with labels and wall-clock times.
316    pub fn outcomes(&self) -> impl Iterator<Item = WasmBuildBatchOutcomeEntry<'_>> {
317        self.entries.iter().filter_map(|entry| {
318            entry.outcome().map(|outcome| WasmBuildBatchOutcomeEntry {
319                index: entry.index,
320                label: &entry.label,
321                outcome,
322                entry_elapsed: entry.entry_elapsed,
323            })
324        })
325    }
326
327    /// Structured failed entries with labels and wall-clock times.
328    pub fn failures(&self) -> impl Iterator<Item = WasmBuildBatchFailure<'_>> {
329        self.entries.iter().filter_map(|entry| {
330            entry.error().map(|error| WasmBuildBatchFailure {
331                index: entry.index,
332                label: &entry.label,
333                error,
334                entry_elapsed: entry.entry_elapsed,
335            })
336        })
337    }
338
339    /// Labeled integrated shared-target maintenance outcomes.
340    ///
341    /// Batch-owned maintenance contributes at most one outcome for each
342    /// distinct configured shared-target path.
343    pub fn shared_incremental_maintenance_outcomes(
344        &self,
345    ) -> impl Iterator<Item = WasmBuildBatchMaintenanceEntry<'_>> {
346        self.outcomes().filter_map(|entry| {
347            entry
348                .outcome
349                .record()
350                .shared_incremental_maintenance()
351                .map(|outcome| WasmBuildBatchMaintenanceEntry {
352                    index: entry.index,
353                    label: entry.label,
354                    outcome,
355                })
356        })
357    }
358
359    /// Complete wall-clock time for the sequential collect-all batch.
360    #[must_use]
361    pub const fn total(&self) -> Duration {
362        self.total
363    }
364
365    /// Whether every specification completed successfully.
366    #[must_use]
367    pub fn is_success(&self) -> bool {
368        self.entries.iter().all(WasmBuildBatchEntry::is_success)
369    }
370
371    /// Aggregate outcome, input-resolution reuse, and timing counters.
372    #[must_use]
373    pub fn metrics(&self) -> WasmBuildBatchMetrics {
374        let mut metrics = WasmBuildBatchMetrics {
375            specifications: self.entries.len(),
376            input_resolution_runs: self.input_resolution.runs,
377            input_resolution_reuses: self.input_resolution.reuses,
378            total: self.total,
379            ..WasmBuildBatchMetrics::default()
380        };
381        for entry in &self.entries {
382            match &entry.result {
383                Ok(outcome) => {
384                    metrics.succeeded += 1;
385                    if outcome.is_reused() {
386                        metrics.reused += 1;
387                    } else {
388                        metrics.built += 1;
389                    }
390                    metrics.successful_timings = metrics
391                        .successful_timings
392                        .saturating_add(outcome.record().timings());
393                }
394                Err(_) => metrics.failed += 1,
395            }
396        }
397        metrics
398    }
399}
400
401impl WasmBuildBatchMetrics {
402    /// Number of supplied specifications.
403    #[must_use]
404    pub const fn specifications(self) -> usize {
405        self.specifications
406    }
407
408    /// Number of successful specifications.
409    #[must_use]
410    pub const fn succeeded(self) -> usize {
411        self.succeeded
412    }
413
414    /// Number of failed specifications.
415    #[must_use]
416    pub const fn failed(self) -> usize {
417        self.failed
418    }
419
420    /// Number of newly built Wasm artifact sets.
421    #[must_use]
422    pub const fn built(self) -> usize {
423        self.built
424    }
425
426    /// Number of Wasm artifact sets reused from the exact cache.
427    #[must_use]
428    pub const fn reused(self) -> usize {
429        self.reused
430    }
431
432    /// Number of workspace/toolchain input-resolution snapshots performed.
433    #[must_use]
434    pub const fn input_resolution_runs(self) -> usize {
435        self.input_resolution_runs
436    }
437
438    /// Number of specifications resolved by reusing another batch snapshot.
439    #[must_use]
440    pub const fn input_resolution_reuses(self) -> usize {
441        self.input_resolution_reuses
442    }
443
444    /// Sum of timings from successful acquisitions.
445    #[must_use]
446    pub const fn successful_timings(self) -> WasmBuildTimings {
447        self.successful_timings
448    }
449
450    /// Complete wall-clock time for the sequential batch.
451    #[must_use]
452    pub const fn total(self) -> Duration {
453        self.total
454    }
455}
456
457impl WasmBuildBatchConfig {
458    /// Create batch orchestration without batch-owned target maintenance.
459    #[must_use]
460    pub const fn new() -> Self {
461        Self {
462            shared_incremental_maintenance: None,
463        }
464    }
465
466    /// Maintain each distinct shared target once through its first batch entry.
467    #[must_use]
468    pub const fn with_shared_incremental_target_maintenance(
469        mut self,
470        config: SharedIncrementalTargetMaintenanceConfig,
471    ) -> Self {
472        self.shared_incremental_maintenance = Some(config);
473        self
474    }
475
476    /// Strictly maintain each distinct shared target at most once per interval.
477    #[must_use]
478    pub const fn with_shared_incremental_target_maintenance_at_most_every(
479        self,
480        policy: SharedIncrementalTargetPrunePolicy,
481        minimum_interval: Duration,
482    ) -> Self {
483        self.with_shared_incremental_target_maintenance(
484            SharedIncrementalTargetMaintenanceConfig::new(policy, minimum_interval),
485        )
486    }
487
488    /// Batch-owned shared-target maintenance, when configured.
489    #[must_use]
490    pub const fn shared_incremental_target_maintenance(
491        self,
492    ) -> Option<SharedIncrementalTargetMaintenanceConfig> {
493        self.shared_incremental_maintenance
494    }
495}
496
497impl std::fmt::Display for WasmBuildBatchReport {
498    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
499        let metrics = self.metrics();
500        write!(
501            formatter,
502            "builds={} succeeded={} failed={} built={} reused={} input_resolution_runs={} input_resolution_reuses={} successful_timings=({}) total={:?}",
503            metrics.specifications(),
504            metrics.succeeded(),
505            metrics.failed(),
506            metrics.built(),
507            metrics.reused(),
508            metrics.input_resolution_runs(),
509            metrics.input_resolution_reuses(),
510            metrics.successful_timings(),
511            metrics.total(),
512        )
513    }
514}
515
516/// Build every Wasm specification as an independent Cargo invocation.
517///
518/// Specifications run sequentially and every result is retained. Each entry
519/// keeps its own package set, profile arguments, feature resolution,
520/// fingerprint, locks, and cache policy. Packages are never combined into one
521/// Cargo command because doing so can unify shared dependency features.
522pub fn build_wasm_canisters_cached_batch(
523    specs: &[LabeledWasmBuildSpec],
524) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError> {
525    build_wasm_canisters_cached_batch_with_config(specs, WasmBuildBatchConfig::new())
526}
527
528/// Build an independent Wasm batch with shared batch orchestration.
529///
530/// Batch-owned maintenance is attached only to the first specification for
531/// each distinct configured shared-target path. Isolated specifications are
532/// unaffected. An entry mixing batch-owned and per-spec integrated maintenance
533/// reports an indexed error without preventing later entries from running.
534pub fn build_wasm_canisters_cached_batch_with_config(
535    specs: &[LabeledWasmBuildSpec],
536    config: WasmBuildBatchConfig,
537) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError> {
538    validate_batch_labels(specs)?;
539    let build_specs = specs
540        .iter()
541        .map(|labeled| labeled.spec.clone())
542        .collect::<Vec<_>>();
543    let mut resolver = WasmBuildBatchInputResolver::new(&build_specs);
544    let mut report = build_wasm_batch(specs, config, |spec, index| {
545        build_wasm_canisters_cached_in_batch(spec, index, &mut resolver)
546    });
547    report.input_resolution = resolver.metrics();
548    Ok(report)
549}
550
551/// Build an independent Wasm batch while forwarding structured progress.
552///
553/// The same observation configuration is applied to every entry. Batch events
554/// identify the originating specification without altering the standalone
555/// build semantics.
556pub fn build_wasm_canisters_cached_batch_with_progress<F>(
557    specs: &[LabeledWasmBuildSpec],
558    config: WasmBuildProgressConfig,
559    observer: F,
560) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError>
561where
562    F: FnMut(WasmBuildBatchProgressEvent),
563{
564    build_wasm_canisters_cached_batch_with_config_and_progress(
565        specs,
566        WasmBuildBatchConfig::new(),
567        config,
568        observer,
569    )
570}
571
572/// Build a configured independent Wasm batch while forwarding structured progress.
573pub fn build_wasm_canisters_cached_batch_with_config_and_progress<F>(
574    specs: &[LabeledWasmBuildSpec],
575    batch_config: WasmBuildBatchConfig,
576    progress_config: WasmBuildProgressConfig,
577    mut observer: F,
578) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError>
579where
580    F: FnMut(WasmBuildBatchProgressEvent),
581{
582    validate_batch_labels(specs)?;
583    let count = specs.len();
584    let build_specs = specs
585        .iter()
586        .map(|labeled| labeled.spec.clone())
587        .collect::<Vec<_>>();
588    let mut resolver = WasmBuildBatchInputResolver::new(&build_specs);
589    let mut report = build_wasm_batch(specs, batch_config, |spec, index| {
590        let label = specs[index].label.clone();
591        observer(WasmBuildBatchProgressEvent::BuildStarted {
592            index,
593            label: label.clone(),
594            total: count,
595        });
596        let result = build_wasm_canisters_cached_in_batch_with_progress(
597            spec,
598            index,
599            &mut resolver,
600            progress_config,
601            |event| {
602                observer(WasmBuildBatchProgressEvent::BuildProgress {
603                    index,
604                    label: label.clone(),
605                    event,
606                });
607            },
608        );
609        observer(match result {
610            Ok(_) => WasmBuildBatchProgressEvent::BuildFinished { index, label },
611            Err(_) => WasmBuildBatchProgressEvent::BuildFailed { index, label },
612        });
613        result
614    });
615    report.input_resolution = resolver.metrics();
616    Ok(report)
617}
618
619fn build_wasm_batch<F>(
620    specs: &[LabeledWasmBuildSpec],
621    config: WasmBuildBatchConfig,
622    mut build: F,
623) -> WasmBuildBatchReport
624where
625    F: FnMut(&WasmBuildSpec, usize) -> Result<WasmBuildOutcome, WasmBuildError>,
626{
627    let started = Instant::now();
628    let mut entries = Vec::with_capacity(specs.len());
629    let mut maintenance = BatchMaintenanceTracker::new(config.shared_incremental_maintenance);
630    for (index, labeled) in specs.iter().enumerate() {
631        let entry_started = Instant::now();
632        let spec = &labeled.spec;
633        if config.shared_incremental_maintenance.is_some()
634            && spec.shared_incremental_target_maintenance().is_some()
635        {
636            entries.push(WasmBuildBatchEntry {
637                index,
638                label: labeled.label.clone(),
639                result: Err(batch_maintenance_ownership_error()),
640                entry_elapsed: entry_started.elapsed(),
641            });
642            continue;
643        }
644        let configured = maintenance.prepare_spec(spec);
645        let result = build(configured.as_ref().unwrap_or(spec), index);
646        entries.push(WasmBuildBatchEntry {
647            index,
648            label: labeled.label.clone(),
649            result,
650            entry_elapsed: entry_started.elapsed(),
651        });
652    }
653    WasmBuildBatchReport {
654        entries,
655        input_resolution: WasmBuildBatchInputMetrics::default(),
656        total: started.elapsed(),
657    }
658}
659
660fn validate_batch_labels(
661    specs: &[LabeledWasmBuildSpec],
662) -> Result<(), WasmBuildBatchContractError> {
663    let mut labels = HashMap::with_capacity(specs.len());
664    for (index, labeled) in specs.iter().enumerate() {
665        if labeled.label.is_empty() {
666            return Err(WasmBuildBatchContractError::EmptyLabel { index });
667        }
668        if let Some(first_index) = labels.get(labeled.label.as_str()) {
669            return Err(WasmBuildBatchContractError::DuplicateLabel {
670                label: labeled.label.clone(),
671                first_index: *first_index,
672                duplicate_index: index,
673            });
674        }
675        labels.insert(labeled.label.as_str(), index);
676    }
677    Ok(())
678}
679
680struct BatchMaintenanceTracker {
681    config: Option<SharedIncrementalTargetMaintenanceConfig>,
682    configured_targets: HashSet<PathBuf>,
683}
684
685impl BatchMaintenanceTracker {
686    fn new(config: Option<SharedIncrementalTargetMaintenanceConfig>) -> Self {
687        Self {
688            config,
689            configured_targets: HashSet::new(),
690        }
691    }
692
693    fn prepare_spec(&mut self, spec: &WasmBuildSpec) -> Option<WasmBuildSpec> {
694        let config = self.config?;
695        debug_assert!(spec.shared_incremental_target_maintenance().is_none());
696        let WasmBuildCacheMode::SharedIncremental { target_dir } = spec.cache_mode() else {
697            return None;
698        };
699        if !self.configured_targets.insert(target_dir.clone()) {
700            return None;
701        }
702        Some(
703            spec.clone()
704                .with_shared_incremental_target_maintenance(config),
705        )
706    }
707}
708
709fn batch_maintenance_ownership_error() -> WasmBuildError {
710    WasmBuildError::InvalidSpec {
711        message:
712            "batch-owned shared-target maintenance cannot be combined with per-spec maintenance"
713                .to_owned(),
714    }
715}
716
717impl std::fmt::Display for WasmBuildBatchContractError {
718    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
719        match self {
720            Self::EmptyLabel { index } => {
721                write!(formatter, "Wasm batch label at index {index} is empty")
722            }
723            Self::DuplicateLabel {
724                label,
725                first_index,
726                duplicate_index,
727            } => write!(
728                formatter,
729                "Wasm batch label {label:?} at index {duplicate_index} duplicates index {first_index}",
730            ),
731        }
732    }
733}
734
735impl std::error::Error for WasmBuildBatchContractError {}
736
737#[cfg(test)]
738mod tests;