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, WasmBuildInputSnapshotState, WasmBuildOutcome,
15 WasmBuildProgressConfig, WasmBuildProgressEvent, WasmBuildSpec, WasmBuildTimings,
16 WasmInputResolutionTimings, build_wasm_canisters_cached_in_batch, shared_incremental_target,
17};
18
19#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
21pub struct WasmBuildBatchConfig {
22 shared_incremental_maintenance: Option<SharedIncrementalTargetMaintenanceConfig>,
23}
24
25#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct LabeledWasmBuildSpec {
31 label: String,
32 spec: WasmBuildSpec,
33}
34
35#[derive(Debug)]
37pub struct WasmBuildBatchReport {
38 entries: Vec<WasmBuildBatchEntry>,
39 input_resolution: WasmBuildBatchInputMetrics,
40 total: Duration,
41}
42
43pub struct WasmBuildInputSnapshot<'guard> {
50 state: WasmBuildInputSnapshotState,
51 _source_guard: PhantomData<&'guard ()>,
52}
53
54#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
56pub struct WasmBuildInputSnapshotMetrics {
57 specifications: usize,
58 input_resolution_runs: usize,
59 input_resolution_reuses: usize,
60 input_resolution_timings: WasmInputResolutionTimings,
61 reader_reuses: usize,
62 invalidated: bool,
63}
64
65#[derive(Debug)]
67pub struct WasmBuildBatchEntry {
68 index: usize,
69 label: String,
70 result: Result<WasmBuildOutcome, (WasmBuildError, WasmBuildFailureDetails)>,
71 entry_elapsed: Duration,
72}
73
74#[non_exhaustive]
76#[derive(Clone, Debug, Eq, PartialEq)]
77pub enum WasmBuildBatchContractError {
78 Labels(BatchLabelError),
80 SourceLeaseInvalidated,
82 SpecificationNotPrepared {
84 index: usize,
86 label: String,
88 },
89}
90
91impl LabeledWasmBuildSpec {
92 #[must_use]
94 pub fn new(label: impl Into<String>, spec: WasmBuildSpec) -> Self {
95 Self {
96 label: label.into(),
97 spec,
98 }
99 }
100
101 #[must_use]
103 pub fn label(&self) -> &str {
104 &self.label
105 }
106
107 #[must_use]
109 pub const fn spec(&self) -> &WasmBuildSpec {
110 &self.spec
111 }
112
113 #[must_use]
115 pub fn into_parts(self) -> (String, WasmBuildSpec) {
116 (self.label, self.spec)
117 }
118}
119
120impl<'guard> WasmBuildInputSnapshot<'guard> {
121 pub fn prepare_assuming_sources_immutable<Guard: ?Sized>(
131 _source_write_guard: &'guard Guard,
132 specs: &[WasmBuildSpec],
133 ) -> Result<Self, WasmBuildError> {
134 Ok(Self {
135 state: WasmBuildInputSnapshotState::prepare(specs)?,
136 _source_guard: PhantomData,
137 })
138 }
139
140 pub fn build_batch(
150 &self,
151 specs: &[LabeledWasmBuildSpec],
152 config: WasmBuildBatchConfig,
153 progress: Option<(
154 WasmBuildProgressConfig,
155 &mut dyn FnMut(WasmBuildBatchProgressEvent),
156 )>,
157 mut spawn: impl FnMut(
158 &mut std::process::Command,
159 ) -> std::io::Result<ic_host_process::child::OwnedChild>,
160 ) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError> {
161 run_wasm_batch(specs, config, Some(&self.state), progress, &mut spawn)
162 }
163
164 #[must_use]
166 pub fn metrics(&self) -> WasmBuildInputSnapshotMetrics {
167 let preparation = self.state.preparation_metrics();
168 WasmBuildInputSnapshotMetrics {
169 specifications: self.state.specification_count(),
170 input_resolution_runs: preparation.runs,
171 input_resolution_reuses: preparation.reuses,
172 input_resolution_timings: self.state.preparation_timings(),
173 reader_reuses: self.state.reader_reuses(),
174 invalidated: self.state.is_invalidated(),
175 }
176 }
177}
178
179impl WasmBuildInputSnapshotMetrics {
180 #[must_use]
182 pub const fn specifications(self) -> usize {
183 self.specifications
184 }
185
186 #[must_use]
188 pub const fn input_resolution_runs(self) -> usize {
189 self.input_resolution_runs
190 }
191
192 #[must_use]
194 pub const fn input_resolution_reuses(self) -> usize {
195 self.input_resolution_reuses
196 }
197
198 #[must_use]
200 pub const fn input_resolution_timings(self) -> WasmInputResolutionTimings {
201 self.input_resolution_timings
202 }
203
204 #[must_use]
206 pub const fn reader_reuses(self) -> usize {
207 self.reader_reuses
208 }
209
210 #[must_use]
212 pub const fn is_invalidated(self) -> bool {
213 self.invalidated
214 }
215}
216
217impl WasmBuildBatchEntry {
218 #[must_use]
220 pub const fn index(&self) -> usize {
221 self.index
222 }
223
224 #[must_use]
226 pub fn label(&self) -> &str {
227 &self.label
228 }
229
230 pub const fn result(&self) -> Result<&WasmBuildOutcome, &WasmBuildError> {
235 match &self.result {
236 Ok(outcome) => Ok(outcome),
237 Err((error, _)) => Err(error),
238 }
239 }
240
241 #[must_use]
243 pub fn outcome(&self) -> Option<&WasmBuildOutcome> {
244 self.result.as_ref().ok()
245 }
246
247 #[must_use]
249 pub fn error(&self) -> Option<&WasmBuildError> {
250 self.result().err()
251 }
252
253 #[must_use]
255 pub const fn failure_details(&self) -> Option<WasmBuildFailureDetails> {
256 match &self.result {
257 Ok(_) => None,
258 Err((_, details)) => Some(*details),
259 }
260 }
261
262 #[must_use]
264 pub const fn entry_elapsed(&self) -> Duration {
265 self.entry_elapsed
266 }
267
268 #[must_use]
270 pub const fn is_success(&self) -> bool {
271 self.result.is_ok()
272 }
273
274 pub fn into_parts(
276 self,
277 ) -> (
278 usize,
279 String,
280 Result<WasmBuildOutcome, WasmBuildError>,
281 Option<WasmBuildFailureDetails>,
282 Duration,
283 ) {
284 let (result, failure) = match self.result {
285 Ok(outcome) => (Ok(outcome), None),
286 Err((error, details)) => (Err(error), Some(details)),
287 };
288 (self.index, self.label, result, failure, self.entry_elapsed)
289 }
290}
291
292#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
294pub struct WasmBuildBatchMetrics {
295 specifications: usize,
296 built: usize,
297 reused: usize,
298 input_resolution_runs: usize,
299 input_resolution_reuses: usize,
300 input_resolution_prepared_reuses: usize,
301 successful_timings: WasmBuildTimings,
302 total: Duration,
303}
304
305#[non_exhaustive]
307#[derive(Clone, Debug, Eq, PartialEq)]
308pub enum WasmBuildBatchProgressEvent {
309 BuildStarted {
311 index: usize,
313 label: String,
315 total: usize,
317 },
318 BuildProgress {
320 index: usize,
322 label: String,
324 event: WasmBuildProgressEvent,
326 },
327 BuildFinished {
329 index: usize,
331 label: String,
333 },
334 BuildFailed {
336 index: usize,
338 label: String,
340 },
341}
342
343impl WasmBuildBatchReport {
344 #[must_use]
346 pub fn entries(&self) -> &[WasmBuildBatchEntry] {
347 &self.entries
348 }
349
350 #[must_use]
352 pub fn into_entries(self) -> Vec<WasmBuildBatchEntry> {
353 self.entries
354 }
355
356 pub fn outcomes(&self) -> impl Iterator<Item = &WasmBuildBatchEntry> {
358 self.entries.iter().filter(|entry| entry.is_success())
359 }
360
361 pub fn failures(&self) -> impl Iterator<Item = &WasmBuildBatchEntry> {
363 self.entries.iter().filter(|entry| !entry.is_success())
364 }
365
366 pub fn shared_incremental_maintenance_outcomes(
371 &self,
372 ) -> impl Iterator<
373 Item = (
374 &WasmBuildBatchEntry,
375 &SharedIncrementalTargetMaintenanceOutcome,
376 ),
377 > {
378 self.outcomes().filter_map(|entry| {
379 entry
380 .outcome()?
381 .record()
382 .shared_incremental_maintenance()
383 .map(|outcome| (entry, outcome))
384 })
385 }
386
387 #[must_use]
389 pub const fn total(&self) -> Duration {
390 self.total
391 }
392
393 #[must_use]
395 pub fn is_success(&self) -> bool {
396 self.entries.iter().all(WasmBuildBatchEntry::is_success)
397 }
398
399 #[must_use]
401 pub fn metrics(&self) -> WasmBuildBatchMetrics {
402 let mut metrics = WasmBuildBatchMetrics {
403 specifications: self.entries.len(),
404 input_resolution_runs: self.input_resolution.runs,
405 input_resolution_reuses: self.input_resolution.reuses,
406 input_resolution_prepared_reuses: self.input_resolution.prepared_reuses,
407 total: self.total,
408 ..WasmBuildBatchMetrics::default()
409 };
410 for outcome in self.entries.iter().filter_map(WasmBuildBatchEntry::outcome) {
411 if outcome.is_reused() {
412 metrics.reused += 1;
413 } else {
414 metrics.built += 1;
415 }
416 metrics.successful_timings = metrics
417 .successful_timings
418 .saturating_add(outcome.record().timings());
419 }
420 metrics
421 }
422}
423
424impl WasmBuildBatchMetrics {
425 #[must_use]
427 pub const fn specifications(self) -> usize {
428 self.specifications
429 }
430
431 #[must_use]
433 pub const fn succeeded(self) -> usize {
434 self.built + self.reused
435 }
436
437 #[must_use]
439 pub const fn failed(self) -> usize {
440 self.specifications - self.succeeded()
441 }
442
443 #[must_use]
445 pub const fn built(self) -> usize {
446 self.built
447 }
448
449 #[must_use]
451 pub const fn reused(self) -> usize {
452 self.reused
453 }
454
455 #[must_use]
457 pub const fn input_resolution_runs(self) -> usize {
458 self.input_resolution_runs
459 }
460
461 #[must_use]
463 pub const fn input_resolution_reuses(self) -> usize {
464 self.input_resolution_reuses
465 }
466
467 #[must_use]
469 pub const fn input_resolution_prepared_reuses(self) -> usize {
470 self.input_resolution_prepared_reuses
471 }
472
473 #[must_use]
475 pub const fn successful_timings(self) -> WasmBuildTimings {
476 self.successful_timings
477 }
478
479 #[must_use]
481 pub const fn total(self) -> Duration {
482 self.total
483 }
484}
485
486impl WasmBuildBatchConfig {
487 #[must_use]
489 pub const fn new() -> Self {
490 Self {
491 shared_incremental_maintenance: None,
492 }
493 }
494
495 #[must_use]
497 pub const fn with_shared_incremental_target_maintenance(
498 mut self,
499 config: SharedIncrementalTargetMaintenanceConfig,
500 ) -> Self {
501 self.shared_incremental_maintenance = Some(config);
502 self
503 }
504
505 #[must_use]
507 pub const fn with_shared_incremental_target_maintenance_at_most_every(
508 self,
509 policy: CacheRetentionLimits,
510 minimum_interval: Duration,
511 ) -> Self {
512 self.with_shared_incremental_target_maintenance(
513 SharedIncrementalTargetMaintenanceConfig::new(policy, minimum_interval),
514 )
515 }
516
517 #[must_use]
519 pub const fn shared_incremental_target_maintenance(
520 self,
521 ) -> Option<SharedIncrementalTargetMaintenanceConfig> {
522 self.shared_incremental_maintenance
523 }
524}
525
526impl std::fmt::Display for WasmBuildBatchReport {
527 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528 let metrics = self.metrics();
529 write!(
530 formatter,
531 "builds={} succeeded={} failed={} built={} reused={} input_resolution_runs={} input_resolution_reuses={} input_resolution_prepared_reuses={} successful_timings=({}) total={:?}",
532 metrics.specifications(),
533 metrics.succeeded(),
534 metrics.failed(),
535 metrics.built(),
536 metrics.reused(),
537 metrics.input_resolution_runs(),
538 metrics.input_resolution_reuses(),
539 metrics.input_resolution_prepared_reuses(),
540 metrics.successful_timings(),
541 metrics.total(),
542 )
543 }
544}
545
546pub fn build_wasm_canisters_cached_batch(
560 specs: &[LabeledWasmBuildSpec],
561 config: WasmBuildBatchConfig,
562 progress: Option<(
563 WasmBuildProgressConfig,
564 &mut dyn FnMut(WasmBuildBatchProgressEvent),
565 )>,
566 mut spawn: impl FnMut(
567 &mut std::process::Command,
568 ) -> std::io::Result<ic_host_process::child::OwnedChild>,
569) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError> {
570 run_wasm_batch(specs, config, None, progress, &mut spawn)
571}
572
573#[expect(
574 clippy::result_large_err,
575 reason = "Batch entries retain complete failure details inline without per-failure allocation."
576)]
577fn run_wasm_batch(
578 specs: &[LabeledWasmBuildSpec],
579 batch_config: WasmBuildBatchConfig,
580 reuse: Option<&WasmBuildInputSnapshotState>,
581 mut observation: Option<(
582 WasmBuildProgressConfig,
583 &mut dyn FnMut(WasmBuildBatchProgressEvent),
584 )>,
585 mut spawn: impl FnMut(
586 &mut std::process::Command,
587 ) -> std::io::Result<ic_host_process::child::OwnedChild>,
588) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError> {
589 validate_labels(specs.iter().map(LabeledWasmBuildSpec::label))
590 .map_err(WasmBuildBatchContractError::Labels)?;
591 validate_input_reuse(reuse)?;
592 let count = specs.len();
593 let mut resolver =
594 WasmBuildBatchInputResolver::new(specs.iter().map(LabeledWasmBuildSpec::spec), reuse)
595 .map_err(
596 |index| WasmBuildBatchContractError::SpecificationNotPrepared {
597 index,
598 label: specs[index].label.clone(),
599 },
600 )?;
601 let mut report = build_wasm_batch(specs, batch_config, |spec, index| {
602 let Some((progress_config, observer)) = observation.as_mut() else {
603 return build_wasm_canisters_cached_in_batch(
604 spec,
605 index,
606 &mut resolver,
607 None,
608 &mut spawn,
609 );
610 };
611 let label = specs[index].label.clone();
612 observer(WasmBuildBatchProgressEvent::BuildStarted {
613 index,
614 label: label.clone(),
615 total: count,
616 });
617 let mut forward = |event| {
618 observer(WasmBuildBatchProgressEvent::BuildProgress {
619 index,
620 label: label.clone(),
621 event,
622 });
623 };
624 let attempt = build_wasm_canisters_cached_in_batch(
625 spec,
626 index,
627 &mut resolver,
628 Some((*progress_config, &mut forward)),
629 &mut spawn,
630 );
631 observer(match &attempt {
632 Ok(_) => WasmBuildBatchProgressEvent::BuildFinished { index, label },
633 Err(_) => WasmBuildBatchProgressEvent::BuildFailed { index, label },
634 });
635 attempt
636 });
637 report.input_resolution = resolver.metrics();
638 Ok(report)
639}
640
641fn build_wasm_batch<F>(
642 specs: &[LabeledWasmBuildSpec],
643 config: WasmBuildBatchConfig,
644 mut build: F,
645) -> WasmBuildBatchReport
646where
647 F: FnMut(
648 &WasmBuildSpec,
649 usize,
650 ) -> Result<WasmBuildOutcome, (WasmBuildError, WasmBuildFailureDetails)>,
651{
652 let started = Instant::now();
653 let mut entries = Vec::with_capacity(specs.len());
654 let mut maintenance = BatchMaintenanceTracker::new(config.shared_incremental_maintenance);
655 for (index, labeled) in specs.iter().enumerate() {
656 let entry_started = Instant::now();
657 let spec = &labeled.spec;
658 let result = if config.shared_incremental_maintenance.is_some()
659 && spec.shared_incremental_target_maintenance().is_some()
660 {
661 Err((
662 batch_maintenance_ownership_error(),
663 WasmBuildFailureDetails::specification(entry_started.elapsed()),
664 ))
665 } else {
666 match maintenance.prepare_spec(spec) {
667 Ok(configured) => build(configured.as_ref().unwrap_or(spec), index),
668 Err(error) => Err((
669 error,
670 WasmBuildFailureDetails::specification(entry_started.elapsed()),
671 )),
672 }
673 };
674 entries.push(WasmBuildBatchEntry {
675 index,
676 label: labeled.label.clone(),
677 result,
678 entry_elapsed: entry_started.elapsed(),
679 });
680 }
681 WasmBuildBatchReport {
682 entries,
683 input_resolution: WasmBuildBatchInputMetrics::default(),
684 total: started.elapsed(),
685 }
686}
687
688fn validate_input_reuse(
689 reuse: Option<&WasmBuildInputSnapshotState>,
690) -> Result<(), WasmBuildBatchContractError> {
691 if reuse.is_some_and(WasmBuildInputSnapshotState::is_invalidated) {
692 Err(WasmBuildBatchContractError::SourceLeaseInvalidated)
693 } else {
694 Ok(())
695 }
696}
697
698struct BatchMaintenanceTracker {
699 config: Option<SharedIncrementalTargetMaintenanceConfig>,
700 configured_targets: HashSet<PathBuf>,
701}
702
703impl BatchMaintenanceTracker {
704 fn new(config: Option<SharedIncrementalTargetMaintenanceConfig>) -> Self {
705 Self {
706 config,
707 configured_targets: HashSet::new(),
708 }
709 }
710
711 fn prepare_spec(
712 &mut self,
713 spec: &WasmBuildSpec,
714 ) -> Result<Option<WasmBuildSpec>, WasmBuildError> {
715 let Some(config) = self.config else {
716 return Ok(None);
717 };
718 debug_assert!(spec.shared_incremental_target_maintenance().is_none());
719 let Some(target_dir) = shared_incremental_target(spec) else {
720 return Ok(None);
721 };
722 let canonical =
723 canonicalize_allow_missing(&target_dir).map_err(|source| WasmBuildError::Io {
724 operation: "resolve batch shared incremental target",
725 path: target_dir,
726 source,
727 })?;
728 if !self.configured_targets.insert(canonical) {
729 return Ok(None);
730 }
731 Ok(Some(
732 spec.clone()
733 .with_shared_incremental_target_maintenance(config),
734 ))
735 }
736}
737
738fn batch_maintenance_ownership_error() -> WasmBuildError {
739 WasmBuildError::InvalidSpec {
740 message:
741 "batch-owned shared-target maintenance cannot be combined with per-spec maintenance"
742 .to_owned(),
743 }
744}
745
746impl std::fmt::Display for WasmBuildBatchContractError {
747 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
748 match self {
749 Self::Labels(source) => write!(formatter, "Wasm batch {source}"),
750 Self::SourceLeaseInvalidated => formatter
751 .write_str("Wasm build source lease was invalidated by a detected input mutation"),
752 Self::SpecificationNotPrepared { index, label } => write!(
753 formatter,
754 "Wasm batch entry {label:?} at index {index} was not declared when the input snapshot was prepared",
755 ),
756 }
757 }
758}
759
760impl std::error::Error for WasmBuildBatchContractError {
761 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
762 match self {
763 Self::Labels(source) => Some(source),
764 _ => None,
765 }
766 }
767}
768
769#[cfg(test)]
770mod tests;