Skip to main content

ic_build/artifacts/
wasm_batch.rs

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