Skip to main content

ic_testkit/artifacts/
transaction_batch.rs

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