liminal_server/health/unloadable.rs
1//! The operator read surface for conversations this node refused to load.
2//!
3//! Containment (`server/participant/production/handler.rs`) answers a
4//! conversation it cannot load by refusing that ONE conversation, naming it,
5//! and serving every other one. Naming is only half a surface while the record
6//! it writes has no reader: an operator deciding whether a node may be
7//! restarted has to ask the running node which conversations it is refusing,
8//! and the node has to be able to answer.
9//!
10//! This module is that answer's shape and its plumbing, and it is PULL-ONLY.
11//! The participant handler writes into [`UnloadableConversationRecord`] on the
12//! two paths that already record a refusal; the health endpoint reads a
13//! snapshot only when an operator scrapes it. Nothing here starts a thread,
14//! arms a timer, or samples anything, so the endpoint's zero-idle-wake
15//! property (W4 leg 2, LAW-1) is untouched: a node nobody scrapes does no work
16//! at all for this surface.
17
18use std::collections::BTreeMap;
19use std::sync::{Arc, Mutex, PoisonError};
20
21use liminal_protocol::wire::ConversationId;
22
23/// One conversation the node refused to load, as an operator reads it.
24#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
25pub struct UnloadableConversation {
26 /// Conversation whose durable state could not be loaded.
27 pub conversation_id: ConversationId,
28 /// Stable refusal class, so a consumer discriminates on a field instead of
29 /// on a substring of `reason` — the rendered text is a diagnostic and is
30 /// allowed to move.
31 pub class: &'static str,
32 /// The load failure's own text, exactly as the refusal carries it.
33 pub reason: String,
34}
35
36/// The body served by `GET /unloadable-conversations`.
37#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
38pub struct UnloadableConversationsStatus {
39 /// Whether a participant record is attached to this server at all.
40 ///
41 /// A node with no participant configured and a node whose participant has
42 /// refused nothing both report `count: 0`; this field is the only thing
43 /// separating them. Without it a zero reads as a clean node when it may
44 /// instead mean the surface is looking at nothing.
45 pub participant_installed: bool,
46 /// How many conversations are refused right now.
47 pub count: usize,
48 /// The refused conversations, in conversation-id order.
49 pub conversations: Vec<UnloadableConversation>,
50}
51
52/// The write side: the record a participant handler maintains.
53///
54/// Cloning shares the one record (an `Arc`), which is exactly what lets the
55/// handler keep writing while the health endpoint reads.
56#[derive(Clone, Debug, Default)]
57pub struct UnloadableConversationRecord {
58 entries: Arc<Mutex<BTreeMap<ConversationId, UnloadableConversation>>>,
59}
60
61impl UnloadableConversationRecord {
62 /// Retains one refusal, replacing any earlier refusal of the same
63 /// conversation.
64 ///
65 /// Answers `false` when the record's lock is poisoned and the refusal was
66 /// therefore NOT retained. The caller must report that: a refusal that was
67 /// reported but not retained is a weaker state than a retained one, and an
68 /// operator reading this surface must not be told a shorter story than the
69 /// log tells.
70 #[must_use]
71 pub fn record(&self, entry: UnloadableConversation) -> bool {
72 self.entries.lock().is_ok_and(|mut entries| {
73 entries.insert(entry.conversation_id, entry);
74 true
75 })
76 }
77
78 /// Retires a conversation's refusal once it has actually loaded, so the
79 /// surface never keeps reporting a conversation that recovered.
80 ///
81 /// Answers `false` when the lock is poisoned and the retirement therefore
82 /// did not happen.
83 #[must_use]
84 pub fn retire(&self, conversation_id: ConversationId) -> bool {
85 self.entries.lock().is_ok_and(|mut entries| {
86 entries.remove(&conversation_id);
87 true
88 })
89 }
90
91 /// Every refusal currently retained, in conversation-id order.
92 ///
93 /// A poisoned lock is read THROUGH ([`PoisonError::into_inner`]) rather
94 /// than answered with an empty set: the retained refusals are still the
95 /// node's true answer, and reporting "nothing is refused" because a
96 /// mutex was poisoned would be the surface's own false green. The write
97 /// side deliberately does not do this — a refusal it could not retain is
98 /// reported as not retained.
99 #[must_use]
100 pub fn snapshot(&self) -> Vec<UnloadableConversation> {
101 self.entries
102 .lock()
103 .unwrap_or_else(PoisonError::into_inner)
104 .values()
105 .cloned()
106 .collect()
107 }
108}
109
110/// The read side the health endpoint serves from.
111///
112/// The health server binds BEFORE the participant handler exists — liveness
113/// has to be answerable during startup — so the endpoint holds this slot from
114/// the moment it starts and the participant's record is published into it once
115/// built. Until that happens the surface says so through
116/// [`UnloadableConversationsStatus::participant_installed`] rather than
117/// reporting a zero that means nothing.
118#[derive(Clone, Debug, Default)]
119pub struct SharedUnloadableConversations {
120 record: Arc<Mutex<Option<UnloadableConversationRecord>>>,
121}
122
123impl SharedUnloadableConversations {
124 /// Publishes a participant's record into the surface, replacing any record
125 /// installed before it.
126 pub fn install(&self, record: UnloadableConversationRecord) {
127 *self.record.lock().unwrap_or_else(PoisonError::into_inner) = Some(record);
128 }
129
130 /// The surface's current answer.
131 #[must_use]
132 pub fn status(&self) -> UnloadableConversationsStatus {
133 let record = self
134 .record
135 .lock()
136 .unwrap_or_else(PoisonError::into_inner)
137 .clone();
138 record.map_or_else(
139 || UnloadableConversationsStatus {
140 participant_installed: false,
141 count: 0,
142 conversations: Vec::new(),
143 },
144 |record| {
145 let conversations = record.snapshot();
146 UnloadableConversationsStatus {
147 participant_installed: true,
148 count: conversations.len(),
149 conversations,
150 }
151 },
152 )
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::{
159 SharedUnloadableConversations, UnloadableConversation, UnloadableConversationRecord,
160 };
161
162 fn refusal(conversation_id: u64) -> UnloadableConversation {
163 UnloadableConversation {
164 conversation_id,
165 class: "internal",
166 reason: "expected value at line 1 column 1".to_owned(),
167 }
168 }
169
170 /// An uninstalled surface must not answer like a clean node: the zero it
171 /// reports is qualified by `participant_installed`.
172 #[test]
173 fn an_uninstalled_surface_reports_that_it_is_looking_at_nothing() {
174 let status = SharedUnloadableConversations::default().status();
175
176 assert!(!status.participant_installed);
177 assert_eq!(status.count, 0);
178 assert!(status.conversations.is_empty());
179 }
180
181 /// An installed record with nothing refused is the OTHER zero, and the two
182 /// are distinguishable — which is the whole reason the flag exists.
183 #[test]
184 fn an_installed_but_empty_record_is_a_different_zero() {
185 let surface = SharedUnloadableConversations::default();
186 surface.install(UnloadableConversationRecord::default());
187
188 let status = surface.status();
189
190 assert!(status.participant_installed);
191 assert_eq!(status.count, 0);
192 }
193
194 /// The surface reads the LIVE record: a refusal recorded after installation
195 /// is reported, and a retirement removes it again.
196 #[test]
197 fn the_surface_follows_the_record_it_was_given() {
198 let record = UnloadableConversationRecord::default();
199 let surface = SharedUnloadableConversations::default();
200 surface.install(record.clone());
201
202 assert!(record.record(refusal(7_201)));
203 assert!(record.record(refusal(7_100)));
204
205 let status = surface.status();
206 assert!(status.participant_installed);
207 assert_eq!(status.count, 2);
208 // Conversation-id order, not insertion order.
209 assert_eq!(
210 status
211 .conversations
212 .iter()
213 .map(|entry| entry.conversation_id)
214 .collect::<Vec<_>>(),
215 vec![7_100, 7_201]
216 );
217 assert_eq!(status.conversations[1].class, "internal");
218
219 assert!(record.retire(7_100));
220 let status = surface.status();
221 assert_eq!(status.count, 1);
222 assert_eq!(status.conversations[0].conversation_id, 7_201);
223 }
224}