Skip to main content

ironflow_store/memory/
artifact_store.rs

1//! [`ArtifactStore`] implementation for [`InMemoryStore`].
2
3use 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            // Candidate steps: same run and attempt, matching name, positioned
96            // strictly before the consumer. The closest one wins.
97            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    /// Create a run with one step at `position`, returning both ids.
171    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}