1use chrono::Utc;
4use uuid::Uuid;
5
6use crate::artifact_store::ArtifactStore;
7use crate::entities::{Artifact, ArtifactLookup, NewArtifact};
8use crate::error::StoreError;
9use crate::store::StoreFuture;
10
11use super::InMemoryStore;
12
13impl ArtifactStore for InMemoryStore {
14 fn create_artifact(&self, artifact: NewArtifact) -> StoreFuture<'_, Artifact> {
15 Box::pin(async move {
16 let mut state = self.state.write().await;
17
18 if !state.steps.contains_key(&artifact.step_id) {
19 return Err(StoreError::StepNotFound(artifact.step_id));
20 }
21 if state
22 .artifacts
23 .values()
24 .any(|a| a.step_id == artifact.step_id && a.name == artifact.name)
25 {
26 return Err(StoreError::DuplicateArtifact {
27 step_id: artifact.step_id,
28 name: artifact.name,
29 });
30 }
31
32 let now = Utc::now();
33 let stored = Artifact {
34 id: artifact.id,
35 run_id: artifact.run_id,
36 step_id: artifact.step_id,
37 name: artifact.name,
38 storage_key: artifact.storage_key,
39 content_type: artifact.content_type,
40 size_bytes: artifact.size_bytes,
41 sha256: artifact.sha256,
42 created_at: now,
43 updated_at: now,
44 };
45
46 state.artifacts.insert(stored.id, stored.clone());
47 Ok(stored)
48 })
49 }
50
51 fn get_artifact(&self, step_id: Uuid, name: &str) -> StoreFuture<'_, Option<Artifact>> {
52 let name = name.to_string();
53 Box::pin(async move {
54 let state = self.state.read().await;
55 Ok(state
56 .artifacts
57 .values()
58 .find(|a| a.step_id == step_id && a.name == name)
59 .cloned())
60 })
61 }
62
63 fn list_artifacts_for_run(&self, run_id: Uuid) -> StoreFuture<'_, Vec<Artifact>> {
64 Box::pin(async move {
65 let state = self.state.read().await;
66
67 let mut artifacts: Vec<Artifact> = state
68 .artifacts
69 .values()
70 .filter(|a| a.run_id == run_id)
71 .cloned()
72 .collect();
73
74 artifacts.sort_by(|a, b| {
75 let position = |artifact: &Artifact| {
76 state
77 .steps
78 .get(&artifact.step_id)
79 .map(|s| (s.attempt, s.position))
80 .unwrap_or((0, 0))
81 };
82 position(a)
83 .cmp(&position(b))
84 .then_with(|| a.name.cmp(&b.name))
85 });
86
87 Ok(artifacts)
88 })
89 }
90
91 fn find_artifact_for_input(&self, lookup: ArtifactLookup) -> StoreFuture<'_, Option<Artifact>> {
92 Box::pin(async move {
93 let state = self.state.read().await;
94
95 let producer = state
98 .steps
99 .values()
100 .filter(|s| {
101 s.run_id == lookup.run_id
102 && s.attempt == lookup.attempt
103 && s.name == lookup.step_name
104 && s.position < lookup.before_position
105 })
106 .max_by_key(|s| s.position);
107
108 let Some(producer) = producer else {
109 return Ok(None);
110 };
111
112 Ok(state
113 .artifacts
114 .values()
115 .find(|a| a.step_id == producer.id && a.name == lookup.name)
116 .cloned())
117 })
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use serde_json::json;
124
125 use crate::entities::{NewStep, StepKind, step_trace_id};
126 use crate::store::RunStore;
127
128 use super::super::tests::new_run_req;
129 use super::*;
130
131 async fn run_with_step(store: &InMemoryStore, step_name: &str, position: u32) -> (Uuid, Uuid) {
133 let run = store
134 .create_run(new_run_req("artifacts"))
135 .await
136 .expect("create run")
137 .into_run();
138 let step = store
139 .create_step(NewStep {
140 run_id: run.id,
141 trace_id: step_trace_id(run.id, step_name, position),
142 name: step_name.to_string(),
143 kind: StepKind::Shell,
144 position,
145 input: Some(json!({})),
146 is_error_handler: false,
147 })
148 .await
149 .expect("create step");
150
151 (run.id, step.id)
152 }
153
154 fn new_artifact(run_id: Uuid, step_id: Uuid, name: &str) -> NewArtifact {
155 let id = Uuid::now_v7();
156 NewArtifact {
157 id,
158 run_id,
159 step_id,
160 name: name.to_string(),
161 storage_key: format!("artifacts/{run_id}/{step_id}/{id}"),
162 content_type: "text/plain".to_string(),
163 size_bytes: 3,
164 sha256: "0".repeat(64),
165 }
166 }
167
168 #[tokio::test]
169 async fn create_then_get_roundtrips() {
170 let store = InMemoryStore::new();
171 let (run_id, step_id) = run_with_step(&store, "build", 0).await;
172
173 let created = store
174 .create_artifact(new_artifact(run_id, step_id, "report.html"))
175 .await
176 .expect("create");
177
178 let fetched = store
179 .get_artifact(step_id, "report.html")
180 .await
181 .expect("get")
182 .expect("present");
183
184 assert_eq!(fetched.id, created.id);
185 assert_eq!(fetched.name, "report.html");
186 assert_eq!(fetched.size_bytes, 3);
187 }
188
189 #[tokio::test]
190 async fn get_on_an_unknown_name_returns_none() {
191 let store = InMemoryStore::new();
192 let (_run_id, step_id) = run_with_step(&store, "build", 0).await;
193
194 assert!(
195 store
196 .get_artifact(step_id, "nope")
197 .await
198 .expect("get")
199 .is_none()
200 );
201 }
202
203 #[tokio::test]
204 async fn create_on_an_unknown_step_is_rejected() {
205 let store = InMemoryStore::new();
206
207 let err = store
208 .create_artifact(new_artifact(Uuid::now_v7(), Uuid::now_v7(), "a.txt"))
209 .await
210 .expect_err("step does not exist");
211
212 assert!(matches!(err, StoreError::StepNotFound(_)));
213 }
214
215 #[tokio::test]
216 async fn the_same_name_twice_on_a_step_is_rejected() {
217 let store = InMemoryStore::new();
218 let (run_id, step_id) = run_with_step(&store, "build", 0).await;
219
220 store
221 .create_artifact(new_artifact(run_id, step_id, "a.txt"))
222 .await
223 .expect("first");
224
225 let err = store
226 .create_artifact(new_artifact(run_id, step_id, "a.txt"))
227 .await
228 .expect_err("duplicate");
229
230 assert!(matches!(err, StoreError::DuplicateArtifact { .. }));
231 }
232
233 #[tokio::test]
234 async fn the_same_name_on_two_steps_is_allowed() {
235 let store = InMemoryStore::new();
236 let (run_id, first) = run_with_step(&store, "build", 0).await;
237 let second = store
238 .create_step(NewStep {
239 run_id,
240 trace_id: step_trace_id(run_id, "test", 1),
241 name: "test".to_string(),
242 kind: StepKind::Shell,
243 position: 1,
244 input: None,
245 is_error_handler: false,
246 })
247 .await
248 .expect("create step")
249 .id;
250
251 store
252 .create_artifact(new_artifact(run_id, first, "a.txt"))
253 .await
254 .expect("first");
255 store
256 .create_artifact(new_artifact(run_id, second, "a.txt"))
257 .await
258 .expect("second");
259
260 assert_eq!(
261 store
262 .list_artifacts_for_run(run_id)
263 .await
264 .expect("list")
265 .len(),
266 2
267 );
268 }
269
270 #[tokio::test]
271 async fn list_is_scoped_to_the_run() {
272 let store = InMemoryStore::new();
273 let (run_a, step_a) = run_with_step(&store, "build", 0).await;
274 let (run_b, step_b) = run_with_step(&store, "build", 0).await;
275
276 store
277 .create_artifact(new_artifact(run_a, step_a, "a.txt"))
278 .await
279 .expect("a");
280 store
281 .create_artifact(new_artifact(run_b, step_b, "b.txt"))
282 .await
283 .expect("b");
284
285 let listed = store.list_artifacts_for_run(run_a).await.expect("list");
286 assert_eq!(listed.len(), 1);
287 assert_eq!(listed[0].name, "a.txt");
288 }
289
290 #[tokio::test]
291 async fn list_on_a_run_without_artifacts_is_empty() {
292 let store = InMemoryStore::new();
293 let (run_id, _) = run_with_step(&store, "build", 0).await;
294
295 assert!(
296 store
297 .list_artifacts_for_run(run_id)
298 .await
299 .expect("list")
300 .is_empty()
301 );
302 }
303
304 #[tokio::test]
305 async fn input_resolves_from_an_earlier_step() {
306 let store = InMemoryStore::new();
307 let (run_id, step_id) = run_with_step(&store, "build", 0).await;
308 store
309 .create_artifact(new_artifact(run_id, step_id, "report.html"))
310 .await
311 .expect("create");
312
313 let found = store
314 .find_artifact_for_input(ArtifactLookup {
315 run_id,
316 attempt: 1,
317 before_position: 1,
318 step_name: "build".to_string(),
319 name: "report.html".to_string(),
320 })
321 .await
322 .expect("lookup");
323
324 assert_eq!(found.expect("present").step_id, step_id);
325 }
326
327 #[tokio::test]
328 async fn input_ignores_a_step_at_or_after_the_consumer() {
329 let store = InMemoryStore::new();
330 let (run_id, step_id) = run_with_step(&store, "build", 2).await;
331 store
332 .create_artifact(new_artifact(run_id, step_id, "report.html"))
333 .await
334 .expect("create");
335
336 let found = store
337 .find_artifact_for_input(ArtifactLookup {
338 run_id,
339 attempt: 1,
340 before_position: 2,
341 step_name: "build".to_string(),
342 name: "report.html".to_string(),
343 })
344 .await
345 .expect("lookup");
346
347 assert!(found.is_none());
348 }
349
350 #[tokio::test]
351 async fn input_picks_the_closest_producer_when_names_repeat() {
352 let store = InMemoryStore::new();
353 let (run_id, first) = run_with_step(&store, "build", 0).await;
354 let second = store
355 .create_step(NewStep {
356 run_id,
357 trace_id: step_trace_id(run_id, "build", 1),
358 name: "build".to_string(),
359 kind: StepKind::Shell,
360 position: 1,
361 input: None,
362 is_error_handler: false,
363 })
364 .await
365 .expect("create step")
366 .id;
367
368 store
369 .create_artifact(new_artifact(run_id, first, "report.html"))
370 .await
371 .expect("first");
372 store
373 .create_artifact(new_artifact(run_id, second, "report.html"))
374 .await
375 .expect("second");
376
377 let found = store
378 .find_artifact_for_input(ArtifactLookup {
379 run_id,
380 attempt: 1,
381 before_position: 2,
382 step_name: "build".to_string(),
383 name: "report.html".to_string(),
384 })
385 .await
386 .expect("lookup")
387 .expect("present");
388
389 assert_eq!(found.step_id, second);
390 }
391
392 #[tokio::test]
393 async fn input_does_not_cross_attempts() {
394 let store = InMemoryStore::new();
395 let (run_id, step_id) = run_with_step(&store, "build", 0).await;
396 store
397 .create_artifact(new_artifact(run_id, step_id, "report.html"))
398 .await
399 .expect("create");
400
401 let found = store
402 .find_artifact_for_input(ArtifactLookup {
403 run_id,
404 attempt: 2,
405 before_position: 1,
406 step_name: "build".to_string(),
407 name: "report.html".to_string(),
408 })
409 .await
410 .expect("lookup");
411
412 assert!(found.is_none());
413 }
414
415 #[tokio::test]
416 async fn input_does_not_cross_runs() {
417 let store = InMemoryStore::new();
418 let (run_a, step_a) = run_with_step(&store, "build", 0).await;
419 let (run_b, _) = run_with_step(&store, "build", 0).await;
420 store
421 .create_artifact(new_artifact(run_a, step_a, "report.html"))
422 .await
423 .expect("create");
424
425 let found = store
426 .find_artifact_for_input(ArtifactLookup {
427 run_id: run_b,
428 attempt: 1,
429 before_position: 1,
430 step_name: "build".to_string(),
431 name: "report.html".to_string(),
432 })
433 .await
434 .expect("lookup");
435
436 assert!(found.is_none());
437 }
438}