1use std::future::Future;
2use std::pin::Pin;
3use std::sync::Arc;
4use std::sync::atomic::{AtomicUsize, Ordering};
5use std::task::{Context, Poll};
6
7use tower::Service;
8use tower::ServiceExt;
9
10use camel_api::{BoxProcessor, CamelError, Exchange, LoadBalanceStrategy, LoadBalancerConfig};
11
12#[derive(Clone)]
13pub struct LoadBalancerService {
14 endpoints: Vec<BoxProcessor>,
15 config: LoadBalancerConfig,
16 round_robin_index: Arc<AtomicUsize>,
17 failover_index: Arc<AtomicUsize>,
18}
19
20impl LoadBalancerService {
21 pub fn new(endpoints: Vec<BoxProcessor>, config: LoadBalancerConfig) -> Self {
22 Self {
23 endpoints,
24 config,
25 round_robin_index: Arc::new(AtomicUsize::new(0)),
26 failover_index: Arc::new(AtomicUsize::new(0)),
27 }
28 }
29}
30
31impl Service<Exchange> for LoadBalancerService {
32 type Response = Exchange;
33 type Error = CamelError;
34 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
35
36 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
37 for endpoint in &mut self.endpoints {
38 match endpoint.poll_ready(cx) {
39 Poll::Pending => return Poll::Pending,
40 Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
41 Poll::Ready(Ok(())) => {}
42 }
43 }
44 Poll::Ready(Ok(()))
45 }
46
47 fn call(&mut self, exchange: Exchange) -> Self::Future {
48 let endpoints = self.endpoints.clone();
49 let config = self.config.clone();
50 let round_robin_index = self.round_robin_index.clone();
51 let failover_index = self.failover_index.clone();
52
53 Box::pin(async move {
54 if endpoints.is_empty() {
55 return Ok(exchange);
56 }
57
58 match &config.strategy {
59 LoadBalanceStrategy::RoundRobin => {
60 process_round_robin(exchange, endpoints, round_robin_index).await
61 }
62 LoadBalanceStrategy::Random => process_random(exchange, endpoints).await,
63 LoadBalanceStrategy::Weighted(weights) => {
64 process_weighted(exchange, endpoints, weights).await
65 }
66 LoadBalanceStrategy::Failover => {
67 process_failover(exchange, endpoints, failover_index).await
68 }
69 }
70 })
71 }
72}
73
74async fn process_round_robin(
75 exchange: Exchange,
76 endpoints: Vec<BoxProcessor>,
77 index: Arc<AtomicUsize>,
78) -> Result<Exchange, CamelError> {
79 let len = endpoints.len();
80 let idx = index.fetch_add(1, Ordering::SeqCst) % len;
81 let mut endpoint = endpoints[idx].clone();
82 endpoint.ready().await?.call(exchange).await
83}
84
85async fn process_random(
86 exchange: Exchange,
87 endpoints: Vec<BoxProcessor>,
88) -> Result<Exchange, CamelError> {
89 let len = endpoints.len();
90 let idx = rand::random_range(0..len);
91 let mut endpoint = endpoints[idx].clone();
92 endpoint.ready().await?.call(exchange).await
93}
94
95async fn process_weighted(
96 exchange: Exchange,
97 endpoints: Vec<BoxProcessor>,
98 weights: &[(String, u32)],
99) -> Result<Exchange, CamelError> {
100 if endpoints.is_empty() || weights.is_empty() {
101 return Ok(exchange);
102 }
103
104 let numeric_weights: Vec<u64> = weights.iter().map(|(_, w)| *w as u64).collect();
105 let total: u64 = numeric_weights
106 .iter()
107 .try_fold(0u64, |acc, w| acc.checked_add(*w))
108 .ok_or_else(|| {
109 CamelError::ProcessorError("Weighted load balancer total weight overflow".to_string())
110 })?;
111
112 if total == 0 {
113 return Err(CamelError::ProcessorError(
114 "Weighted load balancer has zero total weight".to_string(),
115 ));
116 }
117
118 let mut r = rand::random::<u64>() % total;
119 let mut selected_idx = 0;
120 for (i, w) in numeric_weights.iter().enumerate() {
121 if r < *w {
122 selected_idx = i.min(endpoints.len() - 1);
123 break;
124 }
125 r -= w;
126 }
127
128 let mut endpoint = endpoints[selected_idx].clone();
129 endpoint.ready().await?.call(exchange).await
130}
131
132async fn process_failover(
133 exchange: Exchange,
134 endpoints: Vec<BoxProcessor>,
135 start_index: Arc<AtomicUsize>,
136) -> Result<Exchange, CamelError> {
137 let len = endpoints.len();
138 let start = start_index.load(Ordering::SeqCst);
139 let mut last_error = None;
140
141 for i in 0..len {
142 let idx = (start + i) % len;
143 let mut endpoint = endpoints[idx].clone();
144 match endpoint.ready().await?.call(exchange.clone()).await {
145 Ok(ex) => {
146 start_index.store((idx + 1) % len, Ordering::SeqCst);
147 return Ok(ex);
148 }
149 Err(e) => {
150 last_error = Some(e);
151 }
152 }
153 }
154
155 Err(last_error.unwrap_or_else(|| {
156 CamelError::ProcessorError("All endpoints failed in failover".to_string())
157 }))
158}
159
160#[derive(Clone)]
172pub struct LoadBalanceSegment {
173 pub destinations: Vec<camel_api::OutcomeSegment>,
174 pub strategy: camel_api::LoadBalanceStrategy,
175 pub round_robin_index: Arc<AtomicUsize>,
177}
178
179impl camel_api::OutcomePipeline for LoadBalanceSegment {
180 fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
181 Box::new(self.clone())
182 }
183
184 fn run<'a>(
185 &'a mut self,
186 exchange: camel_api::Exchange,
187 ) -> Pin<Box<dyn Future<Output = camel_api::PipelineOutcome> + Send + 'a>> {
188 Box::pin(async move {
189 let len = self.destinations.len();
190 if len == 0 {
191 return camel_api::PipelineOutcome::Completed(exchange);
192 }
193
194 let start_idx = match &self.strategy {
195 camel_api::LoadBalanceStrategy::RoundRobin => {
196 self.round_robin_index.fetch_add(1, Ordering::SeqCst) % len
197 }
198 camel_api::LoadBalanceStrategy::Random => rand::random_range(0..len),
199 camel_api::LoadBalanceStrategy::Weighted(weights) => pick_weighted(weights, len),
200 camel_api::LoadBalanceStrategy::Failover => 0,
201 };
202
203 let mut idx = start_idx;
204 let mut last_err: Option<camel_api::CamelError> = None;
205 loop {
206 if idx >= len {
207 return camel_api::PipelineOutcome::Failed(last_err.unwrap_or_else(|| {
208 camel_api::CamelError::ProcessorError(
209 "load_balance: all destinations exhausted".to_string(),
210 )
211 }));
212 }
213 match self.destinations[idx].run(exchange.clone()).await {
214 camel_api::PipelineOutcome::Completed(ex) => {
215 return camel_api::PipelineOutcome::Completed(ex);
216 }
217 camel_api::PipelineOutcome::Stopped(ex) => {
218 return camel_api::PipelineOutcome::Stopped(ex);
219 }
220 camel_api::PipelineOutcome::Failed(err) => match self.strategy {
221 camel_api::LoadBalanceStrategy::Failover => {
222 last_err = Some(err);
223 idx += 1;
224 continue;
225 }
226 _ => return camel_api::PipelineOutcome::Failed(err),
227 },
228 }
229 }
230 })
231 }
232}
233
234fn pick_weighted(weights: &[(String, u32)], len: usize) -> usize {
236 if weights.is_empty() || len == 0 {
237 return 0;
238 }
239 let numeric_weights: Vec<u64> = weights.iter().map(|(_, w)| *w as u64).collect();
240 let Some(total) = numeric_weights
241 .iter()
242 .try_fold(0u64, |acc, w| acc.checked_add(*w))
243 else {
244 return 0;
245 };
246 if total == 0 {
247 return 0;
248 }
249 let mut r = rand::random::<u64>() % total;
250 for (i, w) in numeric_weights.iter().enumerate() {
251 if r < *w {
252 return i.min(len - 1);
253 }
254 r -= w;
255 }
256 len - 1
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use camel_api::{BoxProcessorExt, Message};
263 use std::sync::Mutex;
264 use tower::ServiceExt;
265
266 fn counting_processor() -> (BoxProcessor, Arc<AtomicUsize>) {
267 let count = Arc::new(AtomicUsize::new(0));
268 let count_clone = count.clone();
269 let processor = BoxProcessor::from_fn(move |ex| {
270 count_clone.fetch_add(1, Ordering::SeqCst);
271 Box::pin(async move { Ok(ex) })
272 });
273 (processor, count)
274 }
275
276 #[tokio::test]
277 async fn test_round_robin_distribution() {
278 let (p1, c1) = counting_processor();
279 let (p2, c2) = counting_processor();
280 let (p3, c3) = counting_processor();
281
282 let config = LoadBalancerConfig::round_robin();
283 let mut svc = LoadBalancerService::new(vec![p1, p2, p3], config);
284
285 for _ in 0..6 {
286 let ex = Exchange::new(Message::new("test"));
287 svc.ready().await.unwrap().call(ex).await.unwrap();
288 }
289
290 assert_eq!(c1.load(Ordering::SeqCst), 2);
291 assert_eq!(c2.load(Ordering::SeqCst), 2);
292 assert_eq!(c3.load(Ordering::SeqCst), 2);
293 }
294
295 #[tokio::test]
296 async fn test_random_distribution() {
297 let (p1, c1) = counting_processor();
298 let (p2, c2) = counting_processor();
299
300 let config = LoadBalancerConfig::random();
301 let mut svc = LoadBalancerService::new(vec![p1, p2], config);
302
303 for _ in 0..100 {
304 let ex = Exchange::new(Message::new("test"));
305 svc.ready().await.unwrap().call(ex).await.unwrap();
306 }
307
308 let total = c1.load(Ordering::SeqCst) + c2.load(Ordering::SeqCst);
309 assert_eq!(total, 100);
310 assert!(c1.load(Ordering::SeqCst) > 20);
311 assert!(c2.load(Ordering::SeqCst) > 20);
312 }
313
314 #[tokio::test]
315 async fn test_failover_on_error() {
316 let failing = BoxProcessor::from_fn(|_ex| {
317 Box::pin(async { Err(CamelError::ProcessorError("fail".into())) })
318 });
319 let (success, count) = counting_processor();
320
321 let config = LoadBalancerConfig::failover();
322 let mut svc = LoadBalancerService::new(vec![failing, success], config);
323
324 let ex = Exchange::new(Message::new("test"));
325 let _result = svc.ready().await.unwrap().call(ex).await.unwrap();
326
327 assert_eq!(count.load(Ordering::SeqCst), 1);
328 }
329
330 #[tokio::test]
331 async fn test_failover_preserves_original_exchange() {
332 let seen_body: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
334 let seen_body_clone = seen_body.clone();
335
336 let failing = BoxProcessor::from_fn(|_ex| {
337 Box::pin(async { Err(CamelError::ProcessorError("fail".into())) })
338 });
339
340 let retry = BoxProcessor::from_fn(move |ex: Exchange| {
341 let seen = seen_body_clone.clone();
342 Box::pin(async move {
343 if let Some(text) = ex.input.body.as_text() {
344 *seen.lock().unwrap() = Some(text.to_string());
345 }
346 Ok(ex)
347 })
348 });
349
350 let config = LoadBalancerConfig::failover();
351 let mut svc = LoadBalancerService::new(vec![failing, retry], config);
352
353 let ex = Exchange::new(Message::new("original body"));
354 svc.ready().await.unwrap().call(ex).await.unwrap();
355
356 assert_eq!(
357 seen_body.lock().unwrap().as_deref(),
358 Some("original body"),
359 "retry endpoint must receive the original exchange body, not a blank one"
360 );
361 }
362
363 #[tokio::test]
364 async fn test_failover_all_fail() {
365 let failing = BoxProcessor::from_fn(|_ex| {
366 Box::pin(async { Err(CamelError::ProcessorError("fail".into())) })
367 });
368
369 let config = LoadBalancerConfig::failover();
370 let mut svc = LoadBalancerService::new(vec![failing.clone(), failing], config);
371
372 let ex = Exchange::new(Message::new("test"));
373 let result = svc.ready().await.unwrap().call(ex).await;
374
375 assert!(result.is_err());
376 }
377
378 #[tokio::test]
379 async fn test_empty_endpoints() {
380 let config = LoadBalancerConfig::round_robin();
381 let mut svc = LoadBalancerService::new(vec![], config);
382
383 let ex = Exchange::new(Message::new("test"));
384 let result = svc.ready().await.unwrap().call(ex).await;
385
386 assert!(result.is_ok());
387 }
388
389 struct StoppingBody;
393 impl camel_api::OutcomePipeline for StoppingBody {
394 fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
395 Box::new(StoppingBody)
396 }
397 fn run<'a>(
398 &'a mut self,
399 mut ex: Exchange,
400 ) -> Pin<Box<dyn Future<Output = camel_api::PipelineOutcome> + Send + 'a>> {
401 Box::pin(async move {
402 ex.input.body = camel_api::Body::Text("lb-stopped".to_string());
403 camel_api::PipelineOutcome::Stopped(ex)
404 })
405 }
406 }
407
408 struct RecordingBody(Arc<AtomicUsize>);
410 impl camel_api::OutcomePipeline for RecordingBody {
411 fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
412 Box::new(RecordingBody(Arc::clone(&self.0)))
413 }
414 fn run<'a>(
415 &'a mut self,
416 ex: Exchange,
417 ) -> Pin<Box<dyn Future<Output = camel_api::PipelineOutcome> + Send + 'a>> {
418 let count = Arc::clone(&self.0);
419 Box::pin(async move {
420 count.fetch_add(1, Ordering::SeqCst);
421 camel_api::PipelineOutcome::Completed(ex)
422 })
423 }
424 }
425
426 struct FailingBody;
428 impl camel_api::OutcomePipeline for FailingBody {
429 fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
430 Box::new(FailingBody)
431 }
432 fn run<'a>(
433 &'a mut self,
434 _ex: Exchange,
435 ) -> Pin<Box<dyn Future<Output = camel_api::PipelineOutcome> + Send + 'a>> {
436 Box::pin(async {
437 camel_api::PipelineOutcome::Failed(CamelError::ProcessorError(
438 "intentional fail".to_string(),
439 ))
440 })
441 }
442 }
443
444 struct RecoveringBody;
446 impl camel_api::OutcomePipeline for RecoveringBody {
447 fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
448 Box::new(RecoveringBody)
449 }
450 fn run<'a>(
451 &'a mut self,
452 mut ex: Exchange,
453 ) -> Pin<Box<dyn Future<Output = camel_api::PipelineOutcome> + Send + 'a>> {
454 Box::pin(async move {
455 ex.input.body = camel_api::Body::Text("recovered".to_string());
456 camel_api::PipelineOutcome::Completed(ex)
457 })
458 }
459 }
460
461 #[tokio::test]
464 async fn load_balance_child_stop_propagates() {
465 let count = Arc::new(AtomicUsize::new(0));
466 let mut seg = LoadBalanceSegment {
467 destinations: vec![
468 camel_api::OutcomeSegment::new(Box::new(StoppingBody)),
469 camel_api::OutcomeSegment::new(Box::new(RecordingBody(count.clone()))),
470 ],
471 strategy: camel_api::LoadBalanceStrategy::RoundRobin,
472 round_robin_index: Arc::new(AtomicUsize::new(0)),
473 };
474
475 let ex = Exchange::new(Message::new("trigger"));
476 let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
477
478 match result {
479 camel_api::PipelineOutcome::Stopped(ex) => {
480 assert_eq!(
481 ex.input.body.as_text(),
482 Some("lb-stopped"),
483 "Stopped exchange must preserve mutation"
484 );
485 }
486 other => panic!("expected PipelineOutcome::Stopped, got {other:?}"),
487 }
488 assert_eq!(
489 count.load(Ordering::SeqCst),
490 0,
491 "second destination must NOT be tried when first is Stopped"
492 );
493 }
494
495 #[tokio::test]
498 async fn load_balance_child_failure_retries_whole_step() {
499 let mut seg = LoadBalanceSegment {
500 destinations: vec![
501 camel_api::OutcomeSegment::new(Box::new(FailingBody)),
502 camel_api::OutcomeSegment::new(Box::new(RecoveringBody)),
503 ],
504 strategy: camel_api::LoadBalanceStrategy::Failover,
505 round_robin_index: Arc::new(AtomicUsize::new(0)),
506 };
507
508 let ex = Exchange::new(Message::new("trigger"));
509 let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
510
511 match result {
512 camel_api::PipelineOutcome::Completed(ex) => {
513 assert_eq!(
514 ex.input.body.as_text(),
515 Some("recovered"),
516 "failover must produce the second destination's output"
517 );
518 }
519 other => panic!("expected PipelineOutcome::Completed, got {other:?}"),
520 }
521 }
522
523 #[tokio::test]
526 async fn load_balance_strategy_selection_preserved() {
527 let c1 = Arc::new(AtomicUsize::new(0));
528 let c2 = Arc::new(AtomicUsize::new(0));
529 let c3 = Arc::new(AtomicUsize::new(0));
530
531 let mut seg = LoadBalanceSegment {
532 destinations: vec![
533 camel_api::OutcomeSegment::new(Box::new(RecordingBody(c1.clone()))),
534 camel_api::OutcomeSegment::new(Box::new(RecordingBody(c2.clone()))),
535 camel_api::OutcomeSegment::new(Box::new(RecordingBody(c3.clone()))),
536 ],
537 strategy: camel_api::LoadBalanceStrategy::RoundRobin,
538 round_robin_index: Arc::new(AtomicUsize::new(0)),
539 };
540
541 for _ in 0..3 {
542 let ex = Exchange::new(Message::new("test"));
543 let _result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
544 }
545
546 assert_eq!(
547 c1.load(Ordering::SeqCst),
548 1,
549 "round-robin: dest 0 call count"
550 );
551 assert_eq!(
552 c2.load(Ordering::SeqCst),
553 1,
554 "round-robin: dest 1 call count"
555 );
556 assert_eq!(
557 c3.load(Ordering::SeqCst),
558 1,
559 "round-robin: dest 2 call count"
560 );
561 }
562
563 #[tokio::test]
564 async fn test_weighted_sum_does_not_overflow_u32() {
565 let ok_processor = BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) }));
566 let endpoints = vec![ok_processor.clone(), ok_processor];
567 let weights = vec![("a".to_string(), u32::MAX), ("b".to_string(), 1)];
569 let result =
570 process_weighted(Exchange::new(Message::new("test")), endpoints, &weights).await;
571 assert!(
572 result.is_ok(),
573 "u32::MAX + 1 must not overflow with u64 sum: {:?}",
574 result.err()
575 );
576 }
577
578 #[tokio::test]
581 async fn load_balance_segment_failover_exhaustion_preserves_last_error() {
582 let err1 = CamelError::ProcessorError("first-dest-failed".to_string());
583 let err2 = CamelError::ProcessorError("second-dest-failed".to_string());
584
585 struct FailWith(CamelError);
586 impl camel_api::OutcomePipeline for FailWith {
587 fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
588 Box::new(FailWith(self.0.clone()))
589 }
590 fn run<'a>(
591 &'a mut self,
592 _ex: Exchange,
593 ) -> Pin<Box<dyn Future<Output = camel_api::PipelineOutcome> + Send + 'a>> {
594 let e = self.0.clone();
595 Box::pin(async move { camel_api::PipelineOutcome::Failed(e) })
596 }
597 }
598
599 let mut seg = LoadBalanceSegment {
600 destinations: vec![
601 camel_api::OutcomeSegment::new(Box::new(FailWith(err1))),
602 camel_api::OutcomeSegment::new(Box::new(FailWith(err2.clone()))),
603 ],
604 strategy: camel_api::LoadBalanceStrategy::Failover,
605 round_robin_index: Arc::new(AtomicUsize::new(0)),
606 };
607
608 let ex = Exchange::new(Message::new("test"));
609 let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
610
611 match result {
612 camel_api::PipelineOutcome::Failed(err) => {
613 assert_eq!(
614 err.to_string(),
615 err2.to_string(),
616 "failover exhaustion must return the LAST destination error, not a generic message"
617 );
618 }
619 other => panic!(
620 "expected PipelineOutcome::Failed(last error), got {:?}",
621 other
622 ),
623 }
624 }
625}