camel_component_api/consumer.rs
1use std::sync::{Arc, OnceLock};
2
3use async_trait::async_trait;
4use tokio::sync::{mpsc, oneshot, watch};
5use tokio::task::JoinHandle;
6use tokio_util::sync::CancellationToken;
7
8use camel_api::security_policy::SecurityPolicy;
9use camel_api::{CamelError, Exchange, InFlightGauge};
10use camel_auth::CredentialSource;
11
12use crate::dispatch::InlineRouteDispatcher;
13
14/// Re-export of [`camel_api::InFlightClaim`] — the context-global
15/// accepted-not-completed RAII claim (drainclaim). The canonical
16/// definition and contract live in `camel_api::in_flight`; this alias
17/// keeps the historical `camel_component_api::consumer::InFlightClaim`
18/// path stable. claimfamily: the type moved to camel-api so
19/// [`camel_api::Exchange`] can carry it for stash-site residency.
20pub use camel_api::InFlightClaim;
21
22/// A message sent from a consumer to the route pipeline.
23///
24/// Fire-and-forget exchanges use `reply_tx = None`.
25/// Request-reply exchanges (e.g. `direct:`) provide a `reply_tx` so the
26/// pipeline result can be sent back to the consumer.
27pub struct ExchangeEnvelope {
28 pub exchange: Exchange,
29 pub reply_tx: Option<oneshot::Sender<Result<Exchange, CamelError>>>,
30 /// RAII claim on the context-global accepted-not-completed counter,
31 /// attached at acceptance boundaries (`ConsumerContext::send` /
32 /// `send_and_wait`, seda enqueue, inline dispatch). `None` on the raw
33 /// `sender()` fast path — a documented uncounted exception (drainclaim).
34 pub in_flight_claim: Option<InFlightClaim>,
35}
36
37/// Declares when the runtime may consider a Consumer "started".
38///
39/// `Immediate` (default) preserves the classic fire-and-forget semantics:
40/// `spawn_consumer_task` returns as soon as the consumer task is spawned,
41/// matching the behaviour of timer, file, direct and similar polling
42/// consumers whose `start()` IS the lifetime loop.
43///
44/// `Explicit` is for resource-binding consumers (HTTP, WebSocket, …) whose
45/// `start()` returns control only after the resource (e.g. `TcpListener`)
46/// is bound and ready. The consumer MUST call `ConsumerContext::mark_ready`
47/// after a successful bind so the runtime can await readiness and propagate
48/// pre-ready `start()` errors as proper startup failures.
49///
50/// Adding this as a default-returning trait method keeps every existing
51/// `Consumer` impl backward compatible.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53#[non_exhaustive]
54pub enum ConsumerStartupMode {
55 /// Consumer's `start()` IS the lifetime loop. The runtime treats the
56 /// consumer as ready the moment `start()` is invoked. (Default)
57 #[default]
58 Immediate,
59 /// Consumer binds/registers a resource inside `start()` and signals
60 /// readiness explicitly via `ConsumerContext::mark_ready()`.
61 Explicit,
62}
63
64/// Internal state of a [`StartupSignal`].
65#[derive(Clone, Debug)]
66enum StartupState {
67 /// Consumer has not yet signalled readiness or failure.
68 Pending,
69 /// Consumer signalled readiness via `mark_ready()`.
70 Ready,
71 /// Consumer's `start()` returned an `Err` before signalling readiness.
72 Failed(String),
73}
74
75/// Shared handle used by a Consumer to signal readiness (or failure) to the
76/// runtime's [`StartupReceiver`].
77///
78/// Constructed in a pair via [`StartupSignal::pair`]. The signal is held by
79/// the consumer side (via [`ConsumerContext`]); the receiver is returned to
80/// the route controller.
81#[derive(Clone)]
82pub struct StartupSignal {
83 tx: watch::Sender<StartupState>,
84}
85
86impl StartupSignal {
87 /// Create a `(signal, receiver)` pair seeded in the `Pending` state.
88 pub fn pair() -> (Self, StartupReceiver) {
89 let (tx, rx) = watch::channel(StartupState::Pending);
90 (Self { tx }, StartupReceiver { rx })
91 }
92
93 /// Mark the consumer as ready. Idempotent — subsequent calls are no-ops
94 /// once the state has transitioned out of `Pending`.
95 ///
96 /// Returns `true` if this call transitioned `Pending → Ready`, `false`
97 /// if the state was already `Ready` or `Failed`. The runtime uses the
98 /// return value to detect Explicit consumers that returned `Ok` from
99 /// `start()` without calling `mark_ready` (a contract violation that
100 /// would hang the controller without the defensive fallback in
101 /// `spawn_consumer_task`).
102 pub fn mark_ready(&self) -> bool {
103 self.tx.send_if_modified(|s| {
104 if matches!(*s, StartupState::Pending) {
105 *s = StartupState::Ready;
106 true
107 } else {
108 false
109 }
110 })
111 }
112
113 /// Mark the consumer's startup as failed with `err`. Idempotent.
114 pub fn mark_failed(&self, err: String) {
115 self.tx.send_if_modified(|s| {
116 if matches!(*s, StartupState::Pending) {
117 *s = StartupState::Failed(err);
118 true
119 } else {
120 false
121 }
122 });
123 }
124}
125
126impl std::fmt::Debug for StartupSignal {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 f.debug_struct("StartupSignal")
129 .field("state", &self.tx.borrow())
130 .finish()
131 }
132}
133
134/// Receiver half of the consumer startup handshake. Resolves once the
135/// consumer calls [`ConsumerContext::mark_ready`] (Ok) or its `start()`
136/// returns an `Err` first (Err).
137///
138/// For [`ConsumerStartupMode::Immediate`] consumers the receiver is
139/// pre-resolved at construction time (see [`StartupReceiver::immediate`]).
140pub struct StartupReceiver {
141 rx: watch::Receiver<StartupState>,
142}
143
144impl StartupReceiver {
145 /// Construct a receiver that is already resolved as `Ok`. Used for
146 /// [`ConsumerStartupMode::Immediate`] consumers so the controller can
147 /// uniformly `await` every receiver without changing behaviour.
148 pub fn immediate() -> Self {
149 let (tx, rx) = watch::channel(StartupState::Ready);
150 // Drop the sender — state is fixed at Ready. Receiver will never
151 // observe a closure error since it already holds Ready.
152 let _ = tx;
153 Self { rx }
154 }
155
156 /// Wait for the consumer to become ready or fail. Resolves:
157 /// - `Ok(())` if the consumer signalled readiness.
158 /// - `Err(CamelError::RouteError(_))` if the consumer's `start()`
159 /// returned an error before signalling readiness.
160 /// - `Err(CamelError::RouteError(_))` if the signal sender was dropped
161 /// without either transition (programming-contract violation).
162 pub async fn await_ready(mut self) -> Result<(), CamelError> {
163 loop {
164 match &*self.rx.borrow() {
165 StartupState::Pending => {}
166 StartupState::Ready => return Ok(()),
167 StartupState::Failed(msg) => {
168 return Err(CamelError::RouteError(msg.clone()));
169 }
170 }
171 if self.rx.changed().await.is_err() {
172 return Err(CamelError::RouteError(
173 "consumer startup signal dropped without resolving".to_string(),
174 ));
175 }
176 }
177 }
178}
179
180impl std::fmt::Debug for StartupReceiver {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 f.debug_struct("StartupReceiver")
183 .field("state", &self.rx.borrow())
184 .finish()
185 }
186}
187
188/// Context provided to a Consumer, allowing it to send exchanges into the route.
189#[derive(Clone)]
190pub struct ConsumerContext {
191 sender: mpsc::Sender<ExchangeEnvelope>,
192 cancel_token: CancellationToken,
193 route_id: String,
194 startup: StartupSignal,
195 /// Inline dispatch capability slot, shared by all clones of this
196 /// context (`Arc<OnceLock<_>>`). Set once by the camel-core runtime
197 /// before the consumer starts; every clone observes the same value.
198 inline_dispatcher: Arc<OnceLock<Arc<dyn InlineRouteDispatcher>>>,
199 /// Context-global accepted-not-completed gauge, installed by the
200 /// camel-core runtime at consumer start (drainclaim). `None` keeps
201 /// the context's sends uncounted (tests, raw fast paths).
202 in_flight: Option<Arc<InFlightGauge>>,
203 /// Stop-budget slot, installed by the camel-core runtime at consumer
204 /// start. The runtime writes the effective stop budget before
205 /// cancelling; the channel value `None` means "not yet written".
206 stop_budget: Option<watch::Receiver<Option<std::time::Duration>>>,
207}
208
209impl ConsumerContext {
210 /// Create a new consumer context wrapping the given channel sender.
211 ///
212 /// The `route_id` identifies the route this consumer is bound to, enabling
213 /// ADR-0012 per-route metrics and health observations.
214 ///
215 /// The startup signal defaults to a fresh `Pending` pair; the consumer
216 /// can call [`Self::mark_ready`] once it has bound its resource. For
217 /// [`ConsumerStartupMode::Immediate`] consumers the runtime ignores
218 /// the signal (it constructs an already-resolved receiver instead).
219 pub fn new(
220 sender: mpsc::Sender<ExchangeEnvelope>,
221 cancel_token: CancellationToken,
222 route_id: String,
223 ) -> Self {
224 let (startup, _unused_receiver) = StartupSignal::pair();
225 // The receiver is dropped here: `spawn_consumer_task` constructs its
226 // own `(signal, receiver)` pair and replaces this one via
227 // `with_startup` so the controller holds the matching receiver.
228 let _ = _unused_receiver;
229 Self {
230 sender,
231 cancel_token,
232 route_id,
233 startup,
234 inline_dispatcher: Arc::new(OnceLock::new()),
235 in_flight: None,
236 stop_budget: None,
237 }
238 }
239
240 /// Replace the startup signal carried by this context. Used by
241 /// `spawn_consumer_task` to install the signal whose matching receiver
242 /// is returned to the route controller.
243 pub fn with_startup(mut self, startup: StartupSignal) -> Self {
244 self.startup = startup;
245 self
246 }
247
248 /// Install the context-global accepted-not-completed gauge on this
249 /// context (drainclaim). The camel-core runtime calls this at consumer
250 /// start; afterwards every envelope sent via [`Self::send`] or
251 /// [`Self::send_and_wait`] carries an [`InFlightClaim`]. Contexts
252 /// without a gauge (tests, raw fast paths) send uncounted envelopes.
253 pub fn with_in_flight_counter(mut self, counter: Arc<InFlightGauge>) -> Self {
254 self.in_flight = Some(counter);
255 self
256 }
257
258 /// Returns the installed accepted-not-completed gauge, if any
259 /// (drainclaim). Components whose consumers dispatch through the raw
260 /// [`Self::sender`] fast path capture this once at consumer start and
261 /// mint an [`InFlightClaim`] at their acceptance-dequeue points, so raw
262 /// dispatches count exactly like [`Self::send`] (rc-nftni). Returns
263 /// `None` for contexts without a gauge (tests) — callers mint none.
264 pub fn in_flight_counter(&self) -> Option<Arc<InFlightGauge>> {
265 self.in_flight.clone()
266 }
267
268 /// Install the stop-budget slot on this context.
269 ///
270 /// The camel-core runtime calls this at consumer start. It writes the
271 /// effective stop budget into the channel before cancelling the consumer;
272 /// the value `None` means "not yet written". Consumers that need to
273 /// bound their graceful drain read the slot once shutdown is requested.
274 pub fn with_stop_budget_slot(
275 mut self,
276 rx: watch::Receiver<Option<std::time::Duration>>,
277 ) -> Self {
278 self.stop_budget = Some(rx);
279 self
280 }
281
282 /// Returns a clone of the installed stop-budget slot, if any.
283 ///
284 /// `None` for contexts without a slot (tests, consumers that never
285 /// drain gracefully). See [`Self::with_stop_budget_slot`].
286 pub fn stop_budget_slot(&self) -> Option<watch::Receiver<Option<std::time::Duration>>> {
287 self.stop_budget.clone()
288 }
289
290 /// Returns a clone of the internal [`StartupSignal`] so callers (e.g.
291 /// `spawn_consumer_task`) can drive failure propagation independently of
292 /// the consumer's own `mark_ready()` call.
293 pub fn startup_signal(&self) -> StartupSignal {
294 self.startup.clone()
295 }
296
297 /// Mark this consumer's startup as complete. Only meaningful for
298 /// [`ConsumerStartupMode::Explicit`] consumers — `Immediate` consumers
299 /// never need to call this because the runtime resolves their startup
300 /// receiver at construction time.
301 ///
302 /// Idempotent.
303 pub fn mark_ready(&self) {
304 let _ = self.startup.mark_ready();
305 }
306
307 /// Mark this consumer's startup as FAILED with `err`. Only meaningful for
308 /// [`ConsumerStartupMode::Explicit`] consumers whose readiness is gated on
309 /// an asynchronous event that may never arrive (e.g. Kafka partition
310 /// assignment). Calling this resolves the runtime's startup await with a
311 /// startup error instead of hanging.
312 ///
313 /// Idempotent — the first transition out of `Pending` wins, so a later
314 /// `mark_ready()` cannot override an earlier `mark_failed()` and vice
315 /// versa.
316 pub fn mark_failed(&self, err: String) {
317 self.startup.mark_failed(err);
318 }
319
320 /// Returns a future that resolves when shutdown is requested.
321 /// Use in `tokio::select!` inside consumer loops.
322 pub async fn cancelled(&self) {
323 self.cancel_token.cancelled().await
324 }
325
326 /// Returns true if shutdown has been requested.
327 pub fn is_cancelled(&self) -> bool {
328 self.cancel_token.is_cancelled()
329 }
330
331 /// Returns the route_id this consumer is bound to.
332 ///
333 /// Available for ADR-0012 metrics/health calls that require a route_id
334 /// (categories (b′), (e), (g)). Set at construction time by the route
335 /// controller when spawning the consumer task.
336 pub fn route_id(&self) -> &str {
337 &self.route_id
338 }
339
340 /// Returns a clone of the `CancellationToken`.
341 ///
342 /// Useful for consumers that spawn per-request tasks and need to propagate
343 /// shutdown to each task. See `HttpConsumer` for an example.
344 pub fn cancel_token(&self) -> CancellationToken {
345 self.cancel_token.clone()
346 }
347
348 /// Returns a clone of the channel sender for manual exchange submission.
349 ///
350 /// Useful for consumers that spawn per-request tasks (e.g., `HttpConsumer`)
351 /// where each task independently sends exchanges into the pipeline.
352 /// For simple consumers, prefer `send()` or `send_and_wait()` instead.
353 ///
354 /// Raw pushes through this sender bypass the claim minting of
355 /// [`Self::send`]; components using it MUST capture
356 /// [`Self::in_flight_counter`] once at start and attach a claim to every
357 /// envelope at their acceptance-dequeue point (rc-nftni), or their
358 /// in-flight work stays invisible to `total_in_flight()`.
359 pub fn sender(&self) -> mpsc::Sender<ExchangeEnvelope> {
360 self.sender.clone()
361 }
362
363 /// Publish the inline dispatch capability on this context.
364 ///
365 /// Set once by the camel-core runtime before the consumer starts, when
366 /// the route's effective concurrency model permits the inline fast path.
367 ///
368 /// Set-once, KEEP-FIRST contract: if a dispatcher was already published,
369 /// the first one stays in place, the second call is ignored with a
370 /// warning (it is not an error). All clones of this context share the
371 /// same slot, so a dispatcher set through any clone is visible through
372 /// all of them.
373 pub fn set_inline_dispatcher(&self, dispatcher: Arc<dyn InlineRouteDispatcher>) {
374 if self.inline_dispatcher.set(dispatcher).is_err() {
375 tracing::warn!("inline dispatcher already set; ignoring second set");
376 }
377 }
378
379 /// Returns the inline dispatch capability published by the camel-core
380 /// runtime, if any. See [`Self::set_inline_dispatcher`] for the
381 /// set-once-keep-first contract.
382 pub fn inline_dispatcher(&self) -> Option<Arc<dyn InlineRouteDispatcher>> {
383 self.inline_dispatcher.get().cloned()
384 }
385
386 /// Send an exchange into the route pipeline (fire-and-forget).
387 ///
388 /// Attaches an [`InFlightClaim`] when a counter is installed
389 /// ([`Self::with_in_flight_counter`]); a failed push drops the
390 /// envelope, which drops the claim and rolls the count back.
391 pub async fn send(&self, exchange: Exchange) -> Result<(), CamelError> {
392 self.sender
393 .send(ExchangeEnvelope {
394 exchange,
395 reply_tx: None,
396 in_flight_claim: self.in_flight.as_ref().map(InFlightClaim::attach),
397 })
398 .await
399 .map_err(|_| CamelError::ChannelClosed)
400 }
401
402 /// Send an exchange and wait for the pipeline result (request-reply).
403 ///
404 /// Returns `Ok(exchange)` on success or `Err(e)` if the pipeline failed
405 /// without an error handler absorbing the error.
406 pub async fn send_and_wait(&self, exchange: Exchange) -> Result<Exchange, CamelError> {
407 let (reply_tx, reply_rx) = oneshot::channel();
408 self.sender
409 .send(ExchangeEnvelope {
410 exchange,
411 reply_tx: Some(reply_tx),
412 in_flight_claim: self.in_flight.as_ref().map(InFlightClaim::attach),
413 })
414 .await
415 .map_err(|_| CamelError::ChannelClosed)?;
416 reply_rx.await.map_err(|_| CamelError::ChannelClosed)?
417 }
418}
419
420/// Security context passed to a consumer before `start()`.
421///
422/// Carries the route's security classification so consumers can register
423/// auth state before accepting connections. Declared routes carry the
424/// `SecurityPolicy` from the route controller; plan-only contexts (routes
425/// without a policy declaration) carry just the compiled
426/// [`RouteSecurityPlan`](camel_api::security_policy::RouteSecurityPlan) (`policy = None`). Authentication itself runs
427/// through the kernel fields (`plan` + `providers`).
428pub struct SecurityContext {
429 /// Route policy, when the route declared one. `None` for plan-only
430 /// contexts built via [`SecurityContext::from_plan`].
431 pub policy: Option<Arc<dyn SecurityPolicy>>,
432 pub credential_sources: Vec<CredentialSource>,
433 /// Compiled `RouteSecurityPlan` for the route, when one has been compiled.
434 ///
435 /// Phase-2 transports read the plan from here to drive per-route dispatch
436 /// enforcement. `None` until Task 1.8 compiles plans into the context.
437 pub plan: Option<camel_api::security_policy::RouteSecurityPlan>,
438 /// Provider registry carrying the route's named authenticators.
439 ///
440 /// Phase-2 transports read providers from here (grpc 2.1, mcp 2.6, ws 2.8,
441 /// http 2.9) instead of holding their own authenticator. `None` for routes
442 /// built before the registry injection path lands.
443 pub providers: Option<std::sync::Arc<camel_auth::ProviderRegistry>>,
444}
445
446impl SecurityContext {
447 pub fn new(policy: impl SecurityPolicy + 'static) -> Self {
448 Self {
449 policy: Some(Arc::new(policy)),
450 credential_sources: vec![CredentialSource::AuthorizationHeader],
451 plan: None,
452 providers: None,
453 }
454 }
455
456 pub fn from_arc(policy: Arc<dyn SecurityPolicy>) -> Self {
457 Self {
458 policy: Some(policy),
459 credential_sources: vec![CredentialSource::AuthorizationHeader],
460 plan: None,
461 providers: None,
462 }
463 }
464
465 /// Build a plan-only context: no policy, no providers, empty credential
466 /// sources. The controller delivers a route's compiled classification
467 /// (e.g. the `Public` default for an undeclared server route) through
468 /// this form.
469 pub fn from_plan(plan: camel_api::security_policy::RouteSecurityPlan) -> Self {
470 Self {
471 policy: None,
472 credential_sources: Vec::new(),
473 plan: Some(plan),
474 providers: None,
475 }
476 }
477
478 pub fn with_credential_sources(mut self, sources: Vec<CredentialSource>) -> Self {
479 self.credential_sources = sources;
480 self
481 }
482
483 /// Attach the compiled `RouteSecurityPlan` for this route.
484 pub fn with_plan(mut self, plan: camel_api::security_policy::RouteSecurityPlan) -> Self {
485 self.plan = Some(plan);
486 self
487 }
488
489 /// Attach the provider registry carrying this route's named authenticators.
490 pub fn with_providers(
491 mut self,
492 providers: std::sync::Arc<camel_auth::ProviderRegistry>,
493 ) -> Self {
494 self.providers = Some(providers);
495 self
496 }
497}
498
499impl Clone for SecurityContext {
500 fn clone(&self) -> Self {
501 Self {
502 policy: self.policy.clone(),
503 credential_sources: self.credential_sources.clone(),
504 plan: self.plan.clone(),
505 providers: self.providers.as_ref().map(Arc::clone),
506 }
507 }
508}
509
510impl std::fmt::Debug for SecurityContext {
511 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
512 f.debug_struct("SecurityContext")
513 .field("policy", &self.policy.as_ref().map(|_| "<SecurityPolicy>"))
514 .field("credential_sources", &self.credential_sources)
515 .field("plan", &self.plan)
516 .field(
517 "providers",
518 &self.providers.as_ref().map(|_| "<ProviderRegistry>"),
519 )
520 .finish()
521 }
522}
523
524/// How a consumer's exchanges should be processed by the pipeline.
525#[derive(Debug, Clone, PartialEq, Eq)]
526#[non_exhaustive]
527pub enum ConcurrencyModel {
528 /// Exchanges are processed one at a time, in order. Default for polling
529 /// consumers (timer, file) and synchronous consumers (direct).
530 Sequential,
531 /// Exchanges are processed concurrently via `tokio::spawn`. Optional
532 /// semaphore limit (`max`). `None` means unbounded (channel buffer is
533 /// the only backpressure).
534 Concurrent { max: Option<usize> },
535}
536
537/// A Consumer receives data from an external system and submits Exchanges
538/// to the Route's Pipeline via the [`ConsumerContext`].
539///
540/// # Shutdown Contract
541///
542/// The Runtime guarantees the following lifecycle:
543///
544/// 1. `start()` is called once. The Runtime spawns a task that owns the Consumer.
545/// 2. On route stop, the Runtime cancels the [`ConsumerContext`] cancel token.
546/// 3. The spawned task calls `stop()` on ALL exit paths after `start()` succeeds
547/// (clean exit, crash, cancellation, natural completion).
548/// 4. `background_task_handle()` is a supervision hook for crash propagation (ADR-0007),
549/// NOT the shutdown API.
550///
551/// Readiness does not extend the consumer's lifetime. Without a background handle,
552/// `stop()` follows successful `start()` completion immediately. Long-lived consumers
553/// must keep `start()` alive until cancellation or return a background handle.
554/// Finite completion remains valid; for Explicit consumers, the Runtime only warns
555/// about successful no-handle completion when cancellation has not been requested.
556///
557/// Component authors MUST ensure:
558///
559/// - `stop()` cancels all component-owned inner tasks and cleans up registrations/resources.
560/// - If inner tasks use a private `CancellationToken`, `stop()` MUST cancel it.
561/// - Best practice: inner tasks should use the [`ConsumerContext`] cancel token (or a child)
562/// so they respond to runtime shutdown without waiting for `stop()`.
563/// - If using a private token, `stop()` must cancel it to ensure prompt cleanup.
564/// - `background_task_handle()` returns the `JoinHandle` of the primary background task,
565/// if any. The Runtime monitors this handle for unexpected exits (crash propagation).
566#[async_trait]
567pub trait Consumer: Send + Sync {
568 /// Start consuming messages, sending them through the provided context.
569 async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError>;
570
571 /// Stop consuming messages and clean up all resources.
572 ///
573 /// Called by the Runtime on every exit path after `start()` succeeds.
574 /// See the [Shutdown Contract](#shutdown-contract) above.
575 async fn stop(&mut self) -> Result<(), CamelError>;
576
577 /// Temporarily suspend consuming messages without fully stopping.
578 ///
579 /// Default: no-op, returns `Ok(())`.
580 async fn suspend(&self) -> Result<(), CamelError> {
581 Ok(())
582 }
583
584 /// Resume consuming after a previous suspension.
585 ///
586 /// Default: no-op, returns `Ok(())`.
587 async fn resume(&self) -> Result<(), CamelError> {
588 Ok(())
589 }
590
591 /// Declares this consumer's natural concurrency model.
592 ///
593 /// The runtime uses this to decide whether to process exchanges
594 /// sequentially or spawn per-exchange. Consumers that accept inbound
595 /// connections (HTTP, WebSocket, Kafka) should override this to return
596 /// `ConcurrencyModel::Concurrent`.
597 ///
598 /// Default: `Sequential`.
599 fn concurrency_model(&self) -> ConcurrencyModel {
600 ConcurrencyModel::Sequential
601 }
602
603 /// Declares how the runtime should wait for this consumer's startup.
604 ///
605 /// - [`ConsumerStartupMode::Immediate`] (default): the consumer's
606 /// `start()` IS the lifetime loop. The runtime treats the route as
607 /// started as soon as `start()` is invoked, preserving the existing
608 /// fire-and-forget semantics for timer/file/direct/… consumers.
609 /// - [`ConsumerStartupMode::Explicit`]: the consumer binds/registers a
610 /// resource inside `start()` and MUST call
611 /// [`ConsumerContext::mark_ready`] after a successful bind. The
612 /// runtime awaits this signal (or an early `start()` error) before
613 /// treating the route as started, so HTTP/WebSocket listeners fail
614 /// fast when the bind fails instead of crashing the background task.
615 ///
616 /// Default: `Immediate`.
617 fn startup_mode(&self) -> ConsumerStartupMode {
618 ConsumerStartupMode::Immediate
619 }
620
621 /// Return a handle to the consumer's long-running background task so the
622 /// runtime can monitor it for unexpected exits after `start()` returns `Ok`.
623 ///
624 /// Default: `None` — consumer's work completes entirely within `start()`.
625 /// Override: return `Some(handle)` if `start()` spawns a detached task.
626 ///
627 /// With `None`, the Runtime calls `stop()` immediately after `start()` succeeds,
628 /// even if readiness was already signalled. Keep `start()` alive until
629 /// cancellation or return a handle when the consumer should remain active.
630 /// Successful finite completion is permitted; an Explicit consumer completing
631 /// without a handle before cancellation receives a diagnostic warning.
632 ///
633 /// **Contract:** the task must observe `ConsumerContext::cancelled()` so
634 /// runtime shutdown is distinguishable from crash exits.
635 ///
636 /// This method is called at most once; implementations should use `.take()`
637 /// to transfer ownership of the handle.
638 fn background_task_handle(&mut self) -> Option<JoinHandle<Result<(), CamelError>>> {
639 None
640 }
641
642 /// Set the security context for this consumer.
643 ///
644 /// Called by the route controller before `start()` so the consumer
645 /// can register auth state (e.g. WebSocket auth in `WsAppState`).
646 ///
647 /// Default: no-op, returns `Ok(())`.
648 fn set_security_context(&mut self, _ctx: SecurityContext) {}
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654
655 #[test]
656 fn consumer_context_exposes_route_id() {
657 let (tx, _rx) = mpsc::channel(1);
658 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "test-route".to_string());
659 assert_eq!(ctx.route_id(), "test-route");
660 }
661
662 #[test]
663 fn stop_budget_slot_roundtrip() {
664 let (tx, _rx) = mpsc::channel(1);
665 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "test-route".to_string());
666 assert!(ctx.stop_budget_slot().is_none(), "slot starts uninstalled");
667
668 let (_budget_tx, budget_rx) = watch::channel(None);
669 let ctx = ctx.with_stop_budget_slot(budget_rx);
670
671 let slot = ctx.stop_budget_slot().expect("slot installed");
672 assert_eq!(*slot.borrow(), None, "not-yet-written value is None");
673 }
674
675 #[tokio::test]
676 async fn test_consumer_context_cancelled() {
677 let (tx, _rx) = mpsc::channel(16);
678 let token = CancellationToken::new();
679 let ctx = ConsumerContext::new(tx, token.clone(), "test-route".to_string());
680
681 assert!(!ctx.is_cancelled());
682 token.cancel();
683 ctx.cancelled().await;
684 assert!(ctx.is_cancelled());
685 }
686
687 #[test]
688 fn test_concurrency_model_default_is_sequential() {
689 use super::ConcurrencyModel;
690
691 struct DummyConsumer;
692
693 #[async_trait::async_trait]
694 impl super::Consumer for DummyConsumer {
695 async fn start(&mut self, _ctx: super::ConsumerContext) -> Result<(), CamelError> {
696 Ok(())
697 }
698 async fn stop(&mut self) -> Result<(), CamelError> {
699 Ok(())
700 }
701 }
702
703 let consumer = DummyConsumer;
704 assert_eq!(consumer.concurrency_model(), ConcurrencyModel::Sequential);
705 }
706
707 #[test]
708 fn test_concurrency_model_concurrent_override() {
709 use super::ConcurrencyModel;
710
711 struct ConcurrentConsumer;
712
713 #[async_trait::async_trait]
714 impl super::Consumer for ConcurrentConsumer {
715 async fn start(&mut self, _ctx: super::ConsumerContext) -> Result<(), CamelError> {
716 Ok(())
717 }
718 async fn stop(&mut self) -> Result<(), CamelError> {
719 Ok(())
720 }
721 fn concurrency_model(&self) -> ConcurrencyModel {
722 ConcurrencyModel::Concurrent { max: Some(16) }
723 }
724 }
725
726 let consumer = ConcurrentConsumer;
727 assert_eq!(
728 consumer.concurrency_model(),
729 ConcurrencyModel::Concurrent { max: Some(16) }
730 );
731 }
732
733 // --- ConsumerStartupMode tests ---
734
735 #[test]
736 fn test_default_startup_mode_is_immediate() {
737 struct DummyConsumer;
738
739 #[async_trait::async_trait]
740 impl super::Consumer for DummyConsumer {
741 async fn start(&mut self, _ctx: super::ConsumerContext) -> Result<(), CamelError> {
742 Ok(())
743 }
744 async fn stop(&mut self) -> Result<(), CamelError> {
745 Ok(())
746 }
747 }
748
749 let consumer = DummyConsumer;
750 assert_eq!(
751 consumer.startup_mode(),
752 super::ConsumerStartupMode::Immediate
753 );
754 }
755
756 #[test]
757 fn test_startup_mode_explicit_override() {
758 struct ExplicitConsumer;
759
760 #[async_trait::async_trait]
761 impl super::Consumer for ExplicitConsumer {
762 async fn start(&mut self, _ctx: super::ConsumerContext) -> Result<(), CamelError> {
763 Ok(())
764 }
765 async fn stop(&mut self) -> Result<(), CamelError> {
766 Ok(())
767 }
768 fn startup_mode(&self) -> super::ConsumerStartupMode {
769 super::ConsumerStartupMode::Explicit
770 }
771 }
772
773 let consumer = ExplicitConsumer;
774 assert_eq!(
775 consumer.startup_mode(),
776 super::ConsumerStartupMode::Explicit
777 );
778 }
779
780 #[tokio::test]
781 async fn test_startup_signal_mark_ready_resolves_receiver_ok() {
782 let (signal, receiver) = StartupSignal::pair();
783 // Not yet signalled — receiver should still be pending.
784 assert!(matches!(*receiver.rx.borrow(), StartupState::Pending));
785
786 // Mark ready — receiver must observe Ok.
787 signal.mark_ready();
788 let result = receiver.await_ready().await;
789 assert!(result.is_ok(), "expected Ok after mark_ready");
790 }
791
792 #[tokio::test]
793 async fn test_startup_signal_mark_failed_propagates_error() {
794 let (signal, receiver) = StartupSignal::pair();
795 signal.mark_failed("bind failed".to_string());
796 let err = receiver
797 .await_ready()
798 .await
799 .expect_err("expected Err after mark_failed");
800 match err {
801 CamelError::RouteError(msg) => assert!(msg.contains("bind failed")),
802 other => panic!("expected RouteError, got {other:?}"),
803 }
804 }
805
806 #[tokio::test]
807 async fn test_startup_signal_idempotent_first_wins() {
808 let (signal, receiver) = StartupSignal::pair();
809 signal.mark_ready();
810 // mark_failed after mark_ready must NOT override.
811 signal.mark_failed("late failure".to_string());
812 let result = receiver.await_ready().await;
813 assert!(result.is_ok(), "first transition (Ready) wins");
814 }
815
816 #[tokio::test]
817 async fn test_startup_receiver_immediate_is_pre_resolved_ok() {
818 let receiver = StartupReceiver::immediate();
819 let result = receiver.await_ready().await;
820 assert!(result.is_ok(), "immediate receiver must resolve Ok");
821 }
822
823 #[tokio::test]
824 async fn test_consumer_context_mark_ready_drives_signal() {
825 let (tx, _rx) = mpsc::channel(1);
826 let ctx = ConsumerContext::new(
827 tx,
828 CancellationToken::new(),
829 "startup-test-route".to_string(),
830 );
831 let (signal, receiver) = StartupSignal::pair();
832 let ctx = ctx.with_startup(signal);
833 ctx.mark_ready();
834 let result = receiver.await_ready().await;
835 assert!(result.is_ok(), "ctx.mark_ready must resolve the receiver");
836 }
837
838 #[tokio::test]
839 async fn test_consumer_context_mark_failed_drives_signal() {
840 let (tx, _rx) = mpsc::channel(1);
841 let ctx = ConsumerContext::new(
842 tx,
843 CancellationToken::new(),
844 "startup-fail-route".to_string(),
845 );
846 let (signal, receiver) = StartupSignal::pair();
847 let ctx = ctx.with_startup(signal);
848 ctx.mark_failed("assignment window elapsed".to_string());
849 let err = receiver
850 .await_ready()
851 .await
852 .expect_err("ctx.mark_failed must resolve the receiver as Err");
853 match err {
854 CamelError::RouteError(msg) => assert!(msg.contains("assignment window elapsed")),
855 other => panic!("expected RouteError, got {other:?}"),
856 }
857 }
858
859 #[tokio::test]
860 async fn test_startup_receiver_dropped_sender_returns_err() {
861 // Build a signal/receiver pair and drop the signal without ever
862 // transitioning — receiver must surface a contract-violation error.
863 let (_signal, receiver) = StartupSignal::pair();
864 drop(_signal);
865 let err = receiver
866 .await_ready()
867 .await
868 .expect_err("dropped signal must surface as Err");
869 match err {
870 CamelError::RouteError(msg) => assert!(msg.contains("dropped")),
871 other => panic!("expected RouteError, got {other:?}"),
872 }
873 }
874
875 #[tokio::test]
876 async fn test_consumer_default_suspend_resume() {
877 struct DummyConsumer;
878
879 #[async_trait::async_trait]
880 impl super::Consumer for DummyConsumer {
881 async fn start(&mut self, _ctx: super::ConsumerContext) -> Result<(), CamelError> {
882 Ok(())
883 }
884 async fn stop(&mut self) -> Result<(), CamelError> {
885 Ok(())
886 }
887 }
888
889 let consumer = DummyConsumer;
890 assert!(consumer.suspend().await.is_ok());
891 assert!(consumer.resume().await.is_ok());
892 }
893
894 // --- SecurityContext tests ---
895
896 struct StubPolicy;
897
898 #[async_trait::async_trait]
899 impl SecurityPolicy for StubPolicy {
900 async fn evaluate(
901 &self,
902 _exchange: &mut Exchange,
903 _auth: &camel_api::security_policy::AuthContext<'_>,
904 ) -> Result<camel_api::security_policy::AuthorizationDecision, CamelError> {
905 Ok(camel_api::security_policy::AuthorizationDecision::Granted {
906 principal: camel_api::security_policy::Principal {
907 subject: "stub".into(),
908 issuer: "stub".into(),
909 audience: vec![],
910 scopes: vec![],
911 roles: vec![],
912 claims: serde_json::json!({}),
913 },
914 })
915 }
916 }
917
918 struct StubAuthenticator;
919
920 #[async_trait::async_trait]
921 impl camel_auth::TokenAuthenticator for StubAuthenticator {
922 async fn authenticate_bearer(
923 &self,
924 _token: &str,
925 ) -> Result<camel_api::security_policy::Principal, CamelError> {
926 Ok(camel_api::security_policy::Principal {
927 subject: "stub".into(),
928 issuer: "stub".into(),
929 audience: vec![],
930 scopes: vec![],
931 roles: vec![],
932 claims: serde_json::json!({}),
933 })
934 }
935 }
936
937 #[test]
938 fn test_security_context_new() {
939 let ctx = SecurityContext::new(StubPolicy);
940 assert!(Arc::strong_count(ctx.policy.as_ref().expect("new sets policy")) == 1);
941 assert_eq!(
942 ctx.credential_sources,
943 vec![camel_auth::CredentialSource::AuthorizationHeader]
944 );
945 }
946
947 #[test]
948 fn test_security_context_from_arc() {
949 let policy: Arc<dyn SecurityPolicy> = Arc::new(StubPolicy);
950 let ctx = SecurityContext::from_arc(Arc::clone(&policy));
951 assert!(Arc::ptr_eq(
952 ctx.policy.as_ref().expect("from_arc sets policy"),
953 &policy
954 ));
955 assert_eq!(
956 ctx.credential_sources,
957 vec![camel_auth::CredentialSource::AuthorizationHeader]
958 );
959 }
960
961 #[test]
962 fn test_security_context_clone_independent() {
963 let ctx = SecurityContext::new(StubPolicy);
964 let cloned = ctx.clone();
965 assert!(Arc::ptr_eq(
966 ctx.policy.as_ref().expect("source policy"),
967 cloned.policy.as_ref().expect("cloned policy"),
968 ));
969 assert_eq!(ctx.credential_sources, cloned.credential_sources);
970 }
971
972 #[test]
973 fn test_security_context_debug_redacts_traits() {
974 let ctx = SecurityContext::new(StubPolicy);
975 let debug_str = format!("{ctx:?}");
976 assert!(debug_str.contains("<SecurityPolicy>"));
977 assert!(debug_str.contains("credential_sources"));
978 }
979
980 #[test]
981 fn test_security_context_with_credential_sources() {
982 let ctx = SecurityContext::new(StubPolicy).with_credential_sources(vec![
983 camel_auth::CredentialSource::Cookie {
984 name: "session".into(),
985 },
986 camel_auth::CredentialSource::AuthorizationHeader,
987 ]);
988 assert_eq!(ctx.credential_sources.len(), 2);
989 assert!(matches!(
990 &ctx.credential_sources[0],
991 camel_auth::CredentialSource::Cookie { .. }
992 ));
993 }
994
995 #[test]
996 fn security_context_holds_plan_and_providers() {
997 use camel_api::security_policy::{AccessMode, RouteSecurityPlan, TransportId};
998
999 let plan = RouteSecurityPlan {
1000 access_mode: AccessMode::Public,
1001 provider_ref: None,
1002 transport: TransportId::Http,
1003 credential_sources: vec![],
1004 audience_binding: None,
1005 };
1006
1007 let registry = camel_auth::ProviderRegistry::new();
1008 registry.register(
1009 "default",
1010 camel_auth::ProviderEntry {
1011 authenticator: Arc::new(StubAuthenticator),
1012 audience_binding: None,
1013 },
1014 );
1015
1016 let ctx = SecurityContext::new(StubPolicy)
1017 .with_plan(plan)
1018 .with_providers(Arc::new(registry));
1019
1020 let ctx_plan = ctx.plan.as_ref().expect("plan must be attached");
1021 assert!(matches!(ctx_plan.access_mode, AccessMode::Public));
1022 assert_eq!(ctx_plan.transport, TransportId::Http);
1023
1024 let providers = ctx.providers.as_ref().expect("providers must be attached");
1025 let resolved = providers.resolve("default").expect("provider must resolve");
1026 assert!(resolved.audience_binding.is_none());
1027 }
1028
1029 /// `finish-auth-flip` Task 1.3: the `SecurityContext.authenticator`
1030 /// hand-off is deleted — transports authenticate through the kernel
1031 /// fields (`plan` + `providers`). Registry/doc mentions of the word
1032 /// `authenticator` (e.g. `ProviderEntry`) are allowed; the struct
1033 /// field is not.
1034 #[test]
1035 fn security_context_authenticator_deleted_source_scan() {
1036 let src = include_str!("consumer.rs");
1037 // Assembled at runtime so this scan's own text cannot match.
1038 let needle = format!("pub {}authenticator", "");
1039 assert!(
1040 !src.contains(&needle),
1041 "SecurityContext.authenticator must stay deleted: field found in consumer.rs"
1042 );
1043 }
1044}
1045
1046#[cfg(test)]
1047#[path = "consumer_claim_tests.rs"]
1048mod consumer_claim_tests;