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