Skip to main content

monarch_hyperactor/code_sync/
manager.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
9use std::collections::HashMap;
10use std::net::SocketAddr;
11use std::path::PathBuf;
12use std::sync::OnceLock;
13
14use anyhow::Context as _;
15use anyhow::Result;
16use anyhow::ensure;
17use async_once_cell::OnceCell;
18use async_trait::async_trait;
19use futures::FutureExt;
20use futures::StreamExt;
21use futures::TryFutureExt;
22use futures::TryStreamExt;
23use futures::try_join;
24use hyperactor as reference;
25use hyperactor::Actor;
26use hyperactor::ActorHandle;
27use hyperactor::Context;
28use hyperactor::Endpoint as _;
29use hyperactor::Handler;
30use hyperactor::RemoteSpawn;
31use hyperactor::context;
32use hyperactor::handle;
33use hyperactor_config::Flattrs;
34use hyperactor_mesh::connect::Connect;
35use hyperactor_mesh::connect::accept;
36use lazy_errors::ErrorStash;
37use lazy_errors::TryCollectOrStash;
38use monarch_conda::sync::sender;
39use ndslice::Shape;
40use ndslice::ShapeError;
41use ndslice::view::Ranked;
42use ndslice::view::RankedSliceable;
43use ndslice::view::ViewExt;
44use serde::Deserialize;
45use serde::Serialize;
46use tokio::io::AsyncReadExt;
47use tokio::io::AsyncWriteExt;
48use tokio::net::TcpListener;
49use tokio::net::TcpStream;
50use typeuri::Named;
51
52use crate::code_sync::WorkspaceLocation;
53use crate::code_sync::auto_reload::AutoReloadActor;
54use crate::code_sync::auto_reload::AutoReloadMessage;
55use crate::code_sync::conda_sync::CondaSyncActor;
56use crate::code_sync::conda_sync::CondaSyncMessage;
57use crate::code_sync::conda_sync::CondaSyncResult;
58use crate::code_sync::rsync::RsyncActor;
59use crate::code_sync::rsync::RsyncDaemon;
60use crate::code_sync::rsync::RsyncMessage;
61use crate::code_sync::rsync::RsyncResult;
62
63#[derive(Clone, Serialize, Deserialize, Debug)]
64pub enum Method {
65    Rsync {
66        connect: reference::PortRef<Connect>,
67    },
68    CondaSync {
69        connect: reference::PortRef<Connect>,
70        path_prefix_replacements: HashMap<PathBuf, WorkspaceLocation>,
71    },
72}
73
74/// Describe the shape of the workspace.
75#[derive(Clone, Serialize, Deserialize, Debug)]
76pub struct WorkspaceShape {
77    /// All actors accessing the workspace.
78    pub shape: Shape,
79    /// Starting dimension in the shape denoting all ranks that share the same workspace.
80    pub dimension: Option<String>,
81}
82
83impl WorkspaceShape {
84    /// Reduce the shape to contain only the "owners" of the remote workspace
85    ///
86    /// This is relevant when e.g. multiple worker on the same host share a workspace, in which case,
87    /// we'll reduce the share to only contain one worker per workspace, so that we don't have multiple
88    /// workers trying to sync to the same workspace at the same time.
89    pub fn owners(&self) -> Result<Shape, ShapeError> {
90        let mut new_shape = self.shape.clone();
91        for label in self
92            .shape
93            .labels()
94            .iter()
95            .skip_while(|l| Some(*l) != self.dimension.as_ref())
96        {
97            new_shape = new_shape.select(label, 0)?;
98            //new_shape = new_shape.slice(label, 0..1)?;
99        }
100        Ok(new_shape)
101    }
102
103    /// Return a new shape that contains all ranks that share the same workspace with the given "owning" rank.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if the given rank's coordinates aren't all zero starting at the specified dimension
108    /// and continuing until the end. For example, if the dimension is Some("host"), then the coordinates
109    /// for the rank must be 0 in the host dimension and all subsequent dimensions.
110    pub fn downstream(&self, rank: usize) -> Result<Shape> {
111        let coords = self.shape.coordinates(rank)?;
112
113        for (label, value) in coords
114            .iter()
115            .skip_while(|(l, _)| Some(l) != self.dimension.as_ref())
116        {
117            ensure!(
118                *value == 0,
119                "Coordinate for dimension '{}' must be 0 for rank {}",
120                label,
121                rank
122            );
123        }
124
125        Ok(self.shape.index(
126            coords
127                .into_iter()
128                .take_while(|(l, _)| Some(l) != self.dimension.as_ref())
129                .collect::<Vec<_>>(),
130        )?)
131    }
132
133    fn downstream_mesh(
134        &self,
135        mesh: &hyperactor_mesh::ActorMeshRef<CodeSyncManager>,
136        rank: usize,
137    ) -> Result<hyperactor_mesh::ActorMeshRef<CodeSyncManager>> {
138        let shape = self.downstream(rank)?;
139        Ok(mesh.sliced(shape.region()))
140    }
141}
142
143#[derive(Clone, Serialize, Deserialize, Debug)]
144pub struct WorkspaceConfig {
145    pub location: WorkspaceLocation,
146    pub shape: WorkspaceShape,
147}
148
149#[derive(Handler, Clone, Serialize, Deserialize, Debug, Named)]
150#[expect(
151    clippy::large_enum_variant,
152    reason = "actor message enum with handler-generated call sites; boxing fields ripples into handlers"
153)]
154pub enum CodeSyncMessage {
155    Sync {
156        workspace: WorkspaceLocation,
157        /// The method to use for syncing.
158        method: Method,
159        /// Whether to hot-reload code after syncing.
160        reload: Option<WorkspaceShape>,
161        /// A port to send back the result of the sync operation.
162        result: reference::PortRef<Result<(), String>>,
163    },
164    Reload {
165        sender_rank: Option<usize>,
166        result: reference::PortRef<Result<(), String>>,
167    },
168}
169wirevalue::register_type!(CodeSyncMessage);
170
171#[derive(Clone, Serialize, Deserialize, Debug, Named)]
172pub struct SetActorMeshMessage {
173    pub actor_mesh: hyperactor_mesh::ActorMeshRef<CodeSyncManager>,
174}
175wirevalue::register_type!(SetActorMeshMessage);
176
177#[derive(Debug, Named, Serialize, Deserialize)]
178pub struct CodeSyncManagerParams {}
179wirevalue::register_type!(CodeSyncManagerParams);
180
181#[derive(Debug)]
182#[hyperactor::export(
183    handlers = [
184        CodeSyncMessage,
185        SetActorMeshMessage
186    ],
187)]
188#[hyperactor::spawnable]
189pub struct CodeSyncManager {
190    rsync: OnceCell<ActorHandle<RsyncActor>>,
191    auto_reload: OnceCell<ActorHandle<AutoReloadActor>>,
192    conda_sync: OnceCell<ActorHandle<CondaSyncActor>>,
193    self_mesh: OnceLock<hyperactor_mesh::ActorMeshRef<CodeSyncManager>>,
194    rank: OnceLock<usize>,
195}
196
197impl Actor for CodeSyncManager {}
198
199#[async_trait]
200impl RemoteSpawn for CodeSyncManager {
201    type Params = CodeSyncManagerParams;
202
203    async fn new(CodeSyncManagerParams {}: Self::Params, _environment: Flattrs) -> Result<Self> {
204        Ok(Self {
205            rsync: OnceCell::new(),
206            auto_reload: OnceCell::new(),
207            conda_sync: OnceCell::new(),
208            self_mesh: OnceLock::new(),
209            rank: OnceLock::new(),
210        })
211    }
212}
213
214impl CodeSyncManager {
215    async fn get_rsync_actor<'a>(
216        &'a mut self,
217        cx: &Context<'a, Self>,
218    ) -> Result<&'a ActorHandle<RsyncActor>> {
219        self.rsync
220            .get_or_try_init(async move { Ok(cx.spawn(RsyncActor::default())) })
221            .await
222    }
223
224    async fn get_auto_reload_actor<'a>(
225        &'a mut self,
226        cx: &Context<'a, Self>,
227    ) -> Result<&'a ActorHandle<AutoReloadActor>> {
228        self.auto_reload
229            .get_or_try_init(async move { Ok(cx.spawn(AutoReloadActor::new().await?)) })
230            .await
231    }
232
233    async fn get_conda_sync_actor<'a>(
234        &'a mut self,
235        cx: &Context<'a, Self>,
236    ) -> Result<&'a ActorHandle<CondaSyncActor>> {
237        self.conda_sync
238            .get_or_try_init(async move { Ok(cx.spawn(CondaSyncActor::default())) })
239            .await
240    }
241}
242
243#[async_trait]
244#[handle(CodeSyncMessage)]
245impl CodeSyncMessageHandler for CodeSyncManager {
246    async fn sync(
247        &mut self,
248        cx: &Context<Self>,
249        workspace: WorkspaceLocation,
250        method: Method,
251        reload: Option<WorkspaceShape>,
252        result: reference::PortRef<Result<(), String>>,
253    ) -> Result<()> {
254        let res = async move {
255            match method {
256                Method::Rsync { connect } => {
257                    // Forward rsync connection port to the RsyncActor, which will do the actual
258                    // connection and run the client.
259                    let (tx, mut rx) = cx.open_port::<Result<RsyncResult, String>>();
260                    self.get_rsync_actor(cx).await?.post(
261                        cx,
262                        RsyncMessage {
263                            connect,
264                            result: tx.bind(),
265                            workspace,
266                        },
267                    );
268                    // Observe any errors.
269                    let _ = rx.recv().await?.map_err(anyhow::Error::msg)?;
270                }
271                Method::CondaSync {
272                    connect,
273                    path_prefix_replacements,
274                } => {
275                    // Forward rsync connection port to the RsyncActor, which will do the actual
276                    // connection and run the client.
277                    let (tx, mut rx) = cx.open_port::<Result<CondaSyncResult, String>>();
278                    self.get_conda_sync_actor(cx).await?.post(
279                        cx,
280                        CondaSyncMessage {
281                            connect,
282                            result: tx.bind(),
283                            workspace,
284                            path_prefix_replacements,
285                        },
286                    );
287                    // Observe any errors.
288                    let _ = rx.recv().await?.map_err(anyhow::Error::msg)?;
289                }
290            }
291
292            // Trigger hot reload on all ranks that use/share this workspace.
293            if let Some(workspace_shape) = reload {
294                let (tx, rx) = cx.open_port::<Result<(), String>>();
295                let tx = tx.bind();
296                let rank = self
297                    .rank
298                    .get()
299                    .ok_or_else(|| anyhow::anyhow!("missing rank"))?;
300                let mesh = self
301                    .self_mesh
302                    .get()
303                    .ok_or_else(|| anyhow::anyhow!("missing self mesh"))?;
304                let mesh = workspace_shape.downstream_mesh(mesh, *rank)?;
305                mesh.cast(
306                    cx,
307                    CodeSyncMessage::Reload {
308                        sender_rank: Some(*rank),
309                        result: tx.clone(),
310                    },
311                )?;
312                // Exclude self from the sync.
313                let len = Ranked::region(&mesh).num_ranks() - 1;
314                let _: ((), Vec<()>) = try_join!(
315                    // Run reload for this rank.
316                    self.reload(cx, self.rank.get().cloned(), tx),
317                    rx.take(len)
318                        .map(|res| res?.map_err(anyhow::Error::msg))
319                        .try_collect(),
320                )?;
321            }
322
323            anyhow::Ok(())
324        }
325        .await;
326        result.post(
327            cx,
328            res.map_err(|e| {
329                format!(
330                    "{:#?}",
331                    Err::<(), _>(e)
332                        .with_context(|| format!("code sync from {}", cx.self_addr()))
333                        .unwrap_err()
334                )
335            }),
336        );
337        Ok(())
338    }
339
340    async fn reload(
341        &mut self,
342        cx: &Context<Self>,
343        sender_rank: Option<usize>,
344        result: reference::PortRef<Result<(), String>>,
345    ) -> Result<()> {
346        if self
347            .rank
348            .get()
349            .is_some_and(|rank| sender_rank.is_some_and(|sender_rank| *rank == sender_rank))
350        {
351            return Ok(());
352        }
353        let res = async move {
354            let (tx, mut rx) = cx.open_port::<Result<(), String>>();
355            self.get_auto_reload_actor(cx)
356                .await?
357                .post(cx, AutoReloadMessage { result: tx.bind() });
358            rx.recv().await?.map_err(anyhow::Error::msg)?;
359            anyhow::Ok(())
360        }
361        .await;
362        result.post(
363            cx,
364            res.map_err(|e| {
365                format!(
366                    "{:#?}",
367                    Err::<(), _>(e)
368                        .with_context(|| format!("module reload from {}", cx.self_addr()))
369                        .unwrap_err()
370                )
371            }),
372        );
373        Ok(())
374    }
375}
376
377#[async_trait]
378impl Handler<SetActorMeshMessage> for CodeSyncManager {
379    async fn handle(&mut self, cx: &Context<Self>, msg: SetActorMeshMessage) -> Result<()> {
380        let mesh = self.self_mesh.get_or_init(|| msg.actor_mesh);
381        self.rank.get_or_init(|| {
382            mesh.iter()
383                .find(|(_, actor)| *actor.actor_addr() == *cx.self_addr())
384                .unwrap()
385                .0
386                .rank()
387        });
388        Ok(())
389    }
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub enum CodeSyncMethod {
394    Rsync,
395    CondaSync {
396        path_prefix_replacements: HashMap<PathBuf, WorkspaceLocation>,
397    },
398}
399
400pub async fn code_sync_mesh(
401    cx: &impl context::Actor,
402    actor_mesh: &hyperactor_mesh::ActorMeshRef<CodeSyncManager>,
403    local_workspace: PathBuf,
404    remote_workspace: WorkspaceConfig,
405    method: CodeSyncMethod,
406    auto_reload: bool,
407) -> Result<()> {
408    let instance = cx.instance();
409
410    // Create a slice of the actor mesh that only includes workspace "owners" (e.g. on multi-GPU hosts,
411    // only one of the ranks on that host will participate in the code sync).
412    let owner_shape = remote_workspace.shape.owners()?;
413    let actor_mesh = actor_mesh.sliced(owner_shape.region());
414    let num_ranks = Ranked::region(&actor_mesh).num_ranks();
415
416    let (method, method_fut) = match method {
417        CodeSyncMethod::Rsync => {
418            // Spawn a rsync daemon to accept incoming connections from actors.
419            // some machines (e.g. github CI) do not have ipv6, so try ipv6 then fallback to ipv4
420            let ipv6_lo: SocketAddr = "[::1]:0".parse()?;
421            let ipv4_lo: SocketAddr = "127.0.0.1:0".parse()?;
422            let addrs: [SocketAddr; 2] = [ipv6_lo, ipv4_lo];
423            let daemon =
424                RsyncDaemon::spawn(TcpListener::bind(&addrs[..]).await?, &local_workspace).await?;
425
426            let daemon_addr = *daemon.addr();
427            let (rsync_conns_tx, rsync_conns_rx) = instance.open_port::<Connect>();
428            (
429                Method::Rsync {
430                    connect: rsync_conns_tx.bind(),
431                },
432                // This async task will process rsync connection attempts concurrently, forwarding
433                // them to the rsync daemon above.
434                async move {
435                    let res = rsync_conns_rx
436                        .take(num_ranks)
437                        .err_into::<anyhow::Error>()
438                        .try_for_each_concurrent(None, |connect| async move {
439                            let (mut local, mut stream) = try_join!(
440                                TcpStream::connect(daemon_addr).err_into(),
441                                accept(instance, instance.self_addr().clone(), connect),
442                            )?;
443                            tokio::io::copy_bidirectional(&mut local, &mut stream).await?;
444                            Ok(())
445                        })
446                        .await;
447                    daemon.shutdown().await?;
448                    res?;
449                    anyhow::Ok(())
450                }
451                .boxed(),
452            )
453        }
454        CodeSyncMethod::CondaSync {
455            path_prefix_replacements,
456        } => {
457            let (conns_tx, conns_rx) = instance.open_port::<Connect>();
458            (
459                Method::CondaSync {
460                    connect: conns_tx.bind(),
461                    path_prefix_replacements,
462                },
463                async move {
464                    conns_rx
465                        .take(num_ranks)
466                        .err_into::<anyhow::Error>()
467                        .try_for_each_concurrent(None, |connect| async {
468                            let (mut read, mut write) =
469                                accept(instance, instance.self_addr().clone(), connect)
470                                    .await?
471                                    .into_split();
472                            let res = sender(&local_workspace, &mut read, &mut write).await;
473
474                            // Shutdown our end, then read from the other end till exhaustion to avoid undeliverable
475                            // message spam.
476                            write.shutdown().await?;
477                            let mut buf = vec![];
478                            read.read_to_end(&mut buf).await?;
479
480                            res
481                        })
482                        .await
483                }
484                .boxed(),
485            )
486        }
487    };
488
489    let ((), ()) = try_join!(
490        method_fut,
491        // This async task will cast the code sync message to workspace owners, and process any errors.
492        async move {
493            let (result_tx, result_rx) = instance.open_port::<Result<(), String>>();
494            actor_mesh.cast(
495                instance,
496                CodeSyncMessage::Sync {
497                    method,
498                    workspace: remote_workspace.location.clone(),
499                    reload: if auto_reload {
500                        Some(remote_workspace.shape)
501                    } else {
502                        None
503                    },
504                    result: result_tx.bind(),
505                },
506            )?;
507
508            // Wait for all actors to report result.
509            let results = result_rx.take(num_ranks).try_collect::<Vec<_>>().await?;
510
511            // Combine all errors into one.
512            let mut errs = ErrorStash::<_, _, anyhow::Error>::new(|| "remote failures");
513            results
514                .into_iter()
515                .map(|res| res.map_err(anyhow::Error::msg))
516                .try_collect_or_stash::<()>(&mut errs);
517            Ok(errs.into_result()?)
518        },
519    )?;
520
521    Ok(())
522}
523
524#[cfg(test)]
525mod tests {
526    use anyhow::anyhow;
527    use hyperactor_mesh::context;
528    use hyperactor_mesh::test_utils;
529    use ndslice::shape;
530    use tempfile::TempDir;
531    use tokio::fs;
532
533    use super::*;
534
535    #[test]
536    fn test_workspace_shape_owners() {
537        // Create a shape with multiple dimensions
538        let shape = shape! { host = 2, replica = 3 };
539
540        // Test case 1: dimension is None (should return the original shape)
541        let ws_shape = WorkspaceShape {
542            shape: shape.clone(),
543            dimension: None,
544        };
545        let owners = ws_shape.owners().unwrap();
546        assert_eq!(owners.slice().len(), 6); // 2 hosts * 3 replicas = 6 ranks
547
548        // Test case 2: dimension is "host" (should return a shape with only one rank per host)
549        let ws_shape = WorkspaceShape {
550            shape: shape.clone(),
551            dimension: Some("host".to_string()),
552        };
553        let owners = ws_shape.owners().unwrap();
554        assert_eq!(owners.slice().len(), 1); // 2 hosts, 1 rank per host
555
556        // Test case 3: dimension is "replica" (should return a shape with only one rank per replica)
557        let ws_shape = WorkspaceShape {
558            shape: shape.clone(),
559            dimension: Some("replica".to_string()),
560        };
561        let owners = ws_shape.owners().unwrap();
562        assert_eq!(owners.slice().len(), 2); // 3 replicas, 1 rank per replica
563    }
564
565    #[test]
566    fn test_workspace_shape_downstream() -> Result<()> {
567        // Create a shape with multiple dimensions
568        let shape = shape! { host = 2, replica = 3 };
569
570        // Test case 1: dimension is None (should return a shape with just the specified rank)
571        let ws_shape = WorkspaceShape {
572            shape: shape.clone(),
573            dimension: None,
574        };
575        let downstream = ws_shape.downstream(0)?;
576        assert_eq!(downstream.slice().len(), 1); // Just rank 0
577
578        // Test case 2: dimension is "host" (should return a shape with all ranks on the same host)
579        let ws_shape = WorkspaceShape {
580            shape: shape.clone(),
581            dimension: Some("host".to_string()),
582        };
583        let downstream = ws_shape.downstream(0)?;
584        assert_eq!(downstream.slice().len(), 6); // All ranks in the shape
585        assert!(ws_shape.downstream(3).is_err());
586
587        // Test case 3: dimension is "e (should return a shape with all ranks on the same host)
588        let ws_shape = WorkspaceShape {
589            shape: shape.clone(),
590            dimension: Some("replica".to_string()),
591        };
592        let downstream = ws_shape.downstream(0)?;
593        assert_eq!(downstream.slice().len(), 3);
594        let downstream = ws_shape.downstream(3)?;
595        assert_eq!(downstream.slice().len(), 3);
596
597        Ok(())
598    }
599
600    #[cfg_attr(not(target_os = "linux"), ignore = "linux-only")]
601    #[tokio::test]
602    async fn test_code_sync_manager_and_mesh() -> Result<()> {
603        // Create source workspace with test files
604        let source_workspace = TempDir::new()?;
605        fs::write(source_workspace.path().join("test1.txt"), "content1").await?;
606        fs::write(source_workspace.path().join("test2.txt"), "content2").await?;
607        fs::create_dir(source_workspace.path().join("subdir")).await?;
608        fs::write(source_workspace.path().join("subdir/test3.txt"), "content3").await?;
609
610        // Create target workspace for the actors
611        let target_workspace = TempDir::new()?;
612        fs::create_dir(target_workspace.path().join("subdir5")).await?;
613        fs::write(target_workspace.path().join("foo.txt"), "something").await?;
614
615        // TODO: thread through context, or access the actual python context;
616        // for now this is basically equivalent (arguably better) to using the proc mesh client.
617        let cx = context().await;
618        let instance = cx.actor_instance;
619        // Set up actor mesh with CodeSyncManager actors
620        let mut host_mesh = test_utils::local_host_mesh(2).await;
621        let proc_mesh = host_mesh
622            .spawn(
623                instance,
624                "code_sync_test",
625                ndslice::Extent::unity(),
626                None,
627                None,
628            )
629            .await
630            .unwrap();
631
632        // Create CodeSyncManagerParams
633        let params = CodeSyncManagerParams {};
634
635        // Spawn actor mesh with CodeSyncManager actors
636        let actor_mesh = proc_mesh
637            .spawn_service(&instance, "code_sync_test", &params)
638            .await?;
639
640        // Set up the mesh reference on each actor
641        actor_mesh.cast(
642            &instance,
643            SetActorMeshMessage {
644                actor_mesh: (*actor_mesh).clone(),
645            },
646        )?;
647
648        // Create workspace configuration
649        let remote_workspace_config = WorkspaceConfig {
650            location: WorkspaceLocation::Constant(target_workspace.path().to_path_buf()),
651            shape: WorkspaceShape {
652                shape: shape! { replica = 2 },
653                dimension: Some("replica".to_string()),
654            },
655        };
656
657        // Test code_sync_mesh function - this coordinates sync operations across the mesh
658        // Test without auto-reload first
659        code_sync_mesh(
660            instance,
661            &actor_mesh,
662            source_workspace.path().to_path_buf(),
663            remote_workspace_config.clone(),
664            CodeSyncMethod::Rsync,
665            false, // no auto-reload
666        )
667        .await?;
668
669        // Verify that files were synchronized correctly
670        assert!(
671            !dir_diff::is_different(&source_workspace, &target_workspace)
672                .map_err(|e| anyhow!("{:?}", e))?,
673            "Source and target workspaces should be identical after sync"
674        );
675
676        let _ = host_mesh.shutdown(instance).await;
677        Ok(())
678    }
679}