Skip to main content

tower/
supervisor.rs

1//! Subscription supervisor: per-rule scryer subscriptions, dedup, rate limiting.
2//!
3//! For each enabled `TowerRule`, the supervisor opens one subscription to the
4//! local scryer and (when the rule's federation policy requests it) one per
5//! remote peer in the yubaba mesh. Events flow through the compiled `ScryerFilter`,
6//! a dedup ring keyed by `(rule_id, scope_id, seq)`, and an optional sliding-
7//! window rate gate before being surfaced as `FiredEvent`s.
8//!
9//! The supervisor is driven by polling (`poll()`). The P2 runtime wraps this in
10//! an async task; the test harness calls `poll()` synchronously from a single
11//! thread.
12//!
13//! Architecture: `.yah/docs/architecture/A052-yah-tower.md`
14
15use std::collections::{HashMap, VecDeque};
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use tower_rules::{FederationPolicy, Predicate, RatePredicate, RuleId, TowerRule};
20
21use crate::event::{Event, EventScope};
22use crate::rules::compiler::{self, CompileError};
23use crate::scryer_filter::ScryerFilter;
24
25// ── Public traits ─────────────────────────────────────────────────────────────
26
27/// Non-blocking event stream from a scryer subscription.
28///
29/// In production this wraps an async stream from the scryer client (R093-F1+F2).
30/// In tests a channel-backed implementation provides deterministic event feeds.
31/// Subscriptions are closed by dropping the `Box<dyn EventStream>`.
32pub trait EventStream: Send {
33    /// Return the next available event without blocking.
34    /// Returns `None` if no event is currently available.
35    fn try_next(&mut self) -> Option<Event>;
36}
37
38/// Source of scryer subscriptions. One implementation per machine (local + one per remote peer).
39///
40/// Tower opens one subscription per rule by calling `subscribe`. The returned
41/// stream is owned by the supervisor and dropped when the rule is disabled.
42pub trait SubscriptionSource: Send + Sync {
43    fn subscribe(&self, filter: ScryerFilter, rule_id: &RuleId) -> Box<dyn EventStream>;
44}
45
46/// Known remote peers in the yubaba mesh.
47///
48/// Injected into the supervisor at boot. On a local-only camp use `NoPeers`.
49/// Federated subscriptions are opened ONCE per peer per rule — not once per
50/// event — per the arch doc §Subscriptions efficiency story.
51pub trait PeerRegistry: Send + Sync {
52    fn peers(&self) -> Vec<String>;
53    fn source_for(&self, peer: &str) -> Option<Arc<dyn SubscriptionSource>>;
54}
55
56/// No-op registry for local-only camps (the common case in tower-1).
57pub struct NoPeers;
58
59impl PeerRegistry for NoPeers {
60    fn peers(&self) -> Vec<String> {
61        vec![]
62    }
63    fn source_for(&self, _peer: &str) -> Option<Arc<dyn SubscriptionSource>> {
64        None
65    }
66}
67
68// ── Dedup ─────────────────────────────────────────────────────────────────────
69
70/// Key for the dedup ring: identifies a (rule, event-scope, sequence-position) triple.
71///
72/// Because `seq` is unique within a scope per the scryer substrate contract
73/// (R093-F1), and the ring includes `rule_id`, two rules matching the same
74/// underlying event have different keys and both fire.
75#[derive(Debug, Clone, PartialEq, Eq, Hash)]
76pub struct DedupKey {
77    pub rule_id: RuleId,
78    pub scope_id: String,
79    pub seq: u64,
80}
81
82pub(crate) fn scope_id(scope: &EventScope) -> String {
83    match scope {
84        EventScope::TaskRun(id) => format!("task:{id}"),
85        EventScope::Service(ident) => format!("service:{}", ident.0),
86        EventScope::Forge(id) => format!("forge:{}", id.0),
87        EventScope::Health => "health".to_string(),
88    }
89}
90
91/// Bounded circular buffer of recently-fired `DedupKey`s with their fire timestamp.
92///
93/// When capacity is reached the oldest entry is evicted. The debounce window
94/// allows expired entries to be matched again after the window elapses.
95struct DedupRing {
96    capacity: usize,
97    entries: VecDeque<(DedupKey, Instant)>,
98}
99
100impl DedupRing {
101    fn new(capacity: usize) -> Self {
102        Self { capacity, entries: VecDeque::with_capacity(capacity) }
103    }
104
105    /// Returns `true` if `key` fired within `debounce`, or at all if debounce is `None`.
106    fn contains(&self, key: &DedupKey, debounce: Option<Duration>) -> bool {
107        self.entries.iter().any(|(k, fired_at)| {
108            k == key && debounce.map_or(true, |d| fired_at.elapsed() < d)
109        })
110    }
111
112    fn insert(&mut self, key: DedupKey) {
113        if self.entries.len() >= self.capacity {
114            self.entries.pop_front();
115        }
116        self.entries.push_back((key, Instant::now()));
117    }
118}
119
120// ── Rate window ───────────────────────────────────────────────────────────────
121
122/// Sliding-window counter for `EventMatch` predicates with a `rate` constraint.
123///
124/// Records match timestamps; `record()` returns `true` only after `min_count`
125/// matches have accumulated within the `window_ms` window.
126struct RateWindow {
127    window_ms: u64,
128    min_count: u32,
129    timestamps: VecDeque<Instant>,
130}
131
132impl RateWindow {
133    fn new(min_count: u32, window_ms: u64) -> Self {
134        Self { window_ms, min_count, timestamps: VecDeque::new() }
135    }
136
137    /// Record a matching event. Returns `true` once the rate threshold is met.
138    fn record(&mut self) -> bool {
139        let now = Instant::now();
140        let window = Duration::from_millis(self.window_ms);
141        self.timestamps.retain(|t| now.duration_since(*t) < window);
142        self.timestamps.push_back(now);
143        self.timestamps.len() >= self.min_count as usize
144    }
145}
146
147// ── Rule entry ────────────────────────────────────────────────────────────────
148
149struct RuleEntry {
150    rule: TowerRule,
151    filter: ScryerFilter,
152    local_stream: Box<dyn EventStream>,
153    peer_streams: HashMap<String, Box<dyn EventStream>>,
154    rate_window: Option<RateWindow>,
155}
156
157// ── Public output types ───────────────────────────────────────────────────────
158
159/// An event that has passed filter, dedup, and rate checks for a rule.
160///
161/// Handed to the dispatch engine (F4) which captures context, renders the
162/// trigger, and records the audit trail.
163#[derive(Debug)]
164pub struct FiredEvent {
165    /// The rule whose predicate matched.
166    pub rule_id: RuleId,
167    /// Snapshot of the full rule at fire time, so the dispatch engine (F4) can
168    /// render the trigger without needing access to the supervisor's rule map.
169    pub rule: TowerRule,
170    /// The matching event.
171    pub event: Event,
172    /// `None` = from the local scryer; `Some(peer)` = federated from a remote peer.
173    pub peer: Option<String>,
174}
175
176/// Health event emitted by the supervisor itself.
177///
178/// Surfaced as a `tower.subscription.*` scryer event so tower can dogfood its
179/// own observability substrate per arch doc §Dispatch step 3.
180#[derive(Debug)]
181pub struct SubscriptionHealth {
182    pub rule_id: RuleId,
183    pub kind: SubscriptionHealthKind,
184}
185
186#[derive(Debug)]
187pub enum SubscriptionHealthKind {
188    /// A new subscription was opened for this rule.
189    Opened,
190    /// A rule's subscription was closed (rule disabled or updated).
191    Closed,
192    /// Events were dropped due to backpressure (local stream channel full).
193    Lag { dropped: u64 },
194}
195
196// ── Supervisor ────────────────────────────────────────────────────────────────
197
198/// Per-rule subscription supervisor.
199///
200/// Manages one scryer subscription per enabled rule plus federation fan-out.
201/// Drives dedup and rate filtering before surfacing `FiredEvent`s to the
202/// dispatch engine (F4).
203///
204/// The supervisor is driven by polling (`poll()`). The P2 async runtime calls
205/// `poll()` in a tokio task; tests call it synchronously.
206pub struct Supervisor {
207    local_source: Arc<dyn SubscriptionSource>,
208    peer_registry: Arc<dyn PeerRegistry>,
209    rules: HashMap<RuleId, RuleEntry>,
210    dedup: DedupRing,
211    /// Max events read from a single rule's local stream per `poll()` call.
212    /// Excess events are drained-and-dropped; a `Lag` health event is emitted.
213    backpressure_limit: usize,
214}
215
216impl Supervisor {
217    pub fn new(
218        local_source: Arc<dyn SubscriptionSource>,
219        peer_registry: Arc<dyn PeerRegistry>,
220        dedup_capacity: usize,
221        backpressure_limit: usize,
222    ) -> Self {
223        Self {
224            local_source,
225            peer_registry,
226            rules: HashMap::new(),
227            dedup: DedupRing::new(dedup_capacity),
228            backpressure_limit,
229        }
230    }
231
232    /// Load a rule and open its subscription(s).
233    ///
234    /// Returns `Ok(true)` if the rule was loaded (enabled), `Ok(false)` if
235    /// skipped (disabled), or `Err(CompileError)` if the predicate is invalid.
236    pub fn load_rule(&mut self, rule: TowerRule) -> Result<bool, CompileError> {
237        if !rule.enabled {
238            return Ok(false);
239        }
240        let filter = compiler::compile(&rule)?;
241        let local_stream = self.local_source.subscribe(filter.clone(), &rule.id);
242        let peer_streams = open_peer_streams(&rule, &filter, &*self.peer_registry);
243        let rate_window = make_rate_window(&rule.predicate);
244        self.rules.insert(
245            rule.id.clone(),
246            RuleEntry { rule, filter, local_stream, peer_streams, rate_window },
247        );
248        Ok(true)
249    }
250
251    /// Disable a rule and close its subscription(s).
252    ///
253    /// Subscriptions are closed by dropping the `RuleEntry` (which drops the
254    /// `Box<dyn EventStream>` handles). Returns `true` if the rule was found.
255    pub fn disable_rule(&mut self, id: &RuleId) -> bool {
256        self.rules.remove(id).is_some()
257    }
258
259    /// Hot-reload a rule: close the existing subscription and open a new one.
260    ///
261    /// Called by the config-file watcher (F6) when a rule file is edited on
262    /// disk. If the updated rule is disabled, the old subscription is closed
263    /// without opening a replacement.
264    pub fn update_rule(&mut self, rule: TowerRule) -> Result<(), CompileError> {
265        let id = rule.id.clone();
266        self.disable_rule(&id);
267        self.load_rule(rule)?;
268        Ok(())
269    }
270
271    /// Poll all active subscriptions and surface `FiredEvent`s.
272    ///
273    /// Reads up to `backpressure_limit` events per rule stream, applies the
274    /// compiled filter, dedup ring, and rate window. Returns
275    /// `(fired_events, health_events)` — both may be empty.
276    pub fn poll(&mut self) -> (Vec<FiredEvent>, Vec<SubscriptionHealth>) {
277        // Phase 1: drain streams into filter-passing candidates.
278        // We borrow `self.rules` mutably here (for stream + rate_window),
279        // deferring `self.dedup` mutations to phase 2 to avoid simultaneous
280        // mutable borrows.
281        let mut candidates: Vec<(RuleId, Event, Option<String>, Option<Duration>)> = Vec::new();
282        let mut health: Vec<SubscriptionHealth> = Vec::new();
283
284        for (rule_id, entry) in self.rules.iter_mut() {
285            let debounce = entry.rule.debounce_ms.map(Duration::from_millis);
286            let mut read_count = 0usize;
287            let mut dropped = 0u64;
288
289            // Local stream — bounded by backpressure_limit
290            loop {
291                if read_count >= self.backpressure_limit {
292                    while entry.local_stream.try_next().is_some() {
293                        dropped += 1;
294                    }
295                    break;
296                }
297                match entry.local_stream.try_next() {
298                    None => break,
299                    Some(event) => {
300                        read_count += 1;
301                        if entry.filter.matches(&event) {
302                            candidates.push((rule_id.clone(), event, None, debounce));
303                        }
304                    }
305                }
306            }
307
308            if dropped > 0 {
309                health.push(SubscriptionHealth {
310                    rule_id: rule_id.clone(),
311                    kind: SubscriptionHealthKind::Lag { dropped },
312                });
313            }
314
315            // Peer streams (federated) — no backpressure limit applied to peers
316            for (peer, stream) in entry.peer_streams.iter_mut() {
317                while let Some(event) = stream.try_next() {
318                    if entry.filter.matches(&event) {
319                        candidates.push((
320                            rule_id.clone(),
321                            event,
322                            Some(peer.clone()),
323                            debounce,
324                        ));
325                    }
326                }
327            }
328        }
329
330        // Phase 2: dedup + rate filtering.
331        // self.rules borrow from phase 1 has ended; we can now borrow self.dedup
332        // and self.rules independently (sequentially, not simultaneously).
333        let mut fired = Vec::new();
334        for (rule_id, event, peer, debounce) in candidates {
335            let key = DedupKey {
336                rule_id: rule_id.clone(),
337                scope_id: scope_id(&event.scope),
338                seq: event.seq,
339            };
340
341            if self.dedup.contains(&key, debounce) {
342                continue;
343            }
344
345            let rate_ok = self
346                .rules
347                .get_mut(&rule_id)
348                .and_then(|e| e.rate_window.as_mut())
349                .map_or(true, |rw| rw.record());
350
351            if rate_ok {
352                self.dedup.insert(key);
353                let rule = self.rules[&rule_id].rule.clone();
354                fired.push(FiredEvent { rule_id, rule, event, peer });
355            }
356        }
357
358        (fired, health)
359    }
360
361    /// Number of currently active (enabled + subscribed) rules.
362    pub fn active_count(&self) -> usize {
363        self.rules.len()
364    }
365
366    /// Whether a rule is currently active.
367    pub fn is_active(&self, id: &RuleId) -> bool {
368        self.rules.contains_key(id)
369    }
370}
371
372// ── Helpers ───────────────────────────────────────────────────────────────────
373
374fn open_peer_streams(
375    rule: &TowerRule,
376    filter: &ScryerFilter,
377    peers: &dyn PeerRegistry,
378) -> HashMap<String, Box<dyn EventStream>> {
379    let peer_names: Vec<String> = match &rule.federation {
380        FederationPolicy::LocalOnly => return HashMap::new(),
381        FederationPolicy::MeshWide => peers.peers(),
382        FederationPolicy::MirrorSet { mirrors } => mirrors.clone(),
383    };
384
385    peer_names
386        .into_iter()
387        .filter_map(|peer| {
388            peers
389                .source_for(&peer)
390                .map(|src| (peer.clone(), src.subscribe(filter.clone(), &rule.id)))
391        })
392        .collect()
393}
394
395/// Extract a rate window from the top-level predicate if it is an `EventMatch`
396/// with a rate constraint. Tower-1 evaluates rate at the rule level for simple
397/// predicates; compound predicates with per-leaf rate windows are tower-2.
398fn make_rate_window(predicate: &Predicate) -> Option<RateWindow> {
399    match predicate {
400        Predicate::EventMatch { rate: Some(RatePredicate { min_count, window_ms }), .. } => {
401            Some(RateWindow::new(*min_count, *window_ms))
402        }
403        _ => None,
404    }
405}