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
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    /// Create a run with one step at `position`, returning both ids.
132    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}