Skip to main content

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;