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::ActorEnvironment;
27use hyperactor::ActorHandle;
28use hyperactor::Context;
29use hyperactor::Endpoint as _;
30use hyperactor::Handler;
31use hyperactor::RemoteSpawn;
32use hyperactor::context;
33use hyperactor::handle;
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(
204        CodeSyncManagerParams {}: Self::Params,
205        _environment: &ActorEnvironment,
206    ) -> Result<Self> {
207        Ok(Self {
208            rsync: OnceCell::new(),
209            auto_reload: OnceCell::new(),
210            conda_sync: OnceCell::new(),
211            self_mesh: OnceLock::new(),
212            rank: OnceLock::new(),
213        })
214    }
215}
216
217impl CodeSyncManager {
218    async fn get_rsync_actor<'a>(
219        &'a mut self,
220        cx: &Context<'a, Self>,
221    ) -> Result<&'a ActorHandle<RsyncActor>> {
222        self.rsync
223            .get_or_try_init(async move { Ok(cx.spawn(RsyncActor::default())) })
224            .await
225    }
226
227    async fn get_auto_reload_actor<'a>(
228        &'a mut self,
229        cx: &Context<'a, Self>,
230    ) -> Result<&'a ActorHandle<AutoReloadActor>> {
231        self.auto_reload
232            .get_or_try_init(async move { Ok(cx.spawn(AutoReloadActor::new().await?)) })
233            .await
234    }
235
236    async fn get_conda_sync_actor<'a>(
237        &'a mut self,
238        cx: &Context<'a, Self>,
239    ) -> Result<&'a ActorHandle<CondaSyncActor>> {
240        self.conda_sync
241            .get_or_try_init(async move { Ok(cx.spawn(CondaSyncActor::default())) })
242            .await
243    }
244}
245
246#[async_trait]
247#[handle(CodeSyncMessage)]
248impl CodeSyncMessageHandler for CodeSyncManager {
249    async fn sync(
250        &mut self,
251        cx: &Context<Self>,
252        workspace: WorkspaceLocation,
253        method: Method,
254        reload: Option<WorkspaceShape>,
255        result: reference::PortRef<Result<(), String>>,
256    ) -> Result<()> {
257        let res = async move {
258            match method {
259                Method::Rsync { connect } => {
260                    // Forward rsync connection port to the RsyncActor, which will do the actual
261                    // connection and run the client.
262                    let (tx, mut rx) = cx.open_port::<Result<RsyncResult, String>>();
263                    self.get_rsync_actor(cx).await?.post(
264                        cx,
265                        RsyncMessage {
266                            connect,
267                            result: tx.bind(),
268                            workspace,
269                        },
270                    );
271                    // Observe any errors.
272                    let _ = rx.recv().await?.map_err(anyhow::Error::msg)?;
273                }
274                Method::CondaSync {
275                    connect,
276                    path_prefix_replacements,
277                } => {
278                    // Forward rsync connection port to the RsyncActor, which will do the actual
279                    // connection and run the client.
280                    let (tx, mut rx) = cx.open_port::<Result<CondaSyncResult, String>>();
281                    self.get_conda_sync_actor(cx).await?.post(
282                        cx,
283                        CondaSyncMessage {
284                            connect,
285                            result: tx.bind(),
286                            workspace,
287                            path_prefix_replacements,
288                        },
289                    );
290                    // Observe any errors.
291                    let _ = rx.recv().await?.map_err(anyhow::Error::msg)?;
292                }
293            }
294
295            // Trigger hot reload on all ranks that use/share this workspace.
296            if let Some(workspace_shape) = reload {
297                let (tx, rx) = cx.open_port::<Result<(), String>>();
298                let tx = tx.bind();
299                let rank = self
300                    .rank
301                    .get()
302                    .ok_or_else(|| anyhow::anyhow!("missing rank"))?;
303                let mesh = self
304                    .self_mesh
305                    .get()
306                    .ok_or_else(|| anyhow::anyhow!("missing self mesh"))?;
307                let mesh = workspace_shape.downstream_mesh(mesh, *rank)?;
308                mesh.cast(
309                    cx,
310                    CodeSyncMessage::Reload {
311                        sender_rank: Some(*rank),
312                        result: tx.clone(),
313                    },
314                )?;
315                // Exclude self from the sync.
316                let len = Ranked::region(&mesh).num_ranks() - 1;
317                let _: ((), Vec<()>) = try_join!(
318                    // Run reload for this rank.
319                    self.reload(cx, self.rank.get().cloned(), tx),
320                    rx.take(len)
321                        .map(|res| res?.map_err(anyhow::Error::msg))
322                        .try_collect(),
323                )?;
324            }
325
326            anyhow::Ok(())
327        }
328        .await;
329        result.post(
330            cx,
331            res.map_err(|e| {
332                format!(
333                    "{:#?}",
334                    Err::<(), _>(e)
335                        .with_context(|| format!("code sync from {}", cx.self_addr()))
336                        .unwrap_err()
337                )
338            }),
339        );
340        Ok(())
341    }
342
343    async fn reload(
344        &mut self,
345        cx: &Context<Self>,
346        sender_rank: Option<usize>,
347        result: reference::PortRef<Result<(), String>>,
348    ) -> Result<()> {
349        if self
350            .rank
351            .get()
352            .is_some_and(|rank| sender_rank.is_some_and(|sender_rank| *rank == sender_rank))
353        {
354            return Ok(());
355        }
356        let res = async move {
357            let (tx, mut rx) = cx.open_port::<Result<(), String>>();
358            self.get_auto_reload_actor(cx)
359                .await?
360                .post(cx, AutoReloadMessage { result: tx.bind() });
361            rx.recv().await?.map_err(anyhow::Error::msg)?;
362            anyhow::Ok(())
363        }
364        .await;
365        result.post(
366            cx,
367            res.map_err(|e| {
368                format!(
369                    "{:#?}",
370                    Err::<(), _>(e)
371                        .with_context(|| format!("module reload from {}", cx.self_addr()))
372                        .unwrap_err()
373                )
374            }),
375        );
376        Ok(())
377    }
378}
379
380#[async_trait]
381impl Handler<SetActorMeshMessage> for CodeSyncManager {
382    async fn handle(&mut self, cx: &Context<Self>, msg: SetActorMeshMessage) -> Result<()> {
383        let mesh = self.self_mesh.get_or_init(|| msg.actor_mesh);
384        self.rank.get_or_init(|| {
385            mesh.iter()
386                .find(|(_, actor)| *actor.actor_addr() == *cx.self_addr())
387                .unwrap()
388                .0
389                .rank()
390        });
391        Ok(())
392    }
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub enum CodeSyncMethod {
397    Rsync,
398    CondaSync {
399        path_prefix_replacements: HashMap<PathBuf, WorkspaceLocation>,
400    },
401}
402
403pub async fn code_sync_mesh(
404    cx: &impl context::Actor,
405    actor_mesh: &hyperactor_mesh::ActorMeshRef<CodeSyncManager>,
406    local_workspace: PathBuf,
407    remote_workspace: WorkspaceConfig,
408    method: CodeSyncMethod,
409    auto_reload: bool,
410) -> Result<()> {
411    let instance = cx.instance();
412
413    // Create a slice of the actor mesh that only includes workspace "owners" (e.g. on multi-GPU hosts,
414    // only one of the ranks on that host will participate in the code sync).
415    let owner_shape = remote_workspace.shape.owners()?;
416    let actor_mesh = actor_mesh.sliced(owner_shape.region());
417    let num_ranks = Ranked::region(&actor_mesh).num_ranks();
418
419    let (method, method_fut) = match method {
420        CodeSyncMethod::Rsync => {
421            // Spawn a rsync daemon to accept incoming connections from actors.
422            // some machines (e.g. github CI) do not have ipv6, so try ipv6 then fallback to ipv4
423            let ipv6_lo: SocketAddr = "[::1]:0".parse()?;
424            let ipv4_lo: SocketAddr = "127.0.0.1:0".parse()?;
425            let addrs: [SocketAddr; 2] = [ipv6_lo, ipv4_lo];
426            let daemon =
427                RsyncDaemon::spawn(TcpListener::bind(&addrs[..]).await?, &local_workspace).await?;
428
429            let daemon_addr = *daemon.addr();
430            let (rsync_conns_tx, rsync_conns_rx) = instance.open_port::<Connect>();
431            (
432                Method::Rsync {
433                    connect: rsync_conns_tx.bind(),
434                },
435                // This async task will process rsync connection attempts concurrently, forwarding
436                // them to the rsync daemon above.
437                async move {
438                    let res = rsync_conns_rx
439                        .take(num_ranks)
440                        .err_into::<anyhow::Error>()
441                        .try_for_each_concurrent(None, |connect| async move {
442                            let (mut local, mut stream) = try_join!(
443                                TcpStream::connect(daemon_addr).err_into(),
444                                accept(instance, instance.self_addr().clone(), connect),
445                            )?;
446                            tokio::io::copy_bidirectional(&mut local, &mut stream).await?;
447                            Ok(())
448                        })
449                        .await;
450                    daemon.shutdown().await?;
451                    res?;
452                    anyhow::Ok(())
453                }
454                .boxed(),
455            )
456        }
457        CodeSyncMethod::CondaSync {
458            path_prefix_replacements,
459        } => {
460            let (conns_tx, conns_rx) = instance.open_port::<Connect>();
461            (
462                Method::CondaSync {
463                    connect: conns_tx.bind(),
464                    path_prefix_replacements,
465                },
466                async move {
467                    conns_rx
468                        .take(num_ranks)
469                        .err_into::<anyhow::Error>()
470                        .try_for_each_concurrent(None, |connect| async {
471                            let (mut read, mut write) =
472                                accept(instance, instance.self_addr().clone(), connect)
473                                    .await?
474                                    .into_split();
475                            let res = sender(&local_workspace, &mut read, &mut write).await;
476
477                            // Shutdown our end, then read from the other end till exhaustion to avoid undeliverable
478                            // message spam.
479                            write.shutdown().await?;
480                            let mut buf = vec![];
481                            read.read_to_end(&mut buf).await?;
482
483                            res
484                        })
485                        .await
486                }
487                .boxed(),
488            )
489        }
490    };
491
492    let ((), ()) = try_join!(
493        method_fut,
494        // This async task will cast the code sync message to workspace owners, and process any errors.
495        async move {
496            let (result_tx, result_rx) = instance.open_port::<Result<(), String>>();
497            actor_mesh.cast(
498                instance,
499                CodeSyncMessage::Sync {
500                    method,
501                    workspace: remote_workspace.location.clone(),
502                    reload: if auto_reload {
503                        Some(remote_workspace.shape)
504                    } else {
505                        None
506                    },
507                    result: result_tx.bind(),
508                },
509            )?;
510
511            // Wait for all actors to report result.
512            let results = result_rx.take(num_ranks).try_collect::<Vec<_>>().await?;
513
514            // Combine all errors into one.
515            let mut errs = ErrorStash::<_, _, anyhow::Error>::new(|| "remote failures");
516            results
517                .into_iter()
518                .map(|res| res.map_err(anyhow::Error::msg))
519                .try_collect_or_stash::<()>(&mut errs);
520            Ok(errs.into_result()?)
521        },
522    )?;
523
524    Ok(())
525}
526
527#[cfg(test)]
528mod tests {
529    use anyhow::anyhow;
530    use hyperactor_mesh::context;
531    use hyperactor_mesh::test_utils;
532    use ndslice::shape;
533    use tempfile::TempDir;
534    use tokio::fs;
535
536    use super::*;
537
538    #[test]
539    fn test_workspace_shape_owners() {
540        // Create a shape with multiple dimensions
541        let shape = shape! { host = 2, replica = 3 };
542
543        // Test case 1: dimension is None (should return the original shape)
544        let ws_shape = WorkspaceShape {
545            shape: shape.clone(),
546            dimension: None,
547        };
548        let owners = ws_shape.owners().unwrap();
549        assert_eq!(owners.slice().len(), 6); // 2 hosts * 3 replicas = 6 ranks
550
551        // Test case 2: dimension is "host" (should return a shape with only one rank per host)
552        let ws_shape = WorkspaceShape {
553            shape: shape.clone(),
554            dimension: Some("host".to_string()),
555        };
556        let owners = ws_shape.owners().unwrap();
557        assert_eq!(owners.slice().len(), 1); // 2 hosts, 1 rank per host
558
559        // Test case 3: dimension is "replica" (should return a shape with only one rank per replica)
560        let ws_shape = WorkspaceShape {
561            shape: shape.clone(),
562            dimension: Some("replica".to_string()),
563        };
564        let owners = ws_shape.owners().unwrap();
565        assert_eq!(owners.slice().len(), 2); // 3 replicas, 1 rank per replica
566    }
567
568    #[test]
569    fn test_workspace_shape_downstream() -> Result<()> {
570        // Create a shape with multiple dimensions
571        let shape = shape! { host = 2, replica = 3 };
572
573        // Test case 1: dimension is None (should return a shape with just the specified rank)
574        let ws_shape = WorkspaceShape {
575            shape: shape.clone(),
576            dimension: None,
577        };
578        let downstream = ws_shape.downstream(0)?;
579        assert_eq!(downstream.slice().len(), 1); // Just rank 0
580
581        // Test case 2: dimension is "host" (should return a shape with all ranks on the same host)
582        let ws_shape = WorkspaceShape {
583            shape: shape.clone(),
584            dimension: Some("host".to_string()),
585        };
586        let downstream = ws_shape.downstream(0)?;
587        assert_eq!(downstream.slice().len(), 6); // All ranks in the shape
588        assert!(ws_shape.downstream(3).is_err());
589
590        // Test case 3: dimension is "e (should return a shape with all ranks on the same host)
591        let ws_shape = WorkspaceShape {
592            shape: shape.clone(),
593            dimension: Some("replica".to_string()),
594        };
595        let downstream = ws_shape.downstream(0)?;
596        assert_eq!(downstream.slice().len(), 3);
597        let downstream = ws_shape.downstream(3)?;
598        assert_eq!(downstream.slice().len(), 3);
599
600        Ok(())
601    }
602
603    #[cfg_attr(not(target_os = "linux"), ignore = "linux-only")]
604    #[tokio::test]
605    async fn test_code_sync_manager_and_mesh() -> Result<()> {
606        // Create source workspace with test files
607        let source_workspace = TempDir::new()?;
608        fs::write(source_workspace.path().join("test1.txt"), "content1").await?;
609        fs::write(source_workspace.path().join("test2.txt"), "content2").await?;
610        fs::create_dir(source_workspace.path().join("subdir")).await?;
611        fs::write(source_workspace.path().join("subdir/test3.txt"), "content3").await?;
612
613        // Create target workspace for the actors
614        let target_workspace = TempDir::new()?;
615        fs::create_dir(target_workspace.path().join("subdir5")).await?;
616        fs::write(target_workspace.path().join("foo.txt"), "something").await?;
617
618        // TODO: thread through context, or access the actual python context;
619        // for now this is basically equivalent (arguably better) to using the proc mesh client.
620        let cx = context().await;
621        let instance = cx.actor_instance;
622        // Set up actor mesh with CodeSyncManager actors
623        let mut host_mesh = test_utils::local_host_mesh(2).await;
624        let proc_mesh = host_mesh
625            .spawn(
626                instance,
627                "code_sync_test",
628                ndslice::Extent::unity(),
629                None,
630                None,
631            )
632            .await
633            .unwrap();
634
635        // Create CodeSyncManagerParams
636        let params = CodeSyncManagerParams {};
637
638        // Spawn actor mesh with CodeSyncManager actors
639        let actor_mesh = proc_mesh
640            .spawn_service(&instance, "code_sync_test", &params)
641            .await?;
642
643        // Set up the mesh reference on each actor
644        actor_mesh.cast(
645            &instance,
646            SetActorMeshMessage {
647                actor_mesh: (*actor_mesh).clone(),
648            },
649        )?;
650
651        // Create workspace configuration
652        let remote_workspace_config = WorkspaceConfig {
653            location: WorkspaceLocation::Constant(target_workspace.path().to_path_buf()),
654            shape: WorkspaceShape {
655                shape: shape! { replica = 2 },
656                dimension: Some("replica".to_string()),
657            },
658        };
659
660        // Test code_sync_mesh function - this coordinates sync operations across the mesh
661        // Test without auto-reload first
662        code_sync_mesh(
663            instance,
664            &actor_mesh,
665            source_workspace.path().to_path_buf(),
666            remote_workspace_config.clone(),
667            CodeSyncMethod::Rsync,
668            false, // no auto-reload
669        )
670        .await?;
671
672        // Verify that files were synchronized correctly
673        assert!(
674            !dir_diff::is_different(&source_workspace, &target_workspace)
675                .map_err(|e| anyhow!("{:?}", e))?,
676            "Source and target workspaces should be identical after sync"
677        );
678
679        let _ = host_mesh.shutdown(instance).await;
680        Ok(())
681    }
682}