hyperactor_mesh/supervision.rs
1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! Messages used in supervision of actor meshes.
10//!
11//! ## Mesh-name propagation
12//!
13//! When a `MeshFailure` is constructed for a supervision event
14//! whose constructing site has the mesh name locally in scope, the
15//! mesh name is carried on `MeshFailure.actor_mesh_name`. The
16//! constructing site does not perform a lookup to obtain the mesh
17//! name; if the mesh name is not locally available at the site,
18//! `None` is correct. `MeshFailure::Display` surfaces the mesh
19//! name as an `on mesh "{name}"` segment when `actor_mesh_name`
20//! is populated; stable identifiers continue to appear in detail
21//! segments where the renderer already includes them.
22//! Python-binding-specific plumbing for this carrier — how a
23//! Python-spawned actor ends up with a mesh base-name string to
24//! supply — lives in `monarch_hyperactor/src/actor.rs`
25//! (`PythonActorParams.mesh_base_name`).
26
27use hyperactor::actor::ActorErrorKind;
28use hyperactor::actor::ActorStatus;
29use hyperactor::context;
30use hyperactor::supervision::ActorSupervisionEvent;
31use serde::Deserialize;
32use serde::Serialize;
33use typeuri::Named;
34
35/// Message about a supervision failure on a mesh of actors instead of a single
36/// actor.
37#[derive(Clone, Debug, Serialize, Deserialize, Named, PartialEq)]
38pub struct MeshFailure {
39 /// Mesh name carried by the `MeshFailure` construction site,
40 /// when locally available. On the direct actor-handled path
41 /// this is the observing PythonActor's mesh base name. On
42 /// controller-owned paths this is the monitored mesh name
43 /// supplied by the controller path.
44 pub actor_mesh_name: Option<String>,
45 /// The supervision event on an actor located at mesh + rank.
46 pub event: ActorSupervisionEvent,
47 /// The set of crashed ranks in the mesh. Empty means the event
48 /// applies to the whole mesh (e.g. mesh stop, controller timeout).
49 pub crashed_ranks: Vec<usize>,
50}
51wirevalue::register_type!(MeshFailure);
52
53impl MeshFailure {
54 /// Returns true if the given rank is part of this failure.
55 /// A whole-mesh event (empty crashed_ranks) contains every rank.
56 pub fn contains_rank(&self, rank: usize) -> bool {
57 self.crashed_ranks.is_empty() || self.crashed_ranks.contains(&rank)
58 }
59
60 /// Helper function to handle a message to an actor that just wants to forward
61 /// it to the next owner.
62 pub fn default_handler(&self, cx: &impl context::Actor) -> Result<(), anyhow::Error> {
63 // If an actor spawned by this one fails, we can't handle it. We fail
64 // ourselves with a chained error and bubble up to the next owner.
65 let err = ActorErrorKind::UnhandledSupervisionEvent(Box::new(ActorSupervisionEvent::new(
66 cx.instance().self_addr().clone(),
67 None,
68 ActorStatus::Failed(ActorErrorKind::UnhandledSupervisionEvent(Box::new(
69 self.event.clone(),
70 ))),
71 None,
72 )));
73 Err(anyhow::Error::new(err))
74 }
75}
76
77impl std::fmt::Display for MeshFailure {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 let actor_mesh_name = self
80 .actor_mesh_name
81 .as_ref()
82 .map(|m| format!(" on mesh \"{}\"", m))
83 .unwrap_or("".to_string());
84 let ranks = if self.crashed_ranks.is_empty() {
85 String::new()
86 } else {
87 format!(" at ranks {:?}", self.crashed_ranks)
88 };
89 write!(
90 f,
91 "failure{}{} with event: {}",
92 actor_mesh_name, ranks, self.event
93 )
94 }
95}
96
97// Shared between mesh types.
98#[derive(Debug, Clone)]
99pub(crate) enum Unhealthy {
100 StreamClosed(MeshFailure), // Event stream closed
101 Crashed(MeshFailure), // Bad health event received
102}
103
104#[cfg(test)]
105mod tests {
106 //! Tests that pin `MeshFailure::Display` rendering. The
107 //! `proof_*` tests capture exact rendered strings for three
108 //! supervision-path shapes, paired for each path as
109 //! `MeshFailure { actor_mesh_name: None, ... }` vs.
110 //! `MeshFailure { actor_mesh_name: Some(...), ... }`, so the
111 //! "with mesh name" and "without mesh name" rendered output is
112 //! locked down and any regression surfaces here.
113 //!
114 //! The `assert_eq!` literals capture the `ActorAddr::Display`
115 //! output of the checkout the tests were generated against. If
116 //! the identifier encoding changes (e.g. a reference-stack
117 //! refactor lands in the same tree), the literals need to be
118 //! regenerated on the new baseline — the mesh-name-rendering
119 //! behavior this module tests is independent of the id format.
120
121 use hyperactor::actor::ActorErrorKind;
122 use hyperactor::actor::ActorStatus;
123 use hyperactor::channel::ChannelAddr;
124
125 use super::*;
126 use crate::mesh_id::ResourceId;
127
128 fn test_event(name: &str, display_name: Option<String>) -> ActorSupervisionEvent {
129 let proc_id = ResourceId::proc_addr_from_name(ChannelAddr::Local(0), "test_proc");
130 ActorSupervisionEvent::new(
131 proc_id.actor_addr(name),
132 display_name,
133 ActorStatus::Failed(ActorErrorKind::Generic("boom".to_string())),
134 None,
135 )
136 }
137
138 // `MeshFailure::Display` renders the mesh name in its prose
139 // when `actor_mesh_name` is Some, producing the "on mesh \"{name}\""
140 // segment alongside the stable id-bearing event.
141 #[test]
142 fn mesh_failure_display_renders_mesh_name_when_populated() {
143 let failure = MeshFailure {
144 actor_mesh_name: Some("training".to_string()),
145 event: test_event("actor_a", None),
146 crashed_ranks: vec![],
147 };
148 let rendered = format!("{}", failure);
149 assert!(
150 rendered.contains("on mesh \"training\""),
151 "expected rendered output to contain `on mesh \"training\"`; got: {rendered}"
152 );
153 }
154
155 // When `actor_mesh_name` is None, the formatter omits the mesh
156 // segment entirely — the absence degrades gracefully without
157 // changing surrounding prose.
158 #[test]
159 fn mesh_failure_display_omits_mesh_segment_when_none() {
160 let failure = MeshFailure {
161 actor_mesh_name: None,
162 event: test_event("actor_a", None),
163 crashed_ranks: vec![],
164 };
165 let rendered = format!("{}", failure);
166 assert!(
167 !rendered.contains("on mesh"),
168 "expected no `on mesh` segment when actor_mesh_name is None; got: {rendered}"
169 );
170 }
171
172 // When both mesh name and the event's Python-class display_name
173 // are populated, the rendered prose includes both, producing a
174 // user-readable description alongside the stable identifier
175 // carried in the event.
176 #[test]
177 fn mesh_failure_display_renders_mesh_and_python_class() {
178 let failure = MeshFailure {
179 actor_mesh_name: Some("training".to_string()),
180 event: test_event(
181 "actor_a",
182 Some("instance0.<my_module.Philosopher training>".to_string()),
183 ),
184 crashed_ranks: vec![],
185 };
186 let rendered = format!("{}", failure);
187 assert!(
188 rendered.contains("on mesh \"training\""),
189 "expected mesh name segment; got: {rendered}"
190 );
191 assert!(
192 rendered.contains("my_module.Philosopher"),
193 "expected Python-class segment from display_name; got: {rendered}"
194 );
195 }
196
197 // Shared fixture for the proofs: the exact synthesized event shape
198 // that `GlobalClientActor::handle_undeliverable_message` produces
199 // (`hyperactor_mesh/src/global_context.rs:278`): display_name =
200 // None, actor_status = generic_failure("message not delivered: ...").
201 fn undeliverable_synthesized_event() -> ActorSupervisionEvent {
202 let proc_id = ResourceId::proc_addr_from_name(ChannelAddr::Local(0), "worker_proc");
203 ActorSupervisionEvent::new(
204 proc_id.actor_addr("dead_actor"),
205 None, // synthesized site has no PythonActor context; display_name stays None
206 ActorStatus::generic_failure(
207 "message not delivered: undeliverable message error: ... \
208 error: broken link: message returned to global root client"
209 .to_string(),
210 ),
211 None,
212 )
213 }
214
215 // Root-client undeliverable path.
216 //
217 // Transport bounces an undeliverable back to the root client;
218 // `GlobalClientActor::handle_undeliverable_message` synthesizes
219 // an `ActorSupervisionEvent` with `display_name = None` and
220 // `"message not delivered: ..."` status. That event propagates
221 // to a `PythonActor::handle_supervision_event`, which wraps it
222 // in a `MeshFailure`. At the wrap site the observing
223 // `PythonActor`'s `mesh_base_name` is the mesh name locally
224 // available; this test pins what `MeshFailure::Display` renders
225 // when `actor_mesh_name` is `None` vs. `Some("training")` for
226 // that exact synthesized inner event shape.
227 #[test]
228 fn proof_motivating_incident_root_client_undeliverable() {
229 let without_mesh_name = MeshFailure {
230 actor_mesh_name: None,
231 event: undeliverable_synthesized_event(),
232 crashed_ranks: vec![],
233 };
234 let with_mesh_name = MeshFailure {
235 actor_mesh_name: Some("training".to_string()),
236 event: undeliverable_synthesized_event(),
237 crashed_ranks: vec![],
238 };
239 let expected_without = "failure with event: Supervision event: \
240 actor worker_proc@inproc://0,dead_actor failed:\n \
241 message not delivered: undeliverable message error: \
242 ... error: broken link: message returned to global \
243 root client";
244 let expected_with = "failure on mesh \"training\" with event: \
245 Supervision event: actor \
246 worker_proc@inproc://0,dead_actor failed:\n \
247 message not delivered: undeliverable message error: \
248 ... error: broken link: message returned to global \
249 root client";
250 assert_eq!(format!("{}", without_mesh_name), expected_without);
251 assert_eq!(format!("{}", with_mesh_name), expected_with);
252
253 // Note: the inner event here has `display_name = None` (the
254 // synthesis site at `global_context.rs` has no PythonActor
255 // context to populate it), so the inner actor mention
256 // renders via raw `ActorAddr` text. That is a separate concern
257 // from mesh-name plumbing.
258 }
259
260 // Direct actor-handled panic path.
261 //
262 // A `PythonActor` panics in a handler. `Proc::stop_actor`
263 // constructs the `ActorSupervisionEvent` using
264 // `actor.display_name()`, which on a `PythonActor` is the
265 // Python-class-bearing `str(PyInstance)`. The event reaches a
266 // supervising `PythonActor` through the propagation chain,
267 // which wraps it in a `MeshFailure` at
268 // `monarch_hyperactor/src/actor.rs:1072`. At that wrap site the
269 // observing `PythonActor`'s `mesh_base_name` is the mesh name
270 // locally available; this test pins what
271 // `MeshFailure::Display` renders when `actor_mesh_name` is
272 // `None` vs. `Some("training")` for a panicked-event inner
273 // shape that already carries a Python-class `display_name`.
274 #[test]
275 fn proof_direct_actor_handled_panic() {
276 let panicked_event = {
277 let proc_id = ResourceId::proc_addr_from_name(ChannelAddr::Local(0), "worker_proc");
278 ActorSupervisionEvent::new(
279 proc_id.actor_addr("philosopher_1"),
280 // `Proc::stop_actor` populates this via
281 // `actor.display_name()` on a PythonActor — which
282 // returns the Python-class-bearing `str(PyInstance)`.
283 Some("instance0.<monarch_examples.dining.Philosopher training>".to_string()),
284 ActorStatus::Failed(ActorErrorKind::Generic(
285 "IndexError: list index out of range".to_string(),
286 )),
287 None,
288 )
289 };
290 let without_mesh_name = MeshFailure {
291 actor_mesh_name: None,
292 event: panicked_event.clone(),
293 crashed_ranks: vec![],
294 };
295 let with_mesh_name = MeshFailure {
296 actor_mesh_name: Some("training".to_string()),
297 event: panicked_event,
298 crashed_ranks: vec![],
299 };
300 let expected_without = "failure with event: Supervision event: actor \
301 instance0.<monarch_examples.dining.Philosopher \
302 training> failed:\n \
303 IndexError: list index out of range";
304 let expected_with = "failure on mesh \"training\" with event: \
305 Supervision event: actor \
306 instance0.<monarch_examples.dining.Philosopher \
307 training> failed:\n \
308 IndexError: list index out of range";
309 assert_eq!(format!("{}", without_mesh_name), expected_without);
310 assert_eq!(format!("{}", with_mesh_name), expected_with);
311 }
312
313 // Controller-unreachable path.
314 //
315 // When the controller for a mesh becomes unreachable, code in
316 // `actor_mesh.rs` synthesizes a `MeshFailure` with
317 // `actor_mesh_name: Some(self.id().to_string())` — the slot is
318 // already populated on this path, and the inner event's
319 // `display_name` is `None` because the construction site has
320 // no `PythonActor` context. This test pins the rendered string
321 // for that exact shape.
322 #[test]
323 fn proof_controller_unreachable() {
324 let controller_timeout_event = {
325 let proc_id = ResourceId::proc_addr_from_name(ChannelAddr::Local(0), "controller_proc");
326 ActorSupervisionEvent::new(
327 proc_id.actor_addr("training_controller"),
328 None,
329 ActorStatus::generic_failure(
330 "timed out reaching controller ... Assuming controller's proc is dead"
331 .to_string(),
332 ),
333 None,
334 )
335 };
336 let failure = MeshFailure {
337 actor_mesh_name: Some("training".to_string()),
338 event: controller_timeout_event,
339 crashed_ranks: vec![],
340 };
341 let expected = "failure on mesh \"training\" with event: \
342 Supervision event: actor \
343 controller_proc@inproc://0,training_controller \
344 failed:\n \
345 timed out reaching controller ... Assuming \
346 controller's proc is dead";
347 assert_eq!(format!("{}", failure), expected);
348 }
349}