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}