Skip to main content

hyperactor_mesh/
monitor.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//! Multicast liveness monitoring.
10//!
11//! [`MeshMonitor`] mirrors [`hyperactor::ActorMonitor`] but observes a set of
12//! ranked actors rather than a single one. It composes: one `ActorMonitor` per
13//! rank, fanning each per-rank [`MonitorFailure`] out into a mesh-level
14//! [`MeshFailure`] that names the failed rank. Multicast monitoring is, for now,
15//! many unicast monitors; a later diff can optimize the fan-out with casting.
16
17use std::future::Future;
18use std::future::IntoFuture;
19use std::pin::Pin;
20
21use futures::future::select_all;
22use hyperactor::ActorAddr;
23use hyperactor::ActorMonitor;
24use hyperactor::RemoteHandles;
25use hyperactor::RemoteMessage;
26use hyperactor::actor::ActorStatus;
27use hyperactor::actor::Referable;
28use hyperactor::context;
29use hyperactor::mailbox::Message;
30use hyperactor::mailbox::PortReceiver;
31use hyperactor::monitor::MonitorActivation;
32use hyperactor::monitor::MonitorFailure;
33use hyperactor::monitor::MonitorStatus;
34use hyperactor::supervision::ActorSupervisionEvent;
35use ndslice::view::MapIntoExt as _;
36use ndslice::view::Ranked as _;
37use ndslice::view::Region;
38
39use crate::ActorMeshRef;
40use crate::ValueMesh;
41use crate::supervision::MeshFailure;
42
43/// A liveness monitor over a set of ranked actors.
44///
45/// A `MeshMonitor` logically monitors one actor per rank in a region. Monitoring
46/// runs until the `MeshMonitor` is dropped. A monitor from [`MeshMonitor::spawn_persistent`]
47/// is persistent and polls throughout; one from [`MeshMonitor::spawn_dormant`]
48/// starts dormant and polls only while active. Awaiting a `&MeshMonitor`
49/// resolves with the first rank failure, reported as a [`MeshFailure`] that
50/// names the rank; use [`MeshMonitor::guard`] to run a future until it completes
51/// or a rank fails.
52pub struct MeshMonitor {
53    /// One monitor per rank, keyed by the mesh's region.
54    monitors: ValueMesh<ActorMonitor>,
55}
56
57impl MeshMonitor {
58    /// Spawn one persistent [`ActorMonitor`] per rank as a child of `cx`.
59    ///
60    /// `actors` holds the actor addresses in the mesh's region, so the resulting
61    /// monitors share that region: each rank reports failures under the same rank
62    /// it holds in the mesh.
63    pub fn spawn_persistent(cx: &impl context::Actor, actors: ValueMesh<ActorAddr>) -> Self {
64        Self::spawn_with(actors, |actor| ActorMonitor::spawn_persistent(cx, actor))
65    }
66
67    /// Spawn one [`ActorMonitor`] per rank as a child of `cx`, starting dormant.
68    ///
69    /// Each rank observes terminal-status notifications in either state, but
70    /// polls status only while active: while a [`MeshMonitorActivation`] from
71    /// [`Self::activate`] is live.
72    pub fn spawn_dormant(cx: &impl context::Actor, actors: ValueMesh<ActorAddr>) -> Self {
73        Self::spawn_with(actors, |actor| ActorMonitor::spawn_dormant(cx, actor))
74    }
75
76    fn spawn_with(actors: ValueMesh<ActorAddr>, spawn: impl Fn(ActorAddr) -> ActorMonitor) -> Self {
77        Self {
78            monitors: actors.map_into(|actor| spawn(actor.clone())),
79        }
80    }
81
82    /// Keep every rank active until the returned activation is dropped.
83    ///
84    /// See [`ActorMonitor::activate`].
85    pub fn activate(&self, cx: &impl context::Actor) -> MeshMonitorActivation {
86        MeshMonitorActivation {
87            activations: self.monitors.map_into(|monitor| monitor.activate(cx)),
88        }
89    }
90
91    /// Return the monitored region.
92    pub fn region(&self) -> &Region {
93        self.monitors.region()
94    }
95
96    /// Return the latest observed status for `rank`.
97    pub fn status(&self, rank: usize) -> Option<ActorStatus> {
98        self.monitors
99            .get(rank)
100            .map(|monitor| match monitor.status() {
101                MonitorStatus::Checking => ActorStatus::Unknown,
102                MonitorStatus::Alive(status) => status,
103                MonitorStatus::Failed(failure) => monitor_failure_to_actor_status(&failure),
104            })
105    }
106
107    /// Wait for the first monitored rank to fail, reported as a [`MeshFailure`].
108    ///
109    /// Internal helper behind the [`IntoFuture`] impl for `&MeshMonitor` and
110    /// [`Self::guard`], the public ways to observe failures. An empty monitor set
111    /// never resolves: a mesh with no ranks has no rank that can fail.
112    async fn wait_for_failure(&self) -> MeshFailure {
113        if self.region().num_ranks() == 0 {
114            return std::future::pending().await;
115        }
116        let waits = (0..self.region().num_ranks()).map(|rank| {
117            let monitor = self
118                .monitors
119                .get(rank)
120                .expect("dense actor monitor mesh has one value per rank");
121            Box::pin(async move {
122                // Guarding a never-completing future resolves only when the
123                // monitor reports a failure.
124                let failure = monitor
125                    .guard(std::future::pending::<()>())
126                    .await
127                    .expect_err("pending future never completes");
128                (rank, failure)
129            })
130        });
131        let ((rank, failure), _index, _rest) = select_all(waits).await;
132        monitor_failure_to_mesh_failure(rank, &failure)
133    }
134
135    /// Run `fut` until it completes or any monitored rank fails.
136    pub async fn guard<F>(&self, fut: F) -> Result<F::Output, MeshFailure>
137    where
138        F: Future,
139    {
140        tokio::pin!(fut);
141        tokio::select! {
142            result = fut => Ok(result),
143            failure = self.wait_for_failure() => Err(failure),
144        }
145    }
146
147    /// Cast `message` and collect one response per rank unless a rank fails.
148    ///
149    /// `receiver` must receive exactly one response from each actor in
150    /// `actor_mesh`. Responses are returned in arrival order. The outer result
151    /// reports cast or mailbox errors; the inner result distinguishes all-rank
152    /// completion from a monitored rank failure. This monitor must have been
153    /// created from `actor_mesh`.
154    pub async fn cast_and_collect<A, M, R>(
155        &self,
156        actor_mesh: &ActorMeshRef<A>,
157        cx: &impl context::Actor,
158        message: M,
159        receiver: &mut PortReceiver<R>,
160    ) -> crate::Result<Result<Vec<R>, MeshFailure>>
161    where
162        A: Referable + RemoteHandles<M>,
163        M: RemoteMessage + Clone,
164        R: Message,
165    {
166        actor_mesh.cast(cx, message)?;
167        let num_ranks = actor_mesh.region().num_ranks();
168        let replies = async {
169            let mut replies = Vec::with_capacity(num_ranks);
170            for _ in 0..num_ranks {
171                replies.push(receiver.recv().await?);
172            }
173            Ok::<_, hyperactor::mailbox::MailboxError>(replies)
174        };
175
176        match self.guard(replies).await {
177            Ok(Ok(replies)) => Ok(Ok(replies)),
178            Ok(Err(error)) => Err(Box::new(error).into()),
179            Err(failure) => Ok(Err(failure)),
180        }
181    }
182}
183
184/// Keeps a [`MeshMonitor`] active; see [`MeshMonitor::activate`].
185#[must_use = "the monitor goes dormant once its last activation is dropped"]
186pub struct MeshMonitorActivation {
187    #[expect(
188        dead_code,
189        reason = "held only so dropping it releases each rank's activation"
190    )]
191    activations: ValueMesh<MonitorActivation>,
192}
193
194/// Awaiting a `&MeshMonitor` resolves with the first rank failure. The borrow
195/// keeps the monitors alive for the duration of the await.
196impl<'a> IntoFuture for &'a MeshMonitor {
197    type Output = MeshFailure;
198    type IntoFuture = Pin<Box<dyn Future<Output = MeshFailure> + Send + 'a>>;
199
200    fn into_future(self) -> Self::IntoFuture {
201        Box::pin(self.wait_for_failure())
202    }
203}
204
205fn monitor_failure_to_mesh_failure(rank: usize, failure: &MonitorFailure) -> MeshFailure {
206    let actor_id = failure.actor_id().clone();
207    let actor_status = monitor_failure_to_actor_status(failure);
208    // The failed actor is identified by `event.actor_id` and the rank; the mesh
209    // itself has no name to carry.
210    MeshFailure {
211        actor_mesh_name: None,
212        event: ActorSupervisionEvent::new(actor_id, None, actor_status, None),
213        crashed_ranks: vec![rank],
214        // Synthesized locally from direct per-rank monitoring, not a
215        // controller report.
216        reporting_controller: None,
217    }
218}
219
220fn monitor_failure_to_actor_status(failure: &MonitorFailure) -> ActorStatus {
221    match failure {
222        MonitorFailure::ActorStopped { status, .. } => status.clone(),
223        MonitorFailure::ActorFailed { status, .. } if status.is_failed() => status.clone(),
224        failure => ActorStatus::generic_failure(failure.to_string()),
225    }
226}
227
228#[cfg(all(test, fbcode_build))]
229mod tests {
230    use std::collections::HashSet;
231
232    use hyperactor::ActorRef;
233    use hyperactor::Proc;
234    use hyperactor::id::Label;
235    use ndslice::Region;
236    use ndslice::extent;
237    use tokio::time::Duration;
238
239    use super::*;
240    use crate::mesh_id::ActorMeshId;
241    use crate::testactor;
242
243    #[tokio::test]
244    async fn test_mesh_monitor_reports_rank_failure() {
245        let proc = Proc::isolated();
246        let client = proc.client("client");
247        let target = client.spawn_with_label("rank0", testactor::TestActor);
248        let region: Region = extent!(replicas = 1).into();
249        let actors = ValueMesh::new(region, vec![target.actor_addr().clone()]).unwrap();
250        let monitor = MeshMonitor::spawn_persistent(&client, actors);
251        assert_eq!(monitor.status(0), Some(ActorStatus::Unknown));
252
253        // No failure should be observed while the actor is alive.
254        let mut wait = (&monitor).into_future();
255        tokio::select! {
256            biased;
257            failure = &mut wait => panic!("unexpected failure before stop: {failure:?}"),
258            _ = tokio::task::yield_now() => {}
259        }
260
261        target
262            .drain_and_stop("rank complete")
263            .expect("target should accept stop");
264
265        let failure = tokio::time::timeout(Duration::from_secs(10), wait)
266            .await
267            .expect("timed out waiting for mesh monitor failure");
268        assert_eq!(failure.crashed_ranks, vec![0]);
269        assert!(matches!(
270            failure.event.actor_status,
271            ActorStatus::Stopped(ref reason) if reason == "rank complete"
272        ));
273        assert!(matches!(
274            monitor.status(0).expect("rank 0 should remain monitored"),
275            ActorStatus::Stopped(ref reason) if reason == "rank complete"
276        ));
277
278        tokio::time::timeout(Duration::from_secs(5), target)
279            .await
280            .expect("timed out waiting for target to stop");
281    }
282
283    #[tokio::test]
284    async fn test_cast_and_collect_returns_all_replies_or_rank_failure() {
285        let proc = Proc::isolated();
286        let client = proc.client("client");
287        let rank0 = client.spawn_with_label("rank0", testactor::TestActor);
288        let rank1 = client.spawn_with_label("rank1", testactor::TestActor);
289        let actor_refs: Vec<ActorRef<testactor::TestActor>> = vec![rank0.bind(), rank1.bind()];
290        let expected_actor_addrs: HashSet<_> = actor_refs
291            .iter()
292            .map(|actor_ref| actor_ref.actor_addr().clone())
293            .collect();
294        let region: Region = extent!(replicas = 2).into();
295        let actor_mesh = ActorMeshRef::try_new_data(
296            ActorMeshId::instance(Label::new("replicas").unwrap()),
297            region,
298            actor_refs,
299        )
300        .expect("data actor mesh should be valid");
301        let monitor = actor_mesh.monitor(&client);
302
303        let (port, mut receiver) = client.open_port();
304        let replies = tokio::time::timeout(
305            Duration::from_secs(10),
306            monitor.cast_and_collect(
307                &actor_mesh,
308                &client,
309                testactor::GetActorId(port.bind()),
310                &mut receiver,
311            ),
312        )
313        .await
314        .expect("timed out waiting for all rank replies")
315        .expect("cast and receive should succeed")
316        .expect("all ranks should remain alive");
317        let actor_addrs: HashSet<_> = replies
318            .into_iter()
319            .map(|(actor_addr, _seq_info)| actor_addr)
320            .collect();
321        assert_eq!(actor_addrs, expected_actor_addrs);
322
323        let (_port, mut receiver): (_, PortReceiver<()>) = client.open_port();
324        let mut pending =
325            std::pin::pin!(monitor.cast_and_collect(&actor_mesh, &client, (), &mut receiver,));
326        tokio::select! {
327            biased;
328            result = &mut pending => panic!("cast unexpectedly completed: {result:?}"),
329            _ = tokio::task::yield_now() => {}
330        }
331        rank1
332            .drain_and_stop("rank complete")
333            .expect("rank 1 should accept stop");
334        let failure = tokio::time::timeout(Duration::from_secs(10), pending)
335            .await
336            .expect("timed out waiting for a rank failure")
337            .expect("cast should initiate")
338            .expect_err("rank failure should win before all replies arrive");
339        assert_eq!(failure.crashed_ranks, vec![1]);
340        assert!(matches!(
341            failure.event.actor_status,
342            ActorStatus::Stopped(ref reason) if reason == "rank complete"
343        ));
344
345        rank0
346            .drain_and_stop("test complete")
347            .expect("rank 0 should accept stop");
348        tokio::time::timeout(Duration::from_secs(5), rank0)
349            .await
350            .expect("timed out waiting for rank 0 to stop");
351        tokio::time::timeout(Duration::from_secs(5), rank1)
352            .await
353            .expect("timed out waiting for rank 1 to stop");
354    }
355}