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::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#[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(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 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 let _ = rx.recv().await?.map_err(anyhow::Error::msg)?;
270 }
271 Method::CondaSync {
272 connect,
273 path_prefix_replacements,
274 } => {
275 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 let _ = rx.recv().await?.map_err(anyhow::Error::msg)?;
289 }
290 }
291
292 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 let len = Ranked::region(&mesh).num_ranks() - 1;
314 let _: ((), Vec<()>) = try_join!(
315 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 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 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 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 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 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 let results = result_rx.take(num_ranks).try_collect::<Vec<_>>().await?;
510
511 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 let shape = shape! { host = 2, replica = 3 };
539
540 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); 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); 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); }
564
565 #[test]
566 fn test_workspace_shape_downstream() -> Result<()> {
567 let shape = shape! { host = 2, replica = 3 };
569
570 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); 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); assert!(ws_shape.downstream(3).is_err());
586
587 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 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 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 let cx = context().await;
618 let instance = cx.actor_instance;
619 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 let params = CodeSyncManagerParams {};
634
635 let actor_mesh = proc_mesh
637 .spawn_service(&instance, "code_sync_test", ¶ms)
638 .await?;
639
640 actor_mesh.cast(
642 &instance,
643 SetActorMeshMessage {
644 actor_mesh: (*actor_mesh).clone(),
645 },
646 )?;
647
648 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 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, )
667 .await?;
668
669 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}