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