Skip to main content

ironflow_store/memory/
signal_store.rs

1//! In-memory [`SignalStore`] implementation.
2//!
3//! Every method runs under the single write lock of the store, which gives
4//! the same atomicity as the PostgreSQL transactions.
5
6use std::cmp::Reverse;
7
8use chrono::{DateTime, Utc};
9use serde_json::Value;
10use uuid::Uuid;
11
12use crate::entities::{
13    NewSignal, Page, RunStatus, Signal, SignalFilter, SignalInsert, SignalStepResolution, Step,
14    StepKind, StepStatus,
15};
16use crate::error::StoreError;
17use crate::memory::InMemoryStore;
18use crate::signal_store::SignalStore;
19use crate::store::StoreFuture;
20
21/// Maximum number of signals returned by [`SignalStore::list_signals_for_key`].
22const SIGNALS_FOR_KEY_LIMIT: usize = 100;
23
24/// Whether `step` is a `signal` step still waiting for a delivery.
25fn is_waiting_signal_step(step: &Step) -> bool {
26    step.kind == StepKind::Signal && step.status.state == StepStatus::Running
27}
28
29/// Whether the `name`/`key` stored in a signal step's input match.
30fn step_waits_for(step: &Step, name: &str, key: &str) -> bool {
31    let Some(input) = step.input.as_ref() else {
32        return false;
33    };
34    input.get("name").and_then(Value::as_str) == Some(name)
35        && input.get("key").and_then(Value::as_str) == Some(key)
36}
37
38impl SignalStore for InMemoryStore {
39    fn insert_signal(&self, signal: NewSignal) -> StoreFuture<'_, SignalInsert> {
40        Box::pin(async move {
41            let mut state = self.state.write().await;
42
43            if let Some(ref idempotency_id) = signal.idempotency_id
44                && let Some(existing) = state
45                    .signal_idempotency
46                    .get(idempotency_id)
47                    .and_then(|id| state.signals.iter().find(|s| s.id == *id))
48            {
49                return Ok(SignalInsert::Duplicate(existing.clone()));
50            }
51
52            let stored = Signal {
53                id: Uuid::now_v7(),
54                name: signal.name,
55                key: signal.key,
56                payload: signal.payload,
57                idempotency_id: signal.idempotency_id,
58                received_at: Utc::now(),
59            };
60            if let Some(ref idempotency_id) = stored.idempotency_id {
61                state
62                    .signal_idempotency
63                    .insert(idempotency_id.clone(), stored.id);
64            }
65            state.signals.push(stored.clone());
66            Ok(SignalInsert::Created(stored))
67        })
68    }
69
70    fn list_signals(
71        &self,
72        filter: SignalFilter,
73        page: u32,
74        per_page: u32,
75    ) -> StoreFuture<'_, Page<Signal>> {
76        Box::pin(async move {
77            let state = self.state.read().await;
78            let mut items: Vec<Signal> = state
79                .signals
80                .iter()
81                .filter(|s| filter.name.as_ref().is_none_or(|name| &s.name == name))
82                .filter(|s| filter.key.as_ref().is_none_or(|key| &s.key == key))
83                .cloned()
84                .collect();
85            items.sort_by_key(|s| Reverse((s.received_at, s.id)));
86
87            let total = items.len() as u64;
88            let page = page.max(1);
89            let per_page = per_page.clamp(1, 100);
90            let offset = ((page - 1) * per_page) as usize;
91            let items = items
92                .into_iter()
93                .skip(offset)
94                .take(per_page as usize)
95                .collect();
96
97            Ok(Page {
98                items,
99                total,
100                page,
101                per_page,
102            })
103        })
104    }
105
106    fn list_signals_for_key(
107        &self,
108        name: &str,
109        key: &str,
110        since: DateTime<Utc>,
111    ) -> StoreFuture<'_, Vec<Signal>> {
112        let name = name.to_string();
113        let key = key.to_string();
114        Box::pin(async move {
115            let state = self.state.read().await;
116            let mut items: Vec<Signal> = state
117                .signals
118                .iter()
119                .filter(|s| s.name == name && s.key == key && s.received_at >= since)
120                .cloned()
121                .collect();
122            items.sort_by_key(|s| (s.received_at, s.id));
123            items.truncate(SIGNALS_FOR_KEY_LIMIT);
124            Ok(items)
125        })
126    }
127
128    fn list_signal_waiters(&self, name: &str, key: &str) -> StoreFuture<'_, Vec<Step>> {
129        let name = name.to_string();
130        let key = key.to_string();
131        Box::pin(async move {
132            let state = self.state.read().await;
133            let mut steps: Vec<Step> = state
134                .steps
135                .values()
136                .filter(|s| is_waiting_signal_step(s) && step_waits_for(s, &name, &key))
137                .filter(|s| {
138                    state.runs.get(&s.run_id).is_some_and(|run| {
139                        matches!(
140                            run.status.state,
141                            RunStatus::Sleeping | RunStatus::Running | RunStatus::Pending
142                        )
143                    })
144                })
145                .cloned()
146                .collect();
147            steps.sort_by_key(|s| (s.created_at, s.id));
148            Ok(steps)
149        })
150    }
151
152    fn resolve_signal_step(
153        &self,
154        step_id: Uuid,
155        output: Value,
156    ) -> StoreFuture<'_, SignalStepResolution> {
157        Box::pin(async move {
158            let mut state = self.state.write().await;
159            let now = Utc::now();
160
161            let step = state
162                .steps
163                .get_mut(&step_id)
164                .ok_or(StoreError::StepNotFound(step_id))?;
165            if !is_waiting_signal_step(step) {
166                return Ok(SignalStepResolution::NotWaiting {
167                    output: step.output.clone(),
168                });
169            }
170            step.status.state = StepStatus::Completed;
171            step.output = Some(output);
172            step.completed_at = Some(now);
173            step.duration_ms = step
174                .started_at
175                .map(|started| (now - started).num_milliseconds().max(0) as u64)
176                .unwrap_or(0);
177            step.updated_at = now;
178            let run_id = step.run_id;
179
180            let run = state
181                .runs
182                .get_mut(&run_id)
183                .ok_or(StoreError::RunNotFound(run_id))?;
184            let run_resumed = run.status.state == RunStatus::Sleeping;
185            if run_resumed {
186                run.status.state = RunStatus::Pending;
187                run.scheduled_at = None;
188                run.updated_at = now;
189            }
190
191            Ok(SignalStepResolution::Resolved {
192                run_id,
193                run_resumed,
194            })
195        })
196    }
197
198    fn suspend_run_on_signal(
199        &self,
200        run_id: Uuid,
201        step_id: Uuid,
202        deadline_at: DateTime<Utc>,
203    ) -> StoreFuture<'_, bool> {
204        Box::pin(async move {
205            let mut state = self.state.write().await;
206            let now = Utc::now();
207
208            let waiting = state
209                .steps
210                .get(&step_id)
211                .map(is_waiting_signal_step)
212                .ok_or(StoreError::StepNotFound(step_id))?;
213
214            let run = state
215                .runs
216                .get_mut(&run_id)
217                .ok_or(StoreError::RunNotFound(run_id))?;
218            if run.status.state != RunStatus::Running {
219                return Err(StoreError::InvalidTransition {
220                    from: run.status.state,
221                    to: RunStatus::Sleeping,
222                });
223            }
224            run.status.state = RunStatus::Sleeping;
225            run.worker_id = None;
226            run.lease_expires_at = None;
227            // A signal that resolved the step before the run could sleep left
228            // nothing to wait for: the next waker tick resumes it right away.
229            run.scheduled_at = Some(if waiting { deadline_at } else { now });
230            run.updated_at = now;
231
232            Ok(waiting)
233        })
234    }
235
236    fn purge_signals(&self, before: DateTime<Utc>) -> StoreFuture<'_, u64> {
237        Box::pin(async move {
238            let mut state = self.state.write().await;
239            let initial = state.signals.len();
240            state.signals.retain(|s| s.received_at >= before);
241            let removed = (initial - state.signals.len()) as u64;
242
243            let kept: Vec<Uuid> = state.signals.iter().map(|s| s.id).collect();
244            state
245                .signal_idempotency
246                .retain(|_, signal_id| kept.contains(signal_id));
247
248            Ok(removed)
249        })
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use chrono::TimeDelta;
256    use serde_json::json;
257
258    use super::*;
259    use crate::entities::{NewStep, StepUpdate, step_trace_id};
260    use crate::memory::tests::new_run_req;
261    use crate::store::RunStore;
262
263    fn new_signal(name: &str, key: &str, idempotency_id: Option<&str>) -> NewSignal {
264        NewSignal {
265            name: name.to_string(),
266            key: key.to_string(),
267            payload: json!({"status": "success"}),
268            idempotency_id: idempotency_id.map(str::to_string),
269        }
270    }
271
272    /// Create a `Running` run holding a `Running` signal step on `(name, key)`.
273    async fn waiting_step(store: &InMemoryStore, name: &str, key: &str) -> Step {
274        let run = store
275            .create_run(new_run_req("wait"))
276            .await
277            .unwrap()
278            .into_run();
279        store
280            .update_run_status(run.id, RunStatus::Running)
281            .await
282            .unwrap();
283        let step = store
284            .create_step(NewStep {
285                run_id: run.id,
286                trace_id: step_trace_id(run.id, "wait-ci", 0),
287                name: "wait-ci".to_string(),
288                kind: StepKind::Signal,
289                position: 0,
290                input: Some(json!({"name": name, "key": key, "schema": {}})),
291                is_error_handler: false,
292            })
293            .await
294            .unwrap();
295        store
296            .update_step(
297                step.id,
298                StepUpdate {
299                    status: Some(StepStatus::Running),
300                    started_at: Some(Utc::now()),
301                    ..StepUpdate::default()
302                },
303            )
304            .await
305            .unwrap();
306        store.get_step(step.id).await.unwrap().unwrap()
307    }
308
309    #[tokio::test]
310    async fn insert_signal_is_idempotent() {
311        let store = InMemoryStore::new();
312        let first = store
313            .insert_signal(new_signal("demo.done", "k1", Some("d-1")))
314            .await
315            .unwrap();
316        assert!(!first.is_duplicate());
317
318        let second = store
319            .insert_signal(new_signal("demo.done", "k2", Some("d-1")))
320            .await
321            .unwrap();
322        assert!(second.is_duplicate());
323        assert_eq!(second.signal(), first.signal());
324
325        let page = store
326            .list_signals(SignalFilter::default(), 1, 20)
327            .await
328            .unwrap();
329        assert_eq!(page.total, 1);
330    }
331
332    #[tokio::test]
333    async fn insert_signal_without_idempotency_id_always_stores() {
334        let store = InMemoryStore::new();
335        for _ in 0..2 {
336            let insert = store
337                .insert_signal(new_signal("demo.done", "k1", None))
338                .await
339                .unwrap();
340            assert!(!insert.is_duplicate());
341        }
342        let page = store
343            .list_signals(SignalFilter::default(), 1, 20)
344            .await
345            .unwrap();
346        assert_eq!(page.total, 2);
347    }
348
349    #[tokio::test]
350    async fn list_signals_filters_and_orders_newest_first() {
351        let store = InMemoryStore::new();
352        store
353            .insert_signal(new_signal("demo.done", "k1", None))
354            .await
355            .unwrap();
356        let newest = store
357            .insert_signal(new_signal("demo.done", "k2", None))
358            .await
359            .unwrap();
360        store
361            .insert_signal(new_signal("other", "k1", None))
362            .await
363            .unwrap();
364
365        let page = store
366            .list_signals(
367                SignalFilter {
368                    name: Some("demo.done".to_string()),
369                    key: None,
370                },
371                1,
372                20,
373            )
374            .await
375            .unwrap();
376        assert_eq!(page.total, 2);
377        assert_eq!(page.items[0].id, newest.signal().id);
378
379        let page = store
380            .list_signals(
381                SignalFilter {
382                    name: Some("demo.done".to_string()),
383                    key: Some("k1".to_string()),
384                },
385                1,
386                20,
387            )
388            .await
389            .unwrap();
390        assert_eq!(page.total, 1);
391        assert_eq!(page.items[0].key, "k1");
392    }
393
394    #[tokio::test]
395    async fn list_signals_for_key_respects_since_and_orders_oldest_first() {
396        let store = InMemoryStore::new();
397        let first = store
398            .insert_signal(new_signal("demo.done", "k1", None))
399            .await
400            .unwrap();
401        let second = store
402            .insert_signal(new_signal("demo.done", "k1", None))
403            .await
404            .unwrap();
405        store
406            .insert_signal(new_signal("demo.done", "k2", None))
407            .await
408            .unwrap();
409
410        let found = store
411            .list_signals_for_key("demo.done", "k1", first.signal().received_at)
412            .await
413            .unwrap();
414        let ids: Vec<Uuid> = found.iter().map(|s| s.id).collect();
415        assert_eq!(ids, vec![first.signal().id, second.signal().id]);
416
417        let later = store
418            .list_signals_for_key("demo.done", "k1", Utc::now() + TimeDelta::seconds(5))
419            .await
420            .unwrap();
421        assert!(later.is_empty());
422    }
423
424    #[tokio::test]
425    async fn list_signal_waiters_matches_name_and_key() {
426        let store = InMemoryStore::new();
427        let step = waiting_step(&store, "demo.done", "k1").await;
428        waiting_step(&store, "demo.done", "k2").await;
429
430        let waiters = store.list_signal_waiters("demo.done", "k1").await.unwrap();
431        assert_eq!(waiters.len(), 1);
432        assert_eq!(waiters[0].id, step.id);
433    }
434
435    #[tokio::test]
436    async fn list_signal_waiters_excludes_cancelled_runs() {
437        let store = InMemoryStore::new();
438        let step = waiting_step(&store, "demo.done", "k1").await;
439        store
440            .update_run_status(step.run_id, RunStatus::Cancelled)
441            .await
442            .unwrap();
443
444        let waiters = store.list_signal_waiters("demo.done", "k1").await.unwrap();
445        assert!(waiters.is_empty());
446    }
447
448    #[tokio::test]
449    async fn resolve_signal_step_resumes_sleeping_run() {
450        let store = InMemoryStore::new();
451        let step = waiting_step(&store, "demo.done", "k1").await;
452        let deadline = Utc::now() + TimeDelta::hours(1);
453        assert!(
454            store
455                .suspend_run_on_signal(step.run_id, step.id, deadline)
456                .await
457                .unwrap()
458        );
459
460        let resolution = store
461            .resolve_signal_step(step.id, json!({"timed_out": false}))
462            .await
463            .unwrap();
464        assert_eq!(
465            resolution,
466            SignalStepResolution::Resolved {
467                run_id: step.run_id,
468                run_resumed: true,
469            }
470        );
471
472        let run = store.get_run(step.run_id).await.unwrap().unwrap();
473        assert_eq!(run.status.state, RunStatus::Pending);
474        assert!(run.scheduled_at.is_none());
475        let step = store.get_step(step.id).await.unwrap().unwrap();
476        assert_eq!(step.status.state, StepStatus::Completed);
477        assert_eq!(step.output, Some(json!({"timed_out": false})));
478    }
479
480    #[tokio::test]
481    async fn resolve_signal_step_leaves_running_run_alone() {
482        let store = InMemoryStore::new();
483        let step = waiting_step(&store, "demo.done", "k1").await;
484
485        let resolution = store
486            .resolve_signal_step(step.id, json!({"timed_out": false}))
487            .await
488            .unwrap();
489        assert_eq!(
490            resolution,
491            SignalStepResolution::Resolved {
492                run_id: step.run_id,
493                run_resumed: false,
494            }
495        );
496        let run = store.get_run(step.run_id).await.unwrap().unwrap();
497        assert_eq!(run.status.state, RunStatus::Running);
498    }
499
500    #[tokio::test]
501    async fn resolve_signal_step_twice_returns_first_output() {
502        let store = InMemoryStore::new();
503        let step = waiting_step(&store, "demo.done", "k1").await;
504        store
505            .resolve_signal_step(step.id, json!({"first": true}))
506            .await
507            .unwrap();
508
509        let second = store
510            .resolve_signal_step(step.id, json!({"first": false}))
511            .await
512            .unwrap();
513        assert_eq!(
514            second,
515            SignalStepResolution::NotWaiting {
516                output: Some(json!({"first": true})),
517            }
518        );
519    }
520
521    #[tokio::test]
522    async fn resolve_signal_step_unknown_step_errors() {
523        let store = InMemoryStore::new();
524        let err = store
525            .resolve_signal_step(Uuid::now_v7(), json!({}))
526            .await
527            .unwrap_err();
528        assert!(matches!(err, StoreError::StepNotFound(_)));
529    }
530
531    #[tokio::test]
532    async fn suspend_run_on_signal_after_resolution_schedules_now() {
533        let store = InMemoryStore::new();
534        let step = waiting_step(&store, "demo.done", "k1").await;
535        store
536            .resolve_signal_step(step.id, json!({"timed_out": false}))
537            .await
538            .unwrap();
539
540        let deadline = Utc::now() + TimeDelta::hours(1);
541        let waiting = store
542            .suspend_run_on_signal(step.run_id, step.id, deadline)
543            .await
544            .unwrap();
545        assert!(!waiting);
546
547        let run = store.get_run(step.run_id).await.unwrap().unwrap();
548        assert_eq!(run.status.state, RunStatus::Sleeping);
549        assert!(run.scheduled_at.is_some_and(|at| at < deadline));
550    }
551
552    #[tokio::test]
553    async fn suspend_run_on_signal_rejects_non_running_run() {
554        let store = InMemoryStore::new();
555        let step = waiting_step(&store, "demo.done", "k1").await;
556        store
557            .update_run_status(step.run_id, RunStatus::Cancelled)
558            .await
559            .unwrap();
560
561        let err = store
562            .suspend_run_on_signal(step.run_id, step.id, Utc::now())
563            .await
564            .unwrap_err();
565        assert!(matches!(err, StoreError::InvalidTransition { .. }));
566    }
567
568    #[tokio::test]
569    async fn purge_signals_removes_old_signals_and_frees_idempotency_ids() {
570        let store = InMemoryStore::new();
571        store
572            .insert_signal(new_signal("demo.done", "k1", Some("d-1")))
573            .await
574            .unwrap();
575
576        let purged = store
577            .purge_signals(Utc::now() + TimeDelta::seconds(1))
578            .await
579            .unwrap();
580        assert_eq!(purged, 1);
581
582        let again = store
583            .insert_signal(new_signal("demo.done", "k1", Some("d-1")))
584            .await
585            .unwrap();
586        assert!(!again.is_duplicate());
587    }
588}