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//! A data actor mesh labels each member's supervision events with
23//! its mesh id, so the owner's supervision handler names the failed
24//! mesh from the event (see `ACTOR_MESH_ID` in `actor_mesh.rs` and
25//! `PythonActor::handle_supervision_event` in
26//! `monarch_hyperactor/src/actor.rs`).
27//!
28//! ## Mesh failure controller-attribution invariants (MFCA-*)
29//!
30//! `MeshFailure.reporting_controller` names the mesh controller that
31//! observed and reported the failure, distinct from `event.actor_id`
32//! (the actor the event concerns).
33//!
34//! - **MFCA-1 (complete controller reports):** every `MeshFailure` a
35//! controller constructs stamps `reporting_controller =
36//! Some(controller_id)`; `send_state_change` is the sole controller-to-owner
37//! failure post and stamps before posting.
38//! - **MFCA-2 (same identity):** the stamped id is
39//! `cx.instance().self_addr().id()`, which equals the id a consumer reads
40//! from the mesh's controller ref (`ActorMesh::controller`, or the proc-mesh
41//! controller).
42//! - **MFCA-3 (subject/source separation):** `event.actor_id` (the event
43//! subject/root cause) and `reporting_controller` (the reporter) are separate
44//! roles; their values may coincide on a controller-originated event, but
45//! neither is overloaded to mean the other.
46//! - **MFCA-4 (non-controller absence):** `None` is reserved for construction
47//! paths with no reporting mesh controller; such a failure carries no
48//! controller attribution.
49
50use hyperactor::ActorId;
51use hyperactor::actor::ActorErrorKind;
52use hyperactor::actor::ActorStatus;
53use hyperactor::context;
54use hyperactor::supervision::ActorSupervisionEvent;
55use serde::Deserialize;
56use serde::Serialize;
57use typeuri::Named;
58
59/// Message about a supervision failure on a mesh of actors instead of a single
60/// actor.
61#[derive(Clone, Debug, Serialize, Deserialize, Named, PartialEq)]
62pub struct MeshFailure {
63 /// Mesh name carried by the `MeshFailure` construction site,
64 /// when locally available. On the direct actor-handled path
65 /// this is the failed member's recorded data mesh id. On
66 /// controller-owned paths this is the monitored mesh name
67 /// supplied by the controller path.
68 pub actor_mesh_name: Option<String>,
69 /// The supervision event on an actor located at mesh + rank.
70 pub event: ActorSupervisionEvent,
71 /// The set of crashed ranks in the mesh. Empty means the event
72 /// applies to the whole mesh (e.g. mesh stop, controller timeout).
73 pub crashed_ranks: Vec<usize>,
74 /// Identity of the mesh controller that reported this failure, when
75 /// one did (MFCA-3, MFCA-4). A separate role from `event.actor_id`
76 /// (the event subject); the two may hold the same `ActorId` on a
77 /// controller-originated event. `None` on construction paths with no
78 /// reporting controller, which carry no controller attribution.
79 pub reporting_controller: Option<ActorId>,
80}
81wirevalue::register_type!(MeshFailure);
82
83impl MeshFailure {
84 /// Returns true if the given rank is part of this failure.
85 /// A whole-mesh event (empty crashed_ranks) contains every rank.
86 pub fn contains_rank(&self, rank: usize) -> bool {
87 self.crashed_ranks.is_empty() || self.crashed_ranks.contains(&rank)
88 }
89
90 /// Helper function to handle a message to an actor that just wants to forward
91 /// it to the next owner.
92 pub fn default_handler(&self, cx: &impl context::Actor) -> Result<(), anyhow::Error> {
93 // If an actor spawned by this one fails, we can't handle it. We fail
94 // ourselves with a chained error and bubble up to the next owner. This
95 // converts the mesh failure into a supervision event, keeping only
96 // `event`; the mesh-level fields (`actor_mesh_name`, `crashed_ranks`,
97 // `reporting_controller`) are dropped and the next controller
98 // re-attributes (MFCA-1). Only a direct clone/re-post of a `MeshFailure`
99 // preserves `reporting_controller`; this conversion is not one.
100 let err = ActorErrorKind::UnhandledSupervisionEvent(Box::new(ActorSupervisionEvent::new(
101 cx.instance().self_addr().clone(),
102 None,
103 ActorStatus::Failed(ActorErrorKind::UnhandledSupervisionEvent(Box::new(
104 self.event.clone(),
105 ))),
106 None,
107 )));
108 Err(anyhow::Error::new(err))
109 }
110}
111
112impl std::fmt::Display for MeshFailure {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 let actor_mesh_name = self
115 .actor_mesh_name
116 .as_ref()
117 .map(|m| format!(" on mesh \"{}\"", m))
118 .unwrap_or("".to_string());
119 let ranks = if self.crashed_ranks.is_empty() {
120 String::new()
121 } else {
122 format!(" at ranks {:?}", self.crashed_ranks)
123 };
124 write!(
125 f,
126 "failure{}{} with event: {}",
127 actor_mesh_name, ranks, self.event
128 )
129 }
130}
131
132// Shared between mesh types.
133#[derive(Debug, Clone)]
134pub(crate) enum Unhealthy {
135 StreamClosed(MeshFailure), // Event stream closed
136 Crashed(MeshFailure), // Bad health event received
137}
138
139#[cfg(test)]
140mod tests {
141 //! Tests that pin `MeshFailure::Display` rendering. The
142 //! `proof_*` tests capture exact rendered strings for three
143 //! supervision-path shapes, paired for each path as
144 //! `MeshFailure { actor_mesh_name: None, ... }` vs.
145 //! `MeshFailure { actor_mesh_name: Some(...), ... }`, so the
146 //! "with mesh name" and "without mesh name" rendered output is
147 //! locked down and any regression surfaces here.
148 //!
149 //! The `assert_eq!` literals capture the `ActorAddr::Display`
150 //! output of the checkout the tests were generated against. If
151 //! the identifier encoding changes (e.g. a reference-stack
152 //! refactor lands in the same tree), the literals need to be
153 //! regenerated on the new baseline — the mesh-name-rendering
154 //! behavior this module tests is independent of the id format.
155
156 use hyperactor::actor::ActorErrorKind;
157 use hyperactor::actor::ActorStatus;
158 use hyperactor::channel::ChannelAddr;
159
160 use super::*;
161 use crate::mesh_id::ResourceId;
162
163 fn test_event(name: &str, display_name: Option<String>) -> ActorSupervisionEvent {
164 let proc_id = ResourceId::proc_addr_from_name(ChannelAddr::Local(0), "test_proc");
165 ActorSupervisionEvent::new(
166 proc_id.actor_addr(name),
167 display_name,
168 ActorStatus::Failed(ActorErrorKind::Generic("boom".to_string())),
169 None,
170 )
171 }
172
173 // `MeshFailure::Display` renders the mesh name in its prose
174 // when `actor_mesh_name` is Some, producing the "on mesh \"{name}\""
175 // segment alongside the stable id-bearing event.
176 #[test]
177 fn mesh_failure_display_renders_mesh_name_when_populated() {
178 let failure = MeshFailure {
179 actor_mesh_name: Some("training".to_string()),
180 event: test_event("actor_a", None),
181 crashed_ranks: vec![],
182 reporting_controller: None,
183 };
184 let rendered = format!("{}", failure);
185 assert!(
186 rendered.contains("on mesh \"training\""),
187 "expected rendered output to contain `on mesh \"training\"`; got: {rendered}"
188 );
189 }
190
191 // When `actor_mesh_name` is None, the formatter omits the mesh
192 // segment entirely — the absence degrades gracefully without
193 // changing surrounding prose.
194 #[test]
195 fn mesh_failure_display_omits_mesh_segment_when_none() {
196 let failure = MeshFailure {
197 actor_mesh_name: None,
198 event: test_event("actor_a", None),
199 crashed_ranks: vec![],
200 reporting_controller: None,
201 };
202 let rendered = format!("{}", failure);
203 assert!(
204 !rendered.contains("on mesh"),
205 "expected no `on mesh` segment when actor_mesh_name is None; got: {rendered}"
206 );
207 }
208
209 // When both mesh name and the event's Python-class display_name
210 // are populated, the rendered prose includes both, producing a
211 // user-readable description alongside the stable identifier
212 // carried in the event.
213 #[test]
214 fn mesh_failure_display_renders_mesh_and_python_class() {
215 let failure = MeshFailure {
216 actor_mesh_name: Some("training".to_string()),
217 event: test_event(
218 "actor_a",
219 Some("instance0.<my_module.Philosopher training>".to_string()),
220 ),
221 crashed_ranks: vec![],
222 reporting_controller: None,
223 };
224 let rendered = format!("{}", failure);
225 assert!(
226 rendered.contains("on mesh \"training\""),
227 "expected mesh name segment; got: {rendered}"
228 );
229 assert!(
230 rendered.contains("my_module.Philosopher"),
231 "expected Python-class segment from display_name; got: {rendered}"
232 );
233 }
234
235 // Shared fixture for the proofs: the exact synthesized event shape
236 // that `GlobalClientActor::handle_undeliverable_message` produces
237 // (`hyperactor_mesh/src/global_context.rs:278`): display_name =
238 // None, actor_status = generic_failure("message not delivered: ...").
239 fn undeliverable_synthesized_event() -> ActorSupervisionEvent {
240 let proc_id = ResourceId::proc_addr_from_name(ChannelAddr::Local(0), "worker_proc");
241 ActorSupervisionEvent::new(
242 proc_id.actor_addr("dead_actor"),
243 None, // synthesized site has no PythonActor context; display_name stays None
244 ActorStatus::generic_failure(
245 "message not delivered: undeliverable message error: ... \
246 error: broken link: message returned to global root client"
247 .to_string(),
248 ),
249 None,
250 )
251 }
252
253 // Root-client undeliverable path.
254 //
255 // Transport bounces an undeliverable back to the root client;
256 // `GlobalClientActor::handle_undeliverable_message` synthesizes
257 // an `ActorSupervisionEvent` with `display_name = None` and
258 // `"message not delivered: ..."` status. That event propagates
259 // to a `PythonActor::handle_supervision_event`, which wraps it
260 // in a `MeshFailure`. At the wrap site the observing
261 // `PythonActor`'s `mesh_base_name` is the mesh name locally
262 // available; this test pins what `MeshFailure::Display` renders
263 // when `actor_mesh_name` is `None` vs. `Some("training")` for
264 // that exact synthesized inner event shape.
265 #[test]
266 fn proof_motivating_incident_root_client_undeliverable() {
267 let without_mesh_name = MeshFailure {
268 actor_mesh_name: None,
269 event: undeliverable_synthesized_event(),
270 crashed_ranks: vec![],
271 reporting_controller: None,
272 };
273 let with_mesh_name = MeshFailure {
274 actor_mesh_name: Some("training".to_string()),
275 event: undeliverable_synthesized_event(),
276 crashed_ranks: vec![],
277 reporting_controller: None,
278 };
279 let expected_without = "failure with event: Supervision event: \
280 actor worker_proc@inproc://0,dead_actor failed:\n \
281 message not delivered: undeliverable message error: \
282 ... error: broken link: message returned to global \
283 root client";
284 let expected_with = "failure on mesh \"training\" with event: \
285 Supervision event: actor \
286 worker_proc@inproc://0,dead_actor failed:\n \
287 message not delivered: undeliverable message error: \
288 ... error: broken link: message returned to global \
289 root client";
290 assert_eq!(format!("{}", without_mesh_name), expected_without);
291 assert_eq!(format!("{}", with_mesh_name), expected_with);
292
293 // Note: the inner event here has `display_name = None` (the
294 // synthesis site at `global_context.rs` has no PythonActor
295 // context to populate it), so the inner actor mention
296 // renders via raw `ActorAddr` text. That is a separate concern
297 // from mesh-name plumbing.
298 }
299
300 // Direct actor-handled panic path.
301 //
302 // A `PythonActor` panics in a handler. `Proc::stop_actor`
303 // constructs the `ActorSupervisionEvent` using
304 // `actor.display_name()`, which on a `PythonActor` is the
305 // Python-class-bearing `str(PyInstance)`. The event reaches a
306 // supervising `PythonActor` through the propagation chain,
307 // which wraps it in a `MeshFailure` at
308 // `monarch_hyperactor/src/actor.rs:1072`. At that wrap site the
309 // observing `PythonActor`'s `mesh_base_name` is the mesh name
310 // locally available; this test pins what
311 // `MeshFailure::Display` renders when `actor_mesh_name` is
312 // `None` vs. `Some("training")` for a panicked-event inner
313 // shape that already carries a Python-class `display_name`.
314 #[test]
315 fn proof_direct_actor_handled_panic() {
316 let panicked_event = {
317 let proc_id = ResourceId::proc_addr_from_name(ChannelAddr::Local(0), "worker_proc");
318 ActorSupervisionEvent::new(
319 proc_id.actor_addr("philosopher_1"),
320 // `Proc::stop_actor` populates this via
321 // `actor.display_name()` on a PythonActor — which
322 // returns the Python-class-bearing `str(PyInstance)`.
323 Some("instance0.<monarch_examples.dining.Philosopher training>".to_string()),
324 ActorStatus::Failed(ActorErrorKind::Generic(
325 "IndexError: list index out of range".to_string(),
326 )),
327 None,
328 )
329 };
330 let without_mesh_name = MeshFailure {
331 actor_mesh_name: None,
332 event: panicked_event.clone(),
333 crashed_ranks: vec![],
334 reporting_controller: None,
335 };
336 let with_mesh_name = MeshFailure {
337 actor_mesh_name: Some("training".to_string()),
338 event: panicked_event,
339 crashed_ranks: vec![],
340 reporting_controller: None,
341 };
342 let expected_without = "failure with event: Supervision event: actor \
343 instance0.<monarch_examples.dining.Philosopher \
344 training> failed:\n \
345 IndexError: list index out of range";
346 let expected_with = "failure on mesh \"training\" with event: \
347 Supervision event: actor \
348 instance0.<monarch_examples.dining.Philosopher \
349 training> failed:\n \
350 IndexError: list index out of range";
351 assert_eq!(format!("{}", without_mesh_name), expected_without);
352 assert_eq!(format!("{}", with_mesh_name), expected_with);
353 }
354
355 // Controller-unreachable path.
356 //
357 // When the controller for a mesh becomes unreachable, code in
358 // `actor_mesh.rs` synthesizes a `MeshFailure` with
359 // `actor_mesh_name: Some(self.id().to_string())` — the slot is
360 // already populated on this path, and the inner event's
361 // `display_name` is `None` because the construction site has
362 // no `PythonActor` context. This test pins the rendered string
363 // for that exact shape.
364 #[test]
365 fn proof_controller_unreachable() {
366 let controller_timeout_event = {
367 let proc_id = ResourceId::proc_addr_from_name(ChannelAddr::Local(0), "controller_proc");
368 ActorSupervisionEvent::new(
369 proc_id.actor_addr("training_controller"),
370 None,
371 ActorStatus::generic_failure(
372 "timed out reaching controller ... Assuming controller's proc is dead"
373 .to_string(),
374 ),
375 None,
376 )
377 };
378 let failure = MeshFailure {
379 actor_mesh_name: Some("training".to_string()),
380 event: controller_timeout_event,
381 crashed_ranks: vec![],
382 reporting_controller: None,
383 };
384 let expected = "failure on mesh \"training\" with event: \
385 Supervision event: actor \
386 controller_proc@inproc://0,training_controller \
387 failed:\n \
388 timed out reaching controller ... Assuming \
389 controller's proc is dead";
390 assert_eq!(format!("{}", failure), expected);
391 }
392
393 // Distinct actor id for use as a controller identity or an event subject.
394 fn mk_actor_id(proc: &str, name: &str) -> ActorId {
395 ResourceId::proc_addr_from_name(ChannelAddr::Local(0), proc)
396 .actor_addr(name)
397 .id()
398 .clone()
399 }
400
401 // MFCA-4 + wire format: `reporting_controller` round-trips through bincode
402 // for both `Some` and `None`.
403 #[test]
404 fn reporting_controller_serde_round_trips() {
405 for reporting_controller in [Some(mk_actor_id("ctrl_proc", "controller")), None] {
406 let failure = MeshFailure {
407 actor_mesh_name: Some("training".to_string()),
408 event: test_event("actor_a", None),
409 crashed_ranks: vec![0],
410 reporting_controller: reporting_controller.clone(),
411 };
412 let bytes = bincode::serde::encode_to_vec(&failure, bincode::config::legacy()).unwrap();
413 let (decoded, _): (MeshFailure, usize) =
414 bincode::serde::decode_from_slice(&bytes, bincode::config::legacy()).unwrap();
415 assert_eq!(decoded, failure);
416 assert_eq!(decoded.reporting_controller, reporting_controller);
417 }
418 }
419
420 // MFCA-3: the reporter and the event subject are separate roles carried
421 // independently. Here they hold different ids (a ProcAgent subject, a
422 // distinct controller reporter); on a controller-originated event they may
423 // instead hold the same id.
424 #[test]
425 fn reporting_controller_and_event_subject_are_independent() {
426 let controller = mk_actor_id("ctrl_proc", "controller");
427 let subject = ResourceId::proc_addr_from_name(ChannelAddr::Local(0), "agent_proc")
428 .actor_addr("proc_agent");
429 let failure = MeshFailure {
430 actor_mesh_name: Some("training".to_string()),
431 event: ActorSupervisionEvent::new(
432 subject.clone(),
433 None,
434 ActorStatus::Failed(ActorErrorKind::Generic("boom".to_string())),
435 None,
436 ),
437 crashed_ranks: Vec::new(),
438 reporting_controller: Some(controller.clone()),
439 };
440 assert_eq!(failure.reporting_controller, Some(controller));
441 assert_eq!(failure.event.actor_id, subject);
442 }
443}