Skip to main content

ic_build/artifacts/
wasm_batch.rs

1use super::cache_fs::{CacheRetentionLimits, canonicalize_allow_missing};
2use crate::batch::{BatchLabelError, validate_labels};
3
4use std::{
5    collections::HashSet,
6    marker::PhantomData,
7    path::PathBuf,
8    time::{Duration, Instant},
9};
10
11use super::wasm_cache::{
12    SharedIncrementalTargetMaintenanceConfig, SharedIncrementalTargetMaintenanceOutcome,
13    WasmBuildBatchInputMetrics, WasmBuildBatchInputResolver, WasmBuildError,
14    WasmBuildFailureDetails, WasmBuildInputReuse, WasmBuildInputSnapshotState, WasmBuildOutcome,
15    WasmBuildProgressConfig, WasmBuildProgressEvent, WasmBuildSessionState, WasmBuildSpec,
16    WasmBuildTimings, WasmInputResolutionTimings, build_wasm_canisters_cached_in_batch,
17    build_wasm_canisters_cached_in_batch_with_progress, shared_incremental_target,
18};
19
20/// Orchestration shared by every entry in one independent Wasm build batch.
21#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
22pub struct WasmBuildBatchConfig {
23    shared_incremental_maintenance: Option<SharedIncrementalTargetMaintenanceConfig>,
24}
25
26/// Caller-labeled specification for one exact Wasm batch entry.
27///
28/// The label is report and progress identity only; it does not alter the
29/// underlying exact Wasm fingerprint or cache key.
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct LabeledWasmBuildSpec {
32    label: String,
33    spec: WasmBuildSpec,
34}
35
36/// Ordered outcomes and failures from a collect-all Wasm build batch.
37#[derive(Debug)]
38pub struct WasmBuildBatchReport {
39    entries: Vec<WasmBuildBatchEntry>,
40    input_resolution: WasmBuildBatchInputMetrics,
41    total: Duration,
42}
43
44/// Explicit cross-call input snapshot scoped to a caller-held source lease.
45///
46/// The session contains no global state. It may reuse successful Cargo/rustc
47/// identity, metadata, input-discovery, and content-digest work while the
48/// caller keeps the supplied write-exclusion guard alive and unchanged.
49pub struct WasmBuildSession<'guard> {
50    state: WasmBuildSessionState,
51    _source_guard: PhantomData<&'guard ()>,
52}
53
54/// Immutable prepared Cargo input resolution shared by concurrent readers.
55///
56/// Preparation resolves the complete declared specification set while the
57/// caller holds a genuine source write-exclusion guard. Reader batches may run
58/// concurrently through `&self`, but cannot introduce specifications that
59/// were not declared during preparation.
60pub struct WasmBuildInputSnapshot<'guard> {
61    state: WasmBuildInputSnapshotState,
62    _source_guard: PhantomData<&'guard ()>,
63}
64
65/// Aggregate state retained by one explicit Wasm build session.
66#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
67pub struct WasmBuildSessionMetrics {
68    snapshots: usize,
69    snapshot_reuses: usize,
70    invalidated: bool,
71}
72
73/// Preparation and reader-reuse counters for one immutable input snapshot.
74#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
75pub struct WasmBuildInputSnapshotMetrics {
76    specifications: usize,
77    input_resolution_runs: usize,
78    input_resolution_reuses: usize,
79    input_resolution_timings: WasmInputResolutionTimings,
80    reader_reuses: usize,
81    invalidated: bool,
82}
83
84/// One ordered caller-labeled result from a Wasm build batch.
85#[derive(Debug)]
86pub struct WasmBuildBatchEntry {
87    index: usize,
88    label: String,
89    result: Result<WasmBuildOutcome, (WasmBuildError, WasmBuildFailureDetails)>,
90    entry_elapsed: Duration,
91}
92
93/// Structural error that prevents a labeled Wasm batch from starting.
94#[non_exhaustive]
95#[derive(Clone, Debug, Eq, PartialEq)]
96pub enum WasmBuildBatchContractError {
97    /// Invalid input labels, rejected before source validation or command effects.
98    Labels(BatchLabelError),
99    /// A source mutation invalidated the caller's immutable-source lease.
100    SourceLeaseInvalidated,
101    /// A prepared snapshot reader requested a specification absent at preparation.
102    SpecificationNotPrepared {
103        /// Zero-based position of the undeclared entry.
104        index: usize,
105        /// Caller-owned label of the undeclared entry.
106        label: String,
107    },
108}
109
110impl LabeledWasmBuildSpec {
111    /// Attach a caller-owned stable label to one Wasm build specification.
112    #[must_use]
113    pub fn new(label: impl Into<String>, spec: WasmBuildSpec) -> Self {
114        Self {
115            label: label.into(),
116            spec,
117        }
118    }
119
120    /// Caller-owned report and progress label.
121    #[must_use]
122    pub fn label(&self) -> &str {
123        &self.label
124    }
125
126    /// Underlying exact Wasm build specification.
127    #[must_use]
128    pub const fn spec(&self) -> &WasmBuildSpec {
129        &self.spec
130    }
131
132    /// Consume the entry into its label and Wasm build specification.
133    #[must_use]
134    pub fn into_parts(self) -> (String, WasmBuildSpec) {
135        (self.label, self.spec)
136    }
137}
138
139impl<'guard> WasmBuildSession<'guard> {
140    /// Assert source immutability and bind reuse to the supplied guard's lifetime.
141    ///
142    /// The guard must prevent mutation of every Cargo/rustc executable,
143    /// manifest, configuration file, discovered source, declared additional
144    /// input, and relevant environment value used by every specification sent
145    /// through this session. The guard must remain held until the session is
146    /// dropped. This method cannot verify the guard's provenance; supplying an
147    /// unrelated value can permit stale cache reuse.
148    #[must_use]
149    pub fn assume_sources_immutable<Guard: ?Sized>(_source_write_guard: &'guard Guard) -> Self {
150        Self {
151            state: WasmBuildSessionState::new(),
152            _source_guard: PhantomData,
153        }
154    }
155
156    /// Build a collect-all batch with explicit orchestration and optional progress.
157    ///
158    /// Source lease, feature isolation and retention match independent batches.
159    /// `spawn` selects Cargo child/group custody and cleanup, independently of progress.
160    /// Prepared snapshots reject specifications absent during preparation.
161    ///
162    /// # Errors
163    /// Returns invalid labels, an invalidated lease or an unprepared specification
164    /// error before work. Individual build failures remain in the report.
165    pub fn build_batch(
166        &mut self,
167        specs: &[LabeledWasmBuildSpec],
168        config: WasmBuildBatchConfig,
169        progress: Option<(
170            WasmBuildProgressConfig,
171            &mut dyn FnMut(WasmBuildBatchProgressEvent),
172        )>,
173        mut spawn: impl FnMut(
174            &mut std::process::Command,
175        ) -> std::io::Result<ic_host_process::child::OwnedChild>,
176    ) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError> {
177        run_wasm_batch(
178            specs,
179            config,
180            Some(WasmBuildInputReuse::Session(&mut self.state)),
181            progress,
182            &mut spawn,
183        )
184    }
185
186    /// Current retained snapshot, reuse, and invalidation counters.
187    #[must_use]
188    pub const fn metrics(&self) -> WasmBuildSessionMetrics {
189        WasmBuildSessionMetrics {
190            snapshots: self.state.snapshot_count(),
191            snapshot_reuses: self.state.snapshot_reuses(),
192            invalidated: self.state.is_invalidated(),
193        }
194    }
195}
196
197impl WasmBuildSessionMetrics {
198    /// Number of successful exact specification snapshots currently retained.
199    #[must_use]
200    pub const fn snapshots(self) -> usize {
201        self.snapshots
202    }
203
204    /// Number of later entries resolved from a retained snapshot.
205    #[must_use]
206    pub const fn snapshot_reuses(self) -> usize {
207        self.snapshot_reuses
208    }
209
210    /// Whether a detected source race permanently invalidated this session.
211    #[must_use]
212    pub const fn is_invalidated(self) -> bool {
213        self.invalidated
214    }
215}
216
217impl<'guard> WasmBuildInputSnapshot<'guard> {
218    /// Resolve and freeze the complete specification set under a source lease.
219    ///
220    /// The guard must prevent mutation of every Cargo/rustc executable,
221    /// manifest, configuration file, discovered source, declared additional
222    /// input, and relevant environment value used by the supplied
223    /// specifications. The type system cannot verify guard provenance.
224    ///
225    /// # Errors
226    /// Returns specification, tool/metadata, filesystem or input-resolution errors.
227    pub fn prepare_assuming_sources_immutable<Guard: ?Sized>(
228        _source_write_guard: &'guard Guard,
229        specs: &[WasmBuildSpec],
230    ) -> Result<Self, WasmBuildError> {
231        Ok(Self {
232            state: WasmBuildInputSnapshotState::prepare(specs)?,
233            _source_guard: PhantomData,
234        })
235    }
236
237    /// Build a collect-all batch with explicit orchestration and optional progress.
238    ///
239    /// Source lease, feature isolation and retention match independent batches.
240    /// `spawn` selects Cargo child/group custody and cleanup, independently of progress.
241    /// Prepared snapshots reject specifications absent during preparation.
242    ///
243    /// # Errors
244    /// Returns invalid labels, an invalidated lease or an unprepared specification
245    /// error before work. Individual build failures remain in the report.
246    pub fn build_batch(
247        &self,
248        specs: &[LabeledWasmBuildSpec],
249        config: WasmBuildBatchConfig,
250        progress: Option<(
251            WasmBuildProgressConfig,
252            &mut dyn FnMut(WasmBuildBatchProgressEvent),
253        )>,
254        mut spawn: impl FnMut(
255            &mut std::process::Command,
256        ) -> std::io::Result<ic_host_process::child::OwnedChild>,
257    ) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError> {
258        run_wasm_batch(
259            specs,
260            config,
261            Some(WasmBuildInputReuse::Snapshot(&self.state)),
262            progress,
263            &mut spawn,
264        )
265    }
266
267    /// Current preparation, reader-reuse, and invalidation metrics.
268    #[must_use]
269    pub fn metrics(&self) -> WasmBuildInputSnapshotMetrics {
270        let preparation = self.state.preparation_metrics();
271        WasmBuildInputSnapshotMetrics {
272            specifications: self.state.specification_count(),
273            input_resolution_runs: preparation.runs,
274            input_resolution_reuses: preparation.reuses,
275            input_resolution_timings: self.state.preparation_timings(),
276            reader_reuses: self.state.reader_reuses(),
277            invalidated: self.state.is_invalidated(),
278        }
279    }
280}
281
282impl WasmBuildInputSnapshotMetrics {
283    /// Number of exact specifications captured during preparation.
284    #[must_use]
285    pub const fn specifications(self) -> usize {
286        self.specifications
287    }
288
289    /// Number of workspace/toolchain resolution snapshots prepared.
290    #[must_use]
291    pub const fn input_resolution_runs(self) -> usize {
292        self.input_resolution_runs
293    }
294
295    /// Number of prepared specifications sharing another resolution run.
296    #[must_use]
297    pub const fn input_resolution_reuses(self) -> usize {
298        self.input_resolution_reuses
299    }
300
301    /// Complete tool, metadata, discovery, and hashing preparation timings.
302    #[must_use]
303    pub const fn input_resolution_timings(self) -> WasmInputResolutionTimings {
304        self.input_resolution_timings
305    }
306
307    /// Cumulative exact specification resolutions served to readers.
308    #[must_use]
309    pub const fn reader_reuses(self) -> usize {
310        self.reader_reuses
311    }
312
313    /// Whether any reader detected a violation of the source lease.
314    #[must_use]
315    pub const fn is_invalidated(self) -> bool {
316        self.invalidated
317    }
318}
319
320impl WasmBuildBatchEntry {
321    /// Zero-based position in the supplied labeled specification slice.
322    #[must_use]
323    pub const fn index(&self) -> usize {
324        self.index
325    }
326
327    /// Caller-owned stable label.
328    #[must_use]
329    pub fn label(&self) -> &str {
330        &self.label
331    }
332
333    /// Structured success or failure for this entry.
334    ///
335    /// # Errors
336    /// Returns this entry's recorded build error when the entry failed.
337    pub const fn result(&self) -> Result<&WasmBuildOutcome, &WasmBuildError> {
338        match &self.result {
339            Ok(outcome) => Ok(outcome),
340            Err((error, _)) => Err(error),
341        }
342    }
343
344    /// Successful Wasm outcome, when this entry succeeded.
345    #[must_use]
346    pub fn outcome(&self) -> Option<&WasmBuildOutcome> {
347        self.result.as_ref().ok()
348    }
349
350    /// Structured build failure, when this entry failed.
351    #[must_use]
352    pub fn error(&self) -> Option<&WasmBuildError> {
353        self.result().err()
354    }
355
356    /// Structured phase and partial timings when this entry failed.
357    #[must_use]
358    pub const fn failure_details(&self) -> Option<WasmBuildFailureDetails> {
359        match &self.result {
360            Ok(_) => None,
361            Err((_, details)) => Some(*details),
362        }
363    }
364
365    /// Complete wall-clock time retained for this entry.
366    #[must_use]
367    pub const fn entry_elapsed(&self) -> Duration {
368        self.entry_elapsed
369    }
370
371    /// Whether this entry completed successfully.
372    #[must_use]
373    pub const fn is_success(&self) -> bool {
374        self.result.is_ok()
375    }
376
377    /// Consume the entry into its identity, result, optional failure details, and wall time.
378    pub fn into_parts(
379        self,
380    ) -> (
381        usize,
382        String,
383        Result<WasmBuildOutcome, WasmBuildError>,
384        Option<WasmBuildFailureDetails>,
385        Duration,
386    ) {
387        let (result, failure) = match self.result {
388            Ok(outcome) => (Ok(outcome), None),
389            Err((error, details)) => (Err(error), Some(details)),
390        };
391        (self.index, self.label, result, failure, self.entry_elapsed)
392    }
393}
394
395/// Aggregate counters and successful-acquisition timings for a Wasm build batch.
396#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
397pub struct WasmBuildBatchMetrics {
398    specifications: usize,
399    built: usize,
400    reused: usize,
401    input_resolution_runs: usize,
402    input_resolution_reuses: usize,
403    input_resolution_session_reuses: usize,
404    input_resolution_prepared_reuses: usize,
405    successful_timings: WasmBuildTimings,
406    total: Duration,
407}
408
409/// Structured progress for an independent sequence of exact Wasm builds.
410#[non_exhaustive]
411#[derive(Clone, Debug, Eq, PartialEq)]
412pub enum WasmBuildBatchProgressEvent {
413    /// One independently resolved build specification is about to start.
414    BuildStarted {
415        /// Zero-based position in the supplied specification slice.
416        index: usize,
417        /// Caller-owned stable label.
418        label: String,
419        /// Total number of supplied specifications.
420        total: usize,
421    },
422    /// Progress forwarded from one independent build.
423    BuildProgress {
424        /// Zero-based position in the supplied specification slice.
425        index: usize,
426        /// Caller-owned stable label.
427        label: String,
428        /// Event emitted by that build.
429        event: WasmBuildProgressEvent,
430    },
431    /// One independent build completed successfully.
432    BuildFinished {
433        /// Zero-based position in the supplied specification slice.
434        index: usize,
435        /// Caller-owned stable label.
436        label: String,
437    },
438    /// One independent build failed.
439    BuildFailed {
440        /// Zero-based position in the supplied specification slice.
441        index: usize,
442        /// Caller-owned stable label.
443        label: String,
444    },
445}
446
447impl WasmBuildBatchReport {
448    /// Ordered labeled entries.
449    #[must_use]
450    pub fn entries(&self) -> &[WasmBuildBatchEntry] {
451        &self.entries
452    }
453
454    /// Consume the report into its ordered labeled entries.
455    #[must_use]
456    pub fn into_entries(self) -> Vec<WasmBuildBatchEntry> {
457        self.entries
458    }
459
460    /// Successful entries, retaining the canonical identity, result and timings.
461    pub fn outcomes(&self) -> impl Iterator<Item = &WasmBuildBatchEntry> {
462        self.entries.iter().filter(|entry| entry.is_success())
463    }
464
465    /// Failed entries; inspect `error()` and `failure_details()` on each entry.
466    pub fn failures(&self) -> impl Iterator<Item = &WasmBuildBatchEntry> {
467        self.entries.iter().filter(|entry| !entry.is_success())
468    }
469
470    /// Canonical entries paired with their integrated shared-target maintenance.
471    ///
472    /// Batch-owned maintenance contributes at most one outcome for each
473    /// distinct configured shared-target path.
474    pub fn shared_incremental_maintenance_outcomes(
475        &self,
476    ) -> impl Iterator<
477        Item = (
478            &WasmBuildBatchEntry,
479            &SharedIncrementalTargetMaintenanceOutcome,
480        ),
481    > {
482        self.outcomes().filter_map(|entry| {
483            entry
484                .outcome()?
485                .record()
486                .shared_incremental_maintenance()
487                .map(|outcome| (entry, outcome))
488        })
489    }
490
491    /// Complete wall-clock time for the sequential collect-all batch.
492    #[must_use]
493    pub const fn total(&self) -> Duration {
494        self.total
495    }
496
497    /// Whether every specification completed successfully.
498    #[must_use]
499    pub fn is_success(&self) -> bool {
500        self.entries.iter().all(WasmBuildBatchEntry::is_success)
501    }
502
503    /// Aggregate outcome, input-resolution reuse, and timing counters.
504    #[must_use]
505    pub fn metrics(&self) -> WasmBuildBatchMetrics {
506        let mut metrics = WasmBuildBatchMetrics {
507            specifications: self.entries.len(),
508            input_resolution_runs: self.input_resolution.runs,
509            input_resolution_reuses: self.input_resolution.reuses,
510            input_resolution_session_reuses: self.input_resolution.session_reuses,
511            input_resolution_prepared_reuses: self.input_resolution.prepared_reuses,
512            total: self.total,
513            ..WasmBuildBatchMetrics::default()
514        };
515        for outcome in self.entries.iter().filter_map(WasmBuildBatchEntry::outcome) {
516            if outcome.is_reused() {
517                metrics.reused += 1;
518            } else {
519                metrics.built += 1;
520            }
521            metrics.successful_timings = metrics
522                .successful_timings
523                .saturating_add(outcome.record().timings());
524        }
525        metrics
526    }
527}
528
529impl WasmBuildBatchMetrics {
530    /// Number of supplied specifications.
531    #[must_use]
532    pub const fn specifications(self) -> usize {
533        self.specifications
534    }
535
536    /// Number of successful specifications.
537    #[must_use]
538    pub const fn succeeded(self) -> usize {
539        self.built + self.reused
540    }
541
542    /// Number of failed specifications.
543    #[must_use]
544    pub const fn failed(self) -> usize {
545        self.specifications - self.succeeded()
546    }
547
548    /// Number of newly built Wasm artifact sets.
549    #[must_use]
550    pub const fn built(self) -> usize {
551        self.built
552    }
553
554    /// Number of Wasm artifact sets reused from the exact cache.
555    #[must_use]
556    pub const fn reused(self) -> usize {
557        self.reused
558    }
559
560    /// Number of workspace/toolchain input-resolution snapshots performed.
561    #[must_use]
562    pub const fn input_resolution_runs(self) -> usize {
563        self.input_resolution_runs
564    }
565
566    /// Number of specifications resolved by reusing another batch snapshot.
567    #[must_use]
568    pub const fn input_resolution_reuses(self) -> usize {
569        self.input_resolution_reuses
570    }
571
572    /// Number of specifications resolved from an explicit session snapshot.
573    #[must_use]
574    pub const fn input_resolution_session_reuses(self) -> usize {
575        self.input_resolution_session_reuses
576    }
577
578    /// Number of specifications resolved from a prepared concurrent snapshot.
579    #[must_use]
580    pub const fn input_resolution_prepared_reuses(self) -> usize {
581        self.input_resolution_prepared_reuses
582    }
583
584    /// Sum of timings from successful acquisitions.
585    #[must_use]
586    pub const fn successful_timings(self) -> WasmBuildTimings {
587        self.successful_timings
588    }
589
590    /// Complete wall-clock time for the sequential batch.
591    #[must_use]
592    pub const fn total(self) -> Duration {
593        self.total
594    }
595}
596
597impl WasmBuildBatchConfig {
598    /// Create batch orchestration without batch-owned target maintenance.
599    #[must_use]
600    pub const fn new() -> Self {
601        Self {
602            shared_incremental_maintenance: None,
603        }
604    }
605
606    /// Maintain each distinct resolved shared target once through its first batch entry.
607    #[must_use]
608    pub const fn with_shared_incremental_target_maintenance(
609        mut self,
610        config: SharedIncrementalTargetMaintenanceConfig,
611    ) -> Self {
612        self.shared_incremental_maintenance = Some(config);
613        self
614    }
615
616    /// Strictly maintain each distinct shared target at most once per interval.
617    #[must_use]
618    pub const fn with_shared_incremental_target_maintenance_at_most_every(
619        self,
620        policy: CacheRetentionLimits,
621        minimum_interval: Duration,
622    ) -> Self {
623        self.with_shared_incremental_target_maintenance(
624            SharedIncrementalTargetMaintenanceConfig::new(policy, minimum_interval),
625        )
626    }
627
628    /// Batch-owned shared-target maintenance, when configured.
629    #[must_use]
630    pub const fn shared_incremental_target_maintenance(
631        self,
632    ) -> Option<SharedIncrementalTargetMaintenanceConfig> {
633        self.shared_incremental_maintenance
634    }
635}
636
637impl std::fmt::Display for WasmBuildBatchReport {
638    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
639        let metrics = self.metrics();
640        write!(
641            formatter,
642            "builds={} succeeded={} failed={} built={} reused={} input_resolution_runs={} input_resolution_reuses={} input_resolution_session_reuses={} input_resolution_prepared_reuses={} successful_timings=({}) total={:?}",
643            metrics.specifications(),
644            metrics.succeeded(),
645            metrics.failed(),
646            metrics.built(),
647            metrics.reused(),
648            metrics.input_resolution_runs(),
649            metrics.input_resolution_reuses(),
650            metrics.input_resolution_session_reuses(),
651            metrics.input_resolution_prepared_reuses(),
652            metrics.successful_timings(),
653            metrics.total(),
654        )
655    }
656}
657
658/// Build every Wasm specification as an independent Cargo invocation.
659///
660/// Pass explicit batch configuration and optional progress reporting.
661/// `spawn` selects Cargo child/group custody and cleanup for each cache miss,
662/// independently of progress; it may override the default null stdin.
663/// Specifications run sequentially and every result is retained. Each entry
664/// keeps its own package set, profile arguments, feature resolution,
665/// fingerprint, locks, and cache policy. Packages are never combined into one
666/// Cargo command because doing so can unify shared dependency features.
667///
668/// # Errors
669/// Returns a contract error for an empty or duplicate label before any work.
670/// Individual build failures are retained in the returned report.
671pub fn build_wasm_canisters_cached_batch(
672    specs: &[LabeledWasmBuildSpec],
673    config: WasmBuildBatchConfig,
674    progress: Option<(
675        WasmBuildProgressConfig,
676        &mut dyn FnMut(WasmBuildBatchProgressEvent),
677    )>,
678    mut spawn: impl FnMut(
679        &mut std::process::Command,
680    ) -> std::io::Result<ic_host_process::child::OwnedChild>,
681) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError> {
682    run_wasm_batch(specs, config, None, progress, &mut spawn)
683}
684
685#[expect(
686    clippy::result_large_err,
687    reason = "Batch entries retain complete failure details inline without per-failure allocation."
688)]
689fn run_wasm_batch(
690    specs: &[LabeledWasmBuildSpec],
691    batch_config: WasmBuildBatchConfig,
692    reuse: Option<WasmBuildInputReuse<'_>>,
693    mut observation: Option<(
694        WasmBuildProgressConfig,
695        &mut dyn FnMut(WasmBuildBatchProgressEvent),
696    )>,
697    mut spawn: impl FnMut(
698        &mut std::process::Command,
699    ) -> std::io::Result<ic_host_process::child::OwnedChild>,
700) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError> {
701    validate_labels(specs.iter().map(LabeledWasmBuildSpec::label))
702        .map_err(WasmBuildBatchContractError::Labels)?;
703    validate_input_reuse(specs, reuse.as_ref())?;
704    let count = specs.len();
705    let mut resolver =
706        WasmBuildBatchInputResolver::new(specs.iter().map(LabeledWasmBuildSpec::spec), reuse);
707    let mut report = build_wasm_batch(specs, batch_config, |spec, index| {
708        let Some((progress_config, observer)) = observation.as_mut() else {
709            return build_wasm_canisters_cached_in_batch(spec, index, &mut resolver, &mut spawn);
710        };
711        let label = specs[index].label.clone();
712        observer(WasmBuildBatchProgressEvent::BuildStarted {
713            index,
714            label: label.clone(),
715            total: count,
716        });
717        let attempt = build_wasm_canisters_cached_in_batch_with_progress(
718            spec,
719            index,
720            &mut resolver,
721            *progress_config,
722            |event| {
723                observer(WasmBuildBatchProgressEvent::BuildProgress {
724                    index,
725                    label: label.clone(),
726                    event,
727                });
728            },
729            &mut spawn,
730        );
731        observer(match &attempt {
732            Ok(_) => WasmBuildBatchProgressEvent::BuildFinished { index, label },
733            Err(_) => WasmBuildBatchProgressEvent::BuildFailed { index, label },
734        });
735        attempt
736    });
737    report.input_resolution = resolver.metrics();
738    Ok(report)
739}
740
741fn build_wasm_batch<F>(
742    specs: &[LabeledWasmBuildSpec],
743    config: WasmBuildBatchConfig,
744    mut build: F,
745) -> WasmBuildBatchReport
746where
747    F: FnMut(
748        &WasmBuildSpec,
749        usize,
750    ) -> Result<WasmBuildOutcome, (WasmBuildError, WasmBuildFailureDetails)>,
751{
752    let started = Instant::now();
753    let mut entries = Vec::with_capacity(specs.len());
754    let mut maintenance = BatchMaintenanceTracker::new(config.shared_incremental_maintenance);
755    for (index, labeled) in specs.iter().enumerate() {
756        let entry_started = Instant::now();
757        let spec = &labeled.spec;
758        let result = if config.shared_incremental_maintenance.is_some()
759            && spec.shared_incremental_target_maintenance().is_some()
760        {
761            Err((
762                batch_maintenance_ownership_error(),
763                WasmBuildFailureDetails::specification(entry_started.elapsed()),
764            ))
765        } else {
766            match maintenance.prepare_spec(spec) {
767                Ok(configured) => build(configured.as_ref().unwrap_or(spec), index),
768                Err(error) => Err((
769                    error,
770                    WasmBuildFailureDetails::specification(entry_started.elapsed()),
771                )),
772            }
773        };
774        entries.push(WasmBuildBatchEntry {
775            index,
776            label: labeled.label.clone(),
777            result,
778            entry_elapsed: entry_started.elapsed(),
779        });
780    }
781    WasmBuildBatchReport {
782        entries,
783        input_resolution: WasmBuildBatchInputMetrics::default(),
784        total: started.elapsed(),
785    }
786}
787
788fn validate_input_reuse(
789    specs: &[LabeledWasmBuildSpec],
790    reuse: Option<&WasmBuildInputReuse<'_>>,
791) -> Result<(), WasmBuildBatchContractError> {
792    match reuse {
793        Some(WasmBuildInputReuse::Session(session)) if session.is_invalidated() => {
794            Err(WasmBuildBatchContractError::SourceLeaseInvalidated)
795        }
796        Some(WasmBuildInputReuse::Snapshot(snapshot)) if snapshot.is_invalidated() => {
797            Err(WasmBuildBatchContractError::SourceLeaseInvalidated)
798        }
799        Some(WasmBuildInputReuse::Snapshot(snapshot)) => {
800            for (index, labeled) in specs.iter().enumerate() {
801                if !snapshot.contains(&labeled.spec) {
802                    return Err(WasmBuildBatchContractError::SpecificationNotPrepared {
803                        index,
804                        label: labeled.label.clone(),
805                    });
806                }
807            }
808            Ok(())
809        }
810        _ => Ok(()),
811    }
812}
813
814struct BatchMaintenanceTracker {
815    config: Option<SharedIncrementalTargetMaintenanceConfig>,
816    configured_targets: HashSet<PathBuf>,
817}
818
819impl BatchMaintenanceTracker {
820    fn new(config: Option<SharedIncrementalTargetMaintenanceConfig>) -> Self {
821        Self {
822            config,
823            configured_targets: HashSet::new(),
824        }
825    }
826
827    fn prepare_spec(
828        &mut self,
829        spec: &WasmBuildSpec,
830    ) -> Result<Option<WasmBuildSpec>, WasmBuildError> {
831        let Some(config) = self.config else {
832            return Ok(None);
833        };
834        debug_assert!(spec.shared_incremental_target_maintenance().is_none());
835        let Some(target_dir) = shared_incremental_target(spec) else {
836            return Ok(None);
837        };
838        let canonical =
839            canonicalize_allow_missing(&target_dir).map_err(|source| WasmBuildError::Io {
840                operation: "resolve batch shared incremental target",
841                path: target_dir,
842                source,
843            })?;
844        if !self.configured_targets.insert(canonical) {
845            return Ok(None);
846        }
847        Ok(Some(
848            spec.clone()
849                .with_shared_incremental_target_maintenance(config),
850        ))
851    }
852}
853
854fn batch_maintenance_ownership_error() -> WasmBuildError {
855    WasmBuildError::InvalidSpec {
856        message:
857            "batch-owned shared-target maintenance cannot be combined with per-spec maintenance"
858                .to_owned(),
859    }
860}
861
862impl std::fmt::Display for WasmBuildBatchContractError {
863    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
864        match self {
865            Self::Labels(source) => write!(formatter, "Wasm batch {source}"),
866            Self::SourceLeaseInvalidated => formatter
867                .write_str("Wasm build source lease was invalidated by a detected input mutation"),
868            Self::SpecificationNotPrepared { index, label } => write!(
869                formatter,
870                "Wasm batch entry {label:?} at index {index} was not declared when the input snapshot was prepared",
871            ),
872        }
873    }
874}
875
876impl std::error::Error for WasmBuildBatchContractError {
877    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
878        match self {
879            Self::Labels(source) => Some(source),
880            _ => None,
881        }
882    }
883}
884
885#[cfg(test)]
886mod tests;