Skip to main content

deepstrike_core/signals/
router.rs

1use std::collections::{HashSet, VecDeque};
2
3use compact_str::CompactString;
4
5use super::attention::UrgencyBasedPolicy;
6use super::queue::SignalQueue;
7use crate::scheduler::tcb::TaskLifecycle;
8use crate::types::policy::SignalDisposition;
9use crate::types::signal::RuntimeSignal;
10
11/// Signal router: dedup set + urgency-based attention + bounded priority queue.
12pub struct SignalRouter {
13    seen: HashSet<CompactString>,
14    seen_order: VecDeque<CompactString>,
15    dedupe_capacity: usize,
16    queue: SignalQueue,
17    attention: UrgencyBasedPolicy,
18    ttl_ms: Option<u64>,
19    deadline_escalation: bool,
20}
21
22#[derive(Debug, Clone, PartialEq)]
23pub struct SignalRouteOutcome {
24    pub disposition: SignalDisposition,
25    pub displaced_signal_id: Option<String>,
26    pub expired_signal_ids: Vec<String>,
27}
28
29impl SignalRouter {
30    pub const DEFAULT_DEDUPE_CAPACITY: usize = 256;
31
32    pub fn new(max_queue_size: usize) -> Self {
33        Self::with_policy(max_queue_size, None, false)
34    }
35
36    pub fn with_policy(
37        max_queue_size: usize,
38        ttl_ms: Option<u64>,
39        deadline_escalation: bool,
40    ) -> Self {
41        Self {
42            seen: HashSet::with_capacity(Self::DEFAULT_DEDUPE_CAPACITY),
43            seen_order: VecDeque::with_capacity(Self::DEFAULT_DEDUPE_CAPACITY),
44            dedupe_capacity: Self::DEFAULT_DEDUPE_CAPACITY,
45            queue: SignalQueue::new(max_queue_size),
46            attention: UrgencyBasedPolicy,
47            ttl_ms,
48            deadline_escalation,
49        }
50    }
51
52    /// Ingest a signal. Returns the disposition after dedup + attention evaluation.
53    /// `Queue` dispositions are buffered; if the queue is full, returns `Dropped`
54    /// so the SDK can apply backpressure or surface the loss to telemetry.
55    /// All other dispositions are returned directly to the caller.
56    pub fn ingest(&mut self, signal: RuntimeSignal, lifecycle: TaskLifecycle) -> SignalDisposition {
57        let now_ms = signal.timestamp_ms;
58        self.ingest_at(signal, lifecycle, now_ms).disposition
59    }
60
61    pub fn ingest_at(
62        &mut self,
63        mut signal: RuntimeSignal,
64        lifecycle: TaskLifecycle,
65        now_ms: u64,
66    ) -> SignalRouteOutcome {
67        let expired_signal_ids = self.expire(now_ms);
68        let dedupe_key = signal.dedupe_key.clone();
69        if let Some(ref key) = dedupe_key {
70            if self.seen.contains(key) {
71                return SignalRouteOutcome {
72                    disposition: SignalDisposition::Ignore,
73                    displaced_signal_id: None,
74                    expired_signal_ids,
75                };
76            }
77        }
78
79        let deadline_escalated = self.deadline_escalation
80            && signal
81                .deadline_ms
82                .is_some_and(|deadline_ms| now_ms >= deadline_ms);
83        if deadline_escalated {
84            signal.urgency = escalate_one_tier(signal.urgency);
85        }
86
87        let disposition = self.attention.evaluate(&signal, lifecycle);
88
89        if disposition == SignalDisposition::Queue {
90            let admission = self
91                .queue
92                .admit_with_deadline_state(signal, deadline_escalated);
93            for key in &admission.displaced_dedupe_keys {
94                self.release_dedupe_key(key);
95            }
96            let displaced_signal_id = admission
97                .displaced
98                .as_ref()
99                .map(|displaced| displaced.id.to_string());
100            if !admission.admitted {
101                return SignalRouteOutcome {
102                    disposition: SignalDisposition::Dropped,
103                    displaced_signal_id: None,
104                    expired_signal_ids,
105                };
106            }
107            if let Some(key) = dedupe_key {
108                self.commit_dedupe(key);
109            }
110            return SignalRouteOutcome {
111                disposition,
112                displaced_signal_id,
113                expired_signal_ids,
114            };
115        }
116
117        if let Some(key) = dedupe_key {
118            self.commit_dedupe(key);
119        }
120
121        SignalRouteOutcome {
122            disposition,
123            displaced_signal_id: None,
124            expired_signal_ids,
125        }
126    }
127
128    /// Expire queued signals against the journaled clock before either admission or delivery.
129    pub fn expire(&mut self, now_ms: u64) -> Vec<String> {
130        let expired = self.queue.expire(now_ms, self.ttl_ms);
131        if self.deadline_escalation {
132            self.queue.escalate_deadlines(now_ms);
133        }
134        for (_, dedupe_keys) in &expired {
135            for key in dedupe_keys {
136                self.release_dedupe_key(key);
137            }
138        }
139        expired
140            .into_iter()
141            .map(|(signal, _)| signal.id.to_string())
142            .collect()
143    }
144
145    fn commit_dedupe(&mut self, key: CompactString) {
146        if self.seen_order.len() == self.dedupe_capacity {
147            if let Some(expired) = self.seen_order.pop_front() {
148                self.seen.remove(&expired);
149            }
150        }
151        self.seen.insert(key.clone());
152        self.seen_order.push_back(key);
153    }
154
155    fn release_dedupe_key(&mut self, key: &CompactString) {
156        self.seen.remove(key);
157        self.seen_order.retain(|seen_key| seen_key != key);
158    }
159
160    /// Pull next queued signal.
161    pub fn next(&mut self) -> Option<RuntimeSignal> {
162        self.queue.pop()
163    }
164
165    /// Number of queued signals.
166    pub fn depth(&self) -> usize {
167        self.queue.len()
168    }
169
170    /// Clear the dedup set (call at session boundaries to prevent unbounded growth).
171    pub fn clear_dedup(&mut self) {
172        self.seen.clear();
173        self.seen_order.clear();
174    }
175
176    #[cfg(test)]
177    fn dedupe_len(&self) -> usize {
178        self.seen.len()
179    }
180}
181
182fn escalate_one_tier(urgency: crate::types::signal::Urgency) -> crate::types::signal::Urgency {
183    use crate::types::signal::Urgency;
184    match urgency {
185        Urgency::Low => Urgency::Normal,
186        Urgency::Normal => Urgency::High,
187        Urgency::High | Urgency::Critical => Urgency::Critical,
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::scheduler::tcb::TaskLifecycle;
195    use crate::types::signal::{SignalSource, SignalType, Urgency};
196
197    #[test]
198    fn deduplicates_signals() {
199        let mut router = SignalRouter::new(100);
200        let sig = RuntimeSignal::new(
201            SignalSource::Cron,
202            SignalType::Event,
203            Urgency::Normal,
204            "tick",
205        )
206        .with_dedupe("cron-tick-1");
207
208        let d1 = router.ingest(sig.clone(), TaskLifecycle::Running);
209        assert_ne!(d1, SignalDisposition::Ignore);
210
211        let d2 = router.ingest(sig, TaskLifecycle::Running);
212        assert_eq!(d2, SignalDisposition::Ignore);
213    }
214
215    #[test]
216    fn normal_signal_queued() {
217        let mut router = SignalRouter::new(100);
218        let sig = RuntimeSignal::new(
219            SignalSource::Cron,
220            SignalType::Event,
221            Urgency::Normal,
222            "job",
223        );
224
225        let d = router.ingest(sig, TaskLifecycle::Running);
226        assert_eq!(d, SignalDisposition::Queue);
227        assert_eq!(router.depth(), 1);
228        assert!(router.next().is_some());
229    }
230
231    #[test]
232    fn interrupt_signals_not_queued() {
233        let mut router = SignalRouter::new(100);
234        let sig = RuntimeSignal::new(
235            SignalSource::Gateway,
236            SignalType::Alert,
237            Urgency::Critical,
238            "fire",
239        );
240
241        let d = router.ingest(sig, TaskLifecycle::Running);
242        assert_eq!(d, SignalDisposition::InterruptNow);
243        assert_eq!(router.depth(), 0);
244    }
245
246    #[test]
247    fn full_queue_drops_signal() {
248        let mut router = SignalRouter::new(1);
249        let s1 = RuntimeSignal::new(
250            SignalSource::Cron,
251            SignalType::Event,
252            Urgency::Normal,
253            "first",
254        );
255        let s2 = RuntimeSignal::new(
256            SignalSource::Cron,
257            SignalType::Event,
258            Urgency::Normal,
259            "second",
260        );
261
262        assert_eq!(
263            router.ingest(s1, TaskLifecycle::Running),
264            SignalDisposition::Queue
265        );
266        assert_eq!(
267            router.ingest(s2, TaskLifecycle::Running),
268            SignalDisposition::Dropped
269        );
270    }
271
272    #[test]
273    fn clear_dedup_allows_reingest() {
274        let mut router = SignalRouter::new(100);
275        let sig = RuntimeSignal::new(
276            SignalSource::Cron,
277            SignalType::Event,
278            Urgency::Normal,
279            "tick",
280        )
281        .with_dedupe("key-1");
282
283        router.ingest(sig.clone(), TaskLifecycle::Running);
284        assert_eq!(
285            router.ingest(sig.clone(), TaskLifecycle::Running),
286            SignalDisposition::Ignore
287        );
288
289        router.clear_dedup();
290        assert_ne!(
291            router.ingest(sig, TaskLifecycle::Running),
292            SignalDisposition::Ignore
293        );
294    }
295
296    #[test]
297    fn dedupe_window_is_bounded_and_expires_oldest_key() {
298        let mut router = SignalRouter::new(1);
299        for index in 0..=SignalRouter::DEFAULT_DEDUPE_CAPACITY {
300            let signal =
301                RuntimeSignal::new(SignalSource::Cron, SignalType::Event, Urgency::Low, "tick")
302                    .with_dedupe(format!("key-{index}"));
303            assert_ne!(
304                router.ingest(signal, TaskLifecycle::Running),
305                SignalDisposition::Ignore
306            );
307        }
308
309        assert_eq!(router.dedupe_len(), SignalRouter::DEFAULT_DEDUPE_CAPACITY);
310        let expired =
311            RuntimeSignal::new(SignalSource::Cron, SignalType::Event, Urgency::Low, "tick")
312                .with_dedupe("key-0");
313        assert_ne!(
314            router.ingest(expired, TaskLifecycle::Running),
315            SignalDisposition::Ignore
316        );
317    }
318
319    #[test]
320    fn dropped_signal_does_not_commit_its_dedupe_key() {
321        let mut router = SignalRouter::new(1);
322        let admitted = RuntimeSignal::new(
323            SignalSource::Cron,
324            SignalType::Event,
325            Urgency::Normal,
326            "admitted",
327        );
328        let retryable = RuntimeSignal::new(
329            SignalSource::Cron,
330            SignalType::Event,
331            Urgency::Normal,
332            "retryable",
333        )
334        .with_dedupe("retryable-key");
335
336        assert_eq!(
337            router.ingest(admitted, TaskLifecycle::Running),
338            SignalDisposition::Queue
339        );
340        assert_eq!(
341            router.ingest(retryable.clone(), TaskLifecycle::Running),
342            SignalDisposition::Dropped
343        );
344        assert!(router.next().is_some());
345        assert_eq!(
346            router.ingest(retryable, TaskLifecycle::Running),
347            SignalDisposition::Queue
348        );
349    }
350
351    #[test]
352    fn ttl_cleanup_precedes_urgency_displacement() {
353        let mut router = SignalRouter::with_policy(1, Some(10), false);
354        let fresh = RuntimeSignal::new(
355            SignalSource::Cron,
356            SignalType::Event,
357            Urgency::Normal,
358            "fresh",
359        )
360        .with_timestamp(30);
361
362        // Terminal forces the critical signal into the pending queue so TTL can be tested.
363        let stale_queued = RuntimeSignal::new(
364            SignalSource::Gateway,
365            SignalType::Alert,
366            Urgency::Critical,
367            "stale queued",
368        )
369        .with_timestamp(10);
370        assert_eq!(
371            router
372                .ingest_at(
373                    stale_queued,
374                    TaskLifecycle::Done(crate::types::result::TerminationReason::Completed),
375                    10,
376                )
377                .disposition,
378            SignalDisposition::Queue
379        );
380
381        let outcome = router.ingest_at(fresh, TaskLifecycle::Running, 30);
382        assert_eq!(outcome.disposition, SignalDisposition::Queue);
383        assert_eq!(outcome.expired_signal_ids.len(), 1);
384        assert!(outcome.displaced_signal_id.is_none());
385    }
386
387    #[test]
388    fn expiration_releases_dedupe_key_before_redelivery_is_checked() {
389        let mut router = SignalRouter::with_policy(1, Some(10), false);
390        let first = RuntimeSignal::new(
391            SignalSource::Cron,
392            SignalType::Event,
393            Urgency::Normal,
394            "first lease",
395        )
396        .with_timestamp(10)
397        .with_dedupe("leased-work");
398        assert_eq!(
399            router
400                .ingest_at(first, TaskLifecycle::Running, 10)
401                .disposition,
402            SignalDisposition::Queue
403        );
404
405        let redelivery = RuntimeSignal::new(
406            SignalSource::Cron,
407            SignalType::Event,
408            Urgency::Normal,
409            "redelivery",
410        )
411        .with_timestamp(30)
412        .with_dedupe("leased-work");
413        let outcome = router.ingest_at(redelivery, TaskLifecycle::Running, 30);
414
415        assert_eq!(outcome.disposition, SignalDisposition::Queue);
416        assert_eq!(outcome.expired_signal_ids.len(), 1);
417    }
418
419    #[test]
420    fn displacement_releases_the_evicted_signals_dedupe_key() {
421        let mut router = SignalRouter::new(2);
422        let old = RuntimeSignal::new(
423            SignalSource::Cron,
424            SignalType::Event,
425            Urgency::Low,
426            "old low",
427        )
428        .with_timestamp(1)
429        .with_dedupe("old-low");
430        let newest = RuntimeSignal::new(
431            SignalSource::Cron,
432            SignalType::Event,
433            Urgency::Low,
434            "new low",
435        )
436        .with_timestamp(2)
437        .with_dedupe("new-low");
438        let newest_id = newest.id.to_string();
439        let critical = RuntimeSignal::new(
440            SignalSource::Gateway,
441            SignalType::Alert,
442            Urgency::Critical,
443            "critical",
444        )
445        .with_timestamp(3);
446        let terminal = TaskLifecycle::Done(crate::types::result::TerminationReason::Completed);
447        assert_eq!(router.ingest(old, terminal), SignalDisposition::Queue);
448        assert_eq!(router.ingest(newest, terminal), SignalDisposition::Queue);
449
450        let outcome = router.ingest_at(critical, terminal, 3);
451        assert_eq!(outcome.disposition, SignalDisposition::Queue);
452        assert_eq!(
453            outcome.displaced_signal_id.as_deref(),
454            Some(newest_id.as_str())
455        );
456
457        let redelivery = RuntimeSignal::new(
458            SignalSource::Cron,
459            SignalType::Event,
460            Urgency::Low,
461            "new low redelivery",
462        )
463        .with_timestamp(4)
464        .with_dedupe("new-low");
465        assert_ne!(
466            router.ingest(redelivery, TaskLifecycle::Running),
467            SignalDisposition::Ignore
468        );
469    }
470
471    #[test]
472    fn due_deadline_escalates_exactly_one_urgency_tier_when_enabled() {
473        let mut router = SignalRouter::with_policy(4, None, true);
474        let due = RuntimeSignal::new(
475            SignalSource::Gateway,
476            SignalType::Event,
477            Urgency::Normal,
478            "due work",
479        )
480        .with_timestamp(10)
481        .with_deadline(20);
482
483        let outcome = router.ingest_at(due, TaskLifecycle::Running, 20);
484
485        assert_eq!(outcome.disposition, SignalDisposition::Interrupt);
486        assert_eq!(router.depth(), 0);
487    }
488
489    #[test]
490    fn deadline_is_inert_when_escalation_policy_is_disabled() {
491        let mut router = SignalRouter::with_policy(4, None, false);
492        let due = RuntimeSignal::new(
493            SignalSource::Gateway,
494            SignalType::Event,
495            Urgency::Normal,
496            "due work",
497        )
498        .with_timestamp(10)
499        .with_deadline(20);
500
501        let outcome = router.ingest_at(due, TaskLifecycle::Running, 20);
502
503        assert_eq!(outcome.disposition, SignalDisposition::Queue);
504        assert_eq!(router.next().unwrap().urgency, Urgency::Normal);
505    }
506
507    #[test]
508    fn queued_signals_coalesce_without_consuming_capacity_or_dedupe_semantics() {
509        let mut router = SignalRouter::with_policy(1, None, false);
510        let first = RuntimeSignal::new(
511            SignalSource::Cron,
512            SignalType::Event,
513            Urgency::Normal,
514            "first sample",
515        )
516        .with_timestamp(10)
517        .with_deadline(200)
518        .with_coalesce("telemetry")
519        .with_dedupe("event-1");
520        let second = RuntimeSignal::new(
521            SignalSource::Cron,
522            SignalType::Event,
523            Urgency::Normal,
524            "second sample",
525        )
526        .with_timestamp(20)
527        .with_deadline(100)
528        .with_coalesce("telemetry");
529
530        assert_eq!(
531            router.ingest(first, TaskLifecycle::Running),
532            SignalDisposition::Queue
533        );
534        assert_eq!(
535            router.ingest(second, TaskLifecycle::Running),
536            SignalDisposition::Queue
537        );
538        assert_eq!(router.depth(), 1);
539
540        let duplicate = RuntimeSignal::new(
541            SignalSource::Cron,
542            SignalType::Event,
543            Urgency::Normal,
544            "dedupe still wins",
545        )
546        .with_timestamp(30)
547        .with_coalesce("telemetry")
548        .with_dedupe("event-1");
549        assert_eq!(
550            router.ingest(duplicate, TaskLifecycle::Running),
551            SignalDisposition::Ignore
552        );
553
554        let merged = router.next().unwrap();
555        assert_eq!(merged.summary.as_str(), "first sample");
556        assert_eq!(merged.timestamp_ms, 10);
557        assert_eq!(merged.deadline_ms, Some(100));
558        assert_eq!(merged.coalesced_count, 2);
559    }
560
561    #[test]
562    fn expiration_releases_every_dedupe_key_merged_into_a_coalesced_entry() {
563        let mut router = SignalRouter::with_policy(1, Some(10), false);
564        let first = RuntimeSignal::new(
565            SignalSource::Cron,
566            SignalType::Event,
567            Urgency::Normal,
568            "first",
569        )
570        .with_timestamp(10)
571        .with_coalesce("batch")
572        .with_dedupe("event-1");
573        let second = RuntimeSignal::new(
574            SignalSource::Cron,
575            SignalType::Event,
576            Urgency::Normal,
577            "second",
578        )
579        .with_timestamp(11)
580        .with_coalesce("batch")
581        .with_dedupe("event-2");
582
583        assert_eq!(
584            router.ingest(first, TaskLifecycle::Running),
585            SignalDisposition::Queue
586        );
587        assert_eq!(
588            router.ingest(second, TaskLifecycle::Running),
589            SignalDisposition::Queue
590        );
591        assert_eq!(router.expire(30).len(), 1);
592
593        for key in ["event-1", "event-2"] {
594            let redelivery = RuntimeSignal::new(
595                SignalSource::Cron,
596                SignalType::Event,
597                Urgency::Normal,
598                "redelivery",
599            )
600            .with_timestamp(30)
601            .with_dedupe(key);
602            assert_eq!(
603                router.ingest(redelivery, TaskLifecycle::Running),
604                SignalDisposition::Queue
605            );
606            router.next();
607        }
608    }
609
610    #[test]
611    fn runtime_signal_wire_rejects_removed_topic_field() {
612        let encoded = serde_json::json!({
613            "id": uuid::Uuid::nil(),
614            "source": "custom",
615            "signal_type": "event",
616            "urgency": "normal",
617            "summary": "signal",
618            "payload": null,
619            "topic": "legacy-field",
620            "timestamp_ms": 1
621        });
622
623        assert!(serde_json::from_value::<RuntimeSignal>(encoded).is_err());
624    }
625}