Skip to main content

mj_controller/pollers/
native_agents.rs

1//! Native transcript loading never delays the shared runtime subscription.
2use super::*;
3use mj_core::native_agent::{NativeAgentHistoryPage, NativeAgentSummary, NativeAgentView};
4use mj_core::snapshot_map::SnapshotMap;
5use std::collections::BTreeSet;
6
7#[derive(Debug, Clone, Default, PartialEq, Eq)]
8pub struct RuntimeFeedHealth {
9    pub refresh_error: Option<String>,
10    pub native_error: Option<String>,
11}
12
13impl RuntimeFeedHealth {
14    pub fn error(&self) -> Option<String> {
15        self.refresh_error
16            .clone()
17            .or_else(|| self.native_error.clone())
18    }
19}
20
21#[derive(Default)]
22pub(super) struct NativeAgentLoader {
23    desired: SnapshotMap<String, NativeAgentSummary>,
24    views: SnapshotMap<String, NativeAgentView>,
25    failures: BTreeMap<String, (NativeAgentSummary, String)>,
26    retry: BTreeSet<String>,
27    active: BTreeSet<String>,
28    pending: BTreeSet<String>,
29    tasks: tokio::task::JoinSet<(String, NativeAgentSummary, Result<NativeAgentView>)>,
30}
31
32impl NativeAgentLoader {
33    #[cfg(test)]
34    pub fn update(&mut self, summaries: Vec<NativeAgentSummary>) {
35        self.update_snapshot(
36            summaries
37                .into_iter()
38                .map(|summary| (summary.agent.view_id(), summary))
39                .collect(),
40        );
41    }
42
43    pub fn update_snapshot(&mut self, summaries: SnapshotMap<String, NativeAgentSummary>) {
44        for (id, summary) in self.desired.changes(&summaries) {
45            if summary.is_some_and(|summary| {
46                self.views
47                    .get(id)
48                    .is_none_or(|view| !summary.is_satisfied_by(view))
49            }) {
50                self.pending.insert(id.clone());
51            } else {
52                self.pending.remove(id);
53            }
54            if summary.is_none_or(|summary| {
55                self.views
56                    .get(id)
57                    .is_some_and(|view| summary.generation_ordinal != view.generation_ordinal)
58            }) {
59                self.views.remove(id);
60            }
61            self.failures.remove(id);
62        }
63        self.desired = summaries;
64        // A subsequent successful poll gives transient read failures another try.
65        self.failures
66            .retain(|id, (summary, _)| self.desired.get(id) == Some(summary));
67        self.retry = self.failures.keys().cloned().collect();
68    }
69
70    #[cfg(test)]
71    pub fn views(&self) -> Vec<NativeAgentView> {
72        self.views.values().cloned().collect()
73    }
74
75    pub fn views_snapshot(&self) -> SnapshotMap<String, NativeAgentView> {
76        self.views.clone()
77    }
78
79    pub fn error(&self) -> Option<String> {
80        self.failures
81            .values()
82            .next()
83            .map(|(_, error)| error.clone())
84    }
85
86    fn pending(&self) -> Option<(String, NativeAgentSummary)> {
87        self.pending
88            .iter()
89            .filter_map(|id| self.desired.get(id).map(|summary| (id, summary)))
90            .find(|(id, summary)| {
91                !self.active.contains(*id)
92                    && (!self.failures.contains_key(*id) || self.retry.contains(*id))
93                    && self
94                        .views
95                        .get(*id)
96                        .is_none_or(|view| !summary.is_satisfied_by(view))
97            })
98            .map(|(id, summary)| (id.clone(), summary.clone()))
99    }
100
101    pub fn has_work(&self) -> bool {
102        !self.tasks.is_empty() || self.pending().is_some()
103    }
104
105    pub async fn next(&mut self) {
106        self.next_with(load_native_projection).await;
107    }
108
109    async fn next_with<L, F>(&mut self, load: L)
110    where
111        L: Fn(NativeAgentSummary) -> F + Clone + Send + 'static,
112        F: Future<Output = Result<NativeAgentView>> + Send + 'static,
113    {
114        while self.tasks.len() < 4 {
115            let Some((id, summary)) = self.pending() else {
116                break;
117            };
118            self.active.insert(id.clone());
119            self.retry.remove(&id);
120            let load = load.clone();
121            self.tasks.spawn(async move {
122                let result = load(summary.clone()).await;
123                (id, summary, result)
124            });
125        }
126        let Some(completed) = self.tasks.join_next().await else {
127            return;
128        };
129        let (id, requested, result) = match completed {
130            Ok(completed) => completed,
131            Err(error) => {
132                // A panic is reported for every affected read and retried on the next poll.
133                tracing::warn!(%error, "native projection task failed");
134                self.tasks = tokio::task::JoinSet::new();
135                for id in std::mem::take(&mut self.active) {
136                    if let Some(summary) = self.desired.get(&id) {
137                        self.failures.insert(
138                            id,
139                            (
140                                summary.clone(),
141                                format!("Native agent reader failed: {error}"),
142                            ),
143                        );
144                    }
145                }
146                return;
147            }
148        };
149        self.active.remove(&id);
150        let Some(desired) = self.desired.get(&id) else {
151            return;
152        };
153        // Old work must never resurrect a deleted child or an earlier replay.
154        if desired.generation_ordinal != requested.generation_ordinal {
155            return;
156        }
157        match result {
158            Ok(view) if desired.is_satisfied_by(&view) => {
159                self.failures.remove(&id);
160                self.pending.remove(&id);
161                self.views.insert(id, view);
162            }
163            Ok(_) => {} // The next read follows the newer published fingerprint.
164            Err(error) if desired == &requested => {
165                let message = format!(
166                    "Could not refresh native agent {}: {error:#}",
167                    desired.agent.name
168                );
169                tracing::warn!(%id, %message);
170                self.failures.insert(id, (requested, message));
171            }
172            Err(error) => {
173                tracing::debug!(%id, %error, "newer native publication superseded a failed read");
174            }
175        }
176    }
177}
178
179async fn load_native_projection(summary: NativeAgentSummary) -> Result<NativeAgentView> {
180    converge_native_projection(&summary, || {
181        let owner = summary.agent.owner_session_id.clone();
182        let child = summary.agent.session_id.clone();
183        native_read(move || crate::database::load_native_agent_view(&owner, &child, 200))
184    })
185    .await
186}
187
188async fn converge_native_projection<F, Fut>(
189    summary: &NativeAgentSummary,
190    mut read: F,
191) -> Result<NativeAgentView>
192where
193    F: FnMut() -> Fut,
194    Fut: Future<Output = Result<Option<NativeAgentView>>>,
195{
196    for attempt in 0..=runtime_feed::PROJECTION_CONVERGENCE_RETRIES {
197        let view = read().await?;
198        if let Some(view) = view
199            && summary.is_satisfied_by(&view)
200        {
201            return Ok(view);
202        }
203        if attempt < runtime_feed::PROJECTION_CONVERGENCE_RETRIES {
204            tokio::time::sleep(runtime_feed::PROJECTION_CONVERGENCE_RETRY_DELAY).await;
205        }
206    }
207    bail!(
208        "stored projection did not converge to generation {} at event {}",
209        summary.generation_ordinal,
210        summary.projection_ordinal
211    )
212}
213
214async fn native_read<T: Send + 'static>(
215    read: impl FnOnce() -> Result<T> + Send + 'static,
216) -> Result<T> {
217    let permit = Arc::clone(&runtime_feed::PROJECTION_READERS)
218        .acquire_owned()
219        .await
220        .context("native projection readers stopped")?;
221    tokio::task::spawn_blocking(move || {
222        let _permit = permit;
223        let result = read();
224        if let Err(error) = &result {
225            tracing::warn!(%error, "native projection read failed");
226        }
227        result
228    })
229    .await
230    .context("native projection reader panicked")?
231}
232
233pub async fn load_native_agent_history(
234    owner: String,
235    child: String,
236    before: Option<(u64, String)>,
237) -> Result<NativeAgentHistoryPage> {
238    native_read(move || crate::database::native_agent_history(&owner, &child, before)).await
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use mj_core::native_agent::{NativeAgent, NativeAgentCapabilities, NativeAgentState};
245    use std::sync::atomic::AtomicUsize;
246
247    fn view(child: &str, generation: u64, ordinal: u64) -> NativeAgentView {
248        let agent = NativeAgent {
249            availability: Default::default(),
250            availability_reason: None,
251            stable_id: None,
252            owner_session_id: "owner".into(),
253            session_id: child.into(),
254            parent_session_id: None,
255            name: child.into(),
256            task: "Inspect".into(),
257            capabilities: NativeAgentCapabilities::default(),
258            state: NativeAgentState::Running,
259        };
260        let mut projection = MaterializedSession::empty(agent.view_id());
261        projection.applied_event_ordinal = ordinal;
262        projection.applied_event_digest = format!("digest-{ordinal}");
263        NativeAgentView {
264            generation_ordinal: generation,
265            agent,
266            projection,
267        }
268    }
269
270    #[tokio::test]
271    async fn unchanged_views_are_cached_and_changed_or_replayed_views_reload() {
272        let mut loader = NativeAgentLoader::default();
273        let original = view("child", 1, 2);
274        loader.update(vec![NativeAgentSummary::of(&original)]);
275        let answer = original.clone();
276        loader
277            .next_with(move |_| {
278                let answer = answer.clone();
279                async { Ok(answer) }
280            })
281            .await;
282        assert_eq!(loader.views(), vec![original.clone()]);
283        loader.update(vec![NativeAgentSummary::of(&original)]);
284        assert!(!loader.has_work());
285
286        let changed = view("child", 1, 3);
287        loader.update(vec![NativeAgentSummary::of(&changed)]);
288        let answer = changed.clone();
289        loader
290            .next_with(move |_| {
291                let answer = answer.clone();
292                async { Ok(answer) }
293            })
294            .await;
295        assert_eq!(loader.views(), vec![changed]);
296
297        let replayed = view("child", 4, 5);
298        loader.update(vec![NativeAgentSummary::of(&replayed)]);
299        assert!(loader.views().is_empty());
300        let answer = replayed.clone();
301        loader
302            .next_with(move |_| {
303                let answer = answer.clone();
304                async { Ok(answer) }
305            })
306            .await;
307        assert_eq!(loader.views(), vec![replayed]);
308        loader.update(Vec::new());
309        assert!(loader.views().is_empty());
310        assert!(!loader.has_work());
311    }
312
313    #[tokio::test]
314    async fn failed_reads_keep_last_good_view_and_recover_without_hiding_the_error_early() {
315        let mut loader = NativeAgentLoader::default();
316        let original = view("child", 1, 2);
317        loader.update(vec![NativeAgentSummary::of(&original)]);
318        let answer = original.clone();
319        loader
320            .next_with(move |_| {
321                let answer = answer.clone();
322                async { Ok(answer) }
323            })
324            .await;
325        let changed = view("child", 1, 3);
326        loader.update(vec![NativeAgentSummary::of(&changed)]);
327        loader.next_with(|_| async { bail!("read failed") }).await;
328        assert_eq!(loader.views(), vec![original]);
329        assert!(loader.error().unwrap().contains("read failed"));
330        assert!(!loader.has_work());
331        loader.update(vec![NativeAgentSummary::of(&changed)]);
332        assert!(loader.error().is_some());
333        let answer = changed.clone();
334        loader
335            .next_with(move |_| {
336                let answer = answer.clone();
337                async { Ok(answer) }
338            })
339            .await;
340        assert!(loader.error().is_none());
341        assert_eq!(loader.views(), vec![changed]);
342    }
343
344    #[tokio::test]
345    async fn slow_reads_are_bounded_and_cannot_resurrect_removed_children() {
346        let mut loader = NativeAgentLoader::default();
347        loader.update(
348            (0..6)
349                .map(|id| NativeAgentSummary::of(&view(&id.to_string(), 1, 2)))
350                .collect(),
351        );
352        let started = Arc::new(AtomicUsize::new(0));
353        let release = Arc::new(tokio::sync::Semaphore::new(0));
354        let load = {
355            let started = started.clone();
356            let release = release.clone();
357            move |summary: NativeAgentSummary| {
358                let started = started.clone();
359                let release = release.clone();
360                async move {
361                    started.fetch_add(1, Ordering::SeqCst);
362                    release.acquire().await.unwrap().forget();
363                    Ok(view(&summary.agent.session_id, 1, 2))
364                }
365            }
366        };
367        // Cancelling the select arm does not cancel or duplicate its owned readers.
368        assert!(
369            tokio::time::timeout(Duration::from_millis(50), loader.next_with(load.clone()))
370                .await
371                .is_err()
372        );
373        assert_eq!(started.load(Ordering::SeqCst), 4);
374        loader.update(Vec::new());
375        release.add_permits(4);
376        while loader.has_work() {
377            loader.next_with(load.clone()).await;
378        }
379        assert!(loader.views().is_empty());
380        assert_eq!(started.load(Ordering::SeqCst), 4);
381    }
382
383    #[tokio::test]
384    async fn a_slow_child_does_not_delay_a_ready_sibling() {
385        let mut loader = NativeAgentLoader::default();
386        loader.update(vec![
387            NativeAgentSummary::of(&view("slow", 1, 2)),
388            NativeAgentSummary::of(&view("ready", 1, 2)),
389        ]);
390        tokio::time::timeout(
391            Duration::from_secs(1),
392            loader.next_with(|summary| async move {
393                if summary.agent.session_id == "slow" {
394                    std::future::pending::<()>().await;
395                }
396                Ok(view(&summary.agent.session_id, 1, 2))
397            }),
398        )
399        .await
400        .unwrap();
401        assert_eq!(loader.views()[0].agent.session_id, "ready");
402    }
403
404    #[tokio::test]
405    async fn read_panics_are_reported_and_retryable() {
406        let mut loader = NativeAgentLoader::default();
407        let original = view("child", 1, 2);
408        loader.update(vec![NativeAgentSummary::of(&original)]);
409        loader
410            .next_with(|_| async {
411                panic!("reader panic");
412                #[allow(unreachable_code)]
413                Ok(view("child", 1, 2))
414            })
415            .await;
416        assert!(loader.error().unwrap().contains("reader panic"));
417        assert!(!loader.has_work());
418        loader.update(vec![NativeAgentSummary::of(&original)]);
419        loader
420            .next_with(|_| async { Ok(view("child", 1, 2)) })
421            .await;
422        assert!(loader.error().is_none());
423    }
424    #[tokio::test(start_paused = true)]
425    async fn lagging_projection_retries_but_persistent_mismatch_is_reported() {
426        let expected = NativeAgentSummary::of(&view("child", 1, 3));
427        let mut reads = 0;
428        let result = converge_native_projection(&expected, || {
429            reads += 1;
430            let ordinal = if reads == 1 { 2 } else { 3 };
431            async move { Ok(Some(view("child", 1, ordinal))) }
432        })
433        .await
434        .unwrap();
435        assert_eq!(result.projection.applied_event_ordinal, 3);
436        assert_eq!(reads, 2);
437        let error =
438            converge_native_projection(&expected, || async { Ok(Some(view("child", 1, 2))) })
439                .await
440                .unwrap_err();
441        assert!(error.to_string().contains("did not converge"), "{error}");
442        let newer =
443            converge_native_projection(&expected, || async { Ok(Some(view("child", 1, 4))) })
444                .await
445                .unwrap();
446        assert_eq!(newer.projection.applied_event_ordinal, 4);
447    }
448
449    #[tokio::test]
450    async fn replay_during_an_inflight_read_discards_the_old_generation() {
451        let mut loader = NativeAgentLoader::default();
452        loader.update(vec![NativeAgentSummary::of(&view("child", 1, 2))]);
453        let release = Arc::new(tokio::sync::Semaphore::new(0));
454        let load = {
455            let release = release.clone();
456            move |_: NativeAgentSummary| {
457                let release = release.clone();
458                async move {
459                    release.acquire().await.unwrap().forget();
460                    Ok(view("child", 1, 2))
461                }
462            }
463        };
464        assert!(
465            tokio::time::timeout(Duration::from_millis(20), loader.next_with(load.clone()))
466                .await
467                .is_err()
468        );
469        loader.update(vec![NativeAgentSummary::of(&view("child", 4, 5))]);
470        release.add_permits(1);
471        loader.next_with(load).await;
472        assert!(loader.views().is_empty());
473        loader
474            .next_with(|_| async { Ok(view("child", 4, 5)) })
475            .await;
476        assert_eq!(loader.views()[0].generation_ordinal, 4);
477    }
478}