layover_core/stall.rs
1//! A chain that stopped and will not start again on its own.
2//!
3//! # Why this is written down rather than inferred
4//!
5//! Every other failure in the system leaves a mark somebody can point at: a failed run, a timeout,
6//! a help request. A stall leaves none. Every run in the chain *succeeded* — the tester reported,
7//! the reviewer reported — and then the publisher never woke, because the barrier it was waiting
8//! behind could no longer be completed.
9//!
10//! Read back from history, that chain is indistinguishable from one that finished. The runs all
11//! say `succeeded` and nothing says the work never happened. So the moment the Tower gives up on
12//! a rendezvous, it says so in a place that outlives the process, and the dashboard reads it.
13//!
14//! That is the whole reason this type exists: silent permanent stalling is the worst outcome in
15//! this system, and the only thing worse than a factory that stops is one that stops quietly.
16
17use jiff::Timestamp;
18use serde::{Deserialize, Serialize};
19
20use crate::agent::AgentName;
21use crate::flight::ItineraryId;
22
23/// A chain the Tower has given up on, and why.
24#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
25pub struct Stall {
26 /// The chain that will not continue.
27 pub itinerary: ItineraryId,
28 /// The agent that was waiting.
29 pub agent: AgentName,
30 /// Upstreams that never arrived and now never can.
31 pub missing: Vec<AgentName>,
32 /// How many flights were being held.
33 ///
34 /// Work somebody asked for that will not happen. A count rather than the flights themselves,
35 /// because the point is to prompt a person to look, not to replay it.
36 pub stranded: usize,
37 /// When the Tower gave up.
38 pub at: Timestamp,
39}
40
41impl Stall {
42 /// Records a chain that can no longer continue.
43 #[must_use]
44 pub fn new(
45 itinerary: ItineraryId,
46 agent: AgentName,
47 missing: Vec<AgentName>,
48 stranded: usize,
49 at: Timestamp,
50 ) -> Self {
51 Self {
52 itinerary,
53 agent,
54 missing,
55 stranded,
56 at,
57 }
58 }
59
60 /// One line saying what went wrong, written for a person reading a dashboard.
61 #[must_use]
62 pub fn summary(&self) -> String {
63 let missing = self
64 .missing
65 .iter()
66 .map(ToString::to_string)
67 .collect::<Vec<_>>()
68 .join(", ");
69
70 format!(
71 "`{}` never woke: nothing live could still deliver {missing}",
72 self.agent
73 )
74 }
75}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80
81 fn stall(missing: &[&str]) -> Stall {
82 Stall::new(
83 ItineraryId::generate(),
84 AgentName::new("publisher"),
85 missing.iter().map(|n| AgentName::new(*n)).collect(),
86 1,
87 Timestamp::now(),
88 )
89 }
90
91 #[test]
92 fn a_stall_names_the_agent_that_never_woke() {
93 // The dashboard shows this next to a chain whose runs all say `succeeded`, so it has to
94 // say what did *not* happen rather than what did.
95 let summary = stall(&["reviewer"]).summary();
96
97 assert!(summary.contains("publisher"), "{summary}");
98 assert!(summary.contains("never woke"), "{summary}");
99 assert!(summary.contains("reviewer"), "{summary}");
100 }
101
102 #[test]
103 fn every_missing_upstream_is_named() {
104 let summary = stall(&["reviewer", "tester"]).summary();
105
106 assert!(summary.contains("reviewer"), "{summary}");
107 assert!(summary.contains("tester"), "{summary}");
108 }
109
110 #[test]
111 fn a_stall_survives_a_round_trip_through_json() {
112 // It is written to disk precisely because it has to outlive the process that noticed it.
113 let original = stall(&["reviewer"]);
114 let text = serde_json::to_string(&original).expect("serialises");
115 let read: Stall = serde_json::from_str(&text).expect("deserialises");
116
117 assert_eq!(read, original);
118 }
119}