moonpool_transport/rpc/failure_monitor.rs
1//! `FailureMonitor`: Reactive failure tracking for addresses and endpoints.
2//!
3//! Tracks two levels of failure:
4//! - **Address-level**: Is a remote machine reachable? (Missing = Failed)
5//! - **Endpoint-level**: Is a specific endpoint permanently dead?
6//!
7//! Producers (`connection_task`) call [`FailureMonitor::set_status`] and
8//! [`FailureMonitor::notify_disconnect`]. Consumers (delivery mode functions)
9//! poll [`FailureMonitor::on_disconnect_or_failure`] to race replies against
10//! disconnect signals.
11//!
12//! # FDB Reference
13//! `SimpleFailureMonitor` from `FailureMonitor.h:146`, `FailureMonitor.actor.cpp`
14
15use std::collections::{BTreeMap, BTreeSet};
16use std::future::Future;
17use std::pin::Pin;
18use std::sync::Arc;
19use std::sync::RwLock;
20use std::task::{Poll, Waker};
21use std::time::Duration;
22
23use moonpool_core::TimeProvider;
24
25use crate::Endpoint;
26
27/// Type-erased sleep closure capturing a concrete [`TimeProvider`].
28///
29/// Keeps [`FailureMonitor`] non-generic while still able to call into the
30/// active time provider (real or simulated).
31type SleepFn = Box<dyn Fn(Duration) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
32
33/// Maximum number of permanently failed endpoints before clearing the map.
34///
35/// Matches FDB: `failedEndpoints.size() > 100000` triggers clear.
36const MAX_FAILED_ENDPOINTS: usize = 100_000;
37
38/// Status of a network address or endpoint.
39///
40/// # FDB Reference
41/// `FailureStatus` from `FailureMonitor.h:34-60`
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum FailureStatus {
44 /// Address is reachable.
45 Available,
46 /// Address is unreachable (default for unknown addresses).
47 Failed,
48}
49
50/// Reactive failure monitor for address and endpoint tracking.
51///
52/// Thread-safe (`Send + Sync`) via `Arc<RwLock<…>>` for interior mutability.
53/// Consumers register wakers via `on_disconnect_or_failure` and similar methods;
54/// producers wake them via `set_status`, `notify_disconnect`, `endpoint_not_found`.
55///
56/// Use [`on_failed_for`](Self::on_failed_for) to wait for a sustained failure
57/// duration before giving up — the canonical building block behind
58/// [`get_reply_unless_failed_for`](crate::rpc::delivery::get_reply_unless_failed_for).
59///
60/// # FDB Reference
61/// `SimpleFailureMonitor` from `FailureMonitor.h:146`, `FailureMonitor.actor.cpp`
62pub struct FailureMonitor {
63 inner: RwLock<FailureMonitorInner>,
64 sleep_fn: SleepFn,
65}
66
67struct FailureMonitorInner {
68 /// Address-level status. Missing entry = Failed (FDB default).
69 address_status: BTreeMap<String, FailureStatus>,
70 /// Permanently failed endpoints (e.g., endpoint not found on remote).
71 failed_endpoints: BTreeSet<Endpoint>,
72 /// Wakers waiting for endpoint state changes, keyed by address.
73 /// Woken on: `set_status` change, `notify_disconnect`, `endpoint_not_found`.
74 endpoint_watchers: BTreeMap<String, Vec<Waker>>,
75 /// Wakers waiting for disconnect events, keyed by address.
76 /// Woken only on: `notify_disconnect`.
77 disconnect_watchers: BTreeMap<String, Vec<Waker>>,
78}
79
80impl FailureMonitor {
81 /// Create a new failure monitor with empty state.
82 ///
83 /// All unknown addresses default to [`FailureStatus::Failed`] until
84 /// a connection succeeds and calls [`set_status`](Self::set_status).
85 ///
86 /// The `time` provider is captured behind a type-erased sleep closure
87 /// so [`FailureMonitor`] itself stays non-generic.
88 pub fn new<T: TimeProvider + 'static>(time: T) -> Self {
89 let sleep_fn: SleepFn = Box::new(move |duration| {
90 let time = time.clone();
91 Box::pin(async move {
92 let _ = time.sleep(duration).await;
93 })
94 });
95 Self {
96 inner: RwLock::new(FailureMonitorInner {
97 address_status: BTreeMap::new(),
98 failed_endpoints: BTreeSet::new(),
99 endpoint_watchers: BTreeMap::new(),
100 disconnect_watchers: BTreeMap::new(),
101 }),
102 sleep_fn,
103 }
104 }
105
106 // =========================================================================
107 // Producer methods (called by connection_task)
108 // =========================================================================
109
110 /// Update the status of an address.
111 ///
112 /// Called by `connection_task` on successful connect (`Available`) or
113 /// connection failure (`Failed`).
114 ///
115 /// Wakes all endpoint watchers for this address on status change.
116 ///
117 /// # FDB Reference
118 /// `SimpleFailureMonitor::setStatus` (FailureMonitor.actor.cpp:83-115)
119 ///
120 /// # Panics
121 ///
122 /// Panics if the internal `RwLock` is poisoned (only possible if a prior
123 /// task panicked while holding the lock).
124 pub fn set_status(&self, address: &str, status: FailureStatus) {
125 let mut inner = self
126 .inner
127 .write()
128 .expect("RwLock poisoned: prior task panicked");
129
130 let changed = match status {
131 FailureStatus::Available => {
132 let prev = inner.address_status.insert(address.to_string(), status);
133 prev != Some(FailureStatus::Available)
134 }
135 FailureStatus::Failed => {
136 // Missing = Failed, so remove the entry
137 inner.address_status.remove(address).is_some()
138 }
139 };
140
141 if changed {
142 wake_all(&mut inner.endpoint_watchers, address);
143 }
144 }
145
146 /// Signal that a connection to the given address has been lost.
147 ///
148 /// Wakes both endpoint watchers and disconnect watchers for this address.
149 /// Called by `connection_task` after `disconnect_notify.notify_waiters()`.
150 ///
151 /// # FDB Reference
152 /// `SimpleFailureMonitor::notifyDisconnect` (FailureMonitor.actor.cpp:150-154)
153 ///
154 /// # Panics
155 ///
156 /// Panics if the internal `RwLock` is poisoned (only possible if a prior
157 /// task panicked while holding the lock).
158 pub fn notify_disconnect(&self, address: &str) {
159 let mut inner = self
160 .inner
161 .write()
162 .expect("RwLock poisoned: prior task panicked");
163 wake_all(&mut inner.endpoint_watchers, address);
164 wake_all(&mut inner.disconnect_watchers, address);
165 }
166
167 /// Mark an endpoint as permanently failed (e.g., not found on remote).
168 ///
169 /// Permanently failed endpoints are never automatically recovered.
170 /// Wakes endpoint watchers for the endpoint's address.
171 ///
172 /// Skips well-known tokens (system endpoints that always exist).
173 ///
174 /// # FDB Reference
175 /// `SimpleFailureMonitor::endpointNotFound` (FailureMonitor.actor.cpp:117-139)
176 ///
177 /// # Panics
178 ///
179 /// Panics if the internal `RwLock` is poisoned (only possible if a prior
180 /// task panicked while holding the lock).
181 pub fn endpoint_not_found(&self, endpoint: &Endpoint) {
182 // Skip well-known tokens (FDB: `if token.first() == -1 return`)
183 if endpoint.token.is_well_known() {
184 return;
185 }
186
187 let mut inner = self
188 .inner
189 .write()
190 .expect("RwLock poisoned: prior task panicked");
191
192 // Cap is informational: every entry here is a permanent NotFound, so
193 // there is nothing to evict. Drop the new addition with a warning when
194 // we hit the limit rather than thrashing the set.
195 if inner.failed_endpoints.len() >= MAX_FAILED_ENDPOINTS
196 && !inner.failed_endpoints.contains(endpoint)
197 {
198 tracing::warn!(
199 cap = MAX_FAILED_ENDPOINTS,
200 ?endpoint,
201 "FailureMonitor: failed-endpoint set at cap, dropping new NotFound marker"
202 );
203 return;
204 }
205
206 inner.failed_endpoints.insert(endpoint.clone());
207
208 let address = endpoint.address.to_string();
209 wake_all(&mut inner.endpoint_watchers, &address);
210 }
211
212 // =========================================================================
213 // Consumer methods (used by delivery mode functions)
214 // =========================================================================
215
216 /// Get the current failure status of an endpoint.
217 ///
218 /// Returns [`FailureStatus::Failed`] if:
219 /// - The endpoint is permanently failed, OR
220 /// - The endpoint's address is unknown or failed
221 ///
222 /// # FDB Reference
223 /// `SimpleFailureMonitor::getState(Endpoint)` (FailureMonitor.actor.cpp:196-206)
224 ///
225 /// # Panics
226 ///
227 /// Panics if the internal `RwLock` is poisoned (only possible if a prior
228 /// task panicked while holding the lock).
229 pub fn state(&self, endpoint: &Endpoint) -> FailureStatus {
230 let inner = self
231 .inner
232 .read()
233 .expect("RwLock poisoned: prior task panicked");
234
235 if inner.failed_endpoints.contains(endpoint) {
236 return FailureStatus::Failed;
237 }
238
239 let address = endpoint.address.to_string();
240 inner
241 .address_status
242 .get(&address)
243 .copied()
244 .unwrap_or(FailureStatus::Failed) // Missing = Failed
245 }
246
247 /// Check if an endpoint is permanently failed.
248 ///
249 /// # FDB Reference
250 /// `SimpleFailureMonitor::permanentlyFailed` (FailureMonitor.h:226-228)
251 ///
252 /// # Panics
253 ///
254 /// Panics if the internal `RwLock` is poisoned (only possible if a prior
255 /// task panicked while holding the lock).
256 pub fn permanently_failed(&self, endpoint: &Endpoint) -> bool {
257 self.inner
258 .read()
259 .expect("RwLock poisoned: prior task panicked")
260 .failed_endpoints
261 .contains(endpoint)
262 }
263
264 /// Returns a future that resolves when the endpoint's address disconnects
265 /// or the endpoint becomes permanently failed.
266 ///
267 /// **Fast path**: Returns `Ready` immediately if already failed.
268 ///
269 /// Used by `try_get_reply()` to race reply vs disconnect.
270 ///
271 /// # FDB Reference
272 /// `SimpleFailureMonitor::onDisconnectOrFailure` (FailureMonitor.actor.cpp:156-178)
273 ///
274 /// # Panics
275 ///
276 /// The returned future panics if the internal `RwLock` is poisoned (only
277 /// possible if a prior task panicked while holding the lock).
278 pub fn on_disconnect_or_failure(
279 self: &Arc<Self>,
280 endpoint: &Endpoint,
281 ) -> impl Future<Output = ()> + Send {
282 let fm = Arc::clone(self);
283 let address = endpoint.address.to_string();
284 let endpoint = endpoint.clone();
285
286 std::future::poll_fn(move |cx| {
287 let inner = fm
288 .inner
289 .read()
290 .expect("RwLock poisoned: prior task panicked");
291
292 // Fast path: already failed
293 if inner.failed_endpoints.contains(&endpoint) {
294 return Poll::Ready(());
295 }
296 if !inner
297 .address_status
298 .get(&address)
299 .is_some_and(|s| *s == FailureStatus::Available)
300 {
301 // Missing or Failed → already failed
302 return Poll::Ready(());
303 }
304
305 // Slow path: register waker
306 drop(inner);
307 let mut inner = fm
308 .inner
309 .write()
310 .expect("RwLock poisoned: prior task panicked");
311 inner
312 .endpoint_watchers
313 .entry(address.clone())
314 .or_default()
315 .push(cx.waker().clone());
316 Poll::Pending
317 })
318 }
319
320 /// Returns a future that resolves when the given address disconnects.
321 ///
322 /// Only triggered by explicit disconnect events, not status changes.
323 ///
324 /// # FDB Reference
325 /// `SimpleFailureMonitor::onDisconnect` (FailureMonitor.actor.cpp:180-182)
326 ///
327 /// # Panics
328 ///
329 /// The returned future panics if the internal `RwLock` is poisoned (only
330 /// possible if a prior task panicked while holding the lock).
331 pub fn on_disconnect(self: &Arc<Self>, address: &str) -> impl Future<Output = ()> + Send {
332 let fm = Arc::clone(self);
333 let address = address.to_string();
334 let registered = Arc::new(std::sync::atomic::AtomicBool::new(false));
335
336 std::future::poll_fn(move |cx| {
337 // If we already registered and got woken, the disconnect happened
338 if registered.load(std::sync::atomic::Ordering::Acquire) {
339 return Poll::Ready(());
340 }
341
342 // First poll: register waker and mark as registered
343 registered.store(true, std::sync::atomic::Ordering::Release);
344 let mut inner = fm
345 .inner
346 .write()
347 .expect("RwLock poisoned: prior task panicked");
348 inner
349 .disconnect_watchers
350 .entry(address.clone())
351 .or_default()
352 .push(cx.waker().clone());
353 Poll::Pending
354 })
355 }
356
357 /// Waits for failure to be detected, then sleeps `duration` before returning.
358 ///
359 /// Two-phase behaviour, **not** sustained-failure detection:
360 ///
361 /// 1. Block via [`on_disconnect_or_failure`](Self::on_disconnect_or_failure)
362 /// until a failure is observed at least once.
363 /// 2. Sleep `duration` using the time provider captured at construction,
364 /// regardless of whether the endpoint recovers in the meantime.
365 ///
366 /// The recovery case is intentionally ignored — callers like
367 /// [`get_reply_unless_failed_for`](crate::rpc::delivery::get_reply_unless_failed_for)
368 /// race this future against the reply future, so a recovered endpoint that
369 /// answers in time wins the race anyway. Treat this as a deadline timer
370 /// armed by the first observed failure, not a continuous health check.
371 pub async fn on_failed_for(self: &Arc<Self>, endpoint: &Endpoint, duration: Duration) {
372 self.on_disconnect_or_failure(endpoint).await;
373 (self.sleep_fn)(duration).await;
374 }
375}
376
377impl std::fmt::Debug for FailureMonitor {
378 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379 let inner = self
380 .inner
381 .read()
382 .expect("RwLock poisoned: prior task panicked");
383 f.debug_struct("FailureMonitor")
384 .field("addresses_available", &inner.address_status.len())
385 .field("endpoints_failed", &inner.failed_endpoints.len())
386 .field("endpoint_watchers", &inner.endpoint_watchers.len())
387 .field("disconnect_watchers", &inner.disconnect_watchers.len())
388 // `sleep_fn` is a captured closure with no useful Debug representation.
389 .finish_non_exhaustive()
390 }
391}
392
393/// Drain and wake all wakers registered for the given address.
394fn wake_all(watchers: &mut BTreeMap<String, Vec<Waker>>, address: &str) {
395 if let Some(wakers) = watchers.remove(address) {
396 for waker in wakers {
397 waker.wake();
398 }
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use std::net::{IpAddr, Ipv4Addr};
405
406 use moonpool_core::TokioTimeProvider;
407
408 use super::*;
409 use crate::{NetworkAddress, UID};
410
411 fn test_addr() -> NetworkAddress {
412 NetworkAddress::new(IpAddr::V4(Ipv4Addr::new(10, 0, 1, 1)), 4500)
413 }
414
415 fn test_endpoint() -> Endpoint {
416 Endpoint::new(test_addr(), UID::new(42, 1))
417 }
418
419 fn make_fm() -> FailureMonitor {
420 FailureMonitor::new(TokioTimeProvider::new())
421 }
422
423 #[test]
424 fn test_unknown_address_is_failed() {
425 let fm = make_fm();
426 let ep = test_endpoint();
427 assert_eq!(fm.state(&ep), FailureStatus::Failed);
428 }
429
430 #[test]
431 fn test_set_status_available() {
432 let fm = make_fm();
433 let ep = test_endpoint();
434 fm.set_status("10.0.1.1:4500", FailureStatus::Available);
435 assert_eq!(fm.state(&ep), FailureStatus::Available);
436 }
437
438 #[test]
439 fn test_endpoint_not_found_marks_permanent() {
440 let fm = make_fm();
441 let ep = test_endpoint();
442 fm.set_status("10.0.1.1:4500", FailureStatus::Available);
443 fm.endpoint_not_found(&ep);
444 assert!(fm.permanently_failed(&ep));
445 assert_eq!(fm.state(&ep), FailureStatus::Failed);
446 }
447
448 #[test]
449 fn test_endpoint_not_found_skips_well_known() {
450 let fm = make_fm();
451 let ep = Endpoint::well_known(test_addr(), crate::WellKnownToken::Ping);
452 fm.endpoint_not_found(&ep);
453 assert!(!fm.permanently_failed(&ep));
454 }
455
456 #[tokio::test]
457 async fn test_on_disconnect_or_failure_fast_path_unknown() {
458 let fm = Arc::new(make_fm());
459 let ep = test_endpoint();
460 // Unknown address → already failed → should resolve immediately
461 fm.on_disconnect_or_failure(&ep).await;
462 }
463
464 #[tokio::test]
465 async fn test_on_disconnect_or_failure_fast_path_permanent() {
466 let fm = Arc::new(make_fm());
467 let ep = test_endpoint();
468 fm.set_status("10.0.1.1:4500", FailureStatus::Available);
469 fm.endpoint_not_found(&ep);
470 // Permanently failed → should resolve immediately
471 fm.on_disconnect_or_failure(&ep).await;
472 }
473
474 #[test]
475 fn test_on_disconnect_or_failure_wakes_on_status_change() {
476 let rt = tokio::runtime::Builder::new_current_thread()
477 .enable_all()
478 .build()
479 .expect("build runtime");
480 rt.block_on(async {
481 let fm = Arc::new(make_fm());
482 let ep = test_endpoint();
483 fm.set_status("10.0.1.1:4500", FailureStatus::Available);
484
485 let fm2 = Arc::clone(&fm);
486 let handle = tokio::spawn(async move {
487 fm2.on_disconnect_or_failure(&ep).await;
488 });
489
490 // Yield to let the future register its waker
491 tokio::task::yield_now().await;
492
493 // Trigger disconnect → should wake the future
494 fm.set_status("10.0.1.1:4500", FailureStatus::Failed);
495
496 handle.await.expect("task should complete");
497 });
498 }
499
500 #[test]
501 fn test_on_disconnect_wakes_on_notify() {
502 let rt = tokio::runtime::Builder::new_current_thread()
503 .enable_all()
504 .build()
505 .expect("build runtime");
506 rt.block_on(async {
507 let fm = Arc::new(make_fm());
508 fm.set_status("10.0.1.1:4500", FailureStatus::Available);
509
510 let fm2 = Arc::clone(&fm);
511 let handle = tokio::spawn(async move {
512 fm2.on_disconnect("10.0.1.1:4500").await;
513 });
514
515 tokio::task::yield_now().await;
516
517 fm.notify_disconnect("10.0.1.1:4500");
518
519 handle.await.expect("task should complete");
520 });
521 }
522
523 #[test]
524 fn test_debug_impl() {
525 let fm = make_fm();
526 fm.set_status("10.0.1.1:4500", FailureStatus::Available);
527 let debug = format!("{fm:?}");
528 assert!(debug.contains("FailureMonitor"));
529 assert!(debug.contains("addresses_available: 1"));
530 }
531
532 #[tokio::test]
533 async fn test_on_failed_for_resolves_after_sustained_failure() {
534 let fm = Arc::new(make_fm());
535 let addr = NetworkAddress::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 4500);
536 let token = UID::new(0xAAAA, 0xBBBB);
537 let endpoint = Endpoint::new(addr.clone(), token);
538
539 // Mark address failed BEFORE awaiting on_failed_for so the disconnect
540 // detection step resolves immediately and we measure only the sleep.
541 fm.set_status(&addr.to_string(), FailureStatus::Failed);
542
543 let start = tokio::time::Instant::now();
544 fm.on_failed_for(&endpoint, Duration::from_millis(50)).await;
545 let elapsed = start.elapsed();
546
547 assert!(
548 elapsed >= Duration::from_millis(50),
549 "should sleep at least 50ms, got {elapsed:?}"
550 );
551 assert!(
552 elapsed < Duration::from_millis(500),
553 "should not exceed 500ms wall clock, got {elapsed:?}"
554 );
555 }
556}