1use 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 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 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 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(_) => {} 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 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}