Skip to main content

ic_testkit/artifacts/
transaction_batch.rs

1use std::time::{Duration, Instant};
2
3use super::transaction::{
4    ArtifactBuildTransaction, ArtifactCacheError, ArtifactCacheOutcome, ArtifactCachePreparation,
5    ArtifactCacheSpec, prepare_artifact_cache,
6};
7
8/// Successful outcomes from sequential independent artifact transactions.
9#[derive(Clone, Debug, Eq, PartialEq)]
10pub struct ArtifactCacheBatchOutcome {
11    outcomes: Vec<ArtifactCacheOutcome>,
12    total: Duration,
13}
14
15/// Failure from a sequential independent artifact transaction batch.
16#[derive(Debug)]
17pub enum ArtifactCacheBatchError<E> {
18    /// Cache preparation or commit failed.
19    Cache {
20        failed_index: usize,
21        completed: Vec<ArtifactCacheOutcome>,
22        total: Duration,
23        source: Box<ArtifactCacheError>,
24    },
25    /// The caller's population callback failed.
26    Build {
27        failed_index: usize,
28        completed: Vec<ArtifactCacheOutcome>,
29        total: Duration,
30        source: Box<E>,
31        cleanup_error: Option<Box<ArtifactCacheError>>,
32    },
33}
34
35impl ArtifactCacheBatchOutcome {
36    /// Successful outcomes in specification order.
37    #[must_use]
38    pub fn outcomes(&self) -> &[ArtifactCacheOutcome] {
39        &self.outcomes
40    }
41
42    /// Consume the report and return its ordered outcomes.
43    #[must_use]
44    pub fn into_outcomes(self) -> Vec<ArtifactCacheOutcome> {
45        self.outcomes
46    }
47
48    /// Complete wall-clock time for the sequential batch.
49    #[must_use]
50    pub const fn total(&self) -> Duration {
51        self.total
52    }
53}
54
55impl<E> ArtifactCacheBatchError<E> {
56    /// Zero-based index of the failed independent specification.
57    #[must_use]
58    pub const fn failed_index(&self) -> usize {
59        match self {
60            Self::Cache { failed_index, .. } | Self::Build { failed_index, .. } => *failed_index,
61        }
62    }
63
64    /// Successful outcomes completed before the failure.
65    #[must_use]
66    pub fn completed(&self) -> &[ArtifactCacheOutcome] {
67        match self {
68            Self::Cache { completed, .. } | Self::Build { completed, .. } => completed,
69        }
70    }
71
72    /// Wall-clock time through the failure.
73    #[must_use]
74    pub const fn total(&self) -> Duration {
75        match self {
76            Self::Cache { total, .. } | Self::Build { total, .. } => *total,
77        }
78    }
79
80    /// Cleanup failure after a caller population error, when one occurred.
81    #[must_use]
82    pub fn cleanup_error(&self) -> Option<&ArtifactCacheError> {
83        match self {
84            Self::Build { cleanup_error, .. } => cleanup_error.as_deref(),
85            Self::Cache { .. } => None,
86        }
87    }
88}
89
90impl std::fmt::Display for ArtifactCacheBatchOutcome {
91    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        let reused = self
93            .outcomes
94            .iter()
95            .filter(|outcome| outcome.is_reused())
96            .count();
97        write!(
98            formatter,
99            "entries={} built={} reused={} total={:?}",
100            self.outcomes.len(),
101            self.outcomes.len().saturating_sub(reused),
102            reused,
103            self.total,
104        )
105    }
106}
107
108/// Build or reuse multiple independent transactional artifact sets.
109///
110/// The callback runs only for misses and receives one live transaction at a
111/// time. Sequential acquisition avoids self-deadlock when entries share a
112/// coordination scope. A callback error aborts and synchronously cleans that
113/// transaction before returning. Completed entries remain valid when a later
114/// entry fails.
115///
116/// This operation is not atomic across specifications. A caller requiring one
117/// all-or-nothing multi-output publication should declare those outputs on one
118/// [`ArtifactCacheSpec`] instead.
119pub fn build_artifact_caches_batch<E, F>(
120    specs: &[ArtifactCacheSpec],
121    mut populate: F,
122) -> Result<ArtifactCacheBatchOutcome, ArtifactCacheBatchError<E>>
123where
124    F: FnMut(usize, &ArtifactBuildTransaction) -> Result<(), E>,
125{
126    let started = Instant::now();
127    let mut outcomes = Vec::with_capacity(specs.len());
128    for (index, spec) in specs.iter().enumerate() {
129        let preparation = match prepare_artifact_cache(spec) {
130            Ok(preparation) => preparation,
131            Err(source) => {
132                return Err(ArtifactCacheBatchError::Cache {
133                    failed_index: index,
134                    completed: outcomes,
135                    total: started.elapsed(),
136                    source: Box::new(source),
137                });
138            }
139        };
140        let outcome = match preparation {
141            ArtifactCachePreparation::Reused(record) => ArtifactCacheOutcome::Reused(record),
142            ArtifactCachePreparation::Build(transaction) => {
143                if let Err(source) = populate(index, &transaction) {
144                    let cleanup_error = transaction.abort().err().map(Box::new);
145                    return Err(ArtifactCacheBatchError::Build {
146                        failed_index: index,
147                        completed: outcomes,
148                        total: started.elapsed(),
149                        source: Box::new(source),
150                        cleanup_error,
151                    });
152                }
153                match transaction.commit() {
154                    Ok(outcome) => outcome,
155                    Err(source) => {
156                        return Err(ArtifactCacheBatchError::Cache {
157                            failed_index: index,
158                            completed: outcomes,
159                            total: started.elapsed(),
160                            source: Box::new(source),
161                        });
162                    }
163                }
164            }
165        };
166        outcomes.push(outcome);
167    }
168    Ok(ArtifactCacheBatchOutcome {
169        outcomes,
170        total: started.elapsed(),
171    })
172}
173
174impl<E: std::fmt::Display> std::fmt::Display for ArtifactCacheBatchError<E> {
175    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        match self {
177            Self::Cache {
178                failed_index,
179                completed,
180                source,
181                ..
182            } => write!(
183                formatter,
184                "artifact cache batch entry {failed_index} failed after {} successful entry/entries: {source}",
185                completed.len(),
186            ),
187            Self::Build {
188                failed_index,
189                completed,
190                source,
191                cleanup_error,
192                ..
193            } => {
194                write!(
195                    formatter,
196                    "artifact cache batch builder {failed_index} failed after {} successful entry/entries: {source}",
197                    completed.len(),
198                )?;
199                if let Some(cleanup_error) = cleanup_error {
200                    write!(formatter, "; cleanup also failed: {cleanup_error}")?;
201                }
202                Ok(())
203            }
204        }
205    }
206}
207
208impl<E> std::error::Error for ArtifactCacheBatchError<E>
209where
210    E: std::error::Error + 'static,
211{
212    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
213        match self {
214            Self::Cache { source, .. } => Some(source.as_ref()),
215            Self::Build { source, .. } => Some(source.as_ref()),
216        }
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::{ArtifactCacheBatchError, build_artifact_caches_batch};
223    use crate::artifacts::{
224        ArtifactCacheOutcome, ArtifactCachePreparation, ArtifactCacheSpec, prepare_artifact_cache,
225        test_support::unique_temp_directory,
226    };
227    use std::fs;
228
229    #[test]
230    fn independent_transactions_build_then_reuse_in_order() {
231        let root = unique_temp_directory("artifact-cache-batch");
232        let input = root.join("input");
233        fs::write(&input, b"input").expect("write batch input");
234        let specs = [
235            ArtifactCacheSpec::new(&root.join("cache"), "first", "recipe/v1")
236                .with_coordination_scope("shared-builder")
237                .with_input("input", &input)
238                .with_output("output", &root.join("first.out")),
239            ArtifactCacheSpec::new(&root.join("cache"), "second", "recipe/v1")
240                .with_coordination_scope("shared-builder")
241                .with_input("input", &input)
242                .with_output("output", &root.join("second.out")),
243        ];
244        let mut built_indices = Vec::new();
245        let built = build_artifact_caches_batch(&specs, |index, transaction| {
246            built_indices.push(index);
247            fs::write(
248                transaction
249                    .output_path("output")
250                    .expect("batch output path"),
251                format!("output-{index}"),
252            )
253            .expect("write batch output");
254            Ok::<(), &'static str>(())
255        })
256        .expect("build independent batch");
257
258        assert_eq!(built_indices, [0, 1]);
259        assert!(
260            built
261                .outcomes()
262                .iter()
263                .all(|outcome| matches!(outcome, ArtifactCacheOutcome::Built(_)))
264        );
265
266        let reused = build_artifact_caches_batch(&specs, |_index, _transaction| {
267            Err::<(), _>("unexpected cache miss")
268        })
269        .expect("reuse independent batch");
270        assert!(
271            reused
272                .outcomes()
273                .iter()
274                .all(ArtifactCacheOutcome::is_reused)
275        );
276        fs::remove_dir_all(root).expect("remove artifact batch fixture");
277    }
278
279    #[test]
280    fn builder_failure_aborts_current_transaction_and_reports_completed_prefix() {
281        let root = unique_temp_directory("artifact-cache-batch-failure");
282        let input = root.join("input");
283        fs::write(&input, b"input").expect("write batch input");
284        let specs = [
285            ArtifactCacheSpec::new(&root.join("cache"), "first", "recipe/v1")
286                .with_input("input", &input)
287                .with_output("output", &root.join("first.out")),
288            ArtifactCacheSpec::new(&root.join("cache"), "second", "recipe/v1")
289                .with_input("input", &input)
290                .with_output("output", &root.join("second.out")),
291        ];
292        let result = build_artifact_caches_batch(&specs, |index, transaction| {
293            if index == 1 {
294                return Err("synthetic builder failure");
295            }
296            fs::write(transaction.output_path("output").unwrap(), b"first")
297                .expect("write successful prefix output");
298            Ok(())
299        });
300
301        let ArtifactCacheBatchError::Build {
302            failed_index,
303            completed,
304            cleanup_error,
305            ..
306        } = result.expect_err("second builder must fail")
307        else {
308            panic!("expected caller builder failure");
309        };
310        assert_eq!(failed_index, 1);
311        assert_eq!(completed.len(), 1);
312        assert!(cleanup_error.is_none());
313        assert!(!root.join("second.out").exists());
314        let preparation = prepare_artifact_cache(&specs[1]).expect("prepare failed entry again");
315        let ArtifactCachePreparation::Build(transaction) = preparation else {
316            panic!("failed batch entry must not be published");
317        };
318        transaction.abort().expect("abort verification transaction");
319        fs::remove_dir_all(root).expect("remove artifact batch failure fixture");
320    }
321}