Skip to main content

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}