Skip to main content

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}