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/// Partial phase timings retained for one failed artifact batch entry.
37#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
38pub struct ArtifactCacheBatchFailureTimings {
39    preparation: Duration,
40    callback: Option<Duration>,
41    cleanup: Option<Duration>,
42    commit: Option<Duration>,
43    total: Duration,
44}
45
46/// Failure from one entry in a collect-all artifact transaction batch.
47#[derive(Debug)]
48pub enum ArtifactCacheBatchFailure<E> {
49    /// Cache preparation failed before the population callback ran.
50    Preparation {
51        /// Cache preparation failure.
52        source: Box<ArtifactCacheError>,
53        /// Phase timings completed before the failure returned.
54        timings: ArtifactCacheBatchFailureTimings,
55    },
56    /// Transaction commit failed after successful population.
57    Commit {
58        /// Cache commit failure.
59        source: Box<ArtifactCacheError>,
60        /// Phase timings completed before the failure returned.
61        timings: ArtifactCacheBatchFailureTimings,
62    },
63    /// The caller's population callback failed.
64    Callback {
65        /// Caller population failure.
66        source: Box<E>,
67        /// Failure from synchronously aborting the active transaction.
68        cleanup_error: Option<Box<ArtifactCacheError>>,
69        /// Preparation, callback, and explicit cleanup timings.
70        timings: ArtifactCacheBatchFailureTimings,
71    },
72}
73
74/// Aggregate counters and successful-acquisition timings for an artifact batch.
75#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
76pub struct ArtifactCacheBatchMetrics {
77    entries: usize,
78    built: usize,
79    reused: usize,
80    successful_timings: ArtifactCacheTimings,
81    total: Duration,
82}
83
84impl LabeledArtifactCacheSpec {
85    /// Attach a caller-owned stable label to one artifact specification.
86    #[must_use]
87    pub fn new(label: impl Into<String>, spec: ArtifactCacheSpec) -> Self {
88        Self {
89            label: label.into(),
90            spec,
91        }
92    }
93
94    /// Caller-owned report and callback label.
95    #[must_use]
96    pub fn label(&self) -> &str {
97        &self.label
98    }
99
100    /// Underlying exact artifact specification.
101    #[must_use]
102    pub const fn spec(&self) -> &ArtifactCacheSpec {
103        &self.spec
104    }
105
106    /// Consume the entry into its label and artifact specification.
107    #[must_use]
108    pub fn into_parts(self) -> (String, ArtifactCacheSpec) {
109        (self.label, self.spec)
110    }
111}
112
113impl<E> ArtifactCacheBatchReport<E> {
114    /// Ordered labeled entries.
115    #[must_use]
116    pub fn entries(&self) -> &[ArtifactCacheBatchEntry<E>] {
117        &self.entries
118    }
119
120    /// Consume the report into its ordered labeled entries.
121    #[must_use]
122    pub fn into_entries(self) -> Vec<ArtifactCacheBatchEntry<E>> {
123        self.entries
124    }
125
126    /// Successful entries, retaining the canonical identity, result and timings.
127    pub fn outcomes(&self) -> impl Iterator<Item = &ArtifactCacheBatchEntry<E>> {
128        self.entries.iter().filter(|entry| entry.is_success())
129    }
130
131    /// Failed entries; inspect `failure()` on each entry.
132    pub fn failures(&self) -> impl Iterator<Item = &ArtifactCacheBatchEntry<E>> {
133        self.entries.iter().filter(|entry| !entry.is_success())
134    }
135
136    /// Complete wall-clock time for the sequential collect-all batch.
137    #[must_use]
138    pub const fn total(&self) -> Duration {
139        self.total
140    }
141
142    /// Whether every specification completed successfully.
143    #[must_use]
144    pub fn is_success(&self) -> bool {
145        self.entries.iter().all(ArtifactCacheBatchEntry::is_success)
146    }
147
148    /// Aggregate outcome counters and successful-acquisition timings.
149    #[must_use]
150    pub fn metrics(&self) -> ArtifactCacheBatchMetrics {
151        let mut metrics = ArtifactCacheBatchMetrics {
152            entries: self.entries.len(),
153            total: self.total,
154            ..ArtifactCacheBatchMetrics::default()
155        };
156        for outcome in self
157            .entries
158            .iter()
159            .filter_map(ArtifactCacheBatchEntry::outcome)
160        {
161            if outcome.is_reused() {
162                metrics.reused += 1;
163            } else {
164                metrics.built += 1;
165            }
166            metrics.successful_timings = metrics
167                .successful_timings
168                .saturating_add(outcome.record().timings());
169        }
170        metrics
171    }
172}
173
174impl<E> ArtifactCacheBatchEntry<E> {
175    /// Zero-based position in the supplied specification slice.
176    #[must_use]
177    pub const fn index(&self) -> usize {
178        self.index
179    }
180
181    /// Caller-owned stable label.
182    #[must_use]
183    pub fn label(&self) -> &str {
184        &self.label
185    }
186
187    /// Structured success or failure for this entry.
188    ///
189    /// # Errors
190    /// Returns this entry's recorded preparation, population or publication failure.
191    pub const fn result(&self) -> Result<&ArtifactCacheOutcome, &ArtifactCacheBatchFailure<E>> {
192        self.result.as_ref()
193    }
194
195    /// Successful artifact outcome, when this entry succeeded.
196    #[must_use]
197    pub fn outcome(&self) -> Option<&ArtifactCacheOutcome> {
198        self.result.as_ref().ok()
199    }
200
201    /// Structured batch failure, when this entry failed.
202    #[must_use]
203    pub fn failure(&self) -> Option<&ArtifactCacheBatchFailure<E>> {
204        self.result.as_ref().err()
205    }
206
207    /// Complete wall-clock time retained for this entry.
208    #[must_use]
209    pub const fn entry_elapsed(&self) -> Duration {
210        self.entry_elapsed
211    }
212
213    /// Whether this entry completed successfully.
214    #[must_use]
215    pub const fn is_success(&self) -> bool {
216        self.result.is_ok()
217    }
218
219    /// Consume the entry into its ordered identity, result, and wall time.
220    pub fn into_parts(
221        self,
222    ) -> (
223        usize,
224        String,
225        Result<ArtifactCacheOutcome, ArtifactCacheBatchFailure<E>>,
226        Duration,
227    ) {
228        (self.index, self.label, self.result, self.entry_elapsed)
229    }
230}
231
232impl<E> ArtifactCacheBatchFailure<E> {
233    /// Partial phase timings completed before the failure returned.
234    #[must_use]
235    pub const fn timings(&self) -> ArtifactCacheBatchFailureTimings {
236        match self {
237            Self::Preparation { timings, .. }
238            | Self::Commit { timings, .. }
239            | Self::Callback { timings, .. } => *timings,
240        }
241    }
242
243    /// Cleanup failure after a caller population error, when one occurred.
244    #[must_use]
245    pub fn cleanup_error(&self) -> Option<&ArtifactCacheError> {
246        match self {
247            Self::Callback { cleanup_error, .. } => cleanup_error.as_deref(),
248            Self::Preparation { .. } | Self::Commit { .. } => None,
249        }
250    }
251}
252
253impl ArtifactCacheBatchFailureTimings {
254    /// Time spent in cache preparation before the failure path continued.
255    #[must_use]
256    pub const fn preparation(self) -> Duration {
257        self.preparation
258    }
259
260    /// Time spent in the population callback, when it ran.
261    #[must_use]
262    pub const fn callback(self) -> Option<Duration> {
263        self.callback
264    }
265
266    /// Time spent explicitly aborting after a callback failure, when attempted.
267    #[must_use]
268    pub const fn cleanup(self) -> Option<Duration> {
269        self.cleanup
270    }
271
272    /// Time spent committing, including commit-owned failure cleanup, when attempted.
273    #[must_use]
274    pub const fn commit(self) -> Option<Duration> {
275        self.commit
276    }
277
278    /// Complete wall-clock time retained for the failed entry.
279    #[must_use]
280    pub const fn total(self) -> Duration {
281        self.total
282    }
283}
284
285impl ArtifactCacheBatchMetrics {
286    /// Number of supplied specifications.
287    #[must_use]
288    pub const fn entries(self) -> usize {
289        self.entries
290    }
291
292    /// Number of successful specifications.
293    #[must_use]
294    pub const fn succeeded(self) -> usize {
295        self.built + self.reused
296    }
297
298    /// Number of failed specifications.
299    #[must_use]
300    pub const fn failed(self) -> usize {
301        self.entries - self.succeeded()
302    }
303
304    /// Number of newly built artifact sets.
305    #[must_use]
306    pub const fn built(self) -> usize {
307        self.built
308    }
309
310    /// Number of artifact sets reused from the exact cache.
311    #[must_use]
312    pub const fn reused(self) -> usize {
313        self.reused
314    }
315
316    /// Sum of timings from successful acquisitions.
317    #[must_use]
318    pub const fn successful_timings(self) -> ArtifactCacheTimings {
319        self.successful_timings
320    }
321
322    /// Complete wall-clock time for the sequential batch.
323    #[must_use]
324    pub const fn total(self) -> Duration {
325        self.total
326    }
327}
328
329impl<E> std::fmt::Display for ArtifactCacheBatchReport<E> {
330    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331        let metrics = self.metrics();
332        write!(
333            formatter,
334            "entries={} succeeded={} failed={} built={} reused={} successful_timings=({}) total={:?}",
335            metrics.entries(),
336            metrics.succeeded(),
337            metrics.failed(),
338            metrics.built(),
339            metrics.reused(),
340            metrics.successful_timings(),
341            metrics.total(),
342        )
343    }
344}
345
346/// Build every independent caller-labeled artifact specification.
347///
348/// Labels must be nonempty and unique; the complete batch is rejected before
349/// work begins otherwise. The callback runs only for misses and receives the
350/// stable label plus one live transaction at a time. Sequential acquisition
351/// avoids self-deadlock when entries share a coordination scope. Every
352/// independent result is retained, and a callback error aborts and
353/// synchronously cleans that transaction before the next entry begins.
354///
355/// This operation is not atomic across specifications. A caller requiring one
356/// all-or-nothing multi-output publication should declare those outputs on one
357/// [`ArtifactCacheSpec`] instead.
358///
359/// # Errors
360/// Returns a contract error for an empty or duplicate label before any work.
361/// Per-entry acquisition, callback and commit errors are retained in the report.
362pub fn build_artifact_caches_batch<E, F>(
363    specs: &[LabeledArtifactCacheSpec],
364    mut populate: F,
365) -> Result<ArtifactCacheBatchReport<E>, BatchLabelError>
366where
367    F: FnMut(&str, &ArtifactBuildTransaction) -> Result<(), E>,
368{
369    validate_labels(specs.iter().map(LabeledArtifactCacheSpec::label))?;
370    let started = Instant::now();
371    let mut entries = Vec::with_capacity(specs.len());
372    for (index, labeled) in specs.iter().enumerate() {
373        entries.push(build_artifact_cache_entry(index, labeled, &mut populate));
374    }
375    Ok(ArtifactCacheBatchReport {
376        entries,
377        total: started.elapsed(),
378    })
379}
380
381fn build_artifact_cache_entry<E>(
382    index: usize,
383    labeled: &LabeledArtifactCacheSpec,
384    populate: &mut impl FnMut(&str, &ArtifactBuildTransaction) -> Result<(), E>,
385) -> ArtifactCacheBatchEntry<E> {
386    let entry_started = Instant::now();
387    let mut timings = ArtifactCacheBatchFailureTimings::default();
388    let mut result = (|| {
389        let preparation_started = Instant::now();
390        let preparation = prepare_artifact_cache(&labeled.spec);
391        timings.preparation = preparation_started.elapsed();
392        let transaction =
393            match preparation.map_err(|source| ArtifactCacheBatchFailure::Preparation {
394                source: Box::new(source),
395                timings,
396            })? {
397                ArtifactCachePreparation::Reused(record) => {
398                    return Ok(ArtifactCacheOutcome::Reused(record));
399                }
400                ArtifactCachePreparation::Build(transaction) => transaction,
401            };
402
403        let callback_started = Instant::now();
404        let callback_result = populate(&labeled.label, &transaction);
405        timings.callback = Some(callback_started.elapsed());
406        if let Err(source) = callback_result {
407            let cleanup_started = Instant::now();
408            let cleanup_error = transaction.abort().err().map(Box::new);
409            timings.cleanup = Some(cleanup_started.elapsed());
410            return Err(ArtifactCacheBatchFailure::Callback {
411                source: Box::new(source),
412                cleanup_error,
413                timings,
414            });
415        }
416
417        let commit_started = Instant::now();
418        let committed = transaction.commit();
419        timings.commit = Some(commit_started.elapsed());
420        committed.map_err(|source| ArtifactCacheBatchFailure::Commit {
421            source: Box::new(source),
422            timings,
423        })
424    })();
425    let entry_elapsed = entry_started.elapsed();
426    if let Err(
427        ArtifactCacheBatchFailure::Preparation { timings, .. }
428        | ArtifactCacheBatchFailure::Commit { timings, .. }
429        | ArtifactCacheBatchFailure::Callback { timings, .. },
430    ) = &mut result
431    {
432        timings.total = entry_elapsed;
433    }
434    ArtifactCacheBatchEntry {
435        index,
436        label: labeled.label.clone(),
437        result,
438        entry_elapsed,
439    }
440}
441
442impl std::fmt::Display for ArtifactCacheBatchFailureTimings {
443    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
444        write!(
445            formatter,
446            "total={:?} preparation={:?} callback={:?} cleanup={:?} commit={:?}",
447            self.total, self.preparation, self.callback, self.cleanup, self.commit,
448        )
449    }
450}
451
452impl<E: std::fmt::Display> std::fmt::Display for ArtifactCacheBatchFailure<E> {
453    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
454        match self {
455            Self::Preparation { source, timings } => write!(
456                formatter,
457                "artifact cache preparation failed: {source}; timings=({timings})",
458            ),
459            Self::Commit { source, timings } => write!(
460                formatter,
461                "artifact cache commit failed: {source}; timings=({timings})",
462            ),
463            Self::Callback {
464                source,
465                cleanup_error,
466                timings,
467            } => {
468                write!(
469                    formatter,
470                    "artifact callback failed: {source}; timings=({timings})"
471                )?;
472                if let Some(cleanup_error) = cleanup_error {
473                    write!(formatter, "; cleanup also failed: {cleanup_error}")?;
474                }
475                Ok(())
476            }
477        }
478    }
479}
480
481impl<E> std::error::Error for ArtifactCacheBatchFailure<E>
482where
483    E: std::error::Error + 'static,
484{
485    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
486        match self {
487            Self::Preparation { source, .. } | Self::Commit { source, .. } => Some(source.as_ref()),
488            Self::Callback { source, .. } => Some(source.as_ref()),
489        }
490    }
491}
492
493#[cfg(test)]
494mod tests;