pitchfork_cli/proxy/
activity.rs1use std::collections::HashMap;
22use std::sync::Mutex;
23use std::time::{Duration, Instant};
24
25use crate::daemon_id::DaemonId;
26
27pub(crate) static ACTIVITY: once_cell::sync::Lazy<ActivityTracker> =
29 once_cell::sync::Lazy::new(ActivityTracker::default);
30
31#[derive(Debug)]
32struct Entry {
33 in_flight: usize,
35 last_activity: Instant,
38 idle_stopping: bool,
40}
41
42impl Entry {
43 fn new(now: Instant) -> Self {
44 Self {
45 in_flight: 0,
46 last_activity: now,
47 idle_stopping: false,
48 }
49 }
50}
51
52#[derive(Debug, Default)]
54pub(crate) struct ActivityTracker {
55 entries: Mutex<HashMap<DaemonId, Entry>>,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub(crate) struct ActivitySnapshot {
61 pub in_flight: usize,
62 pub idle_for: Duration,
63 pub idle_stopping: bool,
64}
65
66impl ActivityTracker {
67 fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<DaemonId, Entry>> {
68 self.entries.lock().unwrap_or_else(|e| e.into_inner())
70 }
71
72 pub(crate) fn begin(&'static self, id: &DaemonId) -> Option<ActivityGuard> {
78 let now = Instant::now();
79 let mut entries = self.lock();
80 let entry = entries.entry(id.clone()).or_insert_with(|| Entry::new(now));
81 if entry.idle_stopping {
82 return None;
83 }
84 entry.in_flight += 1;
85 entry.last_activity = now;
86 Some(ActivityGuard {
87 tracker: self,
88 id: id.clone(),
89 })
90 }
91
92 pub(crate) fn begin_all(&'static self, ids: &[DaemonId]) -> Option<Vec<ActivityGuard>> {
95 let mut guards = Vec::with_capacity(ids.len());
96 for id in ids {
97 guards.push(self.begin(id)?);
99 }
100 Some(guards)
101 }
102
103 fn end(&self, id: &DaemonId) {
104 let mut entries = self.lock();
105 if let Some(entry) = entries.get_mut(id) {
106 entry.in_flight = entry.in_flight.saturating_sub(1);
107 entry.last_activity = Instant::now();
108 }
109 }
110
111 pub(crate) fn snapshot(&self, id: &DaemonId) -> ActivitySnapshot {
117 let now = Instant::now();
118 let mut entries = self.lock();
119 let entry = entries.entry(id.clone()).or_insert_with(|| Entry::new(now));
120 ActivitySnapshot {
121 in_flight: entry.in_flight,
122 idle_for: now.saturating_duration_since(entry.last_activity),
123 idle_stopping: entry.idle_stopping,
124 }
125 }
126
127 pub(crate) fn claim_idle_stop(&self, id: &DaemonId, grace: Duration) -> bool {
134 let now = Instant::now();
135 let mut entries = self.lock();
136 let entry = entries.entry(id.clone()).or_insert_with(|| Entry::new(now));
137 if entry.idle_stopping
138 || entry.in_flight > 0
139 || now.saturating_duration_since(entry.last_activity) < grace
140 {
141 return false;
142 }
143 entry.idle_stopping = true;
144 true
145 }
146
147 pub(crate) fn release_idle_stop(&self, id: &DaemonId) {
152 let mut entries = self.lock();
153 if let Some(entry) = entries.get_mut(id) {
154 entry.idle_stopping = false;
155 entry.last_activity = Instant::now();
156 }
157 }
158
159 pub(crate) fn is_idle_stopping(&self, id: &DaemonId) -> bool {
161 self.lock().get(id).is_some_and(|e| e.idle_stopping)
162 }
163}
164
165#[must_use = "activity ends as soon as the guard is dropped"]
167pub(crate) struct ActivityGuard {
168 tracker: &'static ActivityTracker,
169 id: DaemonId,
170}
171
172impl std::fmt::Debug for ActivityGuard {
173 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174 f.debug_struct("ActivityGuard")
175 .field("id", &self.id)
176 .finish()
177 }
178}
179
180impl Drop for ActivityGuard {
181 fn drop(&mut self) {
182 self.tracker.end(&self.id);
183 }
184}
185
186pub(crate) struct GuardedBody<B> {
190 inner: B,
191 _guard: Option<ActivityGuard>,
192}
193
194impl<B> GuardedBody<B> {
195 pub(crate) fn new(inner: B, guard: Option<ActivityGuard>) -> Self {
196 Self {
197 inner,
198 _guard: guard,
199 }
200 }
201}
202
203impl<B> hyper::body::Body for GuardedBody<B>
204where
205 B: hyper::body::Body + Unpin,
206{
207 type Data = B::Data;
208 type Error = B::Error;
209
210 fn poll_frame(
211 mut self: std::pin::Pin<&mut Self>,
212 cx: &mut std::task::Context<'_>,
213 ) -> std::task::Poll<Option<Result<hyper::body::Frame<Self::Data>, Self::Error>>> {
214 std::pin::Pin::new(&mut self.inner).poll_frame(cx)
215 }
216
217 fn is_end_stream(&self) -> bool {
218 self.inner.is_end_stream()
219 }
220
221 fn size_hint(&self) -> hyper::body::SizeHint {
222 self.inner.size_hint()
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 fn tracker() -> &'static ActivityTracker {
231 Box::leak(Box::default())
232 }
233
234 fn id(name: &str) -> DaemonId {
235 DaemonId::new("proj", name)
236 }
237
238 #[test]
239 fn in_flight_work_blocks_an_idle_claim() {
240 let t = tracker();
241 let guard = t.begin(&id("api")).unwrap();
242 assert!(!t.claim_idle_stop(&id("api"), Duration::ZERO));
243 drop(guard);
244 assert!(t.claim_idle_stop(&id("api"), Duration::ZERO));
245 }
246
247 #[test]
248 fn recent_activity_blocks_an_idle_claim_until_the_grace_passes() {
249 let t = tracker();
250 drop(t.begin(&id("api")).unwrap());
251 assert!(!t.claim_idle_stop(&id("api"), Duration::from_secs(60)));
252 assert!(t.claim_idle_stop(&id("api"), Duration::ZERO));
253 }
254
255 #[test]
256 fn a_claimed_stop_refuses_new_work_until_released() {
257 let t = tracker();
258 assert!(t.claim_idle_stop(&id("api"), Duration::ZERO));
259 assert!(t.is_idle_stopping(&id("api")));
260 assert!(t.begin(&id("api")).is_none());
261 assert!(!t.claim_idle_stop(&id("api"), Duration::ZERO));
263 t.release_idle_stop(&id("api"));
264 assert!(!t.is_idle_stopping(&id("api")));
265 assert!(t.begin(&id("api")).is_some());
266 }
267
268 #[test]
269 fn begin_all_takes_nothing_when_one_daemon_is_stopping() {
270 let t = tracker();
271 assert!(t.claim_idle_stop(&id("db"), Duration::ZERO));
272 assert!(t.begin_all(&[id("api"), id("db")]).is_none());
273 assert_eq!(t.snapshot(&id("api")).in_flight, 0);
275 }
276
277 #[test]
278 fn a_daemon_first_seen_by_a_snapshot_starts_a_fresh_idle_clock() {
279 let t = tracker();
280 let snap = t.snapshot(&id("adopted"));
281 assert_eq!(snap.in_flight, 0);
282 assert!(snap.idle_for < Duration::from_secs(1));
283 assert!(!t.claim_idle_stop(&id("adopted"), Duration::from_secs(60)));
284 }
285
286 #[test]
287 fn releasing_a_claim_restarts_the_idle_clock() {
288 let t = tracker();
289 assert!(t.claim_idle_stop(&id("api"), Duration::ZERO));
290 t.release_idle_stop(&id("api"));
291 assert!(!t.claim_idle_stop(&id("api"), Duration::from_secs(60)));
292 }
293
294 #[test]
295 fn guarded_body_ends_activity_when_dropped() {
296 let t = tracker();
297 let body = GuardedBody::new(
298 http_body_util::Empty::<hyper::body::Bytes>::new(),
299 t.begin(&id("api")),
300 );
301 assert_eq!(t.snapshot(&id("api")).in_flight, 1);
302 drop(body);
303 assert_eq!(t.snapshot(&id("api")).in_flight, 0);
304 }
305}