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