1use std::collections::{BTreeSet, HashSet};
39use std::io::Write;
40use std::sync::Arc;
41use std::time::{Duration, Instant};
42
43use crate::bus::monitor::{FleetEvent, SampleView, StreamItem};
44use crate::judge::condition::{Condition, RuleSet, SweepOutcome};
45use crate::model::decode::SchemaStore;
46use crate::model::registry::SliceSet;
47use crate::model::retain::RetentionBudget;
48use crate::report::{
49 CondState, PreRollInfo, PreambleInfo, PreambleSemantics, RecordReport, Transition, ZrecHeader,
50};
51use crate::tape::record::{RecordBounds, ZREC_VERSION, ZrecSink, record};
52use crate::{Error, Result};
53
54#[derive(Debug, Clone)]
56pub struct TriggerSpec {
57 pub selectors: Vec<String>,
60 pub pre: Duration,
62 pub post: Duration,
64 pub rules: Vec<Condition>,
67 pub tick: Duration,
69 pub timeout: Duration,
71 pub give_up: Option<Duration>,
74 pub preamble: Option<PreambleSemantics>,
76 pub max_samples: Option<u64>,
79 pub max_replies: usize,
82}
83
84#[derive(Debug, Clone)]
87pub enum TriggerEvent<'a> {
88 Armed {
91 watched: &'a [String],
92 pre: Duration,
93 },
94 Transition(&'a Transition),
96 Fired(&'a Transition),
99 Preamble(&'a PreambleInfo),
101 Progress { samples: u64, dropped: u64 },
103 GaveUp { after: Duration },
105}
106
107const FIRE_BUFFER: usize = 4096;
112
113pub fn state_projection(base: &str, selector: &str) -> Option<String> {
125 use zenkey::grammar::{CLASS_STATE, VERSION_CHUNK};
126 let rel = zenkey::grammar::strip_base(base, selector)?;
127 let chunks: Vec<&str> = rel.split('/').collect();
128 let projected: Vec<String> = match chunks.as_slice() {
129 ["**"] | [VERSION_CHUNK, "**"] => {
131 vec![
132 VERSION_CHUNK.into(),
133 "*".into(),
134 CLASS_STATE.into(),
135 "**".into(),
136 ]
137 }
138 [VERSION_CHUNK, origin, "**"] => {
139 vec![
140 VERSION_CHUNK.into(),
141 (*origin).into(),
142 CLASS_STATE.into(),
143 "**".into(),
144 ]
145 }
146 [VERSION_CHUNK, origin, class, rest @ ..] if *class == CLASS_STATE || *class == "*" => {
147 let mut v = vec![
148 VERSION_CHUNK.to_string(),
149 (*origin).into(),
150 CLASS_STATE.into(),
151 ];
152 v.extend(rest.iter().map(|c| (*c).to_string()));
153 if rest.is_empty() {
154 v.push("**".into());
155 }
156 v
157 }
158 _ => return None,
159 };
160 Some(zenkey::grammar::with_base(base, projected.join("/")))
161}
162
163pub fn watch_cover<'s>(selectors: impl IntoIterator<Item = &'s String>) -> Vec<String> {
175 let mut distinct: Vec<String> = Vec::new();
176 for sel in selectors {
177 if !distinct.contains(sel) {
178 distinct.push(sel.clone());
179 }
180 }
181 let parsed: Vec<Option<zenoh::key_expr::KeyExpr<'static>>> = distinct
182 .iter()
183 .map(|s| zenoh::key_expr::KeyExpr::try_from(s.clone()).ok())
184 .collect();
185 distinct
186 .iter()
187 .enumerate()
188 .filter(|(i, _)| {
189 let Some(mine) = &parsed[*i] else { return true };
190 !parsed
191 .iter()
192 .enumerate()
193 .any(|(j, other)| j != *i && other.as_ref().is_some_and(|o| o.includes(mine)))
194 })
195 .map(|(_, s)| s.clone())
196 .collect()
197}
198
199pub async fn record_on<W, F>(
213 fleet: &crate::Fleet<'_>,
214 slices: Option<&SliceSet>,
215 store: &SchemaStore,
216 spec: &TriggerSpec,
217 open: impl FnOnce() -> F,
218 mut on_event: impl FnMut(TriggerEvent<'_>),
219) -> Result<RecordReport>
220where
221 W: Write + Send + 'static,
222 F: std::future::Future<Output = Result<W>>,
223{
224 let (session, base) = (fleet.session(), fleet.base());
225 if spec.rules.is_empty() {
226 return Err(Error::unaskable(
227 "--on",
228 "a trigger capture needs at least one rule to fire on",
229 ));
230 }
231
232 let mut rules = RuleSet::new(&spec.rules, base, slices)?;
235 let watched = watch_cover(spec.selectors.iter().chain(rules.watched()));
236 let (wants_doctor, wants_roster, wants_decode) = (
237 rules.wants_doctor(),
238 rules.wants_roster(),
239 rules.wants_decode(),
240 );
241 if wants_decode {
242 crate::model::decode::prewarm(fleet, store, slices).await;
243 }
244 let _sealed = store.seal();
245
246 let monitor = crate::Monitor::start(session, crate::MonitorSpec::default()).await?;
250 monitor.core().set_retention_budget(RetentionBudget {
251 max_age: spec.pre,
252 ..RetentionBudget::default()
253 });
254 let mut events = monitor.events();
255 let monitor = monitor.watching(&watched).await?;
256 let core = Arc::clone(monitor.core());
257 on_event(TriggerEvent::Armed {
258 watched: &watched,
259 pre: spec.pre,
260 });
261
262 let armed = tokio::time::Instant::now();
263 let give_up_at = spec.give_up.map(|d| armed + d);
264 let mut facts_cache = crate::model::facts::FactsCache::default();
265
266 let fired: Option<Transition> = 'armed: loop {
268 let deadline = rules.last_eval() + spec.tick;
269 let sweep = async {
270 let doctor = if wants_doctor {
271 Some(
272 crate::judge::doctor::run_doctor(
273 fleet,
274 slices,
275 &crate::judge::doctor::DoctorSpec {
276 deep: false,
277 sample: None,
278 timeout: spec.timeout,
279 listen: None,
280 },
281 )
282 .await
283 .map_err(|e| e.to_string()),
284 )
285 } else {
286 None
287 };
288 let roster = if wants_roster {
289 Some(
290 crate::bus::roster::roster(fleet, spec.timeout)
291 .await
292 .map_err(|e| e.to_string()),
293 )
294 } else {
295 None
296 };
297 if wants_decode {
298 crate::model::decode::prewarm(fleet, store, slices).await;
299 }
300 (doctor, roster)
301 };
302 let mut sweep = std::pin::pin!(sweep);
303 let mut swept = None;
304 let tick_over = tokio::time::sleep_until(deadline);
305 tokio::pin!(tick_over);
306 let give_up = async {
307 match give_up_at {
308 Some(at) => tokio::time::sleep_until(at).await,
309 None => std::future::pending().await,
310 }
311 };
312 tokio::pin!(give_up);
313 let mut closed = false;
314 let mut gave_up = false;
315 while !closed {
316 let item = tokio::select! {
317 item = events.recv() => item,
318 outcome = &mut sweep, if swept.is_none() => {
319 swept = Some(outcome);
320 continue;
321 }
322 () = &mut tick_over, if swept.is_some() => break,
323 () = &mut give_up => {
324 gave_up = true;
325 break;
326 }
327 };
328 match item {
329 Some(StreamItem::Event(FleetEvent::Sample(s))) => {
330 let verdict = if rules.wants_verdict(&s) {
331 Some(
332 crate::model::decode::decode_sample(
333 fleet,
334 store,
335 slices,
336 &s.key,
337 Some(&s.encoding),
338 &s.payload.to_bytes(),
339 )
340 .await
341 .verdict,
342 )
343 } else {
344 None
345 };
346 rules.observe_sample(&s, &mut facts_cache, verdict.as_ref());
347 }
348 Some(StreamItem::Dropped(n)) => rules.observe_drop(n),
349 Some(_) => {}
350 None => closed = true,
351 }
352 }
353 if gave_up {
354 break 'armed None;
355 }
356 if closed {
357 break 'armed None;
360 }
361 let (doctor_outcome, roster_outcome) = match swept {
362 Some(outcome) => outcome,
363 None => sweep.await,
364 };
365 let now = tokio::time::Instant::now();
366 let at = crate::tape::record::rfc3339_now();
367 let transitions = rules.evaluate(
368 now,
369 &at,
370 SweepOutcome {
371 doctor: doctor_outcome
372 .as_ref()
373 .map(|o| o.as_ref().map_err(String::as_str)),
374 roster: roster_outcome
375 .as_ref()
376 .map(|o| o.as_ref().map_err(String::as_str)),
377 },
378 );
379 for t in transitions {
380 on_event(TriggerEvent::Transition(&t));
381 if t.to == CondState::Firing {
382 break 'armed Some(t);
383 }
384 }
385 };
386
387 let Some(trigger) = fired else {
388 monitor.shutdown().await?;
389 let after = armed.elapsed();
390 on_event(TriggerEvent::GaveUp { after });
391 return Ok(RecordReport {
392 header: ZrecHeader {
393 zrec: ZREC_VERSION,
394 selectors: watched,
395 base: base.to_string(),
396 captured_at: crate::tape::record::rfc3339_now(),
397 preamble: None,
398 pre_roll: None,
399 },
400 out: None,
401 samples: 0,
402 dropped: 0,
403 duration_ms: u64::try_from(after.as_millis()).unwrap_or(u64::MAX),
404 trigger: None,
405 preamble: None,
406 pre_roll: None,
407 preamble_rows: 0,
408 });
409 };
410
411 on_event(TriggerEvent::Fired(&trigger));
413 let fired_at = Instant::now();
414 let captured_at = crate::tape::record::rfc3339_now();
415 let ring = core.retained();
416 let stats = core.retention();
417 let epoch = ring.first().map_or(fired_at, |v| v.received);
418 let pre_roll = PreRollInfo {
419 asked_s: spec.pre.as_secs_f64(),
420 covered_s: stats.span.as_secs_f64(),
421 watched: watched.clone(),
422 evicted: stats.evicted,
423 expired: stats.expired,
424 };
425
426 let already: HashSet<usize> = ring
434 .iter()
435 .rev()
436 .take(crate::MonitorSpec::default().capacity)
437 .map(|v| Arc::as_ptr(v) as usize)
438 .collect();
439 let mut buffered: Vec<StreamItem> = Vec::new();
440 let mut buffer_dropped = 0u64;
441 let drain_into =
442 |item: Option<StreamItem>, buffered: &mut Vec<StreamItem>, dropped: &mut u64| match item {
443 Some(StreamItem::Event(FleetEvent::Sample(s))) => {
444 if already.contains(&(Arc::as_ptr(&s) as usize)) {
445 return;
446 }
447 if buffered.len() < FIRE_BUFFER {
448 buffered.push(StreamItem::Event(FleetEvent::Sample(s)));
449 } else {
450 *dropped += 1;
451 }
452 }
453 Some(StreamItem::Dropped(n)) => buffered.push(StreamItem::Dropped(n)),
454 _ => {}
455 };
456
457 let preamble = async {
461 let semantics = spec.preamble?;
462 let started = Instant::now();
463 let mut selectors = Vec::new();
464 let mut failed = Vec::new();
465 for sel in &watched {
466 match state_projection(base, sel) {
467 Some(p) if !selectors.contains(&p) => selectors.push(p),
468 Some(_) => {}
469 None => failed.push(sel.clone()),
470 }
471 }
472 let opts = crate::GetOpts::new(spec.timeout).max_replies(spec.max_replies);
473 let gets = futures_util::future::join_all(selectors.iter().map(|selector| {
474 let opts = &opts;
475 async move {
476 (
477 selector.clone(),
478 crate::bus::query::snapshot_get(session, selector, opts).await,
479 )
480 }
481 }))
482 .await;
483 let mut values = Vec::new();
484 let mut errors = 0u64;
485 for (selector, replies) in gets {
486 match replies {
487 Ok(r) => {
488 errors += r.errors;
489 values.extend(r.values);
490 }
491 Err(e) => {
492 tracing::warn!(selector, error = %e, "preamble GET could not be issued");
493 failed.push(selector);
494 }
495 }
496 }
497 let (kept, _superseded) = crate::model::snapshot::fold_latest(values);
498 let in_ring: BTreeSet<&str> = ring.iter().map(|v| v.key.as_str()).collect();
503 let rows: Vec<Arc<SampleView>> = kept
504 .into_values()
505 .filter(|(view, _)| match semantics {
506 PreambleSemantics::AbsentFromWindow => !in_ring.contains(view.key.as_str()),
507 PreambleSemantics::Full => true,
508 })
509 .map(|(view, _)| Arc::new(view))
510 .collect();
511 Some((
512 PreambleInfo {
513 count: rows.len() as u64,
514 collected_over_s: started.elapsed().as_secs_f64(),
515 selectors,
516 semantics,
517 incomplete: errors + opts.elided(),
518 failed,
519 },
520 rows,
521 ))
522 };
523 let mut preamble = std::pin::pin!(preamble);
524 let fetched = loop {
525 tokio::select! {
526 item = events.recv() => drain_into(item, &mut buffered, &mut buffer_dropped),
527 fetched = &mut preamble => break fetched,
528 }
529 };
530 let (preamble_info, preamble_rows) = match fetched {
531 Some((info, rows)) => (Some(info), rows),
532 None => (None, Vec::new()),
533 };
534 if let Some(info) = &preamble_info {
535 on_event(TriggerEvent::Preamble(info));
536 }
537
538 let header = ZrecHeader {
540 zrec: ZREC_VERSION,
541 selectors: watched.clone(),
542 base: base.to_string(),
543 captured_at,
544 preamble: preamble_info.clone(),
545 pre_roll: Some(pre_roll.clone()),
546 };
547 let out = match open().await {
552 Ok(out) => out,
553 Err(e) => {
554 if let Err(teardown) = monitor.shutdown().await {
555 tracing::warn!("after a failed open: {teardown}");
556 }
557 return Err(e);
558 }
559 };
560 let sink = ZrecSink::spawn_at(out, &header, epoch).await?;
561 for row in &preamble_rows {
562 sink.write_preamble(Arc::clone(row)).await?;
563 }
564 for view in ring.iter() {
565 sink.write_sample(Arc::clone(view)).await?;
566 }
567 sink.write_trigger(trigger.clone()).await?;
568 while let Ok(item) = tokio::time::timeout(Duration::ZERO, events.recv()).await {
571 drain_into(item, &mut buffered, &mut buffer_dropped);
572 }
573 for item in buffered.drain(..) {
574 match item {
575 StreamItem::Event(FleetEvent::Sample(s)) => sink.write_sample(s).await?,
576 StreamItem::Dropped(n) => sink.write_dropped(n).await?,
577 StreamItem::Event(_) => {}
578 }
579 }
580 if buffer_dropped > 0 {
581 sink.write_dropped(buffer_dropped).await?;
582 }
583
584 let pre_written = sink.counts().samples;
586 let bounds = RecordBounds {
587 max_samples: spec.max_samples.map(|n| n + pre_written),
588 max_duration: Some(spec.post),
589 };
590 let mut last_line = Instant::now();
591 let recorded = record(&mut events, &sink, bounds, |samples, dropped| {
592 if last_line.elapsed() >= Duration::from_secs(1) {
593 last_line = Instant::now();
594 on_event(TriggerEvent::Progress { samples, dropped });
595 }
596 })
597 .await;
598 let closed = monitor.shutdown().await;
600 recorded?;
601 let counts = sink.finish().await?;
602 closed?;
603 Ok(RecordReport {
604 header,
605 out: None,
606 samples: counts.samples,
607 dropped: counts.dropped,
608 duration_ms: u64::try_from((stats.span + fired_at.elapsed()).as_millis())
609 .unwrap_or(u64::MAX),
610 trigger: Some(trigger),
611 preamble: preamble_info,
612 pre_roll: Some(pre_roll),
613 preamble_rows: counts.preamble,
614 })
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620
621 #[test]
625 fn the_watch_cover_declares_no_included_selector_twice() {
626 let s = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
627 assert_eq!(
628 watch_cover(&s(&["v1/**", "v1/h-aaaaaaaaaaaa/state/p/health"])),
629 s(&["v1/**"])
630 );
631 assert_eq!(
632 watch_cover(&s(&["v1/h-aaaaaaaaaaaa/state/p/health", "v1/**"])),
633 s(&["v1/**"])
634 );
635 assert_eq!(
636 watch_cover(&s(&["v1/a/state/**", "v1/b/state/**", "v1/a/state/**"])),
637 s(&["v1/a/state/**", "v1/b/state/**"])
638 );
639 assert_eq!(watch_cover(&s(&["v1/x/**", "v1/x/**"])), s(&["v1/x/**"]));
643 }
644
645 #[test]
650 fn the_state_projection_narrows_widens_or_declines() {
651 let p = |s| state_projection("", s);
652 assert_eq!(p("v1/**").as_deref(), Some("v1/*/state/**"));
653 assert_eq!(p("**").as_deref(), Some("v1/*/state/**"));
654 assert_eq!(
655 p("v1/h-aaaaaaaaaaaa/**").as_deref(),
656 Some("v1/h-aaaaaaaaaaaa/state/**")
657 );
658 assert_eq!(
659 p("v1/h-aaaaaaaaaaaa/*/demo/**").as_deref(),
660 Some("v1/h-aaaaaaaaaaaa/state/demo/**")
661 );
662 assert_eq!(
663 p("v1/h-aaaaaaaaaaaa/state/demo/health").as_deref(),
664 Some("v1/h-aaaaaaaaaaaa/state/demo/health")
665 );
666 assert_eq!(
667 p("v1/h-aaaaaaaaaaaa/state").as_deref(),
668 Some("v1/h-aaaaaaaaaaaa/state/**")
669 );
670 assert_eq!(p("v1/h-aaaaaaaaaaaa/telemetry/demo/**"), None);
671 assert_eq!(p("v1/h-aaaaaaaaaaaa"), None);
672 assert_eq!(p("v1"), None);
673 assert_eq!(
676 state_projection("acme", "acme/v1/**").as_deref(),
677 Some("acme/v1/*/state/**")
678 );
679 assert_eq!(state_projection("acme", "other/v1/**"), None);
680 }
681}