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