1use std::collections::HashMap;
41use std::sync::Arc;
42use std::sync::OnceLock;
43
44use anyhow::Result;
45use async_trait::async_trait;
46use hyperactor::Actor;
47use hyperactor::ActorAddr;
48use hyperactor::ActorRef;
49use hyperactor::Context;
50use hyperactor::Endpoint as _;
51use hyperactor::EndpointLocation;
52use hyperactor::Handler;
53use hyperactor::IdleFlushPortRefRepr;
54use hyperactor::Instance;
55use hyperactor::Label;
56use hyperactor::OncePortRefRepr;
57use hyperactor::PortRef;
58use hyperactor::PortRefRepr;
59use hyperactor::ProcAddr;
60use hyperactor::RemoteEndpoint as _;
61use hyperactor::Uid;
62use hyperactor::accum::ReducerMode;
63use hyperactor::context;
64use hyperactor::mailbox::DeliveryFailure;
65use hyperactor::mailbox::DeliveryFailureReport;
66use hyperactor::mailbox::MailboxSender;
67use hyperactor::mailbox::MessageEnvelope;
68use hyperactor::mailbox::TransportFailure;
69use hyperactor::mailbox::TransportFailureReason;
70use hyperactor::mailbox::Undeliverable;
71use hyperactor::mailbox::UndeliverableMailboxSender;
72use hyperactor::mailbox::UndeliverableReason;
73use hyperactor::mailbox::monitored_return_handle;
74use hyperactor::ordering::SEQ_INFO;
75use hyperactor::ordering::SeqInfo;
76use hyperactor::ordering::SeqKey;
77use hyperactor::port::Port;
78use hyperactor::value_mesh::ValueMesh;
79use hyperactor_config::Flattrs;
80use hyperactor_config::NonZeroUsize as ConfigNonZeroUsize;
81use ndslice::Point;
82use ndslice::Region;
83use ndslice::view::View;
84use ndslice::view::ViewExt;
85use serde::Deserialize;
86use serde::Serialize;
87use typeuri::Named;
88use uuid::Uuid;
89
90use crate::tile::MaterializedTile;
91use crate::tile::Tile;
92pub use crate::tile::TilingPolicy;
93
94hyperactor_config::declare_attrs! {
95 pub attr CAST_POINT: Point;
101
102 pub attr CAST_ORIGINATING_SENDER: ActorAddr;
105
106 pub attr CAST_FAILURE_PHASE: String;
108
109 pub attr CAST_FAILURE_CAST_ACTOR: ActorAddr;
111
112 pub attr CAST_FAILURE_ORIGIN: ActorAddr;
114
115 pub attr CAST_FAILURE_RETURN_PORT: String;
117}
118
119#[cfg(test)]
120hyperactor_config::declare_attrs! {
121 pub attr CAST_LINEAGE: Vec<ActorAddr>;
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
133struct ResourceRankRepr(Option<usize>);
134
135impl Named for ResourceRankRepr {
136 fn typename() -> &'static str {
137 "hyperactor_mesh::resource::RankRepr"
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
146pub struct CastDomainId {
147 domain_id: Uid,
149}
150
151impl CastDomainId {
152 pub fn new() -> Self {
154 Self {
155 domain_id: Uid::anonymous(),
156 }
157 }
158
159 pub fn domain_id(&self) -> &Uid {
161 &self.domain_id
162 }
163
164 pub fn materialize(
170 self,
171 cx: &impl context::Actor,
172 nodes: Arc<ValueMesh<CastNode>>,
173 tiling_policy: TilingPolicy,
174 headers: Flattrs,
175 ) -> anyhow::Result<CastDomainRef> {
176 let region = nodes.region().clone();
177
178 let member_mesh = Arc::new(ValueMesh::new(
179 region.clone(),
180 nodes
181 .values()
182 .map(|node| node.destination.actor().clone())
183 .collect(),
184 )?);
185
186 self.materialize_members(cx, member_mesh, nodes, region, tiling_policy, headers)
187 }
188
189 fn materialize_members(
190 self,
191 cx: &impl context::Actor,
192 member_mesh: Arc<ValueMesh<ActorAddr>>,
193 node_mesh: Arc<ValueMesh<CastNode>>,
194 region: Region,
195 tiling_policy: TilingPolicy,
196 headers: Flattrs,
197 ) -> anyhow::Result<CastDomainRef> {
198 let root_tile =
199 MaterializedTile::from_value_mesh_with_tile(Tile::from_view(®ion), node_mesh);
200 anyhow::ensure!(
201 !root_tile.tile().space().is_empty(),
202 "cannot construct root-heaved subtree tiles for an empty tile"
203 );
204
205 let root_destination = root_tile
206 .root_item()
207 .ok_or_else(|| anyhow::anyhow!("cast routing mesh must contain a root node"))?
208 .destination
209 .clone();
210
211 let subtrees = tiling_policy
212 .children(root_tile.tile())
213 .into_iter()
214 .map(|subtree_tile| {
215 let served_region =
216 Region::new(region.labels().to_vec(), subtree_tile.space().clone());
217
218 Ok(CastSubtree {
219 route: CastRoute::try_from_tile(
220 tiling_policy,
221 &root_tile.subtile(subtree_tile),
222 )?,
223 served_region,
224 })
225 })
226 .collect::<Result<Vec<_>>>()?;
227
228 for subtree in &subtrees {
229 if let CastRoute::ViaCastActor(root_actor) = &subtree.route {
230 root_actor.port().post_with_headers(
231 cx,
232 headers.clone(),
233 CreateCastDomain {
234 cast_domain_id: self.clone(),
235 region: region.clone(),
236 tiling_policy,
237 tile: root_tile.subtile(Tile::from_view(&subtree.served_region)),
238 },
239 );
240 }
241 }
242
243 Ok(CastDomainRef::from_subtrees(
244 self,
245 root_destination,
246 subtrees,
247 member_mesh,
248 ))
249 }
250}
251
252impl std::fmt::Display for CastDomainId {
253 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254 write!(f, "{}", self.domain_id)
255 }
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct CastDomainRef {
265 id: CastDomainId,
266 sender_hop: CastHop,
268 members: Arc<ValueMesh<ActorAddr>>,
270 #[serde(skip)]
272 seq_keys: Arc<OnceLock<Arc<Vec<SeqKey>>>>,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
276struct CastSubtree {
277 route: CastRoute,
278 served_region: Region,
279}
280
281impl CastDomainRef {
282 fn from_subtrees(
284 id: CastDomainId,
285 root_destination: CastDestination,
286 subtrees: Vec<CastSubtree>,
287 members: Arc<ValueMesh<ActorAddr>>,
288 ) -> Self {
289 let num_destinations = members.region().num_ranks();
290
291 Self {
292 id,
293 sender_hop: CastHop {
294 local_destination: root_destination,
295 next_hops: subtrees,
296 num_destinations,
297 },
298 members,
299 seq_keys: Arc::new(OnceLock::new()),
300 }
301 }
302
303 pub fn id(&self) -> CastDomainId {
305 self.id.clone()
306 }
307
308 pub fn domain_id(&self) -> &Uid {
310 self.id.domain_id()
311 }
312
313 pub fn members(&self) -> &ValueMesh<ActorAddr> {
315 &self.members
316 }
317
318 pub fn cast<M: Serialize + Named>(
324 &self,
325 cx: &impl context::Actor,
326 headers: Flattrs,
327 message: M,
328 ) -> anyhow::Result<()> {
329 self.cast_with_return_undeliverable(cx, headers, message, true)
330 }
331
332 pub fn cast_with_return_undeliverable<M: Serialize + Named>(
334 &self,
335 cx: &impl context::Actor,
336 headers: Flattrs,
337 message: M,
338 return_undeliverable: bool,
339 ) -> anyhow::Result<()> {
340 let data = wirevalue::Any::<wirevalue::encoding::Multipart>::serialize(&message)?;
341 let (session_id, seqs) = self.seqs_for_cast(cx)?;
342 let cast_message = CastMessage {
343 cast_domain_id: self.id.clone(),
344 sender: cx.mailbox().actor_addr().clone(),
345 session_id,
346 seqs,
347 #[cfg(test)]
348 lineage: Vec::new(),
349 headers,
350 dest_port: M::port(),
351 data,
352 return_undeliverable,
353 };
354
355 self.sender_hop
356 .forward(cx, &cast_message, &ForwardLineage::default())
357 }
358
359 pub fn destroy(&self, cx: &impl context::Actor) {
369 let origin = cx.mailbox().actor_addr().clone();
370 for subtree in &self.sender_hop.next_hops {
371 if let CastRoute::ViaCastActor(root_actor) = &subtree.route {
372 root_actor.post(
373 cx,
374 DestroyCastDomain {
375 domain_id: self.id.clone(),
376 origin: origin.clone(),
377 },
378 );
379 }
380 }
381 }
382
383 fn seqs_for_cast(&self, cx: &impl context::Actor) -> Result<(Uuid, ValueMesh<u64>)> {
391 let sequencer = cx.instance().sequencer();
392
393 Ok((
394 sequencer.session_id(),
395 ValueMesh::from_ranges_with_default(
396 self.members.region().clone(),
397 0,
398 sequencer.assign_seqs(self.seq_keys.get_or_init(|| {
399 Arc::new(
400 self.members
401 .values()
402 .map(|member| SeqKey::for_handler(&member))
403 .collect(),
404 )
405 })),
406 )?,
407 ))
408 }
409}
410
411fn next_tiles<T: 'static>(
431 tiling_policy: TilingPolicy,
432 tile: &MaterializedTile<T>,
433) -> Vec<MaterializedTile<T>> {
434 tiling_policy
435 .children(tile.tile())
436 .into_iter()
437 .map(|child| tile.subtile(child))
438 .collect()
439}
440
441pub const CAST_ACTOR_NAME: &str = "cast";
446
447#[derive(Debug, Default)]
454#[hyperactor::export(
455 handlers = [
456 CreateCastDomain,
457 DestroyCastDomain,
458 CastMessage
459 ],
460)]
461#[hyperactor::spawnable]
462pub struct CastActor {
463 installed_hops: HashMap<CastDomainId, CastHop>,
465}
466
467impl CastActor {
468 pub fn ref_for_proc(proc: ProcAddr) -> ActorRef<Self> {
472 ActorRef::attest(ActorAddr::root(proc, Label::strip(CAST_ACTOR_NAME)))
473 }
474}
475
476#[derive(Debug, Clone, Serialize, Deserialize)]
478struct CastHop {
479 local_destination: CastDestination,
481 next_hops: Vec<CastSubtree>,
483 num_destinations: usize,
485}
486
487#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct CastDestination {
490 point_in_domain: Point,
491 base_rank_in_domain: usize,
492 actor: ActorAddr,
493}
494
495impl CastDestination {
496 pub fn mesh(region: Region, actors: Vec<ActorAddr>) -> anyhow::Result<ValueMesh<Self>> {
514 anyhow::ensure!(
515 actors.len() == region.num_ranks(),
516 "cast domain member count must match the logical region"
517 );
518
519 let extent = region.extent();
520 let destinations = region
521 .slice()
522 .iter()
523 .zip(actors)
524 .map(|(base_rank_in_domain, actor)| {
525 let coordinates = region.slice().coordinates(base_rank_in_domain)?;
526
527 Ok(Self {
528 point_in_domain: extent.point(coordinates)?,
529 base_rank_in_domain,
530 actor,
531 })
532 })
533 .collect::<anyhow::Result<Vec<_>>>()?;
534
535 ValueMesh::new(region, destinations).map_err(Into::into)
536 }
537
538 pub fn subset(
556 destinations: &ValueMesh<Self>,
557 region: Region,
558 ) -> anyhow::Result<ValueMesh<Self>> {
559 anyhow::ensure!(
560 region.is_subset(&destinations.region()),
561 "cast domain slice must be a subset of the logical region"
562 );
563
564 let actors = region
565 .slice()
566 .iter()
567 .map(|base_rank| {
568 destinations
569 .get_by_base_rank(base_rank)
570 .map(|destination| destination.actor.clone())
571 .ok_or_else(|| anyhow::anyhow!("missing cast destination for rank {base_rank}"))
572 })
573 .collect::<anyhow::Result<Vec<_>>>()?;
574
575 Self::mesh(region, actors)
576 }
577
578 pub fn actor(&self) -> &ActorAddr {
583 &self.actor
584 }
585
586 fn seq(&self, seqs: &ValueMesh<u64>) -> anyhow::Result<u64> {
587 seqs.get_by_base_rank(self.base_rank_in_domain)
588 .copied()
589 .ok_or_else(|| {
590 anyhow::anyhow!("missing seq for base rank {}", self.base_rank_in_domain)
591 })
592 }
593}
594
595#[derive(Debug, Clone, Serialize, Deserialize)]
597pub struct CastNode {
598 cast_actor: ActorRef<CastActor>,
599 destination: CastDestination,
600}
601
602impl CastNode {
603 pub fn new(cast_actor: ActorRef<CastActor>, destination: CastDestination) -> Self {
608 Self {
609 cast_actor,
610 destination,
611 }
612 }
613}
614
615#[derive(Debug, Clone, Serialize, Deserialize)]
617enum CastRoute {
618 ViaCastActor(ActorRef<CastActor>),
620 Direct(CastDestination),
622}
623
624impl CastRoute {
625 fn try_from_tile(
628 tiling_policy: TilingPolicy,
629 tile: &MaterializedTile<CastNode>,
630 ) -> anyhow::Result<Self> {
631 let root_node = tile
632 .root_item()
633 .ok_or_else(|| anyhow::anyhow!("tile must have at least one cast node"))?;
634 let destination = root_node.destination.clone();
635
636 Ok(if next_tiles(tiling_policy, tile).is_empty() {
637 Self::Direct(destination)
638 } else {
639 Self::ViaCastActor(root_node.cast_actor.clone())
640 })
641 }
642}
643
644#[cfg(test)]
645fn cast_actor_ref_for_member(member: &ActorAddr) -> ActorRef<CastActor> {
646 CastActor::ref_for_proc(member.proc_addr().clone())
647}
648
649fn annotate_cast_failure(
650 envelope: &mut MessageEnvelope,
651 cast_actor: &ActorAddr,
652 phase: &str,
653 origin: &ActorAddr,
654 return_port: &hyperactor::PortAddr,
655) {
656 if let Some(failure) = envelope.root_delivery_failure_mut() {
657 annotate_cast_delivery_failure(failure, cast_actor, phase, origin, return_port);
658 }
659}
660
661fn annotate_cast_delivery_failure(
662 failure: &mut DeliveryFailure,
663 cast_actor: &ActorAddr,
664 phase: &str,
665 origin: &ActorAddr,
666 return_port: &hyperactor::PortAddr,
667) {
668 failure.attrs.set(CAST_FAILURE_PHASE, phase.to_string());
669 failure
670 .attrs
671 .set(CAST_FAILURE_CAST_ACTOR, cast_actor.clone());
672 failure.attrs.set(CAST_FAILURE_ORIGIN, origin.clone());
673 failure
674 .attrs
675 .set(CAST_FAILURE_RETURN_PORT, return_port.to_string());
676}
677
678#[async_trait]
679impl Actor for CastActor {
680 async fn init(&mut self, this: &Instance<Self>) -> Result<(), anyhow::Error> {
681 this.set_system();
682 Ok(())
683 }
684
685 async fn handle_undeliverable_message(
686 &mut self,
687 cx: &Instance<Self>,
688 _reason: hyperactor::mailbox::UndeliverableReason,
689 undelivered: Undeliverable<MessageEnvelope>,
690 ) -> Result<(), anyhow::Error> {
691 self.return_delivery_failure_to_origin(cx, undelivered)
692 .await
693 }
694
695 async fn handle_invalid_reference(
696 &mut self,
697 cx: &Instance<Self>,
698 _invalid: hyperactor::mailbox::InvalidReference,
699 undelivered: Undeliverable<MessageEnvelope>,
700 ) -> Result<(), anyhow::Error> {
701 self.return_delivery_failure_to_origin(cx, undelivered)
702 .await
703 }
704}
705
706impl CastActor {
707 fn return_cast_error_to_origin(
708 cx: &Context<Self>,
709 message: &CastMessage,
710 phase: &str,
711 reason: TransportFailureReason,
712 ) {
713 let destination = cx.self_addr().port_addr(Port::handler::<CastMessage>());
714 let mut failure = DeliveryFailure::new(UndeliverableReason::Transport(
715 TransportFailure::new(destination.clone(), reason),
716 ));
717 let return_port: PortRef<Undeliverable<MessageEnvelope>> =
718 PortRef::attest_handler_port(&message.sender);
719
720 match wirevalue::Any::serialize(message) {
721 Ok(data) => {
722 let mut message_envelope = MessageEnvelope::new(
723 message.sender.clone(),
724 destination,
725 data,
726 cx.headers().clone(),
727 );
728 message_envelope.push_delivery_failure(failure);
729 Self::return_cast_failure_to_origin(cx, message_envelope, message, phase);
730 }
731 Err(error) => {
732 tracing::error!(%error, "failed to serialize returned cast message");
733 annotate_cast_delivery_failure(
734 &mut failure,
735 cx.self_addr(),
736 phase,
737 &message.sender,
738 return_port.port_addr(),
739 );
740 return_port.post(
741 cx,
742 Undeliverable::Report(DeliveryFailureReport::new(
743 cx.self_addr().clone(),
744 EndpointLocation::Port(destination),
745 Some(CastMessage::typename().to_string()),
746 failure,
747 )),
748 );
749 }
750 }
751 }
752
753 fn return_cast_failure_to_origin(
754 cx: &Instance<Self>,
755 mut message_envelope: MessageEnvelope,
756 message: &CastMessage,
757 phase: &str,
758 ) {
759 let return_port = PortRef::attest_handler_port(&message.sender);
760 annotate_cast_failure(
761 &mut message_envelope,
762 cx.self_addr(),
763 phase,
764 &message.sender,
765 return_port.port_addr(),
766 );
767 message_envelope.set_header(CAST_ORIGINATING_SENDER, message.sender.clone());
768 return_port.post(cx, Undeliverable::Returned(message_envelope));
769 }
770
771 async fn return_delivery_failure_to_origin(
772 &mut self,
773 cx: &Instance<Self>,
774 undelivered: Undeliverable<MessageEnvelope>,
775 ) -> Result<(), anyhow::Error> {
776 let mut message_envelope = match undelivered {
777 Undeliverable::Returned(message_envelope) => message_envelope,
778 Undeliverable::Report(report) => {
779 tracing::error!(?report, "cast delivery failed without a returned message");
780 return Ok(());
781 }
782 };
783
784 if let Ok(message) = message_envelope.deserialized::<CastMessage>() {
786 Self::return_cast_failure_to_origin(cx, message_envelope, &message, "forward");
787 return Ok(());
788 }
789
790 if let Ok(message) = message_envelope.deserialized::<DestroyCastDomain>() {
792 let return_port = PortRef::attest_handler_port(&message.origin);
793 annotate_cast_failure(
794 &mut message_envelope,
795 cx.self_addr(),
796 "destroy",
797 &message.origin,
798 return_port.port_addr(),
799 );
800 message_envelope.set_header(CAST_ORIGINATING_SENDER, message.origin.clone());
801 return_port.post(cx, Undeliverable::Returned(message_envelope.clone()));
802 return Ok(());
803 }
804
805 if let Some(sender) = message_envelope.headers().get(CAST_ORIGINATING_SENDER) {
808 let return_port = PortRef::attest_handler_port(&sender);
809 annotate_cast_failure(
810 &mut message_envelope,
811 cx.self_addr(),
812 "deliver_here",
813 &sender,
814 return_port.port_addr(),
815 );
816 return_port.post(cx, Undeliverable::Returned(message_envelope.clone()));
817 return Ok(());
818 }
819
820 UndeliverableMailboxSender
822 .post(message_envelope, monitored_return_handle());
823 Ok(())
824 }
825}
826
827#[derive(Debug, Serialize, Deserialize, typeuri::Named)]
834struct CreateCastDomain {
835 cast_domain_id: CastDomainId,
836 region: Region,
837 tiling_policy: TilingPolicy,
838 tile: MaterializedTile<CastNode>,
839}
840wirevalue::register_type!(CreateCastDomain);
841
842#[async_trait]
843impl Handler<CreateCastDomain> for CastActor {
844 #[tracing::instrument(
845 level = "debug",
846 skip_all,
847 fields(
848 domain_id = %message.cast_domain_id,
849 rank = message.tile.root_rank(),
850 num_members = message.tile.rank_count(),
851 )
852 )]
853 async fn handle(
854 &mut self,
855 cx: &Context<Self>,
856 message: CreateCastDomain,
857 ) -> Result<(), anyhow::Error> {
858 let CreateCastDomain {
859 cast_domain_id,
860 region,
861 tiling_policy,
862 tile,
863 } = message;
864 if self.installed_hops.contains_key(&cast_domain_id) {
865 return Ok(());
866 }
867
868 let mut next_hops = Vec::new();
869
870 for next_tile in next_tiles(tiling_policy, &tile) {
871 let next_hop_served_region =
872 Region::new(region.labels().to_vec(), next_tile.tile().space().clone());
873
874 let route = CastRoute::try_from_tile(tiling_policy, &next_tile)?;
875
876 if let CastRoute::ViaCastActor(next_hop_cast_actor) = &route {
877 next_hop_cast_actor.post(
878 cx,
879 CreateCastDomain {
880 cast_domain_id: cast_domain_id.clone(),
881 region: region.clone(),
882 tiling_policy,
883 tile: next_tile,
884 },
885 );
886 }
887 next_hops.push(CastSubtree {
888 route,
889 served_region: next_hop_served_region,
890 });
891 }
892
893 let local_destination = tile
894 .root_item()
895 .ok_or_else(|| anyhow::anyhow!("cast tile must have a root node"))?
896 .destination
897 .clone();
898
899 let cast_hop = CastHop {
900 local_destination,
901 next_hops,
902 num_destinations: tile.rank_count(),
903 };
904
905 #[cfg(test)]
906 {
907 tests::capture_installed_domain(cx, cast_domain_id.domain_id(), &cast_hop);
908 }
909
910 self.installed_hops.insert(cast_domain_id, cast_hop);
911
912 Ok(())
913 }
914}
915
916#[derive(Debug, Clone, Copy)]
918struct SplitFanout {
919 peer_count: usize,
921 num_destinations: usize,
924}
925
926fn split_ports(
932 cx: &impl context::Actor,
933 data: &mut wirevalue::Any<wirevalue::encoding::Multipart>,
934 fanout: SplitFanout,
935) -> Result<()> {
936 data.visit_multipart_parts_mut::<PortRefRepr, anyhow::Error>(|port| {
937 if port.unsplit() {
938 return Ok(());
939 }
940
941 let split = port.port_addr().split(
942 cx,
943 port.reducer_spec().clone(),
944 ReducerMode::Streaming(port.streaming_opts().clone()),
945 port.get_return_undeliverable(),
946 )?;
947
948 #[cfg(test)]
949 {
950 tests::collect_split_port(port.port_addr(), &split);
951 }
952
953 port.update_port_addr(split);
954 Ok(())
955 })?;
956
957 data.visit_multipart_parts_mut::<IdleFlushPortRefRepr, anyhow::Error>(|port| {
958 if port.unsplit() || port.reducer_spec().is_none() {
959 return Ok(());
960 }
961
962 let opts = port.reducer_opts();
963
964 let expected = fanout
965 .num_destinations
966 .checked_mul(opts.expected_updates_per_destination.get())
967 .and_then(ConfigNonZeroUsize::new)
968 .ok_or_else(|| {
969 anyhow::anyhow!("expected reducer update count must be nonzero and cannot overflow")
970 })?;
971
972 let split = port.port_addr().split(
973 cx,
974 port.reducer_spec().cloned(),
975 ReducerMode::IdleFlush {
976 expected,
977 idle_timeout: opts.idle_timeout,
978 abandon_timeout: opts.abandon_timeout,
979 },
980 port.get_return_undeliverable(),
981 )?;
982
983 #[cfg(test)]
984 {
985 tests::collect_split_port(port.port_addr(), &split);
986 }
987
988 port.update_port_addr(split);
989 Ok(())
990 })?;
991
992 data.visit_multipart_parts_mut::<OncePortRefRepr, anyhow::Error>(|port| {
993 if port.unsplit() || port.reducer_spec().is_none() {
994 return Ok(());
997 }
998
999 let split = port.port_addr().split(
1000 cx,
1001 port.reducer_spec().clone(),
1002 ReducerMode::Once(fanout.peer_count),
1003 true,
1004 )?;
1005
1006 #[cfg(test)]
1007 {
1008 tests::collect_split_port(port.port_addr(), &split);
1009 }
1010
1011 port.update_port_addr(split);
1012 Ok(())
1013 })?;
1014
1015 Ok(())
1016}
1017
1018#[derive(Debug, Clone, Default)]
1024struct ForwardLineage {
1025 #[cfg(test)]
1026 actors: Vec<ActorAddr>,
1027}
1028
1029impl ForwardLineage {
1030 #[cfg(test)]
1031 fn from_message(message: &CastMessage) -> Self {
1032 Self {
1033 actors: message.lineage.clone(),
1034 }
1035 }
1036
1037 #[cfg(not(test))]
1038 fn from_message(_message: &CastMessage) -> Self {
1039 Self {}
1040 }
1041
1042 fn through(&self, actor: &ActorAddr) -> Self {
1043 #[cfg(test)]
1044 {
1045 let mut actors = self.actors.clone();
1046 actors.push(actor.clone());
1047 Self { actors }
1048 }
1049 #[cfg(not(test))]
1050 {
1051 let _ = actor;
1052 Self {}
1053 }
1054 }
1055
1056 #[cfg(test)]
1057 fn actors(&self) -> Vec<ActorAddr> {
1058 self.actors.clone()
1059 }
1060}
1061
1062#[derive(Debug, Serialize, Deserialize, typeuri::Named)]
1068struct CastMessage {
1069 cast_domain_id: CastDomainId,
1071 sender: ActorAddr,
1073 session_id: Uuid,
1075 seqs: ValueMesh<u64>,
1077 #[cfg(test)]
1079 lineage: Vec<ActorAddr>,
1080 headers: Flattrs,
1082 dest_port: u64,
1084 data: wirevalue::Any<wirevalue::encoding::Multipart>,
1086 return_undeliverable: bool,
1088}
1089
1090wirevalue::register_type!(CastMessage);
1091
1092#[async_trait]
1093impl Handler<CastMessage> for CastActor {
1094 #[tracing::instrument(
1098 level = "debug",
1099 skip_all,
1100 fields(
1101 domain_id = %message.cast_domain_id.domain_id(),
1102 )
1103 )]
1104 async fn handle(
1105 &mut self,
1106 cx: &Context<Self>,
1107 message: CastMessage,
1108 ) -> Result<(), anyhow::Error> {
1109 let Some(domain) = self.installed_hops.get(&message.cast_domain_id) else {
1110 Self::return_cast_error_to_origin(
1111 cx,
1112 &message,
1113 "unknown_domain",
1114 TransportFailureReason::NoRoute,
1115 );
1116 return Ok(());
1117 };
1118
1119 let lineage = ForwardLineage::from_message(&message).through(cx.self_addr());
1120 if let Err(error) = domain.forward(cx, &message, &lineage) {
1121 tracing::error!(
1122 %error,
1123 domain_id = %message.cast_domain_id,
1124 "failed to route cast message",
1125 );
1126 Self::return_cast_error_to_origin(
1127 cx,
1128 &message,
1129 "route",
1130 TransportFailureReason::LinkUnavailable(error.to_string()),
1131 );
1132 }
1133
1134 Ok(())
1135 }
1136}
1137
1138struct CastDelivery<'a> {
1139 sender: &'a ActorAddr,
1140 session_id: Uuid,
1141 seq: u64,
1142 headers: &'a Flattrs,
1143 dest_port: u64,
1144 return_undeliverable: bool,
1145}
1146
1147impl<'a> CastDelivery<'a> {
1148 fn try_from_message(
1149 message: &'a CastMessage,
1150 destination: &CastDestination,
1151 ) -> anyhow::Result<Self> {
1152 Ok(Self {
1153 sender: &message.sender,
1154 session_id: message.session_id,
1155 seq: destination.seq(&message.seqs)?,
1156 headers: &message.headers,
1157 dest_port: message.dest_port,
1158 return_undeliverable: message.return_undeliverable,
1159 })
1160 }
1161}
1162
1163fn deliver_to_destination(
1167 cx: &impl context::Actor,
1168 delivery: &CastDelivery<'_>,
1169 destination: &CastDestination,
1170 mut data: wirevalue::Any<wirevalue::encoding::Multipart>,
1171 lineage: &ForwardLineage,
1172) -> Result<()> {
1173 let rank = destination.point_in_domain.rank();
1174
1175 data.visit_multipart_parts_mut::<ResourceRankRepr, anyhow::Error>(
1176 |ResourceRankRepr(resource_rank)| {
1177 *resource_rank = Some(rank);
1178 Ok(())
1179 },
1180 )?;
1181
1182 let mut headers = delivery.headers.clone();
1183 headers.set(CAST_POINT, destination.point_in_domain.clone());
1184 headers.set(CAST_ORIGINATING_SENDER, delivery.sender.clone());
1185 let seq_info = SeqInfo::Session {
1186 session_id: delivery.session_id,
1187 seq: delivery.seq,
1188 };
1189 headers.set(SEQ_INFO, seq_info.clone());
1190
1191 #[cfg(not(test))]
1192 let _ = lineage;
1193
1194 #[cfg(test)]
1195 headers.set(CAST_LINEAGE, lineage.actors());
1196
1197 let dest = destination
1198 .actor
1199 .port_addr(Port::handler_id(delivery.dest_port, None));
1200 hyperactor::mailbox::headers::stamp_sender_actor_id_hash(&mut headers, delivery.sender);
1201 hyperactor::mailbox::headers::stamp_sender_actor_id(
1202 &mut headers,
1203 &seq_info,
1204 &dest,
1205 delivery.sender,
1206 );
1207 cx.instance().post_with_external_seq_info_and_return(
1208 dest,
1209 headers,
1210 data.erase_encoding(),
1211 delivery.return_undeliverable,
1212 );
1213 Ok(())
1214}
1215
1216impl CastHop {
1217 fn forward(
1235 &self,
1236 cx: &impl context::Actor,
1237 message: &CastMessage,
1238 lineage: &ForwardLineage,
1239 ) -> Result<(), anyhow::Error> {
1240 let mut data = message.data.clone();
1243 split_ports(
1244 cx,
1245 &mut data,
1246 SplitFanout {
1247 peer_count: 1 + self.next_hops.len(),
1248 num_destinations: self.num_destinations,
1249 },
1250 )?;
1251
1252 let destination = &self.local_destination;
1253 let local_lineage = lineage.through(&destination.actor);
1254
1255 deliver_to_destination(
1256 cx,
1257 &CastDelivery::try_from_message(message, destination)?,
1258 destination,
1259 data.clone(),
1260 &local_lineage,
1261 )?;
1262
1263 for next_hop in &self.next_hops {
1264 match &next_hop.route {
1265 CastRoute::ViaCastActor(next_hop_cast_actor) => {
1266 let forward_headers = message.headers.clone();
1267 let mut port = next_hop_cast_actor.port();
1268 port.return_undeliverable(message.return_undeliverable);
1269 port.post_with_headers(
1270 cx,
1271 forward_headers,
1272 CastMessage {
1273 cast_domain_id: message.cast_domain_id.clone(),
1274 sender: message.sender.clone(),
1275 session_id: message.session_id,
1276 seqs: message
1277 .seqs
1278 .sliced_unchecked(next_hop.served_region.clone()),
1279 #[cfg(test)]
1280 lineage: lineage.actors(),
1281 headers: message.headers.clone(),
1282 dest_port: message.dest_port,
1283 data: data.clone(),
1284 return_undeliverable: message.return_undeliverable,
1285 },
1286 );
1287 }
1288 CastRoute::Direct(destination) => {
1289 let direct_lineage = lineage.through(&destination.actor);
1290 deliver_to_destination(
1291 cx,
1292 &CastDelivery::try_from_message(message, destination)?,
1293 destination,
1294 data.clone(),
1295 &direct_lineage,
1296 )?;
1297 }
1298 }
1299 }
1300
1301 Ok(())
1302 }
1303}
1304
1305#[derive(Debug, Serialize, Deserialize, typeuri::Named)]
1313struct DestroyCastDomain {
1314 domain_id: CastDomainId,
1316 origin: ActorAddr,
1318}
1319wirevalue::register_type!(DestroyCastDomain);
1320
1321#[async_trait]
1322impl Handler<DestroyCastDomain> for CastActor {
1323 #[tracing::instrument(
1324 level = "debug",
1325 skip_all,
1326 fields(domain_id = %message.domain_id)
1327 )]
1328 async fn handle(
1329 &mut self,
1330 cx: &Context<Self>,
1331 message: DestroyCastDomain,
1332 ) -> Result<(), anyhow::Error> {
1333 let Some(cast_hop) = self.installed_hops.remove(&message.domain_id) else {
1334 return Ok(());
1335 };
1336
1337 #[cfg(test)]
1338 {
1339 tests::capture_destroyed_domain(cx, message.domain_id.domain_id());
1340 }
1341
1342 for next_hop in &cast_hop.next_hops {
1343 if let CastRoute::ViaCastActor(next_hop) = &next_hop.route {
1344 next_hop.post(
1345 cx,
1346 DestroyCastDomain {
1347 domain_id: message.domain_id.clone(),
1348 origin: message.origin.clone(),
1349 },
1350 );
1351 }
1352 }
1353
1354 Ok(())
1355 }
1356}
1357
1358#[cfg(test)]
1359mod tests {
1360 use std::collections::BTreeMap;
1377 use std::collections::BTreeSet;
1378 use std::collections::HashMap;
1379 use std::num::NonZeroUsize;
1380 use std::sync::Mutex;
1381 use std::sync::OnceLock;
1382 use std::time::Duration;
1383
1384 use hyperactor::Client;
1385 use hyperactor::IdleFlushPortRef;
1386 use hyperactor::PortAddr;
1387 use hyperactor::ProcAddr;
1388 use hyperactor::accum::IdleFlushReducerOpts;
1389 use hyperactor::channel::ChannelTransport;
1390 use hyperactor::proc::Proc;
1391 use ndslice::Shape;
1392 use ndslice::Slice;
1393 use ndslice::ViewExt;
1394 use ndslice::shape;
1395 use ndslice::strategy::gen_region_strided;
1396 use ndslice::view::BuildFromRegionIndexed;
1397 use ndslice::view::Ranked;
1398 use proptest::prelude::*;
1399 use timed_test::async_timed_test;
1400 use tokio::runtime::Runtime;
1401 use typeuri::Named;
1402
1403 use super::*;
1404
1405 fn small_shape_sizes() -> impl Strategy<Value = Vec<usize>> {
1406 prop::collection::vec(1usize..=4, 1..=4).prop_filter("shape must stay small", |sizes| {
1407 sizes.iter().product::<usize>() <= 64
1408 })
1409 }
1410
1411 fn shape_from_sizes(sizes: &[usize]) -> Shape {
1412 Shape::new(
1413 (0..sizes.len()).map(|dim| format!("d{dim}")).collect(),
1414 Slice::new_row_major(sizes.to_vec()),
1415 )
1416 .unwrap()
1417 }
1418
1419 fn member(rank: usize) -> ActorAddr {
1420 ActorAddr::root(
1421 format!("proc{rank}@inproc://{rank}")
1422 .parse::<ProcAddr>()
1423 .unwrap(),
1424 Label::strip("member"),
1425 )
1426 }
1427
1428 fn validate_domain_tree(
1429 members: &HashMap<usize, ActorAddr>,
1430 tile: &MaterializedTile<ActorAddr>,
1431 seen_roots: &mut BTreeSet<usize>,
1432 ) -> Result<(), TestCaseError> {
1433 let expected_members = tile
1434 .tile()
1435 .space()
1436 .iter()
1437 .map(|rank| members[&rank].clone())
1438 .collect::<Vec<_>>();
1439 prop_assert_eq!(tile.items().cloned().collect::<Vec<_>>(), expected_members);
1440 prop_assert!(seen_roots.insert(tile.root_rank()));
1441
1442 for child in next_tiles(TilingPolicy::BlockPartitioning, tile) {
1443 validate_domain_tree(members, &child, seen_roots)?;
1444 }
1445
1446 Ok(())
1447 }
1448
1449 #[derive(Clone, Debug, PartialEq, Eq)]
1450 pub(crate) struct CastHopSnapshot {
1451 point_in_domain: Point,
1452 base_rank_in_domain: usize,
1453 next_hop_procs: BTreeSet<String>,
1454 direct_hop_procs: BTreeSet<String>,
1455 local_actor_proc: String,
1456 }
1457
1458 static INSTALLED_DOMAINS: OnceLock<Mutex<HashMap<Uid, BTreeMap<String, CastHopSnapshot>>>> =
1459 OnceLock::new();
1460 static DESTROYED_DOMAINS: OnceLock<Mutex<HashMap<Uid, BTreeSet<String>>>> = OnceLock::new();
1461
1462 fn installed_domains() -> &'static Mutex<HashMap<Uid, BTreeMap<String, CastHopSnapshot>>> {
1463 INSTALLED_DOMAINS.get_or_init(|| Mutex::new(HashMap::new()))
1464 }
1465
1466 fn destroyed_domains() -> &'static Mutex<HashMap<Uid, BTreeSet<String>>> {
1467 DESTROYED_DOMAINS.get_or_init(|| Mutex::new(HashMap::new()))
1468 }
1469
1470 pub(crate) fn capture_installed_domain(
1471 cx: &Context<'_, CastActor>,
1472 domain_id: &Uid,
1473 cast_hop: &CastHop,
1474 ) {
1475 let proc_name = cx.self_addr().proc_addr().log_name().to_string();
1476 let local_destination = &cast_hop.local_destination;
1477 let snapshot = CastHopSnapshot {
1478 point_in_domain: local_destination.point_in_domain.clone(),
1479 base_rank_in_domain: local_destination.base_rank_in_domain,
1480 next_hop_procs: cast_hop
1481 .next_hops
1482 .iter()
1483 .map(|next_hop| match &next_hop.route {
1484 CastRoute::ViaCastActor(next_hop) => {
1485 next_hop.actor_addr().proc_addr().log_name().to_string()
1486 }
1487 CastRoute::Direct(destination) => {
1488 destination.actor.proc_addr().log_name().to_string()
1489 }
1490 })
1491 .collect(),
1492 direct_hop_procs: cast_hop
1493 .next_hops
1494 .iter()
1495 .filter_map(|next_hop| match &next_hop.route {
1496 CastRoute::ViaCastActor(_) => None,
1497 CastRoute::Direct(destination) => {
1498 Some(destination.actor.proc_addr().log_name().to_string())
1499 }
1500 })
1501 .collect(),
1502 local_actor_proc: local_destination.actor.proc_addr().log_name().to_string(),
1503 };
1504 installed_domains()
1505 .lock()
1506 .unwrap()
1507 .entry(domain_id.clone())
1508 .or_default()
1509 .insert(proc_name, snapshot);
1510 }
1511
1512 pub(crate) fn capture_destroyed_domain(cx: &Context<'_, CastActor>, domain_id: &Uid) {
1513 let proc_name = cx.self_addr().proc_addr().log_name().to_string();
1514 destroyed_domains()
1515 .lock()
1516 .unwrap()
1517 .entry(domain_id.clone())
1518 .or_default()
1519 .insert(proc_name);
1520 }
1521
1522 fn clear_captured_domains() {
1523 installed_domains().lock().unwrap().clear();
1524 destroyed_domains().lock().unwrap().clear();
1525 }
1526
1527 fn captured_domain_snapshots(domain_id: &Uid) -> BTreeMap<String, CastHopSnapshot> {
1528 installed_domains()
1529 .lock()
1530 .unwrap()
1531 .get(domain_id)
1532 .cloned()
1533 .unwrap_or_default()
1534 }
1535
1536 fn destroyed_domain_snapshots(domain_id: &Uid) -> BTreeSet<String> {
1537 destroyed_domains()
1538 .lock()
1539 .unwrap()
1540 .get(domain_id)
1541 .cloned()
1542 .unwrap_or_default()
1543 }
1544
1545 fn members(count: usize) -> Vec<ActorAddr> {
1546 (0..count)
1547 .map(|i| {
1548 ActorAddr::root(
1549 format!("proc{i}@inproc://{i}").parse::<ProcAddr>().unwrap(),
1550 Label::strip("member"),
1551 )
1552 })
1553 .collect()
1554 }
1555
1556 fn proc_cast_node_mesh(
1571 region: Region,
1572 members: &HashMap<usize, ActorAddr>,
1573 ) -> anyhow::Result<Arc<ValueMesh<CastNode>>> {
1574 let actors = region
1575 .slice()
1576 .iter()
1577 .map(|rank| {
1578 members
1579 .get(&rank)
1580 .cloned()
1581 .ok_or_else(|| anyhow::anyhow!("missing test member for rank {rank}"))
1582 })
1583 .collect::<anyhow::Result<Vec<_>>>()?;
1584 let destinations = CastDestination::mesh(region.clone(), actors)?;
1585 let nodes = destinations
1586 .values()
1587 .map(|destination| {
1588 CastNode::new(
1589 CastActor::ref_for_proc(destination.actor().proc_addr()),
1590 destination.clone(),
1591 )
1592 })
1593 .collect::<Vec<_>>();
1594
1595 Ok(Arc::new(ValueMesh::new(region, nodes)?))
1596 }
1597
1598 #[test]
1599 fn test_cast_destination_mesh_and_subset_preserve_strided_base_ranks() {
1600 let full_region = Region::from(shape!(row = 4, col = 4));
1607
1608 let mesh_region = full_region
1609 .range("row", ndslice::Range(1, Some(4), 2))
1610 .unwrap();
1611
1612 let all_members = members(16);
1613
1614 let mesh_members = vec![
1615 all_members[4].clone(),
1616 all_members[5].clone(),
1617 all_members[6].clone(),
1618 all_members[7].clone(),
1619 all_members[12].clone(),
1620 all_members[13].clone(),
1621 all_members[14].clone(),
1622 all_members[15].clone(),
1623 ];
1624
1625 let destinations = CastDestination::mesh(mesh_region.clone(), mesh_members).unwrap();
1626
1627 let mesh_values = destinations
1628 .values()
1629 .map(|destination| {
1630 (
1631 destination.actor.clone(),
1632 destination.base_rank_in_domain,
1633 destination.point_in_domain.coords(),
1634 )
1635 })
1636 .collect::<Vec<_>>();
1637
1638 assert_eq!(
1639 mesh_values,
1640 vec![
1641 (all_members[4].clone(), 4, vec![0, 0]),
1642 (all_members[5].clone(), 5, vec![0, 1]),
1643 (all_members[6].clone(), 6, vec![0, 2]),
1644 (all_members[7].clone(), 7, vec![0, 3]),
1645 (all_members[12].clone(), 12, vec![1, 0]),
1646 (all_members[13].clone(), 13, vec![1, 1]),
1647 (all_members[14].clone(), 14, vec![1, 2]),
1648 (all_members[15].clone(), 15, vec![1, 3]),
1649 ]
1650 );
1651
1652 let subset_region = mesh_region
1659 .range("col", ndslice::Range(1, Some(4), 2))
1660 .unwrap();
1661
1662 let subset = CastDestination::subset(&destinations, subset_region).unwrap();
1663
1664 let subset_values = subset
1665 .values()
1666 .map(|destination| {
1667 (
1668 destination.actor.clone(),
1669 destination.base_rank_in_domain,
1670 destination.point_in_domain.coords(),
1671 )
1672 })
1673 .collect::<Vec<_>>();
1674
1675 assert_eq!(
1676 subset_values,
1677 vec![
1678 (all_members[5].clone(), 5, vec![0, 0]),
1679 (all_members[7].clone(), 7, vec![0, 1]),
1680 (all_members[13].clone(), 13, vec![1, 0]),
1681 (all_members[15].clone(), 15, vec![1, 1]),
1682 ]
1683 );
1684 }
1685
1686 #[test]
1687 fn test_subset_members_preserves_selected_member_order() {
1688 let parent_view = Region::from(shape!(row = 4, col = 2));
1695
1696 let child_view = parent_view
1698 .range("row", ndslice::Range(1, Some(3), 1))
1699 .unwrap();
1700 let members = members(8);
1701 let parent_members = ValueMesh::new(parent_view.clone(), members.clone()).unwrap();
1702
1703 let child_members = parent_members.subset(child_view.clone()).unwrap();
1704 let child_members = (0..Ranked::region(&child_members).num_ranks())
1705 .map(|rank| Ranked::get(&child_members, rank).unwrap().clone())
1706 .collect::<Vec<_>>();
1707
1708 assert_eq!(child_members, &members[2..6]);
1712 }
1713
1714 #[derive(Debug, Clone, Serialize, Deserialize, typeuri::Named)]
1718 struct TestDelivery {
1719 payload: String,
1720 }
1721 wirevalue::register_type!(TestDelivery);
1722
1723 #[derive(Debug, Serialize, Deserialize, typeuri::Named)]
1724 enum CastStoppedDomain {
1725 Cast,
1726 CastWithoutReturn,
1727 DestroyAndCast,
1728 }
1729 wirevalue::register_type!(CastStoppedDomain);
1730
1731 #[hyperactor::export(handlers = [CastStoppedDomain])]
1732 struct CastFailureProbe {
1733 members: HashMap<usize, ActorAddr>,
1734 region: Region,
1735 failures: PortRef<Undeliverable<MessageEnvelope>>,
1736 domain: Option<CastDomainRef>,
1737 }
1738
1739 #[async_trait]
1740 impl Actor for CastFailureProbe {
1741 async fn handle_delivery_failure_event(
1742 &mut self,
1743 cx: &Instance<Self>,
1744 undeliverable: Undeliverable<MessageEnvelope>,
1745 ) -> Result<(), anyhow::Error> {
1746 self.failures.post(cx, undeliverable);
1747 Ok(())
1748 }
1749 }
1750
1751 #[async_trait]
1752 impl Handler<CastStoppedDomain> for CastFailureProbe {
1753 async fn handle(
1754 &mut self,
1755 cx: &Context<Self>,
1756 message: CastStoppedDomain,
1757 ) -> Result<(), anyhow::Error> {
1758 if self.domain.is_none() {
1759 self.domain = Some(CastDomainId::new().materialize(
1760 cx,
1761 proc_cast_node_mesh(self.region.clone(), &self.members)?,
1762 TilingPolicy::BlockPartitioning,
1763 Flattrs::new(),
1764 )?);
1765 }
1766 let domain = self
1767 .domain
1768 .as_ref()
1769 .expect("cast domain was initialized above");
1770 let (payload, return_undeliverable) = match message {
1771 CastStoppedDomain::Cast => ("before-destroy", true),
1772 CastStoppedDomain::CastWithoutReturn => ("without-return", false),
1773 CastStoppedDomain::DestroyAndCast => {
1774 domain.destroy(cx);
1775 domain.destroy(cx);
1776 ("after-destroy", true)
1777 }
1778 };
1779 domain.cast_with_return_undeliverable(
1780 cx,
1781 Flattrs::new(),
1782 TestDelivery {
1783 payload: payload.to_string(),
1784 },
1785 return_undeliverable,
1786 )
1787 }
1788 }
1789
1790 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, typeuri::Named)]
1792 struct TestDeliveryRecord {
1793 payload: String,
1794 lineage: Vec<ActorAddr>,
1795 operation_endpoint: Option<String>,
1796 }
1797 wirevalue::register_type!(TestDeliveryRecord);
1798
1799 #[derive(
1800 Debug,
1801 Clone,
1802 Default,
1803 PartialEq,
1804 Eq,
1805 Serialize,
1806 Deserialize,
1807 typeuri::Named
1808 )]
1809 struct TestDeliveryHistories {
1810 by_proc: BTreeMap<String, Vec<TestDeliveryRecord>>,
1811 }
1812 wirevalue::register_type!(TestDeliveryHistories);
1813
1814 impl TestDeliveryHistories {
1815 fn single(proc_name: String, deliveries: Vec<TestDeliveryRecord>) -> Self {
1816 Self {
1817 by_proc: [(proc_name, deliveries)].into_iter().collect(),
1818 }
1819 }
1820
1821 fn merge(&mut self, other: Self) -> anyhow::Result<()> {
1822 for (proc_name, history) in other.by_proc {
1823 anyhow::ensure!(
1824 self.by_proc.insert(proc_name.clone(), history).is_none(),
1825 "duplicate history reply from {proc_name}",
1826 );
1827 }
1828 Ok(())
1829 }
1830 }
1831
1832 #[derive(Debug, Serialize, Deserialize, typeuri::Named)]
1833 struct GetHistory {
1834 reply_to: hyperactor::OncePortRef<TestDeliveryHistories>,
1835 }
1836 wirevalue::register_type!(GetHistory);
1837
1838 #[derive(Debug, Default)]
1840 #[hyperactor::export(
1841 handlers = [
1842 TestDelivery,
1843 GetHistory,
1844 ],
1845 )]
1846 struct TestReceiver {
1847 deliveries: Vec<TestDeliveryRecord>,
1848 }
1849
1850 #[async_trait]
1851 impl Actor for TestReceiver {
1852 async fn init(&mut self, _this: &Instance<Self>) -> Result<(), anyhow::Error> {
1853 Ok(())
1854 }
1855 }
1856
1857 #[async_trait]
1858 impl Handler<TestDelivery> for TestReceiver {
1859 async fn handle(
1860 &mut self,
1861 cx: &Context<Self>,
1862 msg: TestDelivery,
1863 ) -> Result<(), anyhow::Error> {
1864 let _seq_info = cx
1865 .headers()
1866 .get(SEQ_INFO)
1867 .expect("cast delivery should stamp SEQ_INFO");
1868 let lineage = cx.headers().get(CAST_LINEAGE).unwrap_or_default();
1869 let operation_endpoint = cx
1870 .headers()
1871 .get(hyperactor::mailbox::headers::OPERATION_ENDPOINT);
1872 self.deliveries.push(TestDeliveryRecord {
1873 payload: msg.payload,
1874 lineage,
1875 operation_endpoint,
1876 });
1877 Ok(())
1878 }
1879 }
1880
1881 #[async_trait]
1882 impl Handler<GetHistory> for TestReceiver {
1883 async fn handle(
1884 &mut self,
1885 cx: &Context<Self>,
1886 msg: GetHistory,
1887 ) -> Result<(), anyhow::Error> {
1888 msg.reply_to.post(
1889 cx,
1890 TestDeliveryHistories::single(
1891 cx.self_addr().proc_addr().log_name().to_string(),
1892 self.deliveries.clone(),
1893 ),
1894 );
1895 Ok(())
1896 }
1897 }
1898
1899 #[derive(typeuri::Named)]
1900 struct TestDeliveryHistoriesReducer;
1901
1902 impl hyperactor::accum::CommReducer for TestDeliveryHistoriesReducer {
1903 type Update = TestDeliveryHistories;
1904
1905 fn reduce(
1906 &self,
1907 mut left: Self::Update,
1908 right: Self::Update,
1909 ) -> anyhow::Result<Self::Update> {
1910 left.merge(right)?;
1911 Ok(left)
1912 }
1913 }
1914
1915 inventory::submit! {
1916 hyperactor::accum::ReducerFactory {
1917 typehash_f: <TestDeliveryHistoriesReducer as Named>::typehash,
1918 builder_f: |_| Ok(Box::new(TestDeliveryHistoriesReducer)),
1919 }
1920 }
1921
1922 struct TestDeliveryHistoriesAccumulator;
1923
1924 impl hyperactor::accum::Accumulator for TestDeliveryHistoriesAccumulator {
1925 type State = TestDeliveryHistories;
1926 type Update = TestDeliveryHistories;
1927
1928 fn accumulate(&self, state: &mut Self::State, update: Self::Update) -> anyhow::Result<()> {
1929 state.merge(update)
1930 }
1931
1932 fn reducer_spec(&self) -> Option<hyperactor::accum::ReducerSpec> {
1933 Some(hyperactor::accum::ReducerSpec {
1934 typehash: <TestDeliveryHistoriesReducer as Named>::typehash(),
1935 builder_params: None,
1936 })
1937 }
1938 }
1939
1940 struct CastTestMesh {
1941 _client_proc: Arc<Proc>,
1942 client: Client,
1943 _procs: Arc<Vec<Proc>>,
1944 member_ids: HashMap<usize, ActorAddr>,
1945 receiver_ids: Vec<ActorAddr>,
1946 }
1947
1948 impl CastTestMesh {
1949 fn new(n: usize) -> Self {
1950 let client_proc =
1951 Proc::direct(ChannelTransport::Unix.any(), "client_proc".into()).unwrap();
1952 let client = client_proc.client("client");
1953
1954 let procs: Vec<Proc> = (0..n)
1955 .map(|i| {
1956 let proc =
1957 Proc::direct(ChannelTransport::Unix.any(), format!("proc_{i}")).unwrap();
1958 let cast_handle = proc
1959 .spawn_with_uid(
1960 Uid::singleton(Label::strip(CAST_ACTOR_NAME)),
1961 CastActor::default(),
1962 )
1963 .unwrap();
1964 let _: ActorRef<CastActor> = cast_handle.bind::<CastActor>();
1965 proc
1966 })
1967 .collect();
1968 let member_ids = procs
1969 .iter()
1970 .enumerate()
1971 .map(|(rank, proc)| {
1972 (
1973 rank,
1974 ActorAddr::root(proc.proc_addr().clone(), Label::strip("member")),
1975 )
1976 })
1977 .collect();
1978
1979 Self {
1980 _client_proc: Arc::new(client_proc),
1981 client,
1982 _procs: Arc::new(procs),
1983 member_ids,
1984 receiver_ids: Vec::new(),
1985 }
1986 }
1987
1988 fn spawn_delivery_receivers(&mut self) {
1989 self.receiver_ids = self
1990 ._procs
1991 .iter()
1992 .map(|proc| {
1993 let recv_handle = proc
1994 .spawn_with_uid(
1995 Uid::singleton(Label::strip("receiver")),
1996 TestReceiver::default(),
1997 )
1998 .unwrap();
1999 let _: ActorRef<TestReceiver> = recv_handle.bind::<TestReceiver>();
2000 ActorAddr::root(proc.proc_addr().clone(), Label::strip("receiver"))
2001 })
2002 .collect();
2003 }
2004
2005 fn spawn_split_port_receivers(&mut self) {
2006 self.receiver_ids = self
2007 ._procs
2008 .iter()
2009 .map(|proc| {
2010 let recv_handle = proc
2011 .spawn_with_uid(Uid::singleton(Label::strip("receiver")), SplitPortReceiver)
2012 .unwrap();
2013 let _: ActorRef<SplitPortReceiver> = recv_handle.bind::<SplitPortReceiver>();
2014 ActorAddr::root(proc.proc_addr().clone(), Label::strip("receiver"))
2015 })
2016 .collect();
2017 }
2018
2019 fn domain_members(&self) -> HashMap<usize, ActorAddr> {
2020 if self.receiver_ids.is_empty() {
2021 self.member_ids.clone()
2022 } else {
2023 self.member_ids
2024 .keys()
2025 .map(|rank| (*rank, self.receiver_ids[*rank].clone()))
2026 .collect::<HashMap<_, _>>()
2027 }
2028 }
2029
2030 fn sliced(&self, region: Region) -> Self {
2036 let all_members = self.domain_members();
2037 let member_ids = region
2038 .slice()
2039 .iter()
2040 .map(|rank| {
2041 (
2042 rank,
2043 all_members
2044 .get(&rank)
2045 .expect("test mesh must contain every selected rank")
2046 .clone(),
2047 )
2048 })
2049 .collect();
2050
2051 Self {
2052 _client_proc: Arc::clone(&self._client_proc),
2053 client: self.client.clone(),
2054 _procs: Arc::clone(&self._procs),
2055 member_ids,
2056 receiver_ids: self.receiver_ids.clone(),
2057 }
2058 }
2059
2060 fn root_domain(&self, region: Region) -> CastDomainRef {
2061 let members = self.domain_members();
2062
2063 CastDomainId::new()
2064 .materialize(
2065 &self.client,
2066 proc_cast_node_mesh(region, &members).unwrap(),
2067 TilingPolicy::BlockPartitioning,
2068 Flattrs::new(),
2069 )
2070 .unwrap()
2071 }
2072
2073 fn proc_names(&self) -> Vec<String> {
2074 let mut ranks = self.domain_members().into_keys().collect::<Vec<_>>();
2075 ranks.sort_unstable();
2076
2077 ranks
2078 .into_iter()
2079 .map(|rank| format!("proc_{rank}"))
2080 .collect()
2081 }
2082
2083 fn root_domain_with_policy(&self, region: Region, policy: TilingPolicy) -> CastDomainRef {
2084 CastDomainId::new()
2085 .materialize(
2086 &self.client,
2087 proc_cast_node_mesh(region, &self.member_ids).unwrap(),
2088 policy,
2089 Flattrs::new(),
2090 )
2091 .unwrap()
2092 }
2093
2094 async fn wait_for_domain_snapshots(
2095 &self,
2096 domain_id: &Uid,
2097 expected_count: usize,
2098 ) -> BTreeMap<String, CastHopSnapshot> {
2099 tokio::time::timeout(Duration::from_secs(5), async {
2100 loop {
2101 let snapshots = captured_domain_snapshots(domain_id);
2102 if snapshots.len() == expected_count {
2103 return snapshots;
2104 }
2105 tokio::time::sleep(Duration::from_millis(10)).await;
2106 }
2107 })
2108 .await
2109 .unwrap_or_else(|_| {
2110 panic!(
2111 "timed out waiting for {expected_count} installed domain snapshots; saw {:?}",
2112 captured_domain_snapshots(domain_id).keys()
2113 )
2114 })
2115 }
2116
2117 async fn wait_for_any_domain_snapshot(&self, domain_id: &Uid) {
2118 tokio::time::timeout(Duration::from_secs(5), async {
2119 while captured_domain_snapshots(domain_id).is_empty() {
2120 tokio::time::sleep(Duration::from_millis(10)).await;
2121 }
2122 })
2123 .await
2124 .unwrap_or_else(|_| {
2125 panic!("timed out waiting for an installed domain snapshot for {domain_id}")
2126 });
2127 }
2128
2129 async fn wait_for_destroyed_domain_snapshots(
2130 &self,
2131 domain_id: &Uid,
2132 expected_count: usize,
2133 ) -> BTreeSet<String> {
2134 tokio::time::timeout(Duration::from_secs(5), async {
2135 loop {
2136 let snapshots = destroyed_domain_snapshots(domain_id);
2137 if snapshots.len() == expected_count {
2138 return snapshots;
2139 }
2140 tokio::time::sleep(Duration::from_millis(10)).await;
2141 }
2142 })
2143 .await
2144 .unwrap_or_else(|_| {
2145 panic!(
2146 "timed out waiting for {expected_count} destroyed domain snapshots; saw {:?}",
2147 destroyed_domain_snapshots(domain_id)
2148 )
2149 })
2150 }
2151 }
2152
2153 proptest! {
2154 #[test]
2155 fn prop_domain_tree_materialization_covers_each_rank_once(sizes in small_shape_sizes()) {
2156 let shape = shape_from_sizes(&sizes);
2157 let region = Region::from(shape);
2158 let members = region
2159 .slice()
2160 .iter()
2161 .map(|rank| (rank, member(rank)))
2162 .collect::<HashMap<_, _>>();
2163 let root_tile = MaterializedTile::from_value_mesh_with_tile(
2164 Tile::from_view(®ion),
2165 Arc::new(ValueMesh::build_indexed(region.clone(), members.clone()).unwrap()),
2166 );
2167
2168 let mut seen_roots = BTreeSet::new();
2169 validate_domain_tree(&members, &root_tile, &mut seen_roots)?;
2170
2171 let expected_roots = region.slice().iter().collect::<BTreeSet<_>>();
2172 prop_assert_eq!(seen_roots, expected_roots);
2173 }
2174
2175 #[test]
2176 fn prop_domain_destinations_preserve_member_mapping(sizes in small_shape_sizes()) {
2177 let shape = shape_from_sizes(&sizes);
2178 let region = Region::from(shape);
2179 let members = region
2180 .slice()
2181 .iter()
2182 .map(|rank| (rank, member(rank)))
2183 .collect::<HashMap<_, _>>();
2184 let root = MaterializedTile::from_value_mesh_with_tile(
2185 Tile::from_view(®ion),
2186 Arc::new(ValueMesh::build_indexed(region.clone(), members.clone()).unwrap()),
2187 );
2188 let mut seen_roots = BTreeSet::new();
2189
2190 validate_domain_tree(&members, &root, &mut seen_roots)?;
2191
2192 prop_assert_eq!(
2193 seen_roots.into_iter().collect::<Vec<_>>(),
2194 region.slice().iter().collect::<Vec<_>>(),
2195 );
2196 }
2197 }
2198
2199 fn materialization_regions() -> impl Strategy<Value = Region> {
2200 prop_oneof![
2201 small_shape_sizes().prop_map(|sizes| Region::from(shape_from_sizes(sizes.as_slice()))),
2202 gen_region_strided(1..=4, 4, 3, 0),
2203 ]
2204 }
2205
2206 fn tiling_policies() -> impl Strategy<Value = TilingPolicy> {
2207 prop_oneof![
2208 Just(TilingPolicy::BlockPartitioning),
2209 (1usize..=4).prop_map(|fanout| TilingPolicy::BoundedFanout {
2210 fanout: NonZeroUsize::new(fanout).expect("generated fanout is nonzero"),
2211 }),
2212 Just(TilingPolicy::Bisection),
2213 ]
2214 }
2215
2216 fn materialization_runtime() -> &'static Runtime {
2217 static RUNTIME: OnceLock<Runtime> = OnceLock::new();
2218
2219 RUNTIME.get_or_init(|| {
2220 Runtime::new().expect("materialization property test should create its runtime")
2221 })
2222 }
2223
2224 fn validate_materialized_subtrees(
2225 region: Region,
2226 policy: TilingPolicy,
2227 ) -> Result<(), TestCaseError> {
2228 let proc = Proc::isolated();
2229 let sender = proc.actor_instance::<()>("materialization_sender").unwrap();
2230 sender.instance.bind::<()>();
2231
2232 let members = region
2234 .slice()
2235 .iter()
2236 .map(|rank| (rank, member(rank)))
2237 .collect::<HashMap<_, _>>();
2238
2239 let domain = CastDomainId::new()
2241 .materialize(
2242 &sender.instance,
2243 proc_cast_node_mesh(region.clone(), &members)
2244 .map_err(|error| TestCaseError::fail(error.to_string()))?,
2245 policy,
2246 Flattrs::new(),
2247 )
2248 .map_err(|error| TestCaseError::fail(error.to_string()))?;
2249
2250 let expected_ranks = region.slice().iter().collect::<BTreeSet<_>>();
2253 let mut covered_ranks = BTreeSet::new();
2254 let root_destination = &domain.sender_hop.local_destination;
2255 prop_assert_eq!(
2256 &root_destination.actor,
2257 &members[&root_destination.base_rank_in_domain]
2258 );
2259 prop_assert!(covered_ranks.insert(root_destination.base_rank_in_domain));
2260
2261 for subtree in &domain.sender_hop.next_hops {
2262 let subtree_ranks = subtree
2263 .served_region
2264 .slice()
2265 .iter()
2266 .collect::<BTreeSet<_>>();
2267 prop_assert!(!subtree_ranks.is_empty());
2268
2269 for rank in subtree_ranks {
2270 prop_assert!(expected_ranks.contains(&rank));
2271 prop_assert!(covered_ranks.insert(rank));
2272 }
2273
2274 let root_rank = Tile::from_view(&subtree.served_region).root_rank();
2275 match &subtree.route {
2276 CastRoute::ViaCastActor(root_actor) => {
2277 prop_assert_eq!(
2278 root_actor.actor_addr().clone(),
2279 cast_actor_ref_for_member(&members[&root_rank])
2280 .actor_addr()
2281 .clone(),
2282 );
2283 }
2284 CastRoute::Direct(destination) => {
2285 prop_assert_eq!(subtree.served_region.num_ranks(), 1);
2286 prop_assert_eq!(destination.base_rank_in_domain, root_rank);
2287 prop_assert_eq!(
2288 destination.point_in_domain.clone(),
2289 region.point_of_base_rank(root_rank).unwrap(),
2290 );
2291 prop_assert_eq!(destination.actor.clone(), members[&root_rank].clone());
2292 }
2293 }
2294 }
2295
2296 prop_assert_eq!(covered_ranks, expected_ranks);
2297 Ok(())
2298 }
2299
2300 proptest! {
2301 #![proptest_config(ProptestConfig {
2302 cases: 64,
2303 ..ProptestConfig::default()
2304 })]
2305
2306 #[test]
2308 fn prop_materialized_subtrees_partition_the_domain(
2309 region in materialization_regions(),
2310 policy in tiling_policies(),
2311 ) {
2312 let _runtime = materialization_runtime().enter();
2313 validate_materialized_subtrees(region, policy)?;
2314 }
2315 }
2316
2317 async fn cast_and_collect_histories(
2318 test_mesh: &CastTestMesh,
2319 cast_domain: &CastDomainRef,
2320 ) -> BTreeMap<String, Vec<TestDeliveryRecord>> {
2321 let (reply_handle, reply_rx) = context::Mailbox::mailbox(&test_mesh.client)
2322 .open_reduce_port(TestDeliveryHistoriesAccumulator);
2323 let reply_ref = reply_handle.bind();
2324
2325 cast_domain
2326 .cast(
2327 &test_mesh.client,
2328 Flattrs::new(),
2329 GetHistory {
2330 reply_to: reply_ref,
2331 },
2332 )
2333 .unwrap();
2334
2335 match tokio::time::timeout(Duration::from_secs(5), reply_rx.recv()).await {
2336 Ok(Ok(histories)) => histories.by_proc,
2337 Ok(Err(e)) => panic!("history recv error: {e}"),
2338 Err(_) => panic!("timed out waiting for reduced histories"),
2339 }
2340 }
2341
2342 #[async_timed_test(timeout_secs = 30)]
2343 async fn test_create_cast_domain_installs_expected_hops() {
2344 clear_captured_domains();
2345
2346 let test_mesh = CastTestMesh::new(8);
2347 let root_domain = test_mesh.root_domain(shape!(a = 2, b = 2, c = 2).into());
2348 let snapshots = test_mesh
2349 .wait_for_domain_snapshots(root_domain.domain_id(), 3)
2350 .await;
2351
2352 let region = Region::from(shape!(a = 2, b = 2, c = 2));
2353 let expected_next_hops: BTreeMap<String, BTreeSet<String>> = [
2354 ("proc_2", vec!["proc_3"]),
2355 ("proc_4", vec!["proc_5", "proc_6"]),
2356 ("proc_6", vec!["proc_7"]),
2357 ]
2358 .into_iter()
2359 .map(|(proc_name, next_hops)| {
2360 (
2361 proc_name.to_string(),
2362 next_hops
2363 .into_iter()
2364 .map(str::to_string)
2365 .collect::<BTreeSet<_>>(),
2366 )
2367 })
2368 .collect();
2369
2370 assert_eq!(
2371 snapshots.keys().cloned().collect::<BTreeSet<_>>(),
2372 [2, 4, 6]
2373 .into_iter()
2374 .map(|rank| format!("proc_{rank}"))
2375 .collect()
2376 );
2377
2378 let expected_direct_hops: BTreeMap<String, BTreeSet<String>> = [
2379 ("proc_2", vec!["proc_3"]),
2380 ("proc_4", vec!["proc_5"]),
2381 ("proc_6", vec!["proc_7"]),
2382 ]
2383 .into_iter()
2384 .map(|(proc_name, next_hops)| {
2385 (
2386 proc_name.to_string(),
2387 next_hops.into_iter().map(str::to_string).collect(),
2388 )
2389 })
2390 .collect();
2391
2392 for rank in [2, 4, 6] {
2393 let proc_name = format!("proc_{rank}");
2394 let snapshot = snapshots
2395 .get(&proc_name)
2396 .unwrap_or_else(|| panic!("missing snapshot for {proc_name}"));
2397
2398 assert_eq!(snapshot.base_rank_in_domain, rank);
2399 assert_eq!(
2400 snapshot.point_in_domain,
2401 region.point_of_base_rank(rank).unwrap()
2402 );
2403 assert_eq!(snapshot.local_actor_proc, proc_name);
2404 assert_eq!(snapshot.next_hop_procs, expected_next_hops[&proc_name]);
2405 assert_eq!(snapshot.direct_hop_procs, expected_direct_hops[&proc_name]);
2406 }
2407 }
2408
2409 fn expected_reply_counts(proc_names: &[&str]) -> BTreeMap<String, u64> {
2410 proc_names
2411 .iter()
2412 .map(|proc_name| (proc_name.to_string(), 1))
2413 .collect()
2414 }
2415
2416 async fn cast_and_collect_reply_counts(
2417 test_mesh: &CastTestMesh,
2418 cast_domain: &CastDomainRef,
2419 payload: &str,
2420 ) -> BTreeMap<String, u64> {
2421 let (reply_handle, reply_rx) = context::Mailbox::mailbox(&test_mesh.client)
2422 .open_reduce_port(TestReplyCountsAccumulator);
2423 let reply_ref = reply_handle.bind();
2424
2425 cast_domain
2426 .cast(
2427 &test_mesh.client,
2428 Flattrs::new(),
2429 TestRequestWithReply {
2430 payload: payload.to_string(),
2431 reply_to: reply_ref,
2432 },
2433 )
2434 .unwrap();
2435
2436 match tokio::time::timeout(Duration::from_secs(5), reply_rx.recv()).await {
2437 Ok(Ok(reply_counts)) => reply_counts.counts_by_proc,
2438 Ok(Err(e)) => panic!("reply recv error: {e}"),
2439 Err(_) => panic!("timed out waiting for reduced replies"),
2440 }
2441 }
2442
2443 #[async_timed_test(timeout_secs = 30)]
2446 async fn test_cast_message_delivery_8_procs() {
2447 let config = hyperactor_config::global::lock();
2448 let _guard = config.override_key(
2449 hyperactor::config::ENABLE_DEST_ACTOR_REORDERING_BUFFER,
2450 true,
2451 );
2452
2453 let n = 8;
2454 let mut test_mesh = CastTestMesh::new(n);
2455 test_mesh.spawn_delivery_receivers();
2456 let root_domain = test_mesh.root_domain(shape!(a = 2, b = 2, c = 2).into());
2457
2458 let expected_payloads = vec![
2459 "hello-0".to_string(),
2460 "hello-1".to_string(),
2461 "hello-2".to_string(),
2462 ];
2463 for payload in &expected_payloads {
2464 root_domain
2465 .cast(
2466 &test_mesh.client,
2467 Flattrs::new(),
2468 TestDelivery {
2469 payload: payload.clone(),
2470 },
2471 )
2472 .unwrap();
2473 }
2474
2475 let expected_histories: BTreeMap<String, Vec<String>> = test_mesh
2476 .proc_names()
2477 .into_iter()
2478 .map(|proc_name| (proc_name, expected_payloads.clone()))
2479 .collect();
2480 let histories = cast_and_collect_histories(&test_mesh, &root_domain).await;
2481
2482 let observed_payloads: BTreeMap<String, Vec<String>> = histories
2483 .iter()
2484 .map(|(proc_name, history)| {
2485 (
2486 proc_name.clone(),
2487 history
2488 .iter()
2489 .map(|delivery| delivery.payload.clone())
2490 .collect(),
2491 )
2492 })
2493 .collect();
2494 assert_eq!(observed_payloads, expected_histories);
2495
2496 let mut lineage_by_proc = BTreeMap::new();
2497 for (proc_name, history) in histories {
2498 assert_eq!(
2499 history.len(),
2500 expected_payloads.len(),
2501 "proc {proc_name} received the wrong number of deliveries"
2502 );
2503 let first_lineage = history[0].lineage.clone();
2504 for delivery in &history {
2505 assert_eq!(
2506 delivery.lineage, first_lineage,
2507 "proc {proc_name} changed lineage across root casts"
2508 );
2509 }
2510 lineage_by_proc.insert(proc_name, first_lineage);
2511 }
2512
2513 let expected_lineage: BTreeMap<String, Vec<ActorAddr>> = [
2514 (
2515 "proc_0".to_string(),
2516 vec![test_mesh.receiver_ids[0].clone()],
2517 ),
2518 (
2519 "proc_1".to_string(),
2520 vec![test_mesh.receiver_ids[1].clone()],
2521 ),
2522 (
2523 "proc_2".to_string(),
2524 vec![
2525 cast_actor_ref_for_member(&test_mesh.receiver_ids[2])
2526 .actor_addr()
2527 .clone(),
2528 test_mesh.receiver_ids[2].clone(),
2529 ],
2530 ),
2531 (
2532 "proc_3".to_string(),
2533 vec![
2534 cast_actor_ref_for_member(&test_mesh.receiver_ids[2])
2535 .actor_addr()
2536 .clone(),
2537 test_mesh.receiver_ids[3].clone(),
2538 ],
2539 ),
2540 (
2541 "proc_4".to_string(),
2542 vec![
2543 cast_actor_ref_for_member(&test_mesh.receiver_ids[4])
2544 .actor_addr()
2545 .clone(),
2546 test_mesh.receiver_ids[4].clone(),
2547 ],
2548 ),
2549 (
2550 "proc_5".to_string(),
2551 vec![
2552 cast_actor_ref_for_member(&test_mesh.receiver_ids[4])
2553 .actor_addr()
2554 .clone(),
2555 test_mesh.receiver_ids[5].clone(),
2556 ],
2557 ),
2558 (
2559 "proc_6".to_string(),
2560 vec![
2561 cast_actor_ref_for_member(&test_mesh.receiver_ids[4])
2562 .actor_addr()
2563 .clone(),
2564 cast_actor_ref_for_member(&test_mesh.receiver_ids[6])
2565 .actor_addr()
2566 .clone(),
2567 test_mesh.receiver_ids[6].clone(),
2568 ],
2569 ),
2570 (
2571 "proc_7".to_string(),
2572 vec![
2573 cast_actor_ref_for_member(&test_mesh.receiver_ids[4])
2574 .actor_addr()
2575 .clone(),
2576 cast_actor_ref_for_member(&test_mesh.receiver_ids[6])
2577 .actor_addr()
2578 .clone(),
2579 test_mesh.receiver_ids[7].clone(),
2580 ],
2581 ),
2582 ]
2583 .into_iter()
2584 .collect();
2585 assert_eq!(lineage_by_proc, expected_lineage);
2586 }
2587
2588 #[async_timed_test(timeout_secs = 30)]
2589 async fn test_root_absorption_block_partitioning_delivers_once() {
2590 let mut test_mesh = CastTestMesh::new(16);
2592 test_mesh.spawn_split_port_receivers();
2593
2594 let root_domain = test_mesh.root_domain(shape!(hosts = 4, procs = 4).into());
2596
2597 assert_eq!(
2600 root_domain.sender_hop.local_destination.base_rank_in_domain,
2601 0
2602 );
2603 let mut subtree_root_ranks = root_domain
2604 .sender_hop
2605 .next_hops
2606 .iter()
2607 .map(|subtree| subtree.served_region.slice().offset())
2608 .collect::<Vec<_>>();
2609 subtree_root_ranks.sort_unstable();
2610 assert_eq!(subtree_root_ranks, vec![1, 2, 3, 4, 8, 12]);
2611
2612 let proc_names = test_mesh.proc_names();
2614 assert_eq!(
2615 cast_and_collect_reply_counts(&test_mesh, &root_domain, "root-heaved").await,
2616 expected_reply_counts(&proc_names.iter().map(String::as_str).collect::<Vec<_>>())
2617 );
2618 }
2619
2620 #[async_timed_test(timeout_secs = 30)]
2622 async fn test_cast_preserves_supplied_operation_context_headers() {
2623 let n = 2;
2624 let mut test_mesh = CastTestMesh::new(n);
2625 test_mesh.spawn_delivery_receivers();
2626 let root_domain = test_mesh.root_domain(shape!(rank = 2).into());
2627
2628 let mut headers = Flattrs::new();
2629 headers.set(
2630 hyperactor::mailbox::headers::OPERATION_ENDPOINT,
2631 "endpoint.call()".to_string(),
2632 );
2633 root_domain
2634 .cast(
2635 &test_mesh.client,
2636 headers,
2637 TestDelivery {
2638 payload: "with-operation-context".to_string(),
2639 },
2640 )
2641 .unwrap();
2642
2643 let histories = cast_and_collect_histories(&test_mesh, &root_domain).await;
2644 for history in histories.values() {
2645 assert_eq!(
2646 history[0].operation_endpoint.as_deref(),
2647 Some("endpoint.call()")
2648 );
2649 }
2650 }
2651
2652 #[async_timed_test(timeout_secs = 30)]
2655 async fn test_stopped_actors_return_casts_before_and_after_domain_destroy() {
2656 clear_captured_domains();
2657
2658 let mut test_mesh = CastTestMesh::new(8);
2661 test_mesh.spawn_delivery_receivers();
2662 for (proc, receiver) in test_mesh._procs.iter().zip(&test_mesh.receiver_ids) {
2663 let mut status = proc
2664 .stop_actor(receiver.id(), "cast domain test shutdown".to_string())
2665 .expect("delivery receiver should be running");
2666 status
2667 .wait_for(|status| status.is_terminal())
2668 .await
2669 .expect("delivery receiver status should remain observable");
2670 }
2671 let region = Region::from(shape!(a = 2, b = 2, c = 2));
2672 let (failure_handle, mut failure_receiver) =
2673 context::Mailbox::mailbox(&test_mesh.client).open_port();
2674 let probe_handle = test_mesh
2675 ._client_proc
2676 .spawn_with_uid(
2677 Uid::singleton(Label::strip("cast_failure_probe")),
2678 CastFailureProbe {
2679 members: test_mesh.domain_members(),
2680 region: region.clone(),
2681 failures: failure_handle.bind(),
2682 domain: None,
2683 },
2684 )
2685 .unwrap();
2686 let probe_ref: ActorRef<CastFailureProbe> = probe_handle.bind::<CastFailureProbe>();
2687
2688 probe_ref
2690 .port::<CastStoppedDomain>()
2691 .post(&test_mesh.client, CastStoppedDomain::Cast);
2692
2693 let returned = tokio::time::timeout(Duration::from_secs(5), failure_receiver.recv())
2695 .await
2696 .expect("stopped actor should return the cast to the sender")
2697 .expect("cast failure receiver should remain open");
2698 let Undeliverable::Returned(envelope) = returned else {
2699 panic!("stopped actor should return the original message envelope");
2700 };
2701 assert_eq!(
2702 envelope.headers().get(CAST_ORIGINATING_SENDER),
2703 Some(probe_ref.actor_addr().clone()),
2704 );
2705
2706 probe_ref
2708 .port::<CastStoppedDomain>()
2709 .post(&test_mesh.client, CastStoppedDomain::DestroyAndCast);
2710
2711 let envelope = tokio::time::timeout(Duration::from_secs(5), async {
2715 loop {
2716 let returned = failure_receiver
2717 .recv()
2718 .await
2719 .expect("cast failure receiver should remain open");
2720 if let Undeliverable::Returned(envelope) = returned
2721 && envelope
2722 .root_delivery_failure()
2723 .and_then(|failure| failure.attrs.get(CAST_FAILURE_PHASE))
2724 == Some("unknown_domain".to_string())
2725 {
2726 break envelope;
2727 }
2728 }
2729 })
2730 .await
2731 .expect("destroyed CastActor route should return to the sender");
2732 assert_eq!(
2733 envelope
2734 .root_delivery_failure()
2735 .and_then(|failure| failure.attrs.get(CAST_FAILURE_PHASE)),
2736 Some("unknown_domain".to_string()),
2737 );
2738 assert_eq!(
2739 envelope.headers().get(CAST_ORIGINATING_SENDER),
2740 Some(probe_ref.actor_addr().clone()),
2741 );
2742
2743 let live_domain = test_mesh.root_domain(region);
2746 test_mesh
2747 .wait_for_any_domain_snapshot(live_domain.domain_id())
2748 .await;
2749 }
2750
2751 #[async_timed_test(timeout_secs = 30)]
2752 async fn test_stopped_actors_drop_casts_when_returns_are_disabled() {
2753 let mut test_mesh = CastTestMesh::new(8);
2754 test_mesh.spawn_delivery_receivers();
2755 for (proc, receiver) in test_mesh._procs.iter().zip(&test_mesh.receiver_ids) {
2756 let mut status = proc
2757 .stop_actor(receiver.id(), "cast domain test shutdown".to_string())
2758 .expect("delivery receiver should be running");
2759 status
2760 .wait_for(|status| status.is_terminal())
2761 .await
2762 .expect("delivery receiver status should remain observable");
2763 }
2764
2765 let region = Region::from(shape!(a = 2, b = 2, c = 2));
2766 let (failure_handle, mut failure_receiver) =
2767 context::Mailbox::mailbox(&test_mesh.client).open_port();
2768 let probe_handle = test_mesh
2769 ._client_proc
2770 .spawn_with_uid(
2771 Uid::singleton(Label::strip("cast_failure_probe")),
2772 CastFailureProbe {
2773 members: test_mesh.domain_members(),
2774 region,
2775 failures: failure_handle.bind(),
2776 domain: None,
2777 },
2778 )
2779 .unwrap();
2780 let probe_ref: ActorRef<CastFailureProbe> = probe_handle.bind::<CastFailureProbe>();
2781
2782 probe_ref
2783 .port::<CastStoppedDomain>()
2784 .post(&test_mesh.client, CastStoppedDomain::CastWithoutReturn);
2785
2786 assert!(
2787 tokio::time::timeout(Duration::from_millis(500), failure_receiver.recv())
2788 .await
2789 .is_err(),
2790 "stopped actors should not return casts when returns are disabled"
2791 );
2792 }
2793
2794 #[async_timed_test(timeout_secs = 30)]
2796 async fn test_cast_routing_error_returns_undeliverable() {
2797 let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client_proc".into()).unwrap();
2799 let client = client_proc.client("client");
2800 let (failure_handle, mut failure_receiver) = context::Mailbox::mailbox(&client).open_port();
2801 let probe_handle = client_proc
2802 .spawn_with_uid(
2803 Uid::singleton(Label::strip("cast_failure_probe")),
2804 CastFailureProbe {
2805 members: HashMap::new(),
2806 region: Region::from(Shape::unity()),
2807 failures: failure_handle.bind(),
2808 domain: None,
2809 },
2810 )
2811 .unwrap();
2812 let probe_ref: ActorRef<CastFailureProbe> = probe_handle.bind::<CastFailureProbe>();
2813 let cast_proc = Proc::direct(ChannelTransport::Unix.any(), "cast_proc".into()).unwrap();
2814 let actor_instance = cast_proc.actor_instance::<CastActor>("cast").unwrap();
2815 let cx = Context::new(&actor_instance.instance, Flattrs::new());
2816 let mut cast_actor = CastActor::default();
2817 let domain_id = CastDomainId::new();
2818 let domain_region = Region::from(shape!(x = 2));
2819 cast_actor.installed_hops.insert(
2820 domain_id.clone(),
2821 CastHop {
2822 local_destination: CastDestination {
2823 point_in_domain: domain_region.point_of_base_rank(1).unwrap(),
2824 base_rank_in_domain: 1,
2825 actor: ActorAddr::root(cast_proc.proc_addr().clone(), Label::strip("receiver")),
2826 },
2827 next_hops: Vec::new(),
2828 num_destinations: 1,
2829 },
2830 );
2831 let seq_region = domain_region
2832 .range("x", ndslice::Range(0, Some(1), 1))
2833 .unwrap();
2834
2835 Handler::<CastMessage>::handle(
2837 &mut cast_actor,
2838 &cx,
2839 CastMessage {
2840 cast_domain_id: domain_id,
2841 sender: probe_ref.actor_addr().clone(),
2842 session_id: client.sequencer().session_id(),
2843 seqs: ValueMesh::new(seq_region, vec![1]).unwrap(),
2844 lineage: Vec::new(),
2845 headers: Flattrs::new(),
2846 dest_port: TestDelivery::port(),
2847 data: wirevalue::Any::<wirevalue::encoding::Multipart>::serialize(&TestDelivery {
2848 payload: "malformed".to_string(),
2849 })
2850 .unwrap(),
2851 return_undeliverable: true,
2852 },
2853 )
2854 .await
2855 .expect("routing errors must not escape the CastActor handler");
2856
2857 let returned = tokio::time::timeout(Duration::from_secs(5), failure_receiver.recv())
2859 .await
2860 .expect("routing failure should return to the sender")
2861 .expect("routing failure receiver should remain open");
2862 let Undeliverable::Returned(envelope) = returned else {
2863 panic!("routing failure should return the original message envelope");
2864 };
2865 assert_eq!(
2866 envelope
2867 .root_delivery_failure()
2868 .and_then(|failure| failure.attrs.get(CAST_FAILURE_PHASE)),
2869 Some("route".to_string()),
2870 );
2871 }
2872
2873 #[async_timed_test(timeout_secs = 30)]
2875 async fn test_delivery_failure_report_does_not_fail_cast_actor() {
2876 let proc = Proc::direct(ChannelTransport::Unix.any(), "cast_proc".into()).unwrap();
2878 let actor_instance = proc.actor_instance::<CastActor>("cast").unwrap();
2879 let mut cast_actor = CastActor::default();
2880 let destination = actor_instance
2881 .instance
2882 .self_addr()
2883 .port_addr(Port::handler::<CastMessage>());
2884 let report = DeliveryFailureReport::new(
2885 actor_instance.instance.self_addr().clone(),
2886 EndpointLocation::Port(destination.clone()),
2887 Some(CastMessage::typename().to_string()),
2888 DeliveryFailure::new(UndeliverableReason::Transport(TransportFailure::new(
2889 destination,
2890 TransportFailureReason::NoRoute,
2891 ))),
2892 );
2893
2894 let result = cast_actor
2896 .return_delivery_failure_to_origin(
2897 &actor_instance.instance,
2898 Undeliverable::Report(report),
2899 )
2900 .await;
2901
2902 result.expect("delivery failure reports must not fail the CastActor");
2904 }
2905
2906 #[async_timed_test(timeout_secs = 30)]
2908 async fn test_destroy_domain_propagates_to_all_hops() {
2909 clear_captured_domains();
2910
2911 let test_mesh = CastTestMesh::new(8);
2912 let root_domain = test_mesh.root_domain(shape!(a = 2, b = 2, c = 2).into());
2913 let domain_id = root_domain.domain_id().clone();
2914 test_mesh.wait_for_domain_snapshots(&domain_id, 3).await;
2915
2916 root_domain.destroy(&test_mesh.client);
2917
2918 let destroyed = test_mesh
2919 .wait_for_destroyed_domain_snapshots(&domain_id, 3)
2920 .await;
2921 assert_eq!(
2922 destroyed,
2923 [2, 4, 6]
2924 .into_iter()
2925 .map(|rank| format!("proc_{rank}"))
2926 .collect()
2927 );
2928 }
2929
2930 #[async_timed_test(timeout_secs = 30)]
2931 async fn test_cast_message_delivery_slice() {
2932 let config = hyperactor_config::global::lock();
2933 let _guard = config.override_key(
2934 hyperactor::config::ENABLE_DEST_ACTOR_REORDERING_BUFFER,
2935 true,
2936 );
2937
2938 let n = 8;
2939 let mut test_mesh = CastTestMesh::new(n);
2940 test_mesh.spawn_delivery_receivers();
2941
2942 let slice_region = Region::from(shape!(a = 2, b = 2, c = 2))
2943 .range("a", ndslice::Range(1, Some(2), 1))
2944 .unwrap();
2945 let slice_mesh = test_mesh.sliced(slice_region.clone());
2946 let slice_domain =
2947 slice_mesh.root_domain_with_policy(slice_region, TilingPolicy::BlockPartitioning);
2948
2949 let expected_payloads = vec![
2950 "hello-0".to_string(),
2951 "hello-1".to_string(),
2952 "hello-2".to_string(),
2953 ];
2954 for payload in &expected_payloads {
2955 slice_domain
2956 .cast(
2957 &test_mesh.client,
2958 Flattrs::new(),
2959 TestDelivery {
2960 payload: payload.clone(),
2961 },
2962 )
2963 .unwrap();
2964 }
2965
2966 let expected_histories: BTreeMap<String, Vec<String>> = [
2967 "proc_4".to_string(),
2968 "proc_5".to_string(),
2969 "proc_6".to_string(),
2970 "proc_7".to_string(),
2971 ]
2972 .into_iter()
2973 .map(|proc_name| (proc_name, expected_payloads.clone()))
2974 .collect();
2975 let histories = cast_and_collect_histories(&slice_mesh, &slice_domain).await;
2976
2977 let observed_payloads: BTreeMap<String, Vec<String>> = histories
2978 .iter()
2979 .map(|(proc_name, history)| {
2980 (
2981 proc_name.clone(),
2982 history
2983 .iter()
2984 .map(|delivery| delivery.payload.clone())
2985 .collect(),
2986 )
2987 })
2988 .collect();
2989 assert_eq!(observed_payloads, expected_histories);
2990
2991 let mut lineage_by_proc = BTreeMap::new();
2992 for (proc_name, history) in histories {
2993 assert_eq!(
2994 history.len(),
2995 expected_payloads.len(),
2996 "proc {proc_name} received the wrong number of deliveries"
2997 );
2998 let first_lineage = history[0].lineage.clone();
2999 for delivery in &history {
3000 assert_eq!(
3001 delivery.lineage, first_lineage,
3002 "proc {proc_name} changed lineage across slice casts"
3003 );
3004 }
3005 lineage_by_proc.insert(proc_name, first_lineage);
3006 }
3007
3008 let expected_lineage: BTreeMap<String, Vec<ActorAddr>> = [
3009 (
3010 "proc_4".to_string(),
3011 vec![test_mesh.receiver_ids[4].clone()],
3012 ),
3013 (
3014 "proc_5".to_string(),
3015 vec![test_mesh.receiver_ids[5].clone()],
3016 ),
3017 (
3018 "proc_6".to_string(),
3019 vec![
3020 cast_actor_ref_for_member(&test_mesh.receiver_ids[6])
3021 .actor_addr()
3022 .clone(),
3023 test_mesh.receiver_ids[6].clone(),
3024 ],
3025 ),
3026 (
3027 "proc_7".to_string(),
3028 vec![
3029 cast_actor_ref_for_member(&test_mesh.receiver_ids[6])
3030 .actor_addr()
3031 .clone(),
3032 test_mesh.receiver_ids[7].clone(),
3033 ],
3034 ),
3035 ]
3036 .into_iter()
3037 .collect();
3038 assert_eq!(lineage_by_proc, expected_lineage);
3039 }
3040
3041 #[async_timed_test(timeout_secs = 30)]
3043 async fn test_sender_sequenced_casts_are_observed_in_projection_order() {
3044 let config = hyperactor_config::global::lock();
3045 let _guard = config.override_key(
3046 hyperactor::config::ENABLE_DEST_ACTOR_REORDERING_BUFFER,
3047 true,
3048 );
3049
3050 let n = 4;
3051 let mut test_mesh = CastTestMesh::new(n);
3052 test_mesh.spawn_delivery_receivers();
3053 let root = test_mesh.root_domain(shape!(rank = 4).into());
3054
3055 let rank_0_to_2_region = Region::from(shape!(rank = 4))
3056 .range("rank", ndslice::Range(0, Some(2), 1))
3057 .unwrap();
3058 let rank_0_to_2 = test_mesh
3059 .sliced(rank_0_to_2_region.clone())
3060 .root_domain_with_policy(rank_0_to_2_region, TilingPolicy::BlockPartitioning);
3061
3062 let rank_1_to_3_region = Region::from(shape!(rank = 4))
3063 .range("rank", ndslice::Range(1, Some(3), 1))
3064 .unwrap();
3065 let rank_1_to_3 = test_mesh
3066 .sliced(rank_1_to_3_region.clone())
3067 .root_domain_with_policy(rank_1_to_3_region, TilingPolicy::BlockPartitioning);
3068
3069 let rank_2_to_4_region = Region::from(shape!(rank = 4))
3070 .range("rank", ndslice::Range(2, Some(4), 1))
3071 .unwrap();
3072 let rank_2_to_4 = test_mesh
3073 .sliced(rank_2_to_4_region.clone())
3074 .root_domain_with_policy(rank_2_to_4_region, TilingPolicy::BlockPartitioning);
3075
3076 let casts = [
3077 (
3078 &root,
3079 "root-0",
3080 vec!["proc_0", "proc_1", "proc_2", "proc_3"],
3081 ),
3082 (&rank_0_to_2, "rank_0_to_2-1", vec!["proc_0", "proc_1"]),
3083 (&rank_1_to_3, "rank_1_to_3-2", vec!["proc_1", "proc_2"]),
3084 (&rank_2_to_4, "rank_2_to_4-3", vec!["proc_2", "proc_3"]),
3085 (
3086 &root,
3087 "root-4",
3088 vec!["proc_0", "proc_1", "proc_2", "proc_3"],
3089 ),
3090 ];
3091
3092 let mut expected_histories: BTreeMap<String, Vec<String>> = test_mesh
3093 .proc_names()
3094 .into_iter()
3095 .map(|proc_name| (proc_name, Vec::new()))
3096 .collect();
3097
3098 for (domain, payload, expected_receivers) in &casts {
3099 domain
3100 .cast(
3101 &test_mesh.client,
3102 Flattrs::new(),
3103 TestDelivery {
3104 payload: payload.to_string(),
3105 },
3106 )
3107 .unwrap();
3108
3109 for proc_name in expected_receivers {
3110 expected_histories
3111 .get_mut(*proc_name)
3112 .unwrap()
3113 .push(payload.to_string());
3114 }
3115 }
3116
3117 let observed_histories: BTreeMap<String, Vec<String>> =
3118 cast_and_collect_histories(&test_mesh, &root)
3119 .await
3120 .into_iter()
3121 .map(|(proc_name, history)| {
3122 (
3123 proc_name,
3124 history
3125 .into_iter()
3126 .map(|delivery| delivery.payload)
3127 .collect(),
3128 )
3129 })
3130 .collect();
3131
3132 assert_eq!(observed_histories, expected_histories);
3133 }
3134
3135 #[derive(Debug, Clone)]
3142 struct SplitEdge {
3143 from: PortAddr,
3144 to: PortAddr,
3145 is_leaf: bool,
3146 }
3147
3148 struct SplitPortRecording {
3149 root: PortAddr,
3150 edges: Vec<SplitEdge>,
3151 }
3152
3153 struct SplitPortRecordingGuard;
3154
3155 static SPLIT_PORT_TREE: OnceLock<Mutex<Option<SplitPortRecording>>> = OnceLock::new();
3156
3157 fn split_port_tree() -> &'static Mutex<Option<SplitPortRecording>> {
3158 SPLIT_PORT_TREE.get_or_init(|| Mutex::new(None))
3159 }
3160
3161 pub(crate) fn collect_split_port(original: &PortAddr, split: &PortAddr) {
3162 let mut guard = split_port_tree().lock().unwrap();
3163 let Some(recording) = guard.as_mut() else {
3164 return;
3165 };
3166 if original != &recording.root && !recording.edges.iter().any(|edge| &edge.to == original) {
3167 return;
3168 }
3169 recording.edges.push(SplitEdge {
3170 from: original.clone(),
3171 to: split.clone(),
3172 is_leaf: original != &recording.root,
3173 });
3174 }
3175
3176 fn record_split_port_tree(root: PortAddr) -> SplitPortRecordingGuard {
3177 *split_port_tree().lock().unwrap() = Some(SplitPortRecording {
3178 root,
3179 edges: Vec::new(),
3180 });
3181 SplitPortRecordingGuard
3182 }
3183
3184 impl SplitPortRecordingGuard {
3185 fn edges(&self) -> Vec<SplitEdge> {
3186 split_port_tree()
3187 .lock()
3188 .unwrap()
3189 .as_ref()
3190 .map(|recording| recording.edges.clone())
3191 .unwrap_or_default()
3192 }
3193 }
3194
3195 impl Drop for SplitPortRecordingGuard {
3196 fn drop(&mut self) {
3197 *split_port_tree().lock().unwrap() = None;
3198 }
3199 }
3200
3201 fn build_split_paths(edges: &[SplitEdge]) -> BTreeMap<PortAddr, Vec<PortAddr>> {
3204 let mut child_to_parent: HashMap<PortAddr, PortAddr> = HashMap::new();
3205 let mut leaves = Vec::new();
3206
3207 for edge in edges {
3208 child_to_parent.insert(edge.to.clone(), edge.from.clone());
3209 if edge.is_leaf {
3210 leaves.push(edge.to.clone());
3211 }
3212 }
3213
3214 let mut result = BTreeMap::new();
3215 for leaf in leaves {
3216 let mut path = vec![leaf.clone()];
3217 let mut current = leaf.clone();
3218 while let Some(parent) = child_to_parent.get(¤t) {
3219 path.push(parent.clone());
3220 current = parent.clone();
3221 }
3222 path.reverse();
3223 result.insert(leaf, path);
3224 }
3225 result
3226 }
3227
3228 fn split_path_ranks(
3231 paths: &BTreeMap<PortAddr, Vec<PortAddr>>,
3232 rank_lookup: &HashMap<String, usize>,
3233 ) -> BTreeMap<usize, Vec<usize>> {
3234 paths
3235 .iter()
3236 .map(|(leaf, path)| {
3237 let ranks: Vec<usize> = path
3238 .iter()
3239 .map(|port| port.actor_addr().proc_addr().log_name().to_string())
3240 .skip_while(|proc_name| !rank_lookup.contains_key(proc_name))
3241 .map(|proc_name| {
3242 *rank_lookup
3243 .get(&proc_name)
3244 .unwrap_or_else(|| panic!("unknown proc {proc_name} in split path"))
3245 })
3246 .collect();
3247 let leaf_proc = leaf.actor_addr().proc_addr().log_name().to_string();
3248 let leaf_rank = rank_lookup[&leaf_proc];
3249 (leaf_rank, ranks)
3250 })
3251 .collect()
3252 }
3253
3254 #[derive(
3256 Debug,
3257 Clone,
3258 Default,
3259 PartialEq,
3260 Eq,
3261 Serialize,
3262 Deserialize,
3263 typeuri::Named
3264 )]
3265 struct TestReplyCounts {
3266 counts_by_proc: BTreeMap<String, u64>,
3267 }
3268 wirevalue::register_type!(TestReplyCounts);
3269
3270 impl TestReplyCounts {
3271 fn single(proc_name: String) -> Self {
3272 Self {
3273 counts_by_proc: [(proc_name, 1)].into_iter().collect(),
3274 }
3275 }
3276
3277 fn merge(&mut self, other: Self) {
3278 for (proc_name, count) in other.counts_by_proc {
3279 *self.counts_by_proc.entry(proc_name).or_default() += count;
3280 }
3281 }
3282 }
3283
3284 #[derive(typeuri::Named)]
3285 struct TestReplyCountsReducer;
3286
3287 impl hyperactor::accum::CommReducer for TestReplyCountsReducer {
3288 type Update = TestReplyCounts;
3289
3290 fn reduce(
3291 &self,
3292 mut left: Self::Update,
3293 right: Self::Update,
3294 ) -> anyhow::Result<Self::Update> {
3295 left.merge(right);
3296 Ok(left)
3297 }
3298 }
3299
3300 inventory::submit! {
3301 hyperactor::accum::ReducerFactory {
3302 typehash_f: <TestReplyCountsReducer as Named>::typehash,
3303 builder_f: |_| Ok(Box::new(TestReplyCountsReducer)),
3304 }
3305 }
3306
3307 struct TestReplyCountsAccumulator;
3308
3309 impl hyperactor::accum::Accumulator for TestReplyCountsAccumulator {
3310 type State = TestReplyCounts;
3311 type Update = TestReplyCounts;
3312
3313 fn accumulate(&self, state: &mut Self::State, update: Self::Update) -> anyhow::Result<()> {
3314 state.merge(update);
3315 Ok(())
3316 }
3317
3318 fn reducer_spec(&self) -> Option<hyperactor::accum::ReducerSpec> {
3319 Some(hyperactor::accum::ReducerSpec {
3320 typehash: <TestReplyCountsReducer as Named>::typehash(),
3321 builder_params: None,
3322 })
3323 }
3324 }
3325
3326 #[derive(Debug, Clone, Serialize, Deserialize, typeuri::Named)]
3328 struct TestRequestWithReply {
3329 payload: String,
3330 reply_to: hyperactor::OncePortRef<TestReplyCounts>,
3331 }
3332 wirevalue::register_type!(TestRequestWithReply);
3333
3334 #[derive(Debug, Clone, Serialize, Deserialize, typeuri::Named)]
3335 struct TestIdleFlushRequestWithReply {
3336 delayed_proc: String,
3337 delay: Duration,
3338 replies_per_destination: usize,
3339 reply_to: IdleFlushPortRef<TestReplyCounts>,
3340 }
3341 wirevalue::register_type!(TestIdleFlushRequestWithReply);
3342
3343 #[derive(Debug, Default)]
3346 #[hyperactor::export(
3347 handlers = [TestRequestWithReply, TestIdleFlushRequestWithReply],
3348 )]
3349 struct SplitPortReceiver;
3350
3351 #[async_trait]
3352 impl Actor for SplitPortReceiver {
3353 async fn init(&mut self, _this: &Instance<Self>) -> Result<(), anyhow::Error> {
3354 Ok(())
3355 }
3356 }
3357
3358 #[async_trait]
3359 impl Handler<TestRequestWithReply> for SplitPortReceiver {
3360 async fn handle(
3361 &mut self,
3362 cx: &Context<Self>,
3363 msg: TestRequestWithReply,
3364 ) -> Result<(), anyhow::Error> {
3365 msg.reply_to.post(
3366 cx,
3367 TestReplyCounts::single(cx.self_addr().proc_addr().log_name().to_string()),
3368 );
3369 Ok(())
3370 }
3371 }
3372
3373 #[async_trait]
3374 impl Handler<TestIdleFlushRequestWithReply> for SplitPortReceiver {
3375 async fn handle(
3376 &mut self,
3377 cx: &Context<Self>,
3378 msg: TestIdleFlushRequestWithReply,
3379 ) -> Result<(), anyhow::Error> {
3380 let proc_name = cx.self_addr().proc_addr().log_name().to_string();
3381
3382 if proc_name == msg.delayed_proc {
3383 tokio::time::sleep(msg.delay).await;
3384 }
3385
3386 for _ in 0..msg.replies_per_destination {
3387 msg.reply_to
3388 .post(cx, TestReplyCounts::single(proc_name.clone()));
3389 }
3390
3391 Ok(())
3392 }
3393 }
3394
3395 #[async_timed_test(timeout_secs = 30)]
3398 async fn test_port_splitting_replies_and_tree() {
3399 let n = 8;
3400 let mut test_mesh = CastTestMesh::new(n);
3401 test_mesh.spawn_split_port_receivers();
3402 let root_domain = test_mesh.root_domain(shape!(a = 2, b = 2, c = 2).into());
3403
3404 let (reply_handle, reply_rx) = context::Mailbox::mailbox(&test_mesh.client)
3405 .open_reduce_port(TestReplyCountsAccumulator);
3406 let reply_ref = reply_handle.bind();
3407 let split_port_recording = record_split_port_tree(reply_ref.port_addr().clone());
3408
3409 root_domain
3411 .cast(
3412 &test_mesh.client,
3413 Flattrs::new(),
3414 TestRequestWithReply {
3415 payload: "split_test".to_string(),
3416 reply_to: reply_ref,
3417 },
3418 )
3419 .unwrap();
3420
3421 let reply_counts = match tokio::time::timeout(Duration::from_secs(5), reply_rx.recv()).await
3422 {
3423 Ok(Ok(reply_counts)) => reply_counts.counts_by_proc,
3424 Ok(Err(e)) => panic!("reply recv error: {e}"),
3425 Err(_) => panic!("timed out waiting for reduced replies"),
3426 };
3427
3428 let expected_counts: BTreeMap<String, u64> =
3431 (0..n).map(|i| (format!("proc_{i}"), 1)).collect();
3432 assert_eq!(reply_counts, expected_counts);
3433
3434 let edges = split_port_recording.edges();
3436 let paths = build_split_paths(&edges);
3437
3438 let rank_lookup: HashMap<String, usize> =
3439 (0..n).map(|i| (format!("proc_{i}"), i)).collect();
3440 let rank_paths = split_path_ranks(&paths, &rank_lookup);
3441
3442 let expected: BTreeMap<usize, Vec<usize>> = [(2, vec![2]), (4, vec![4]), (6, vec![4, 6])]
3443 .into_iter()
3444 .collect();
3445
3446 assert_eq!(
3447 rank_paths, expected,
3448 "split-port tree doesn't mirror cast tree"
3449 );
3450 }
3451
3452 #[async_timed_test(timeout_secs = 30)]
3453 async fn test_idle_flush_split_reducer_preserves_late_reply() {
3454 let n = 8;
3455
3456 let mut test_mesh = CastTestMesh::new(n);
3457
3458 test_mesh.spawn_split_port_receivers();
3459 let root_domain = test_mesh.root_domain(shape!(a = 2, b = 2, c = 2).into());
3460
3461 let (reply_handle, mut reply_rx) = context::Mailbox::mailbox(&test_mesh.client)
3462 .open_accum_port(TestReplyCountsAccumulator);
3463
3464 let reply_to = reply_handle.bind().into_idle_flush(IdleFlushReducerOpts {
3465 idle_timeout: Duration::from_millis(50),
3466 abandon_timeout: Duration::from_secs(30),
3467 expected_updates_per_destination: NonZeroUsize::new(2).expect("2 is non-zero").into(),
3468 });
3469 let split_port_recording = record_split_port_tree(reply_to.port_addr().clone());
3470
3471 root_domain
3472 .cast(
3473 &test_mesh.client,
3474 Flattrs::new(),
3475 TestIdleFlushRequestWithReply {
3476 delayed_proc: "proc_7".to_string(),
3477 delay: Duration::from_millis(1500),
3478 replies_per_destination: 2,
3479 reply_to,
3480 },
3481 )
3482 .unwrap();
3483
3484 let partial = tokio::time::timeout(Duration::from_secs(1), async {
3485 loop {
3486 let reply = reply_rx.recv().await.unwrap();
3487 if reply.counts_by_proc.len() == n - 1 {
3488 break reply;
3489 }
3490 }
3491 })
3492 .await
3493 .expect("responsive subtrees should flush before the delayed reply");
3494
3495 assert_eq!(
3496 partial.counts_by_proc,
3497 [
3498 "proc_0", "proc_1", "proc_2", "proc_3", "proc_4", "proc_5", "proc_6"
3499 ]
3500 .into_iter()
3501 .map(|proc_name| (proc_name.to_string(), 2))
3502 .collect()
3503 );
3504
3505 let complete = tokio::time::timeout(Duration::from_secs(2), async {
3506 loop {
3507 let reply = reply_rx.recv().await.unwrap();
3508 if reply.counts_by_proc.len() == n {
3509 break reply;
3510 }
3511 }
3512 })
3513 .await
3514 .expect("the late reply should complete the idle-flush reduction");
3515
3516 assert_eq!(
3517 complete.counts_by_proc,
3518 (0..n).map(|rank| (format!("proc_{rank}"), 2)).collect()
3519 );
3520
3521 let edges = split_port_recording.edges();
3522 let paths = build_split_paths(&edges);
3523
3524 let rank_lookup: HashMap<String, usize> =
3525 (0..n).map(|i| (format!("proc_{i}"), i)).collect();
3526 let rank_paths = split_path_ranks(&paths, &rank_lookup);
3527
3528 let expected: BTreeMap<usize, Vec<usize>> = [(2, vec![2]), (4, vec![4]), (6, vec![4, 6])]
3529 .into_iter()
3530 .collect();
3531
3532 assert_eq!(
3533 rank_paths, expected,
3534 "idle-flush split-port tree doesn't mirror cast tree"
3535 );
3536 }
3537
3538 #[async_timed_test(timeout_secs = 30)]
3542 async fn test_root_heaved_bounded_fanout_installs_expected_hops() {
3543 clear_captured_domains();
3544
3545 let test_mesh = CastTestMesh::new(8);
3547
3548 let root_domain = test_mesh.root_domain_with_policy(
3550 shape!(a = 8).into(),
3551 TilingPolicy::BoundedFanout {
3552 fanout: NonZeroUsize::new(2).unwrap(),
3553 },
3554 );
3555 let snapshots = test_mesh
3556 .wait_for_domain_snapshots(root_domain.domain_id(), 3)
3557 .await;
3558
3559 assert_eq!(
3562 root_domain.sender_hop.local_destination.base_rank_in_domain,
3563 0
3564 );
3565 assert_eq!(
3566 root_domain
3567 .sender_hop
3568 .next_hops
3569 .iter()
3570 .map(|subtree| subtree.served_region.slice().offset())
3571 .collect::<BTreeSet<_>>(),
3572 [1, 5].into_iter().collect()
3573 );
3574
3575 let region = Region::from(shape!(a = 8));
3576 let expected_next_hops: BTreeMap<String, BTreeSet<String>> = [
3579 ("proc_1", vec!["proc_2", "proc_4"]),
3580 ("proc_2", vec!["proc_3"]),
3581 ("proc_5", vec!["proc_6", "proc_7"]),
3582 ]
3583 .into_iter()
3584 .map(|(proc_name, next_hops)| {
3585 (
3586 proc_name.to_string(),
3587 next_hops
3588 .into_iter()
3589 .map(str::to_string)
3590 .collect::<BTreeSet<_>>(),
3591 )
3592 })
3593 .collect();
3594
3595 assert_eq!(
3596 snapshots.keys().cloned().collect::<BTreeSet<_>>(),
3597 [1, 2, 5]
3598 .into_iter()
3599 .map(|rank| format!("proc_{rank}"))
3600 .collect()
3601 );
3602
3603 let expected_direct_hops: BTreeMap<String, BTreeSet<String>> = [
3604 ("proc_1", vec!["proc_4"]),
3605 ("proc_2", vec!["proc_3"]),
3606 ("proc_5", vec!["proc_6", "proc_7"]),
3607 ]
3608 .into_iter()
3609 .map(|(proc_name, next_hops)| {
3610 (
3611 proc_name.to_string(),
3612 next_hops.into_iter().map(str::to_string).collect(),
3613 )
3614 })
3615 .collect();
3616
3617 for rank in [1, 2, 5] {
3618 let proc_name = format!("proc_{rank}");
3619 let snapshot = snapshots
3620 .get(&proc_name)
3621 .unwrap_or_else(|| panic!("missing snapshot for {proc_name}"));
3622
3623 assert_eq!(snapshot.base_rank_in_domain, rank);
3624 assert_eq!(
3625 snapshot.point_in_domain,
3626 region.point_of_base_rank(rank).unwrap()
3627 );
3628 assert_eq!(snapshot.local_actor_proc, proc_name);
3629 assert_eq!(snapshot.next_hop_procs, expected_next_hops[&proc_name]);
3630 assert_eq!(snapshot.direct_hop_procs, expected_direct_hops[&proc_name]);
3631 }
3632 }
3633
3634 #[async_timed_test(timeout_secs = 30)]
3637 async fn test_root_heaved_bisection_installs_expected_hops() {
3638 clear_captured_domains();
3639
3640 let test_mesh = CastTestMesh::new(8);
3642
3643 let root_domain =
3645 test_mesh.root_domain_with_policy(shape!(a = 8).into(), TilingPolicy::Bisection);
3646 let snapshots = test_mesh
3647 .wait_for_domain_snapshots(root_domain.domain_id(), 3)
3648 .await;
3649
3650 assert_eq!(
3653 root_domain.sender_hop.local_destination.base_rank_in_domain,
3654 0
3655 );
3656 assert_eq!(
3657 root_domain
3658 .sender_hop
3659 .next_hops
3660 .iter()
3661 .map(|subtree| subtree.served_region.slice().offset())
3662 .collect::<BTreeSet<_>>(),
3663 [1, 2, 4].into_iter().collect()
3664 );
3665
3666 let region = Region::from(shape!(a = 8));
3667 let expected_next_hops: BTreeMap<String, BTreeSet<String>> = [
3670 ("proc_2", vec!["proc_3"]),
3671 ("proc_4", vec!["proc_5", "proc_6"]),
3672 ("proc_6", vec!["proc_7"]),
3673 ]
3674 .into_iter()
3675 .map(|(proc_name, next_hops)| {
3676 (
3677 proc_name.to_string(),
3678 next_hops
3679 .into_iter()
3680 .map(str::to_string)
3681 .collect::<BTreeSet<_>>(),
3682 )
3683 })
3684 .collect();
3685
3686 assert_eq!(
3687 snapshots.keys().cloned().collect::<BTreeSet<_>>(),
3688 [2, 4, 6]
3689 .into_iter()
3690 .map(|rank| format!("proc_{rank}"))
3691 .collect()
3692 );
3693
3694 let expected_direct_hops: BTreeMap<String, BTreeSet<String>> = [
3695 ("proc_2", vec!["proc_3"]),
3696 ("proc_4", vec!["proc_5"]),
3697 ("proc_6", vec!["proc_7"]),
3698 ]
3699 .into_iter()
3700 .map(|(proc_name, next_hops)| {
3701 (
3702 proc_name.to_string(),
3703 next_hops.into_iter().map(str::to_string).collect(),
3704 )
3705 })
3706 .collect();
3707
3708 for rank in [2, 4, 6] {
3709 let proc_name = format!("proc_{rank}");
3710 let snapshot = snapshots
3711 .get(&proc_name)
3712 .unwrap_or_else(|| panic!("missing snapshot for {proc_name}"));
3713
3714 assert_eq!(snapshot.base_rank_in_domain, rank);
3715 assert_eq!(
3716 snapshot.point_in_domain,
3717 region.point_of_base_rank(rank).unwrap()
3718 );
3719 assert_eq!(snapshot.local_actor_proc, proc_name);
3720 assert_eq!(snapshot.next_hop_procs, expected_next_hops[&proc_name]);
3721 assert_eq!(snapshot.direct_hop_procs, expected_direct_hops[&proc_name]);
3722 }
3723 }
3724}