1use 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
27pub const SETTINGS_FILE: &str = "polling.toml";
29
30pub 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 #[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
57fn traffic_path(paths: &AppPaths) -> PathBuf {
59 paths.state_dir().join(TRAFFIC_FILE)
60}
61
62pub fn idle_interval(paths: &AppPaths) -> Result<IdlePollInterval, ServiceError> {
69 intervals(paths).map(|(idle, _)| idle)
70}
71
72pub 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
97pub 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
108pub 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 coordinated_interval_secs: (coordinated != CoordinatedPollInterval::default())
125 .then_some(coordinated.as_secs()),
126 },
127 )
128}
129
130#[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 pub idle_interval_secs: u16,
144 pub active_interval_secs: u16,
145 pub pace: String,
149 pub targets_idle: u32,
151 pub targets_total: u32,
152 pub throttled_because: Option<String>,
154 pub observed_minutes: u32,
155 pub full: u32,
157 pub not_modified: u32,
159 pub failed: u32,
161 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 pub demand_reads: u64,
169 pub demand_unchanged_reads: u64,
170 pub demand_shared_reads: u64,
172 pub demand_suppressed: u64,
174}
175
176impl PollTraffic {
177 #[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 #[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 #[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 #[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 #[must_use]
234 pub const fn is_throttled(&self) -> bool {
235 self.throttled_because.is_some()
236 }
237}
238
239pub fn record_traffic(paths: &AppPaths, traffic: &PollTraffic) -> Result<(), ServiceError> {
244 write_state_record(&traffic_path(paths), traffic)
245}
246
247pub 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 set_idle_interval(&paths, IdlePollInterval::default()).expect("written");
291 assert_eq!(coordinated_interval(&paths).expect("read"), coordinated);
292 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}