1use std::time::{Duration, Instant};
2
3use super::transaction::{
4 ArtifactBuildTransaction, ArtifactCacheError, ArtifactCacheOutcome, ArtifactCachePreparation,
5 ArtifactCacheSpec, prepare_artifact_cache,
6};
7
8#[derive(Clone, Debug, Eq, PartialEq)]
10pub struct ArtifactCacheBatchOutcome {
11 outcomes: Vec<ArtifactCacheOutcome>,
12 total: Duration,
13}
14
15#[derive(Debug)]
17pub enum ArtifactCacheBatchError<E> {
18 Cache {
20 failed_index: usize,
21 completed: Vec<ArtifactCacheOutcome>,
22 total: Duration,
23 source: Box<ArtifactCacheError>,
24 },
25 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 #[must_use]
38 pub fn outcomes(&self) -> &[ArtifactCacheOutcome] {
39 &self.outcomes
40 }
41
42 #[must_use]
44 pub fn into_outcomes(self) -> Vec<ArtifactCacheOutcome> {
45 self.outcomes
46 }
47
48 #[must_use]
50 pub const fn total(&self) -> Duration {
51 self.total
52 }
53}
54
55impl<E> ArtifactCacheBatchError<E> {
56 #[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 #[must_use]
66 pub fn completed(&self) -> &[ArtifactCacheOutcome] {
67 match self {
68 Self::Cache { completed, .. } | Self::Build { completed, .. } => completed,
69 }
70 }
71
72 #[must_use]
74 pub const fn total(&self) -> Duration {
75 match self {
76 Self::Cache { total, .. } | Self::Build { total, .. } => *total,
77 }
78 }
79
80 #[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
108pub 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}