Skip to main content

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