Skip to main content

ic_testkit/artifacts/
wasm_batch.rs

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