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    succeeded: usize,
529    failed: usize,
530    built: usize,
531    reused: usize,
532    input_resolution_runs: usize,
533    input_resolution_reuses: usize,
534    input_resolution_session_reuses: usize,
535    input_resolution_prepared_reuses: usize,
536    successful_timings: WasmBuildTimings,
537    total: Duration,
538}
539
540/// Structured progress for an independent sequence of exact Wasm builds.
541#[non_exhaustive]
542#[derive(Clone, Debug, Eq, PartialEq)]
543pub enum WasmBuildBatchProgressEvent {
544    /// One independently resolved build specification is about to start.
545    BuildStarted {
546        /// Zero-based position in the supplied specification slice.
547        index: usize,
548        /// Caller-owned stable label.
549        label: String,
550        /// Total number of supplied specifications.
551        total: usize,
552    },
553    /// Progress forwarded from one independent build.
554    BuildProgress {
555        /// Zero-based position in the supplied specification slice.
556        index: usize,
557        /// Caller-owned stable label.
558        label: String,
559        /// Event emitted by that build.
560        event: WasmBuildProgressEvent,
561    },
562    /// One independent build completed successfully.
563    BuildFinished {
564        /// Zero-based position in the supplied specification slice.
565        index: usize,
566        /// Caller-owned stable label.
567        label: String,
568    },
569    /// One independent build failed.
570    BuildFailed {
571        /// Zero-based position in the supplied specification slice.
572        index: usize,
573        /// Caller-owned stable label.
574        label: String,
575    },
576}
577
578impl WasmBuildBatchReport {
579    /// Ordered labeled entries.
580    #[must_use]
581    pub fn entries(&self) -> &[WasmBuildBatchEntry] {
582        &self.entries
583    }
584
585    /// Consume the report into its ordered labeled entries.
586    #[must_use]
587    pub fn into_entries(self) -> Vec<WasmBuildBatchEntry> {
588        self.entries
589    }
590
591    /// Structured successful entries with labels and wall-clock times.
592    pub fn outcomes(&self) -> impl Iterator<Item = WasmBuildBatchOutcomeEntry<'_>> {
593        self.entries.iter().filter_map(|entry| {
594            entry.outcome().map(|outcome| WasmBuildBatchOutcomeEntry {
595                index: entry.index,
596                label: &entry.label,
597                outcome,
598                entry_elapsed: entry.entry_elapsed,
599            })
600        })
601    }
602
603    /// Structured failed entries with labels and wall-clock times.
604    pub fn failures(&self) -> impl Iterator<Item = WasmBuildBatchFailure<'_>> {
605        self.entries.iter().filter_map(|entry| {
606            entry
607                .result
608                .as_ref()
609                .err()
610                .map(|(error, details)| WasmBuildBatchFailure {
611                    index: entry.index,
612                    label: &entry.label,
613                    error,
614                    details: *details,
615                    entry_elapsed: entry.entry_elapsed,
616                })
617        })
618    }
619
620    /// Labeled integrated shared-target maintenance outcomes.
621    ///
622    /// Batch-owned maintenance contributes at most one outcome for each
623    /// distinct configured shared-target path.
624    pub fn shared_incremental_maintenance_outcomes(
625        &self,
626    ) -> impl Iterator<Item = WasmBuildBatchMaintenanceEntry<'_>> {
627        self.outcomes().filter_map(|entry| {
628            entry
629                .outcome
630                .record()
631                .shared_incremental_maintenance()
632                .map(|outcome| WasmBuildBatchMaintenanceEntry {
633                    index: entry.index,
634                    label: entry.label,
635                    outcome,
636                })
637        })
638    }
639
640    /// Complete wall-clock time for the sequential collect-all batch.
641    #[must_use]
642    pub const fn total(&self) -> Duration {
643        self.total
644    }
645
646    /// Whether every specification completed successfully.
647    #[must_use]
648    pub fn is_success(&self) -> bool {
649        self.entries.iter().all(WasmBuildBatchEntry::is_success)
650    }
651
652    /// Aggregate outcome, input-resolution reuse, and timing counters.
653    #[must_use]
654    pub fn metrics(&self) -> WasmBuildBatchMetrics {
655        let mut metrics = WasmBuildBatchMetrics {
656            specifications: self.entries.len(),
657            input_resolution_runs: self.input_resolution.runs,
658            input_resolution_reuses: self.input_resolution.reuses,
659            input_resolution_session_reuses: self.input_resolution.session_reuses,
660            input_resolution_prepared_reuses: self.input_resolution.prepared_reuses,
661            total: self.total,
662            ..WasmBuildBatchMetrics::default()
663        };
664        for entry in &self.entries {
665            match &entry.result {
666                Ok(outcome) => {
667                    metrics.succeeded += 1;
668                    if outcome.is_reused() {
669                        metrics.reused += 1;
670                    } else {
671                        metrics.built += 1;
672                    }
673                    metrics.successful_timings = metrics
674                        .successful_timings
675                        .saturating_add(outcome.record().timings());
676                }
677                Err(_) => metrics.failed += 1,
678            }
679        }
680        metrics
681    }
682}
683
684impl WasmBuildBatchMetrics {
685    /// Number of supplied specifications.
686    #[must_use]
687    pub const fn specifications(self) -> usize {
688        self.specifications
689    }
690
691    /// Number of successful specifications.
692    #[must_use]
693    pub const fn succeeded(self) -> usize {
694        self.succeeded
695    }
696
697    /// Number of failed specifications.
698    #[must_use]
699    pub const fn failed(self) -> usize {
700        self.failed
701    }
702
703    /// Number of newly built Wasm artifact sets.
704    #[must_use]
705    pub const fn built(self) -> usize {
706        self.built
707    }
708
709    /// Number of Wasm artifact sets reused from the exact cache.
710    #[must_use]
711    pub const fn reused(self) -> usize {
712        self.reused
713    }
714
715    /// Number of workspace/toolchain input-resolution snapshots performed.
716    #[must_use]
717    pub const fn input_resolution_runs(self) -> usize {
718        self.input_resolution_runs
719    }
720
721    /// Number of specifications resolved by reusing another batch snapshot.
722    #[must_use]
723    pub const fn input_resolution_reuses(self) -> usize {
724        self.input_resolution_reuses
725    }
726
727    /// Number of specifications resolved from an explicit session snapshot.
728    #[must_use]
729    pub const fn input_resolution_session_reuses(self) -> usize {
730        self.input_resolution_session_reuses
731    }
732
733    /// Number of specifications resolved from a prepared concurrent snapshot.
734    #[must_use]
735    pub const fn input_resolution_prepared_reuses(self) -> usize {
736        self.input_resolution_prepared_reuses
737    }
738
739    /// Sum of timings from successful acquisitions.
740    #[must_use]
741    pub const fn successful_timings(self) -> WasmBuildTimings {
742        self.successful_timings
743    }
744
745    /// Complete wall-clock time for the sequential batch.
746    #[must_use]
747    pub const fn total(self) -> Duration {
748        self.total
749    }
750}
751
752impl WasmBuildBatchConfig {
753    /// Create batch orchestration without batch-owned target maintenance.
754    #[must_use]
755    pub const fn new() -> Self {
756        Self {
757            shared_incremental_maintenance: None,
758        }
759    }
760
761    /// Maintain each distinct resolved shared target once through its first batch entry.
762    #[must_use]
763    pub const fn with_shared_incremental_target_maintenance(
764        mut self,
765        config: SharedIncrementalTargetMaintenanceConfig,
766    ) -> Self {
767        self.shared_incremental_maintenance = Some(config);
768        self
769    }
770
771    /// Strictly maintain each distinct shared target at most once per interval.
772    #[must_use]
773    pub const fn with_shared_incremental_target_maintenance_at_most_every(
774        self,
775        policy: SharedIncrementalTargetPrunePolicy,
776        minimum_interval: Duration,
777    ) -> Self {
778        self.with_shared_incremental_target_maintenance(
779            SharedIncrementalTargetMaintenanceConfig::new(policy, minimum_interval),
780        )
781    }
782
783    /// Batch-owned shared-target maintenance, when configured.
784    #[must_use]
785    pub const fn shared_incremental_target_maintenance(
786        self,
787    ) -> Option<SharedIncrementalTargetMaintenanceConfig> {
788        self.shared_incremental_maintenance
789    }
790}
791
792impl std::fmt::Display for WasmBuildBatchReport {
793    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
794        let metrics = self.metrics();
795        write!(
796            formatter,
797            "builds={} succeeded={} failed={} built={} reused={} input_resolution_runs={} input_resolution_reuses={} input_resolution_session_reuses={} input_resolution_prepared_reuses={} successful_timings=({}) total={:?}",
798            metrics.specifications(),
799            metrics.succeeded(),
800            metrics.failed(),
801            metrics.built(),
802            metrics.reused(),
803            metrics.input_resolution_runs(),
804            metrics.input_resolution_reuses(),
805            metrics.input_resolution_session_reuses(),
806            metrics.input_resolution_prepared_reuses(),
807            metrics.successful_timings(),
808            metrics.total(),
809        )
810    }
811}
812
813/// Build every Wasm specification as an independent Cargo invocation.
814///
815/// Specifications run sequentially and every result is retained. Each entry
816/// keeps its own package set, profile arguments, feature resolution,
817/// fingerprint, locks, and cache policy. Packages are never combined into one
818/// Cargo command because doing so can unify shared dependency features.
819pub fn build_wasm_canisters_cached_batch(
820    specs: &[LabeledWasmBuildSpec],
821) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError> {
822    build_wasm_canisters_cached_batch_with_config(specs, WasmBuildBatchConfig::new())
823}
824
825/// Build an independent Wasm batch with shared batch orchestration.
826///
827/// Batch-owned maintenance is attached only to the first specification for
828/// each distinct configured shared-target path. Isolated specifications are
829/// unaffected. An entry mixing batch-owned and per-spec integrated maintenance
830/// reports an indexed error without preventing later entries from running.
831pub fn build_wasm_canisters_cached_batch_with_config(
832    specs: &[LabeledWasmBuildSpec],
833    config: WasmBuildBatchConfig,
834) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError> {
835    run_wasm_batch(specs, config, None, None)
836}
837
838/// Build an independent Wasm batch while forwarding structured progress.
839///
840/// The same observation configuration is applied to every entry. Batch events
841/// identify the originating specification without altering the standalone
842/// build semantics.
843pub fn build_wasm_canisters_cached_batch_with_progress<F>(
844    specs: &[LabeledWasmBuildSpec],
845    config: WasmBuildProgressConfig,
846    observer: F,
847) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError>
848where
849    F: FnMut(WasmBuildBatchProgressEvent),
850{
851    build_wasm_canisters_cached_batch_with_config_and_progress(
852        specs,
853        WasmBuildBatchConfig::new(),
854        config,
855        observer,
856    )
857}
858
859/// Build a configured independent Wasm batch while forwarding structured progress.
860pub fn build_wasm_canisters_cached_batch_with_config_and_progress<F>(
861    specs: &[LabeledWasmBuildSpec],
862    batch_config: WasmBuildBatchConfig,
863    progress_config: WasmBuildProgressConfig,
864    mut observer: F,
865) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError>
866where
867    F: FnMut(WasmBuildBatchProgressEvent),
868{
869    run_wasm_batch(
870        specs,
871        batch_config,
872        None,
873        Some((progress_config, &mut observer)),
874    )
875}
876
877fn run_wasm_batch(
878    specs: &[LabeledWasmBuildSpec],
879    batch_config: WasmBuildBatchConfig,
880    reuse: Option<WasmBuildInputReuse<'_>>,
881    mut observation: Option<(
882        WasmBuildProgressConfig,
883        &mut dyn FnMut(WasmBuildBatchProgressEvent),
884    )>,
885) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError> {
886    validate_batch_labels(specs)?;
887    validate_input_reuse(specs, reuse.as_ref())?;
888    let count = specs.len();
889    let mut resolver =
890        WasmBuildBatchInputResolver::new(specs.iter().map(LabeledWasmBuildSpec::spec), reuse);
891    let mut report = build_wasm_batch(specs, batch_config, |spec, index| {
892        let Some((progress_config, observer)) = observation.as_mut() else {
893            return build_wasm_canisters_cached_in_batch(spec, index, &mut resolver);
894        };
895        let label = specs[index].label.clone();
896        observer(WasmBuildBatchProgressEvent::BuildStarted {
897            index,
898            label: label.clone(),
899            total: count,
900        });
901        let attempt = build_wasm_canisters_cached_in_batch_with_progress(
902            spec,
903            index,
904            &mut resolver,
905            *progress_config,
906            |event| {
907                observer(WasmBuildBatchProgressEvent::BuildProgress {
908                    index,
909                    label: label.clone(),
910                    event,
911                });
912            },
913        );
914        observer(match &attempt.result {
915            Ok(_) => WasmBuildBatchProgressEvent::BuildFinished { index, label },
916            Err(_) => WasmBuildBatchProgressEvent::BuildFailed { index, label },
917        });
918        attempt
919    });
920    report.input_resolution = resolver.metrics();
921    Ok(report)
922}
923
924fn build_wasm_batch<F>(
925    specs: &[LabeledWasmBuildSpec],
926    config: WasmBuildBatchConfig,
927    mut build: F,
928) -> WasmBuildBatchReport
929where
930    F: FnMut(&WasmBuildSpec, usize) -> WasmBuildBatchAttempt,
931{
932    let started = Instant::now();
933    let mut entries = Vec::with_capacity(specs.len());
934    let mut maintenance = BatchMaintenanceTracker::new(config.shared_incremental_maintenance);
935    for (index, labeled) in specs.iter().enumerate() {
936        let entry_started = Instant::now();
937        let spec = &labeled.spec;
938        let result = if config.shared_incremental_maintenance.is_some()
939            && spec.shared_incremental_target_maintenance().is_some()
940        {
941            Err((
942                batch_maintenance_ownership_error(),
943                WasmBuildFailureDetails::specification(entry_started.elapsed()),
944            ))
945        } else {
946            match maintenance.prepare_spec(spec) {
947                Ok(configured) => build(configured.as_ref().unwrap_or(spec), index).result,
948                Err(error) => Err((
949                    error,
950                    WasmBuildFailureDetails::specification(entry_started.elapsed()),
951                )),
952            }
953        };
954        entries.push(WasmBuildBatchEntry {
955            index,
956            label: labeled.label.clone(),
957            result,
958            entry_elapsed: entry_started.elapsed(),
959        });
960    }
961    WasmBuildBatchReport {
962        entries,
963        input_resolution: WasmBuildBatchInputMetrics::default(),
964        total: started.elapsed(),
965    }
966}
967
968fn validate_batch_labels(
969    specs: &[LabeledWasmBuildSpec],
970) -> Result<(), WasmBuildBatchContractError> {
971    validate_labels(specs.iter().map(|labeled| labeled.label.as_str())).map_err(|error| match error
972    {
973        BatchLabelError::Empty { index } => WasmBuildBatchContractError::EmptyLabel { index },
974        BatchLabelError::Duplicate {
975            label,
976            first_index,
977            duplicate_index,
978        } => WasmBuildBatchContractError::DuplicateLabel {
979            label,
980            first_index,
981            duplicate_index,
982        },
983    })
984}
985
986fn validate_input_reuse(
987    specs: &[LabeledWasmBuildSpec],
988    reuse: Option<&WasmBuildInputReuse<'_>>,
989) -> Result<(), WasmBuildBatchContractError> {
990    match reuse {
991        Some(WasmBuildInputReuse::Session(session)) if session.is_invalidated() => {
992            Err(WasmBuildBatchContractError::SourceLeaseInvalidated)
993        }
994        Some(WasmBuildInputReuse::Snapshot(snapshot)) if snapshot.is_invalidated() => {
995            Err(WasmBuildBatchContractError::SourceLeaseInvalidated)
996        }
997        Some(WasmBuildInputReuse::Snapshot(snapshot)) => {
998            for (index, labeled) in specs.iter().enumerate() {
999                if !snapshot.contains(&labeled.spec) {
1000                    return Err(WasmBuildBatchContractError::SpecificationNotPrepared {
1001                        index,
1002                        label: labeled.label.clone(),
1003                    });
1004                }
1005            }
1006            Ok(())
1007        }
1008        _ => Ok(()),
1009    }
1010}
1011
1012struct BatchMaintenanceTracker {
1013    config: Option<SharedIncrementalTargetMaintenanceConfig>,
1014    configured_targets: HashSet<PathBuf>,
1015}
1016
1017impl BatchMaintenanceTracker {
1018    fn new(config: Option<SharedIncrementalTargetMaintenanceConfig>) -> Self {
1019        Self {
1020            config,
1021            configured_targets: HashSet::new(),
1022        }
1023    }
1024
1025    fn prepare_spec(
1026        &mut self,
1027        spec: &WasmBuildSpec,
1028    ) -> Result<Option<WasmBuildSpec>, WasmBuildError> {
1029        let Some(config) = self.config else {
1030            return Ok(None);
1031        };
1032        debug_assert!(spec.shared_incremental_target_maintenance().is_none());
1033        let Some(target_dir) = shared_incremental_target(spec) else {
1034            return Ok(None);
1035        };
1036        let canonical =
1037            canonicalize_allow_missing(&target_dir).map_err(|source| WasmBuildError::Io {
1038                operation: "resolve batch shared incremental target",
1039                path: target_dir,
1040                source,
1041            })?;
1042        if !self.configured_targets.insert(canonical) {
1043            return Ok(None);
1044        }
1045        Ok(Some(
1046            spec.clone()
1047                .with_shared_incremental_target_maintenance(config),
1048        ))
1049    }
1050}
1051
1052fn batch_maintenance_ownership_error() -> WasmBuildError {
1053    WasmBuildError::InvalidSpec {
1054        message:
1055            "batch-owned shared-target maintenance cannot be combined with per-spec maintenance"
1056                .to_owned(),
1057    }
1058}
1059
1060impl std::fmt::Display for WasmBuildBatchContractError {
1061    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1062        match self {
1063            Self::EmptyLabel { index } => {
1064                write!(formatter, "Wasm batch label at index {index} is empty")
1065            }
1066            Self::DuplicateLabel {
1067                label,
1068                first_index,
1069                duplicate_index,
1070            } => write!(
1071                formatter,
1072                "Wasm batch label {label:?} at index {duplicate_index} duplicates index {first_index}",
1073            ),
1074            Self::SourceLeaseInvalidated => formatter
1075                .write_str("Wasm build source lease was invalidated by a detected input mutation"),
1076            Self::SpecificationNotPrepared { index, label } => write!(
1077                formatter,
1078                "Wasm batch entry {label:?} at index {index} was not declared when the input snapshot was prepared",
1079            ),
1080        }
1081    }
1082}
1083
1084impl std::error::Error for WasmBuildBatchContractError {}
1085
1086#[cfg(test)]
1087mod tests;