1use 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
26pub const SETTINGS_FILE: &str = "polling.toml";
28
29pub 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
45fn traffic_path(paths: &AppPaths) -> PathBuf {
47 paths.state_dir().join(TRAFFIC_FILE)
48}
49
50pub 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
68pub 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#[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 pub idle_interval_secs: u16,
96 pub active_interval_secs: u16,
97 pub pace: String,
101 pub targets_idle: u32,
103 pub targets_total: u32,
104 pub throttled_because: Option<String>,
106 pub observed_minutes: u32,
107 pub full: u32,
109 pub not_modified: u32,
111 pub failed: u32,
113 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 pub demand_reads: u64,
121 pub demand_unchanged_reads: u64,
122 pub demand_shared_reads: u64,
124 pub demand_suppressed: u64,
126}
127
128impl PollTraffic {
129 #[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 #[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 #[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 #[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 #[must_use]
186 pub const fn is_throttled(&self) -> bool {
187 self.throttled_because.is_some()
188 }
189}
190
191pub fn record_traffic(paths: &AppPaths, traffic: &PollTraffic) -> Result<(), ServiceError> {
196 write_state_record(&traffic_path(paths), traffic)
197}
198
199pub 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}