1use super::cache_fs::{CacheRetentionLimits, 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 WasmBuildBatchInputMetrics, WasmBuildBatchInputResolver, WasmBuildError,
14 WasmBuildFailureDetails, WasmBuildInputReuse, WasmBuildInputSnapshotState, WasmBuildOutcome,
15 WasmBuildProgressConfig, WasmBuildProgressEvent, WasmBuildSessionState, WasmBuildSpec,
16 WasmBuildTimings, WasmInputResolutionTimings, build_wasm_canisters_cached_in_batch,
17 build_wasm_canisters_cached_in_batch_with_progress, shared_incremental_target,
18};
19
20#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
22pub struct WasmBuildBatchConfig {
23 shared_incremental_maintenance: Option<SharedIncrementalTargetMaintenanceConfig>,
24}
25
26#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct LabeledWasmBuildSpec {
32 label: String,
33 spec: WasmBuildSpec,
34}
35
36#[derive(Debug)]
38pub struct WasmBuildBatchReport {
39 entries: Vec<WasmBuildBatchEntry>,
40 input_resolution: WasmBuildBatchInputMetrics,
41 total: Duration,
42}
43
44pub struct WasmBuildSession<'guard> {
50 state: WasmBuildSessionState,
51 _source_guard: PhantomData<&'guard ()>,
52}
53
54pub struct WasmBuildInputSnapshot<'guard> {
61 state: WasmBuildInputSnapshotState,
62 _source_guard: PhantomData<&'guard ()>,
63}
64
65#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
67pub struct WasmBuildSessionMetrics {
68 snapshots: usize,
69 snapshot_reuses: usize,
70 invalidated: bool,
71}
72
73#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
75pub struct WasmBuildInputSnapshotMetrics {
76 specifications: usize,
77 input_resolution_runs: usize,
78 input_resolution_reuses: usize,
79 input_resolution_timings: WasmInputResolutionTimings,
80 reader_reuses: usize,
81 invalidated: bool,
82}
83
84#[derive(Debug)]
86pub struct WasmBuildBatchEntry {
87 index: usize,
88 label: String,
89 result: Result<WasmBuildOutcome, (WasmBuildError, WasmBuildFailureDetails)>,
90 entry_elapsed: Duration,
91}
92
93#[non_exhaustive]
95#[derive(Clone, Debug, Eq, PartialEq)]
96pub enum WasmBuildBatchContractError {
97 Labels(BatchLabelError),
99 SourceLeaseInvalidated,
101 SpecificationNotPrepared {
103 index: usize,
105 label: String,
107 },
108}
109
110impl LabeledWasmBuildSpec {
111 #[must_use]
113 pub fn new(label: impl Into<String>, spec: WasmBuildSpec) -> Self {
114 Self {
115 label: label.into(),
116 spec,
117 }
118 }
119
120 #[must_use]
122 pub fn label(&self) -> &str {
123 &self.label
124 }
125
126 #[must_use]
128 pub const fn spec(&self) -> &WasmBuildSpec {
129 &self.spec
130 }
131
132 #[must_use]
134 pub fn into_parts(self) -> (String, WasmBuildSpec) {
135 (self.label, self.spec)
136 }
137}
138
139impl<'guard> WasmBuildSession<'guard> {
140 #[must_use]
149 pub fn assume_sources_immutable<Guard: ?Sized>(_source_write_guard: &'guard Guard) -> Self {
150 Self {
151 state: WasmBuildSessionState::new(),
152 _source_guard: PhantomData,
153 }
154 }
155
156 pub fn build_batch(
166 &mut self,
167 specs: &[LabeledWasmBuildSpec],
168 config: WasmBuildBatchConfig,
169 progress: Option<(
170 WasmBuildProgressConfig,
171 &mut dyn FnMut(WasmBuildBatchProgressEvent),
172 )>,
173 mut spawn: impl FnMut(
174 &mut std::process::Command,
175 ) -> std::io::Result<ic_host_process::child::OwnedChild>,
176 ) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError> {
177 run_wasm_batch(
178 specs,
179 config,
180 Some(WasmBuildInputReuse::Session(&mut self.state)),
181 progress,
182 &mut spawn,
183 )
184 }
185
186 #[must_use]
188 pub const fn metrics(&self) -> WasmBuildSessionMetrics {
189 WasmBuildSessionMetrics {
190 snapshots: self.state.snapshot_count(),
191 snapshot_reuses: self.state.snapshot_reuses(),
192 invalidated: self.state.is_invalidated(),
193 }
194 }
195}
196
197impl WasmBuildSessionMetrics {
198 #[must_use]
200 pub const fn snapshots(self) -> usize {
201 self.snapshots
202 }
203
204 #[must_use]
206 pub const fn snapshot_reuses(self) -> usize {
207 self.snapshot_reuses
208 }
209
210 #[must_use]
212 pub const fn is_invalidated(self) -> bool {
213 self.invalidated
214 }
215}
216
217impl<'guard> WasmBuildInputSnapshot<'guard> {
218 pub fn prepare_assuming_sources_immutable<Guard: ?Sized>(
228 _source_write_guard: &'guard Guard,
229 specs: &[WasmBuildSpec],
230 ) -> Result<Self, WasmBuildError> {
231 Ok(Self {
232 state: WasmBuildInputSnapshotState::prepare(specs)?,
233 _source_guard: PhantomData,
234 })
235 }
236
237 pub fn build_batch(
247 &self,
248 specs: &[LabeledWasmBuildSpec],
249 config: WasmBuildBatchConfig,
250 progress: Option<(
251 WasmBuildProgressConfig,
252 &mut dyn FnMut(WasmBuildBatchProgressEvent),
253 )>,
254 mut spawn: impl FnMut(
255 &mut std::process::Command,
256 ) -> std::io::Result<ic_host_process::child::OwnedChild>,
257 ) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError> {
258 run_wasm_batch(
259 specs,
260 config,
261 Some(WasmBuildInputReuse::Snapshot(&self.state)),
262 progress,
263 &mut spawn,
264 )
265 }
266
267 #[must_use]
269 pub fn metrics(&self) -> WasmBuildInputSnapshotMetrics {
270 let preparation = self.state.preparation_metrics();
271 WasmBuildInputSnapshotMetrics {
272 specifications: self.state.specification_count(),
273 input_resolution_runs: preparation.runs,
274 input_resolution_reuses: preparation.reuses,
275 input_resolution_timings: self.state.preparation_timings(),
276 reader_reuses: self.state.reader_reuses(),
277 invalidated: self.state.is_invalidated(),
278 }
279 }
280}
281
282impl WasmBuildInputSnapshotMetrics {
283 #[must_use]
285 pub const fn specifications(self) -> usize {
286 self.specifications
287 }
288
289 #[must_use]
291 pub const fn input_resolution_runs(self) -> usize {
292 self.input_resolution_runs
293 }
294
295 #[must_use]
297 pub const fn input_resolution_reuses(self) -> usize {
298 self.input_resolution_reuses
299 }
300
301 #[must_use]
303 pub const fn input_resolution_timings(self) -> WasmInputResolutionTimings {
304 self.input_resolution_timings
305 }
306
307 #[must_use]
309 pub const fn reader_reuses(self) -> usize {
310 self.reader_reuses
311 }
312
313 #[must_use]
315 pub const fn is_invalidated(self) -> bool {
316 self.invalidated
317 }
318}
319
320impl WasmBuildBatchEntry {
321 #[must_use]
323 pub const fn index(&self) -> usize {
324 self.index
325 }
326
327 #[must_use]
329 pub fn label(&self) -> &str {
330 &self.label
331 }
332
333 pub const fn result(&self) -> Result<&WasmBuildOutcome, &WasmBuildError> {
338 match &self.result {
339 Ok(outcome) => Ok(outcome),
340 Err((error, _)) => Err(error),
341 }
342 }
343
344 #[must_use]
346 pub fn outcome(&self) -> Option<&WasmBuildOutcome> {
347 self.result.as_ref().ok()
348 }
349
350 #[must_use]
352 pub fn error(&self) -> Option<&WasmBuildError> {
353 self.result().err()
354 }
355
356 #[must_use]
358 pub const fn failure_details(&self) -> Option<WasmBuildFailureDetails> {
359 match &self.result {
360 Ok(_) => None,
361 Err((_, details)) => Some(*details),
362 }
363 }
364
365 #[must_use]
367 pub const fn entry_elapsed(&self) -> Duration {
368 self.entry_elapsed
369 }
370
371 #[must_use]
373 pub const fn is_success(&self) -> bool {
374 self.result.is_ok()
375 }
376
377 pub fn into_parts(
379 self,
380 ) -> (
381 usize,
382 String,
383 Result<WasmBuildOutcome, WasmBuildError>,
384 Option<WasmBuildFailureDetails>,
385 Duration,
386 ) {
387 let (result, failure) = match self.result {
388 Ok(outcome) => (Ok(outcome), None),
389 Err((error, details)) => (Err(error), Some(details)),
390 };
391 (self.index, self.label, result, failure, self.entry_elapsed)
392 }
393}
394
395#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
397pub struct WasmBuildBatchMetrics {
398 specifications: usize,
399 built: usize,
400 reused: usize,
401 input_resolution_runs: usize,
402 input_resolution_reuses: usize,
403 input_resolution_session_reuses: usize,
404 input_resolution_prepared_reuses: usize,
405 successful_timings: WasmBuildTimings,
406 total: Duration,
407}
408
409#[non_exhaustive]
411#[derive(Clone, Debug, Eq, PartialEq)]
412pub enum WasmBuildBatchProgressEvent {
413 BuildStarted {
415 index: usize,
417 label: String,
419 total: usize,
421 },
422 BuildProgress {
424 index: usize,
426 label: String,
428 event: WasmBuildProgressEvent,
430 },
431 BuildFinished {
433 index: usize,
435 label: String,
437 },
438 BuildFailed {
440 index: usize,
442 label: String,
444 },
445}
446
447impl WasmBuildBatchReport {
448 #[must_use]
450 pub fn entries(&self) -> &[WasmBuildBatchEntry] {
451 &self.entries
452 }
453
454 #[must_use]
456 pub fn into_entries(self) -> Vec<WasmBuildBatchEntry> {
457 self.entries
458 }
459
460 pub fn outcomes(&self) -> impl Iterator<Item = &WasmBuildBatchEntry> {
462 self.entries.iter().filter(|entry| entry.is_success())
463 }
464
465 pub fn failures(&self) -> impl Iterator<Item = &WasmBuildBatchEntry> {
467 self.entries.iter().filter(|entry| !entry.is_success())
468 }
469
470 pub fn shared_incremental_maintenance_outcomes(
475 &self,
476 ) -> impl Iterator<
477 Item = (
478 &WasmBuildBatchEntry,
479 &SharedIncrementalTargetMaintenanceOutcome,
480 ),
481 > {
482 self.outcomes().filter_map(|entry| {
483 entry
484 .outcome()?
485 .record()
486 .shared_incremental_maintenance()
487 .map(|outcome| (entry, outcome))
488 })
489 }
490
491 #[must_use]
493 pub const fn total(&self) -> Duration {
494 self.total
495 }
496
497 #[must_use]
499 pub fn is_success(&self) -> bool {
500 self.entries.iter().all(WasmBuildBatchEntry::is_success)
501 }
502
503 #[must_use]
505 pub fn metrics(&self) -> WasmBuildBatchMetrics {
506 let mut metrics = WasmBuildBatchMetrics {
507 specifications: self.entries.len(),
508 input_resolution_runs: self.input_resolution.runs,
509 input_resolution_reuses: self.input_resolution.reuses,
510 input_resolution_session_reuses: self.input_resolution.session_reuses,
511 input_resolution_prepared_reuses: self.input_resolution.prepared_reuses,
512 total: self.total,
513 ..WasmBuildBatchMetrics::default()
514 };
515 for outcome in self.entries.iter().filter_map(WasmBuildBatchEntry::outcome) {
516 if outcome.is_reused() {
517 metrics.reused += 1;
518 } else {
519 metrics.built += 1;
520 }
521 metrics.successful_timings = metrics
522 .successful_timings
523 .saturating_add(outcome.record().timings());
524 }
525 metrics
526 }
527}
528
529impl WasmBuildBatchMetrics {
530 #[must_use]
532 pub const fn specifications(self) -> usize {
533 self.specifications
534 }
535
536 #[must_use]
538 pub const fn succeeded(self) -> usize {
539 self.built + self.reused
540 }
541
542 #[must_use]
544 pub const fn failed(self) -> usize {
545 self.specifications - self.succeeded()
546 }
547
548 #[must_use]
550 pub const fn built(self) -> usize {
551 self.built
552 }
553
554 #[must_use]
556 pub const fn reused(self) -> usize {
557 self.reused
558 }
559
560 #[must_use]
562 pub const fn input_resolution_runs(self) -> usize {
563 self.input_resolution_runs
564 }
565
566 #[must_use]
568 pub const fn input_resolution_reuses(self) -> usize {
569 self.input_resolution_reuses
570 }
571
572 #[must_use]
574 pub const fn input_resolution_session_reuses(self) -> usize {
575 self.input_resolution_session_reuses
576 }
577
578 #[must_use]
580 pub const fn input_resolution_prepared_reuses(self) -> usize {
581 self.input_resolution_prepared_reuses
582 }
583
584 #[must_use]
586 pub const fn successful_timings(self) -> WasmBuildTimings {
587 self.successful_timings
588 }
589
590 #[must_use]
592 pub const fn total(self) -> Duration {
593 self.total
594 }
595}
596
597impl WasmBuildBatchConfig {
598 #[must_use]
600 pub const fn new() -> Self {
601 Self {
602 shared_incremental_maintenance: None,
603 }
604 }
605
606 #[must_use]
608 pub const fn with_shared_incremental_target_maintenance(
609 mut self,
610 config: SharedIncrementalTargetMaintenanceConfig,
611 ) -> Self {
612 self.shared_incremental_maintenance = Some(config);
613 self
614 }
615
616 #[must_use]
618 pub const fn with_shared_incremental_target_maintenance_at_most_every(
619 self,
620 policy: CacheRetentionLimits,
621 minimum_interval: Duration,
622 ) -> Self {
623 self.with_shared_incremental_target_maintenance(
624 SharedIncrementalTargetMaintenanceConfig::new(policy, minimum_interval),
625 )
626 }
627
628 #[must_use]
630 pub const fn shared_incremental_target_maintenance(
631 self,
632 ) -> Option<SharedIncrementalTargetMaintenanceConfig> {
633 self.shared_incremental_maintenance
634 }
635}
636
637impl std::fmt::Display for WasmBuildBatchReport {
638 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
639 let metrics = self.metrics();
640 write!(
641 formatter,
642 "builds={} succeeded={} failed={} built={} reused={} input_resolution_runs={} input_resolution_reuses={} input_resolution_session_reuses={} input_resolution_prepared_reuses={} successful_timings=({}) total={:?}",
643 metrics.specifications(),
644 metrics.succeeded(),
645 metrics.failed(),
646 metrics.built(),
647 metrics.reused(),
648 metrics.input_resolution_runs(),
649 metrics.input_resolution_reuses(),
650 metrics.input_resolution_session_reuses(),
651 metrics.input_resolution_prepared_reuses(),
652 metrics.successful_timings(),
653 metrics.total(),
654 )
655 }
656}
657
658pub fn build_wasm_canisters_cached_batch(
672 specs: &[LabeledWasmBuildSpec],
673 config: WasmBuildBatchConfig,
674 progress: Option<(
675 WasmBuildProgressConfig,
676 &mut dyn FnMut(WasmBuildBatchProgressEvent),
677 )>,
678 mut spawn: impl FnMut(
679 &mut std::process::Command,
680 ) -> std::io::Result<ic_host_process::child::OwnedChild>,
681) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError> {
682 run_wasm_batch(specs, config, None, progress, &mut spawn)
683}
684
685#[expect(
686 clippy::result_large_err,
687 reason = "Batch entries retain complete failure details inline without per-failure allocation."
688)]
689fn run_wasm_batch(
690 specs: &[LabeledWasmBuildSpec],
691 batch_config: WasmBuildBatchConfig,
692 reuse: Option<WasmBuildInputReuse<'_>>,
693 mut observation: Option<(
694 WasmBuildProgressConfig,
695 &mut dyn FnMut(WasmBuildBatchProgressEvent),
696 )>,
697 mut spawn: impl FnMut(
698 &mut std::process::Command,
699 ) -> std::io::Result<ic_host_process::child::OwnedChild>,
700) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError> {
701 validate_labels(specs.iter().map(LabeledWasmBuildSpec::label))
702 .map_err(WasmBuildBatchContractError::Labels)?;
703 validate_input_reuse(specs, reuse.as_ref())?;
704 let count = specs.len();
705 let mut resolver =
706 WasmBuildBatchInputResolver::new(specs.iter().map(LabeledWasmBuildSpec::spec), reuse);
707 let mut report = build_wasm_batch(specs, batch_config, |spec, index| {
708 let Some((progress_config, observer)) = observation.as_mut() else {
709 return build_wasm_canisters_cached_in_batch(spec, index, &mut resolver, &mut spawn);
710 };
711 let label = specs[index].label.clone();
712 observer(WasmBuildBatchProgressEvent::BuildStarted {
713 index,
714 label: label.clone(),
715 total: count,
716 });
717 let attempt = build_wasm_canisters_cached_in_batch_with_progress(
718 spec,
719 index,
720 &mut resolver,
721 *progress_config,
722 |event| {
723 observer(WasmBuildBatchProgressEvent::BuildProgress {
724 index,
725 label: label.clone(),
726 event,
727 });
728 },
729 &mut spawn,
730 );
731 observer(match &attempt {
732 Ok(_) => WasmBuildBatchProgressEvent::BuildFinished { index, label },
733 Err(_) => WasmBuildBatchProgressEvent::BuildFailed { index, label },
734 });
735 attempt
736 });
737 report.input_resolution = resolver.metrics();
738 Ok(report)
739}
740
741fn build_wasm_batch<F>(
742 specs: &[LabeledWasmBuildSpec],
743 config: WasmBuildBatchConfig,
744 mut build: F,
745) -> WasmBuildBatchReport
746where
747 F: FnMut(
748 &WasmBuildSpec,
749 usize,
750 ) -> Result<WasmBuildOutcome, (WasmBuildError, WasmBuildFailureDetails)>,
751{
752 let started = Instant::now();
753 let mut entries = Vec::with_capacity(specs.len());
754 let mut maintenance = BatchMaintenanceTracker::new(config.shared_incremental_maintenance);
755 for (index, labeled) in specs.iter().enumerate() {
756 let entry_started = Instant::now();
757 let spec = &labeled.spec;
758 let result = if config.shared_incremental_maintenance.is_some()
759 && spec.shared_incremental_target_maintenance().is_some()
760 {
761 Err((
762 batch_maintenance_ownership_error(),
763 WasmBuildFailureDetails::specification(entry_started.elapsed()),
764 ))
765 } else {
766 match maintenance.prepare_spec(spec) {
767 Ok(configured) => build(configured.as_ref().unwrap_or(spec), index),
768 Err(error) => Err((
769 error,
770 WasmBuildFailureDetails::specification(entry_started.elapsed()),
771 )),
772 }
773 };
774 entries.push(WasmBuildBatchEntry {
775 index,
776 label: labeled.label.clone(),
777 result,
778 entry_elapsed: entry_started.elapsed(),
779 });
780 }
781 WasmBuildBatchReport {
782 entries,
783 input_resolution: WasmBuildBatchInputMetrics::default(),
784 total: started.elapsed(),
785 }
786}
787
788fn validate_input_reuse(
789 specs: &[LabeledWasmBuildSpec],
790 reuse: Option<&WasmBuildInputReuse<'_>>,
791) -> Result<(), WasmBuildBatchContractError> {
792 match reuse {
793 Some(WasmBuildInputReuse::Session(session)) if session.is_invalidated() => {
794 Err(WasmBuildBatchContractError::SourceLeaseInvalidated)
795 }
796 Some(WasmBuildInputReuse::Snapshot(snapshot)) if snapshot.is_invalidated() => {
797 Err(WasmBuildBatchContractError::SourceLeaseInvalidated)
798 }
799 Some(WasmBuildInputReuse::Snapshot(snapshot)) => {
800 for (index, labeled) in specs.iter().enumerate() {
801 if !snapshot.contains(&labeled.spec) {
802 return Err(WasmBuildBatchContractError::SpecificationNotPrepared {
803 index,
804 label: labeled.label.clone(),
805 });
806 }
807 }
808 Ok(())
809 }
810 _ => Ok(()),
811 }
812}
813
814struct BatchMaintenanceTracker {
815 config: Option<SharedIncrementalTargetMaintenanceConfig>,
816 configured_targets: HashSet<PathBuf>,
817}
818
819impl BatchMaintenanceTracker {
820 fn new(config: Option<SharedIncrementalTargetMaintenanceConfig>) -> Self {
821 Self {
822 config,
823 configured_targets: HashSet::new(),
824 }
825 }
826
827 fn prepare_spec(
828 &mut self,
829 spec: &WasmBuildSpec,
830 ) -> Result<Option<WasmBuildSpec>, WasmBuildError> {
831 let Some(config) = self.config else {
832 return Ok(None);
833 };
834 debug_assert!(spec.shared_incremental_target_maintenance().is_none());
835 let Some(target_dir) = shared_incremental_target(spec) else {
836 return Ok(None);
837 };
838 let canonical =
839 canonicalize_allow_missing(&target_dir).map_err(|source| WasmBuildError::Io {
840 operation: "resolve batch shared incremental target",
841 path: target_dir,
842 source,
843 })?;
844 if !self.configured_targets.insert(canonical) {
845 return Ok(None);
846 }
847 Ok(Some(
848 spec.clone()
849 .with_shared_incremental_target_maintenance(config),
850 ))
851 }
852}
853
854fn batch_maintenance_ownership_error() -> WasmBuildError {
855 WasmBuildError::InvalidSpec {
856 message:
857 "batch-owned shared-target maintenance cannot be combined with per-spec maintenance"
858 .to_owned(),
859 }
860}
861
862impl std::fmt::Display for WasmBuildBatchContractError {
863 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
864 match self {
865 Self::Labels(source) => write!(formatter, "Wasm batch {source}"),
866 Self::SourceLeaseInvalidated => formatter
867 .write_str("Wasm build source lease was invalidated by a detected input mutation"),
868 Self::SpecificationNotPrepared { index, label } => write!(
869 formatter,
870 "Wasm batch entry {label:?} at index {index} was not declared when the input snapshot was prepared",
871 ),
872 }
873 }
874}
875
876impl std::error::Error for WasmBuildBatchContractError {
877 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
878 match self {
879 Self::Labels(source) => Some(source),
880 _ => None,
881 }
882 }
883}
884
885#[cfg(test)]
886mod tests;