Skip to content

Commit d7499cf

Browse files
committed
feat(vector sink): add multiple endpoint strategies
1 parent 5d41252 commit d7499cf

4 files changed

Lines changed: 636 additions & 28 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Add support for configuring multiple endpoints in the `vector` sink via the new `addresses` option, enabling built-in load balancing and failover across downstream Vector instances.
2+
3+
authors: fpytloun

src/sinks/vector/config.rs

Lines changed: 234 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
1+
use std::{
2+
sync::{
3+
Arc,
4+
atomic::{AtomicUsize, Ordering},
5+
},
6+
task::{Context, Poll},
7+
};
8+
9+
use futures::{FutureExt, TryFutureExt, future::BoxFuture};
110
use http::Uri;
211
use hyper::client::HttpConnector;
312
use hyper_openssl::HttpsConnector;
413
use hyper_proxy::ProxyConnector;
514
use tonic::body::BoxBody;
6-
use tower::ServiceBuilder;
15+
use tower::{Service, ServiceBuilder};
716
use vector_lib::configurable::configurable_component;
817

918
use super::{
@@ -22,8 +31,9 @@ use crate::{
2231
sinks::{
2332
Healthcheck, VectorSink as VectorSinkType,
2433
util::{
25-
BatchConfig, RealtimeEventBasedDefaultBatchSettings, ServiceBuilderExt,
26-
TowerRequestConfig, retries::RetryLogic,
34+
BatchConfig, RealtimeEventBasedDefaultBatchSettings, TowerRequestConfig,
35+
retries::RetryLogic,
36+
service::{HealthConfig, HealthLogic, ServiceBuilderExt},
2737
},
2838
},
2939
tls::{MaybeTlsSettings, TlsEnableableConfig},
@@ -45,10 +55,28 @@ pub struct VectorConfig {
4555
/// Both IP address and hostname are accepted formats.
4656
///
4757
/// The address _must_ include a port.
58+
///
59+
/// This option is mutually exclusive with `addresses`. Set exactly one of
60+
/// `address` or `addresses`.
61+
#[configurable(validation(format = "uri"))]
62+
#[configurable(metadata(docs::examples = "92.12.333.224:6000"))]
63+
#[configurable(metadata(docs::examples = "https://somehost:6000"))]
64+
#[serde(default)]
65+
address: Option<String>,
66+
67+
/// The downstream Vector addresses to which to connect.
68+
///
69+
/// Both IP addresses and hostnames are accepted formats.
70+
///
71+
/// Each address _must_ include a port.
72+
///
73+
/// This option is mutually exclusive with `address`. Set exactly one of
74+
/// `address` or `addresses`.
4875
#[configurable(validation(format = "uri"))]
4976
#[configurable(metadata(docs::examples = "92.12.333.224:6000"))]
5077
#[configurable(metadata(docs::examples = "https://somehost:6000"))]
51-
address: String,
78+
#[serde(default)]
79+
addresses: Vec<String>,
5280

5381
/// Compression algorithm for requests.
5482
///
@@ -72,6 +100,17 @@ pub struct VectorConfig {
72100
#[serde(default)]
73101
pub request: TowerRequestConfig,
74102

103+
/// Options for determining the health of Vector endpoints.
104+
#[serde(default)]
105+
#[configurable(derived)]
106+
pub endpoint_health: Option<HealthConfig>,
107+
108+
/// Strategy for routing requests across multiple configured addresses.
109+
///
110+
/// This option is only used when `addresses` is configured.
111+
#[serde(default)]
112+
pub endpoint_strategy: EndpointStrategy,
113+
75114
#[configurable(derived)]
76115
#[serde(default)]
77116
tls: Option<TlsEnableableConfig>,
@@ -102,10 +141,13 @@ impl GenerateConfig for VectorConfig {
102141
fn default_config(address: &str) -> VectorConfig {
103142
VectorConfig {
104143
version: None,
105-
address: address.to_owned(),
144+
address: Some(address.to_owned()),
145+
addresses: Vec::new(),
106146
compression: VectorCompression::None,
107147
batch: BatchConfig::default(),
108148
request: TowerRequestConfig::default(),
149+
endpoint_health: None,
150+
endpoint_strategy: EndpointStrategy::default(),
109151
tls: None,
110152
acknowledgements: Default::default(),
111153
}
@@ -116,36 +158,63 @@ fn default_config(address: &str) -> VectorConfig {
116158
impl SinkConfig for VectorConfig {
117159
async fn build(&self, cx: SinkContext) -> crate::Result<(VectorSinkType, Healthcheck)> {
118160
let tls = MaybeTlsSettings::from_config(self.tls.as_ref(), false)?;
119-
let uri = with_default_scheme(&self.address, tls.is_tls())?;
161+
let uris = self.uris(tls.is_tls())?;
120162

121163
let client = new_client(&tls, cx.proxy())?;
122164

123-
let healthcheck_uri = cx
124-
.healthcheck
125-
.uri
126-
.clone()
127-
.map(|uri| uri.uri)
128-
.unwrap_or_else(|| uri.clone());
129-
let healthcheck_client =
130-
VectorService::new(client.clone(), healthcheck_uri, VectorCompression::None);
131-
let healthcheck = healthcheck(healthcheck_client, cx.healthcheck);
132-
let service = VectorService::new(client, uri, self.compression);
165+
let healthcheck = healthchecks(client.clone(), &uris, cx.healthcheck);
133166
let request_settings = self.request.into_settings();
134167
let batch_settings = self.batch.into_batcher_settings()?;
135168

136-
let service = ServiceBuilder::new()
137-
.settings(request_settings, VectorGrpcRetryLogic)
138-
.service(service);
169+
let services = uris
170+
.into_iter()
171+
.map(|uri| {
172+
let endpoint = uri.to_string();
173+
let service = VectorService::new(client.clone(), uri, self.compression);
174+
(endpoint, service)
175+
})
176+
.collect::<Vec<_>>();
177+
178+
let sink = match self.endpoint_strategy {
179+
EndpointStrategy::LoadBalance => {
180+
let service = request_settings.distributed_service(
181+
VectorGrpcRetryLogic,
182+
services,
183+
self.endpoint_health.clone().unwrap_or_default(),
184+
VectorGrpcHealthLogic,
185+
1,
186+
);
139187

140-
let sink = VectorSink {
141-
batch_settings,
142-
service,
188+
VectorSinkType::from_event_streamsink(VectorSink {
189+
batch_settings,
190+
service,
191+
})
192+
}
193+
EndpointStrategy::Failover => {
194+
let endpoint_timeout = request_settings.timeout;
195+
let mut failover_request_settings = request_settings;
196+
failover_request_settings.timeout = endpoint_timeout
197+
.checked_mul(services.len() as u32)
198+
.unwrap_or(endpoint_timeout);
199+
200+
let service = ServiceBuilder::new()
201+
.settings(failover_request_settings, VectorGrpcRetryLogic)
202+
.service(FailoverVectorService::new(
203+
services
204+
.into_iter()
205+
.map(|(_endpoint, service)| service)
206+
.collect(),
207+
endpoint_timeout,
208+
));
209+
210+
VectorSinkType::from_event_streamsink(VectorSink {
211+
batch_settings,
212+
service,
213+
})
214+
}
143215
};
144216

145-
Ok((
146-
VectorSinkType::from_event_streamsink(sink),
147-
Box::pin(healthcheck),
148-
))
217+
Ok((sink, Box::pin(healthcheck)))
149218
}
150219

151220
fn input(&self) -> Input {
@@ -157,6 +226,100 @@ impl SinkConfig for VectorConfig {
157226
}
158227
}
159228

229+
/// Strategy for routing requests across multiple Vector endpoints.
230+
#[configurable_component]
231+
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
232+
#[serde(rename_all = "snake_case")]
233+
pub enum EndpointStrategy {
234+
/// Distribute requests across healthy endpoints.
235+
#[default]
236+
LoadBalance,
237+
/// Use endpoints in configured order, moving to the next endpoint only when
238+
/// the current endpoint fails.
239+
Failover,
240+
}
241+
242+
#[derive(Clone)]
243+
struct FailoverVectorService {
244+
services: Vec<VectorService>,
245+
active: Arc<AtomicUsize>,
246+
endpoint_timeout: std::time::Duration,
247+
}
248+
249+
impl FailoverVectorService {
250+
fn new(services: Vec<VectorService>, endpoint_timeout: std::time::Duration) -> Self {
251+
Self {
252+
services,
253+
active: Arc::new(AtomicUsize::new(0)),
254+
endpoint_timeout,
255+
}
256+
}
257+
}
258+
259+
impl Service<VectorRequest> for FailoverVectorService {
260+
type Response = VectorResponse;
261+
type Error = crate::Error;
262+
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
263+
264+
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
265+
Poll::Ready(Ok(()))
266+
}
267+
268+
fn call(&mut self, request: VectorRequest) -> Self::Future {
269+
let services = self.services.clone();
270+
let active = Arc::clone(&self.active);
271+
let endpoint_timeout = self.endpoint_timeout;
272+
273+
Box::pin(async move {
274+
let start = active.load(Ordering::Acquire);
275+
let mut last_error = None;
276+
277+
for offset in 0..services.len() {
278+
let index = (start + offset) % services.len();
279+
let mut service = services[index].clone();
280+
281+
match tokio::time::timeout(endpoint_timeout, service.call(request.clone())).await {
282+
Ok(Ok(response)) => {
283+
active.store(index, Ordering::Release);
284+
return Ok(response);
285+
}
286+
Ok(Err(error)) => {
287+
active.store((index + 1) % services.len(), Ordering::Release);
288+
last_error = Some(error);
289+
}
290+
Err(_elapsed) => {
291+
active.store((index + 1) % services.len(), Ordering::Release);
292+
last_error = Some(Box::new(VectorSinkError::Request {
293+
source: tonic::Status::deadline_exceeded(
294+
"vector endpoint request timed out",
295+
),
296+
}) as crate::Error);
297+
}
298+
}
299+
}
300+
301+
Err(last_error.expect("failover service should have at least one endpoint"))
302+
})
303+
}
304+
}
305+
306+
impl VectorConfig {
307+
fn uris(&self, tls: bool) -> crate::Result<Vec<Uri>> {
308+
match (self.address.as_ref(), self.addresses.as_slice()) {
309+
(Some(_), [_first, ..]) => Err(
310+
"`address` and `addresses` options are mutually exclusive. Please use `addresses` for multiple Vector endpoints."
311+
.into(),
312+
),
313+
(None, []) => Err("No Vector endpoint configured. Please set `address` or `addresses`.".into()),
314+
(Some(address), []) => Ok(vec![with_default_scheme(address, tls)?]),
315+
(None, addresses) => addresses
316+
.iter()
317+
.map(|address| with_default_scheme(address, tls))
318+
.collect(),
319+
}
320+
}
321+
}
322+
160323
/// Check to see if the remote service accepts new events.
161324
async fn healthcheck(
162325
mut service: VectorService,
@@ -183,6 +346,39 @@ async fn healthcheck(
183346
}
184347
}
185348

349+
fn healthchecks(
350+
client: hyper::Client<ProxyConnector<HttpsConnector<HttpConnector>>, BoxBody>,
351+
uris: &[Uri],
352+
options: SinkHealthcheckOptions,
353+
) -> Healthcheck {
354+
if !options.enabled {
355+
return Box::pin(futures::future::ok(()));
356+
}
357+
358+
let healthcheck_uris = options
359+
.uri
360+
.clone()
361+
.map(|uri| vec![uri.uri])
362+
.unwrap_or_else(|| uris.to_vec());
363+
364+
Box::pin(
365+
futures::future::select_ok(healthcheck_uris.into_iter().map(move |uri| {
366+
let service = VectorService::new(client.clone(), uri, VectorCompression::None);
367+
let timeout = options.timeout;
368+
healthcheck(
369+
service,
370+
SinkHealthcheckOptions {
371+
enabled: true,
372+
uri: None,
373+
timeout,
374+
},
375+
)
376+
.boxed()
377+
}))
378+
.map_ok(|((), _)| ()),
379+
)
380+
}
381+
186382
/// grpc doesn't like an address without a scheme, so we default to http or https if one isn't
187383
/// specified in the address.
188384
pub fn with_default_scheme(address: &str, tls: bool) -> crate::Result<Uri> {
@@ -256,3 +452,15 @@ impl RetryLogic for VectorGrpcRetryLogic {
256452
}
257453
}
258454
}
455+
456+
#[derive(Debug, Clone)]
457+
struct VectorGrpcHealthLogic;
458+
459+
impl HealthLogic for VectorGrpcHealthLogic {
460+
type Error = crate::Error;
461+
type Response = VectorResponse;
462+
463+
fn is_healthy(&self, response: &Result<Self::Response, Self::Error>) -> Option<bool> {
464+
Some(response.is_ok())
465+
}
466+
}

0 commit comments

Comments
 (0)