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        let entry_started = Instant::now();
498        let preparation_started = Instant::now();
499        let preparation = prepare_artifact_cache(&labeled.spec);
500        let preparation_elapsed = preparation_started.elapsed();
501        let (result, entry_elapsed) = match preparation {
502            Ok(ArtifactCachePreparation::Reused(record)) => (
503                Ok(ArtifactCacheOutcome::Reused(record)),
504                entry_started.elapsed(),
505            ),
506            Ok(ArtifactCachePreparation::Build(transaction)) => {
507                let callback_started = Instant::now();
508                let callback_result = populate(&labeled.label, &transaction);
509                let callback_elapsed = callback_started.elapsed();
510                if let Err(source) = callback_result {
511                    let cleanup_started = Instant::now();
512                    let cleanup_error = transaction.abort().err().map(Box::new);
513                    let cleanup_elapsed = cleanup_started.elapsed();
514                    let entry_elapsed = entry_started.elapsed();
515                    (
516                        Err(ArtifactCacheBatchFailure::Build {
517                            source: Box::new(source),
518                            cleanup_error,
519                            timings: ArtifactCacheBatchFailureTimings {
520                                preparation: preparation_elapsed,
521                                callback: Some(callback_elapsed),
522                                cleanup: Some(cleanup_elapsed),
523                                commit: None,
524                                total: entry_elapsed,
525                            },
526                        }),
527                        entry_elapsed,
528                    )
529                } else {
530                    let commit_started = Instant::now();
531                    match transaction.commit() {
532                        Ok(outcome) => (Ok(outcome), entry_started.elapsed()),
533                        Err(source) => {
534                            let commit_elapsed = commit_started.elapsed();
535                            let entry_elapsed = entry_started.elapsed();
536                            (
537                                Err(ArtifactCacheBatchFailure::Cache {
538                                    phase: ArtifactCacheBatchFailurePhase::Commit,
539                                    source: Box::new(source),
540                                    timings: ArtifactCacheBatchFailureTimings {
541                                        preparation: preparation_elapsed,
542                                        callback: Some(callback_elapsed),
543                                        cleanup: None,
544                                        commit: Some(commit_elapsed),
545                                        total: entry_elapsed,
546                                    },
547                                }),
548                                entry_elapsed,
549                            )
550                        }
551                    }
552                }
553            }
554            Err(source) => {
555                let entry_elapsed = entry_started.elapsed();
556                (
557                    Err(ArtifactCacheBatchFailure::Cache {
558                        phase: ArtifactCacheBatchFailurePhase::Preparation,
559                        source: Box::new(source),
560                        timings: ArtifactCacheBatchFailureTimings {
561                            preparation: preparation_elapsed,
562                            callback: None,
563                            cleanup: None,
564                            commit: None,
565                            total: entry_elapsed,
566                        },
567                    }),
568                    entry_elapsed,
569                )
570            }
571        };
572        entries.push(ArtifactCacheBatchEntry {
573            index,
574            label: labeled.label.clone(),
575            result,
576            entry_elapsed,
577        });
578    }
579    Ok(ArtifactCacheBatchReport {
580        entries,
581        total: started.elapsed(),
582    })
583}
584
585fn validate_batch_labels(
586    specs: &[LabeledArtifactCacheSpec],
587) -> Result<(), ArtifactCacheBatchContractError> {
588    validate_labels(specs.iter().map(|labeled| labeled.label.as_str())).map_err(|error| match error
589    {
590        BatchLabelError::Empty { index } => ArtifactCacheBatchContractError::EmptyLabel { index },
591        BatchLabelError::Duplicate {
592            label,
593            first_index,
594            duplicate_index,
595        } => ArtifactCacheBatchContractError::DuplicateLabel {
596            label,
597            first_index,
598            duplicate_index,
599        },
600    })
601}
602
603impl std::fmt::Display for ArtifactCacheBatchContractError {
604    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
605        match self {
606            Self::EmptyLabel { index } => {
607                write!(formatter, "artifact batch label at index {index} is empty")
608            }
609            Self::DuplicateLabel {
610                label,
611                first_index,
612                duplicate_index,
613            } => write!(
614                formatter,
615                "artifact batch label {label:?} at index {duplicate_index} duplicates index {first_index}",
616            ),
617        }
618    }
619}
620
621impl std::error::Error for ArtifactCacheBatchContractError {}
622
623impl std::fmt::Display for ArtifactCacheBatchFailurePhase {
624    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
625        formatter.write_str(match self {
626            Self::Preparation => "preparation",
627            Self::Callback => "callback",
628            Self::Commit => "commit",
629        })
630    }
631}
632
633impl std::fmt::Display for ArtifactCacheBatchFailureTimings {
634    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
635        write!(
636            formatter,
637            "total={:?} preparation={:?} callback={:?} cleanup={:?} commit={:?}",
638            self.total, self.preparation, self.callback, self.cleanup, self.commit,
639        )
640    }
641}
642
643impl<E: std::fmt::Display> std::fmt::Display for ArtifactCacheBatchFailure<E> {
644    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
645        match self {
646            Self::Cache {
647                phase,
648                source,
649                timings,
650            } => write!(
651                formatter,
652                "artifact cache {phase} failed: {source}; timings=({timings})",
653            ),
654            Self::Build {
655                source,
656                cleanup_error,
657                timings,
658            } => {
659                write!(
660                    formatter,
661                    "artifact callback failed: {source}; timings=({timings})"
662                )?;
663                if let Some(cleanup_error) = cleanup_error {
664                    write!(formatter, "; cleanup also failed: {cleanup_error}")?;
665                }
666                Ok(())
667            }
668        }
669    }
670}
671
672impl<E> std::error::Error for ArtifactCacheBatchFailure<E>
673where
674    E: std::error::Error + 'static,
675{
676    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
677        match self {
678            Self::Cache { source, .. } => Some(source.as_ref()),
679            Self::Build { source, .. } => Some(source.as_ref()),
680        }
681    }
682}
683
684#[cfg(test)]
685mod tests;