Skip to main content

vector/sinks/util/service/
health.rs

1use std::{
2    future::Future,
3    pin::Pin,
4    sync::{
5        Arc,
6        atomic::{AtomicUsize, Ordering},
7    },
8    task::{Context, Poll, ready},
9};
10
11use futures::FutureExt;
12use futures_util::{TryFuture, future::BoxFuture};
13use pin_project::pin_project;
14use serde_with::serde_as;
15use stream_cancel::{Trigger, Tripwire};
16use tokio::time::{Duration, sleep};
17use tower::Service;
18use vector_lib::{configurable::configurable_component, emit};
19
20use crate::{
21    common::backoff::ExponentialBackoff,
22    internal_events::{EndpointsActive, OpenGauge},
23};
24
25const RETRY_MAX_DURATION_SECONDS_DEFAULT: u64 = 3_600;
26const RETRY_INITIAL_BACKOFF_SECONDS_DEFAULT: u64 = 1;
27const UNHEALTHY_AMOUNT_OF_ERRORS: usize = 5;
28
29/// Options for determining the health of an endpoint.
30#[serde_as]
31#[configurable_component]
32#[derive(Clone, Debug)]
33#[serde(rename_all = "snake_case")]
34pub struct HealthConfig {
35    /// Initial delay between attempts to reactivate endpoints once they become unhealthy.
36    #[serde(default = "default_retry_initial_backoff_secs")]
37    #[configurable(metadata(docs::type_unit = "seconds"))]
38    // not using Duration type because the value is only used as a u64.
39    #[configurable(metadata(docs::human_name = "Retry Initial Backoff"))]
40    pub retry_initial_backoff_secs: u64,
41
42    /// Maximum delay between attempts to reactivate endpoints once they become unhealthy.
43    #[serde_as(as = "serde_with::DurationSeconds<u64>")]
44    #[serde(default = "default_retry_max_duration_secs")]
45    #[configurable(metadata(docs::human_name = "Max Retry Duration"))]
46    pub retry_max_duration_secs: Duration,
47}
48
49const fn default_retry_initial_backoff_secs() -> u64 {
50    RETRY_INITIAL_BACKOFF_SECONDS_DEFAULT
51}
52
53const fn default_retry_max_duration_secs() -> std::time::Duration {
54    Duration::from_secs(RETRY_MAX_DURATION_SECONDS_DEFAULT)
55}
56
57impl Default for HealthConfig {
58    fn default() -> Self {
59        Self {
60            retry_initial_backoff_secs: default_retry_initial_backoff_secs(),
61            retry_max_duration_secs: default_retry_max_duration_secs(),
62        }
63    }
64}
65
66impl HealthConfig {
67    pub fn build<S, L>(
68        &self,
69        logic: L,
70        inner: S,
71        open: OpenGauge,
72        endpoint: String,
73    ) -> HealthService<S, L> {
74        let counters = Arc::new(HealthCounters::new());
75        let snapshot = counters.snapshot();
76
77        open.clone().open(emit_active_endpoints);
78        HealthService {
79            inner,
80            logic,
81            counters,
82            snapshot,
83            endpoint,
84            state: CircuitState::Closed,
85            open,
86            // An exponential backoff starting from retry_initial_backoff_sec and doubling every time
87            // up to retry_max_duration_secs.
88            backoff: ExponentialBackoff::default()
89                .factor((self.retry_initial_backoff_secs.saturating_mul(1000) / 2).max(1))
90                .max_delay(self.retry_max_duration_secs),
91        }
92    }
93}
94
95pub trait HealthLogic: Clone + Send + Sync + 'static {
96    type Error: Send + Sync + 'static;
97    type Response;
98
99    /// Returns health of the endpoint based on the response/error.
100    /// None if there is not enough information to determine it.
101    fn is_healthy(&self, response: &Result<Self::Response, Self::Error>) -> Option<bool>;
102}
103
104enum CircuitState {
105    /// Service is unhealthy hence it's not passing requests downstream.
106    /// Contains timeout.
107    Open(BoxFuture<'static, ()>),
108
109    /// Service will pass one request to test its health.
110    HalfOpen {
111        permit: Option<Trigger>,
112        done: Tripwire,
113    },
114
115    /// Service is healthy and passing requests downstream.
116    Closed,
117}
118
119/// A service which monitors the health of a service.
120/// Behaves like a circuit breaker.
121pub struct HealthService<S, L> {
122    inner: S,
123    logic: L,
124    counters: Arc<HealthCounters>,
125    snapshot: HealthSnapshot,
126    backoff: ExponentialBackoff,
127    state: CircuitState,
128    open: OpenGauge,
129    endpoint: String,
130}
131
132impl<S, L, Req> Service<Req> for HealthService<S, L>
133where
134    L: HealthLogic<Response = S::Response, Error = S::Error>,
135    S: Service<Req>,
136{
137    type Response = S::Response;
138    type Error = S::Error;
139    type Future = HealthFuture<S::Future, L>;
140
141    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
142        loop {
143            self.state = match self.state {
144                CircuitState::Open(ref mut timer) => {
145                    ready!(timer.as_mut().poll(cx));
146
147                    debug!(message = "Endpoint is on probation.", endpoint = %&self.endpoint);
148
149                    // Using Tripwire will let us be notified when the request is done.
150                    // This can't be done through counters since a request can end without changing them.
151                    let (permit, done) = Tripwire::new();
152
153                    CircuitState::HalfOpen {
154                        permit: Some(permit),
155                        done,
156                    }
157                }
158                CircuitState::HalfOpen {
159                    permit: Some(_), ..
160                } => {
161                    // Pass one request to test health.
162                    return self.inner.poll_ready(cx).map_err(Into::into);
163                }
164                CircuitState::HalfOpen {
165                    permit: None,
166                    ref mut done,
167                } => {
168                    let done = Pin::new(done);
169                    ready!(done.poll(cx));
170
171                    if self.counters.healthy(self.snapshot).is_ok() {
172                        // A healthy response was observed
173                        info!(message = "Endpoint is healthy.", endpoint = %&self.endpoint);
174
175                        self.backoff.reset();
176                        self.open.clone().open(emit_active_endpoints);
177                        CircuitState::Closed
178                    } else {
179                        debug!(message = "Endpoint failed probation.", endpoint = %&self.endpoint);
180
181                        CircuitState::Open(
182                            sleep(self.backoff.next().expect("Should never end")).boxed(),
183                        )
184                    }
185                }
186                CircuitState::Closed => {
187                    // Check for errors
188                    match self.counters.healthy(self.snapshot) {
189                        Ok(snapshot) => {
190                            // Healthy
191                            self.snapshot = snapshot;
192                            return self.inner.poll_ready(cx).map_err(Into::into);
193                        }
194                        Err(errors) if errors >= UNHEALTHY_AMOUNT_OF_ERRORS => {
195                            // Unhealthy
196                            warn!(message = "Endpoint is unhealthy.", endpoint = %&self.endpoint);
197                            CircuitState::Open(
198                                sleep(self.backoff.next().expect("Should never end")).boxed(),
199                            )
200                        }
201                        Err(_) => {
202                            // Not ideal, but not enough errors to trip yet
203                            return self.inner.poll_ready(cx).map_err(Into::into);
204                        }
205                    }
206                }
207            }
208        }
209    }
210
211    fn call(&mut self, req: Req) -> Self::Future {
212        let permit = if let CircuitState::HalfOpen { permit, .. } = &mut self.state {
213            permit.take()
214        } else {
215            None
216        };
217
218        HealthFuture {
219            inner: self.inner.call(req),
220            logic: self.logic.clone(),
221            counters: Arc::clone(&self.counters),
222            permit,
223        }
224    }
225}
226
227/// Future for HealthService.
228#[pin_project]
229pub struct HealthFuture<F, L> {
230    #[pin]
231    inner: F,
232    logic: L,
233    counters: Arc<HealthCounters>,
234    permit: Option<Trigger>,
235}
236
237impl<F: TryFuture, L> Future for HealthFuture<F, L>
238where
239    F: Future<Output = Result<F::Ok, F::Error>>,
240    L: HealthLogic<Response = F::Ok, Error = F::Error>,
241{
242    type Output = Result<F::Ok, F::Error>;
243
244    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
245        // Poll inner
246        let this = self.project();
247        let output = ready!(this.inner.poll(cx));
248
249        match this.logic.is_healthy(&output) {
250            None => (),
251            Some(true) => this.counters.inc_healthy(),
252            Some(false) => this.counters.inc_unhealthy(),
253        }
254
255        // Request is done so we can now drop the permit.
256        this.permit.take();
257
258        Poll::Ready(output)
259    }
260}
261
262/// Tracker of response health, incremented by HealthFuture and used by HealthService.
263struct HealthCounters {
264    healthy: AtomicUsize,
265    unhealthy: AtomicUsize,
266}
267
268impl HealthCounters {
269    const fn new() -> Self {
270        HealthCounters {
271            healthy: AtomicUsize::new(0),
272            unhealthy: AtomicUsize::new(0),
273        }
274    }
275
276    fn inc_healthy(&self) {
277        self.healthy.fetch_add(1, Ordering::Release);
278    }
279
280    fn inc_unhealthy(&self) {
281        self.unhealthy.fetch_add(1, Ordering::Release);
282    }
283
284    /// Checks if healthy.
285    ///
286    /// Returns new snapshot if healthy.
287    /// Else returns measure of unhealthy. Old snapshot is valid in that case.
288    fn healthy(&self, snapshot: HealthSnapshot) -> Result<HealthSnapshot, usize> {
289        let now = self.snapshot();
290
291        // Compare current snapshot with given
292        if now.healthy > snapshot.healthy {
293            // Healthy response was observed
294            Ok(now)
295        } else if now.unhealthy > snapshot.unhealthy {
296            // Unhealthy response was observed
297            Err(now.unhealthy - snapshot.unhealthy)
298        } else {
299            // No relative observations
300            Ok(now)
301        }
302    }
303
304    fn snapshot(&self) -> HealthSnapshot {
305        HealthSnapshot {
306            healthy: self.healthy.load(Ordering::Acquire),
307            unhealthy: self.unhealthy.load(Ordering::Acquire),
308        }
309    }
310}
311
312#[derive(Clone, Copy, Eq, PartialEq, Debug)]
313struct HealthSnapshot {
314    healthy: usize,
315    unhealthy: usize,
316}
317
318fn emit_active_endpoints(count: usize) {
319    emit!(EndpointsActive { count });
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_health_counters() {
328        let counters = HealthCounters::new();
329        let mut snapshot = counters.snapshot();
330
331        counters.inc_healthy();
332        snapshot = counters.healthy(snapshot).unwrap();
333
334        counters.inc_unhealthy();
335        counters.inc_unhealthy();
336        assert_eq!(counters.healthy(snapshot), Err(2));
337
338        counters.inc_healthy();
339        assert!(counters.healthy(snapshot).is_ok());
340    }
341
342    #[test]
343    fn health_config_default_matches_deserialize_defaults() {
344        let config = HealthConfig::default();
345
346        assert_eq!(
347            config.retry_initial_backoff_secs,
348            RETRY_INITIAL_BACKOFF_SECONDS_DEFAULT
349        );
350        assert_eq!(
351            config.retry_max_duration_secs,
352            Duration::from_secs(RETRY_MAX_DURATION_SECONDS_DEFAULT)
353        );
354    }
355}