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