camel_component_direct/lib.rs
1//! In-memory direct component for rust-camel — synchronous point-to-point
2//! dispatch between routes sharing the same context. The producer submits
3//! each exchange directly to the consumer route's pipeline task via
4//! `ConsumerContext::send_and_wait`; there is no per-message channel hop
5//! inside camel-direct.
6//!
7//! Main types: `DirectComponent`, `DirectEndpoint`, `DirectConsumer`, `DirectProducer`.
8
9use std::collections::HashMap;
10use std::future::Future;
11use std::pin::Pin;
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::{Arc, Mutex};
14use std::task::{Context, Poll};
15use std::time::Duration;
16
17use async_trait::async_trait;
18use tokio::sync::Semaphore;
19use tokio_util::sync::CancellationToken;
20use tower::Service;
21
22use camel_component_api::InlineRouteDispatcher;
23use camel_component_api::UriConfig;
24use camel_component_api::parse_uri;
25use camel_component_api::{BoxProcessor, CamelError, Exchange};
26use camel_component_api::{
27 Component, ComponentMetadata, Consumer, ConsumerContext, ConsumerStartupMode, Endpoint,
28 ProducerContext,
29};
30use tracing::{debug, error, info};
31
32mod inline_guard;
33
34// ---------------------------------------------------------------------------
35// Shared state: maps endpoint names to their registered consumer's route
36// submission context. The producer dispatches by calling `send_and_wait` on
37// the stored context; the `closed` flag replaces the mpsc sender's
38// `is_closed()` liveness signal for crashed-consumer detection.
39// ---------------------------------------------------------------------------
40
41/// A registered direct consumer: its route submission context plus the
42/// liveness flag owned by the consumer's `start()` task. The optional
43/// `dispatcher` carries the inline-dispatch capability the core runtime
44/// published on the context (absent when the route's concurrency model does
45/// not permit inline dispatch).
46struct DirectEntry {
47 ctx: ConsumerContext,
48 closed: Arc<AtomicBool>,
49 dispatcher: Option<Arc<dyn InlineRouteDispatcher>>,
50}
51
52type DirectRegistry = Arc<Mutex<HashMap<String, DirectEntry>>>;
53
54/// Sets the owning consumer's `closed` flag on drop, mirroring the
55/// receiver-drop signal of the previous channel design: any exit path from
56/// `DirectConsumer::start` — normal return, panic, or task abort — marks the
57/// registry entry stale so a replacement consumer may overwrite it.
58struct CloseGuard(Arc<AtomicBool>);
59
60impl Drop for CloseGuard {
61 fn drop(&mut self) {
62 self.0.store(true, Ordering::Release);
63 }
64}
65
66// ---------------------------------------------------------------------------
67// Validation helpers
68// ---------------------------------------------------------------------------
69
70/// Validate the direct endpoint name (the part after `direct:`).
71fn validate_name(name: &str) -> Result<(), CamelError> {
72 if name.trim().is_empty() {
73 return Err(CamelError::InvalidUri(
74 "direct: endpoint name must not be empty".to_string(),
75 ));
76 }
77 if name.contains(char::is_whitespace) {
78 return Err(CamelError::InvalidUri(
79 "direct: endpoint name must not contain whitespace".to_string(),
80 ));
81 }
82 Ok(())
83}
84
85// ---------------------------------------------------------------------------
86// DirectConfig
87// ---------------------------------------------------------------------------
88
89/// Configuration for Direct endpoints parsed from URIs.
90///
91/// URI format: `direct:name[?timeout_ms=30000]`
92///
93/// Example: `direct:foo` creates an endpoint named "foo"
94#[derive(Debug, Clone, UriConfig)]
95#[uri_scheme = "direct"]
96#[uri_config(
97 skip_impl,
98 metadata(
99 scheme = "direct",
100 description = "Synchronous in-memory direct invocation between routes",
101 producer,
102 consumer
103 ),
104 crate = "camel_component_api"
105)]
106pub struct DirectConfig {
107 /// Endpoint name (path portion).
108 pub name: String,
109 /// Timeout in milliseconds for producer `call()`. Defaults to 30 000 ms.
110 #[uri_param(
111 name = "timeout_ms",
112 default = "30000",
113 desc = "Producer call timeout in milliseconds"
114 )]
115 pub timeout_ms: Option<u64>,
116 /// When false, skip readiness error if no consumer registered.
117 #[uri_param(
118 name = "failIfNoConsumers",
119 default = "true",
120 desc = "Fail if no consumer registered for the name"
121 )]
122 pub fail_if_no_consumers: Option<bool>,
123}
124
125impl DirectConfig {
126 pub fn from_uri(uri: &str) -> Result<Self, CamelError> {
127 let parts = parse_uri(uri)?;
128 if parts.scheme != "direct" {
129 return Err(CamelError::InvalidUri(format!(
130 "invalid scheme '{}', expected 'direct'",
131 parts.scheme
132 )));
133 }
134
135 let parse_bool = |name: &str, value: &str| -> Result<bool, CamelError> {
136 match value.to_ascii_lowercase().as_str() {
137 "true" | "1" | "yes" => Ok(true),
138 "false" | "0" | "no" => Ok(false),
139 _ => Err(CamelError::InvalidUri(format!(
140 "invalid value for {}: invalid boolean value: '{}'",
141 name, value
142 ))),
143 }
144 };
145
146 let timeout_ms = parts
147 .params
148 .get("timeout_ms")
149 .map(|v| {
150 v.parse::<u64>().map_err(|e| {
151 CamelError::InvalidUri(format!("invalid value for timeout_ms: {}", e))
152 })
153 })
154 .transpose()?;
155
156 if parts.params.contains_key("block") {
157 return Err(CamelError::InvalidUri("block is not supported".into()));
158 }
159
160 let fail_if_no_consumers = parts
161 .params
162 .get("fail_if_no_consumers")
163 .or_else(|| parts.params.get("failIfNoConsumers"))
164 .map(|v| parse_bool("fail_if_no_consumers", v))
165 .transpose()?;
166
167 if parts.params.contains_key("exchange_pattern")
168 || parts.params.contains_key("exchangePattern")
169 {
170 return Err(CamelError::InvalidUri(
171 "exchange_pattern is not supported".into(),
172 ));
173 }
174
175 Ok(Self {
176 name: parts.path,
177 timeout_ms,
178 fail_if_no_consumers,
179 })
180 }
181}
182
183// ---------------------------------------------------------------------------
184// DirectComponent
185// ---------------------------------------------------------------------------
186
187/// The Direct component provides in-memory synchronous communication between
188/// routes.
189///
190/// URI format: `direct:name`
191///
192/// A producer sending to `direct:foo` will block until the consumer on
193/// `direct:foo` has finished processing the exchange.
194pub struct DirectComponent {
195 registry: DirectRegistry,
196}
197
198impl DirectComponent {
199 pub fn new() -> Self {
200 Self {
201 registry: Arc::new(Mutex::new(HashMap::new())),
202 }
203 }
204}
205
206impl Default for DirectComponent {
207 fn default() -> Self {
208 Self::new()
209 }
210}
211
212impl Component for DirectComponent {
213 fn scheme(&self) -> &str {
214 "direct"
215 }
216
217 fn metadata(&self) -> ComponentMetadata {
218 DirectConfig::metadata()
219 }
220
221 fn create_endpoint(
222 &self,
223 uri: &str,
224 _ctx: &dyn camel_component_api::ComponentContext,
225 ) -> Result<Box<dyn Endpoint>, CamelError> {
226 let config = DirectConfig::from_uri(uri)?;
227 validate_name(&config.name)?;
228 let name = config.name.clone();
229 debug!(endpoint_name = %name, "direct endpoint created");
230 Ok(Box::new(DirectEndpoint {
231 uri: uri.to_string(),
232 config,
233 registry: Arc::clone(&self.registry),
234 }))
235 }
236}
237
238// ---------------------------------------------------------------------------
239// DirectEndpoint
240// ---------------------------------------------------------------------------
241
242struct DirectEndpoint {
243 uri: String,
244 config: DirectConfig,
245 registry: DirectRegistry,
246}
247
248impl Endpoint for DirectEndpoint {
249 fn uri(&self) -> &str {
250 &self.uri
251 }
252
253 fn create_consumer(
254 &self,
255 _rt: Arc<dyn camel_component_api::RuntimeObservability>,
256 ) -> Result<Box<dyn Consumer>, CamelError> {
257 Ok(Box::new(DirectConsumer::new(
258 self.config.name.clone(),
259 Arc::clone(&self.registry),
260 )))
261 }
262
263 fn create_producer(
264 &self,
265 rt: Arc<dyn camel_component_api::RuntimeObservability>,
266 _ctx: &ProducerContext,
267 ) -> Result<BoxProcessor, CamelError> {
268 Ok(BoxProcessor::new(DirectProducer {
269 name: self.config.name.clone(),
270 registry: Arc::clone(&self.registry),
271 config: self.config.clone(),
272 semaphore: Arc::new(Semaphore::new(1)),
273 fail_if_no_consumers: self.config.fail_if_no_consumers,
274 runtime: rt,
275 }))
276 }
277}
278
279// ---------------------------------------------------------------------------
280// DirectConsumer
281// ---------------------------------------------------------------------------
282
283/// The Direct consumer registers its route submission context in the shared
284/// registry so producers can dispatch exchanges into its pipeline directly.
285struct DirectConsumer {
286 name: String,
287 registry: DirectRegistry,
288 cancel: Option<CancellationToken>,
289}
290
291impl DirectConsumer {
292 fn new(name: String, registry: DirectRegistry) -> Self {
293 Self {
294 name,
295 registry,
296 cancel: None,
297 }
298 }
299}
300
301#[async_trait]
302impl Consumer for DirectConsumer {
303 fn startup_mode(&self) -> ConsumerStartupMode {
304 ConsumerStartupMode::Explicit
305 }
306
307 async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
308 // Liveness flag: set by the guard on every exit path from this task.
309 let closed = Arc::new(AtomicBool::new(false));
310 let _close_guard = CloseGuard(Arc::clone(&closed));
311
312 // Capture the inline-dispatch capability once: the core runtime sets
313 // it before start(), and the registry entry must carry the same
314 // snapshot for the whole consumer lifetime.
315 let dispatcher = context.inline_dispatcher();
316
317 // Register our submission context so producers can dispatch to us.
318 {
319 let mut reg = self.registry.lock().unwrap_or_else(|e| e.into_inner());
320 if let Some(existing) = reg.get(&self.name)
321 && !existing.closed.load(Ordering::Acquire)
322 {
323 return Err(CamelError::EndpointCreationFailed(format!(
324 "direct endpoint '{}' already has a registered consumer",
325 self.name
326 )));
327 }
328 reg.insert(
329 self.name.clone(),
330 DirectEntry {
331 ctx: context.clone(),
332 closed: Arc::clone(&closed),
333 dispatcher,
334 },
335 );
336 }
337
338 context.mark_ready();
339
340 let name = self.name.clone();
341 let registry = Arc::clone(&self.registry);
342 let cancel = context.cancel_token();
343 let cancel_clone = cancel.clone();
344
345 info!(endpoint_name = %self.name, "direct consumer started");
346
347 self.cancel = Some(cancel);
348
349 // No receive loop: producers submit directly through the registered
350 // context. Park until shutdown.
351 cancel_clone.cancelled().await;
352
353 // Cleanup: remove from registry on exit (the guard sets `closed`).
354 {
355 let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner());
356 reg.remove(&name);
357 }
358
359 debug!(endpoint_name = %name, "direct consumer stopped");
360 Ok(())
361 }
362
363 async fn stop(&mut self) -> Result<(), CamelError> {
364 // Cancel the consumer loop if we have a cancellation token.
365 if let Some(cancel) = self.cancel.take() {
366 cancel.cancel();
367 }
368
369 let mut reg = self.registry.lock().unwrap_or_else(|e| e.into_inner());
370 reg.remove(&self.name);
371
372 debug!(endpoint_name = %self.name, "direct consumer stopped");
373 Ok(())
374 }
375}
376
377// ---------------------------------------------------------------------------
378// DirectProducer
379// ---------------------------------------------------------------------------
380
381/// The Direct producer sends an exchange to the named direct endpoint and
382/// waits for the reply (synchronous in-memory call): it submits the exchange
383/// into the consumer route's pipeline through the registered
384/// `ConsumerContext::send_and_wait`.
385struct DirectProducer {
386 name: String,
387 registry: DirectRegistry,
388 config: DirectConfig,
389 semaphore: Arc<Semaphore>,
390 fail_if_no_consumers: Option<bool>,
391 runtime: Arc<dyn camel_component_api::RuntimeObservability>,
392}
393
394impl Clone for DirectProducer {
395 fn clone(&self) -> Self {
396 Self {
397 name: self.name.clone(),
398 registry: self.registry.clone(),
399 config: self.config.clone(),
400 semaphore: self.semaphore.clone(),
401 fail_if_no_consumers: self.fail_if_no_consumers,
402 runtime: Arc::clone(&self.runtime),
403 }
404 }
405}
406
407/// Effective dispatch timeout shared by the channel and inline paths —
408/// single construction site so the 30s default cannot drift between paths
409/// (inline timeout parity).
410fn effective_dispatch_timeout(timeout_ms: Option<u64>) -> Duration {
411 Duration::from_millis(timeout_ms.unwrap_or(30_000))
412}
413
414/// Timeout error shared by the channel and inline paths — single
415/// construction site so the error text cannot drift between paths
416/// (inline timeout parity).
417fn dispatch_timeout_error(name: &str) -> CamelError {
418 CamelError::ProcessorError(format!("direct:{name} call timed out"))
419}
420
421impl Service<Exchange> for DirectProducer {
422 type Response = Exchange;
423 type Error = CamelError;
424 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
425
426 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
427 // Check that the endpoint is registered. Permits are NOT acquired
428 // here: a permit reserved in poll_ready is held across the
429 // poll_ready/call boundary, wedging the semaphore when a wrapping
430 // Service re-readies a clone (tower contract: call() may only
431 // reserve resources for its own future).
432 let reg = self.registry.lock().unwrap_or_else(|e| e.into_inner());
433 match reg.get(&self.name) {
434 None => {
435 if self.fail_if_no_consumers != Some(false) {
436 return Poll::Ready(Err(CamelError::EndpointCreationFailed(format!(
437 "direct endpoint '{}' not registered",
438 self.name
439 ))));
440 }
441 Poll::Ready(Ok(()))
442 }
443 Some(entry) if entry.closed.load(Ordering::Acquire) => {
444 Poll::Ready(Err(CamelError::EndpointCreationFailed(format!(
445 "direct endpoint '{}' consumer closed",
446 self.name
447 ))))
448 }
449 Some(_) => Poll::Ready(Ok(())),
450 }
451 }
452
453 fn call(&mut self, exchange: Exchange) -> Self::Future {
454 let name = self.name.clone();
455 let registry = Arc::clone(&self.registry);
456 let semaphore = Arc::clone(&self.semaphore);
457 let runtime = Arc::clone(&self.runtime);
458 let timeout = effective_dispatch_timeout(self.config.timeout_ms);
459 let exchange_id = exchange.correlation_id.clone();
460
461 debug!(
462 endpoint_name = %name,
463 exchange_id = %exchange_id,
464 "direct producer call entry"
465 );
466
467 Box::pin(async move {
468 // One timed section covers BOTH paths: the boundary spans the
469 // registry lookup, the per-path serialization wait (channel
470 // permit or dispatcher admission), and the dispatch itself,
471 // so neither path's timeout can drift (inline timeout parity).
472 let timed = tokio::time::timeout(timeout, async {
473 // Registry lookup: a missing entry is an unhandled dispatch
474 // failure like any other — it flows through the same
475 // emission site below instead of `?`-exiting before it
476 // (b′ visibility for the no-consumer case).
477 let looked_up = {
478 let reg = registry.lock().unwrap_or_else(|e| e.into_inner());
479 reg.get(&name)
480 .map(|entry| (entry.ctx.clone(), entry.dispatcher.clone()))
481 };
482
483 let (result, entry_ctx) = match looked_up {
484 None => {
485 let err = CamelError::EndpointCreationFailed(format!(
486 "no consumer registered for direct:{name}"
487 ));
488 // No warn here: the shared emission site below logs
489 // at error! with the b′ increment for this failure
490 // (single log per failed dispatch — review finding).
491 (Err(err), None)
492 }
493 Some((ctx, dispatcher)) => {
494 let result = match dispatcher {
495 // Inline fast path: run the consumer pipeline on this
496 // task. The endpoint semaphore is skipped — the
497 // dispatcher's admission mutex is the single
498 // serializer for inline dispatches. Cycle/depth guard
499 // rejection maps straight out; there is no channel
500 // fallback on guard rejection.
501 Some(d) => {
502 let dispatch_future = d.dispatch(exchange);
503 let guard_name = name.clone();
504 inline_guard::with_inline_stack(async move {
505 // Per-dispatch guard: drops after the dispatch
506 // completes, unwinding before the reply.
507 let _guard = inline_guard::enter(&guard_name)?;
508 dispatch_future.await
509 })
510 .await
511 }
512 // Channel path (Phase 1 semantics): submit through the
513 // consumer context under the endpoint's sole permit.
514 // Covers Concurrent consumers and
515 // capability-unavailable entries.
516 None => {
517 let _permit = semaphore
518 .acquire_owned()
519 .await
520 .map_err(|_| CamelError::ChannelClosed)?;
521 ctx.send_and_wait(exchange).await
522 }
523 };
524 (result, Some(ctx))
525 }
526 };
527
528 if let Err(ref err) = result
529 && !matches!(err, CamelError::ConsumerStopping)
530 {
531 // (category b′: the dispatch invocation returned Err for a
532 // normal-data send — lookup failure, admission failure, or
533 // an in-pipeline error the route handler did NOT absorb —
534 // see ADR-0012 "b-bridged discriminator". This emitter is
535 // the only ERROR signal for the unhandled failure; must
536 // stay loud. ConsumerStopping is a stop-time surrender,
537 // not an operator-visible failure, and does not emit.
538 // Attribution: entry-present failures record under the
539 // consumer entry's route id; the no-entry case has no
540 // entry context and records under the endpoint-derived id
541 // `direct:<name>`, distinguishing the component signal
542 // from the producing route's traced wrapper.)
543 let attribution = match entry_ctx.as_ref() {
544 Some(ctx) => ctx.route_id().to_string(),
545 None => format!("direct:{name}"),
546 };
547 runtime
548 .metrics()
549 .increment_errors(&attribution, "b-prime:direct:send-and-wait");
550 // log-policy: outside-contract
551 error!(
552 endpoint_name = %name,
553 error = %err,
554 "direct consumer pipeline error"
555 );
556 }
557
558 debug!(endpoint_name = %name, "direct message sent");
559 result
560 })
561 .await;
562
563 match timed {
564 Ok(result) => result,
565 Err(_) => {
566 // Timeout branch: tokio dropped the inner future on
567 // expiry, so the emission site above never ran — an
568 // expired dispatch emitted nothing pre-fix. Emit here
569 // through the same context-threaded handle (the freshly
570 // constructed timeout error is never ConsumerStopping).
571 let err = dispatch_timeout_error(&name);
572 runtime.metrics().increment_errors(
573 &format!("direct:{name}"),
574 "b-prime:direct:send-and-wait",
575 );
576 // log-policy: outside-contract
577 error!(
578 endpoint_name = %name,
579 error = %err,
580 "direct dispatch timed out"
581 );
582 Err(err)
583 }
584 }
585 })
586 }
587}
588
589#[cfg(test)]
590#[path = "direct_tests.rs"]
591mod tests;