Skip to main content

ic_build/artifacts/
transaction_batch.rs

1use crate::batch::{BatchLabelError, validate_labels};
2
3use std::time::{Duration, Instant};
4
5use super::transaction::{
6    ArtifactBuildTransaction, ArtifactCacheError, ArtifactCacheOutcome, ArtifactCachePreparation,
7    ArtifactCacheSpec, ArtifactCacheTimings, prepare_artifact_cache,
8};
9
10/// Caller-labeled specification for one generic artifact batch entry.
11///
12/// The label is report and callback identity, not artifact-cache identity. The
13/// caller must keep it stable anywhere reports are composed across stages.
14#[derive(Clone, Debug, Eq, PartialEq)]
15pub struct LabeledArtifactCacheSpec {
16    label: String,
17    spec: ArtifactCacheSpec,
18}
19
20/// Ordered labeled entries from a collect-all artifact transaction batch.
21#[derive(Debug)]
22pub struct ArtifactCacheBatchReport<E> {
23    entries: Vec<ArtifactCacheBatchEntry<E>>,
24    total: Duration,
25}
26
27/// One ordered labeled result from a generic artifact batch.
28#[derive(Debug)]
29pub struct ArtifactCacheBatchEntry<E> {
30    index: usize,
31    label: String,
32    result: Result<ArtifactCacheOutcome, ArtifactCacheBatchFailure<E>>,
33    entry_elapsed: Duration,
34}
35
36/// One successful generic artifact batch entry.
37#[derive(Clone, Copy, Debug)]
38pub struct ArtifactCacheBatchOutcomeEntry<'a> {
39    index: usize,
40    label: &'a str,
41    outcome: &'a ArtifactCacheOutcome,
42    entry_elapsed: Duration,
43}
44
45/// One failed generic artifact batch entry.
46#[derive(Debug)]
47pub struct ArtifactCacheBatchFailedEntry<'a, E> {
48    index: usize,
49    label: &'a str,
50    failure: &'a ArtifactCacheBatchFailure<E>,
51    entry_elapsed: Duration,
52}
53
54/// Structural error that prevents a labeled artifact batch from starting.
55#[non_exhaustive]
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub enum ArtifactCacheBatchContractError {
58    /// An entry label was empty.
59    EmptyLabel {
60        /// Zero-based position of the invalid entry.
61        index: usize,
62    },
63    /// Two entries used the same label.
64    DuplicateLabel {
65        /// Duplicated caller label.
66        label: String,
67        /// Position where the label first appeared.
68        first_index: usize,
69        /// Position where the label was repeated.
70        duplicate_index: usize,
71    },
72}
73
74/// Primary phase in which a generic artifact batch entry failed.
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76pub enum ArtifactCacheBatchFailurePhase {
77    /// Cache preparation failed before the population callback ran.
78    Preparation,
79    /// The caller's population callback failed.
80    Callback,
81    /// Transaction commit failed after successful population.
82    Commit,
83}
84
85/// Partial phase timings retained for one failed artifact batch entry.
86#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
87pub struct ArtifactCacheBatchFailureTimings {
88    preparation: Duration,
89    callback: Option<Duration>,
90    cleanup: Option<Duration>,
91    commit: Option<Duration>,
92    total: Duration,
93}
94
95/// Failure from one entry in a collect-all artifact transaction batch.
96#[derive(Debug)]
97pub enum ArtifactCacheBatchFailure<E> {
98    /// Cache preparation or commit failed.
99    Cache {
100        /// Failed cache phase.
101        phase: ArtifactCacheBatchFailurePhase,
102        /// Cache preparation or commit failure.
103        source: Box<ArtifactCacheError>,
104        /// Phase timings completed before the failure returned.
105        timings: ArtifactCacheBatchFailureTimings,
106    },
107    /// The caller's population callback failed.
108    Build {
109        /// Caller population failure.
110        source: Box<E>,
111        /// Failure from synchronously aborting the active transaction.
112        cleanup_error: Option<Box<ArtifactCacheError>>,
113        /// Preparation, callback, and explicit cleanup timings.
114        timings: ArtifactCacheBatchFailureTimings,
115    },
116}
117
118/// Aggregate counters and successful-acquisition timings for an artifact batch.
119#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
120pub struct ArtifactCacheBatchMetrics {
121    entries: usize,
122    built: usize,
123    reused: usize,
124    successful_timings: ArtifactCacheTimings,
125    total: Duration,
126}
127
128impl LabeledArtifactCacheSpec {
129    /// Attach a caller-owned stable label to one artifact specification.
130    #[must_use]
131    pub fn new(label: impl Into<String>, spec: ArtifactCacheSpec) -> Self {
132        Self {
133            label: label.into(),
134            spec,
135        }
136    }
137
138    /// Caller-owned report and callback label.
139    #[must_use]
140    pub fn label(&self) -> &str {
141        &self.label
142    }
143
144    /// Underlying exact artifact specification.
145    #[must_use]
146    pub const fn spec(&self) -> &ArtifactCacheSpec {
147        &self.spec
148    }
149
150    /// Consume the entry into its label and artifact specification.
151    #[must_use]
152    pub fn into_parts(self) -> (String, ArtifactCacheSpec) {
153        (self.label, self.spec)
154    }
155}
156
157impl<E> ArtifactCacheBatchReport<E> {
158    /// Ordered labeled entries.
159    #[must_use]
160    pub fn entries(&self) -> &[ArtifactCacheBatchEntry<E>] {
161        &self.entries
162    }
163
164    /// Consume the report into its ordered labeled entries.
165    #[must_use]
166    pub fn into_entries(self) -> Vec<ArtifactCacheBatchEntry<E>> {
167        self.entries
168    }
169
170    /// Structured successful entries with labels and wall-clock times.
171    pub fn outcomes(&self) -> impl Iterator<Item = ArtifactCacheBatchOutcomeEntry<'_>> {
172        self.entries.iter().filter_map(|entry| {
173            entry
174                .outcome()
175                .map(|outcome| ArtifactCacheBatchOutcomeEntry {
176                    index: entry.index,
177                    label: &entry.label,
178                    outcome,
179                    entry_elapsed: entry.entry_elapsed,
180                })
181        })
182    }
183
184    /// Structured failed entries with labels and partial phase timings.
185    pub fn failures(&self) -> impl Iterator<Item = ArtifactCacheBatchFailedEntry<'_, E>> {
186        self.entries.iter().filter_map(|entry| {
187            entry
188                .failure()
189                .map(|failure| ArtifactCacheBatchFailedEntry {
190                    index: entry.index,
191                    label: &entry.label,
192                    failure,
193                    entry_elapsed: entry.entry_elapsed,
194                })
195        })
196    }
197
198    /// Complete wall-clock time for the sequential collect-all batch.
199    #[must_use]
200    pub const fn total(&self) -> Duration {
201        self.total
202    }
203
204    /// Whether every specification completed successfully.
205    #[must_use]
206    pub fn is_success(&self) -> bool {
207        self.entries.iter().all(ArtifactCacheBatchEntry::is_success)
208    }
209
210    /// Aggregate outcome counters and successful-acquisition timings.
211    #[must_use]
212    pub fn metrics(&self) -> ArtifactCacheBatchMetrics {
213        let mut metrics = ArtifactCacheBatchMetrics {
214            entries: self.entries.len(),
215            total: self.total,
216            ..ArtifactCacheBatchMetrics::default()
217        };
218        for entry in self.outcomes() {
219            let outcome = entry.outcome();
220            if outcome.is_reused() {
221                metrics.reused += 1;
222            } else {
223                metrics.built += 1;
224            }
225            metrics.successful_timings = metrics
226                .successful_timings
227                .saturating_add(outcome.record().timings());
228        }
229        metrics
230    }
231}
232
233impl<E> ArtifactCacheBatchEntry<E> {
234    /// Zero-based position in the supplied specification slice.
235    #[must_use]
236    pub const fn index(&self) -> usize {
237        self.index
238    }
239
240    /// Caller-owned stable label.
241    #[must_use]
242    pub fn label(&self) -> &str {
243        &self.label
244    }
245
246    /// Structured success or failure for this entry.
247    ///
248    /// # Errors
249    /// Returns this entry's recorded preparation, population or publication failure.
250    pub const fn result(&self) -> Result<&ArtifactCacheOutcome, &ArtifactCacheBatchFailure<E>> {
251        self.result.as_ref()
252    }
253
254    /// Successful artifact outcome, when this entry succeeded.
255    #[must_use]
256    pub fn outcome(&self) -> Option<&ArtifactCacheOutcome> {
257        self.result.as_ref().ok()
258    }
259
260    /// Structured batch failure, when this entry failed.
261    #[must_use]
262    pub fn failure(&self) -> Option<&ArtifactCacheBatchFailure<E>> {
263        self.result.as_ref().err()
264    }
265
266    /// Complete wall-clock time retained for this entry.
267    #[must_use]
268    pub const fn entry_elapsed(&self) -> Duration {
269        self.entry_elapsed
270    }
271
272    /// Whether this entry completed successfully.
273    #[must_use]
274    pub const fn is_success(&self) -> bool {
275        self.result.is_ok()
276    }
277
278    /// Consume the entry into its ordered identity, result, and wall time.
279    pub fn into_parts(
280        self,
281    ) -> (
282        usize,
283        String,
284        Result<ArtifactCacheOutcome, ArtifactCacheBatchFailure<E>>,
285        Duration,
286    ) {
287        (self.index, self.label, self.result, self.entry_elapsed)
288    }
289}
290
291impl<'a> ArtifactCacheBatchOutcomeEntry<'a> {
292    /// Zero-based position in the supplied specification slice.
293    #[must_use]
294    pub const fn index(self) -> usize {
295        self.index
296    }
297
298    /// Caller-owned stable label.
299    #[must_use]
300    pub const fn label(self) -> &'a str {
301        self.label
302    }
303
304    /// Successful artifact outcome.
305    #[must_use]
306    pub const fn outcome(self) -> &'a ArtifactCacheOutcome {
307        self.outcome
308    }
309
310    /// Complete wall-clock time retained for this successful entry.
311    #[must_use]
312    pub const fn entry_elapsed(self) -> Duration {
313        self.entry_elapsed
314    }
315}
316
317impl<'a, E> ArtifactCacheBatchFailedEntry<'a, E> {
318    /// Zero-based position in the supplied specification slice.
319    #[must_use]
320    pub const fn index(&self) -> usize {
321        self.index
322    }
323
324    /// Caller-owned stable label.
325    #[must_use]
326    pub const fn label(&self) -> &'a str {
327        self.label
328    }
329
330    /// Structured cache or caller-build failure.
331    #[must_use]
332    pub const fn failure(&self) -> &'a ArtifactCacheBatchFailure<E> {
333        self.failure
334    }
335
336    /// Partial phase timings retained with the failure.
337    #[must_use]
338    pub const fn timings(&self) -> ArtifactCacheBatchFailureTimings {
339        self.failure.timings()
340    }
341
342    /// Complete wall-clock time retained for this failed entry.
343    #[must_use]
344    pub const fn entry_elapsed(&self) -> Duration {
345        self.entry_elapsed
346    }
347}
348
349impl<E> ArtifactCacheBatchFailure<E> {
350    /// Primary phase in which this entry failed.
351    #[must_use]
352    pub const fn phase(&self) -> ArtifactCacheBatchFailurePhase {
353        match self {
354            Self::Cache { phase, .. } => *phase,
355            Self::Build { .. } => ArtifactCacheBatchFailurePhase::Callback,
356        }
357    }
358
359    /// Partial phase timings completed before the failure returned.
360    #[must_use]
361    pub const fn timings(&self) -> ArtifactCacheBatchFailureTimings {
362        match self {
363            Self::Cache { timings, .. } | Self::Build { timings, .. } => *timings,
364        }
365    }
366
367    /// Cleanup failure after a caller population error, when one occurred.
368    #[must_use]
369    pub fn cleanup_error(&self) -> Option<&ArtifactCacheError> {
370        match self {
371            Self::Build { cleanup_error, .. } => cleanup_error.as_deref(),
372            Self::Cache { .. } => None,
373        }
374    }
375}
376
377impl ArtifactCacheBatchFailureTimings {
378    /// Time spent in cache preparation before the failure path continued.
379    #[must_use]
380    pub const fn preparation(self) -> Duration {
381        self.preparation
382    }
383
384    /// Time spent in the population callback, when it ran.
385    #[must_use]
386    pub const fn callback(self) -> Option<Duration> {
387        self.callback
388    }
389
390    /// Time spent explicitly aborting after a callback failure, when attempted.
391    #[must_use]
392    pub const fn cleanup(self) -> Option<Duration> {
393        self.cleanup
394    }
395
396    /// Time spent committing, including commit-owned failure cleanup, when attempted.
397    #[must_use]
398    pub const fn commit(self) -> Option<Duration> {
399        self.commit
400    }
401
402    /// Complete wall-clock time retained for the failed entry.
403    #[must_use]
404    pub const fn total(self) -> Duration {
405        self.total
406    }
407}
408
409impl ArtifactCacheBatchMetrics {
410    /// Number of supplied specifications.
411    #[must_use]
412    pub const fn entries(self) -> usize {
413        self.entries
414    }
415
416    /// Number of successful specifications.
417    #[must_use]
418    pub const fn succeeded(self) -> usize {
419        self.built + self.reused
420    }
421
422    /// Number of failed specifications.
423    #[must_use]
424    pub const fn failed(self) -> usize {
425        self.entries - self.succeeded()
426    }
427
428    /// Number of newly built artifact sets.
429    #[must_use]
430    pub const fn built(self) -> usize {
431        self.built
432    }
433
434    /// Number of artifact sets reused from the exact cache.
435    #[must_use]
436    pub const fn reused(self) -> usize {
437        self.reused
438    }
439
440    /// Sum of timings from successful acquisitions.
441    #[must_use]
442    pub const fn successful_timings(self) -> ArtifactCacheTimings {
443        self.successful_timings
444    }
445
446    /// Complete wall-clock time for the sequential batch.
447    #[must_use]
448    pub const fn total(self) -> Duration {
449        self.total
450    }
451}
452
453impl<E> std::fmt::Display for ArtifactCacheBatchReport<E> {
454    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455        let metrics = self.metrics();
456        write!(
457            formatter,
458            "entries={} succeeded={} failed={} built={} reused={} successful_timings=({}) total={:?}",
459            metrics.entries(),
460            metrics.succeeded(),
461            metrics.failed(),
462            metrics.built(),
463            metrics.reused(),
464            metrics.successful_timings(),
465            metrics.total(),
466        )
467    }
468}
469
470/// Build every independent caller-labeled artifact specification.
471///
472/// Labels must be nonempty and unique; the complete batch is rejected before
473/// work begins otherwise. The callback runs only for misses and receives the
474/// stable label plus one live transaction at a time. Sequential acquisition
475/// avoids self-deadlock when entries share a coordination scope. Every
476/// independent result is retained, and a callback error aborts and
477/// synchronously cleans that transaction before the next entry begins.
478///
479/// This operation is not atomic across specifications. A caller requiring one
480/// all-or-nothing multi-output publication should declare those outputs on one
481/// [`ArtifactCacheSpec`] instead.
482///
483/// # Errors
484/// Returns a contract error for an empty or duplicate label before any work.
485/// Per-entry acquisition, callback and commit errors are retained in the report.
486pub fn build_artifact_caches_batch<E, F>(
487    specs: &[LabeledArtifactCacheSpec],
488    mut populate: F,
489) -> Result<ArtifactCacheBatchReport<E>, ArtifactCacheBatchContractError>
490where
491    F: FnMut(&str, &ArtifactBuildTransaction) -> Result<(), E>,
492{
493    validate_batch_labels(specs)?;
494    let started = Instant::now();
495    let mut entries = Vec::with_capacity(specs.len());
496    for (index, labeled) in specs.iter().enumerate() {
497        entries.push(build_artifact_cache_entry(index, labeled, &mut populate));
498    }
499    Ok(ArtifactCacheBatchReport {
500        entries,
501        total: started.elapsed(),
502    })
503}
504
505fn build_artifact_cache_entry<E>(
506    index: usize,
507    labeled: &LabeledArtifactCacheSpec,
508    populate: &mut impl FnMut(&str, &ArtifactBuildTransaction) -> Result<(), E>,
509) -> ArtifactCacheBatchEntry<E> {
510    let entry_started = Instant::now();
511    let mut timings = ArtifactCacheBatchFailureTimings::default();
512    let mut result = (|| {
513        let preparation_started = Instant::now();
514        let preparation = prepare_artifact_cache(&labeled.spec);
515        timings.preparation = preparation_started.elapsed();
516        let transaction = match preparation.map_err(|source| ArtifactCacheBatchFailure::Cache {
517            phase: ArtifactCacheBatchFailurePhase::Preparation,
518            source: Box::new(source),
519            timings,
520        })? {
521            ArtifactCachePreparation::Reused(record) => {
522                return Ok(ArtifactCacheOutcome::Reused(record));
523            }
524            ArtifactCachePreparation::Build(transaction) => transaction,
525        };
526
527        let callback_started = Instant::now();
528        let callback_result = populate(&labeled.label, &transaction);
529        timings.callback = Some(callback_started.elapsed());
530        if let Err(source) = callback_result {
531            let cleanup_started = Instant::now();
532            let cleanup_error = transaction.abort().err().map(Box::new);
533            timings.cleanup = Some(cleanup_started.elapsed());
534            return Err(ArtifactCacheBatchFailure::Build {
535                source: Box::new(source),
536                cleanup_error,
537                timings,
538            });
539        }
540
541        let commit_started = Instant::now();
542        let committed = transaction.commit();
543        timings.commit = Some(commit_started.elapsed());
544        committed.map_err(|source| ArtifactCacheBatchFailure::Cache {
545            phase: ArtifactCacheBatchFailurePhase::Commit,
546            source: Box::new(source),
547            timings,
548        })
549    })();
550    let entry_elapsed = entry_started.elapsed();
551    if let Err(
552        ArtifactCacheBatchFailure::Cache { timings, .. }
553        | ArtifactCacheBatchFailure::Build { timings, .. },
554    ) = &mut result
555    {
556        timings.total = entry_elapsed;
557    }
558    ArtifactCacheBatchEntry {
559        index,
560        label: labeled.label.clone(),
561        result,
562        entry_elapsed,
563    }
564}
565
566fn validate_batch_labels(
567    specs: &[LabeledArtifactCacheSpec],
568) -> Result<(), ArtifactCacheBatchContractError> {
569    validate_labels(specs.iter().map(|labeled| labeled.label.as_str())).map_err(|error| match error
570    {
571        BatchLabelError::Empty { index } => ArtifactCacheBatchContractError::EmptyLabel { index },
572        BatchLabelError::Duplicate {
573            label,
574            first_index,
575            duplicate_index,
576        } => ArtifactCacheBatchContractError::DuplicateLabel {
577            label,
578            first_index,
579            duplicate_index,
580        },
581    })
582}
583
584impl std::fmt::Display for ArtifactCacheBatchContractError {
585    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
586        match self {
587            Self::EmptyLabel { index } => {
588                write!(formatter, "artifact batch label at index {index} is empty")
589            }
590            Self::DuplicateLabel {
591                label,
592                first_index,
593                duplicate_index,
594            } => write!(
595                formatter,
596                "artifact batch label {label:?} at index {duplicate_index} duplicates index {first_index}",
597            ),
598        }
599    }
600}
601
602impl std::error::Error for ArtifactCacheBatchContractError {}
603
604impl std::fmt::Display for ArtifactCacheBatchFailurePhase {
605    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
606        formatter.write_str(match self {
607            Self::Preparation => "preparation",
608            Self::Callback => "callback",
609            Self::Commit => "commit",
610        })
611    }
612}
613
614impl std::fmt::Display for ArtifactCacheBatchFailureTimings {
615    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
616        write!(
617            formatter,
618            "total={:?} preparation={:?} callback={:?} cleanup={:?} commit={:?}",
619            self.total, self.preparation, self.callback, self.cleanup, self.commit,
620        )
621    }
622}
623
624impl<E: std::fmt::Display> std::fmt::Display for ArtifactCacheBatchFailure<E> {
625    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
626        match self {
627            Self::Cache {
628                phase,
629                source,
630                timings,
631            } => write!(
632                formatter,
633                "artifact cache {phase} failed: {source}; timings=({timings})",
634            ),
635            Self::Build {
636                source,
637                cleanup_error,
638                timings,
639            } => {
640                write!(
641                    formatter,
642                    "artifact callback failed: {source}; timings=({timings})"
643                )?;
644                if let Some(cleanup_error) = cleanup_error {
645                    write!(formatter, "; cleanup also failed: {cleanup_error}")?;
646                }
647                Ok(())
648            }
649        }
650    }
651}
652
653impl<E> std::error::Error for ArtifactCacheBatchFailure<E>
654where
655    E: std::error::Error + 'static,
656{
657    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
658        match self {
659            Self::Cache { source, .. } => Some(source.as_ref()),
660            Self::Build { source, .. } => Some(source.as_ref()),
661        }
662    }
663}
664
665#[cfg(test)]
666mod tests;