Skip to main content

net/adapter/net/behavior/lifecycle/
group.rs

1//! [`LifecycleGroup`] — N interchangeable replicas of an
2//! `L: LifecycleDaemon`, managed as a unit via
3//! [`LifecycleHandle`]s and a shared deterministic identity
4//! seed.
5//!
6//! Parallel to
7//! [`ReplicaGroup`](crate::adapter::net::compute::replica_group::ReplicaGroup)
8//! (which targets sync [`MeshDaemon`](crate::adapter::net::compute::MeshDaemon)s).
9//! Direction B of `docs/plans/AGGREGATOR_LIFECYCLE_DEFERRED_2026_05_23.md`:
10//! we keep `LifecycleDaemon` separate from `MeshDaemon` and
11//! share the underlying placement / capability primitives
12//! rather than the trait. Layered slices add:
13//!
14//! - Step 2 (this file later): per-daemon `requirements()` →
15//!   `Scheduler::place_with_spread` integration for cross-node
16//!   placement.
17//! - Step 3 (`MeshNode::aggregator_registry`): process-level
18//!   registry of live groups for operator CLI / Deck.
19//! - Step 4 (this file later): per-replica health snapshot +
20//!   auto-replace via factory respawn at the same index.
21//!
22//! # Shape
23//!
24//! - [`LifecycleGroup::spawn`] — accept a `replica_count`, a
25//!   32-byte `group_seed`, and a factory that produces an
26//!   `Arc<L>` per replica index. Each daemon is wrapped in a
27//!   [`LifecycleHandle`] (which runs `on_start` synchronously).
28//!   Errors surface per-replica via [`LifecycleGroupError`];
29//!   partially-started handles drop cleanly via their RAII
30//!   `Drop` impl.
31//! - [`LifecycleGroup::stop`] — `stop()` each handle in order
32//!   and await teardown.
33//! - [`LifecycleGroup::replica_keypair`] — derive the
34//!   deterministic per-replica `EntityKeypair` via
35//!   [`derive_replica_keypair`]. The group itself doesn't
36//!   currently install these into each `MeshNode` (single-mesh
37//!   deployments share one identity); the accessor exists so
38//!   future cross-node placement can read the same derivation
39//!   `ReplicaGroup` uses.
40
41use 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/// Group-spawn failure shape. Distinguishes config-time errors
52/// (rejected on the caller side before any on_start fires) from
53/// per-replica `on_start` failures (carry the failing index for
54/// operator diagnosis) and from placement failures (no candidate
55/// node satisfied the daemon's capability requirements + spread
56/// constraint).
57#[derive(Debug)]
58pub enum LifecycleGroupError {
59    /// `replica_count == 0` or other up-front validation
60    /// rejected the spawn.
61    InvalidConfig(String),
62    /// A specific replica's `on_start` failed. The other
63    /// already-started replicas drop cleanly via their handles'
64    /// `Drop` impl when the partially-built group goes out of
65    /// scope.
66    StartFailed {
67        /// Index of the replica whose `on_start` failed.
68        index: u8,
69        /// The underlying lifecycle error.
70        error: LifecycleError,
71    },
72    /// `Scheduler::place_with_spread` could not find a candidate
73    /// node satisfying the daemon's `CapabilityFilter` outside
74    /// the already-used set (spread invariant).
75    PlacementFailed {
76        /// Index of the replica that could not be placed.
77        index: u8,
78        /// Operator-facing diagnostic string.
79        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/// Per-replica context the factory receives during
100/// [`LifecycleGroup::spawn_with_placement`]. Carries the
101/// replica index + the scheduler's placement decision so
102/// factories that need to bind a daemon to a specific node can.
103/// Factories that don't care about placement can ignore the
104/// `placement` field (or use the simpler
105/// [`LifecycleGroup::spawn`] which doesn't run placement at
106/// all).
107#[derive(Debug, Clone)]
108pub struct ReplicaContext {
109    /// Replica index in `0..replica_count`.
110    pub index: u8,
111    /// Placement decision from the scheduler. `None` only on
112    /// the placement-free [`LifecycleGroup::spawn`] path —
113    /// always `Some` under `spawn_with_placement`.
114    pub placement: Option<PlacementDecision>,
115}
116
117/// N interchangeable replicas of a single `LifecycleDaemon` type
118/// with a shared `group_seed` for deterministic identity
119/// derivation.
120///
121/// `L` is the concrete daemon type — generic so callers retain
122/// typed access to each replica's state without dyn-erasure.
123pub struct LifecycleGroup<L: LifecycleDaemon> {
124    handles: Vec<LifecycleHandle>,
125    /// Concrete-typed Arcs to each replica, in declaration
126    /// order. Mirrors `handles` 1-to-1; lets callers read
127    /// daemon state without going through the type-erased
128    /// `LifecycleHandle::daemon()`.
129    replicas: Vec<Arc<L>>,
130    /// Per-replica placement decisions in declaration order.
131    /// Populated by `spawn_with_placement`; left empty by the
132    /// placement-free `spawn`. The accessor [`Self::placement`]
133    /// returns `Some` only when this Vec is non-empty.
134    placements: Vec<PlacementDecision>,
135    group_seed: [u8; 32],
136}
137
138impl<L: LifecycleDaemon> LifecycleGroup<L> {
139    /// Spawn `replica_count` replicas of `L`. The factory is
140    /// called once per index `0..replica_count` and must return
141    /// a fully-configured `Arc<L>` — the group wraps each in a
142    /// [`LifecycleHandle`] (which runs `on_start` synchronously).
143    ///
144    /// Starts run **concurrently** via `try_join_all`. If any
145    /// `on_start` fails, every other in-flight start cancels and
146    /// the partially-started replicas drop their handles cleanly
147    /// via `Drop` (which schedules `on_stop` on a detached
148    /// task).
149    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    /// Spawn `replica_count` replicas with cross-node placement
172    /// via [`Scheduler::place`] /
173    /// [`GroupCoordinator::place_with_spread`].
174    ///
175    /// Differences from [`Self::spawn`]:
176    /// - Caller supplies a `Scheduler` + a `CapabilityFilter`
177    ///   the scheduler uses to find candidate nodes for each
178    ///   replica.
179    /// - Replicas are spread across distinct nodes (spread
180    ///   invariant) — failing if fewer than `replica_count`
181    ///   candidates match the filter.
182    /// - The factory receives a [`ReplicaContext`] carrying the
183    ///   placement decision so daemons that bind to a specific
184    ///   node can read it.
185    ///
186    /// Daemon construction happens **after** placement so a
187    /// factory can use `ctx.placement.node_id` to configure the
188    /// daemon for its target node. The placement decision is
189    /// recorded on the group for inspection.
190    ///
191    /// # Note on single-process semantics
192    ///
193    /// In a single-process deployment the scheduler may pick
194    /// the local node for every replica — `place_with_spread`
195    /// errors with `PlacementFailed` when fewer candidate nodes
196    /// than replicas match the filter. The group does not
197    /// actually move daemons across nodes; that is the
198    /// substrate's remote-spawn responsibility, not the group
199    /// helper's. Recording the placement decisions here lets a
200    /// future cross-node integration consume them without
201    /// re-deriving them.
202    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        // Walk placements first so factory invocations see a
218        // populated `ReplicaContext`. Spread invariant: each
219        // placement excludes prior ones.
220        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    /// Number of live replicas managed by the group.
254    pub fn replica_count(&self) -> usize {
255        self.handles.len()
256    }
257
258    /// The 32-byte seed used to derive per-replica identities.
259    pub fn group_seed(&self) -> &[u8; 32] {
260        &self.group_seed
261    }
262
263    /// Derive the deterministic per-replica keypair for `index`.
264    /// Same derivation
265    /// [`ReplicaGroup`](crate::adapter::net::compute::replica_group::ReplicaGroup)
266    /// uses for sync MeshDaemon replicas — so a future
267    /// cross-node lifecycle-daemon deployment can reuse this id.
268    pub fn replica_keypair(&self, index: u8) -> EntityKeypair {
269        derive_replica_keypair(&self.group_seed, index)
270    }
271
272    /// Concrete, typed access to each replica's daemon. Mirrors
273    /// `replicas[index].clone()` — preserves the underlying
274    /// `L`'s state surface so callers don't have to downcast
275    /// from a trait object.
276    pub fn replica(&self, index: usize) -> Option<Arc<L>> {
277        self.replicas.get(index).cloned()
278    }
279
280    /// All replicas in declaration order. Cheap O(n) Arc clones.
281    pub fn replicas(&self) -> Vec<Arc<L>> {
282        self.replicas.clone()
283    }
284
285    /// Placement decision recorded for `index`, or `None` when
286    /// the group was created via the placement-free
287    /// [`Self::spawn`].
288    pub fn placement(&self, index: usize) -> Option<&PlacementDecision> {
289        self.placements.get(index)
290    }
291
292    /// All recorded placement decisions in declaration order.
293    /// Empty when the group was created via the placement-free
294    /// [`Self::spawn`].
295    pub fn placements(&self) -> &[PlacementDecision] {
296        &self.placements
297    }
298
299    /// Per-replica health snapshot in declaration order.
300    /// Polls each replica's
301    /// [`LifecycleDaemon::health`] in parallel via
302    /// `join_all` — the typical impl is cheap (atomic load or
303    /// short-RwLock read), so the parallelism is mostly future-
304    /// proofing for impls that need to await a lock.
305    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    /// Replace the daemon at `index` with `new_daemon`. The old
314    /// handle is stopped + awaited before the new one is
315    /// installed, so the slot is briefly empty during the
316    /// transition. Returns the stopped handle's underlying
317    /// daemon Arc — callers wanting to inspect the old state
318    /// (e.g. for forensics on what caused the unhealthy flip)
319    /// can hold onto it.
320    ///
321    /// Errors:
322    /// - `InvalidConfig` if `index >= replica_count`.
323    /// - `StartFailed { index, error }` if the new handle's
324    ///   `on_start` fails. The slot is left empty in this case
325    ///   — caller must retry or shrink the group.
326    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        // Drain the old handle out of the Vec and stop it. Use
338        // `Vec::remove` shift-cost is bounded by `replica_count`
339        // which is u8-bounded; cheap.
340        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        // Start the replacement.
345        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                // Slot is now empty for handles but replicas Vec
350                // still has the new Arc. Leave it that way and
351                // surface the error — `health()` will report
352                // unhealthy for the missing-handle index.
353                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    /// Append one replica to the group, growing it in place. The
364    /// factory receives the new replica's index (= current
365    /// `replica_count`). Existing replicas keep their identities
366    /// and their handles — neither stops nor restarts. This is the
367    /// scale-up primitive for
368    /// [`crate::adapter::net::behavior::aggregator::AggregatorRegistry::scale_group`]
369    /// and the `Scale` RPC.
370    ///
371    /// Errors:
372    /// - `InvalidConfig` when `replica_count == u8::MAX`
373    ///   (group-size hard cap; the index field is `u8`).
374    /// - `StartFailed { index, error }` when the new handle's
375    ///   `on_start` fails. The factory's `Arc<L>` is dropped
376    ///   before the error returns, so no zombie replica leaks.
377    ///
378    /// # Placement
379    ///
380    /// `add_replica` does **not** engage the scheduler. A group
381    /// originally created via [`Self::spawn_with_placement`] still
382    /// has its placement Vec — the new replica gets no placement
383    /// entry and runs on the local node. Operators who need
384    /// placement-aware scale-up wait for a future
385    /// `add_replica_with_placement` sibling; the single-process /
386    /// single-host deployment shipping today doesn't engage that
387    /// surface.
388    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        // u8 cast is safe by the guard above (len < 255).
399        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    /// Bulk version of [`Self::add_replica`]. Constructs `count`
414    /// new daemons via the factory, then runs their `on_start`
415    /// handlers **concurrently** via `try_join_all` (same shape
416    /// as the initial-spawn path in `start_replicas`). If any
417    /// `on_start` fails, every successfully-started replica's
418    /// handle is dropped — its `LifecycleHandle::Drop` schedules
419    /// `on_stop` on a detached task, so partial-start cleanup is
420    /// automatic. The group itself stays at its pre-call size on
421    /// error.
422    ///
423    /// Used by [`super::super::aggregator::AggregatorRegistry::scale_group`]
424    /// so a 1→N grow doesn't serialize N `on_start`s under the
425    /// entry mutex (which would block `List` / `health` /
426    /// `HealthMonitor` for the duration).
427    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        // Pre-allocate everything synchronously so the FnMut
447        // closure runs serially (factory is operator-defined; we
448        // don't get to thread-balance it). The starts get
449        // collected as futures and awaited concurrently.
450        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        // Await every on_start concurrently. join_all preserves
461        // order so we can map the index back when any fails.
462        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 everything started so far — their RAII
474                    // `LifecycleHandle::Drop` schedules `on_stop`.
475                    // Drop `new_daemons` too so we don't leak the
476                    // Arc<L>s that never made it to the group.
477                    drop(handles);
478                    drop(new_daemons);
479                    return Err(LifecycleGroupError::StartFailed { index: idx, error });
480                }
481            }
482        }
483        // All starts succeeded — commit the daemons + handles.
484        self.replicas.extend(new_daemons);
485        self.handles.extend(handles);
486        Ok(())
487    }
488
489    /// Stop and pop the last replica. Returns the stopped
490    /// replica's Arc so callers can inspect post-stop state (e.g.
491    /// for forensic logging). The other replicas' handles are
492    /// untouched — neither stopped nor signalled — preserving
493    /// their identity, generation counters, and any in-memory
494    /// state.
495    ///
496    /// Refuses to drop below one replica: callers that want to
497    /// dismantle the whole group should call [`Self::stop`]
498    /// instead. Returning an error rather than completing as a
499    /// no-op surfaces the typo at the caller (e.g. operator who
500    /// meant `--replica-count 1` and wrote `--replica-count 0`).
501    ///
502    /// If the group was created via
503    /// [`Self::spawn_with_placement`], the last placement entry
504    /// is also popped so the parallel-Vec invariant
505    /// (`placements.len() == replicas.len()` when populated) is
506    /// preserved.
507    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        // `expect_used` lint guard: the `len <= 1` check above
516        // guarantees both pops succeed; suppress lint locally
517        // rather than fall back to `unwrap_or_else` panic shapes
518        // that would obscure the invariant.
519        #[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            // Parallel-Vec invariant: when placements is
532            // populated, it tracks replicas 1-to-1. Pop the last
533            // so a subsequent `placement(replicas.len()-1)` still
534            // resolves.
535            self.placements.pop();
536        }
537        Ok(replica)
538    }
539
540    /// Borrow the underlying lifecycle handles. Operator
541    /// tooling that wants type-erased access (e.g. iterating
542    /// `daemon().name()` across heterogeneous groups in a
543    /// future registry) reaches in here.
544    pub fn handles(&self) -> &[LifecycleHandle] {
545        &self.handles
546    }
547
548    /// Stop every replica in declaration order and await the
549    /// teardown. Consumes the group.
550    pub async fn stop(self) {
551        for h in self.handles {
552            h.stop().await;
553        }
554    }
555
556    /// Surrender the group's parts to the caller. Used by
557    /// process-level registries (e.g.
558    /// `AggregatorRegistry::register`) that take ownership of
559    /// the handles for shutdown but still want concrete-typed
560    /// access to the replicas + placement records.
561    ///
562    /// Returns `(replicas, placements, handles, group_seed)` in
563    /// declaration order. After this call the group no longer
564    /// exists; lifecycle shutdown becomes the caller's
565    /// responsibility (via the returned `LifecycleHandle`s).
566    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
583/// Shared spawn helper: invoke `factory(index)` for each
584/// replica, wrap each in a `LifecycleHandle` concurrently via
585/// `join_all`, and return the parallel `(replicas, handles)`
586/// Vecs in declaration order. Partial failure drops the
587/// already-collected Arcs cleanly — each handle's RAII Drop
588/// schedules `on_stop`.
589async 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    /// Bare-minimum LifecycleDaemon for group testing — no
632    /// background work, just bumps a counter on each lifecycle
633    /// callback so tests can pin start/stop semantics without
634    /// pulling in the aggregator stack.
635    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        // Typed access to each replica.
708        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                // Factory observes the placement decision for
777                // its index — record it for assertion.
778                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        // Spread invariant: three replicas → three distinct nodes.
791        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        // Two candidate nodes, three replicas requested — spread
807        // invariant rejects the third.
808        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    /// Daemon variant that reports unhealthy when `force_unhealthy`
827    /// is set — lets tests pin LifecycleGroup::health snapshot
828    /// + replace() behavior without dragging in AggregatorDaemon.
829    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        // All three healthy initially.
880        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        // Flip replica 1 to unhealthy.
888        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        // Build a replacement.
917        let replacement = Arc::new(HealthControlDaemon::new());
918        let returned = group
919            .replace(1, replacement.clone())
920            .await
921            .expect("replace");
922        // The returned Arc is the original replica.
923        assert!(Arc::ptr_eq(&returned, &original_idx_1));
924        // The old daemon was stopped.
925        assert_eq!(original_idx_1.stops.load(Ordering::Acquire), 1);
926        // The replacement was started.
927        assert_eq!(replacement.starts.load(Ordering::Acquire), 1);
928        // The group's typed accessor reflects the swap.
929        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        // The replacement's on_stop fires on group.stop.
934        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        // Placement-free path returns no recorded placements;
958        // `placement(0)` is None.
959        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        // Existing replicas each ran on_start exactly once.
1004        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        // Critical: existing replicas did NOT restart — their
1018        // start counters stay at 1 (no respawn).
1019        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        // Returned Arc is the original index-2 replica.
1051        let last_original = daemons.lock()[2].clone();
1052        assert!(Arc::ptr_eq(&removed, &last_original));
1053        // The dropped replica's stop counter incremented exactly once.
1054        assert_eq!(removed.stops.load(Ordering::Acquire), 1);
1055        // Indices 0 and 1 untouched. Scope the guard so clippy's
1056        // `await_holding_lock` lint sees the explicit lifetime
1057        // bound — an `drop(kept)` works at runtime but clippy
1058        // doesn't always recognize it.
1059        {
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        // Replica still there, can still stop the group cleanly.
1083        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        // Each daemon's on_start sleeps for SLEEP; if `add_replicas`
1091        // serialized them, total wall-clock would be N×SLEEP. With
1092        // try_join_all the bound is ~1×SLEEP (plus scheduling).
1093        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        // Serial bound is N×SLEEP. Allow a generous margin
1133        // (2.5×SLEEP) for CI scheduling noise.
1134        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                // Second of the three new replicas fails on_start.
1156                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                // 0-indexed; the original replica occupies idx 0,
1167                // so the failing slot is index 1+1 = 2.
1168                assert_eq!(index, 2);
1169            }
1170            Err(other) => panic!("expected StartFailed, got {other:?}"),
1171        }
1172        // Group stayed at its pre-call size — no zombie additions.
1173        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        // Group still has the original replica; no zombie added.
1199        assert_eq!(group.replica_count(), 1);
1200        group.stop().await;
1201    }
1202}