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
142                                | RunStatus::Running
143                                | RunStatus::Pending
144                                | RunStatus::Paused
145                        )
146                    })
147                })
148                .cloned()
149                .collect();
150            steps.sort_by_key(|s| (s.created_at, s.id));
151            Ok(steps)
152        })
153    }
154
155    fn resolve_signal_step(
156        &self,
157        step_id: Uuid,
158        output: Value,
159    ) -> StoreFuture<'_, SignalStepResolution> {
160        Box::pin(async move {
161            let mut state = self.state.write().await;
162            let now = Utc::now();
163
164            let step = state
165                .steps
166                .get_mut(&step_id)
167                .ok_or(StoreError::StepNotFound(step_id))?;
168            if !is_waiting_signal_step(step) {
169                return Ok(SignalStepResolution::NotWaiting {
170                    output: step.output.clone(),
171                });
172            }
173            step.status.state = StepStatus::Completed;
174            step.output = Some(output);
175            step.completed_at = Some(now);
176            step.duration_ms = step
177                .started_at
178                .map(|started| (now - started).num_milliseconds().max(0) as u64)
179                .unwrap_or(0);
180            step.updated_at = now;
181            let run_id = step.run_id;
182
183            let run = state
184                .runs
185                .get_mut(&run_id)
186                .ok_or(StoreError::RunNotFound(run_id))?;
187            let run_resumed = run.status.state == RunStatus::Sleeping;
188            if run_resumed {
189                run.status.state = RunStatus::Pending;
190                run.scheduled_at = None;
191                run.capacity_wait_kind = None;
192                run.updated_at = now;
193            } else if run.status.state == RunStatus::Paused
194                && run.resume_status == Some(RunStatus::Sleeping)
195            {
196                // The signal ended the wait: the resume requeues the run
197                // instead of putting it back to sleep.
198                run.resume_status = Some(RunStatus::Pending);
199                run.scheduled_at = None;
200                run.capacity_wait_kind = None;
201                run.updated_at = now;
202            }
203
204            Ok(SignalStepResolution::Resolved {
205                run_id,
206                run_resumed,
207            })
208        })
209    }
210
211    fn suspend_run_on_signal(
212        &self,
213        run_id: Uuid,
214        step_id: Uuid,
215        deadline_at: DateTime<Utc>,
216    ) -> StoreFuture<'_, bool> {
217        Box::pin(async move {
218            let mut state = self.state.write().await;
219            let now = Utc::now();
220
221            let waiting = state
222                .steps
223                .get(&step_id)
224                .map(is_waiting_signal_step)
225                .ok_or(StoreError::StepNotFound(step_id))?;
226
227            let run = state
228                .runs
229                .get_mut(&run_id)
230                .ok_or(StoreError::RunNotFound(run_id))?;
231            if run.status.state != RunStatus::Running {
232                return Err(StoreError::InvalidTransition {
233                    from: run.status.state,
234                    to: RunStatus::Sleeping,
235                });
236            }
237            run.status.state = RunStatus::Sleeping;
238            run.worker_id = None;
239            run.capacity_wait_kind = None;
240            run.lease_expires_at = None;
241            // A signal that resolved the step before the run could sleep left
242            // nothing to wait for: the next waker tick resumes it right away.
243            run.scheduled_at = Some(if waiting { deadline_at } else { now });
244            run.updated_at = now;
245
246            Ok(waiting)
247        })
248    }
249
250    fn purge_signals(&self, before: DateTime<Utc>) -> StoreFuture<'_, u64> {
251        Box::pin(async move {
252            let mut state = self.state.write().await;
253            let initial = state.signals.len();
254            state.signals.retain(|s| s.received_at >= before);
255            let removed = (initial - state.signals.len()) as u64;
256
257            let kept: Vec<Uuid> = state.signals.iter().map(|s| s.id).collect();
258            state
259                .signal_idempotency
260                .retain(|_, signal_id| kept.contains(signal_id));
261
262            Ok(removed)
263        })
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use chrono::TimeDelta;
270    use serde_json::json;
271
272    use super::*;
273    use crate::entities::{NewStep, StepUpdate, step_trace_id};
274    use crate::memory::tests::new_run_req;
275    use crate::store::RunStore;
276
277    fn new_signal(name: &str, key: &str, idempotency_id: Option<&str>) -> NewSignal {
278        NewSignal {
279            name: name.to_string(),
280            key: key.to_string(),
281            payload: json!({"status": "success"}),
282            idempotency_id: idempotency_id.map(str::to_string),
283        }
284    }
285
286    /// Create a `Running` run holding a `Running` signal step on `(name, key)`.
287    async fn waiting_step(store: &InMemoryStore, name: &str, key: &str) -> Step {
288        let run = store
289            .create_run(new_run_req("wait"))
290            .await
291            .unwrap()
292            .into_run();
293        store
294            .update_run_status(run.id, RunStatus::Running)
295            .await
296            .unwrap();
297        let step = store
298            .create_step(NewStep {
299                run_id: run.id,
300                trace_id: step_trace_id(run.id, "wait-ci", 0),
301                name: "wait-ci".to_string(),
302                kind: StepKind::Signal,
303                position: 0,
304                input: Some(json!({"name": name, "key": key, "schema": {}})),
305                is_error_handler: false,
306            })
307            .await
308            .unwrap();
309        store
310            .update_step(
311                step.id,
312                StepUpdate {
313                    status: Some(StepStatus::Running),
314                    started_at: Some(Utc::now()),
315                    ..StepUpdate::default()
316                },
317            )
318            .await
319            .unwrap();
320        store.get_step(step.id).await.unwrap().unwrap()
321    }
322
323    #[tokio::test]
324    async fn insert_signal_is_idempotent() {
325        let store = InMemoryStore::new();
326        let first = store
327            .insert_signal(new_signal("demo.done", "k1", Some("d-1")))
328            .await
329            .unwrap();
330        assert!(!first.is_duplicate());
331
332        let second = store
333            .insert_signal(new_signal("demo.done", "k2", Some("d-1")))
334            .await
335            .unwrap();
336        assert!(second.is_duplicate());
337        assert_eq!(second.signal(), first.signal());
338
339        let page = store
340            .list_signals(SignalFilter::default(), 1, 20)
341            .await
342            .unwrap();
343        assert_eq!(page.total, 1);
344    }
345
346    #[tokio::test]
347    async fn insert_signal_without_idempotency_id_always_stores() {
348        let store = InMemoryStore::new();
349        for _ in 0..2 {
350            let insert = store
351                .insert_signal(new_signal("demo.done", "k1", None))
352                .await
353                .unwrap();
354            assert!(!insert.is_duplicate());
355        }
356        let page = store
357            .list_signals(SignalFilter::default(), 1, 20)
358            .await
359            .unwrap();
360        assert_eq!(page.total, 2);
361    }
362
363    #[tokio::test]
364    async fn list_signals_filters_and_orders_newest_first() {
365        let store = InMemoryStore::new();
366        store
367            .insert_signal(new_signal("demo.done", "k1", None))
368            .await
369            .unwrap();
370        let newest = store
371            .insert_signal(new_signal("demo.done", "k2", None))
372            .await
373            .unwrap();
374        store
375            .insert_signal(new_signal("other", "k1", None))
376            .await
377            .unwrap();
378
379        let page = store
380            .list_signals(
381                SignalFilter {
382                    name: Some("demo.done".to_string()),
383                    key: None,
384                },
385                1,
386                20,
387            )
388            .await
389            .unwrap();
390        assert_eq!(page.total, 2);
391        assert_eq!(page.items[0].id, newest.signal().id);
392
393        let page = store
394            .list_signals(
395                SignalFilter {
396                    name: Some("demo.done".to_string()),
397                    key: Some("k1".to_string()),
398                },
399                1,
400                20,
401            )
402            .await
403            .unwrap();
404        assert_eq!(page.total, 1);
405        assert_eq!(page.items[0].key, "k1");
406    }
407
408    #[tokio::test]
409    async fn list_signals_for_key_respects_since_and_orders_oldest_first() {
410        let store = InMemoryStore::new();
411        let first = store
412            .insert_signal(new_signal("demo.done", "k1", None))
413            .await
414            .unwrap();
415        let second = store
416            .insert_signal(new_signal("demo.done", "k1", None))
417            .await
418            .unwrap();
419        store
420            .insert_signal(new_signal("demo.done", "k2", None))
421            .await
422            .unwrap();
423
424        let found = store
425            .list_signals_for_key("demo.done", "k1", first.signal().received_at)
426            .await
427            .unwrap();
428        let ids: Vec<Uuid> = found.iter().map(|s| s.id).collect();
429        assert_eq!(ids, vec![first.signal().id, second.signal().id]);
430
431        let later = store
432            .list_signals_for_key("demo.done", "k1", Utc::now() + TimeDelta::seconds(5))
433            .await
434            .unwrap();
435        assert!(later.is_empty());
436    }
437
438    #[tokio::test]
439    async fn list_signal_waiters_matches_name_and_key() {
440        let store = InMemoryStore::new();
441        let step = waiting_step(&store, "demo.done", "k1").await;
442        waiting_step(&store, "demo.done", "k2").await;
443
444        let waiters = store.list_signal_waiters("demo.done", "k1").await.unwrap();
445        assert_eq!(waiters.len(), 1);
446        assert_eq!(waiters[0].id, step.id);
447    }
448
449    #[tokio::test]
450    async fn list_signal_waiters_excludes_cancelled_runs() {
451        let store = InMemoryStore::new();
452        let step = waiting_step(&store, "demo.done", "k1").await;
453        store
454            .update_run_status(step.run_id, RunStatus::Cancelled)
455            .await
456            .unwrap();
457
458        let waiters = store.list_signal_waiters("demo.done", "k1").await.unwrap();
459        assert!(waiters.is_empty());
460    }
461
462    #[tokio::test]
463    async fn resolve_signal_step_resumes_sleeping_run() {
464        let store = InMemoryStore::new();
465        let step = waiting_step(&store, "demo.done", "k1").await;
466        let deadline = Utc::now() + TimeDelta::hours(1);
467        assert!(
468            store
469                .suspend_run_on_signal(step.run_id, step.id, deadline)
470                .await
471                .unwrap()
472        );
473
474        let resolution = store
475            .resolve_signal_step(step.id, json!({"timed_out": false}))
476            .await
477            .unwrap();
478        assert_eq!(
479            resolution,
480            SignalStepResolution::Resolved {
481                run_id: step.run_id,
482                run_resumed: true,
483            }
484        );
485
486        let run = store.get_run(step.run_id).await.unwrap().unwrap();
487        assert_eq!(run.status.state, RunStatus::Pending);
488        assert!(run.scheduled_at.is_none());
489        let step = store.get_step(step.id).await.unwrap().unwrap();
490        assert_eq!(step.status.state, StepStatus::Completed);
491        assert_eq!(step.output, Some(json!({"timed_out": false})));
492    }
493
494    #[tokio::test]
495    async fn resolve_signal_step_leaves_running_run_alone() {
496        let store = InMemoryStore::new();
497        let step = waiting_step(&store, "demo.done", "k1").await;
498
499        let resolution = store
500            .resolve_signal_step(step.id, json!({"timed_out": false}))
501            .await
502            .unwrap();
503        assert_eq!(
504            resolution,
505            SignalStepResolution::Resolved {
506                run_id: step.run_id,
507                run_resumed: false,
508            }
509        );
510        let run = store.get_run(step.run_id).await.unwrap().unwrap();
511        assert_eq!(run.status.state, RunStatus::Running);
512    }
513
514    #[tokio::test]
515    async fn resolve_signal_step_twice_returns_first_output() {
516        let store = InMemoryStore::new();
517        let step = waiting_step(&store, "demo.done", "k1").await;
518        store
519            .resolve_signal_step(step.id, json!({"first": true}))
520            .await
521            .unwrap();
522
523        let second = store
524            .resolve_signal_step(step.id, json!({"first": false}))
525            .await
526            .unwrap();
527        assert_eq!(
528            second,
529            SignalStepResolution::NotWaiting {
530                output: Some(json!({"first": true})),
531            }
532        );
533    }
534
535    #[tokio::test]
536    async fn resolve_signal_step_unknown_step_errors() {
537        let store = InMemoryStore::new();
538        let err = store
539            .resolve_signal_step(Uuid::now_v7(), json!({}))
540            .await
541            .unwrap_err();
542        assert!(matches!(err, StoreError::StepNotFound(_)));
543    }
544
545    #[tokio::test]
546    async fn suspend_run_on_signal_after_resolution_schedules_now() {
547        let store = InMemoryStore::new();
548        let step = waiting_step(&store, "demo.done", "k1").await;
549        store
550            .resolve_signal_step(step.id, json!({"timed_out": false}))
551            .await
552            .unwrap();
553
554        let deadline = Utc::now() + TimeDelta::hours(1);
555        let waiting = store
556            .suspend_run_on_signal(step.run_id, step.id, deadline)
557            .await
558            .unwrap();
559        assert!(!waiting);
560
561        let run = store.get_run(step.run_id).await.unwrap().unwrap();
562        assert_eq!(run.status.state, RunStatus::Sleeping);
563        assert!(run.scheduled_at.is_some_and(|at| at < deadline));
564    }
565
566    #[tokio::test]
567    async fn suspend_run_on_signal_rejects_non_running_run() {
568        let store = InMemoryStore::new();
569        let step = waiting_step(&store, "demo.done", "k1").await;
570        store
571            .update_run_status(step.run_id, RunStatus::Cancelled)
572            .await
573            .unwrap();
574
575        let err = store
576            .suspend_run_on_signal(step.run_id, step.id, Utc::now())
577            .await
578            .unwrap_err();
579        assert!(matches!(err, StoreError::InvalidTransition { .. }));
580    }
581
582    #[tokio::test]
583    async fn purge_signals_removes_old_signals_and_frees_idempotency_ids() {
584        let store = InMemoryStore::new();
585        store
586            .insert_signal(new_signal("demo.done", "k1", Some("d-1")))
587            .await
588            .unwrap();
589
590        let purged = store
591            .purge_signals(Utc::now() + TimeDelta::seconds(1))
592            .await
593            .unwrap();
594        assert_eq!(purged, 1);
595
596        let again = store
597            .insert_signal(new_signal("demo.done", "k1", Some("d-1")))
598            .await
599            .unwrap();
600        assert!(!again.is_duplicate());
601    }
602}