1use std::sync::Arc;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use bytes::Bytes;
6use camel_api::datasource::DatasourceCatalog;
7use futures::TryStreamExt;
8use serde_json::Value as JsonValue;
9use sqlx::AnyPool;
10use sqlx::any::AnyPoolOptions;
11use sqlx::any::AnyRow;
12use tokio::sync::OnceCell;
13use tracing::{debug, error, info, warn};
14
15use camel_component_api::retry_async;
16use camel_component_api::{
17 Body, CamelError, Exchange, Message, RuntimeObservability, StreamBody, StreamMetadata,
18};
19use camel_component_api::{ConcurrencyModel, Consumer, ConsumerContext};
20
21use crate::config::{
22 PollStrategy, ProcessingStrategy, SqlEndpointConfig, SqlOutputType, TransactionMode,
23 enrich_db_url_with_ssl, redact_db_url,
24};
25use crate::headers;
26use crate::query::{QueryTemplate, parse_query_template, resolve_params};
27use crate::utils::{bind_json_values, is_retryable_sqlx_error, row_to_json};
28
29fn record_post_process_failure(
43 runtime: &dyn RuntimeObservability,
44 route_id: &str,
45 label: &str,
46 error: &CamelError,
47 message: &str,
48) {
49 runtime.metrics().increment_errors(route_id, label);
50 error!(error = %error, "{message}");
52}
53
54#[derive(Debug, Clone, Copy, Default)]
58struct PollOutcome {
59 was_empty: bool,
60}
61
62pub struct SqlConsumer {
63 pub(crate) config: SqlEndpointConfig,
64 pub(crate) pool: Arc<OnceCell<Arc<AnyPool>>>,
65 pub(crate) catalog: Option<Arc<dyn DatasourceCatalog>>,
66 stopped: bool,
67 runtime: Arc<dyn RuntimeObservability>,
70}
71
72impl SqlConsumer {
73 pub fn new(
74 config: SqlEndpointConfig,
75 pool: Arc<OnceCell<Arc<AnyPool>>>,
76 catalog: Option<Arc<dyn DatasourceCatalog>>,
77 runtime: Arc<dyn RuntimeObservability>,
78 ) -> Self {
79 Self {
80 config,
81 pool,
82 catalog,
83 stopped: false,
84 runtime,
85 }
86 }
87
88 async fn poll_database(
90 &self,
91 pool: &AnyPool,
92 context: &ConsumerContext,
93 template: &QueryTemplate,
94 ) -> Result<PollOutcome, CamelError> {
95 let route_id = context.route_id();
97
98 let empty_exchange = Exchange::new(Message::default());
100
101 let prepared = resolve_params(template, &empty_exchange, &self.config.in_separator)?;
103
104 debug!(query = %prepared.sql, "executing SQL consumer poll");
105
106 if self.config.output_type == SqlOutputType::StreamList {
107 return self.poll_database_stream(pool, context, &prepared).await;
108 }
109
110 let query = bind_json_values(sqlx::query(&prepared.sql), &prepared.bindings);
111 let rows: Vec<AnyRow> = query.fetch_all(pool).await.map_err(|e| {
112 warn!(error = %e, "SQL consumer poll query failed");
113 CamelError::ProcessorError(format!("Query execution failed: {}", e))
114 })?;
115
116 debug!(rows = rows.len(), "SQL consumer poll completed");
117
118 let was_empty = rows.is_empty();
119 if was_empty && !self.config.route_empty_result_set {
120 return Ok(PollOutcome { was_empty });
121 }
122
123 let rows_to_process: Vec<AnyRow> = if let Some(max) = self.config.max_messages_per_poll {
124 if max > 0 {
125 rows.into_iter().take(max as usize).collect()
126 } else {
127 rows
128 }
129 } else {
130 rows
131 };
132
133 if self.config.use_iterator {
134 for row in rows_to_process {
136 let row_json = row_to_json(&row)?;
137
138 let mut msg = Message::new(Body::Json(row_json.clone()));
140
141 if let Some(obj) = row_json.as_object() {
143 for (key, value) in obj {
144 msg.set_header(format!("CamelSql.{}", key), value.clone());
145 }
146 }
147
148 let exchange = Exchange::new(msg);
149
150 let result = context.send_and_wait(exchange).await;
152
153 if let Err(e) = self.handle_post_processing(pool, &result, &row_json).await {
155 record_post_process_failure(
156 self.runtime.as_ref(),
157 route_id,
158 "b-prime:sql:on-consume",
159 &e,
160 "Post-processing failed",
161 );
162 if self.config.break_batch_on_consume_fail {
163 return Err(e);
164 }
165 }
166
167 if let Err(ref consume_err) = result
169 && self.config.break_batch_on_consume_fail
170 {
171 return Err(consume_err.clone());
172 }
173 }
174 } else {
175 let rows_json: Vec<JsonValue> = rows_to_process
177 .iter()
178 .map(row_to_json)
179 .collect::<Result<Vec<_>, CamelError>>()?;
180
181 let row_count = rows_json.len();
182
183 let mut msg = Message::new(Body::Json(JsonValue::Array(rows_json.clone())));
185 msg.set_header(headers::ROW_COUNT, JsonValue::Number(row_count.into()));
186
187 let exchange = Exchange::new(msg);
188
189 let result = context.send_and_wait(exchange).await;
191
192 for row_json in rows_json.iter() {
196 if let Err(e) = self.handle_post_processing(pool, &result, row_json).await {
197 record_post_process_failure(
198 self.runtime.as_ref(),
199 route_id,
200 "b-prime:sql:on-consume-batch",
201 &e,
202 "Post-processing failed for batch row",
203 );
204 if self.config.break_batch_on_consume_fail {
205 return Err(e);
206 }
207 }
208 }
209
210 if let Err(ref consume_err) = result
212 && self.config.break_batch_on_consume_fail
213 {
214 return Err(consume_err.clone());
215 }
216 }
217
218 if let Some(ref batch_query) = self.config.on_consume_batch_complete {
220 let _ = self
221 .execute_post_query(pool, batch_query, &JsonValue::Null)
222 .await;
223 }
224
225 Ok(PollOutcome { was_empty })
226 }
227
228 async fn poll_database_stream(
229 &self,
230 pool: &AnyPool,
231 context: &ConsumerContext,
232 prepared: &crate::query::PreparedQuery,
233 ) -> Result<PollOutcome, CamelError> {
234 let pool_clone = pool.clone();
235 let sql_str = prepared.sql.clone();
236 let bindings = prepared.bindings.clone();
237
238 let byte_stream = async_stream::try_stream! {
239 let mut q = sqlx::query(&sql_str);
240 q = bind_json_values(q, &bindings);
241 let mut rows = q.fetch(&pool_clone);
242 while let Some(row) = rows.try_next().await.map_err(|e| {
243 CamelError::ProcessorError(format!("Query execution failed: {}", e))
244 })? {
245 let json_val = row_to_json(&row).map_err(|e| {
246 CamelError::ProcessorError(format!("JSON serialization failed: {}", e))
247 })?;
248 let mut bytes = serde_json::to_vec(&json_val)
249 .map_err(|e| CamelError::ProcessorError(format!("JSON serialization failed: {}", e)))?;
250 bytes.push(b'\n');
251 yield Bytes::from(bytes);
252 }
253 };
254
255 let msg = Message::new(Body::Stream(StreamBody {
256 stream: Arc::new(tokio::sync::Mutex::new(Some(Box::pin(byte_stream)))),
257 metadata: StreamMetadata {
258 content_type: Some("application/x-ndjson".to_string()),
259 size_hint: None,
260 origin: None,
261 },
262 }));
263
264 let exchange = Exchange::new(msg);
265 let result = context.send_and_wait(exchange).await;
266 if let Err(e) = result {
267 record_post_process_failure(
268 self.runtime.as_ref(),
269 context.route_id(),
270 "b-prime:sql:stream-list",
271 &e,
272 "StreamList consumer downstream processing failed",
273 );
274 return Err(e);
275 }
276
277 debug!("StreamList: consumer poll completed (lazy stream emitted)");
278 Ok(PollOutcome::default())
280 }
281
282 async fn handle_post_processing(
284 &self,
285 pool: &AnyPool,
286 result: &Result<Exchange, CamelError>,
287 row_json: &JsonValue,
288 ) -> Result<(), CamelError> {
289 match result {
290 Ok(_) => {
291 if let Some(ref on_consume) = self.config.on_consume {
293 self.execute_post_query(pool, on_consume, row_json).await?;
294 }
295 }
296 Err(_) => {
297 if let Some(ref on_consume_failed) = self.config.on_consume_failed {
299 self.execute_post_query(pool, on_consume_failed, row_json)
300 .await?;
301 }
302 }
303 }
304 Ok(())
305 }
306
307 async fn execute_post_query(
309 &self,
310 pool: &AnyPool,
311 query_str: &str,
312 row_json: &JsonValue,
313 ) -> Result<(), CamelError> {
314 let template = parse_query_template(query_str, self.config.placeholder)?;
316
317 let mut temp_msg = Message::new(Body::Json(row_json.clone()));
320 if let Some(obj) = row_json.as_object() {
321 for (key, value) in obj {
322 temp_msg.set_header(format!("CamelSql.{}", key), value.clone());
323 }
324 }
325 let temp_exchange = Exchange::new(temp_msg);
326
327 let prepared = resolve_params(&template, &temp_exchange, &self.config.in_separator)?;
329
330 let query = bind_json_values(sqlx::query(&prepared.sql), &prepared.bindings);
332 let result = query.execute(pool).await.map_err(|e| {
333 CamelError::ProcessorError(format!("Post-query execution failed: {}", e))
334 })?;
335
336 if result.rows_affected() == 0 {
338 warn!(
339 query = query_str,
340 "Post-processing query affected 0 rows — the row may not have been marked correctly"
341 );
342 }
343
344 Ok(())
345 }
346
347 async fn handle_poll_result(
351 &self,
352 pool: &AnyPool,
353 context: &ConsumerContext,
354 template: &QueryTemplate,
355 ) -> PollOutcome {
356 match self.poll_database(pool, context, template).await {
357 Ok(outcome) => outcome,
358 Err(e) => {
359 if self.config.bridge_error_handler {
361 warn!(error = %e, "SQL consumer poll failed (bridged)");
365 if let Err(route_err) = self.bridge_poll_error(context, e).await {
366 error!(error = %route_err, "Failed to bridge SQL consumer error to route");
369 }
370 } else {
371 record_post_process_failure(
372 self.runtime.as_ref(),
373 context.route_id(),
374 "b-prime:sql:poll-failed",
375 &e,
376 "SQL consumer poll failed",
377 );
378 }
379 PollOutcome::default()
381 }
382 }
383 }
384
385 async fn bridge_poll_error(
386 &self,
387 context: &ConsumerContext,
388 error: CamelError,
389 ) -> Result<(), CamelError> {
390 if !self.config.bridge_error_handler {
391 return Ok(());
392 }
393 let mut exchange = Exchange::new(Message::default());
394 exchange.set_error(error);
395 context.send_and_wait(exchange).await.map(|_| ())
396 }
397}
398
399#[async_trait]
400impl Consumer for SqlConsumer {
401 async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
402 if self.stopped {
404 return Err(CamelError::Config(
405 "SQL consumer cannot be restarted after stop".into(),
406 ));
407 }
408
409 let route_id = context.route_id().to_string();
411 let catalog = self.catalog.clone();
412 let ds_name = self.config.datasource_name.clone();
413
414 self.config.resolve_defaults();
416 self.config.resolve_file_query().await?;
417
418 let pool = self
419 .pool
420 .get_or_try_init(|| async {
421 if let (Some(ref cat), Some(ref name)) = (catalog, ds_name) {
423 let handle = cat.get_pool(name).await?;
424 return handle.downcast::<AnyPool>();
425 }
426
427 sqlx::any::install_default_drivers();
430 let db_url = enrich_db_url_with_ssl(&self.config.db_url, &self.config)?;
431
432 let max_conn = self.config.max_connections.ok_or_else(|| {
433 CamelError::Config("max_connections not resolved for SQL consumer pool".into())
434 })?;
435 let min_conn = self.config.min_connections.ok_or_else(|| {
436 CamelError::Config("min_connections not resolved for SQL consumer pool".into())
437 })?;
438 let idle_timeout = self.config.idle_timeout_secs.ok_or_else(|| {
439 CamelError::Config(
440 "idle_timeout_secs not resolved for SQL consumer pool".into(),
441 )
442 })?;
443 let max_lifetime = self.config.max_lifetime_secs.ok_or_else(|| {
444 CamelError::Config(
445 "max_lifetime_secs not resolved for SQL consumer pool".into(),
446 )
447 })?;
448
449 info!(
450 db_url = %redact_db_url(&self.config.db_url),
451 "SQL consumer pool initializing"
452 );
453 let retry_policy = &self.config.retry;
454 let pool = retry_async::<_, _, _, _, sqlx::Error>(
455 retry_policy,
456 "sql",
457 "consumer-pool-init",
458 || {
459 async {
460 AnyPoolOptions::new()
461 .max_connections(max_conn)
462 .min_connections(min_conn)
463 .idle_timeout(Duration::from_secs(idle_timeout))
464 .max_lifetime(Duration::from_secs(max_lifetime))
465 .connect(&db_url)
466 .await
467 }
468 },
469 is_retryable_sqlx_error,
470 Some(self.runtime.metrics().as_ref()),
471 )
472 .await
473 .map_err(|e| {
474 self.runtime.health().force_unhealthy_for_route(
475 &route_id,
476 "g:sql:consumer-pool-init",
477 &e.to_string(),
478 );
479 error!(error = %e, db_url = %redact_db_url(&self.config.db_url), "SQL connect failed, giving up");
481 CamelError::EndpointCreationFailed(format!(
482 "Failed to connect to database: {}",
483 e
484 ))
485 })?;
486 Ok(Arc::new(pool))
487 })
488 .await?;
489
490 if self.config.transaction_mode == TransactionMode::Managed {
492 warn!("transactionManager not yet implemented; using Auto mode");
493 }
494
495 if self.config.processing_strategy == ProcessingStrategy::Scheduled {
497 debug!(
498 "Processing strategy: Scheduled (rows dispatched individually via send_and_wait)"
499 );
500 }
501 if self.config.poll_strategy == PollStrategy::Burst {
502 debug!("Poll strategy: Burst (rapid successive polls)");
503 }
504
505 if self.config.output_type == SqlOutputType::StreamList
506 && (self.config.on_consume.is_some()
507 || self.config.on_consume_failed.is_some()
508 || self.config.on_consume_batch_complete.is_some()
509 || self.config.break_on_empty)
510 {
511 warn!(
512 "onConsume/onConsumeFailed/onConsumeBatchComplete/breakOnEmpty are not executed in \
513 StreamList mode (rows are consumed lazily downstream)"
514 );
515 }
516
517 if self.config.on_consume.is_none() {
519 warn!(
520 "SQL consumer started without onConsume configured — consumed rows will not be marked/deleted"
521 );
522 }
523
524 info!(
525 db_url = %redact_db_url(&self.config.db_url),
526 query_len = self.config.query.len(),
527 "SQL consumer started"
528 );
529
530 let template = parse_query_template(&self.config.query, self.config.placeholder)
532 .map_err(|e| CamelError::Config(format!("Invalid query template: {}", e)))?;
533
534 if self.config.initial_delay_ms > 0 {
536 tokio::select! {
537 _ = context.cancelled() => {
538 info!("SQL consumer stopped during initial delay");
539 return Ok(());
540 }
541 _ = tokio::time::sleep(Duration::from_millis(self.config.initial_delay_ms)) => {}
542 }
543 }
544
545 let mut poll_count: u32 = 0;
561 loop {
562 if let Some(max_repeats) = self.config.repeat_count
564 && poll_count >= max_repeats
565 {
566 info!(
567 repeat_count = max_repeats,
568 "SQL consumer reached repeat_count limit, stopping"
569 );
570 break;
571 }
572
573 tokio::select! {
574 _ = context.cancelled() => {
575 info!("SQL consumer stopped");
576 break;
577 }
578 _ = tokio::time::sleep(Duration::from_millis(self.config.delay_ms)) => {
579 poll_count += 1;
580 let outcome = self.handle_poll_result(pool.as_ref(), &context, &template).await;
581 if self.config.break_on_empty && outcome.was_empty {
582 info!("SQL consumer stopping: break_on_empty triggered (poll returned 0 rows)");
583 break;
584 }
585 }
586 }
587 }
588
589 Ok(())
590 }
591
592 async fn stop(&mut self) -> Result<(), CamelError> {
593 if self.stopped {
595 debug!("SQL consumer stop called on already-stopped consumer");
596 return Ok(());
597 }
598
599 if let Some(pool) = self.pool.get() {
601 debug!("SQL consumer closing connection pool");
602 pool.close().await;
603 debug!("SQL consumer pool closed");
604 }
605
606 self.stopped = true;
607 info!("SQL consumer stopped");
608 Ok(())
609 }
610
611 fn concurrency_model(&self) -> ConcurrencyModel {
612 ConcurrencyModel::Sequential
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622 use camel_api::MetricsCollector;
623 use camel_component_api::HealthCheckRegistry;
624 use camel_component_api::test_support::PanicRuntimeObservability;
625 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
626 std::sync::Arc::new(PanicRuntimeObservability)
627 }
628 use crate::config::SqlEndpointConfig;
629 use camel_component_api::ExchangeEnvelope;
630 use camel_component_api::UriConfig;
631 use sqlx::any::AnyPoolOptions;
632 use std::sync::Arc;
633 use std::sync::Mutex;
634 use std::time::Duration;
635 use tokio::sync::mpsc;
636 use tokio_util::sync::CancellationToken;
637
638 struct RecordingMetrics {
643 errors: Arc<Mutex<Vec<(String, String)>>>,
644 }
645
646 impl MetricsCollector for RecordingMetrics {
647 fn record_exchange_duration(&self, _: &str, _: Duration) {}
648 fn increment_errors(&self, route_id: &str, error_type: &str) {
649 self.errors
650 .lock()
651 .unwrap()
652 .push((route_id.to_string(), error_type.to_string()));
653 }
654 fn increment_exchanges(&self, _: &str) {}
655 fn set_queue_depth(&self, _: &str, _: usize) {}
656 fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
657 }
658
659 struct RecordingRuntime {
660 metrics_collector: Arc<RecordingMetrics>,
661 }
662
663 impl RecordingRuntime {
664 fn new(errors: Arc<Mutex<Vec<(String, String)>>>) -> Self {
665 Self {
666 metrics_collector: Arc::new(RecordingMetrics { errors }),
667 }
668 }
669 }
670
671 impl RuntimeObservability for RecordingRuntime {
672 fn metrics(&self) -> Arc<dyn MetricsCollector> {
673 self.metrics_collector.clone() as Arc<dyn MetricsCollector>
674 }
675 fn health(&self) -> Arc<dyn HealthCheckRegistry> {
676 panic!("RecordingRuntime::health not used in this test")
677 }
678 }
679
680 #[tracing_test::traced_test]
684 #[test]
685 fn record_post_process_failure_increments_errors_and_emits_error_log() {
686 let errors: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
687 let runtime = Arc::new(RecordingRuntime::new(Arc::clone(&errors)));
688 let error = CamelError::ProcessorError("test failure".to_string());
689
690 record_post_process_failure(
692 runtime.as_ref(),
693 "test-route",
694 "b-prime:sql:on-consume",
695 &error,
696 "Post-processing failed",
697 );
698
699 let recorded = errors.lock().unwrap();
701 assert_eq!(recorded.len(), 1, "expected 1 increment_errors call");
702 assert_eq!(recorded[0].0, "test-route");
703 assert_eq!(recorded[0].1, "b-prime:sql:on-consume");
704 drop(recorded);
705
706 assert!(logs_contain("ERROR"), "helper must emit error! log");
708 assert!(
709 logs_contain("Post-processing failed"),
710 "helper must include the message in the log"
711 );
712 }
713
714 async fn sqlite_pool() -> AnyPool {
715 sqlx::any::install_default_drivers();
716 AnyPoolOptions::new()
717 .max_connections(1)
718 .connect("sqlite::memory:")
719 .await
720 .expect("sqlite pool")
721 }
722
723 async fn seed_consumer_table(pool: &AnyPool) {
724 sqlx::query("CREATE TABLE jobs (id INTEGER PRIMARY KEY, processed INTEGER DEFAULT 0, failed INTEGER DEFAULT 0)")
725 .execute(pool)
726 .await
727 .expect("create table");
728 sqlx::query("INSERT INTO jobs (id, processed, failed) VALUES (1, 0, 0), (2, 0, 0)")
729 .execute(pool)
730 .await
731 .expect("seed rows");
732 }
733
734 fn config() -> SqlEndpointConfig {
735 let mut c =
736 SqlEndpointConfig::from_uri("sql:select * from t?db_url=postgres://localhost/test")
737 .unwrap();
738 c.resolve_defaults();
739 c
740 }
741
742 #[test]
743 fn consumer_concurrency_model() {
744 let c = SqlConsumer::new(config(), Arc::new(OnceCell::new()), None, test_rt());
745 assert_eq!(c.concurrency_model(), ConcurrencyModel::Sequential);
746 }
747
748 #[test]
749 fn consumer_stores_config() {
750 let mut config = SqlEndpointConfig::from_uri(
751 "sql:select * from t?db_url=postgres://localhost/test&delay=2000&onConsume=update t set done=true"
752 ).unwrap();
753 config.resolve_defaults();
754 let c = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
755 assert_eq!(c.config.delay_ms, 2000);
756 assert!(c.config.on_consume.is_some());
757 }
758
759 #[tokio::test]
760 async fn poll_database_runs_on_consume_for_successful_rows() {
761 let pool = sqlite_pool().await;
762 seed_consumer_table(&pool).await;
763
764 let mut config = SqlEndpointConfig::from_uri(
765 "sql:select id, processed, failed from jobs where processed = 0 order by id?db_url=sqlite::memory:&onConsume=update jobs set processed=1 where id=:#id&initialDelay=0&delay=1",
766 )
767 .unwrap();
768 config.resolve_defaults();
769
770 let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
771 let template = parse_query_template(&config.query, config.placeholder).unwrap();
772
773 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
774 tokio::spawn(async move {
775 while let Some(env) = rx.recv().await {
776 if let Some(reply_tx) = env.reply_tx {
777 let _ = reply_tx.send(Ok(env.exchange));
778 }
779 }
780 });
781 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
782
783 consumer
784 .poll_database(&pool, &ctx, &template)
785 .await
786 .expect("poll must succeed");
787
788 let row = sqlx::query("select processed from jobs where id = 1")
789 .fetch_one(&pool)
790 .await
791 .expect("row 1");
792 let processed_1: i64 = sqlx::Row::try_get(&row, 0).expect("processed");
793
794 let row = sqlx::query("select processed from jobs where id = 2")
795 .fetch_one(&pool)
796 .await
797 .expect("row 2");
798 let processed_2: i64 = sqlx::Row::try_get(&row, 0).expect("processed");
799
800 assert_eq!(processed_1, 1);
801 assert_eq!(processed_2, 1);
802 }
803
804 #[tokio::test]
805 async fn poll_database_runs_on_consume_failed_when_downstream_fails() {
806 let pool = sqlite_pool().await;
807 seed_consumer_table(&pool).await;
808
809 let mut config = SqlEndpointConfig::from_uri(
810 "sql:select id, processed, failed from jobs where processed = 0 order by id?db_url=sqlite::memory:&onConsumeFailed=update jobs set failed=1 where id=:#id&initialDelay=0&delay=1",
811 )
812 .unwrap();
813 config.resolve_defaults();
814
815 let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
816 let template = parse_query_template(&config.query, config.placeholder).unwrap();
817
818 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
819 tokio::spawn(async move {
820 while let Some(env) = rx.recv().await {
821 if let Some(reply_tx) = env.reply_tx {
822 let _ =
823 reply_tx.send(Err(CamelError::ProcessorError("downstream boom".into())));
824 }
825 }
826 });
827 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
828
829 consumer
830 .poll_database(&pool, &ctx, &template)
831 .await
832 .expect("consumer should swallow downstream errors when breakBatchOnConsumeFail=false");
833
834 let row = sqlx::query("select failed from jobs where id = 1")
835 .fetch_one(&pool)
836 .await
837 .expect("row 1");
838 let failed_1: i64 = sqlx::Row::try_get(&row, 0).expect("failed");
839
840 let row = sqlx::query("select failed from jobs where id = 2")
841 .fetch_one(&pool)
842 .await
843 .expect("row 2");
844 let failed_2: i64 = sqlx::Row::try_get(&row, 0).expect("failed");
845
846 assert_eq!(failed_1, 1);
847 assert_eq!(failed_2, 1);
848 }
849
850 #[tokio::test]
851 async fn poll_database_breaks_batch_on_consume_fail() {
852 let pool = sqlite_pool().await;
853 seed_consumer_table(&pool).await;
854
855 let mut config = SqlEndpointConfig::from_uri(
856 "sql:select id, processed, failed from jobs where processed = 0 order by id?db_url=sqlite::memory:&onConsumeFailed=update jobs set failed=1 where id=:#id&breakBatchOnConsumeFail=true&initialDelay=0&delay=1",
857 )
858 .unwrap();
859 config.resolve_defaults();
860
861 let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
862 let template = parse_query_template(&config.query, config.placeholder).unwrap();
863
864 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
865 tokio::spawn(async move {
866 while let Some(env) = rx.recv().await {
867 if let Some(reply_tx) = env.reply_tx {
868 let _ =
869 reply_tx.send(Err(CamelError::ProcessorError("downstream boom".into())));
870 }
871 }
872 });
873 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
874
875 let err = consumer
876 .poll_database(&pool, &ctx, &template)
877 .await
878 .expect_err("must stop on first downstream failure");
879 assert!(err.to_string().contains("downstream boom"));
880
881 let row = sqlx::query("select failed from jobs where id = 1")
882 .fetch_one(&pool)
883 .await
884 .expect("row 1");
885 let failed_1: i64 = sqlx::Row::try_get(&row, 0).expect("failed");
886
887 let row = sqlx::query("select failed from jobs where id = 2")
888 .fetch_one(&pool)
889 .await
890 .expect("row 2");
891 let failed_2: i64 = sqlx::Row::try_get(&row, 0).expect("failed");
892
893 assert_eq!(failed_1, 1);
894 assert_eq!(failed_2, 0, "second row must not be processed");
895 }
896
897 #[tokio::test]
903 async fn consumer_no_panic_without_prior_resolve_defaults() {
904 let config = SqlEndpointConfig::from_uri(
905 "sql:select 1?db_url=sqlite::memory:&initialDelay=0&delay=1",
906 )
907 .unwrap();
908 assert!(config.max_connections.is_none());
910
911 let mut consumer = SqlConsumer::new(
912 config,
913 Arc::new(OnceCell::new()),
914 None,
915 std::sync::Arc::new(camel_component_api::test_support::NoopRuntimeObservability),
918 );
919 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
920 tokio::spawn(async move {
921 while let Some(env) = rx.recv().await {
922 if let Some(reply_tx) = env.reply_tx {
923 let _ = reply_tx.send(Ok(env.exchange));
924 }
925 }
926 });
927 let token = CancellationToken::new();
928 let ctx = ConsumerContext::new(tx, token.clone(), "sql-test-route".to_string());
929
930 let consumer_handle = tokio::spawn(async move { consumer.start(ctx).await });
932
933 tokio::time::sleep(Duration::from_millis(50)).await;
935 token.cancel();
936
937 let result = consumer_handle.await.expect("task should not panic");
938 let _ = result;
940 }
941
942 #[tokio::test]
944 async fn stop_closes_pool() {
945 let pool = sqlite_pool().await;
946 seed_consumer_table(&pool).await;
947
948 let mut config = SqlEndpointConfig::from_uri(
949 "sql:select id from jobs?db_url=sqlite::memory:&onConsume=update jobs set processed=1 where id=:#id&initialDelay=0&delay=1",
950 )
951 .unwrap();
952 config.resolve_defaults();
953
954 let pool_cell = Arc::new(OnceCell::new());
955 pool_cell.set(Arc::new(pool.clone())).unwrap();
956
957 let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
958 consumer.stop().await.expect("stop should succeed");
959
960 assert!(
962 pool.is_closed(),
963 "Pool should be closed after consumer.stop()"
964 );
965 }
966
967 #[tokio::test]
969 async fn double_stop_is_safe() {
970 let pool = sqlite_pool().await;
971 let mut config = SqlEndpointConfig::from_uri(
972 "sql:select 1?db_url=sqlite::memory:&initialDelay=0&delay=1",
973 )
974 .unwrap();
975 config.resolve_defaults();
976
977 let pool_cell = Arc::new(OnceCell::new());
978 pool_cell.set(Arc::new(pool.clone())).unwrap();
979
980 let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
981 consumer.stop().await.expect("first stop should succeed");
982 consumer
983 .stop()
984 .await
985 .expect("second stop should also succeed");
986 }
987
988 #[tokio::test]
990 async fn start_after_stop_rejected() {
991 let pool = sqlite_pool().await;
992 let mut config = SqlEndpointConfig::from_uri(
993 "sql:select 1?db_url=sqlite::memory:&initialDelay=0&delay=1",
994 )
995 .unwrap();
996 config.resolve_defaults();
997
998 let pool_cell = Arc::new(OnceCell::new());
999 pool_cell.set(Arc::new(pool.clone())).unwrap();
1000
1001 let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
1002 consumer.stop().await.expect("stop should succeed");
1003
1004 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1005 tokio::spawn(async move {
1006 while let Some(env) = rx.recv().await {
1007 if let Some(reply_tx) = env.reply_tx {
1008 let _ = reply_tx.send(Ok(env.exchange));
1009 }
1010 }
1011 });
1012 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1013
1014 let result = consumer.start(ctx).await;
1015 assert!(result.is_err());
1016 let err_msg = result.unwrap_err().to_string();
1017 assert!(
1018 err_msg.contains("cannot be restarted") || err_msg.contains("after stop"),
1019 "Expected restart error, got: {}",
1020 err_msg
1021 );
1022 }
1023
1024 #[tokio::test]
1026 async fn batch_mode_per_row_post_processing() {
1027 let pool = sqlite_pool().await;
1028 seed_consumer_table(&pool).await;
1029
1030 let mut config = SqlEndpointConfig::from_uri(
1031 "sql:select id, processed, failed from jobs where processed = 0 order by id?db_url=sqlite::memory:&onConsume=update jobs set processed=1 where id=:#id&useIterator=false&initialDelay=0&delay=1",
1032 )
1033 .unwrap();
1034 config.resolve_defaults();
1035
1036 let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
1037 let template = parse_query_template(&config.query, config.placeholder).unwrap();
1038
1039 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1040 tokio::spawn(async move {
1041 while let Some(env) = rx.recv().await {
1042 if let Some(reply_tx) = env.reply_tx {
1043 let _ = reply_tx.send(Ok(env.exchange));
1044 }
1045 }
1046 });
1047 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1048
1049 consumer
1050 .poll_database(&pool, &ctx, &template)
1051 .await
1052 .expect("poll must succeed");
1053
1054 let row = sqlx::query("select processed from jobs where id = 1")
1056 .fetch_one(&pool)
1057 .await
1058 .expect("row 1");
1059 let processed_1: i64 = sqlx::Row::try_get(&row, 0).expect("processed");
1060
1061 let row = sqlx::query("select processed from jobs where id = 2")
1062 .fetch_one(&pool)
1063 .await
1064 .expect("row 2");
1065 let processed_2: i64 = sqlx::Row::try_get(&row, 0).expect("processed");
1066
1067 assert_eq!(
1068 processed_1, 1,
1069 "row 1 should be marked processed via per-row onConsume"
1070 );
1071 assert_eq!(
1072 processed_2, 1,
1073 "row 2 should be marked processed via per-row onConsume"
1074 );
1075 }
1076
1077 #[tokio::test]
1079 async fn batch_mode_per_row_post_processing_on_failure() {
1080 let pool = sqlite_pool().await;
1081 seed_consumer_table(&pool).await;
1082
1083 let mut config = SqlEndpointConfig::from_uri(
1084 "sql:select id, processed, failed from jobs where processed = 0 order by id?db_url=sqlite::memory:&onConsumeFailed=update jobs set failed=1 where id=:#id&useIterator=false&initialDelay=0&delay=1",
1085 )
1086 .unwrap();
1087 config.resolve_defaults();
1088
1089 let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
1090 let template = parse_query_template(&config.query, config.placeholder).unwrap();
1091
1092 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1093 tokio::spawn(async move {
1094 while let Some(env) = rx.recv().await {
1095 if let Some(reply_tx) = env.reply_tx {
1096 let _ =
1097 reply_tx.send(Err(CamelError::ProcessorError("downstream boom".into())));
1098 }
1099 }
1100 });
1101 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1102
1103 consumer
1104 .poll_database(&pool, &ctx, &template)
1105 .await
1106 .expect("consumer should swallow downstream errors when breakBatchOnConsumeFail=false");
1107
1108 let row = sqlx::query("select failed from jobs where id = 1")
1110 .fetch_one(&pool)
1111 .await
1112 .expect("row 1");
1113 let failed_1: i64 = sqlx::Row::try_get(&row, 0).expect("failed");
1114
1115 let row = sqlx::query("select failed from jobs where id = 2")
1116 .fetch_one(&pool)
1117 .await
1118 .expect("row 2");
1119 let failed_2: i64 = sqlx::Row::try_get(&row, 0).expect("failed");
1120
1121 assert_eq!(
1122 failed_1, 1,
1123 "row 1 should be marked failed via per-row onConsumeFailed"
1124 );
1125 assert_eq!(
1126 failed_2, 1,
1127 "row 2 should be marked failed via per-row onConsumeFailed"
1128 );
1129 }
1130
1131 #[tokio::test]
1132 async fn bridge_error_handler_routes_poll_errors_to_exchange_error() {
1133 let mut config = config();
1134 config.bridge_error_handler = true;
1135 let consumer = SqlConsumer::new(config, Arc::new(OnceCell::new()), None, test_rt());
1136
1137 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(4);
1138 tokio::spawn(async move {
1139 #[allow(clippy::never_loop)]
1140 while let Some(env) = rx.recv().await {
1141 assert!(env.exchange.error.is_some(), "exchange must carry error");
1142 if let Some(reply_tx) = env.reply_tx {
1143 let _ = reply_tx.send(Ok(env.exchange));
1144 }
1145 break;
1146 }
1147 });
1148
1149 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1150 consumer
1151 .bridge_poll_error(&ctx, CamelError::ProcessorError("poll failed".into()))
1152 .await
1153 .expect("bridging should succeed");
1154 }
1155
1156 #[tracing_test::traced_test]
1160 #[tokio::test]
1161 async fn bridged_poll_failure_emits_warn_not_error() {
1162 let pool = sqlite_pool().await;
1163 let mut config = config();
1168 config.bridge_error_handler = true;
1169 config.query = "select * from nonexistent_table".to_string();
1171 config.resolve_defaults();
1172 let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
1173 let template = parse_query_template(&config.query, config.placeholder).unwrap();
1174
1175 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(4);
1178 tokio::spawn(async move {
1179 while let Some(env) = rx.recv().await {
1180 if let Some(reply_tx) = env.reply_tx {
1181 let _ = reply_tx.send(Ok(env.exchange));
1182 }
1183 }
1184 });
1185 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1186
1187 consumer.handle_poll_result(&pool, &ctx, &template).await;
1189
1190 assert!(
1192 !logs_contain("ERROR"),
1193 "bridged poll failure must not emit ERROR (handler owns it); check captured logs for stray ERROR lines"
1194 );
1195 assert!(
1197 logs_contain("WARN"),
1198 "bridged poll failure should emit warn! for operator visibility"
1199 );
1200 }
1201
1202 #[tracing_test::traced_test]
1212 #[tokio::test]
1213 async fn unbridged_send_and_wait_failure_emits_error_loud() {
1214 let pool = sqlite_pool().await;
1215 sqlx::query("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)")
1216 .execute(&pool)
1217 .await
1218 .expect("create table");
1219 sqlx::query("INSERT INTO items (id, name) VALUES (1, 'alpha')")
1220 .execute(&pool)
1221 .await
1222 .expect("seed rows");
1223
1224 let mut config = SqlEndpointConfig::from_uri(
1225 "sql:select id, name from items order by id?db_url=sqlite::memory:&outputType=StreamList&initialDelay=0&delay=1",
1226 )
1227 .unwrap();
1228 config.resolve_defaults();
1229 config.bridge_error_handler = false;
1231 let consumer = SqlConsumer::new(
1232 config.clone(),
1233 Arc::new(OnceCell::new()),
1234 None,
1235 Arc::new(RecordingRuntime::new(Arc::new(Mutex::new(Vec::new())))),
1236 );
1237 let template = parse_query_template(&config.query, config.placeholder).unwrap();
1238
1239 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1241 tokio::spawn(async move {
1242 while let Some(env) = rx.recv().await {
1243 if let Some(reply_tx) = env.reply_tx {
1244 let _ = reply_tx.send(Err(CamelError::ProcessorError("boom".into())));
1245 }
1246 }
1247 });
1248 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1249
1250 let _ = consumer.poll_database(&pool, &ctx, &template).await;
1251
1252 assert!(
1254 logs_contain("ERROR"),
1255 "unbridged send_and_wait failure MUST emit ERROR (consumer owns the signal)"
1256 );
1257 }
1258
1259 #[tracing_test::traced_test]
1262 #[tokio::test]
1263 async fn unbridged_handle_poll_result_emits_error_loud() {
1264 let pool = sqlite_pool().await;
1265 let mut config = config();
1268 config.bridge_error_handler = false;
1269 config.query = "select * from nonexistent_table".to_string();
1270 config.resolve_defaults();
1271 let consumer = SqlConsumer::new(
1272 config.clone(),
1273 Arc::new(OnceCell::new()),
1274 None,
1275 Arc::new(RecordingRuntime::new(Arc::new(Mutex::new(Vec::new())))),
1276 );
1277 let template = parse_query_template(&config.query, config.placeholder).unwrap();
1278
1279 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(4);
1281 tokio::spawn(async move {
1282 while let Some(env) = rx.recv().await {
1283 if let Some(reply_tx) = env.reply_tx {
1284 let _ = reply_tx.send(Ok(env.exchange));
1285 }
1286 }
1287 });
1288 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1289
1290 consumer.handle_poll_result(&pool, &ctx, &template).await;
1291
1292 assert!(
1293 logs_contain("ERROR"),
1294 "unbridged handle_poll_result failure MUST emit ERROR (consumer owns signal)"
1295 );
1296 }
1297
1298 #[tokio::test]
1299 async fn stream_list_consumer_emits_ndjson_body() {
1300 let pool = sqlite_pool().await;
1301 sqlx::query("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)")
1302 .execute(&pool)
1303 .await
1304 .expect("create table");
1305 sqlx::query("INSERT INTO items (id, name) VALUES (1, 'alpha'), (2, 'beta'), (3, 'gamma')")
1306 .execute(&pool)
1307 .await
1308 .expect("seed rows");
1309
1310 let mut config = SqlEndpointConfig::from_uri(
1311 "sql:select id, name from items order by id?db_url=sqlite::memory:&outputType=StreamList&initialDelay=0&delay=1",
1312 )
1313 .unwrap();
1314 config.resolve_defaults();
1315
1316 let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
1317 let template = parse_query_template(&config.query, config.placeholder).unwrap();
1318
1319 let (tx, rx) = mpsc::channel::<ExchangeEnvelope>(8);
1320 let (result_tx, result_rx) = tokio::sync::oneshot::channel::<Exchange>();
1321 tokio::spawn(async move {
1322 let mut rx = rx;
1323 if let Some(env) = rx.recv().await {
1324 if let Some(reply_tx) = env.reply_tx {
1325 let _ = reply_tx.send(Ok(env.exchange.clone()));
1326 }
1327 let _ = result_tx.send(env.exchange);
1328 }
1329 });
1330 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1331
1332 consumer
1333 .poll_database(&pool, &ctx, &template)
1334 .await
1335 .expect("poll must succeed");
1336
1337 let exchange = result_rx.await.expect("should have received one exchange");
1338
1339 match exchange.input.body {
1340 Body::Stream(ref stream_body) => {
1341 let stream = stream_body.stream.clone();
1342 let mut guard = stream.lock().await;
1343 let stream_opt = guard.take();
1344 assert!(stream_opt.is_some(), "stream should be present");
1345
1346 use futures::StreamExt;
1347 let mut collected = Vec::new();
1348 let mut stream = stream_opt.unwrap();
1349 while let Some(chunk) = stream.next().await {
1350 let chunk = chunk.expect("stream chunk should not error");
1351 collected.extend_from_slice(&chunk);
1352 }
1353
1354 let ndjson = String::from_utf8(collected).expect("valid utf8");
1355 let lines: Vec<&str> = ndjson.trim().lines().collect();
1356 assert_eq!(lines.len(), 3, "should have 3 NDJSON lines");
1357
1358 let row0: serde_json::Value =
1359 serde_json::from_str(lines[0]).expect("valid json line 0");
1360 assert_eq!(row0["id"], 1);
1361 assert_eq!(row0["name"], "alpha");
1362
1363 let row1: serde_json::Value =
1364 serde_json::from_str(lines[1]).expect("valid json line 1");
1365 assert_eq!(row1["id"], 2);
1366 assert_eq!(row1["name"], "beta");
1367
1368 let row2: serde_json::Value =
1369 serde_json::from_str(lines[2]).expect("valid json line 2");
1370 assert_eq!(row2["id"], 3);
1371 assert_eq!(row2["name"], "gamma");
1372 }
1373 ref other => panic!("expected Body::Stream, got {:?}", other),
1374 }
1375 }
1376
1377 #[tokio::test]
1378 async fn stream_list_consumer_empty_result_set_emits_empty_stream() {
1379 let pool = sqlite_pool().await;
1380 sqlx::query("CREATE TABLE empty_items (id INTEGER PRIMARY KEY, name TEXT)")
1381 .execute(&pool)
1382 .await
1383 .expect("create table");
1384
1385 let mut config = SqlEndpointConfig::from_uri(
1386 "sql:select id, name from empty_items?db_url=sqlite::memory:&outputType=StreamList&initialDelay=0&delay=1",
1387 )
1388 .unwrap();
1389 config.resolve_defaults();
1390
1391 let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
1392 let template = parse_query_template(&config.query, config.placeholder).unwrap();
1393
1394 let (tx, rx) = tokio::sync::oneshot::channel();
1395 let (mpsc_tx, mut mpsc_rx) = mpsc::channel::<ExchangeEnvelope>(8);
1396 tokio::spawn(async move {
1397 #[allow(clippy::never_loop)]
1398 while let Some(env) = mpsc_rx.recv().await {
1399 if let Some(reply_tx) = env.reply_tx {
1400 let _ = reply_tx.send(Ok(env.exchange.clone()));
1401 }
1402 let _ = tx.send(env.exchange);
1403 break;
1404 }
1405 });
1406 let ctx = ConsumerContext::new(
1407 mpsc_tx,
1408 CancellationToken::new(),
1409 "sql-test-route".to_string(),
1410 );
1411
1412 consumer
1413 .poll_database(&pool, &ctx, &template)
1414 .await
1415 .expect("poll must succeed");
1416
1417 let exchange = rx
1418 .await
1419 .expect("StreamList should emit exchange even for empty results");
1420
1421 match exchange.input.body {
1422 Body::Stream(ref stream_body) => {
1423 let stream = stream_body.stream.clone();
1424 let mut guard = stream.lock().await;
1425 let stream_opt = guard.take();
1426
1427 use futures::StreamExt;
1428 let mut count = 0;
1429 if let Some(mut stream) = stream_opt {
1430 while let Some(chunk) = stream.next().await {
1431 let chunk = chunk.expect("stream chunk should not error");
1432 count += chunk.len();
1433 }
1434 }
1435 assert_eq!(count, 0, "empty table should produce zero stream bytes");
1436 }
1437 ref other => panic!("expected Body::Stream, got {:?}", other),
1438 }
1439 }
1440
1441 #[tokio::test]
1442 async fn break_on_empty_stops_after_drained_table() {
1443 let pool = sqlite_pool().await;
1444 seed_consumer_table(&pool).await;
1445
1446 let mut config = SqlEndpointConfig::from_uri(
1448 "sql:select id from jobs where processed = 0 order by id?db_url=sqlite::memory:&onConsume=update jobs set processed=1 where id=:#id&initialDelay=0&delay=50&breakOnEmpty=true&repeatCount=100",
1449 )
1450 .unwrap();
1451 config.resolve_defaults();
1452
1453 let pool_cell = Arc::new(OnceCell::new());
1456 pool_cell
1457 .set(Arc::new(pool.clone()))
1458 .expect("pool cell set");
1459 let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
1460
1461 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1462 let route_cancel = CancellationToken::new();
1463 let received = Arc::new(std::sync::atomic::AtomicU32::new(0));
1467 let echo_cancel = route_cancel.clone();
1468 let counter = Arc::clone(&received);
1469 tokio::spawn(async move {
1471 loop {
1472 tokio::select! {
1473 _ = echo_cancel.cancelled() => break,
1474 Some(env) = rx.recv() => {
1475 counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1476 if let Some(reply_tx) = env.reply_tx {
1477 let _ = reply_tx.send(Ok(env.exchange));
1478 }
1479 }
1480 }
1481 }
1482 });
1483
1484 let ctx = ConsumerContext::new(tx, route_cancel.clone(), "sql-test-route".to_string());
1485
1486 let start = tokio::time::Instant::now();
1488 consumer.start(ctx).await.expect("start must succeed");
1489 let elapsed = start.elapsed();
1490
1491 let n = received.load(std::sync::atomic::Ordering::Relaxed);
1494 assert_eq!(
1495 n, 2,
1496 "productive poll should emit 2 envelopes (2 seeded rows), got {}",
1497 n
1498 );
1499
1500 let count_unprocessed: i64 =
1502 sqlx::query_scalar("select count(*) from jobs where processed = 0")
1503 .fetch_one(&pool)
1504 .await
1505 .expect("count");
1506 assert_eq!(count_unprocessed, 0);
1507
1508 assert!(
1511 elapsed < std::time::Duration::from_millis(500),
1512 "consumer should have stopped on empty poll, took {:?}",
1513 elapsed
1514 );
1515
1516 assert!(
1525 elapsed >= std::time::Duration::from_millis(100),
1526 "consumer must run a second (empty) poll before breaking on break_on_empty, \
1527 took {:?} — likely broke after the productive poll without seeing the empty one",
1528 elapsed
1529 );
1530 }
1531
1532 #[tokio::test]
1536 async fn handle_poll_result_error_does_not_signal_empty() {
1537 let pool = sqlite_pool().await;
1538
1539 let mut config = SqlEndpointConfig::from_uri(
1540 "sql:select * from this_table_does_not_exist?db_url=sqlite::memory:&breakOnEmpty=true&initialDelay=0&delay=1",
1541 )
1542 .unwrap();
1543 config.resolve_defaults();
1544
1545 let pool_cell = Arc::new(OnceCell::new());
1546 pool_cell.set(Arc::new(pool.clone())).unwrap();
1547 let consumer = SqlConsumer::new(
1550 config.clone(),
1551 pool_cell,
1552 None,
1553 Arc::new(RecordingRuntime::new(Arc::new(Mutex::new(Vec::new())))),
1554 );
1555
1556 let template = parse_query_template(&config.query, config.placeholder).unwrap();
1557
1558 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1559 tokio::spawn(async move {
1560 while let Some(env) = rx.recv().await {
1561 if let Some(reply_tx) = env.reply_tx {
1562 let _ = reply_tx.send(Ok(env.exchange));
1563 }
1564 }
1565 });
1566 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1567
1568 let outcome = consumer.handle_poll_result(&pool, &ctx, &template).await;
1569 assert!(
1570 !outcome.was_empty,
1571 "poll error must NOT signal empty (was_empty should be false)"
1572 );
1573 }
1574
1575 #[derive(Debug, Default)]
1579 struct RecordingHealth {
1580 forced: Arc<Mutex<Vec<(String, String, String)>>>,
1581 }
1582
1583 impl HealthCheckRegistry for RecordingHealth {
1584 fn force_unhealthy_for_route(&self, route_id: &str, name: &str, reason: &str) {
1585 self.forced.lock().unwrap().push((
1586 route_id.to_string(),
1587 name.to_string(),
1588 reason.to_string(),
1589 ));
1590 }
1591 }
1592
1593 struct NoopMetricsForConsumer;
1594
1595 impl MetricsCollector for NoopMetricsForConsumer {
1596 fn record_exchange_duration(&self, _: &str, _: Duration) {}
1597 fn increment_errors(&self, _: &str, _: &str) {}
1598 fn increment_exchanges(&self, _: &str) {}
1599 fn set_queue_depth(&self, _: &str, _: usize) {}
1600 fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
1601 }
1602
1603 struct RecordingRuntimeWithHealth {
1604 health: Arc<RecordingHealth>,
1605 }
1606
1607 impl RuntimeObservability for RecordingRuntimeWithHealth {
1608 fn metrics(&self) -> Arc<dyn MetricsCollector> {
1609 Arc::new(NoopMetricsForConsumer)
1610 }
1611 fn health(&self) -> Arc<dyn HealthCheckRegistry> {
1612 self.health.clone()
1613 }
1614 }
1615
1616 #[tokio::test]
1619 async fn consumer_pool_init_failure_calls_force_unhealthy_for_route() {
1620 let health = Arc::new(RecordingHealth::default());
1621 let recorded_health = health.clone();
1622 let rt: Arc<dyn RuntimeObservability> = Arc::new(RecordingRuntimeWithHealth { health });
1623
1624 let mut config = SqlEndpointConfig::from_uri(
1625 "sql:select 1?db_url=postgres://nonexistent-host:5432/nonexistent_db&retryEnabled=false&initialDelay=0&delay=1",
1626 )
1627 .unwrap();
1628 config.max_connections = Some(1);
1629 config.min_connections = Some(0);
1630 config.idle_timeout_secs = Some(300);
1631 config.max_lifetime_secs = Some(1800);
1632
1633 let mut consumer = SqlConsumer::new(config, Arc::new(OnceCell::new()), None, rt);
1634
1635 let (tx, _rx) = mpsc::channel(8);
1636 let ctx = ConsumerContext::new(
1637 tx,
1638 CancellationToken::new(),
1639 "sql-consumer-test-route".to_string(),
1640 );
1641
1642 let result = consumer.start(ctx).await;
1643 assert!(result.is_err(), "pool init should fail with bad db_url");
1644
1645 let forced = recorded_health.forced.lock().unwrap();
1646 assert_eq!(
1647 forced.len(),
1648 1,
1649 "expected one force_unhealthy_for_route call"
1650 );
1651 assert_eq!(forced[0].0, "sql-consumer-test-route");
1652 assert_eq!(forced[0].1, "g:sql:consumer-pool-init");
1653 assert!(!forced[0].2.is_empty(), "reason should be non-empty");
1654 }
1655
1656 #[tokio::test]
1660 async fn retry_loop_invokes_operation_exactly_max_attempts_times() {
1661 use camel_component_api::NetworkRetryPolicy;
1662 use std::sync::Arc;
1663 use std::sync::atomic::{AtomicU32, Ordering};
1664
1665 let policy = NetworkRetryPolicy {
1666 max_attempts: 3,
1667 initial_delay: Duration::from_millis(1),
1668 max_delay: Duration::from_millis(1),
1669 multiplier: 1.0,
1670 ..NetworkRetryPolicy::default()
1671 };
1672
1673 let calls = Arc::new(AtomicU32::new(0));
1674 let calls_clone = Arc::clone(&calls);
1675
1676 let mut attempt: u32 = 0;
1677 let _result: Result<(), ()> = loop {
1678 attempt += 1;
1679 calls_clone.fetch_add(1, Ordering::SeqCst);
1680 let op_result: Result<(), ()> = Err(());
1681 match op_result {
1682 Ok(v) => break Ok(v),
1683 Err(_) if policy.should_retry(attempt) => {
1684 let delay = policy.delay_for(attempt - 1);
1685 tokio::time::sleep(delay).await;
1686 continue;
1687 }
1688 Err(_) => break Err(()),
1689 }
1690 };
1691
1692 assert_eq!(
1693 calls.load(Ordering::SeqCst),
1694 3,
1695 "max_attempts=3 must yield exactly 3 invocations"
1696 );
1697 }
1698
1699 #[tokio::test]
1702 #[tracing_test::traced_test]
1703 async fn break_on_empty_ignored_in_streamlist() {
1704 let pool = sqlite_pool().await;
1705 seed_consumer_table(&pool).await;
1706
1707 let mut config = SqlEndpointConfig::from_uri(
1711 "sql:select id from jobs?db_url=sqlite::memory:&outputType=StreamList&initialDelay=0&delay=1&breakOnEmpty=true&repeatCount=3",
1712 )
1713 .unwrap();
1714 config.resolve_defaults();
1715 assert_eq!(config.output_type, SqlOutputType::StreamList);
1716 assert!(config.break_on_empty);
1717
1718 let pool_cell = Arc::new(OnceCell::new());
1719 pool_cell
1720 .set(Arc::new(pool.clone()))
1721 .expect("pool cell set");
1722 let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
1723
1724 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1725 let route_cancel = CancellationToken::new();
1726 let received = Arc::new(std::sync::atomic::AtomicU32::new(0));
1727 let echo_cancel = route_cancel.clone();
1728 let counter = Arc::clone(&received);
1729 tokio::spawn(async move {
1730 loop {
1731 tokio::select! {
1732 _ = echo_cancel.cancelled() => break,
1733 Some(env) = rx.recv() => {
1734 counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1735 if let Some(reply_tx) = env.reply_tx {
1736 let _ = reply_tx.send(Ok(env.exchange));
1737 }
1738 }
1739 }
1740 }
1741 });
1742
1743 let ctx = ConsumerContext::new(tx, route_cancel, "sql-test-route".to_string());
1744 consumer.start(ctx).await.expect("start must succeed");
1745
1746 assert!(
1749 logs_contain("breakOnEmpty"),
1750 "expected StreamList warn naming breakOnEmpty"
1751 );
1752
1753 assert!(
1757 received.load(std::sync::atomic::Ordering::Relaxed) >= 2,
1758 "StreamList must not break early with breakOnEmpty, got {} exchanges",
1759 received.load(std::sync::atomic::Ordering::Relaxed)
1760 );
1761 }
1762
1763 #[tokio::test]
1764 async fn break_on_empty_false_default_loops_on_empty() {
1765 let pool = sqlite_pool().await;
1766 seed_consumer_table(&pool).await;
1768 sqlx::query("delete from jobs")
1769 .execute(&pool)
1770 .await
1771 .expect("drain");
1772
1773 let mut config = SqlEndpointConfig::from_uri(
1776 "sql:select id from jobs where processed = 0?db_url=sqlite::memory:&initialDelay=0&delay=20&repeatCount=3",
1777 )
1778 .unwrap();
1779 config.resolve_defaults();
1780 assert!(!config.break_on_empty);
1781
1782 let pool_cell = Arc::new(OnceCell::new());
1783 pool_cell
1784 .set(Arc::new(pool.clone()))
1785 .expect("pool cell set");
1786 let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
1787
1788 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1789 let route_cancel = CancellationToken::new();
1790 let echo_cancel = route_cancel.clone();
1791 tokio::spawn(async move {
1792 loop {
1793 tokio::select! {
1794 _ = echo_cancel.cancelled() => break,
1795 Some(env) = rx.recv() => {
1796 if let Some(reply_tx) = env.reply_tx {
1797 let _ = reply_tx.send(Ok(env.exchange));
1798 }
1799 }
1800 }
1801 }
1802 });
1803
1804 let ctx = ConsumerContext::new(tx, route_cancel, "sql-test-route".to_string());
1805 let start = tokio::time::Instant::now();
1806 consumer.start(ctx).await.expect("start must succeed");
1807 let elapsed = start.elapsed();
1808
1809 assert!(
1813 elapsed >= std::time::Duration::from_millis(55),
1814 "consumer should run all 3 polls (breakOnEmpty=false), took {:?}",
1815 elapsed
1816 );
1817 }
1818
1819 #[tokio::test]
1820 async fn break_on_empty_with_route_empty_result_set() {
1821 let pool = sqlite_pool().await;
1822 seed_consumer_table(&pool).await;
1823 sqlx::query("delete from jobs")
1824 .execute(&pool)
1825 .await
1826 .expect("drain");
1827 sqlx::query("CREATE TABLE batch_marks (n INTEGER NOT NULL DEFAULT 0)")
1833 .execute(&pool)
1834 .await
1835 .expect("create batch_marks");
1836 sqlx::query("INSERT INTO batch_marks (n) VALUES (0)")
1837 .execute(&pool)
1838 .await
1839 .expect("seed batch_marks");
1840
1841 let mut config = SqlEndpointConfig::from_uri(
1846 "sql:select id from jobs where processed = 0?db_url=sqlite::memory:&routeEmptyResultSet=true&breakOnEmpty=true&useIterator=false&onConsumeBatchComplete=update batch_marks set n = n + 1&initialDelay=0&delay=10&repeatCount=100",
1847 )
1848 .unwrap();
1849 config.resolve_defaults();
1850
1851 let pool_cell = Arc::new(OnceCell::new());
1852 pool_cell
1853 .set(Arc::new(pool.clone()))
1854 .expect("pool cell set");
1855 let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
1856
1857 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1858 let route_cancel = CancellationToken::new();
1859 let received = Arc::new(std::sync::atomic::AtomicU32::new(0));
1860 let echo_cancel = route_cancel.clone();
1861 let counter = Arc::clone(&received);
1862 tokio::spawn(async move {
1863 loop {
1864 tokio::select! {
1865 _ = echo_cancel.cancelled() => break,
1866 Some(env) = rx.recv() => {
1867 counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1868 if let Some(reply_tx) = env.reply_tx {
1869 let _ = reply_tx.send(Ok(env.exchange));
1870 }
1871 }
1872 }
1873 }
1874 });
1875
1876 let ctx = ConsumerContext::new(tx, route_cancel, "sql-test-route".to_string());
1877 let start = tokio::time::Instant::now();
1878 consumer.start(ctx).await.expect("start must succeed");
1879
1880 let n = received.load(std::sync::atomic::Ordering::Relaxed);
1884 assert_eq!(n, 1, "expected exactly 1 empty-result exchange, got {}", n);
1885 assert!(
1886 start.elapsed() < std::time::Duration::from_millis(500),
1887 "consumer should have stopped after the first empty poll, took {:?}",
1888 start.elapsed()
1889 );
1890
1891 let batch_fires: i64 = sqlx::query_scalar("select n from batch_marks")
1896 .fetch_one(&pool)
1897 .await
1898 .expect("batch_marks n");
1899 assert_eq!(
1900 batch_fires, 1,
1901 "onConsumeBatchComplete must fire exactly once on the empty-poll \
1902 fall-through before break_on_empty, got {}",
1903 batch_fires
1904 );
1905 }
1906
1907 #[tokio::test]
1908 async fn repeat_count_zero_polls_never() {
1909 let pool = sqlite_pool().await;
1910 seed_consumer_table(&pool).await;
1911
1912 let mut config = SqlEndpointConfig::from_uri(
1914 "sql:select id from jobs?db_url=sqlite::memory:&initialDelay=0&delay=1&repeatCount=0&onConsume=update jobs set processed=1 where id=:#id",
1915 )
1916 .unwrap();
1917 config.resolve_defaults();
1918
1919 let pool_cell = Arc::new(OnceCell::new());
1922 pool_cell
1923 .set(Arc::new(pool.clone()))
1924 .expect("pool cell set");
1925 let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
1926
1927 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1928 let route_cancel = CancellationToken::new();
1929 let echo_cancel = route_cancel.clone();
1930 tokio::spawn(async move {
1931 loop {
1932 tokio::select! {
1933 _ = echo_cancel.cancelled() => break,
1934 Some(env) = rx.recv() => {
1935 if let Some(reply_tx) = env.reply_tx {
1936 let _ = reply_tx.send(Ok(env.exchange));
1937 }
1938 }
1939 }
1940 }
1941 });
1942
1943 let ctx = ConsumerContext::new(tx, route_cancel, "sql-test-route".to_string());
1944 consumer.start(ctx).await.expect("start must succeed");
1945
1946 let count_processed: i64 =
1948 sqlx::query_scalar("select count(*) from jobs where processed = 1")
1949 .fetch_one(&pool)
1950 .await
1951 .expect("count");
1952 assert_eq!(count_processed, 0, "repeatCount=0 must not poll");
1953 }
1954}