1use std::collections::HashSet;
42use std::sync::Arc;
43
44use super::daemon::{LifecycleDaemon, LifecycleError, LifecycleHandle, ReplicaHealth};
45use crate::adapter::net::behavior::capability::CapabilityFilter;
46use crate::adapter::net::compute::group_coord::GroupCoordinator;
47use crate::adapter::net::compute::replica_group::derive_replica_keypair;
48use crate::adapter::net::compute::{PlacementDecision, Scheduler};
49use crate::adapter::net::identity::EntityKeypair;
50
51#[derive(Debug)]
58pub enum LifecycleGroupError {
59 InvalidConfig(String),
62 StartFailed {
67 index: u8,
69 error: LifecycleError,
71 },
72 PlacementFailed {
76 index: u8,
78 reason: String,
80 },
81}
82
83impl std::fmt::Display for LifecycleGroupError {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 match self {
86 Self::InvalidConfig(msg) => write!(f, "invalid lifecycle group config: {msg}"),
87 Self::StartFailed { index, error } => {
88 write!(f, "replica {index} failed to start: {error}")
89 }
90 Self::PlacementFailed { index, reason } => {
91 write!(f, "replica {index} placement failed: {reason}")
92 }
93 }
94 }
95}
96
97impl std::error::Error for LifecycleGroupError {}
98
99#[derive(Debug, Clone)]
108pub struct ReplicaContext {
109 pub index: u8,
111 pub placement: Option<PlacementDecision>,
115}
116
117pub struct LifecycleGroup<L: LifecycleDaemon> {
124 handles: Vec<LifecycleHandle>,
125 replicas: Vec<Arc<L>>,
130 placements: Vec<PlacementDecision>,
135 group_seed: [u8; 32],
136}
137
138impl<L: LifecycleDaemon> LifecycleGroup<L> {
139 pub async fn spawn<F>(
150 replica_count: u8,
151 group_seed: [u8; 32],
152 factory: F,
153 ) -> Result<Self, LifecycleGroupError>
154 where
155 F: FnMut(u8) -> Arc<L>,
156 {
157 if replica_count == 0 {
158 return Err(LifecycleGroupError::InvalidConfig(
159 "replica_count must be > 0".into(),
160 ));
161 }
162 let (replicas, handles) = start_replicas(replica_count, factory).await?;
163 Ok(Self {
164 handles,
165 replicas,
166 placements: Vec::new(),
167 group_seed,
168 })
169 }
170
171 pub async fn spawn_with_placement<F>(
203 replica_count: u8,
204 group_seed: [u8; 32],
205 requirements: CapabilityFilter,
206 scheduler: &Scheduler,
207 mut factory: F,
208 ) -> Result<Self, LifecycleGroupError>
209 where
210 F: FnMut(ReplicaContext) -> Arc<L>,
211 {
212 if replica_count == 0 {
213 return Err(LifecycleGroupError::InvalidConfig(
214 "replica_count must be > 0".into(),
215 ));
216 }
217 let mut placements: Vec<PlacementDecision> = Vec::with_capacity(replica_count as usize);
221 let mut used_nodes: HashSet<u64> = HashSet::new();
222 for index in 0..replica_count {
223 match GroupCoordinator::place_with_spread(scheduler, &requirements, &used_nodes) {
224 Ok(decision) => {
225 used_nodes.insert(decision.node_id);
226 placements.push(decision);
227 }
228 Err(e) => {
229 return Err(LifecycleGroupError::PlacementFailed {
230 index,
231 reason: format!("{e}"),
232 });
233 }
234 }
235 }
236 let placements_for_factory = placements.clone();
237 let (replicas, handles) = start_replicas(replica_count, move |index| {
238 let ctx = ReplicaContext {
239 index,
240 placement: Some(placements_for_factory[index as usize].clone()),
241 };
242 factory(ctx)
243 })
244 .await?;
245 Ok(Self {
246 handles,
247 replicas,
248 placements,
249 group_seed,
250 })
251 }
252
253 pub fn replica_count(&self) -> usize {
255 self.handles.len()
256 }
257
258 pub fn group_seed(&self) -> &[u8; 32] {
260 &self.group_seed
261 }
262
263 pub fn replica_keypair(&self, index: u8) -> EntityKeypair {
269 derive_replica_keypair(&self.group_seed, index)
270 }
271
272 pub fn replica(&self, index: usize) -> Option<Arc<L>> {
277 self.replicas.get(index).cloned()
278 }
279
280 pub fn replicas(&self) -> Vec<Arc<L>> {
282 self.replicas.clone()
283 }
284
285 pub fn placement(&self, index: usize) -> Option<&PlacementDecision> {
289 self.placements.get(index)
290 }
291
292 pub fn placements(&self) -> &[PlacementDecision] {
296 &self.placements
297 }
298
299 pub async fn health(&self) -> Vec<ReplicaHealth> {
306 let futures = self.replicas.iter().map(|r| {
307 let r = r.clone();
308 async move { r.health().await }
309 });
310 futures::future::join_all(futures).await
311 }
312
313 pub async fn replace(
327 &mut self,
328 index: usize,
329 new_daemon: Arc<L>,
330 ) -> Result<Arc<L>, LifecycleGroupError> {
331 if index >= self.replicas.len() {
332 return Err(LifecycleGroupError::InvalidConfig(format!(
333 "replace index {index} out of bounds for {} replicas",
334 self.replicas.len()
335 )));
336 }
337 let old_handle = self.handles.remove(index);
341 old_handle.stop().await;
342 let old_replica = std::mem::replace(&mut self.replicas[index], new_daemon.clone());
343
344 let trait_obj: Arc<dyn LifecycleDaemon> = new_daemon;
346 let new_handle = match LifecycleHandle::start(trait_obj).await {
347 Ok(h) => h,
348 Err(error) => {
349 return Err(LifecycleGroupError::StartFailed {
354 index: u8::try_from(index).unwrap_or(u8::MAX),
355 error,
356 });
357 }
358 };
359 self.handles.insert(index, new_handle);
360 Ok(old_replica)
361 }
362
363 pub async fn add_replica<F>(&mut self, factory: F) -> Result<u8, LifecycleGroupError>
389 where
390 F: FnOnce(u8) -> Arc<L>,
391 {
392 if self.replicas.len() >= u8::MAX as usize {
393 return Err(LifecycleGroupError::InvalidConfig(format!(
394 "cannot grow past u8::MAX replicas (current: {})",
395 self.replicas.len()
396 )));
397 }
398 let new_idx = self.replicas.len() as u8;
400 let daemon = factory(new_idx);
401 let trait_obj: Arc<dyn LifecycleDaemon> = daemon.clone();
402 let handle = LifecycleHandle::start(trait_obj).await.map_err(|error| {
403 LifecycleGroupError::StartFailed {
404 index: new_idx,
405 error,
406 }
407 })?;
408 self.replicas.push(daemon);
409 self.handles.push(handle);
410 Ok(new_idx)
411 }
412
413 pub async fn add_replicas<F>(
428 &mut self,
429 count: u8,
430 mut factory: F,
431 ) -> Result<(), LifecycleGroupError>
432 where
433 F: FnMut(u8) -> Arc<L>,
434 {
435 if count == 0 {
436 return Ok(());
437 }
438 let new_total = (self.replicas.len() as u32) + (count as u32);
439 if new_total > u8::MAX as u32 {
440 return Err(LifecycleGroupError::InvalidConfig(format!(
441 "cannot grow past u8::MAX replicas (current: {}, requested +{})",
442 self.replicas.len(),
443 count
444 )));
445 }
446 let base_idx = self.replicas.len() as u8;
451 let mut new_daemons: Vec<Arc<L>> = Vec::with_capacity(count as usize);
452 let mut starts = Vec::with_capacity(count as usize);
453 for offset in 0..count {
454 let idx = base_idx + offset;
455 let daemon = factory(idx);
456 new_daemons.push(daemon.clone());
457 let trait_obj: Arc<dyn LifecycleDaemon> = daemon;
458 starts.push((idx, LifecycleHandle::start(trait_obj)));
459 }
460 let started: Vec<_> = futures::future::join_all(
463 starts
464 .into_iter()
465 .map(|(idx, fut)| async move { (idx, fut.await) }),
466 )
467 .await;
468 let mut handles = Vec::with_capacity(count as usize);
469 for (idx, result) in started {
470 match result {
471 Ok(h) => handles.push(h),
472 Err(error) => {
473 drop(handles);
478 drop(new_daemons);
479 return Err(LifecycleGroupError::StartFailed { index: idx, error });
480 }
481 }
482 }
483 self.replicas.extend(new_daemons);
485 self.handles.extend(handles);
486 Ok(())
487 }
488
489 pub async fn remove_last(&mut self) -> Result<Arc<L>, LifecycleGroupError> {
508 if self.replicas.len() <= 1 {
509 return Err(LifecycleGroupError::InvalidConfig(format!(
510 "cannot remove last replica below count 1 (current: {}); \
511 call stop() to dismantle the whole group instead",
512 self.replicas.len()
513 )));
514 }
515 #[allow(clippy::expect_used)]
520 let handle = self
521 .handles
522 .pop()
523 .expect("replica_count > 1 above; handles parallel to replicas");
524 handle.stop().await;
525 #[allow(clippy::expect_used)]
526 let replica = self
527 .replicas
528 .pop()
529 .expect("replica_count > 1 above; pop after handle.stop succeeded");
530 if !self.placements.is_empty() {
531 self.placements.pop();
536 }
537 Ok(replica)
538 }
539
540 pub fn handles(&self) -> &[LifecycleHandle] {
545 &self.handles
546 }
547
548 pub async fn stop(self) {
551 for h in self.handles {
552 h.stop().await;
553 }
554 }
555
556 pub fn into_parts(
567 self,
568 ) -> (
569 Vec<Arc<L>>,
570 Vec<PlacementDecision>,
571 Vec<LifecycleHandle>,
572 [u8; 32],
573 ) {
574 (
575 self.replicas,
576 self.placements,
577 self.handles,
578 self.group_seed,
579 )
580 }
581}
582
583async fn start_replicas<L, F>(
590 replica_count: u8,
591 mut factory: F,
592) -> Result<(Vec<Arc<L>>, Vec<LifecycleHandle>), LifecycleGroupError>
593where
594 L: LifecycleDaemon,
595 F: FnMut(u8) -> Arc<L>,
596{
597 let mut replicas: Vec<Arc<L>> = Vec::with_capacity(replica_count as usize);
598 let mut starts = Vec::with_capacity(replica_count as usize);
599 for index in 0..replica_count {
600 let daemon = factory(index);
601 replicas.push(daemon.clone());
602 let trait_obj: Arc<dyn LifecycleDaemon> = daemon;
603 starts.push((index, LifecycleHandle::start(trait_obj)));
604 }
605 let started: Vec<_> = futures::future::join_all(
606 starts
607 .into_iter()
608 .map(|(i, fut)| async move { (i, fut.await) }),
609 )
610 .await;
611 let mut handles = Vec::with_capacity(replica_count as usize);
612 for (index, result) in started {
613 match result {
614 Ok(h) => handles.push(h),
615 Err(error) => {
616 drop(handles);
617 drop(replicas);
618 return Err(LifecycleGroupError::StartFailed { index, error });
619 }
620 }
621 }
622 Ok((replicas, handles))
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628 use async_trait::async_trait;
629 use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
630
631 struct CountingDaemon {
636 starts: AtomicU64,
637 stops: AtomicU64,
638 fail_start: AtomicBool,
639 }
640
641 impl CountingDaemon {
642 fn new() -> Self {
643 Self {
644 starts: AtomicU64::new(0),
645 stops: AtomicU64::new(0),
646 fail_start: AtomicBool::new(false),
647 }
648 }
649 }
650
651 #[async_trait]
652 impl LifecycleDaemon for CountingDaemon {
653 fn name(&self) -> &str {
654 "counting"
655 }
656 async fn on_start(self: Arc<Self>) -> Result<(), LifecycleError> {
657 if self.fail_start.load(Ordering::Acquire) {
658 return Err(LifecycleError::StartFailed("intentional".into()));
659 }
660 self.starts.fetch_add(1, Ordering::AcqRel);
661 Ok(())
662 }
663 async fn on_stop(&self) {
664 self.stops.fetch_add(1, Ordering::AcqRel);
665 }
666 }
667
668 #[tokio::test]
669 async fn spawn_zero_replicas_is_rejected_as_config_error() {
670 let result = LifecycleGroup::<CountingDaemon>::spawn(0, [0u8; 32], |_| {
671 panic!("factory must not be called when replica_count == 0")
672 })
673 .await;
674 match result {
675 Err(LifecycleGroupError::InvalidConfig(msg)) => {
676 assert!(msg.contains("replica_count"), "msg was: {msg}");
677 }
678 Err(other) => panic!("expected InvalidConfig, got {other:?}"),
679 Ok(_) => panic!("expected InvalidConfig, got Ok"),
680 }
681 }
682
683 #[tokio::test]
684 async fn spawn_three_replicas_runs_each_lifecycle_then_stops_all() {
685 let factory_calls = Arc::new(parking_lot::Mutex::new(Vec::<u8>::new()));
686 let factory_calls_clone = factory_calls.clone();
687 let daemons: Arc<parking_lot::Mutex<Vec<Arc<CountingDaemon>>>> =
688 Arc::new(parking_lot::Mutex::new(Vec::new()));
689 let daemons_clone = daemons.clone();
690
691 let group = LifecycleGroup::<CountingDaemon>::spawn(3, [0xABu8; 32], move |idx| {
692 factory_calls_clone.lock().push(idx);
693 let d = Arc::new(CountingDaemon::new());
694 daemons_clone.lock().push(d.clone());
695 d
696 })
697 .await
698 .expect("group spawn");
699
700 assert_eq!(group.replica_count(), 3);
701 assert_eq!(*factory_calls.lock(), vec![0u8, 1, 2]);
702 for d in daemons.lock().iter() {
703 assert_eq!(d.starts.load(Ordering::Acquire), 1);
704 assert_eq!(d.stops.load(Ordering::Acquire), 0);
705 }
706
707 let r0 = group.replica(0).expect("replica 0");
709 assert_eq!(r0.starts.load(Ordering::Acquire), 1);
710 assert!(group.replica(3).is_none());
711
712 group.stop().await;
713 for d in daemons.lock().iter() {
714 assert_eq!(d.stops.load(Ordering::Acquire), 1);
715 }
716 }
717
718 #[tokio::test]
719 async fn replica_keypair_is_deterministic_for_a_given_index() {
720 let seed = [0x42u8; 32];
721 let group =
722 LifecycleGroup::<CountingDaemon>::spawn(
723 2,
724 seed,
725 |_idx| Arc::new(CountingDaemon::new()),
726 )
727 .await
728 .expect("group spawn");
729 let expected_kp_0 = derive_replica_keypair(&seed, 0);
730 let expected_kp_1 = derive_replica_keypair(&seed, 1);
731 assert_eq!(
732 group.replica_keypair(0).entity_id(),
733 expected_kp_0.entity_id()
734 );
735 assert_eq!(
736 group.replica_keypair(1).entity_id(),
737 expected_kp_1.entity_id()
738 );
739 assert_ne!(
740 group.replica_keypair(0).entity_id(),
741 group.replica_keypair(1).entity_id()
742 );
743 assert_eq!(group.group_seed(), &seed);
744 group.stop().await;
745 }
746
747 fn make_scheduler(node_ids: &[u64]) -> Scheduler {
748 use crate::adapter::net::behavior::capability::{CapabilityAnnouncement, CapabilitySet};
749 use crate::adapter::net::behavior::fold::{capability_bridge, CapabilityFold, Fold};
750 let fold: Arc<Fold<CapabilityFold>> =
751 Arc::new(Fold::with_sweep_interval(std::time::Duration::ZERO));
752 let eid = crate::adapter::net::identity::EntityId::from_bytes([0u8; 32]);
753 for &id in node_ids {
754 capability_bridge::apply_legacy_announcement(
755 &fold,
756 CapabilityAnnouncement::new(id, eid.clone(), 1, CapabilitySet::new()),
757 )
758 .expect("apply legacy announcement in fixture");
759 }
760 let local = node_ids.first().copied().unwrap_or(0xFFFF);
761 Scheduler::new(fold, local, CapabilitySet::new())
762 }
763
764 #[tokio::test]
765 async fn spawn_with_placement_records_distinct_node_per_replica() {
766 let scheduler = make_scheduler(&[0x1111, 0x2222, 0x3333]);
767 let seen_placements = Arc::new(parking_lot::Mutex::new(Vec::<u64>::new()));
768 let seen_placements_clone = seen_placements.clone();
769
770 let group = LifecycleGroup::<CountingDaemon>::spawn_with_placement(
771 3,
772 [0u8; 32],
773 CapabilityFilter::default(),
774 &scheduler,
775 move |ctx| {
776 let node_id = ctx
779 .placement
780 .as_ref()
781 .expect("placement set under spawn_with_placement")
782 .node_id;
783 seen_placements_clone.lock().push(node_id);
784 Arc::new(CountingDaemon::new())
785 },
786 )
787 .await
788 .expect("spawn_with_placement");
789
790 let recorded: Vec<u64> = group.placements().iter().map(|p| p.node_id).collect();
792 assert_eq!(recorded.len(), 3);
793 let unique: std::collections::HashSet<u64> = recorded.iter().copied().collect();
794 assert_eq!(unique.len(), 3, "placements must be on distinct nodes");
795 assert_eq!(*seen_placements.lock(), recorded);
796 for i in 0..3 {
797 assert!(group.placement(i).is_some());
798 }
799 assert!(group.placement(3).is_none());
800
801 group.stop().await;
802 }
803
804 #[tokio::test]
805 async fn spawn_with_placement_fails_when_fewer_nodes_than_replicas() {
806 let scheduler = make_scheduler(&[0xAA, 0xBB]);
809 let result = LifecycleGroup::<CountingDaemon>::spawn_with_placement(
810 3,
811 [0u8; 32],
812 CapabilityFilter::default(),
813 &scheduler,
814 |_ctx| Arc::new(CountingDaemon::new()),
815 )
816 .await;
817 match result {
818 Err(LifecycleGroupError::PlacementFailed { index, .. }) => {
819 assert_eq!(index, 2);
820 }
821 Err(other) => panic!("expected PlacementFailed, got {other:?}"),
822 Ok(_) => panic!("expected PlacementFailed, got Ok"),
823 }
824 }
825
826 struct HealthControlDaemon {
830 force_unhealthy: AtomicBool,
831 starts: AtomicU64,
832 stops: AtomicU64,
833 }
834
835 impl HealthControlDaemon {
836 fn new() -> Self {
837 Self {
838 force_unhealthy: AtomicBool::new(false),
839 starts: AtomicU64::new(0),
840 stops: AtomicU64::new(0),
841 }
842 }
843 }
844
845 #[async_trait]
846 impl LifecycleDaemon for HealthControlDaemon {
847 fn name(&self) -> &str {
848 "health-control"
849 }
850 async fn on_start(self: Arc<Self>) -> Result<(), LifecycleError> {
851 self.starts.fetch_add(1, Ordering::AcqRel);
852 Ok(())
853 }
854 async fn on_stop(&self) {
855 self.stops.fetch_add(1, Ordering::AcqRel);
856 }
857 async fn health(&self) -> ReplicaHealth {
858 if self.force_unhealthy.load(Ordering::Acquire) {
859 ReplicaHealth::unhealthy("test-forced")
860 } else {
861 ReplicaHealth::healthy()
862 }
863 }
864 }
865
866 #[tokio::test]
867 async fn health_returns_per_replica_snapshot_in_declaration_order() {
868 let daemons: Arc<parking_lot::Mutex<Vec<Arc<HealthControlDaemon>>>> =
869 Arc::new(parking_lot::Mutex::new(Vec::new()));
870 let daemons_clone = daemons.clone();
871 let group = LifecycleGroup::<HealthControlDaemon>::spawn(3, [0u8; 32], move |_idx| {
872 let d = Arc::new(HealthControlDaemon::new());
873 daemons_clone.lock().push(d.clone());
874 d
875 })
876 .await
877 .expect("spawn");
878
879 let snapshot = group.health().await;
881 assert_eq!(snapshot.len(), 3);
882 for h in &snapshot {
883 assert!(h.healthy);
884 assert!(h.diagnostic.is_none());
885 }
886
887 daemons.lock()[1]
889 .force_unhealthy
890 .store(true, Ordering::Release);
891 let snapshot = group.health().await;
892 assert!(snapshot[0].healthy);
893 assert!(!snapshot[1].healthy);
894 assert_eq!(snapshot[1].diagnostic.as_deref(), Some("test-forced"));
895 assert!(snapshot[2].healthy);
896
897 group.stop().await;
898 }
899
900 #[tokio::test]
901 async fn replace_stops_old_handle_and_installs_new_daemon() {
902 let daemons: Arc<parking_lot::Mutex<Vec<Arc<HealthControlDaemon>>>> =
903 Arc::new(parking_lot::Mutex::new(Vec::new()));
904 let daemons_clone = daemons.clone();
905 let mut group = LifecycleGroup::<HealthControlDaemon>::spawn(2, [0u8; 32], move |_idx| {
906 let d = Arc::new(HealthControlDaemon::new());
907 daemons_clone.lock().push(d.clone());
908 d
909 })
910 .await
911 .expect("spawn");
912
913 let original_idx_1 = daemons.lock()[1].clone();
914 assert_eq!(original_idx_1.stops.load(Ordering::Acquire), 0);
915
916 let replacement = Arc::new(HealthControlDaemon::new());
918 let returned = group
919 .replace(1, replacement.clone())
920 .await
921 .expect("replace");
922 assert!(Arc::ptr_eq(&returned, &original_idx_1));
924 assert_eq!(original_idx_1.stops.load(Ordering::Acquire), 1);
926 assert_eq!(replacement.starts.load(Ordering::Acquire), 1);
928 let now_at_1 = group.replica(1).expect("replica 1");
930 assert!(Arc::ptr_eq(&now_at_1, &replacement));
931
932 group.stop().await;
933 assert_eq!(replacement.stops.load(Ordering::Acquire), 1);
935 }
936
937 #[tokio::test]
938 async fn replace_rejects_out_of_bounds_index() {
939 let mut group = LifecycleGroup::<HealthControlDaemon>::spawn(2, [0u8; 32], |_idx| {
940 Arc::new(HealthControlDaemon::new())
941 })
942 .await
943 .expect("spawn");
944 let replacement = Arc::new(HealthControlDaemon::new());
945 match group.replace(5, replacement).await {
946 Err(LifecycleGroupError::InvalidConfig(msg)) => {
947 assert!(msg.contains("out of bounds"), "msg was: {msg}");
948 }
949 Err(other) => panic!("expected InvalidConfig, got {other:?}"),
950 Ok(_) => panic!("expected InvalidConfig, got Ok"),
951 }
952 group.stop().await;
953 }
954
955 #[tokio::test]
956 async fn spawn_path_leaves_placements_empty() {
957 let group = LifecycleGroup::<CountingDaemon>::spawn(2, [0u8; 32], |_idx| {
960 Arc::new(CountingDaemon::new())
961 })
962 .await
963 .expect("spawn");
964 assert!(group.placements().is_empty());
965 assert!(group.placement(0).is_none());
966 group.stop().await;
967 }
968
969 #[tokio::test]
970 async fn start_failure_at_index_two_returns_typed_error_with_index() {
971 let result = LifecycleGroup::<CountingDaemon>::spawn(3, [0u8; 32], |idx| {
972 let d = Arc::new(CountingDaemon::new());
973 if idx == 2 {
974 d.fail_start.store(true, Ordering::Release);
975 }
976 d
977 })
978 .await;
979 match result {
980 Err(LifecycleGroupError::StartFailed { index, error }) => {
981 assert_eq!(index, 2);
982 match error {
983 LifecycleError::StartFailed(msg) => assert_eq!(msg, "intentional"),
984 }
985 }
986 Err(other) => panic!("expected StartFailed, got {other:?}"),
987 Ok(_) => panic!("expected StartFailed, got Ok"),
988 }
989 }
990
991 #[tokio::test]
992 async fn add_replica_grows_in_place_preserving_existing_replicas() {
993 let daemons: Arc<parking_lot::Mutex<Vec<Arc<CountingDaemon>>>> =
994 Arc::new(parking_lot::Mutex::new(Vec::new()));
995 let daemons_clone = daemons.clone();
996 let mut group = LifecycleGroup::<CountingDaemon>::spawn(2, [0u8; 32], move |_idx| {
997 let d = Arc::new(CountingDaemon::new());
998 daemons_clone.lock().push(d.clone());
999 d
1000 })
1001 .await
1002 .expect("initial spawn");
1003 for d in daemons.lock().iter() {
1005 assert_eq!(d.starts.load(Ordering::Acquire), 1);
1006 }
1007
1008 let new_replica = Arc::new(CountingDaemon::new());
1009 let new_replica_clone = new_replica.clone();
1010 let new_idx = group
1011 .add_replica(move |_idx| new_replica_clone)
1012 .await
1013 .expect("add_replica");
1014 assert_eq!(new_idx, 2, "new index = old replica_count");
1015 assert_eq!(group.replica_count(), 3);
1016 assert_eq!(new_replica.starts.load(Ordering::Acquire), 1);
1017 for d in daemons.lock().iter() {
1020 assert_eq!(
1021 d.starts.load(Ordering::Acquire),
1022 1,
1023 "existing replica restarted"
1024 );
1025 assert_eq!(
1026 d.stops.load(Ordering::Acquire),
1027 0,
1028 "existing replica stopped"
1029 );
1030 }
1031
1032 group.stop().await;
1033 }
1034
1035 #[tokio::test]
1036 async fn remove_last_stops_only_the_last_replica() {
1037 let daemons: Arc<parking_lot::Mutex<Vec<Arc<CountingDaemon>>>> =
1038 Arc::new(parking_lot::Mutex::new(Vec::new()));
1039 let daemons_clone = daemons.clone();
1040 let mut group = LifecycleGroup::<CountingDaemon>::spawn(3, [0u8; 32], move |_idx| {
1041 let d = Arc::new(CountingDaemon::new());
1042 daemons_clone.lock().push(d.clone());
1043 d
1044 })
1045 .await
1046 .expect("spawn");
1047
1048 let removed = group.remove_last().await.expect("remove_last");
1049 assert_eq!(group.replica_count(), 2);
1050 let last_original = daemons.lock()[2].clone();
1052 assert!(Arc::ptr_eq(&removed, &last_original));
1053 assert_eq!(removed.stops.load(Ordering::Acquire), 1);
1055 {
1060 let kept = daemons.lock();
1061 assert_eq!(kept[0].stops.load(Ordering::Acquire), 0);
1062 assert_eq!(kept[1].stops.load(Ordering::Acquire), 0);
1063 }
1064
1065 group.stop().await;
1066 }
1067
1068 #[tokio::test]
1069 async fn remove_last_refuses_to_drop_below_one() {
1070 let mut group = LifecycleGroup::<CountingDaemon>::spawn(1, [0u8; 32], |_idx| {
1071 Arc::new(CountingDaemon::new())
1072 })
1073 .await
1074 .expect("spawn");
1075 match group.remove_last().await {
1076 Ok(_) => panic!("expected InvalidConfig, got Ok"),
1077 Err(LifecycleGroupError::InvalidConfig(msg)) => {
1078 assert!(msg.contains("cannot remove last replica"), "msg was: {msg}");
1079 }
1080 Err(other) => panic!("expected InvalidConfig, got {other:?}"),
1081 }
1082 assert_eq!(group.replica_count(), 1);
1084 group.stop().await;
1085 }
1086
1087 #[tokio::test]
1088 async fn add_replicas_bulk_runs_starts_concurrently() {
1089 use std::time::Duration;
1090 const SLEEP: Duration = Duration::from_millis(120);
1094 const N: u8 = 8;
1095
1096 struct SleepyDaemon {
1097 stops: AtomicU64,
1098 }
1099 #[async_trait]
1100 impl LifecycleDaemon for SleepyDaemon {
1101 fn name(&self) -> &str {
1102 "sleepy"
1103 }
1104 async fn on_start(self: Arc<Self>) -> Result<(), LifecycleError> {
1105 tokio::time::sleep(SLEEP).await;
1106 Ok(())
1107 }
1108 async fn on_stop(&self) {
1109 self.stops.fetch_add(1, Ordering::AcqRel);
1110 }
1111 }
1112
1113 let mut group = LifecycleGroup::<SleepyDaemon>::spawn(1, [0u8; 32], |_idx| {
1114 Arc::new(SleepyDaemon {
1115 stops: AtomicU64::new(0),
1116 })
1117 })
1118 .await
1119 .expect("initial spawn");
1120
1121 let started = std::time::Instant::now();
1122 group
1123 .add_replicas(N, |_idx| {
1124 Arc::new(SleepyDaemon {
1125 stops: AtomicU64::new(0),
1126 })
1127 })
1128 .await
1129 .expect("add_replicas");
1130 let elapsed = started.elapsed();
1131 assert_eq!(group.replica_count(), 1 + N as usize);
1132 assert!(
1135 elapsed < SLEEP * 5 / 2,
1136 "add_replicas took {elapsed:?} — likely serialized (serial bound {}ms)",
1137 (SLEEP * N as u32).as_millis()
1138 );
1139
1140 group.stop().await;
1141 }
1142
1143 #[tokio::test]
1144 async fn add_replicas_propagates_first_failure_and_leaves_group_unchanged() {
1145 let mut group = LifecycleGroup::<CountingDaemon>::spawn(1, [0u8; 32], |_idx| {
1146 Arc::new(CountingDaemon::new())
1147 })
1148 .await
1149 .expect("spawn");
1150
1151 let mut call = 0u8;
1152 let result = group
1153 .add_replicas(3, |_idx| {
1154 let d = Arc::new(CountingDaemon::new());
1155 if call == 1 {
1157 d.fail_start.store(true, Ordering::Release);
1158 }
1159 call += 1;
1160 d
1161 })
1162 .await;
1163 match result {
1164 Ok(_) => panic!("expected StartFailed, got Ok"),
1165 Err(LifecycleGroupError::StartFailed { index, .. }) => {
1166 assert_eq!(index, 2);
1169 }
1170 Err(other) => panic!("expected StartFailed, got {other:?}"),
1171 }
1172 assert_eq!(group.replica_count(), 1);
1174 group.stop().await;
1175 }
1176
1177 #[tokio::test]
1178 async fn add_replica_propagates_on_start_failure() {
1179 let mut group = LifecycleGroup::<CountingDaemon>::spawn(1, [0u8; 32], |_idx| {
1180 Arc::new(CountingDaemon::new())
1181 })
1182 .await
1183 .expect("spawn");
1184 let result = group
1185 .add_replica(|_idx| {
1186 let d = Arc::new(CountingDaemon::new());
1187 d.fail_start.store(true, Ordering::Release);
1188 d
1189 })
1190 .await;
1191 match result {
1192 Ok(_) => panic!("expected StartFailed, got Ok"),
1193 Err(LifecycleGroupError::StartFailed { index, .. }) => {
1194 assert_eq!(index, 1);
1195 }
1196 Err(other) => panic!("expected StartFailed, got {other:?}"),
1197 }
1198 assert_eq!(group.replica_count(), 1);
1200 group.stop().await;
1201 }
1202}