Skip to main content

meerkat_mobkit/unified_runtime/
mob_ops.rs

1//! Mob member operations on `UnifiedRuntime`.
2//!
3//! Kept intentionally small: a pair of accessors (`mob_handle`, `mob_runtime`)
4//! and hook-aware variants of `spawn` / `spawn_many` (which fire `post_spawn_hook`
5//! and report errors via the shared error hook). All other member-lifecycle
6//! operations — status, discover, reconcile, retire, helpers, etc. — are now
7//! on `MobHandle` directly; callers reach through `runtime.mob_handle()`.
8
9use meerkat_mob::{MobError, MobHandle, SpawnMemberSpec, SpawnResult};
10use std::future::Future;
11
12use crate::mob_handle_runtime::MobRuntimeError;
13
14use super::UnifiedRuntime;
15
16// Upstream routed runtime-ready signals use a bounded actor queue with
17// fail-fast enqueue in meerkat-mob 0.6.x. Keep bulk discovery bootstrap
18// serialized until the upstream signal path is backpressured.
19const MAX_CONCURRENT_SPAWN_MANY: usize = 1;
20
21/// Default ceiling for `mobkit/wait_ready` when the caller omits `timeout_ms`.
22///
23/// meerkat-mob 0.7.9 (#798, reactive-readiness redesign) lowered its own
24/// internal `DEFAULT_READY_WAIT_TIMEOUT` from 600s to 60s; that default is
25/// applied inside `MobHandle::wait_for_ready` whenever the caller passes
26/// `None`. mobkit's SDK contract for `wait_ready` is "wait until the mob is
27/// ready", so a caller that omits a timeout keeps the prior generous ceiling
28/// rather than silently inheriting meerkat's lowered 60s — the reactive wait
29/// still returns promptly when members converge, so this is only the safety
30/// wall for genuinely slow startups. Pass an explicit `timeout_ms` to override.
31pub(crate) const DEFAULT_WAIT_READY_TIMEOUT: std::time::Duration =
32    std::time::Duration::from_mins(10);
33
34/// Whether a `MobHandle::wait_for_ready` error is a readiness-deadline timeout
35/// (which maps to the documented `{ ready: [], timeout: true }` envelope) as
36/// opposed to a genuine failure (which surfaces as an RPC error).
37///
38/// Matches the typed variant rather than the Display string: meerkat-mob's
39/// [`MobError::ReadyWaitTimedOut`] renders as `"member ready wait timed out"`,
40/// which does NOT contain the substring `"timeout"` (only `"timed out"`), so
41/// the previous `message.to_lowercase().contains("timeout")` check always fell
42/// through to the error branch and returned `-32000` instead of the envelope.
43pub(crate) fn is_ready_wait_timeout(err: &MobError) -> bool {
44    matches!(err, MobError::ReadyWaitTimedOut { .. })
45}
46
47impl UnifiedRuntime {
48    pub fn mob_handle(&self) -> MobHandle {
49        self.mob_runtime.handle()
50    }
51
52    /// Access the underlying `MobRuntime` (owns the session service + ephemeral dir).
53    pub fn mob_runtime(&self) -> &crate::mob_handle_runtime::MobRuntime {
54        &self.mob_runtime
55    }
56
57    /// Spawn a member, firing `post_spawn_hook` on success and the shared error
58    /// hook on failure. For raw spawning without hooks, use `mob_handle().spawn_spec(...)`.
59    ///
60    /// The spec's member id is a public alias; it is encoded into the
61    /// comms-safe roster id here (meerkat 0.7 `MemberCommsName` rejects `:`,
62    /// which MobKit's identity-first aliases like `rt:review:singleton:0`
63    /// contain). Hooks and error events keep speaking the alias.
64    pub async fn spawn(&self, mut spec: SpawnMemberSpec) -> Result<SpawnResult, MobRuntimeError> {
65        let member_id = spec.identity.to_string();
66        let profile = spec.role_name.to_string();
67        spec.identity = crate::member_comms_id::mob_member_id(member_id.as_str());
68        match Box::pin(self.mob_handle().spawn_spec(spec)).await {
69            Ok(result) => {
70                if let Some(hook) = &self.post_spawn_hook {
71                    hook(vec![member_id]).await;
72                }
73                Ok(result)
74            }
75            Err(err) => {
76                self.fire_error(super::types::ErrorEvent::SpawnFailure {
77                    member_id,
78                    profile,
79                    error: format!("{err}"),
80                });
81                Err(err.into())
82            }
83        }
84    }
85
86    /// Spawn many members, firing `post_spawn_hook` once on success with all ids.
87    pub async fn spawn_many(
88        &self,
89        mut specs: Vec<SpawnMemberSpec>,
90    ) -> Result<Vec<SpawnResult>, MobRuntimeError> {
91        let member_ids: Vec<String> = specs.iter().map(|s| s.identity.to_string()).collect();
92        // As in `spawn`: wire aliases become comms-safe roster ids.
93        for spec in &mut specs {
94            spec.identity = crate::member_comms_id::mob_member_id(spec.identity.as_str());
95        }
96        let handle = self.mob_handle();
97        let refs = try_join_in_batches(specs, MAX_CONCURRENT_SPAWN_MANY, |spec| {
98            let handle = handle.clone();
99            async move { Box::pin(handle.spawn_spec(spec)).await }
100        })
101        .await
102        .map_err(MobRuntimeError::from)?;
103        if !member_ids.is_empty()
104            && let Some(hook) = &self.post_spawn_hook
105        {
106            hook(member_ids).await;
107        }
108        Ok(refs)
109    }
110}
111
112async fn try_join_in_batches<I, F, T, E, Build>(
113    items: Vec<I>,
114    batch_size: usize,
115    mut build: Build,
116) -> Result<Vec<T>, E>
117where
118    F: Future<Output = Result<T, E>>,
119    Build: FnMut(I) -> F,
120{
121    let batch_size = batch_size.max(1);
122    let mut results = Vec::with_capacity(items.len());
123    let mut iter = items.into_iter();
124
125    loop {
126        let batch: Vec<I> = iter.by_ref().take(batch_size).collect();
127        if batch.is_empty() {
128            break;
129        }
130
131        let futures = batch.into_iter().map(&mut build);
132        let mut batch_results = futures::future::try_join_all(futures).await?;
133        results.append(&mut batch_results);
134        tokio::task::yield_now().await;
135    }
136
137    Ok(results)
138}
139
140#[cfg(test)]
141mod tests {
142    use std::sync::{
143        Arc,
144        atomic::{AtomicUsize, Ordering},
145    };
146
147    use super::{is_ready_wait_timeout, try_join_in_batches};
148    use meerkat_mob::MobError;
149
150    #[tokio::test]
151    async fn spawn_many_batch_size_stays_serial_until_upstream_backpressure_exists() {
152        assert_eq!(super::MAX_CONCURRENT_SPAWN_MANY, 1);
153    }
154
155    #[test]
156    fn ready_wait_timeout_is_classified_as_envelope_not_error() {
157        // Regression (meerkat-mob 0.7.9 #798): the default ready-wait dropped
158        // 600s -> 60s, so `wait_for_ready` hits this timeout far more often.
159        // The prior `message.to_lowercase().contains("timeout")` never matched
160        // the Display "member ready wait timed out", so timeouts wrongly
161        // surfaced as a -32000 RPC error instead of `{ ready: [], timeout:true }`.
162        let timed_out = MobError::ReadyWaitTimedOut {
163            pending_member_ids: vec![],
164        };
165        assert!(is_ready_wait_timeout(&timed_out));
166
167        // Pin the exact failure mode that motivated the typed match.
168        let display = timed_out.to_string().to_lowercase();
169        assert!(
170            !display.contains("timeout"),
171            "old substring check would have missed this timeout"
172        );
173        assert!(display.contains("timed out"));
174
175        // Precision: a *kickoff* timeout also Displays "...timed out" but is not
176        // a readiness timeout — it must NOT be folded into the ready envelope.
177        assert!(!is_ready_wait_timeout(&MobError::KickoffWaitTimedOut {
178            pending_member_ids: vec![],
179        }));
180    }
181
182    #[tokio::test]
183    async fn try_join_in_batches_can_run_serially() {
184        let active = Arc::new(AtomicUsize::new(0));
185        let max_active = Arc::new(AtomicUsize::new(0));
186        let items: Vec<usize> = (0..25).collect();
187
188        let results = try_join_in_batches(items.clone(), 1, |item| {
189            let active = active.clone();
190            let max_active = max_active.clone();
191            async move {
192                let current = active.fetch_add(1, Ordering::SeqCst) + 1;
193                max_active.fetch_max(current, Ordering::SeqCst);
194                tokio::task::yield_now().await;
195                active.fetch_sub(1, Ordering::SeqCst);
196                Ok::<_, ()>(item)
197            }
198        })
199        .await;
200
201        assert_eq!(results, Ok(items));
202        assert_eq!(max_active.load(Ordering::SeqCst), 1);
203    }
204
205    #[tokio::test]
206    async fn try_join_in_batches_limits_concurrent_work_and_preserves_order() {
207        let active = Arc::new(AtomicUsize::new(0));
208        let max_active = Arc::new(AtomicUsize::new(0));
209        let items: Vec<usize> = (0..75).collect();
210
211        let results = try_join_in_batches(items.clone(), 16, |item| {
212            let active = active.clone();
213            let max_active = max_active.clone();
214            async move {
215                let current = active.fetch_add(1, Ordering::SeqCst) + 1;
216                max_active.fetch_max(current, Ordering::SeqCst);
217                tokio::task::yield_now().await;
218                active.fetch_sub(1, Ordering::SeqCst);
219                Ok::<_, ()>(item)
220            }
221        })
222        .await;
223
224        assert_eq!(results, Ok(items));
225        assert!(max_active.load(Ordering::SeqCst) <= 16);
226    }
227
228    #[tokio::test]
229    async fn try_join_in_batches_stops_before_starting_later_batches_after_error() {
230        let started = Arc::new(AtomicUsize::new(0));
231        let items: Vec<usize> = (0..40).collect();
232
233        let result = try_join_in_batches(items, 16, |item| {
234            let started = started.clone();
235            async move {
236                started.fetch_add(1, Ordering::SeqCst);
237                tokio::task::yield_now().await;
238                if item == 20 { Err(item) } else { Ok(item) }
239            }
240        })
241        .await;
242
243        assert_eq!(result, Err(20));
244        assert_eq!(started.load(Ordering::SeqCst), 32);
245    }
246}