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 fn find_artifact_by_sha256(&self, sha256: &str) -> StoreFuture<'_, Option<Artifact>> {
121 let sha256 = sha256.to_string();
122 Box::pin(async move {
123 let state = self.state.read().await;
124 Ok(state
125 .artifacts
126 .values()
127 .find(|a| a.sha256 == sha256)
128 .cloned())
129 })
130 }
131
132 fn count_artifacts_by_storage_key(&self, storage_key: &str) -> StoreFuture<'_, u64> {
133 let storage_key = storage_key.to_string();
134 Box::pin(async move {
135 let state = self.state.read().await;
136 let count = state
137 .artifacts
138 .values()
139 .filter(|a| a.storage_key == storage_key)
140 .count() as u64;
141 Ok(count)
142 })
143 }
144
145 fn list_all_storage_keys(&self) -> StoreFuture<'_, Vec<String>> {
146 Box::pin(async move {
147 let state = self.state.read().await;
148 let mut keys: Vec<String> = state
149 .artifacts
150 .values()
151 .map(|a| a.storage_key.clone())
152 .collect();
153 keys.sort();
154 keys.dedup();
155 Ok(keys)
156 })
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use serde_json::json;
163
164 use crate::entities::{NewStep, StepKind, step_trace_id};
165 use crate::store::RunStore;
166
167 use super::super::tests::new_run_req;
168 use super::*;
169
170 async fn run_with_step(store: &InMemoryStore, step_name: &str, position: u32) -> (Uuid, Uuid) {
172 let run = store
173 .create_run(new_run_req("artifacts"))
174 .await
175 .expect("create run")
176 .into_run();
177 let step = store
178 .create_step(NewStep {
179 run_id: run.id,
180 trace_id: step_trace_id(run.id, step_name, position),
181 name: step_name.to_string(),
182 kind: StepKind::Shell,
183 position,
184 input: Some(json!({})),
185 is_error_handler: false,
186 })
187 .await
188 .expect("create step");
189
190 (run.id, step.id)
191 }
192
193 fn new_artifact(run_id: Uuid, step_id: Uuid, name: &str) -> NewArtifact {
194 let id = Uuid::now_v7();
195 NewArtifact {
196 id,
197 run_id,
198 step_id,
199 name: name.to_string(),
200 storage_key: format!("artifacts/{run_id}/{step_id}/{id}"),
201 content_type: "text/plain".to_string(),
202 size_bytes: 3,
203 sha256: "0".repeat(64),
204 }
205 }
206
207 #[tokio::test]
208 async fn create_then_get_roundtrips() {
209 let store = InMemoryStore::new();
210 let (run_id, step_id) = run_with_step(&store, "build", 0).await;
211
212 let created = store
213 .create_artifact(new_artifact(run_id, step_id, "report.html"))
214 .await
215 .expect("create");
216
217 let fetched = store
218 .get_artifact(step_id, "report.html")
219 .await
220 .expect("get")
221 .expect("present");
222
223 assert_eq!(fetched.id, created.id);
224 assert_eq!(fetched.name, "report.html");
225 assert_eq!(fetched.size_bytes, 3);
226 }
227
228 #[tokio::test]
229 async fn get_on_an_unknown_name_returns_none() {
230 let store = InMemoryStore::new();
231 let (_run_id, step_id) = run_with_step(&store, "build", 0).await;
232
233 assert!(
234 store
235 .get_artifact(step_id, "nope")
236 .await
237 .expect("get")
238 .is_none()
239 );
240 }
241
242 #[tokio::test]
243 async fn create_on_an_unknown_step_is_rejected() {
244 let store = InMemoryStore::new();
245
246 let err = store
247 .create_artifact(new_artifact(Uuid::now_v7(), Uuid::now_v7(), "a.txt"))
248 .await
249 .expect_err("step does not exist");
250
251 assert!(matches!(err, StoreError::StepNotFound(_)));
252 }
253
254 #[tokio::test]
255 async fn the_same_name_twice_on_a_step_is_rejected() {
256 let store = InMemoryStore::new();
257 let (run_id, step_id) = run_with_step(&store, "build", 0).await;
258
259 store
260 .create_artifact(new_artifact(run_id, step_id, "a.txt"))
261 .await
262 .expect("first");
263
264 let err = store
265 .create_artifact(new_artifact(run_id, step_id, "a.txt"))
266 .await
267 .expect_err("duplicate");
268
269 assert!(matches!(err, StoreError::DuplicateArtifact { .. }));
270 }
271
272 #[tokio::test]
273 async fn the_same_name_on_two_steps_is_allowed() {
274 let store = InMemoryStore::new();
275 let (run_id, first) = run_with_step(&store, "build", 0).await;
276 let second = store
277 .create_step(NewStep {
278 run_id,
279 trace_id: step_trace_id(run_id, "test", 1),
280 name: "test".to_string(),
281 kind: StepKind::Shell,
282 position: 1,
283 input: None,
284 is_error_handler: false,
285 })
286 .await
287 .expect("create step")
288 .id;
289
290 store
291 .create_artifact(new_artifact(run_id, first, "a.txt"))
292 .await
293 .expect("first");
294 store
295 .create_artifact(new_artifact(run_id, second, "a.txt"))
296 .await
297 .expect("second");
298
299 assert_eq!(
300 store
301 .list_artifacts_for_run(run_id)
302 .await
303 .expect("list")
304 .len(),
305 2
306 );
307 }
308
309 #[tokio::test]
310 async fn list_is_scoped_to_the_run() {
311 let store = InMemoryStore::new();
312 let (run_a, step_a) = run_with_step(&store, "build", 0).await;
313 let (run_b, step_b) = run_with_step(&store, "build", 0).await;
314
315 store
316 .create_artifact(new_artifact(run_a, step_a, "a.txt"))
317 .await
318 .expect("a");
319 store
320 .create_artifact(new_artifact(run_b, step_b, "b.txt"))
321 .await
322 .expect("b");
323
324 let listed = store.list_artifacts_for_run(run_a).await.expect("list");
325 assert_eq!(listed.len(), 1);
326 assert_eq!(listed[0].name, "a.txt");
327 }
328
329 #[tokio::test]
330 async fn list_on_a_run_without_artifacts_is_empty() {
331 let store = InMemoryStore::new();
332 let (run_id, _) = run_with_step(&store, "build", 0).await;
333
334 assert!(
335 store
336 .list_artifacts_for_run(run_id)
337 .await
338 .expect("list")
339 .is_empty()
340 );
341 }
342
343 #[tokio::test]
344 async fn input_resolves_from_an_earlier_step() {
345 let store = InMemoryStore::new();
346 let (run_id, step_id) = run_with_step(&store, "build", 0).await;
347 store
348 .create_artifact(new_artifact(run_id, step_id, "report.html"))
349 .await
350 .expect("create");
351
352 let found = store
353 .find_artifact_for_input(ArtifactLookup {
354 run_id,
355 attempt: 1,
356 before_position: 1,
357 step_name: "build".to_string(),
358 name: "report.html".to_string(),
359 })
360 .await
361 .expect("lookup");
362
363 assert_eq!(found.expect("present").step_id, step_id);
364 }
365
366 #[tokio::test]
367 async fn input_ignores_a_step_at_or_after_the_consumer() {
368 let store = InMemoryStore::new();
369 let (run_id, step_id) = run_with_step(&store, "build", 2).await;
370 store
371 .create_artifact(new_artifact(run_id, step_id, "report.html"))
372 .await
373 .expect("create");
374
375 let found = store
376 .find_artifact_for_input(ArtifactLookup {
377 run_id,
378 attempt: 1,
379 before_position: 2,
380 step_name: "build".to_string(),
381 name: "report.html".to_string(),
382 })
383 .await
384 .expect("lookup");
385
386 assert!(found.is_none());
387 }
388
389 #[tokio::test]
390 async fn input_picks_the_closest_producer_when_names_repeat() {
391 let store = InMemoryStore::new();
392 let (run_id, first) = run_with_step(&store, "build", 0).await;
393 let second = store
394 .create_step(NewStep {
395 run_id,
396 trace_id: step_trace_id(run_id, "build", 1),
397 name: "build".to_string(),
398 kind: StepKind::Shell,
399 position: 1,
400 input: None,
401 is_error_handler: false,
402 })
403 .await
404 .expect("create step")
405 .id;
406
407 store
408 .create_artifact(new_artifact(run_id, first, "report.html"))
409 .await
410 .expect("first");
411 store
412 .create_artifact(new_artifact(run_id, second, "report.html"))
413 .await
414 .expect("second");
415
416 let found = store
417 .find_artifact_for_input(ArtifactLookup {
418 run_id,
419 attempt: 1,
420 before_position: 2,
421 step_name: "build".to_string(),
422 name: "report.html".to_string(),
423 })
424 .await
425 .expect("lookup")
426 .expect("present");
427
428 assert_eq!(found.step_id, second);
429 }
430
431 #[tokio::test]
432 async fn input_does_not_cross_attempts() {
433 let store = InMemoryStore::new();
434 let (run_id, step_id) = run_with_step(&store, "build", 0).await;
435 store
436 .create_artifact(new_artifact(run_id, step_id, "report.html"))
437 .await
438 .expect("create");
439
440 let found = store
441 .find_artifact_for_input(ArtifactLookup {
442 run_id,
443 attempt: 2,
444 before_position: 1,
445 step_name: "build".to_string(),
446 name: "report.html".to_string(),
447 })
448 .await
449 .expect("lookup");
450
451 assert!(found.is_none());
452 }
453
454 #[tokio::test]
455 async fn find_artifact_by_sha256_returns_existing_match() {
456 let store = InMemoryStore::new();
457 let (run_id, step_id) = run_with_step(&store, "build", 0).await;
458
459 let mut artifact = new_artifact(run_id, step_id, "report.html");
460 artifact.sha256 = "abc123".repeat(10);
461 let created = store.create_artifact(artifact).await.expect("create");
462
463 let found = store
464 .find_artifact_by_sha256(&created.sha256)
465 .await
466 .expect("lookup")
467 .expect("present");
468
469 assert_eq!(found.id, created.id);
470 assert_eq!(found.sha256, created.sha256);
471 }
472
473 #[tokio::test]
474 async fn find_artifact_by_sha256_returns_none_when_absent() {
475 let store = InMemoryStore::new();
476
477 let found = store
478 .find_artifact_by_sha256("nonexistent_hash")
479 .await
480 .expect("lookup");
481
482 assert!(found.is_none());
483 }
484
485 #[tokio::test]
486 async fn count_artifacts_by_storage_key_counts_shared_keys() {
487 let store = InMemoryStore::new();
488 let (run_id, step_a) = run_with_step(&store, "build", 0).await;
489 let step_b = store
490 .create_step(NewStep {
491 run_id,
492 trace_id: step_trace_id(run_id, "test", 1),
493 name: "test".to_string(),
494 kind: StepKind::Shell,
495 position: 1,
496 input: None,
497 is_error_handler: false,
498 })
499 .await
500 .expect("create step")
501 .id;
502
503 let shared_key = "artifacts/shared/blob/id".to_string();
504
505 let mut a1 = new_artifact(run_id, step_a, "report.html");
506 a1.storage_key = shared_key.clone();
507 store.create_artifact(a1).await.expect("create a1");
508
509 let mut a2 = new_artifact(run_id, step_b, "report.html");
510 a2.storage_key = shared_key.clone();
511 store.create_artifact(a2).await.expect("create a2");
512
513 let count = store
514 .count_artifacts_by_storage_key(&shared_key)
515 .await
516 .expect("count");
517 assert_eq!(count, 2);
518
519 let count_zero = store
520 .count_artifacts_by_storage_key("nonexistent/key")
521 .await
522 .expect("count");
523 assert_eq!(count_zero, 0);
524 }
525
526 #[tokio::test]
527 async fn list_all_storage_keys_returns_distinct_keys() {
528 let store = InMemoryStore::new();
529 let (run_id, step_a) = run_with_step(&store, "build", 0).await;
530 let step_b = store
531 .create_step(NewStep {
532 run_id,
533 trace_id: step_trace_id(run_id, "test", 1),
534 name: "test".to_string(),
535 kind: StepKind::Shell,
536 position: 1,
537 input: None,
538 is_error_handler: false,
539 })
540 .await
541 .expect("create step")
542 .id;
543
544 let shared_key = "artifacts/shared/key".to_string();
545 let unique_key = "artifacts/unique/key".to_string();
546
547 let mut a1 = new_artifact(run_id, step_a, "a.txt");
548 a1.storage_key = shared_key.clone();
549 store.create_artifact(a1).await.expect("a1");
550
551 let mut a2 = new_artifact(run_id, step_b, "b.txt");
552 a2.storage_key = shared_key.clone();
553 store.create_artifact(a2).await.expect("a2");
554
555 let (run_id2, step_c) = run_with_step(&store, "deploy", 0).await;
556 let mut a3 = new_artifact(run_id2, step_c, "c.txt");
557 a3.storage_key = unique_key.clone();
558 store.create_artifact(a3).await.expect("a3");
559
560 let keys = store.list_all_storage_keys().await.expect("list");
561 assert_eq!(keys.len(), 2);
562 assert!(keys.contains(&shared_key));
563 assert!(keys.contains(&unique_key));
564 }
565
566 #[tokio::test]
567 async fn input_does_not_cross_runs() {
568 let store = InMemoryStore::new();
569 let (run_a, step_a) = run_with_step(&store, "build", 0).await;
570 let (run_b, _) = run_with_step(&store, "build", 0).await;
571 store
572 .create_artifact(new_artifact(run_a, step_a, "report.html"))
573 .await
574 .expect("create");
575
576 let found = store
577 .find_artifact_for_input(ArtifactLookup {
578 run_id: run_b,
579 attempt: 1,
580 before_position: 1,
581 step_name: "build".to_string(),
582 name: "report.html".to_string(),
583 })
584 .await
585 .expect("lookup");
586
587 assert!(found.is_none());
588 }
589}