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
11pub 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 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 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 pub fn next(&mut self) -> Option<RuntimeSignal> {
162 self.queue.pop()
163 }
164
165 pub fn depth(&self) -> usize {
167 self.queue.len()
168 }
169
170 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 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}