1use 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#[derive(Clone, Serialize, Deserialize, Debug)]
76pub struct WorkspaceShape {
77 pub shape: Shape,
79 pub dimension: Option<String>,
81}
82
83impl WorkspaceShape {
84 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 }
100 Ok(new_shape)
101 }
102
103 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 method: Method,
159 reload: Option<WorkspaceShape>,
161 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 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 let _ = rx.recv().await?.map_err(anyhow::Error::msg)?;
273 }
274 Method::CondaSync {
275 connect,
276 path_prefix_replacements,
277 } => {
278 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 let _ = rx.recv().await?.map_err(anyhow::Error::msg)?;
292 }
293 }
294
295 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 let len = Ranked::region(&mesh).num_ranks() - 1;
317 let _: ((), Vec<()>) = try_join!(
318 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 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 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 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 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 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 let results = result_rx.take(num_ranks).try_collect::<Vec<_>>().await?;
513
514 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 let shape = shape! { host = 2, replica = 3 };
542
543 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); 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); 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); }
567
568 #[test]
569 fn test_workspace_shape_downstream() -> Result<()> {
570 let shape = shape! { host = 2, replica = 3 };
572
573 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); 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); assert!(ws_shape.downstream(3).is_err());
589
590 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 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 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 let cx = context().await;
621 let instance = cx.actor_instance;
622 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 let params = CodeSyncManagerParams {};
637
638 let actor_mesh = proc_mesh
640 .spawn_service(&instance, "code_sync_test", ¶ms)
641 .await?;
642
643 actor_mesh.cast(
645 &instance,
646 SetActorMeshMessage {
647 actor_mesh: (*actor_mesh).clone(),
648 },
649 )?;
650
651 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 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, )
670 .await?;
671
672 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}