Skip to main content

runner_manager_platform/
polling.rs

1//! The idle poll interval, and what the daemon measured its polling cost.
2//!
3//! Two small records, one in each direction:
4//!
5//! * **`config/polling.toml`** holds the idle poll interval
6//!   (`host set-poll-interval --idle`) and the coordinated one (`--coordinated`,
7//!   used while a coordinator's sweep covers a target). The *active* interval is the existing
8//!   `hosts.refresh_interval_secs` column. The idle one lives in a file rather
9//!   than a new column on purpose: a schema step makes the database refuse to
10//!   open under an older build, and a polling preference is not worth making a
11//!   downgrade impossible on every host it reaches. An absent file is the
12//!   default.
13//! * **`state/poll-traffic.toml`** is written by the daemon and read by
14//!   `status`, `host show` and `host doctor`. They run in the operator's
15//!   terminal and the daemon runs in a service, so the measurement has to be
16//!   on disk, like `github-contact.toml` beside it.
17
18use std::path::PathBuf;
19
20use chrono::{DateTime, Utc};
21use runner_manager_domain::model::{CoordinatedPollInterval, IdlePollInterval};
22use serde::{Deserialize, Serialize};
23
24use crate::paths::AppPaths;
25use crate::service::{ServiceError, read_state_record, write_state_record};
26
27/// The idle-interval setting, inside `config/`.
28pub const SETTINGS_FILE: &str = "polling.toml";
29
30/// The daemon's measurement, inside `state/`.
31pub const TRAFFIC_FILE: &str = "poll-traffic.toml";
32
33const SETTINGS_SCHEMA_VERSION: u32 = 1;
34const TRAFFIC_SCHEMA_VERSION: u32 = 1;
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37struct Settings {
38    schema_version: u32,
39    idle_interval_secs: u16,
40    /// Absent from a file an older build wrote: the default.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    coordinated_interval_secs: Option<u16>,
43}
44
45fn record_error(path: PathBuf, detail: String) -> ServiceError {
46    ServiceError::Record {
47        operation: "read",
48        path,
49        detail,
50    }
51}
52
53fn settings_path(paths: &AppPaths) -> PathBuf {
54    paths.config_dir().join(SETTINGS_FILE)
55}
56
57/// Where the measurement lives.
58fn traffic_path(paths: &AppPaths) -> PathBuf {
59    paths.state_dir().join(TRAFFIC_FILE)
60}
61
62/// The configured idle interval, or the default when none was set.
63///
64/// # Errors
65/// [`ServiceError::Record`] when the file exists and cannot be read, parsed,
66/// or holds a value under [`IdlePollInterval::MIN_SECS`]. A hand-edited `1`
67/// is refused rather than obeyed, for the reason the floor exists.
68pub fn idle_interval(paths: &AppPaths) -> Result<IdlePollInterval, ServiceError> {
69    intervals(paths).map(|(idle, _)| idle)
70}
71
72/// Both intervals the file holds.
73///
74/// # Errors
75/// As [`idle_interval`].
76pub fn intervals(
77    paths: &AppPaths,
78) -> Result<(IdlePollInterval, CoordinatedPollInterval), ServiceError> {
79    let path = settings_path(paths);
80    let Some(settings) = read_state_record::<Settings>(&path)? else {
81        return Ok((
82            IdlePollInterval::default(),
83            CoordinatedPollInterval::default(),
84        ));
85    };
86    let idle = IdlePollInterval::from_secs(settings.idle_interval_secs)
87        .map_err(|error| record_error(path.clone(), error.to_string()))?;
88    let coordinated = settings
89        .coordinated_interval_secs
90        .map_or(Ok(CoordinatedPollInterval::default()), |secs| {
91            CoordinatedPollInterval::from_secs(secs)
92        })
93        .map_err(|error| record_error(path, error.to_string()))?;
94    Ok((idle, coordinated))
95}
96
97/// Store the idle interval, keeping the coordinated one.
98///
99/// # Errors
100/// [`ServiceError::Record`] when `config/` cannot be written.
101pub fn set_idle_interval(paths: &AppPaths, idle: IdlePollInterval) -> Result<(), ServiceError> {
102    let coordinated = intervals(paths)
103        .map(|(_, coordinated)| coordinated)
104        .unwrap_or_default();
105    set_intervals(paths, idle, coordinated)
106}
107
108/// Store both intervals.
109///
110/// # Errors
111/// [`ServiceError::Record`] when `config/` cannot be written.
112pub fn set_intervals(
113    paths: &AppPaths,
114    idle: IdlePollInterval,
115    coordinated: CoordinatedPollInterval,
116) -> Result<(), ServiceError> {
117    write_state_record(
118        &settings_path(paths),
119        &Settings {
120            schema_version: SETTINGS_SCHEMA_VERSION,
121            idle_interval_secs: idle.as_secs(),
122            // Written only when it differs from the default, so a file an
123            // older build reads back is the file it wrote.
124            coordinated_interval_secs: (coordinated != CoordinatedPollInterval::default())
125                .then_some(coordinated.as_secs()),
126        },
127    )
128}
129
130/// What one daemon measured about its own polling, host-wide.
131///
132/// Every count covers the last hour at most; [`Self::observed_minutes`] says
133/// how much of it. The rate-limit fields are the **account's** quota as GitHub
134/// last reported it, which every host signed in as the same user shares.
135// `default` on every field: a CLI newer or older than the service's private
136// copy of the binary must still read what the service wrote.
137#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
138#[serde(default)]
139pub struct PollTraffic {
140    pub schema_version: u32,
141    pub written_at: DateTime<Utc>,
142    /// The intervals the daemon is polling at.
143    pub idle_interval_secs: u16,
144    pub active_interval_secs: u16,
145    /// The most severe pace any target loop is running at: `idle`,
146    /// `nominal`, or a slowdown (`stretched_low_budget`,
147    /// `rate_limited_secondary`, …).
148    pub pace: String,
149    /// Targets polling at the idle interval, and at any other pace.
150    pub targets_idle: u32,
151    pub targets_total: u32,
152    /// Why polling is slower than configured, when it is.
153    pub throttled_because: Option<String>,
154    pub observed_minutes: u32,
155    /// `2xx` responses: charged against the primary limit.
156    pub full: u32,
157    /// `304` responses: free against the primary limit.
158    pub not_modified: u32,
159    /// Error responses: charged, not data.
160    pub failed: u32,
161    /// This host's busiest minute, every response counted.
162    pub peak_requests_per_minute: u32,
163    pub rate_limit_limit: Option<u64>,
164    pub rate_limit_remaining: Option<u64>,
165    pub rate_limit_used: Option<u64>,
166    pub rate_limit_reset: Option<DateTime<Utc>>,
167    /// Repository readings fetched, and how many of those were all `304`.
168    pub demand_reads: u64,
169    pub demand_unchanged_reads: u64,
170    /// Readings handed to a second target instead of being fetched again.
171    pub demand_shared_reads: u64,
172    /// Polls withheld inside a rate-limit quiet period.
173    pub demand_suppressed: u64,
174}
175
176impl PollTraffic {
177    /// A record carrying the current schema version; the caller fills the
178    /// rest.
179    #[must_use]
180    pub fn new(written_at: DateTime<Utc>) -> Self {
181        Self {
182            schema_version: TRAFFIC_SCHEMA_VERSION,
183            written_at,
184            idle_interval_secs: IdlePollInterval::DEFAULT_SECS,
185            active_interval_secs: 0,
186            pace: String::from("nominal"),
187            targets_idle: 0,
188            targets_total: 0,
189            throttled_because: None,
190            observed_minutes: 0,
191            full: 0,
192            not_modified: 0,
193            failed: 0,
194            peak_requests_per_minute: 0,
195            rate_limit_limit: None,
196            rate_limit_remaining: None,
197            rate_limit_used: None,
198            rate_limit_reset: None,
199            demand_reads: 0,
200            demand_unchanged_reads: 0,
201            demand_shared_reads: 0,
202            demand_suppressed: 0,
203        }
204    }
205
206    /// Every response counted.
207    #[must_use]
208    pub const fn total(&self) -> u32 {
209        self.full
210            .saturating_add(self.not_modified)
211            .saturating_add(self.failed)
212    }
213
214    /// `count`, scaled from the observed minutes to an hour.
215    #[must_use]
216    pub fn per_hour(&self, count: u32) -> u32 {
217        if self.observed_minutes == 0 {
218            return 0;
219        }
220        u32::try_from(u64::from(count) * 60 / u64::from(self.observed_minutes)).unwrap_or(u32::MAX)
221    }
222
223    /// The share of responses that were `304`, in whole percent.
224    #[must_use]
225    pub fn not_modified_percent(&self) -> Option<u32> {
226        let total = self.total();
227        (total > 0).then(|| {
228            u32::try_from(u64::from(self.not_modified) * 100 / u64::from(total)).unwrap_or(100)
229        })
230    }
231
232    /// Whether polling is slower than configured.
233    #[must_use]
234    pub const fn is_throttled(&self) -> bool {
235        self.throttled_because.is_some()
236    }
237}
238
239/// Write the measurement.
240///
241/// # Errors
242/// [`ServiceError::Record`] when `state/` cannot be written.
243pub fn record_traffic(paths: &AppPaths, traffic: &PollTraffic) -> Result<(), ServiceError> {
244    write_state_record(&traffic_path(paths), traffic)
245}
246
247/// The daemon's last measurement, or `None` when no daemon has written one.
248///
249/// # Errors
250/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
251pub fn last_traffic(paths: &AppPaths) -> Result<Option<PollTraffic>, ServiceError> {
252    read_state_record(&traffic_path(paths))
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    fn paths() -> (tempfile::TempDir, AppPaths) {
260        let dir = tempfile::tempdir().expect("a temporary directory");
261        let paths = AppPaths::rooted_at(dir.path());
262        (dir, paths)
263    }
264
265    #[test]
266    fn an_absent_setting_is_the_default_and_a_stored_one_round_trips() {
267        let (_dir, paths) = paths();
268        assert_eq!(
269            idle_interval(&paths).expect("a default"),
270            IdlePollInterval::default()
271        );
272        let five = IdlePollInterval::from_secs(5).expect("the floor");
273        set_idle_interval(&paths, five).expect("written");
274        assert_eq!(idle_interval(&paths).expect("read back"), five);
275    }
276
277    #[test]
278    fn the_coordinated_interval_round_trips_beside_the_idle_one() {
279        let (_dir, paths) = paths();
280        let coordinated_interval = |paths: &AppPaths| intervals(paths).map(|(_, c)| c);
281        assert_eq!(
282            coordinated_interval(&paths).expect("a default"),
283            CoordinatedPollInterval::default()
284        );
285        let idle = IdlePollInterval::from_secs(7).expect("valid");
286        let coordinated = CoordinatedPollInterval::from_secs(600).expect("valid");
287        set_intervals(&paths, idle, coordinated).expect("written");
288        assert_eq!(intervals(&paths).expect("read"), (idle, coordinated));
289        // Changing the idle one keeps the coordinated one.
290        set_idle_interval(&paths, IdlePollInterval::default()).expect("written");
291        assert_eq!(coordinated_interval(&paths).expect("read"), coordinated);
292        // A file an older build wrote has no coordinated value: the default.
293        std::fs::write(
294            settings_path(&paths),
295            "schema_version = 1\nidle_interval_secs = 10\n",
296        )
297        .expect("written");
298        assert_eq!(
299            coordinated_interval(&paths).expect("read"),
300            CoordinatedPollInterval::default()
301        );
302        std::fs::write(
303            settings_path(&paths),
304            "schema_version = 1\nidle_interval_secs = 10\ncoordinated_interval_secs = 30\n",
305        )
306        .expect("written");
307        assert!(coordinated_interval(&paths).is_err(), "under the floor");
308    }
309
310    #[test]
311    fn a_hand_edited_value_under_the_floor_is_refused() {
312        let (_dir, paths) = paths();
313        std::fs::create_dir_all(paths.config_dir()).expect("config dir");
314        std::fs::write(
315            settings_path(&paths),
316            "schema_version = 1\nidle_interval_secs = 1\n",
317        )
318        .expect("written");
319        assert!(idle_interval(&paths).is_err());
320    }
321
322    #[test]
323    fn the_measurement_round_trips() {
324        let (_dir, paths) = paths();
325        assert_eq!(last_traffic(&paths).expect("nothing yet"), None);
326        let mut traffic = PollTraffic::new(Utc::now());
327        traffic.full = 3;
328        traffic.not_modified = 9;
329        traffic.observed_minutes = 30;
330        traffic.rate_limit_remaining = Some(4_000);
331        record_traffic(&paths, &traffic).expect("written");
332        let read = last_traffic(&paths).expect("read").expect("present");
333        assert_eq!(read, traffic);
334        assert_eq!(read.not_modified_percent(), Some(75));
335        assert_eq!(read.per_hour(read.full), 6);
336    }
337}