Skip to main content

monitord/
timer.rs

1//! # timers module
2//!
3//! All timer related logic goes here. This will be hitting timer specific
4//! dbus / varlink etc.
5
6use struct_field_names_as_array::FieldNamesAsArray;
7use thiserror::Error;
8use tracing::error;
9
10#[derive(Error, Debug)]
11pub enum MonitordTimerError {
12    #[error("Timer D-Bus error: {0}")]
13    ZbusError(#[from] zbus::Error),
14}
15
16#[derive(
17    serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, FieldNamesAsArray, PartialEq,
18)]
19
20/// Per-timer unit metrics from the org.freedesktop.systemd1.Timer D-Bus interface.
21/// Ref: <https://www.freedesktop.org/software/systemd/man/org.freedesktop.systemd1.html>
22pub struct TimerStats {
23    /// AccuracySec timer property in microseconds; systemd may coalesce timer firings within this window to save wakeups
24    pub accuracy_usec: u64,
25    /// Whether FixedRandomDelay= is set; when true, the random delay is stable across reboots for this timer
26    pub fixed_random_delay: bool,
27    /// Realtime timestamp (usec since epoch) when this timer last triggered its service unit
28    pub last_trigger_usec: u64,
29    /// Monotonic timestamp (usec since boot) when this timer last triggered its service unit
30    pub last_trigger_usec_monotonic: u64,
31    /// Monotonic timestamp (usec since boot) when this timer will next elapse
32    pub next_elapse_usec_monotonic: u64,
33    /// Realtime timestamp (usec since epoch) when this timer will next elapse
34    pub next_elapse_usec_realtime: u64,
35    /// Whether Persistent= is set; when true, missed timer runs (e.g. during downtime) are triggered on next boot
36    pub persistent: bool,
37    /// RandomizedDelaySec property in microseconds; a random delay up to this value is added before each trigger
38    pub randomized_delay_usec: u64,
39    /// Whether RemainAfterElapse= is set; when true, the timer stays loaded after all triggers have elapsed
40    pub remain_after_elapse: bool,
41    /// Realtime timestamp (usec since epoch) of the most recent state change of the triggered service unit
42    pub service_unit_last_state_change_usec: u64,
43    /// Monotonic timestamp (usec since boot) of the most recent state change of the triggered service unit
44    pub service_unit_last_state_change_usec_monotonic: u64,
45}
46
47pub const TIMER_STATS_FIELD_NAMES: &[&str] = &TimerStats::FIELD_NAMES_AS_ARRAY;
48
49#[tracing::instrument(level = "debug", skip(connection))]
50pub async fn collect_timer_stats(
51    connection: &zbus::Connection,
52    unit: &crate::units::ListedUnit,
53) -> Result<TimerStats, MonitordTimerError> {
54    let mut timer_stats = TimerStats::default();
55
56    let pt = crate::dbus::zbus_timer::TimerProxy::builder(connection)
57        .cache_properties(zbus::proxy::CacheProperties::No)
58        .path(unit.unit_object_path.clone())?
59        .build()
60        .await?;
61    // Get service unit name to check when it last ran to ensure
62    // we are triggers the configured service with times set
63    let service_unit = pt.unit().await?;
64    let mut service_unit_last_state_change_usec: Result<u64, zbus::Error> = Ok(0);
65    let mut service_unit_last_state_change_usec_monotonic: Result<u64, zbus::Error> = Ok(0);
66    if service_unit.is_empty() {
67        error!("{}: No service unit name found for timer.", unit.name);
68    } else {
69        // Get the object path of the service unit
70        let mp = crate::dbus::zbus_systemd::ManagerProxy::builder(connection)
71            .cache_properties(zbus::proxy::CacheProperties::No)
72            .build()
73            .await?;
74        let service_unit_path = mp.get_unit(&service_unit).await?;
75        // Create a UnitProxy with the unit path to async get the two counters we want
76        let up = crate::dbus::zbus_unit::UnitProxy::builder(connection)
77            .cache_properties(zbus::proxy::CacheProperties::No)
78            .path(service_unit_path)?
79            .build()
80            .await?;
81
82        (
83            service_unit_last_state_change_usec,
84            service_unit_last_state_change_usec_monotonic,
85        ) = tokio::join!(
86            up.state_change_timestamp(),
87            up.state_change_timestamp_monotonic(),
88        );
89    }
90    timer_stats.service_unit_last_state_change_usec = service_unit_last_state_change_usec?;
91    timer_stats.service_unit_last_state_change_usec_monotonic =
92        service_unit_last_state_change_usec_monotonic?;
93
94    // Use tokio::join! without tokio::spawn to avoid per-task allocation overhead.
95    // These all share the same D-Bus connection so spawn adds no parallelism benefit.
96    let (
97        accuracy_usec,
98        fixed_random_delay,
99        last_trigger_usec,
100        last_trigger_usec_monotonic,
101        persistent,
102        next_elapse_usec_monotonic,
103        next_elapse_usec_realtime,
104        randomized_delay_usec,
105        remain_after_elapse,
106    ) = tokio::join!(
107        pt.accuracy_usec(),
108        pt.fixed_random_delay(),
109        pt.last_trigger_usec(),
110        pt.last_trigger_usec_monotonic(),
111        pt.persistent(),
112        pt.next_elapse_usec_monotonic(),
113        pt.next_elapse_usec_realtime(),
114        pt.randomized_delay_usec(),
115        pt.remain_after_elapse(),
116    );
117
118    timer_stats.accuracy_usec = accuracy_usec?;
119    timer_stats.fixed_random_delay = fixed_random_delay?;
120    timer_stats.last_trigger_usec = last_trigger_usec?;
121    timer_stats.last_trigger_usec_monotonic = last_trigger_usec_monotonic?;
122    timer_stats.persistent = persistent?;
123    timer_stats.next_elapse_usec_monotonic = next_elapse_usec_monotonic?;
124    timer_stats.next_elapse_usec_realtime = next_elapse_usec_realtime?;
125    timer_stats.randomized_delay_usec = randomized_delay_usec?;
126    timer_stats.remain_after_elapse = remain_after_elapse?;
127
128    Ok(timer_stats)
129}
130
131/// Merge D-Bus-collected timer stats into varlink-collected unit stats.
132///
133/// The varlink metrics API does not expose timer properties, so both the host
134/// (lib.rs) and container (machines.rs) varlink paths backfill them via
135/// `collect_all_timers_dbus` and merge the result here.
136///
137/// `elapsed_ms` is the wall time of that backfill. It is accounted the same way
138/// the D-Bus path accounts its own timer work — duration into `per_unit_loop_ms`
139/// and one `timer_dbus_fetches` per timer resolved — so the inner timings stay
140/// comparable between the two paths instead of under-reporting the varlink one.
141pub fn merge_timer_stats(
142    target: &mut crate::units::SystemdUnitStats,
143    collected: crate::units::SystemdUnitStats,
144    elapsed_ms: f64,
145) {
146    record_backfill_duration(target, elapsed_ms);
147    target.collection_timings.timer_dbus_fetches += collected.timer_stats.len() as u64;
148    target.timer_stats = collected.timer_stats;
149    target.timer_persistent_units = collected.timer_persistent_units;
150    target.timer_remain_after_elapse = collected.timer_remain_after_elapse;
151}
152
153/// Account a timer backfill that produced no stats.
154///
155/// A failed backfill still spent D-Bus time — a `dbus_timeout` worth of it, if
156/// that is how it failed — so the duration is recorded even though there is
157/// nothing to merge and no fetch to count. Leaving it out would hide the
158/// slowest case from `per_unit_loop_ms`.
159pub fn record_backfill_duration(target: &mut crate::units::SystemdUnitStats, elapsed_ms: f64) {
160    target.collection_timings.per_unit_loop_ms += elapsed_ms;
161}
162
163/// Collect all timer stats via D-Bus and return them ready to merge into unit stats.
164///
165/// Used when unit stats were collected via varlink (which doesn't yet expose timer
166/// properties) so that `timers.*`, `timer_persistent_units`, and
167/// `timer_remain_after_elapse` match the D-Bus output.
168pub async fn collect_all_timers_dbus(
169    connection: &zbus::Connection,
170    config: &crate::config::Config,
171) -> anyhow::Result<crate::units::SystemdUnitStats> {
172    use std::collections::HashMap;
173    use tracing::debug;
174
175    if !config.timers.enabled {
176        return Ok(crate::units::SystemdUnitStats::default());
177    }
178
179    let p = crate::dbus::zbus_systemd::ManagerProxy::builder(connection)
180        .cache_properties(zbus::proxy::CacheProperties::No)
181        .build()
182        .await?;
183    let units = p.list_units().await?;
184
185    let mut stats = crate::units::SystemdUnitStats::default();
186    let mut timer_stats_map = HashMap::new();
187
188    for unit_raw in units {
189        let unit: crate::units::ListedUnit = unit_raw.into();
190        if !unit
191            .name
192            .ends_with(crate::unit_constants::SYSTEMD_TIMER_SUFFIX)
193        {
194            continue;
195        }
196        if config.timers.blocklist.contains(&unit.name) {
197            debug!("Skipping timer stats for {} due to blocklist", &unit.name);
198            continue;
199        }
200        if !config.timers.allowlist.is_empty() && !config.timers.allowlist.contains(&unit.name) {
201            continue;
202        }
203        match collect_timer_stats(connection, &unit).await {
204            Ok(ts) => {
205                if ts.persistent {
206                    stats.timer_persistent_units += 1;
207                }
208                if ts.remain_after_elapse {
209                    stats.timer_remain_after_elapse += 1;
210                }
211                timer_stats_map.insert(unit.name.clone(), ts);
212            }
213            Err(err) => {
214                error!("Failed to get {} stats: {:#?}", &unit.name, err);
215            }
216        }
217    }
218
219    stats.timer_stats = timer_stats_map;
220    Ok(stats)
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn test_merge_timer_stats() {
229        let mut target = crate::units::SystemdUnitStats::default();
230        let mut collected = crate::units::SystemdUnitStats::default();
231        collected.timer_stats.insert(
232            "foo.timer".to_string(),
233            TimerStats {
234                persistent: true,
235                ..Default::default()
236            },
237        );
238        collected.timer_persistent_units = 1;
239        collected.timer_remain_after_elapse = 2;
240
241        merge_timer_stats(&mut target, collected, 12.5);
242
243        assert_eq!(target.timer_stats.len(), 1);
244        assert!(
245            target
246                .timer_stats
247                .get("foo.timer")
248                .expect("foo.timer should be merged")
249                .persistent
250        );
251        assert_eq!(target.timer_persistent_units, 1);
252        assert_eq!(target.timer_remain_after_elapse, 2);
253        // Untouched aggregates stay zero.
254        assert_eq!(target.total_units, 0);
255    }
256
257    #[test]
258    fn test_merge_timer_stats_accounts_the_backfill() {
259        // The varlink path has already recorded its own parse loop, so the
260        // backfill adds to per_unit_loop_ms rather than replacing it.
261        let mut target = crate::units::SystemdUnitStats::default();
262        target.collection_timings.per_unit_loop_ms = 2.5;
263        let mut collected = crate::units::SystemdUnitStats::default();
264        for name in ["foo.timer", "bar.timer"] {
265            collected
266                .timer_stats
267                .insert(name.to_string(), TimerStats::default());
268        }
269
270        merge_timer_stats(&mut target, collected, 10.0);
271
272        assert_eq!(target.collection_timings.per_unit_loop_ms, 12.5);
273        assert_eq!(target.collection_timings.timer_dbus_fetches, 2);
274    }
275
276    #[test]
277    fn test_record_backfill_duration_without_stats() {
278        // A backfill that failed — a dbus_timeout worth of wall time is the
279        // case worth seeing, so it is accounted with no fetch counted.
280        let mut target = crate::units::SystemdUnitStats::default();
281        target.collection_timings.per_unit_loop_ms = 1.5;
282
283        record_backfill_duration(&mut target, 5000.0);
284
285        assert_eq!(target.collection_timings.per_unit_loop_ms, 5001.5);
286        assert_eq!(target.collection_timings.timer_dbus_fetches, 0);
287        assert!(target.timer_stats.is_empty());
288    }
289
290    #[test]
291    fn test_merge_timer_stats_with_no_timers() {
292        // An empty allowlist result still costs a ListUnits call, so the time
293        // is accounted even though no timer was fetched.
294        let mut target = crate::units::SystemdUnitStats::default();
295
296        merge_timer_stats(&mut target, crate::units::SystemdUnitStats::default(), 3.0);
297
298        assert_eq!(target.collection_timings.per_unit_loop_ms, 3.0);
299        assert_eq!(target.collection_timings.timer_dbus_fetches, 0);
300    }
301
302    #[test]
303    fn test_only_timer_units_are_selected() {
304        // Both collection paths pick timers out of a list of every unit, so the
305        // match has to be on the suffix. `.contains(".timer")` also matched
306        // units that merely mention a timer in their name, which then failed a
307        // TimerProxy call and logged an error for a unit that was never a timer.
308        let selects = |name: &str| name.ends_with(crate::unit_constants::SYSTEMD_TIMER_SUFFIX);
309
310        assert!(selects("logrotate.timer"));
311        assert!(selects("systemd-tmpfiles-clean.timer"));
312
313        assert!(!selects("my.timer.service"));
314        assert!(!selects("foo.timers.service"));
315        assert!(!selects("timer.target"));
316        assert!(!selects("dbus-broker.service"));
317    }
318}