1use planarian_schema::{
7 DesiredStatus, EscalationReason, Incident, IncidentId, IncidentKind, IncidentState, Millis, Outcome, SystemState,
8 Timestamp, TxnId, UnitDefs, UnitId, UnitStatus,
9};
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12
13pub const BUDGET: u8 = 3;
14pub const BACKOFF: [Millis; 3] = [Millis::secs(10), Millis::secs(60), Millis::secs(300)];
15pub const STABILIZATION: Millis = Millis::secs(5 * 60);
16pub const REGRESSION_WINDOW: Millis = Millis::secs(30 * 60);
17pub const ESCALATE_AT: u8 = 3;
18pub const COOLDOWN_FIRST: Millis = Millis::secs(3600);
19pub const COOLDOWN_LATER: Millis = Millis::secs(6 * 3600);
20
21pub fn escalation_cooldown(reason: EscalationReason) -> Millis {
31 match reason {
32 EscalationReason::Regressed => COOLDOWN_FIRST,
33 EscalationReason::RevertBlockedByFloor => COOLDOWN_LATER,
34 EscalationReason::BudgetExhausted | EscalationReason::DiagnoserExhausted | EscalationReason::RecoveryFailed => {
35 COOLDOWN_FIRST
36 }
37 }
38}
39pub const SETTLE: Millis = Millis::secs(15);
40pub const OPEN_AFTER: u8 = 2;
42pub const UNKNOWN_LIMIT: u8 = 3;
44pub const FAILURE_WINDOW: Millis = Millis::secs(10 * 60);
46
47pub fn divergent(desired: DesiredStatus, status: &UnitStatus) -> bool {
49 use UnitStatus as S;
50 match desired {
51 DesiredStatus::Running => {
52 matches!(status, S::Failed(_) | S::Degraded(_) | S::Stopped | S::Absent | S::Detected)
53 }
54 DesiredStatus::Bound => matches!(status, S::Detected | S::Absent | S::Failed(_)),
55 DesiredStatus::Stopped => matches!(status, S::Failed(_)),
56 DesiredStatus::Absent => false,
57 }
58}
59
60pub fn satisfied(desired: DesiredStatus, status: &UnitStatus) -> bool {
62 use UnitStatus as S;
63 match desired {
64 DesiredStatus::Running => matches!(status, S::Running),
65 DesiredStatus::Bound => matches!(status, S::Bound | S::Running),
66 DesiredStatus::Stopped => matches!(status, S::Stopped | S::Absent),
67 DesiredStatus::Absent => matches!(status, S::Absent),
68 }
69}
70
71#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
75struct Track {
76 divergent_ticks: u8,
77 unknown_ticks: u8,
78 satisfied_since: Option<Timestamp>,
79 last_closed: Option<(Timestamp, u8)>,
81 failures: Vec<Timestamp>,
83 was_divergent: bool,
84 up_since: Option<Timestamp>,
87 in_grace: bool,
89}
90
91#[derive(Clone, Debug)]
92pub struct Manager {
93 pub incidents: BTreeMap<IncidentId, Incident>,
94 tracks: BTreeMap<UnitId, Track>,
95 next_id: u64,
96 pub startup_grace: Millis,
99}
100
101pub const STARTUP_GRACE: Millis = Millis::secs(90);
103
104impl Default for Manager {
105 fn default() -> Manager {
106 Manager::restore([])
107 }
108}
109
110impl Manager {
111 pub fn restore(incidents: impl IntoIterator<Item = Incident>) -> Manager {
113 let incidents: BTreeMap<IncidentId, Incident> = incidents.into_iter().map(|i| (i.id, i)).collect();
114 let next_id = incidents.keys().map(|i| i.0).max().unwrap_or(0);
115 Manager { incidents, tracks: BTreeMap::new(), next_id, startup_grace: STARTUP_GRACE }
116 }
117
118 pub fn started(&mut self, unit: &UnitId, now: Timestamp) {
121 self.tracks.entry(unit.clone()).or_default().up_since = Some(now);
122 }
123
124 pub fn active_for(&self, unit: &UnitId) -> Option<&Incident> {
125 self.incidents.values().find(|i| &i.target == unit && i.state != IncidentState::Closed)
126 }
127
128 fn active_id(&self, unit: &UnitId) -> Option<IncidentId> {
129 self.active_for(unit).map(|i| i.id)
130 }
131
132 fn close(&mut self, id: IncidentId, now: Timestamp, changed: &mut Vec<Incident>) {
133 if let Some(mut i) = self.incidents.remove(&id) {
134 if i.kind == IncidentKind::Repair {
135 self.tracks.entry(i.target.clone()).or_default().last_closed = Some((now, i.regressions));
136 }
137 i.state = IncidentState::Closed;
138 changed.push(i);
139 }
140 }
141
142 pub fn tick(&mut self, now: Timestamp, state: &SystemState, defs: &UnitDefs) -> Vec<Incident> {
144 self.tick_at(now, now, state, defs)
145 }
146
147 pub fn tick_at(
153 &mut self,
154 now: Timestamp,
155 decided: Timestamp,
156 state: &SystemState,
157 defs: &UnitDefs,
158 ) -> Vec<Incident> {
159 let mut changed = vec![];
160
161 let gone: Vec<IncidentId> =
163 self.incidents.values().filter(|i| !defs.contains_key(&i.target)).map(|i| i.id).collect();
164 for id in gone {
165 self.close(id, now, &mut changed);
166 }
167
168 for i in self.incidents.values_mut() {
170 match i.state {
171 IncidentState::Cooldown { until } | IncidentState::Escalated { until, .. } if now >= until => {
172 i.state = IncidentState::Open;
173 i.budget_left = 1;
174 i.next_attempt = now;
175 changed.push(i.clone());
176 }
177 _ => {}
178 }
179 }
180
181 for (id, def) in defs {
182 let status = state.status(id, defs);
183 let desired = def.desired.status;
184 let active = self.active_id(id);
185
186 if matches!(status, UnitStatus::Overridden(_) | UnitStatus::NotExpected) {
187 if let Some(a) = active {
190 self.close(a, now, &mut changed);
191 }
192 let t = self.tracks.entry(id.clone()).or_default();
193 (t.divergent_ticks, t.unknown_ticks, t.satisfied_since, t.was_divergent) = (0, 0, None, false);
194 continue;
195 }
196
197 if status == UnitStatus::Unknown {
198 let t = self.tracks.entry(id.clone()).or_default();
199 t.unknown_ticks = t.unknown_ticks.saturating_add(1);
200 t.divergent_ticks = 0;
201 t.satisfied_since = None;
202 if t.unknown_ticks >= UNKNOWN_LIMIT && active.is_none() {
203 let inc = self.open(id, IncidentKind::Diagnostic, (desired, status), now, now, 0);
204 changed.push(inc);
205 }
206 continue;
207 }
208
209 {
212 let t = self.tracks.entry(id.clone()).or_default();
213 if matches!(status, UnitStatus::Running | UnitStatus::Degraded(_)) {
214 t.up_since.get_or_insert(now);
215 } else {
216 t.up_since = None;
217 }
218 let grace = def.startup_grace.unwrap_or(self.startup_grace);
219 t.in_grace = matches!(status, UnitStatus::Degraded(_))
220 && t.up_since.is_some_and(|since| now.0.saturating_sub(since.0) < grace.0);
221 if t.in_grace {
222 (t.divergent_ticks, t.unknown_ticks, t.satisfied_since) = (0, 0, None);
223 continue;
224 }
225 }
226
227 if divergent(desired, &status) {
228 let t = self.tracks.entry(id.clone()).or_default();
229 t.unknown_ticks = 0;
230 t.satisfied_since = None;
231 t.divergent_ticks = t.divergent_ticks.saturating_add(1);
232 if !t.was_divergent {
233 t.was_divergent = true;
234 t.failures.push(now);
235 }
236 t.failures.retain(|f| now.0.saturating_sub(f.0) <= FAILURE_WINDOW.0);
237 let ready = t.divergent_ticks >= OPEN_AFTER;
238 let recent_failures = t.failures.len() as u32;
239 let last_closed = t.last_closed;
240
241 if let Some(a) = active {
242 let inc = self.incidents.get_mut(&a).expect("active");
243 if inc.kind == IncidentKind::Diagnostic {
244 inc.kind = IncidentKind::Repair;
246 inc.budget_left = BUDGET;
247 inc.next_attempt = now;
248 }
249 if inc.divergence.1 != status {
250 inc.divergence = (desired, status.clone());
251 changed.push(inc.clone());
252 }
253 continue;
254 }
255 if !ready {
256 continue;
257 }
258 let next_attempt = match &def.systemd_restart {
260 Some(p) if p.restart && recent_failures <= p.start_limit_burst => {
261 now.plus(Millis(p.restart_sec.0 + SETTLE.0))
262 }
263 _ => now,
264 };
265 let regressions = match last_closed {
266 Some((closed, n)) if now.0.saturating_sub(closed.0) <= REGRESSION_WINDOW.0 => n + 1,
267 _ => 0,
268 };
269 let mut inc = self.open(id, IncidentKind::Repair, (desired, status), now, next_attempt, regressions);
270 if regressions >= ESCALATE_AT {
271 let reason = EscalationReason::Regressed;
272 inc.state = IncidentState::Escalated { until: decided.plus(escalation_cooldown(reason)), reason };
273 } else if regressions > 0 {
274 inc.state = IncidentState::Regressed { count: regressions };
275 }
276 self.incidents.insert(inc.id, inc.clone());
277 changed.push(inc);
278 continue;
279 }
280
281 let t = self.tracks.entry(id.clone()).or_default();
282 t.divergent_ticks = 0;
283 t.unknown_ticks = 0;
284 t.was_divergent = false;
285 if satisfied(desired, &status) {
286 let since = *t.satisfied_since.get_or_insert(now);
287 if let Some(a) = active {
288 let kind = self.incidents[&a].kind;
289 if kind == IncidentKind::Diagnostic || now.0.saturating_sub(since.0) >= STABILIZATION.0 {
290 self.close(a, now, &mut changed);
291 }
292 }
293 } else {
294 t.satisfied_since = None;
295 }
296 }
297 changed
298 }
299
300 fn open(
301 &mut self,
302 target: &UnitId,
303 kind: IncidentKind,
304 divergence: (DesiredStatus, UnitStatus),
305 now: Timestamp,
306 next_attempt: Timestamp,
307 regressions: u8,
308 ) -> Incident {
309 self.next_id += 1;
310 let inc = Incident {
311 id: IncidentId(self.next_id),
312 kind,
313 target: target.clone(),
314 divergence,
315 opened: now,
316 attempts: vec![],
317 budget_left: if kind == IncidentKind::Repair { BUDGET } else { 0 },
318 state: IncidentState::Open,
319 backoff: BACKOFF[0],
320 next_attempt,
321 regressions,
322 };
323 self.incidents.insert(inc.id, inc.clone());
324 inc
325 }
326
327 pub fn actionable(&self, now: Timestamp) -> Vec<&Incident> {
331 self.incidents
332 .values()
333 .filter(|i| {
334 i.kind == IncidentKind::Repair
335 && matches!(i.state, IncidentState::Open | IncidentState::Regressed { .. })
336 && i.budget_left > 0
337 && now >= i.next_attempt
338 && self.tracks.get(&i.target).is_none_or(|t| t.satisfied_since.is_none() && !t.in_grace)
339 })
340 .collect()
341 }
342
343 pub fn diagnostic(&self) -> Vec<&Incident> {
345 self.incidents.values().filter(|i| i.kind == IncidentKind::Diagnostic).collect()
346 }
347
348 pub fn record_attempt(&mut self, id: IncidentId, txn: TxnId, outcome: Outcome, now: Timestamp) -> Option<Incident> {
351 let i = self.incidents.get_mut(&id)?;
352 i.attempts.push((txn, outcome));
353 i.budget_left = i.budget_left.saturating_sub(1);
354 let step = (BUDGET - i.budget_left - 1) as usize + i.regressions as usize;
355 i.backoff = BACKOFF[step.min(BACKOFF.len() - 1)];
356 i.next_attempt = now.plus(i.backoff);
357 if i.budget_left == 0 {
358 Self::cool(i, now);
359 }
360 Some(i.clone())
361 }
362
363 fn cool(i: &mut Incident, now: Timestamp) {
364 let cooled_before = i.attempts.len() > BUDGET as usize || i.backoff.0 >= COOLDOWN_FIRST.0;
368 let d = if cooled_before { COOLDOWN_LATER } else { COOLDOWN_FIRST };
369 i.backoff = d;
370 i.state = IncidentState::Cooldown { until: now.plus(d) };
371 }
372
373 pub fn exhausted(&mut self, id: IncidentId, now: Timestamp) -> Option<Incident> {
376 let i = self.incidents.get_mut(&id)?;
377 Self::cool(i, now);
378 Some(i.clone())
379 }
380
381 pub fn escalate(&mut self, id: IncidentId, reason: EscalationReason, now: Timestamp) -> Option<Incident> {
384 let i = self.incidents.get_mut(&id)?;
385 i.state = IncidentState::Escalated { until: now.plus(escalation_cooldown(reason)), reason };
386 Some(i.clone())
387 }
388
389 pub fn human_acted(&mut self, target: &UnitId, now: Timestamp) -> Option<Incident> {
391 let i = self.incidents.values_mut().find(|i| &i.target == target)?;
392 i.state = IncidentState::Open;
393 i.budget_left = i.budget_left.max(1);
394 i.next_attempt = now;
395 Some(i.clone())
396 }
397}
398
399#[cfg(test)]
400mod tests;