1use std::collections::HashMap;
12use std::sync::Arc;
13
14use anyhow::Result;
15use async_trait::async_trait;
16use hyperactor::Actor;
17use hyperactor::ActorAddr;
18use hyperactor::ActorRef;
19use hyperactor::Context;
20use hyperactor::Endpoint as _;
21use hyperactor::Handler;
22use hyperactor::Instance;
23use hyperactor::Label;
24use hyperactor::OncePortRefRepr;
25use hyperactor::PortRef;
26use hyperactor::PortRefRepr;
27use hyperactor::RemoteEndpoint as _;
28use hyperactor::Uid;
29use hyperactor::accum::ReducerMode;
30use hyperactor::context;
31use hyperactor::mailbox::MailboxSender;
32use hyperactor::mailbox::MessageEnvelope;
33use hyperactor::mailbox::Undeliverable;
34use hyperactor::mailbox::UndeliverableMailboxSender;
35use hyperactor::mailbox::UndeliverableMessageError;
36use hyperactor::mailbox::monitored_return_handle;
37use hyperactor::ordering::SEQ_INFO;
38use hyperactor::ordering::SeqInfo;
39use hyperactor::port::Port;
40use hyperactor::value_mesh::ValueMesh;
41use hyperactor_config::Flattrs;
42use ndslice::Point;
43use ndslice::Region;
44use ndslice::view::MapIntoExt;
45use ndslice::view::View;
46use serde::Deserialize;
47use serde::Serialize;
48use typeuri::Named;
49use uuid::Uuid;
50
51use crate::tile::MaterializedTile;
52use crate::tile::Tile;
53pub use crate::tile::TilingPolicy;
54
55hyperactor_config::declare_attrs! {
56 pub attr CAST_POINT: Point;
59
60 pub attr CAST_ORIGINATING_SENDER: ActorAddr;
63
64 pub attr CAST_FAILURE_PHASE: String;
66
67 pub attr CAST_FAILURE_CAST_ACTOR: ActorAddr;
69
70 pub attr CAST_FAILURE_ORIGIN: ActorAddr;
72
73 pub attr CAST_FAILURE_RETURN_PORT: String;
75}
76
77#[cfg(test)]
78hyperactor_config::declare_attrs! {
79 pub attr CAST_LINEAGE: Vec<usize>;
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
91struct ResourceRankRepr(Option<usize>);
92
93impl Named for ResourceRankRepr {
94 fn typename() -> &'static str {
95 "hyperactor_mesh::resource::RankRepr"
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
104pub struct CastDomainId {
105 domain_id: Uid,
107}
108
109impl CastDomainId {
110 pub fn new() -> Self {
112 Self {
113 domain_id: Uid::anonymous(),
114 }
115 }
116
117 pub fn domain_id(&self) -> &Uid {
119 &self.domain_id
120 }
121
122 pub fn materialize(
129 self,
130 cx: &impl context::Actor,
131 members: HashMap<usize, ActorAddr>,
132 region: Region,
133 tiling_policy: TilingPolicy,
134 headers: Flattrs,
135 ) -> anyhow::Result<CastDomainRef> {
136 anyhow::ensure!(
137 members.len() == region.num_ranks()
138 && region
139 .slice()
140 .iter()
141 .all(|rank| members.contains_key(&rank)),
142 "members must contain exactly one actor address for every domain rank"
143 );
144
145 let root_rank = region.slice().offset();
146 let entry_point = members
147 .get(&root_rank)
148 .expect("members coverage was checked above")
149 .clone();
150 let member_mesh = Arc::new(ValueMesh::new(
151 region.clone(),
152 region
153 .slice()
154 .iter()
155 .map(|rank| {
156 members
157 .get(&rank)
158 .expect("members coverage was checked above")
159 .clone()
160 })
161 .collect(),
162 )?);
163
164 let domain_ref = CastDomainRef::from_entry_point(
165 self.clone(),
166 cast_actor_ref_for_member(&entry_point),
167 Arc::clone(&member_mesh),
168 );
169 let root_tile =
170 MaterializedTile::from_value_mesh_with_tile(Tile::from_view(®ion), member_mesh);
171
172 domain_ref.entry_point.port().post_with_headers(
173 cx,
174 headers,
175 CreateCastDomain {
176 cast_domain_id: self,
177 region,
178 tiling_policy,
179 tile: root_tile,
180 },
181 );
182 Ok(domain_ref)
183 }
184}
185
186impl std::fmt::Display for CastDomainId {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 write!(f, "{}", self.domain_id)
189 }
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct CastDomainRef {
199 id: CastDomainId,
200 entry_point: ActorRef<CastActor>,
202 members: Arc<ValueMesh<ActorAddr>>,
204}
205
206impl CastDomainRef {
207 fn from_entry_point(
209 id: CastDomainId,
210 entry_point: ActorRef<CastActor>,
211 members: Arc<ValueMesh<ActorAddr>>,
212 ) -> Self {
213 Self {
214 id,
215 entry_point,
216 members,
217 }
218 }
219
220 pub fn id(&self) -> CastDomainId {
222 self.id.clone()
223 }
224
225 pub fn domain_id(&self) -> &Uid {
227 self.id.domain_id()
228 }
229
230 pub fn entry_point(&self) -> &ActorRef<CastActor> {
232 &self.entry_point
233 }
234
235 pub fn members(&self) -> &ValueMesh<ActorAddr> {
237 &self.members
238 }
239
240 pub fn materialize_slice(
247 &self,
248 cx: &impl context::Actor,
249 region: Region,
250 tiling_policy: TilingPolicy,
251 ) -> anyhow::Result<CastDomainRef> {
252 let slice_id = CastDomainId::new();
253 let slice_member_mesh = Arc::new(self.members.subset(region.clone())?);
254 let slice_tile = MaterializedTile::from_value_mesh_with_tile(
255 Tile::from_view(®ion),
256 Arc::clone(&slice_member_mesh),
257 );
258
259 let slice_entrypoint = cast_actor_ref_for_member(
260 slice_tile
261 .root_item()
262 .ok_or_else(|| anyhow::anyhow!("slice root tile must have at least one member"))?,
263 );
264
265 let slice_ref = CastDomainRef::from_entry_point(
266 slice_id.clone(),
267 slice_entrypoint.clone(),
268 slice_member_mesh,
269 );
270
271 slice_entrypoint.post(
272 cx,
273 CreateCastDomain {
274 cast_domain_id: slice_id.clone(),
275 region,
276 tiling_policy,
277 tile: slice_tile,
278 },
279 );
280 Ok(slice_ref)
281 }
282
283 pub fn cast<M: Serialize + Named>(
289 &self,
290 cx: &impl context::Actor,
291 headers: Flattrs,
292 message: M,
293 ) -> anyhow::Result<()> {
294 let data = wirevalue::Any::<wirevalue::encoding::Multipart>::serialize(&message)?;
295 let sender = cx.mailbox().actor_addr().clone();
296 let dest_port = M::port();
297 let (session_id, seqs) = self.seqs_for_cast(cx, dest_port)?;
298
299 let cast_headers = headers.clone();
300 self.entry_point.port().post_with_headers(
301 cx,
302 headers,
303 CastMessage {
304 cast_domain_id: self.id.clone(),
305 sender,
306 session_id,
307 seqs,
308 #[cfg(test)]
309 lineage: Vec::new(),
310 headers: cast_headers,
311 dest_port,
312 data,
313 },
314 );
315 Ok(())
316 }
317
318 pub fn destroy(&self, cx: &impl context::Actor) {
327 self.entry_point.post(
328 cx,
329 DestroyCastDomain {
330 domain_id: self.id.clone(),
331 origin: cx.mailbox().actor_addr().clone(),
332 },
333 );
334 }
335
336 fn seqs_for_cast(
344 &self,
345 cx: &impl context::Actor,
346 dest_port: u64,
347 ) -> Result<(Uuid, ValueMesh<u64>)> {
348 let sequencer = cx.instance().sequencer();
349
350 let mut seqs: ValueMesh<u64> = self.members.as_ref().map_into(|member| {
351 let port = member.port_addr(Port::handler_id(dest_port, None));
352 let SeqInfo::Session { session_id: _, seq } = sequencer.assign_seq(&port) else {
353 unreachable!("assign_seq always returns SeqInfo::Session");
354 };
355 seq
356 });
357
358 seqs.compress_adjacent_in_place();
359
360 Ok((sequencer.session_id(), seqs))
361 }
362}
363
364fn next_tiles(
385 tiling_policy: TilingPolicy,
386 tile: &MaterializedTile<ActorAddr>,
387) -> Vec<MaterializedTile<ActorAddr>> {
388 tiling_policy
389 .children(tile.tile())
390 .into_iter()
391 .map(|child| tile.subtile(child))
392 .collect()
393}
394
395pub const CAST_ACTOR_NAME: &str = "cast";
400
401#[derive(Debug, Default)]
408#[hyperactor::export(
409 handlers = [
410 CreateCastDomain,
411 DestroyCastDomain,
412 CastMessage
413 ],
414)]
415#[hyperactor::spawnable]
416pub struct CastActor {
417 installed_hops: HashMap<CastDomainId, CastHop>,
419}
420
421#[derive(Debug, Clone)]
423struct CastHop {
424 point_in_domain: Point,
426 base_rank_in_domain: usize,
428 next_hops: Vec<ActorRef<CastActor>>,
430 local_actor: ActorAddr,
432}
433
434fn cast_actor_ref_for_member(member: &ActorAddr) -> ActorRef<CastActor> {
435 ActorRef::attest(ActorAddr::root(
436 member.proc_addr(),
437 Label::strip(CAST_ACTOR_NAME),
438 ))
439}
440
441fn annotate_cast_failure(
442 envelope: &mut MessageEnvelope,
443 cast_actor: &ActorAddr,
444 phase: &str,
445 origin: &ActorAddr,
446 return_port: &hyperactor::PortAddr,
447) {
448 if let Some(failure) = envelope.root_delivery_failure_mut() {
449 failure.attrs.set(CAST_FAILURE_PHASE, phase.to_string());
450 failure
451 .attrs
452 .set(CAST_FAILURE_CAST_ACTOR, cast_actor.clone());
453 failure.attrs.set(CAST_FAILURE_ORIGIN, origin.clone());
454 failure
455 .attrs
456 .set(CAST_FAILURE_RETURN_PORT, return_port.to_string());
457 }
458}
459
460#[async_trait]
461impl Actor for CastActor {
462 async fn init(&mut self, this: &Instance<Self>) -> Result<(), anyhow::Error> {
463 this.set_system();
464 Ok(())
465 }
466
467 async fn handle_undeliverable_message(
468 &mut self,
469 cx: &Instance<Self>,
470 _reason: hyperactor::mailbox::UndeliverableReason,
471 undelivered: Undeliverable<MessageEnvelope>,
472 ) -> Result<(), anyhow::Error> {
473 self.return_delivery_failure_to_origin(cx, undelivered)
474 .await
475 }
476
477 async fn handle_invalid_reference(
478 &mut self,
479 cx: &Instance<Self>,
480 _invalid: hyperactor::mailbox::InvalidReference,
481 undelivered: Undeliverable<MessageEnvelope>,
482 ) -> Result<(), anyhow::Error> {
483 self.return_delivery_failure_to_origin(cx, undelivered)
484 .await
485 }
486}
487
488impl CastActor {
489 async fn return_delivery_failure_to_origin(
490 &mut self,
491 cx: &Instance<Self>,
492 undelivered: Undeliverable<MessageEnvelope>,
493 ) -> Result<(), anyhow::Error> {
494 let mut message_envelope = match undelivered {
496 Undeliverable::Returned(message_envelope) => message_envelope,
497 Undeliverable::Report(report) => {
498 anyhow::bail!(UndeliverableMessageError::Report { report });
499 }
500 };
501
502 if let Ok(message) = message_envelope.deserialized::<CastMessage>() {
504 let return_port = PortRef::attest_handler_port(&message.sender);
505 annotate_cast_failure(
506 &mut message_envelope,
507 cx.self_addr(),
508 "forward",
509 &message.sender,
510 return_port.port_addr(),
511 );
512
513 message_envelope.set_header(CAST_ORIGINATING_SENDER, message.sender.clone());
516
517 return_port.post(cx, Undeliverable::Returned(message_envelope.clone()));
518 return Ok(());
519 }
520
521 if let Ok(message) = message_envelope.deserialized::<DestroyCastDomain>() {
523 let return_port = PortRef::attest_handler_port(&message.origin);
524 annotate_cast_failure(
525 &mut message_envelope,
526 cx.self_addr(),
527 "destroy",
528 &message.origin,
529 return_port.port_addr(),
530 );
531 message_envelope.set_header(CAST_ORIGINATING_SENDER, message.origin.clone());
532 return_port.post(cx, Undeliverable::Returned(message_envelope.clone()));
533 return Ok(());
534 }
535
536 if let Some(sender) = message_envelope.headers().get(CAST_ORIGINATING_SENDER) {
539 let return_port = PortRef::attest_handler_port(&sender);
540 annotate_cast_failure(
541 &mut message_envelope,
542 cx.self_addr(),
543 "deliver_here",
544 &sender,
545 return_port.port_addr(),
546 );
547 return_port.post(cx, Undeliverable::Returned(message_envelope.clone()));
548 return Ok(());
549 }
550
551 UndeliverableMailboxSender
553 .post(message_envelope, monitored_return_handle());
554 Ok(())
555 }
556}
557
558#[derive(Debug, Serialize, Deserialize, typeuri::Named)]
565struct CreateCastDomain {
566 cast_domain_id: CastDomainId,
567 region: Region,
568 tiling_policy: TilingPolicy,
569 tile: MaterializedTile<ActorAddr>,
570}
571wirevalue::register_type!(CreateCastDomain);
572
573#[async_trait]
574impl Handler<CreateCastDomain> for CastActor {
575 #[tracing::instrument(
576 level = "debug",
577 skip_all,
578 fields(
579 domain_id = %message.cast_domain_id,
580 rank = message.tile.root_rank(),
581 num_members = message.tile.rank_count(),
582 )
583 )]
584 async fn handle(
585 &mut self,
586 cx: &Context<Self>,
587 message: CreateCastDomain,
588 ) -> Result<(), anyhow::Error> {
589 let CreateCastDomain {
590 cast_domain_id,
591 region,
592 tiling_policy,
593 tile,
594 } = message;
595 if self.installed_hops.contains_key(&cast_domain_id) {
596 return Ok(());
597 }
598
599 let mut next_hops = Vec::new();
600
601 for next_tile in next_tiles(tiling_policy, &tile) {
602 let next_hop_cast_actor = cast_actor_ref_for_member(
603 next_tile
604 .root_item()
605 .ok_or_else(|| anyhow::anyhow!("next tile must have at least one member"))?,
606 );
607
608 next_hop_cast_actor.post(
609 cx,
610 CreateCastDomain {
611 cast_domain_id: cast_domain_id.clone(),
612 region: region.clone(),
613 tiling_policy,
614 tile: next_tile,
615 },
616 );
617
618 next_hops.push(next_hop_cast_actor);
619 }
620
621 let cast_hop = CastHop {
622 point_in_domain: region.point_of_base_rank(tile.root_rank())?,
623 base_rank_in_domain: tile.root_rank(),
624 next_hops,
625 local_actor: tile
626 .root_item()
627 .ok_or_else(|| anyhow::anyhow!("tile must have at least one member"))?
628 .clone(),
629 };
630
631 #[cfg(test)]
632 {
633 tests::capture_installed_domain(cx, cast_domain_id.domain_id(), &cast_hop);
634 }
635
636 self.installed_hops.insert(cast_domain_id, cast_hop);
637
638 Ok(())
639 }
640}
641
642fn split_ports(
650 cx: &Context<'_, CastActor>,
651 data: &mut wirevalue::Any<wirevalue::encoding::Multipart>,
652 num_next_hops: usize,
653 deliver_here: bool,
654) -> Result<()> {
655 data.visit_multipart_parts_mut::<PortRefRepr, anyhow::Error>(|port| {
656 if port.unsplit() {
657 return Ok(());
658 }
659
660 let split = port.port_addr().split(
661 cx,
662 port.reducer_spec().clone(),
663 ReducerMode::Streaming(port.streaming_opts().clone()),
664 port.get_return_undeliverable(),
665 )?;
666
667 #[cfg(test)]
668 {
669 tests::collect_split_port(port.port_addr(), &split, deliver_here);
670 }
671
672 port.update_port_addr(split);
673 Ok(())
674 })?;
675
676 data.visit_multipart_parts_mut::<OncePortRefRepr, anyhow::Error>(|port| {
677 if port.unsplit() || port.reducer_spec().is_none() {
678 return Ok(());
681 }
682
683 let peer_count = num_next_hops + if deliver_here { 1 } else { 0 };
684 let split = port.port_addr().split(
685 cx,
686 port.reducer_spec().clone(),
687 ReducerMode::Once(peer_count),
688 true,
689 )?;
690
691 #[cfg(test)]
692 {
693 tests::collect_split_port(port.port_addr(), &split, deliver_here);
694 }
695
696 port.update_port_addr(split);
697 Ok(())
698 })?;
699
700 Ok(())
701}
702
703#[derive(Debug, Clone, Default)]
709struct ForwardLineage {
710 #[cfg(test)]
711 ranks: Vec<usize>,
712}
713
714impl ForwardLineage {
715 #[cfg(test)]
716 fn from_message(message: &CastMessage) -> Self {
717 Self {
718 ranks: message.lineage.clone(),
719 }
720 }
721
722 #[cfg(not(test))]
723 fn from_message(_message: &CastMessage) -> Self {
724 Self {}
725 }
726
727 fn through(&self, rank: usize) -> Self {
728 #[cfg(test)]
729 {
730 let mut ranks = self.ranks.clone();
731 ranks.push(rank);
732 Self { ranks }
733 }
734 #[cfg(not(test))]
735 {
736 let _ = rank;
737 Self {}
738 }
739 }
740
741 #[cfg(test)]
742 fn ranks(&self) -> Vec<usize> {
743 self.ranks.clone()
744 }
745}
746
747#[derive(Debug, Serialize, Deserialize, typeuri::Named)]
753struct CastMessage {
754 cast_domain_id: CastDomainId,
756 sender: ActorAddr,
758 session_id: Uuid,
760 seqs: ValueMesh<u64>,
762 #[cfg(test)]
764 lineage: Vec<usize>,
765 headers: Flattrs,
767 dest_port: u64,
769 data: wirevalue::Any<wirevalue::encoding::Multipart>,
771}
772
773wirevalue::register_type!(CastMessage);
774
775#[async_trait]
776impl Handler<CastMessage> for CastActor {
777 #[tracing::instrument(
778 level = "debug",
779 skip_all,
780 fields(
781 domain_id = %message.cast_domain_id.domain_id(),
782 )
783 )]
784 async fn handle(
785 &mut self,
786 cx: &Context<Self>,
787 message: CastMessage,
788 ) -> Result<(), anyhow::Error> {
789 let lineage = ForwardLineage::from_message(&message);
790 let domain = self
791 .installed_hops
792 .get(&message.cast_domain_id)
793 .ok_or_else(|| anyhow::anyhow!("unknown domain {}", message.cast_domain_id))?;
794
795 let mut data = message.data.clone();
799 split_ports(cx, &mut data, domain.next_hops.len(), true)?;
800
801 let local_lineage = lineage.through(domain.base_rank_in_domain);
802
803 {
805 let mut local_data = data.clone();
806
807 let rank = domain.point_in_domain.rank();
808
809 local_data.visit_multipart_parts_mut::<ResourceRankRepr, anyhow::Error>(
810 |ResourceRankRepr(resource_rank)| {
811 *resource_rank = Some(rank);
812 Ok(())
813 },
814 )?;
815
816 let seq = *message
817 .seqs
818 .get_by_base_rank(domain.base_rank_in_domain)
819 .ok_or_else(|| {
820 anyhow::anyhow!("missing seq for base rank {}", domain.base_rank_in_domain)
821 })?;
822 let mut headers = message.headers.clone();
823 headers.set(CAST_POINT, domain.point_in_domain.clone());
824 headers.set(CAST_ORIGINATING_SENDER, message.sender.clone());
825 let seq_info = SeqInfo::Session {
826 session_id: message.session_id,
827 seq,
828 };
829 headers.set(SEQ_INFO, seq_info.clone());
830
831 #[cfg(not(test))]
832 let _ = &local_lineage;
833
834 #[cfg(test)]
835 headers.set(CAST_LINEAGE, local_lineage.ranks());
836
837 let dest = domain
838 .local_actor
839 .port_addr(Port::handler_id(message.dest_port, None));
840 hyperactor::mailbox::headers::stamp_sender_actor_id(
841 &mut headers,
842 &seq_info,
843 &dest,
844 &message.sender,
845 );
846 cx.post_with_external_seq_info(dest, headers, local_data.erase_encoding());
847 }
848
849 for next_hop in &domain.next_hops {
850 #[cfg(not(test))]
851 let _ = &local_lineage;
852 let forward_headers = message.headers.clone();
853 next_hop.port().post_with_headers(
854 cx,
855 forward_headers,
856 CastMessage {
857 cast_domain_id: message.cast_domain_id.clone(),
858 sender: message.sender.clone(),
859 session_id: message.session_id,
860 seqs: message.seqs.clone(),
861 #[cfg(test)]
862 lineage: local_lineage.ranks(),
863 headers: message.headers.clone(),
864 dest_port: message.dest_port,
865 data: data.clone(),
866 },
867 );
868 }
869
870 Ok(())
871 }
872}
873
874#[derive(Debug, Serialize, Deserialize, typeuri::Named)]
882struct DestroyCastDomain {
883 domain_id: CastDomainId,
885 origin: ActorAddr,
887}
888wirevalue::register_type!(DestroyCastDomain);
889
890#[async_trait]
891impl Handler<DestroyCastDomain> for CastActor {
892 #[tracing::instrument(
893 level = "debug",
894 skip_all,
895 fields(domain_id = %message.domain_id)
896 )]
897 async fn handle(
898 &mut self,
899 cx: &Context<Self>,
900 message: DestroyCastDomain,
901 ) -> Result<(), anyhow::Error> {
902 let Some(cast_hop) = self.installed_hops.remove(&message.domain_id) else {
903 return Ok(());
904 };
905
906 #[cfg(test)]
907 {
908 tests::capture_destroyed_domain(cx, message.domain_id.domain_id());
909 }
910
911 for next_hop in &cast_hop.next_hops {
912 next_hop.post(
913 cx,
914 DestroyCastDomain {
915 domain_id: message.domain_id.clone(),
916 origin: message.origin.clone(),
917 },
918 );
919 }
920
921 Ok(())
922 }
923}
924
925#[cfg(test)]
926mod tests {
927 use std::collections::BTreeMap;
928 use std::collections::BTreeSet;
929 use std::collections::HashMap;
930 use std::num::NonZeroUsize;
931 use std::sync::Mutex;
932 use std::sync::OnceLock;
933 use std::time::Duration;
934
935 use hyperactor::Client;
936 use hyperactor::PortAddr;
937 use hyperactor::ProcAddr;
938 use hyperactor::channel::ChannelTransport;
939 use hyperactor::proc::Proc;
940 use ndslice::Shape;
941 use ndslice::Slice;
942 use ndslice::ViewExt;
943 use ndslice::shape;
944 use ndslice::view::BuildFromRegionIndexed;
945 use ndslice::view::Ranked;
946 use proptest::prelude::*;
947 use timed_test::async_timed_test;
948 use typeuri::Named;
949
950 use super::*;
951
952 fn small_shape_sizes() -> impl Strategy<Value = Vec<usize>> {
953 prop::collection::vec(1usize..=4, 1..=4).prop_filter("shape must stay small", |sizes| {
954 sizes.iter().product::<usize>() <= 64
955 })
956 }
957
958 fn shape_from_sizes(sizes: &[usize]) -> Shape {
959 Shape::new(
960 (0..sizes.len()).map(|dim| format!("d{dim}")).collect(),
961 Slice::new_row_major(sizes.to_vec()),
962 )
963 .unwrap()
964 }
965
966 fn member(rank: usize) -> ActorAddr {
967 ActorAddr::root(
968 format!("proc{rank}@inproc://{rank}")
969 .parse::<ProcAddr>()
970 .unwrap(),
971 Label::strip("member"),
972 )
973 }
974
975 fn validate_domain_tree(
976 members: &HashMap<usize, ActorAddr>,
977 tile: &MaterializedTile<ActorAddr>,
978 seen_roots: &mut BTreeSet<usize>,
979 ) -> Result<(), TestCaseError> {
980 let expected_members = tile
981 .tile()
982 .space()
983 .iter()
984 .map(|rank| members[&rank].clone())
985 .collect::<Vec<_>>();
986 prop_assert_eq!(tile.items().cloned().collect::<Vec<_>>(), expected_members);
987 prop_assert!(seen_roots.insert(tile.root_rank()));
988
989 for child in next_tiles(TilingPolicy::BlockPartitioning, tile) {
990 validate_domain_tree(members, &child, seen_roots)?;
991 }
992
993 Ok(())
994 }
995
996 #[derive(Clone, Debug, PartialEq, Eq)]
997 pub(crate) struct CastHopSnapshot {
998 point_in_domain: Point,
999 base_rank_in_domain: usize,
1000 next_hop_procs: BTreeSet<String>,
1001 local_actor_proc: String,
1002 }
1003
1004 static INSTALLED_DOMAINS: OnceLock<Mutex<HashMap<Uid, BTreeMap<String, CastHopSnapshot>>>> =
1005 OnceLock::new();
1006 static DESTROYED_DOMAINS: OnceLock<Mutex<HashMap<Uid, BTreeSet<String>>>> = OnceLock::new();
1007
1008 fn installed_domains() -> &'static Mutex<HashMap<Uid, BTreeMap<String, CastHopSnapshot>>> {
1009 INSTALLED_DOMAINS.get_or_init(|| Mutex::new(HashMap::new()))
1010 }
1011
1012 fn destroyed_domains() -> &'static Mutex<HashMap<Uid, BTreeSet<String>>> {
1013 DESTROYED_DOMAINS.get_or_init(|| Mutex::new(HashMap::new()))
1014 }
1015
1016 pub(crate) fn capture_installed_domain(
1017 cx: &Context<'_, CastActor>,
1018 domain_id: &Uid,
1019 cast_hop: &CastHop,
1020 ) {
1021 let proc_name = cx.self_addr().proc_addr().log_name().to_string();
1022 let snapshot = CastHopSnapshot {
1023 point_in_domain: cast_hop.point_in_domain.clone(),
1024 base_rank_in_domain: cast_hop.base_rank_in_domain,
1025 next_hop_procs: cast_hop
1026 .next_hops
1027 .iter()
1028 .map(|next_hop| next_hop.actor_addr().proc_addr().log_name().to_string())
1029 .collect(),
1030 local_actor_proc: cast_hop.local_actor.proc_addr().log_name().to_string(),
1031 };
1032 installed_domains()
1033 .lock()
1034 .unwrap()
1035 .entry(domain_id.clone())
1036 .or_default()
1037 .insert(proc_name, snapshot);
1038 }
1039
1040 pub(crate) fn capture_destroyed_domain(cx: &Context<'_, CastActor>, domain_id: &Uid) {
1041 let proc_name = cx.self_addr().proc_addr().log_name().to_string();
1042 destroyed_domains()
1043 .lock()
1044 .unwrap()
1045 .entry(domain_id.clone())
1046 .or_default()
1047 .insert(proc_name);
1048 }
1049
1050 fn clear_captured_domains() {
1051 installed_domains().lock().unwrap().clear();
1052 destroyed_domains().lock().unwrap().clear();
1053 }
1054
1055 fn captured_domain_snapshots(domain_id: &Uid) -> BTreeMap<String, CastHopSnapshot> {
1056 installed_domains()
1057 .lock()
1058 .unwrap()
1059 .get(domain_id)
1060 .cloned()
1061 .unwrap_or_default()
1062 }
1063
1064 fn destroyed_domain_snapshots(domain_id: &Uid) -> BTreeSet<String> {
1065 destroyed_domains()
1066 .lock()
1067 .unwrap()
1068 .get(domain_id)
1069 .cloned()
1070 .unwrap_or_default()
1071 }
1072
1073 fn members(count: usize) -> Vec<ActorAddr> {
1074 (0..count)
1075 .map(|i| {
1076 ActorAddr::root(
1077 format!("proc{i}@inproc://{i}").parse::<ProcAddr>().unwrap(),
1078 Label::strip("member"),
1079 )
1080 })
1081 .collect()
1082 }
1083
1084 #[test]
1085 fn test_subset_members_preserves_selected_member_order() {
1086 let parent_view = Region::from(shape!(row = 4, col = 2));
1093
1094 let child_view = parent_view
1096 .range("row", ndslice::Range(1, Some(3), 1))
1097 .unwrap();
1098 let members = members(8);
1099 let parent_members = ValueMesh::new(parent_view.clone(), members.clone()).unwrap();
1100
1101 let child_members = parent_members.subset(child_view.clone()).unwrap();
1102 let child_members = (0..Ranked::region(&child_members).num_ranks())
1103 .map(|rank| Ranked::get(&child_members, rank).unwrap().clone())
1104 .collect::<Vec<_>>();
1105
1106 assert_eq!(child_members, &members[2..6]);
1110 }
1111
1112 #[derive(Debug, Clone, Serialize, Deserialize, typeuri::Named)]
1116 struct TestDelivery {
1117 payload: String,
1118 }
1119 wirevalue::register_type!(TestDelivery);
1120
1121 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, typeuri::Named)]
1123 struct TestDeliveryRecord {
1124 payload: String,
1125 lineage: Vec<usize>,
1126 operation_endpoint: Option<String>,
1127 }
1128 wirevalue::register_type!(TestDeliveryRecord);
1129
1130 #[derive(
1131 Debug,
1132 Clone,
1133 Default,
1134 PartialEq,
1135 Eq,
1136 Serialize,
1137 Deserialize,
1138 typeuri::Named
1139 )]
1140 struct TestDeliveryHistories {
1141 by_proc: BTreeMap<String, Vec<TestDeliveryRecord>>,
1142 }
1143 wirevalue::register_type!(TestDeliveryHistories);
1144
1145 impl TestDeliveryHistories {
1146 fn single(proc_name: String, deliveries: Vec<TestDeliveryRecord>) -> Self {
1147 Self {
1148 by_proc: [(proc_name, deliveries)].into_iter().collect(),
1149 }
1150 }
1151
1152 fn merge(&mut self, other: Self) -> anyhow::Result<()> {
1153 for (proc_name, history) in other.by_proc {
1154 anyhow::ensure!(
1155 self.by_proc.insert(proc_name.clone(), history).is_none(),
1156 "duplicate history reply from {proc_name}",
1157 );
1158 }
1159 Ok(())
1160 }
1161 }
1162
1163 #[derive(Debug, Serialize, Deserialize, typeuri::Named)]
1164 struct GetHistory {
1165 reply_to: hyperactor::OncePortRef<TestDeliveryHistories>,
1166 }
1167 wirevalue::register_type!(GetHistory);
1168
1169 #[derive(Debug, Default)]
1171 #[hyperactor::export(
1172 handlers = [
1173 TestDelivery,
1174 GetHistory,
1175 ],
1176 )]
1177 struct TestReceiver {
1178 deliveries: Vec<TestDeliveryRecord>,
1179 }
1180
1181 #[async_trait]
1182 impl Actor for TestReceiver {
1183 async fn init(&mut self, _this: &Instance<Self>) -> Result<(), anyhow::Error> {
1184 Ok(())
1185 }
1186 }
1187
1188 #[async_trait]
1189 impl Handler<TestDelivery> for TestReceiver {
1190 async fn handle(
1191 &mut self,
1192 cx: &Context<Self>,
1193 msg: TestDelivery,
1194 ) -> Result<(), anyhow::Error> {
1195 let _seq_info = cx
1196 .headers()
1197 .get(SEQ_INFO)
1198 .expect("cast delivery should stamp SEQ_INFO");
1199 let lineage = cx.headers().get(CAST_LINEAGE).unwrap_or_default();
1200 let operation_endpoint = cx
1201 .headers()
1202 .get(hyperactor::mailbox::headers::OPERATION_ENDPOINT);
1203 self.deliveries.push(TestDeliveryRecord {
1204 payload: msg.payload,
1205 lineage,
1206 operation_endpoint,
1207 });
1208 Ok(())
1209 }
1210 }
1211
1212 #[async_trait]
1213 impl Handler<GetHistory> for TestReceiver {
1214 async fn handle(
1215 &mut self,
1216 cx: &Context<Self>,
1217 msg: GetHistory,
1218 ) -> Result<(), anyhow::Error> {
1219 msg.reply_to.post(
1220 cx,
1221 TestDeliveryHistories::single(
1222 cx.self_addr().proc_addr().log_name().to_string(),
1223 self.deliveries.clone(),
1224 ),
1225 );
1226 Ok(())
1227 }
1228 }
1229
1230 #[derive(typeuri::Named)]
1231 struct TestDeliveryHistoriesReducer;
1232
1233 impl hyperactor::accum::CommReducer for TestDeliveryHistoriesReducer {
1234 type Update = TestDeliveryHistories;
1235
1236 fn reduce(
1237 &self,
1238 mut left: Self::Update,
1239 right: Self::Update,
1240 ) -> anyhow::Result<Self::Update> {
1241 left.merge(right)?;
1242 Ok(left)
1243 }
1244 }
1245
1246 inventory::submit! {
1247 hyperactor::accum::ReducerFactory {
1248 typehash_f: <TestDeliveryHistoriesReducer as Named>::typehash,
1249 builder_f: |_| Ok(Box::new(TestDeliveryHistoriesReducer)),
1250 }
1251 }
1252
1253 struct TestDeliveryHistoriesAccumulator;
1254
1255 impl hyperactor::accum::Accumulator for TestDeliveryHistoriesAccumulator {
1256 type State = TestDeliveryHistories;
1257 type Update = TestDeliveryHistories;
1258
1259 fn accumulate(&self, state: &mut Self::State, update: Self::Update) -> anyhow::Result<()> {
1260 state.merge(update)
1261 }
1262
1263 fn reducer_spec(&self) -> Option<hyperactor::accum::ReducerSpec> {
1264 Some(hyperactor::accum::ReducerSpec {
1265 typehash: <TestDeliveryHistoriesReducer as Named>::typehash(),
1266 builder_params: None,
1267 })
1268 }
1269 }
1270
1271 struct CastTestMesh {
1272 _client_proc: Proc,
1273 client: Client,
1274 _procs: Vec<Proc>,
1275 member_ids: HashMap<usize, ActorAddr>,
1276 receiver_ids: Vec<ActorAddr>,
1277 }
1278
1279 impl CastTestMesh {
1280 fn new(n: usize) -> Self {
1281 let client_proc =
1282 Proc::direct(ChannelTransport::Unix.any(), "client_proc".into()).unwrap();
1283 let client = client_proc.client("client");
1284
1285 let procs: Vec<Proc> = (0..n)
1286 .map(|i| {
1287 let proc =
1288 Proc::direct(ChannelTransport::Unix.any(), format!("proc_{i}")).unwrap();
1289 let cast_handle = proc
1290 .spawn_with_uid(
1291 Uid::singleton(Label::strip(CAST_ACTOR_NAME)),
1292 CastActor::default(),
1293 )
1294 .unwrap();
1295 let _: ActorRef<CastActor> = cast_handle.bind::<CastActor>();
1296 proc
1297 })
1298 .collect();
1299 let member_ids = procs
1300 .iter()
1301 .enumerate()
1302 .map(|(rank, proc)| {
1303 (
1304 rank,
1305 ActorAddr::root(proc.proc_addr().clone(), Label::strip("member")),
1306 )
1307 })
1308 .collect();
1309
1310 Self {
1311 _client_proc: client_proc,
1312 client,
1313 _procs: procs,
1314 member_ids,
1315 receiver_ids: Vec::new(),
1316 }
1317 }
1318
1319 fn spawn_delivery_receivers(&mut self) {
1320 self.receiver_ids = self
1321 ._procs
1322 .iter()
1323 .map(|proc| {
1324 let recv_handle = proc
1325 .spawn_with_uid(
1326 Uid::singleton(Label::strip("receiver")),
1327 TestReceiver::default(),
1328 )
1329 .unwrap();
1330 let _: ActorRef<TestReceiver> = recv_handle.bind::<TestReceiver>();
1331 ActorAddr::root(proc.proc_addr().clone(), Label::strip("receiver"))
1332 })
1333 .collect();
1334 }
1335
1336 fn spawn_split_port_receivers(&mut self) {
1337 self.receiver_ids = self
1338 ._procs
1339 .iter()
1340 .map(|proc| {
1341 let recv_handle = proc
1342 .spawn_with_uid(Uid::singleton(Label::strip("receiver")), SplitPortReceiver)
1343 .unwrap();
1344 let _: ActorRef<SplitPortReceiver> = recv_handle.bind::<SplitPortReceiver>();
1345 ActorAddr::root(proc.proc_addr().clone(), Label::strip("receiver"))
1346 })
1347 .collect();
1348 }
1349
1350 fn domain_members(&self) -> HashMap<usize, ActorAddr> {
1351 if self.receiver_ids.is_empty() {
1352 self.member_ids.clone()
1353 } else {
1354 self.receiver_ids
1355 .iter()
1356 .cloned()
1357 .enumerate()
1358 .collect::<HashMap<_, _>>()
1359 }
1360 }
1361
1362 fn root_domain(&self, region: Region) -> CastDomainRef {
1363 CastDomainId::new()
1364 .materialize(
1365 &self.client,
1366 self.domain_members(),
1367 region,
1368 TilingPolicy::BlockPartitioning,
1369 Flattrs::new(),
1370 )
1371 .unwrap()
1372 }
1373
1374 fn proc_names(&self) -> Vec<String> {
1375 (0..self.domain_members().len())
1376 .map(|i| format!("proc_{i}"))
1377 .collect()
1378 }
1379
1380 fn root_domain_with_policy(&self, region: Region, policy: TilingPolicy) -> CastDomainRef {
1381 CastDomainId::new()
1382 .materialize(
1383 &self.client,
1384 self.member_ids.clone(),
1385 region,
1386 policy,
1387 Flattrs::new(),
1388 )
1389 .unwrap()
1390 }
1391
1392 async fn wait_for_domain_snapshots(
1393 &self,
1394 domain_id: &Uid,
1395 expected_count: usize,
1396 ) -> BTreeMap<String, CastHopSnapshot> {
1397 tokio::time::timeout(Duration::from_secs(5), async {
1398 loop {
1399 let snapshots = captured_domain_snapshots(domain_id);
1400 if snapshots.len() == expected_count {
1401 return snapshots;
1402 }
1403 tokio::time::sleep(Duration::from_millis(10)).await;
1404 }
1405 })
1406 .await
1407 .unwrap_or_else(|_| {
1408 panic!(
1409 "timed out waiting for {expected_count} installed domain snapshots; saw {:?}",
1410 captured_domain_snapshots(domain_id).keys()
1411 )
1412 })
1413 }
1414
1415 async fn wait_for_destroyed_domain_snapshots(
1416 &self,
1417 domain_id: &Uid,
1418 expected_count: usize,
1419 ) -> BTreeSet<String> {
1420 tokio::time::timeout(Duration::from_secs(5), async {
1421 loop {
1422 let snapshots = destroyed_domain_snapshots(domain_id);
1423 if snapshots.len() == expected_count {
1424 return snapshots;
1425 }
1426 tokio::time::sleep(Duration::from_millis(10)).await;
1427 }
1428 })
1429 .await
1430 .unwrap_or_else(|_| {
1431 panic!(
1432 "timed out waiting for {expected_count} destroyed domain snapshots; saw {:?}",
1433 destroyed_domain_snapshots(domain_id)
1434 )
1435 })
1436 }
1437 }
1438
1439 proptest! {
1440 #[test]
1441 fn prop_domain_tree_materialization_covers_each_rank_once(sizes in small_shape_sizes()) {
1442 let shape = shape_from_sizes(&sizes);
1443 let region = Region::from(shape);
1444 let members = region
1445 .slice()
1446 .iter()
1447 .map(|rank| (rank, member(rank)))
1448 .collect::<HashMap<_, _>>();
1449 let root_tile = MaterializedTile::from_value_mesh_with_tile(
1450 Tile::from_view(®ion),
1451 Arc::new(ValueMesh::build_indexed(region.clone(), members.clone()).unwrap()),
1452 );
1453
1454 let mut seen_roots = BTreeSet::new();
1455 validate_domain_tree(&members, &root_tile, &mut seen_roots)?;
1456
1457 let expected_roots = region.slice().iter().collect::<BTreeSet<_>>();
1458 prop_assert_eq!(seen_roots, expected_roots);
1459 }
1460
1461 #[test]
1462 fn prop_domain_destinations_preserve_member_mapping(sizes in small_shape_sizes()) {
1463 let shape = shape_from_sizes(&sizes);
1464 let region = Region::from(shape);
1465 let members = region
1466 .slice()
1467 .iter()
1468 .map(|rank| (rank, member(rank)))
1469 .collect::<HashMap<_, _>>();
1470 let root = MaterializedTile::from_value_mesh_with_tile(
1471 Tile::from_view(®ion),
1472 Arc::new(ValueMesh::build_indexed(region.clone(), members.clone()).unwrap()),
1473 );
1474 let mut seen_roots = BTreeSet::new();
1475
1476 validate_domain_tree(&members, &root, &mut seen_roots)?;
1477
1478 prop_assert_eq!(
1479 seen_roots.into_iter().collect::<Vec<_>>(),
1480 region.slice().iter().collect::<Vec<_>>(),
1481 );
1482 }
1483 }
1484
1485 async fn cast_and_collect_histories(
1486 test_mesh: &CastTestMesh,
1487 cast_domain: &CastDomainRef,
1488 ) -> BTreeMap<String, Vec<TestDeliveryRecord>> {
1489 let (reply_handle, reply_rx) = context::Mailbox::mailbox(&test_mesh.client)
1490 .open_reduce_port(TestDeliveryHistoriesAccumulator);
1491 let reply_ref = reply_handle.bind();
1492
1493 cast_domain
1494 .cast(
1495 &test_mesh.client,
1496 Flattrs::new(),
1497 GetHistory {
1498 reply_to: reply_ref,
1499 },
1500 )
1501 .unwrap();
1502
1503 match tokio::time::timeout(Duration::from_secs(5), reply_rx.recv()).await {
1504 Ok(Ok(histories)) => histories.by_proc,
1505 Ok(Err(e)) => panic!("history recv error: {e}"),
1506 Err(_) => panic!("timed out waiting for reduced histories"),
1507 }
1508 }
1509
1510 #[async_timed_test(timeout_secs = 30)]
1511 async fn test_create_cast_domain_installs_expected_hops() {
1512 clear_captured_domains();
1513
1514 let test_mesh = CastTestMesh::new(8);
1515 let root_domain = test_mesh.root_domain(shape!(a = 2, b = 2, c = 2).into());
1516 let snapshots = test_mesh
1517 .wait_for_domain_snapshots(root_domain.domain_id(), 8)
1518 .await;
1519
1520 let region = Region::from(shape!(a = 2, b = 2, c = 2));
1521 let expected_next_hops: BTreeMap<String, BTreeSet<String>> = [
1522 ("proc_0", vec!["proc_1", "proc_2", "proc_4"]),
1523 ("proc_1", vec![]),
1524 ("proc_2", vec!["proc_3"]),
1525 ("proc_3", vec![]),
1526 ("proc_4", vec!["proc_5", "proc_6"]),
1527 ("proc_5", vec![]),
1528 ("proc_6", vec!["proc_7"]),
1529 ("proc_7", vec![]),
1530 ]
1531 .into_iter()
1532 .map(|(proc_name, next_hops)| {
1533 (
1534 proc_name.to_string(),
1535 next_hops
1536 .into_iter()
1537 .map(str::to_string)
1538 .collect::<BTreeSet<_>>(),
1539 )
1540 })
1541 .collect();
1542
1543 assert_eq!(
1544 snapshots.keys().cloned().collect::<BTreeSet<_>>(),
1545 (0..8).map(|rank| format!("proc_{rank}")).collect()
1546 );
1547
1548 for rank in 0..8 {
1549 let proc_name = format!("proc_{rank}");
1550 let snapshot = snapshots
1551 .get(&proc_name)
1552 .unwrap_or_else(|| panic!("missing snapshot for {proc_name}"));
1553
1554 assert_eq!(snapshot.base_rank_in_domain, rank);
1555 assert_eq!(
1556 snapshot.point_in_domain,
1557 region.point_of_base_rank(rank).unwrap()
1558 );
1559 assert_eq!(snapshot.local_actor_proc, proc_name);
1560 assert_eq!(snapshot.next_hop_procs, expected_next_hops[&proc_name]);
1561 }
1562 }
1563
1564 fn expected_reply_counts(proc_names: &[&str]) -> BTreeMap<String, u64> {
1565 proc_names
1566 .iter()
1567 .map(|proc_name| (proc_name.to_string(), 1))
1568 .collect()
1569 }
1570
1571 async fn cast_and_collect_reply_counts(
1572 test_mesh: &CastTestMesh,
1573 cast_domain: &CastDomainRef,
1574 payload: &str,
1575 ) -> BTreeMap<String, u64> {
1576 let (reply_handle, reply_rx) = context::Mailbox::mailbox(&test_mesh.client)
1577 .open_reduce_port(TestReplyCountsAccumulator);
1578 let reply_ref = reply_handle.bind();
1579
1580 cast_domain
1581 .cast(
1582 &test_mesh.client,
1583 Flattrs::new(),
1584 TestRequestWithReply {
1585 payload: payload.to_string(),
1586 reply_to: reply_ref,
1587 },
1588 )
1589 .unwrap();
1590
1591 match tokio::time::timeout(Duration::from_secs(5), reply_rx.recv()).await {
1592 Ok(Ok(reply_counts)) => reply_counts.counts_by_proc,
1593 Ok(Err(e)) => panic!("reply recv error: {e}"),
1594 Err(_) => panic!("timed out waiting for reduced replies"),
1595 }
1596 }
1597
1598 #[async_timed_test(timeout_secs = 30)]
1599 async fn test_cast_message_delivery_8_procs() {
1600 let config = hyperactor_config::global::lock();
1601 let _guard = config.override_key(
1602 hyperactor::config::ENABLE_DEST_ACTOR_REORDERING_BUFFER,
1603 true,
1604 );
1605
1606 let n = 8;
1607 let mut test_mesh = CastTestMesh::new(n);
1608 test_mesh.spawn_delivery_receivers();
1609 let root_domain = test_mesh.root_domain(shape!(a = 2, b = 2, c = 2).into());
1610
1611 let expected_payloads = vec![
1612 "hello-0".to_string(),
1613 "hello-1".to_string(),
1614 "hello-2".to_string(),
1615 ];
1616 for payload in &expected_payloads {
1617 root_domain
1618 .cast(
1619 &test_mesh.client,
1620 Flattrs::new(),
1621 TestDelivery {
1622 payload: payload.clone(),
1623 },
1624 )
1625 .unwrap();
1626 }
1627
1628 let expected_histories: BTreeMap<String, Vec<String>> = test_mesh
1629 .proc_names()
1630 .into_iter()
1631 .map(|proc_name| (proc_name, expected_payloads.clone()))
1632 .collect();
1633 let histories = cast_and_collect_histories(&test_mesh, &root_domain).await;
1634
1635 let observed_payloads: BTreeMap<String, Vec<String>> = histories
1636 .iter()
1637 .map(|(proc_name, history)| {
1638 (
1639 proc_name.clone(),
1640 history
1641 .iter()
1642 .map(|delivery| delivery.payload.clone())
1643 .collect(),
1644 )
1645 })
1646 .collect();
1647 assert_eq!(observed_payloads, expected_histories);
1648
1649 let mut lineage_by_proc = BTreeMap::new();
1650 for (proc_name, history) in histories {
1651 assert_eq!(
1652 history.len(),
1653 expected_payloads.len(),
1654 "proc {proc_name} received the wrong number of deliveries"
1655 );
1656 let first_lineage = history[0].lineage.clone();
1657 for delivery in &history {
1658 assert_eq!(
1659 delivery.lineage, first_lineage,
1660 "proc {proc_name} changed lineage across root casts"
1661 );
1662 }
1663 lineage_by_proc.insert(proc_name, first_lineage);
1664 }
1665
1666 let expected_lineage: BTreeMap<String, Vec<usize>> = [
1667 ("proc_0".to_string(), vec![0]),
1668 ("proc_1".to_string(), vec![0, 1]),
1669 ("proc_2".to_string(), vec![0, 2]),
1670 ("proc_3".to_string(), vec![0, 2, 3]),
1671 ("proc_4".to_string(), vec![0, 4]),
1672 ("proc_5".to_string(), vec![0, 4, 5]),
1673 ("proc_6".to_string(), vec![0, 4, 6]),
1674 ("proc_7".to_string(), vec![0, 4, 6, 7]),
1675 ]
1676 .into_iter()
1677 .collect();
1678 assert_eq!(lineage_by_proc, expected_lineage);
1679 }
1680
1681 #[async_timed_test(timeout_secs = 30)]
1682 async fn test_cast_preserves_supplied_operation_context_headers() {
1683 let n = 2;
1684 let mut test_mesh = CastTestMesh::new(n);
1685 test_mesh.spawn_delivery_receivers();
1686 let root_domain = test_mesh.root_domain(shape!(rank = 2).into());
1687
1688 let mut headers = Flattrs::new();
1689 headers.set(
1690 hyperactor::mailbox::headers::OPERATION_ENDPOINT,
1691 "endpoint.call()".to_string(),
1692 );
1693 root_domain
1694 .cast(
1695 &test_mesh.client,
1696 headers,
1697 TestDelivery {
1698 payload: "with-operation-context".to_string(),
1699 },
1700 )
1701 .unwrap();
1702
1703 let histories = cast_and_collect_histories(&test_mesh, &root_domain).await;
1704 for history in histories.values() {
1705 assert_eq!(
1706 history[0].operation_endpoint.as_deref(),
1707 Some("endpoint.call()")
1708 );
1709 }
1710 }
1711
1712 #[async_timed_test(timeout_secs = 30)]
1713 async fn test_destroy_domain_rejects_later_casts() {
1714 let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client_proc".into()).unwrap();
1715 let client = client_proc.client("client");
1716 let cast_proc = Proc::direct(ChannelTransport::Unix.any(), "cast_proc".into()).unwrap();
1717 let actor_instance = cast_proc.actor_instance::<CastActor>("cast").unwrap();
1718 let mut cast_actor = CastActor::default();
1719 let cx = Context::new(&actor_instance.instance, Flattrs::new());
1720
1721 let receiver_id = ActorAddr::root(cast_proc.proc_addr().clone(), Label::strip("receiver"));
1722 let cast_domain_id = CastDomainId::new();
1723 let region = Region::from(Shape::unity());
1724 let root_tile = MaterializedTile::from_value_mesh_with_tile(
1725 Tile::from_view(®ion),
1726 Arc::new(ValueMesh::new(region.clone(), vec![receiver_id]).unwrap()),
1727 );
1728
1729 Handler::<CreateCastDomain>::handle(
1730 &mut cast_actor,
1731 &cx,
1732 CreateCastDomain {
1733 cast_domain_id: cast_domain_id.clone(),
1734 region,
1735 tiling_policy: TilingPolicy::BlockPartitioning,
1736 tile: root_tile,
1737 },
1738 )
1739 .await
1740 .unwrap();
1741
1742 Handler::<DestroyCastDomain>::handle(
1743 &mut cast_actor,
1744 &cx,
1745 DestroyCastDomain {
1746 domain_id: cast_domain_id.clone(),
1747 origin: client.self_addr().clone(),
1748 },
1749 )
1750 .await
1751 .unwrap();
1752
1753 Handler::<DestroyCastDomain>::handle(
1755 &mut cast_actor,
1756 &cx,
1757 DestroyCastDomain {
1758 domain_id: cast_domain_id.clone(),
1759 origin: client.self_addr().clone(),
1760 },
1761 )
1762 .await
1763 .unwrap();
1764
1765 let err = Handler::<CastMessage>::handle(
1766 &mut cast_actor,
1767 &cx,
1768 CastMessage {
1769 cast_domain_id,
1770 sender: client.self_addr().clone(),
1771 session_id: client.sequencer().session_id(),
1772 seqs: ValueMesh::new(Region::from(Shape::unity()), vec![1]).unwrap(),
1773 lineage: Vec::new(),
1774 headers: Flattrs::new(),
1775 dest_port: TestDelivery::port(),
1776 data: wirevalue::Any::<wirevalue::encoding::Multipart>::serialize(&TestDelivery {
1777 payload: "hello".to_string(),
1778 })
1779 .unwrap(),
1780 },
1781 )
1782 .await
1783 .unwrap_err();
1784
1785 let err = err.to_string();
1786 assert!(err.contains("unknown domain"), "unexpected error: {err}");
1787 }
1788
1789 #[async_timed_test(timeout_secs = 30)]
1790 async fn test_destroy_domain_propagates_to_all_hops() {
1791 clear_captured_domains();
1792
1793 let test_mesh = CastTestMesh::new(8);
1794 let root_domain = test_mesh.root_domain(shape!(a = 2, b = 2, c = 2).into());
1795 let domain_id = root_domain.domain_id().clone();
1796 test_mesh.wait_for_domain_snapshots(&domain_id, 8).await;
1797
1798 root_domain.destroy(&test_mesh.client);
1799
1800 let destroyed = test_mesh
1801 .wait_for_destroyed_domain_snapshots(&domain_id, 8)
1802 .await;
1803 assert_eq!(
1804 destroyed,
1805 (0..8).map(|rank| format!("proc_{rank}")).collect()
1806 );
1807 }
1808
1809 #[async_timed_test(timeout_secs = 30)]
1810 async fn test_slice_cast_reaches_exactly_selected_members() {
1811 let n = 8;
1812 let mut test_mesh = CastTestMesh::new(n);
1813 test_mesh.spawn_split_port_receivers();
1814 let root_cast_domain = test_mesh.root_domain(shape!(a = 2, b = 2, c = 2).into());
1815
1816 for (case_name, range, expected_procs) in [
1817 (
1818 "a_0_to_1",
1819 ndslice::Range(0, Some(1), 1),
1820 ["proc_0", "proc_1", "proc_2", "proc_3"],
1821 ),
1822 (
1823 "a_1_to_2",
1824 ndslice::Range(1, Some(2), 1),
1825 ["proc_4", "proc_5", "proc_6", "proc_7"],
1826 ),
1827 ] {
1828 let payload = format!("slice-{case_name}");
1829 let slice_region = Region::from(shape!(a = 2, b = 2, c = 2))
1830 .range("a", range)
1831 .unwrap();
1832 let slice_cast_domain = root_cast_domain
1833 .materialize_slice(
1834 &test_mesh.client,
1835 slice_region,
1836 TilingPolicy::BlockPartitioning,
1837 )
1838 .unwrap();
1839
1840 assert_eq!(
1841 cast_and_collect_reply_counts(&test_mesh, &slice_cast_domain, &payload,).await,
1842 expected_reply_counts(&expected_procs)
1843 );
1844 }
1845 }
1846
1847 #[async_timed_test(timeout_secs = 30)]
1848 async fn test_sender_sequenced_casts_are_observed_in_projection_order() {
1849 let config = hyperactor_config::global::lock();
1850 let _guard = config.override_key(
1851 hyperactor::config::ENABLE_DEST_ACTOR_REORDERING_BUFFER,
1852 true,
1853 );
1854
1855 let n = 4;
1856 let mut test_mesh = CastTestMesh::new(n);
1857 test_mesh.spawn_delivery_receivers();
1858 let root = test_mesh.root_domain(shape!(rank = 4).into());
1859
1860 let rank_0_to_2 = root
1861 .materialize_slice(
1862 &test_mesh.client,
1863 Region::from(shape!(rank = 4))
1864 .range("rank", ndslice::Range(0, Some(2), 1))
1865 .unwrap(),
1866 TilingPolicy::BlockPartitioning,
1867 )
1868 .unwrap();
1869
1870 let rank_1_to_3 = root
1871 .materialize_slice(
1872 &test_mesh.client,
1873 Region::from(shape!(rank = 4))
1874 .range("rank", ndslice::Range(1, Some(3), 1))
1875 .unwrap(),
1876 TilingPolicy::BlockPartitioning,
1877 )
1878 .unwrap();
1879
1880 let rank_2_to_4 = root
1881 .materialize_slice(
1882 &test_mesh.client,
1883 Region::from(shape!(rank = 4))
1884 .range("rank", ndslice::Range(2, Some(4), 1))
1885 .unwrap(),
1886 TilingPolicy::BlockPartitioning,
1887 )
1888 .unwrap();
1889
1890 let casts = [
1891 (
1892 &root,
1893 "root-0",
1894 vec!["proc_0", "proc_1", "proc_2", "proc_3"],
1895 ),
1896 (&rank_0_to_2, "rank_0_to_2-1", vec!["proc_0", "proc_1"]),
1897 (&rank_1_to_3, "rank_1_to_3-2", vec!["proc_1", "proc_2"]),
1898 (&rank_2_to_4, "rank_2_to_4-3", vec!["proc_2", "proc_3"]),
1899 (
1900 &root,
1901 "root-4",
1902 vec!["proc_0", "proc_1", "proc_2", "proc_3"],
1903 ),
1904 ];
1905
1906 let mut expected_histories: BTreeMap<String, Vec<String>> = test_mesh
1907 .proc_names()
1908 .into_iter()
1909 .map(|proc_name| (proc_name, Vec::new()))
1910 .collect();
1911
1912 for (domain, payload, expected_receivers) in &casts {
1913 domain
1914 .cast(
1915 &test_mesh.client,
1916 Flattrs::new(),
1917 TestDelivery {
1918 payload: payload.to_string(),
1919 },
1920 )
1921 .unwrap();
1922
1923 for proc_name in expected_receivers {
1924 expected_histories
1925 .get_mut(*proc_name)
1926 .unwrap()
1927 .push(payload.to_string());
1928 }
1929 }
1930
1931 let observed_histories: BTreeMap<String, Vec<String>> =
1932 cast_and_collect_histories(&test_mesh, &root)
1933 .await
1934 .into_iter()
1935 .map(|(proc_name, history)| {
1936 (
1937 proc_name,
1938 history
1939 .into_iter()
1940 .map(|delivery| delivery.payload)
1941 .collect(),
1942 )
1943 })
1944 .collect();
1945
1946 assert_eq!(observed_histories, expected_histories);
1947 }
1948
1949 #[derive(Debug, Clone)]
1957 struct SplitEdge {
1958 from: PortAddr,
1959 to: PortAddr,
1960 is_leaf: bool,
1961 }
1962
1963 struct SplitPortRecording {
1964 root: PortAddr,
1965 edges: Vec<SplitEdge>,
1966 }
1967
1968 struct SplitPortRecordingGuard;
1969
1970 static SPLIT_PORT_TREE: OnceLock<Mutex<Option<SplitPortRecording>>> = OnceLock::new();
1971
1972 fn split_port_tree() -> &'static Mutex<Option<SplitPortRecording>> {
1973 SPLIT_PORT_TREE.get_or_init(|| Mutex::new(None))
1974 }
1975
1976 pub(crate) fn collect_split_port(original: &PortAddr, split: &PortAddr, deliver_here: bool) {
1977 let mut guard = split_port_tree().lock().unwrap();
1978 let Some(recording) = guard.as_mut() else {
1979 return;
1980 };
1981 if original != &recording.root && !recording.edges.iter().any(|edge| &edge.to == original) {
1982 return;
1983 }
1984 recording.edges.push(SplitEdge {
1985 from: original.clone(),
1986 to: split.clone(),
1987 is_leaf: deliver_here,
1988 });
1989 }
1990
1991 fn record_split_port_tree(root: PortAddr) -> SplitPortRecordingGuard {
1992 *split_port_tree().lock().unwrap() = Some(SplitPortRecording {
1993 root,
1994 edges: Vec::new(),
1995 });
1996 SplitPortRecordingGuard
1997 }
1998
1999 impl SplitPortRecordingGuard {
2000 fn edges(&self) -> Vec<SplitEdge> {
2001 split_port_tree()
2002 .lock()
2003 .unwrap()
2004 .as_ref()
2005 .map(|recording| recording.edges.clone())
2006 .unwrap_or_default()
2007 }
2008 }
2009
2010 impl Drop for SplitPortRecordingGuard {
2011 fn drop(&mut self) {
2012 *split_port_tree().lock().unwrap() = None;
2013 }
2014 }
2015
2016 fn build_split_paths(edges: &[SplitEdge]) -> BTreeMap<PortAddr, Vec<PortAddr>> {
2019 let mut child_to_parent: HashMap<PortAddr, PortAddr> = HashMap::new();
2020 let mut leaves = Vec::new();
2021
2022 for edge in edges {
2023 child_to_parent.insert(edge.to.clone(), edge.from.clone());
2024 if edge.is_leaf {
2025 leaves.push(edge.to.clone());
2026 }
2027 }
2028
2029 let mut result = BTreeMap::new();
2030 for leaf in leaves {
2031 let mut path = vec![leaf.clone()];
2032 let mut current = leaf.clone();
2033 while let Some(parent) = child_to_parent.get(¤t) {
2034 path.push(parent.clone());
2035 current = parent.clone();
2036 }
2037 path.reverse();
2038 result.insert(leaf, path);
2039 }
2040 result
2041 }
2042
2043 fn split_path_ranks(
2046 paths: &BTreeMap<PortAddr, Vec<PortAddr>>,
2047 rank_lookup: &HashMap<String, usize>,
2048 ) -> BTreeMap<usize, Vec<usize>> {
2049 paths
2050 .iter()
2051 .map(|(leaf, path)| {
2052 let ranks: Vec<usize> = path[1..]
2054 .iter()
2055 .map(|pid| {
2056 let proc_name = pid.actor_addr().proc_addr().log_name().to_string();
2057 *rank_lookup
2058 .get(&proc_name)
2059 .unwrap_or_else(|| panic!("unknown proc {proc_name} in split path"))
2060 })
2061 .collect();
2062 let leaf_proc = leaf.actor_addr().proc_addr().log_name().to_string();
2063 let leaf_rank = rank_lookup[&leaf_proc];
2064 (leaf_rank, ranks)
2065 })
2066 .collect()
2067 }
2068
2069 #[derive(
2071 Debug,
2072 Clone,
2073 Default,
2074 PartialEq,
2075 Eq,
2076 Serialize,
2077 Deserialize,
2078 typeuri::Named
2079 )]
2080 struct TestReplyCounts {
2081 counts_by_proc: BTreeMap<String, u64>,
2082 }
2083 wirevalue::register_type!(TestReplyCounts);
2084
2085 impl TestReplyCounts {
2086 fn single(proc_name: String) -> Self {
2087 Self {
2088 counts_by_proc: [(proc_name, 1)].into_iter().collect(),
2089 }
2090 }
2091
2092 fn merge(&mut self, other: Self) {
2093 for (proc_name, count) in other.counts_by_proc {
2094 *self.counts_by_proc.entry(proc_name).or_default() += count;
2095 }
2096 }
2097 }
2098
2099 #[derive(typeuri::Named)]
2100 struct TestReplyCountsReducer;
2101
2102 impl hyperactor::accum::CommReducer for TestReplyCountsReducer {
2103 type Update = TestReplyCounts;
2104
2105 fn reduce(
2106 &self,
2107 mut left: Self::Update,
2108 right: Self::Update,
2109 ) -> anyhow::Result<Self::Update> {
2110 left.merge(right);
2111 Ok(left)
2112 }
2113 }
2114
2115 inventory::submit! {
2116 hyperactor::accum::ReducerFactory {
2117 typehash_f: <TestReplyCountsReducer as Named>::typehash,
2118 builder_f: |_| Ok(Box::new(TestReplyCountsReducer)),
2119 }
2120 }
2121
2122 struct TestReplyCountsAccumulator;
2123
2124 impl hyperactor::accum::Accumulator for TestReplyCountsAccumulator {
2125 type State = TestReplyCounts;
2126 type Update = TestReplyCounts;
2127
2128 fn accumulate(&self, state: &mut Self::State, update: Self::Update) -> anyhow::Result<()> {
2129 state.merge(update);
2130 Ok(())
2131 }
2132
2133 fn reducer_spec(&self) -> Option<hyperactor::accum::ReducerSpec> {
2134 Some(hyperactor::accum::ReducerSpec {
2135 typehash: <TestReplyCountsReducer as Named>::typehash(),
2136 builder_params: None,
2137 })
2138 }
2139 }
2140
2141 #[derive(Debug, Clone, Serialize, Deserialize, typeuri::Named)]
2143 struct TestRequestWithReply {
2144 payload: String,
2145 reply_to: hyperactor::OncePortRef<TestReplyCounts>,
2146 }
2147 wirevalue::register_type!(TestRequestWithReply);
2148
2149 #[derive(Debug, Default)]
2152 #[hyperactor::export(
2153 handlers = [TestRequestWithReply],
2154 )]
2155 struct SplitPortReceiver;
2156
2157 #[async_trait]
2158 impl Actor for SplitPortReceiver {
2159 async fn init(&mut self, _this: &Instance<Self>) -> Result<(), anyhow::Error> {
2160 Ok(())
2161 }
2162 }
2163
2164 #[async_trait]
2165 impl Handler<TestRequestWithReply> for SplitPortReceiver {
2166 async fn handle(
2167 &mut self,
2168 cx: &Context<Self>,
2169 msg: TestRequestWithReply,
2170 ) -> Result<(), anyhow::Error> {
2171 msg.reply_to.post(
2172 cx,
2173 TestReplyCounts::single(cx.self_addr().proc_addr().log_name().to_string()),
2174 );
2175 Ok(())
2176 }
2177 }
2178
2179 #[async_timed_test(timeout_secs = 30)]
2183 async fn test_port_splitting_replies_and_tree() {
2184 let n = 8;
2185 let mut test_mesh = CastTestMesh::new(n);
2186 test_mesh.spawn_split_port_receivers();
2187 let root_domain = test_mesh.root_domain(shape!(a = 2, b = 2, c = 2).into());
2188
2189 let (reply_handle, reply_rx) = context::Mailbox::mailbox(&test_mesh.client)
2190 .open_reduce_port(TestReplyCountsAccumulator);
2191 let reply_ref = reply_handle.bind();
2192 let split_port_recording = record_split_port_tree(reply_ref.port_addr().clone());
2193
2194 root_domain
2196 .cast(
2197 &test_mesh.client,
2198 Flattrs::new(),
2199 TestRequestWithReply {
2200 payload: "split_test".to_string(),
2201 reply_to: reply_ref,
2202 },
2203 )
2204 .unwrap();
2205
2206 let reply_counts = match tokio::time::timeout(Duration::from_secs(5), reply_rx.recv()).await
2207 {
2208 Ok(Ok(reply_counts)) => reply_counts.counts_by_proc,
2209 Ok(Err(e)) => panic!("reply recv error: {e}"),
2210 Err(_) => panic!("timed out waiting for reduced replies"),
2211 };
2212
2213 let expected_counts: BTreeMap<String, u64> =
2216 (0..n).map(|i| (format!("proc_{i}"), 1)).collect();
2217 assert_eq!(reply_counts, expected_counts);
2218
2219 let edges = split_port_recording.edges();
2221 let paths = build_split_paths(&edges);
2222
2223 let rank_lookup: HashMap<String, usize> =
2224 (0..n).map(|i| (format!("proc_{i}"), i)).collect();
2225 let rank_paths = split_path_ranks(&paths, &rank_lookup);
2226
2227 let expected: BTreeMap<usize, Vec<usize>> = [
2228 (0, vec![0]),
2229 (1, vec![0, 1]),
2230 (2, vec![0, 2]),
2231 (3, vec![0, 2, 3]),
2232 (4, vec![0, 4]),
2233 (5, vec![0, 4, 5]),
2234 (6, vec![0, 4, 6]),
2235 (7, vec![0, 4, 6, 7]),
2236 ]
2237 .into_iter()
2238 .collect();
2239
2240 assert_eq!(
2241 rank_paths, expected,
2242 "split-port tree doesn't mirror cast tree"
2243 );
2244 }
2245
2246 #[async_timed_test(timeout_secs = 30)]
2251 async fn test_create_cast_domain_with_bounded_fanout_installs_expected_hops() {
2252 clear_captured_domains();
2253
2254 let test_mesh = CastTestMesh::new(8);
2255 let root_domain = test_mesh.root_domain_with_policy(
2256 shape!(a = 8).into(),
2257 TilingPolicy::BoundedFanout {
2258 fanout: NonZeroUsize::new(2).unwrap(),
2259 },
2260 );
2261 let snapshots = test_mesh
2262 .wait_for_domain_snapshots(root_domain.domain_id(), 8)
2263 .await;
2264
2265 let region = Region::from(shape!(a = 8));
2266 let expected_next_hops: BTreeMap<String, BTreeSet<String>> = [
2270 ("proc_0", vec!["proc_1", "proc_5"]),
2271 ("proc_1", vec!["proc_2", "proc_4"]),
2272 ("proc_2", vec!["proc_3"]),
2273 ("proc_3", vec![]),
2274 ("proc_4", vec![]),
2275 ("proc_5", vec!["proc_6", "proc_7"]),
2276 ("proc_6", vec![]),
2277 ("proc_7", vec![]),
2278 ]
2279 .into_iter()
2280 .map(|(proc_name, next_hops)| {
2281 (
2282 proc_name.to_string(),
2283 next_hops
2284 .into_iter()
2285 .map(str::to_string)
2286 .collect::<BTreeSet<_>>(),
2287 )
2288 })
2289 .collect();
2290
2291 assert_eq!(
2292 snapshots.keys().cloned().collect::<BTreeSet<_>>(),
2293 (0..8).map(|rank| format!("proc_{rank}")).collect()
2294 );
2295
2296 for rank in 0..8 {
2297 let proc_name = format!("proc_{rank}");
2298 let snapshot = snapshots
2299 .get(&proc_name)
2300 .unwrap_or_else(|| panic!("missing snapshot for {proc_name}"));
2301
2302 assert_eq!(snapshot.base_rank_in_domain, rank);
2303 assert_eq!(
2304 snapshot.point_in_domain,
2305 region.point_of_base_rank(rank).unwrap()
2306 );
2307 assert_eq!(snapshot.local_actor_proc, proc_name);
2308 assert_eq!(snapshot.next_hop_procs, expected_next_hops[&proc_name]);
2309 }
2310 }
2311
2312 #[async_timed_test(timeout_secs = 30)]
2317 async fn test_create_cast_domain_with_bisection_installs_expected_hops() {
2318 clear_captured_domains();
2319
2320 let test_mesh = CastTestMesh::new(8);
2321 let root_domain =
2322 test_mesh.root_domain_with_policy(shape!(a = 8).into(), TilingPolicy::Bisection);
2323 let snapshots = test_mesh
2324 .wait_for_domain_snapshots(root_domain.domain_id(), 8)
2325 .await;
2326
2327 let region = Region::from(shape!(a = 8));
2328 let expected_next_hops: BTreeMap<String, BTreeSet<String>> = [
2332 ("proc_0", vec!["proc_1", "proc_2", "proc_4"]),
2333 ("proc_1", vec![]),
2334 ("proc_2", vec!["proc_3"]),
2335 ("proc_3", vec![]),
2336 ("proc_4", vec!["proc_5", "proc_6"]),
2337 ("proc_5", vec![]),
2338 ("proc_6", vec!["proc_7"]),
2339 ("proc_7", vec![]),
2340 ]
2341 .into_iter()
2342 .map(|(proc_name, next_hops)| {
2343 (
2344 proc_name.to_string(),
2345 next_hops
2346 .into_iter()
2347 .map(str::to_string)
2348 .collect::<BTreeSet<_>>(),
2349 )
2350 })
2351 .collect();
2352
2353 assert_eq!(
2354 snapshots.keys().cloned().collect::<BTreeSet<_>>(),
2355 (0..8).map(|rank| format!("proc_{rank}")).collect()
2356 );
2357
2358 for rank in 0..8 {
2359 let proc_name = format!("proc_{rank}");
2360 let snapshot = snapshots
2361 .get(&proc_name)
2362 .unwrap_or_else(|| panic!("missing snapshot for {proc_name}"));
2363
2364 assert_eq!(snapshot.base_rank_in_domain, rank);
2365 assert_eq!(
2366 snapshot.point_in_domain,
2367 region.point_of_base_rank(rank).unwrap()
2368 );
2369 assert_eq!(snapshot.local_actor_proc, proc_name);
2370 assert_eq!(snapshot.next_hop_procs, expected_next_hops[&proc_name]);
2371 }
2372 }
2373}