use std::collections::HashMap; use std::path::Path; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; #[derive(Clone)] pub struct TimingHandle { inner: Arc>, } impl TimingHandle { pub fn new(meta: TimingMeta) -> Self { Self { inner: Arc::new(Mutex::new(TimingCollector::new(meta))), } } pub fn span_phase(&self, phase: &'static str) -> TimingSpanGuard<'_> { TimingSpanGuard { handle: self.clone(), kind: TimingSpanKind::Phase(phase), start: Instant::now(), } } pub fn span_rrdp_repo<'a>(&self, repo_uri: &'a str) -> TimingSpanGuard<'a> { TimingSpanGuard { handle: self.clone(), kind: TimingSpanKind::RrdpRepo(repo_uri), start: Instant::now(), } } pub fn span_rrdp_repo_step<'a>( &self, repo_uri: &'a str, step: &'static str, ) -> TimingSpanGuard<'a> { TimingSpanGuard { handle: self.clone(), kind: TimingSpanKind::RrdpRepoStep { repo_uri, step }, start: Instant::now(), } } pub fn span_publication_point<'a>(&self, manifest_rsync_uri: &'a str) -> TimingSpanGuard<'a> { TimingSpanGuard { handle: self.clone(), kind: TimingSpanKind::PublicationPoint(manifest_rsync_uri), start: Instant::now(), } } pub fn set_meta(&self, update: TimingMetaUpdate<'_>) { let mut g = self.inner.lock().expect("timing lock"); if let Some(v) = update.tal_url { g.meta.tal_url = Some(v.to_string()); } if let Some(v) = update.db_path { g.meta.db_path = Some(v.to_string()); } } pub fn record_count(&self, key: &'static str, inc: u64) { let mut g = self.inner.lock().expect("timing lock"); g.counts .entry(key) .and_modify(|v| *v = v.saturating_add(inc)) .or_insert(inc); } pub fn counts_snapshot(&self) -> HashMap { let g = self.inner.lock().expect("timing lock"); g.counts .iter() .map(|(key, value)| ((*key).to_string(), *value)) .collect() } pub fn report_snapshot(&self, top_n: usize) -> TimingReportV1 { let g = self.inner.lock().expect("timing lock"); g.to_report(top_n) } /// Record a phase duration directly in nanoseconds. /// /// This is useful when aggregating sub-phase timings locally (to reduce lock contention) /// and then emitting a single record per publication point. pub fn record_phase_nanos(&self, phase: &'static str, nanos: u64) { let mut g = self.inner.lock().expect("timing lock"); g.phases.record(phase, nanos); } pub fn record_publication_point_nanos(&self, manifest_rsync_uri: &str, nanos: u64) { let mut g = self.inner.lock().expect("timing lock"); g.publication_points.record(manifest_rsync_uri, nanos); } pub fn record_publication_point_step_nanos( &self, manifest_rsync_uri: &str, step: &'static str, nanos: u64, ) { let mut g = self.inner.lock().expect("timing lock"); g.publication_point_steps .record(&format!("{manifest_rsync_uri}::{step}"), nanos); } pub fn write_json(&self, path: &Path, top_n: usize) -> Result<(), String> { let report = { let g = self.inner.lock().expect("timing lock"); g.to_report(top_n) }; let f = std::fs::File::create(path) .map_err(|e| format!("create timing json failed: {}: {e}", path.display()))?; serde_json::to_writer_pretty(f, &report) .map_err(|e| format!("write timing json failed: {e}"))?; Ok(()) } fn record_duration(&self, kind: TimingSpanKind<'_>, duration: Duration) { let nanos_u64 = duration.as_nanos().min(u128::from(u64::MAX)) as u64; let mut g = self.inner.lock().expect("timing lock"); match kind { TimingSpanKind::Phase(name) => g.phases.record(name, nanos_u64), TimingSpanKind::RrdpRepo(uri) => g.rrdp_repos.record(uri, nanos_u64), TimingSpanKind::RrdpRepoStep { repo_uri, step } => g .rrdp_repo_steps .record(&format!("{repo_uri}::{step}"), nanos_u64), TimingSpanKind::PublicationPoint(uri) => g.publication_points.record(uri, nanos_u64), } } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct TimingMeta { pub recorded_at_utc_rfc3339: String, pub validation_time_utc_rfc3339: String, pub tal_url: Option, pub db_path: Option, } #[derive(Clone, Debug, Default)] pub struct TimingMetaUpdate<'a> { pub tal_url: Option<&'a str>, pub db_path: Option<&'a str>, } pub struct TimingSpanGuard<'a> { handle: TimingHandle, kind: TimingSpanKind<'a>, start: Instant, } impl Drop for TimingSpanGuard<'_> { fn drop(&mut self) { self.handle .record_duration(self.kind.clone(), self.start.elapsed()); } } #[derive(Clone, Debug)] enum TimingSpanKind<'a> { Phase(&'static str), RrdpRepo(&'a str), RrdpRepoStep { repo_uri: &'a str, step: &'static str, }, PublicationPoint(&'a str), } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct DurationStats { pub count: u64, pub total_nanos: u64, } impl DurationStats { fn record(&mut self, nanos: u64) { self.count = self.count.saturating_add(1); self.total_nanos = self.total_nanos.saturating_add(nanos); } } #[derive(Clone, Debug, Default)] struct DurationStatsMap { map: HashMap, } impl DurationStatsMap { fn record(&mut self, key: &str, nanos: u64) { self.map.entry(key.to_string()).or_default().record(nanos); } fn top(&self, n: usize) -> Vec { let mut v = self .map .iter() .map(|(k, s)| TopDurationEntry { key: k.clone(), count: s.count, total_nanos: s.total_nanos, }) .collect::>(); v.sort_by(|a, b| b.total_nanos.cmp(&a.total_nanos)); v.truncate(n); v } } struct TimingCollector { meta: TimingMeta, counts: HashMap<&'static str, u64>, phases: DurationStatsMap, rrdp_repos: DurationStatsMap, rrdp_repo_steps: DurationStatsMap, publication_points: DurationStatsMap, publication_point_steps: DurationStatsMap, } impl TimingCollector { fn new(meta: TimingMeta) -> Self { Self { meta, counts: HashMap::new(), phases: DurationStatsMap::default(), rrdp_repos: DurationStatsMap::default(), rrdp_repo_steps: DurationStatsMap::default(), publication_points: DurationStatsMap::default(), publication_point_steps: DurationStatsMap::default(), } } fn to_report(&self, top_n: usize) -> TimingReportV1 { TimingReportV1 { format_version: 1, meta: self.meta.clone(), counts: self .counts .iter() .map(|(k, v)| ((*k).to_string(), *v)) .collect(), phases: self .phases .map .iter() .map(|(k, s)| (k.clone(), s.clone())) .collect(), top_rrdp_repos: self.rrdp_repos.top(top_n), top_rrdp_repo_steps: self.rrdp_repo_steps.top(top_n), top_publication_points: self.publication_points.top(top_n), top_publication_point_steps: self.publication_point_steps.top(top_n), } } } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TimingReportV1 { pub format_version: u64, pub meta: TimingMeta, pub counts: HashMap, pub phases: HashMap, pub top_rrdp_repos: Vec, pub top_rrdp_repo_steps: Vec, pub top_publication_points: Vec, pub top_publication_point_steps: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct TopDurationEntry { pub key: String, pub count: u64, pub total_nanos: u64, } #[cfg(test)] mod tests { use super::*; #[test] fn timing_handle_writes_json_with_phases_and_tops() { let meta = TimingMeta { recorded_at_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(), validation_time_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(), tal_url: Some("https://example.test/x.tal".to_string()), db_path: Some("db".to_string()), }; let h = TimingHandle::new(meta); { let _p = h.span_phase("tal_bootstrap"); } { let _r = h.span_rrdp_repo("https://rrdp.example.test/notification.xml"); } { let _s = h.span_rrdp_repo_step( "https://rrdp.example.test/notification.xml", "fetch_notification", ); } { let _pp = h.span_publication_point("rsync://example.test/repo/manifest.mft"); } h.record_count("vrps", 42); h.record_publication_point_nanos("rsync://example.test/repo/manifest.mft", 1_000_000); h.record_publication_point_step_nanos( "rsync://example.test/repo/manifest.mft", "fresh_snapshot_prepare", 1_000_000, ); let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("timing.json"); h.write_json(&path, 10).expect("write_json"); let rep: TimingReportV1 = serde_json::from_slice(&std::fs::read(&path).expect("read timing.json")) .expect("parse timing.json"); assert_eq!(rep.format_version, 1); assert!(rep.phases.contains_key("tal_bootstrap")); assert_eq!(rep.counts.get("vrps").copied(), Some(42)); assert!( rep.top_rrdp_repos .iter() .any(|e| e.key.contains("rrdp.example.test")), "expected repo in top list" ); assert!( rep.top_rrdp_repo_steps .iter() .any(|e| e.key.contains("fetch_notification")), "expected repo step in top list" ); assert!( rep.top_publication_points .iter() .any(|e| e.key.contains("manifest.mft")), "expected PP in top list" ); assert!( rep.top_publication_point_steps .iter() .any(|e| e.key.contains("fresh_snapshot_prepare")), "expected PP step in top list" ); } }