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 Some("sql-consumer"),
457 || {
458 async {
459 AnyPoolOptions::new()
460 .max_connections(max_conn)
461 .min_connections(min_conn)
462 .idle_timeout(Duration::from_secs(idle_timeout))
463 .max_lifetime(Duration::from_secs(max_lifetime))
464 .connect(&db_url)
465 .await
466 }
467 },
468 is_retryable_sqlx_error,
469 )
470 .await
471 .map_err(|e| {
472 self.runtime.health().force_unhealthy_for_route(
473 &route_id,
474 "g:sql:consumer-pool-init",
475 &e.to_string(),
476 );
477 error!(error = %e, db_url = %redact_db_url(&self.config.db_url), "SQL connect failed, giving up");
479 CamelError::EndpointCreationFailed(format!(
480 "Failed to connect to database: {}",
481 e
482 ))
483 })?;
484 Ok(Arc::new(pool))
485 })
486 .await?;
487
488 if self.config.transaction_mode == TransactionMode::Managed {
490 warn!("transactionManager not yet implemented; using Auto mode");
491 }
492
493 if self.config.processing_strategy == ProcessingStrategy::Scheduled {
495 debug!(
496 "Processing strategy: Scheduled (rows dispatched individually via send_and_wait)"
497 );
498 }
499 if self.config.poll_strategy == PollStrategy::Burst {
500 debug!("Poll strategy: Burst (rapid successive polls)");
501 }
502
503 if self.config.output_type == SqlOutputType::StreamList
504 && (self.config.on_consume.is_some()
505 || self.config.on_consume_failed.is_some()
506 || self.config.on_consume_batch_complete.is_some()
507 || self.config.break_on_empty)
508 {
509 warn!(
510 "onConsume/onConsumeFailed/onConsumeBatchComplete/breakOnEmpty are not executed in \
511 StreamList mode (rows are consumed lazily downstream)"
512 );
513 }
514
515 if self.config.on_consume.is_none() {
517 warn!(
518 "SQL consumer started without onConsume configured — consumed rows will not be marked/deleted"
519 );
520 }
521
522 info!(
523 db_url = %redact_db_url(&self.config.db_url),
524 query_len = self.config.query.len(),
525 "SQL consumer started"
526 );
527
528 let template = parse_query_template(&self.config.query, self.config.placeholder)
530 .map_err(|e| CamelError::Config(format!("Invalid query template: {}", e)))?;
531
532 if self.config.initial_delay_ms > 0 {
534 tokio::select! {
535 _ = context.cancelled() => {
536 info!("SQL consumer stopped during initial delay");
537 return Ok(());
538 }
539 _ = tokio::time::sleep(Duration::from_millis(self.config.initial_delay_ms)) => {}
540 }
541 }
542
543 let mut poll_count: u32 = 0;
559 loop {
560 if let Some(max_repeats) = self.config.repeat_count
562 && poll_count >= max_repeats
563 {
564 info!(
565 repeat_count = max_repeats,
566 "SQL consumer reached repeat_count limit, stopping"
567 );
568 break;
569 }
570
571 tokio::select! {
572 _ = context.cancelled() => {
573 info!("SQL consumer stopped");
574 break;
575 }
576 _ = tokio::time::sleep(Duration::from_millis(self.config.delay_ms)) => {
577 poll_count += 1;
578 let outcome = self.handle_poll_result(pool.as_ref(), &context, &template).await;
579 if self.config.break_on_empty && outcome.was_empty {
580 info!("SQL consumer stopping: break_on_empty triggered (poll returned 0 rows)");
581 break;
582 }
583 }
584 }
585 }
586
587 Ok(())
588 }
589
590 async fn stop(&mut self) -> Result<(), CamelError> {
591 if self.stopped {
593 debug!("SQL consumer stop called on already-stopped consumer");
594 return Ok(());
595 }
596
597 if let Some(pool) = self.pool.get() {
599 debug!("SQL consumer closing connection pool");
600 pool.close().await;
601 debug!("SQL consumer pool closed");
602 }
603
604 self.stopped = true;
605 info!("SQL consumer stopped");
606 Ok(())
607 }
608
609 fn concurrency_model(&self) -> ConcurrencyModel {
610 ConcurrencyModel::Sequential
614 }
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620 use camel_api::MetricsCollector;
621 use camel_component_api::HealthCheckRegistry;
622 use camel_component_api::test_support::PanicRuntimeObservability;
623 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
624 std::sync::Arc::new(PanicRuntimeObservability)
625 }
626 use crate::config::SqlEndpointConfig;
627 use camel_component_api::ExchangeEnvelope;
628 use camel_component_api::UriConfig;
629 use sqlx::any::AnyPoolOptions;
630 use std::sync::Arc;
631 use std::sync::Mutex;
632 use std::time::Duration;
633 use tokio::sync::mpsc;
634 use tokio_util::sync::CancellationToken;
635
636 struct RecordingMetrics {
641 errors: Arc<Mutex<Vec<(String, String)>>>,
642 }
643
644 impl MetricsCollector for RecordingMetrics {
645 fn record_exchange_duration(&self, _: &str, _: Duration) {}
646 fn increment_errors(&self, route_id: &str, error_type: &str) {
647 self.errors
648 .lock()
649 .unwrap()
650 .push((route_id.to_string(), error_type.to_string()));
651 }
652 fn increment_exchanges(&self, _: &str) {}
653 fn set_queue_depth(&self, _: &str, _: usize) {}
654 fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
655 }
656
657 struct RecordingRuntime {
658 metrics_collector: Arc<RecordingMetrics>,
659 }
660
661 impl RecordingRuntime {
662 fn new(errors: Arc<Mutex<Vec<(String, String)>>>) -> Self {
663 Self {
664 metrics_collector: Arc::new(RecordingMetrics { errors }),
665 }
666 }
667 }
668
669 impl RuntimeObservability for RecordingRuntime {
670 fn metrics(&self) -> Arc<dyn MetricsCollector> {
671 self.metrics_collector.clone() as Arc<dyn MetricsCollector>
672 }
673 fn health(&self) -> Arc<dyn HealthCheckRegistry> {
674 panic!("RecordingRuntime::health not used in this test")
675 }
676 }
677
678 #[tracing_test::traced_test]
682 #[test]
683 fn record_post_process_failure_increments_errors_and_emits_error_log() {
684 let errors: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
685 let runtime = Arc::new(RecordingRuntime::new(Arc::clone(&errors)));
686 let error = CamelError::ProcessorError("test failure".to_string());
687
688 record_post_process_failure(
690 runtime.as_ref(),
691 "test-route",
692 "b-prime:sql:on-consume",
693 &error,
694 "Post-processing failed",
695 );
696
697 let recorded = errors.lock().unwrap();
699 assert_eq!(recorded.len(), 1, "expected 1 increment_errors call");
700 assert_eq!(recorded[0].0, "test-route");
701 assert_eq!(recorded[0].1, "b-prime:sql:on-consume");
702 drop(recorded);
703
704 assert!(logs_contain("ERROR"), "helper must emit error! log");
706 assert!(
707 logs_contain("Post-processing failed"),
708 "helper must include the message in the log"
709 );
710 }
711
712 async fn sqlite_pool() -> AnyPool {
713 sqlx::any::install_default_drivers();
714 AnyPoolOptions::new()
715 .max_connections(1)
716 .connect("sqlite::memory:")
717 .await
718 .expect("sqlite pool")
719 }
720
721 async fn seed_consumer_table(pool: &AnyPool) {
722 sqlx::query("CREATE TABLE jobs (id INTEGER PRIMARY KEY, processed INTEGER DEFAULT 0, failed INTEGER DEFAULT 0)")
723 .execute(pool)
724 .await
725 .expect("create table");
726 sqlx::query("INSERT INTO jobs (id, processed, failed) VALUES (1, 0, 0), (2, 0, 0)")
727 .execute(pool)
728 .await
729 .expect("seed rows");
730 }
731
732 fn config() -> SqlEndpointConfig {
733 let mut c =
734 SqlEndpointConfig::from_uri("sql:select * from t?db_url=postgres://localhost/test")
735 .unwrap();
736 c.resolve_defaults();
737 c
738 }
739
740 #[test]
741 fn consumer_concurrency_model() {
742 let c = SqlConsumer::new(config(), Arc::new(OnceCell::new()), None, test_rt());
743 assert_eq!(c.concurrency_model(), ConcurrencyModel::Sequential);
744 }
745
746 #[test]
747 fn consumer_stores_config() {
748 let mut config = SqlEndpointConfig::from_uri(
749 "sql:select * from t?db_url=postgres://localhost/test&delay=2000&onConsume=update t set done=true"
750 ).unwrap();
751 config.resolve_defaults();
752 let c = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
753 assert_eq!(c.config.delay_ms, 2000);
754 assert!(c.config.on_consume.is_some());
755 }
756
757 #[tokio::test]
758 async fn poll_database_runs_on_consume_for_successful_rows() {
759 let pool = sqlite_pool().await;
760 seed_consumer_table(&pool).await;
761
762 let mut config = SqlEndpointConfig::from_uri(
763 "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",
764 )
765 .unwrap();
766 config.resolve_defaults();
767
768 let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
769 let template = parse_query_template(&config.query, config.placeholder).unwrap();
770
771 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
772 tokio::spawn(async move {
773 while let Some(env) = rx.recv().await {
774 if let Some(reply_tx) = env.reply_tx {
775 let _ = reply_tx.send(Ok(env.exchange));
776 }
777 }
778 });
779 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
780
781 consumer
782 .poll_database(&pool, &ctx, &template)
783 .await
784 .expect("poll must succeed");
785
786 let row = sqlx::query("select processed from jobs where id = 1")
787 .fetch_one(&pool)
788 .await
789 .expect("row 1");
790 let processed_1: i64 = sqlx::Row::try_get(&row, 0).expect("processed");
791
792 let row = sqlx::query("select processed from jobs where id = 2")
793 .fetch_one(&pool)
794 .await
795 .expect("row 2");
796 let processed_2: i64 = sqlx::Row::try_get(&row, 0).expect("processed");
797
798 assert_eq!(processed_1, 1);
799 assert_eq!(processed_2, 1);
800 }
801
802 #[tokio::test]
803 async fn poll_database_runs_on_consume_failed_when_downstream_fails() {
804 let pool = sqlite_pool().await;
805 seed_consumer_table(&pool).await;
806
807 let mut config = SqlEndpointConfig::from_uri(
808 "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",
809 )
810 .unwrap();
811 config.resolve_defaults();
812
813 let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
814 let template = parse_query_template(&config.query, config.placeholder).unwrap();
815
816 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
817 tokio::spawn(async move {
818 while let Some(env) = rx.recv().await {
819 if let Some(reply_tx) = env.reply_tx {
820 let _ =
821 reply_tx.send(Err(CamelError::ProcessorError("downstream boom".into())));
822 }
823 }
824 });
825 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
826
827 consumer
828 .poll_database(&pool, &ctx, &template)
829 .await
830 .expect("consumer should swallow downstream errors when breakBatchOnConsumeFail=false");
831
832 let row = sqlx::query("select failed from jobs where id = 1")
833 .fetch_one(&pool)
834 .await
835 .expect("row 1");
836 let failed_1: i64 = sqlx::Row::try_get(&row, 0).expect("failed");
837
838 let row = sqlx::query("select failed from jobs where id = 2")
839 .fetch_one(&pool)
840 .await
841 .expect("row 2");
842 let failed_2: i64 = sqlx::Row::try_get(&row, 0).expect("failed");
843
844 assert_eq!(failed_1, 1);
845 assert_eq!(failed_2, 1);
846 }
847
848 #[tokio::test]
849 async fn poll_database_breaks_batch_on_consume_fail() {
850 let pool = sqlite_pool().await;
851 seed_consumer_table(&pool).await;
852
853 let mut config = SqlEndpointConfig::from_uri(
854 "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",
855 )
856 .unwrap();
857 config.resolve_defaults();
858
859 let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
860 let template = parse_query_template(&config.query, config.placeholder).unwrap();
861
862 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
863 tokio::spawn(async move {
864 while let Some(env) = rx.recv().await {
865 if let Some(reply_tx) = env.reply_tx {
866 let _ =
867 reply_tx.send(Err(CamelError::ProcessorError("downstream boom".into())));
868 }
869 }
870 });
871 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
872
873 let err = consumer
874 .poll_database(&pool, &ctx, &template)
875 .await
876 .expect_err("must stop on first downstream failure");
877 assert!(err.to_string().contains("downstream boom"));
878
879 let row = sqlx::query("select failed from jobs where id = 1")
880 .fetch_one(&pool)
881 .await
882 .expect("row 1");
883 let failed_1: i64 = sqlx::Row::try_get(&row, 0).expect("failed");
884
885 let row = sqlx::query("select failed from jobs where id = 2")
886 .fetch_one(&pool)
887 .await
888 .expect("row 2");
889 let failed_2: i64 = sqlx::Row::try_get(&row, 0).expect("failed");
890
891 assert_eq!(failed_1, 1);
892 assert_eq!(failed_2, 0, "second row must not be processed");
893 }
894
895 #[tokio::test]
901 async fn consumer_no_panic_without_prior_resolve_defaults() {
902 let config = SqlEndpointConfig::from_uri(
903 "sql:select 1?db_url=sqlite::memory:&initialDelay=0&delay=1",
904 )
905 .unwrap();
906 assert!(config.max_connections.is_none());
908
909 let mut consumer = SqlConsumer::new(config, Arc::new(OnceCell::new()), None, test_rt());
910 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
911 tokio::spawn(async move {
912 while let Some(env) = rx.recv().await {
913 if let Some(reply_tx) = env.reply_tx {
914 let _ = reply_tx.send(Ok(env.exchange));
915 }
916 }
917 });
918 let token = CancellationToken::new();
919 let ctx = ConsumerContext::new(tx, token.clone(), "sql-test-route".to_string());
920
921 let consumer_handle = tokio::spawn(async move { consumer.start(ctx).await });
923
924 tokio::time::sleep(Duration::from_millis(50)).await;
926 token.cancel();
927
928 let result = consumer_handle.await.expect("task should not panic");
929 let _ = result;
931 }
932
933 #[tokio::test]
935 async fn stop_closes_pool() {
936 let pool = sqlite_pool().await;
937 seed_consumer_table(&pool).await;
938
939 let mut config = SqlEndpointConfig::from_uri(
940 "sql:select id from jobs?db_url=sqlite::memory:&onConsume=update jobs set processed=1 where id=:#id&initialDelay=0&delay=1",
941 )
942 .unwrap();
943 config.resolve_defaults();
944
945 let pool_cell = Arc::new(OnceCell::new());
946 pool_cell.set(Arc::new(pool.clone())).unwrap();
947
948 let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
949 consumer.stop().await.expect("stop should succeed");
950
951 assert!(
953 pool.is_closed(),
954 "Pool should be closed after consumer.stop()"
955 );
956 }
957
958 #[tokio::test]
960 async fn double_stop_is_safe() {
961 let pool = sqlite_pool().await;
962 let mut config = SqlEndpointConfig::from_uri(
963 "sql:select 1?db_url=sqlite::memory:&initialDelay=0&delay=1",
964 )
965 .unwrap();
966 config.resolve_defaults();
967
968 let pool_cell = Arc::new(OnceCell::new());
969 pool_cell.set(Arc::new(pool.clone())).unwrap();
970
971 let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
972 consumer.stop().await.expect("first stop should succeed");
973 consumer
974 .stop()
975 .await
976 .expect("second stop should also succeed");
977 }
978
979 #[tokio::test]
981 async fn start_after_stop_rejected() {
982 let pool = sqlite_pool().await;
983 let mut config = SqlEndpointConfig::from_uri(
984 "sql:select 1?db_url=sqlite::memory:&initialDelay=0&delay=1",
985 )
986 .unwrap();
987 config.resolve_defaults();
988
989 let pool_cell = Arc::new(OnceCell::new());
990 pool_cell.set(Arc::new(pool.clone())).unwrap();
991
992 let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
993 consumer.stop().await.expect("stop should succeed");
994
995 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
996 tokio::spawn(async move {
997 while let Some(env) = rx.recv().await {
998 if let Some(reply_tx) = env.reply_tx {
999 let _ = reply_tx.send(Ok(env.exchange));
1000 }
1001 }
1002 });
1003 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1004
1005 let result = consumer.start(ctx).await;
1006 assert!(result.is_err());
1007 let err_msg = result.unwrap_err().to_string();
1008 assert!(
1009 err_msg.contains("cannot be restarted") || err_msg.contains("after stop"),
1010 "Expected restart error, got: {}",
1011 err_msg
1012 );
1013 }
1014
1015 #[tokio::test]
1017 async fn batch_mode_per_row_post_processing() {
1018 let pool = sqlite_pool().await;
1019 seed_consumer_table(&pool).await;
1020
1021 let mut config = SqlEndpointConfig::from_uri(
1022 "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",
1023 )
1024 .unwrap();
1025 config.resolve_defaults();
1026
1027 let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
1028 let template = parse_query_template(&config.query, config.placeholder).unwrap();
1029
1030 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1031 tokio::spawn(async move {
1032 while let Some(env) = rx.recv().await {
1033 if let Some(reply_tx) = env.reply_tx {
1034 let _ = reply_tx.send(Ok(env.exchange));
1035 }
1036 }
1037 });
1038 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1039
1040 consumer
1041 .poll_database(&pool, &ctx, &template)
1042 .await
1043 .expect("poll must succeed");
1044
1045 let row = sqlx::query("select processed from jobs where id = 1")
1047 .fetch_one(&pool)
1048 .await
1049 .expect("row 1");
1050 let processed_1: i64 = sqlx::Row::try_get(&row, 0).expect("processed");
1051
1052 let row = sqlx::query("select processed from jobs where id = 2")
1053 .fetch_one(&pool)
1054 .await
1055 .expect("row 2");
1056 let processed_2: i64 = sqlx::Row::try_get(&row, 0).expect("processed");
1057
1058 assert_eq!(
1059 processed_1, 1,
1060 "row 1 should be marked processed via per-row onConsume"
1061 );
1062 assert_eq!(
1063 processed_2, 1,
1064 "row 2 should be marked processed via per-row onConsume"
1065 );
1066 }
1067
1068 #[tokio::test]
1070 async fn batch_mode_per_row_post_processing_on_failure() {
1071 let pool = sqlite_pool().await;
1072 seed_consumer_table(&pool).await;
1073
1074 let mut config = SqlEndpointConfig::from_uri(
1075 "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",
1076 )
1077 .unwrap();
1078 config.resolve_defaults();
1079
1080 let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
1081 let template = parse_query_template(&config.query, config.placeholder).unwrap();
1082
1083 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1084 tokio::spawn(async move {
1085 while let Some(env) = rx.recv().await {
1086 if let Some(reply_tx) = env.reply_tx {
1087 let _ =
1088 reply_tx.send(Err(CamelError::ProcessorError("downstream boom".into())));
1089 }
1090 }
1091 });
1092 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1093
1094 consumer
1095 .poll_database(&pool, &ctx, &template)
1096 .await
1097 .expect("consumer should swallow downstream errors when breakBatchOnConsumeFail=false");
1098
1099 let row = sqlx::query("select failed from jobs where id = 1")
1101 .fetch_one(&pool)
1102 .await
1103 .expect("row 1");
1104 let failed_1: i64 = sqlx::Row::try_get(&row, 0).expect("failed");
1105
1106 let row = sqlx::query("select failed from jobs where id = 2")
1107 .fetch_one(&pool)
1108 .await
1109 .expect("row 2");
1110 let failed_2: i64 = sqlx::Row::try_get(&row, 0).expect("failed");
1111
1112 assert_eq!(
1113 failed_1, 1,
1114 "row 1 should be marked failed via per-row onConsumeFailed"
1115 );
1116 assert_eq!(
1117 failed_2, 1,
1118 "row 2 should be marked failed via per-row onConsumeFailed"
1119 );
1120 }
1121
1122 #[tokio::test]
1123 async fn bridge_error_handler_routes_poll_errors_to_exchange_error() {
1124 let mut config = config();
1125 config.bridge_error_handler = true;
1126 let consumer = SqlConsumer::new(config, Arc::new(OnceCell::new()), None, test_rt());
1127
1128 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(4);
1129 tokio::spawn(async move {
1130 #[allow(clippy::never_loop)]
1131 while let Some(env) = rx.recv().await {
1132 assert!(env.exchange.error.is_some(), "exchange must carry error");
1133 if let Some(reply_tx) = env.reply_tx {
1134 let _ = reply_tx.send(Ok(env.exchange));
1135 }
1136 break;
1137 }
1138 });
1139
1140 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1141 consumer
1142 .bridge_poll_error(&ctx, CamelError::ProcessorError("poll failed".into()))
1143 .await
1144 .expect("bridging should succeed");
1145 }
1146
1147 #[tracing_test::traced_test]
1151 #[tokio::test]
1152 async fn bridged_poll_failure_emits_warn_not_error() {
1153 let pool = sqlite_pool().await;
1154 let mut config = config();
1159 config.bridge_error_handler = true;
1160 config.query = "select * from nonexistent_table".to_string();
1162 config.resolve_defaults();
1163 let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
1164 let template = parse_query_template(&config.query, config.placeholder).unwrap();
1165
1166 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(4);
1169 tokio::spawn(async move {
1170 while let Some(env) = rx.recv().await {
1171 if let Some(reply_tx) = env.reply_tx {
1172 let _ = reply_tx.send(Ok(env.exchange));
1173 }
1174 }
1175 });
1176 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1177
1178 consumer.handle_poll_result(&pool, &ctx, &template).await;
1180
1181 assert!(
1183 !logs_contain("ERROR"),
1184 "bridged poll failure must not emit ERROR (handler owns it); check captured logs for stray ERROR lines"
1185 );
1186 assert!(
1188 logs_contain("WARN"),
1189 "bridged poll failure should emit warn! for operator visibility"
1190 );
1191 }
1192
1193 #[tracing_test::traced_test]
1203 #[tokio::test]
1204 async fn unbridged_send_and_wait_failure_emits_error_loud() {
1205 let pool = sqlite_pool().await;
1206 sqlx::query("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)")
1207 .execute(&pool)
1208 .await
1209 .expect("create table");
1210 sqlx::query("INSERT INTO items (id, name) VALUES (1, 'alpha')")
1211 .execute(&pool)
1212 .await
1213 .expect("seed rows");
1214
1215 let mut config = SqlEndpointConfig::from_uri(
1216 "sql:select id, name from items order by id?db_url=sqlite::memory:&outputType=StreamList&initialDelay=0&delay=1",
1217 )
1218 .unwrap();
1219 config.resolve_defaults();
1220 config.bridge_error_handler = false;
1222 let consumer = SqlConsumer::new(
1223 config.clone(),
1224 Arc::new(OnceCell::new()),
1225 None,
1226 Arc::new(RecordingRuntime::new(Arc::new(Mutex::new(Vec::new())))),
1227 );
1228 let template = parse_query_template(&config.query, config.placeholder).unwrap();
1229
1230 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1232 tokio::spawn(async move {
1233 while let Some(env) = rx.recv().await {
1234 if let Some(reply_tx) = env.reply_tx {
1235 let _ = reply_tx.send(Err(CamelError::ProcessorError("boom".into())));
1236 }
1237 }
1238 });
1239 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1240
1241 let _ = consumer.poll_database(&pool, &ctx, &template).await;
1242
1243 assert!(
1245 logs_contain("ERROR"),
1246 "unbridged send_and_wait failure MUST emit ERROR (consumer owns the signal)"
1247 );
1248 }
1249
1250 #[tracing_test::traced_test]
1253 #[tokio::test]
1254 async fn unbridged_handle_poll_result_emits_error_loud() {
1255 let pool = sqlite_pool().await;
1256 let mut config = config();
1259 config.bridge_error_handler = false;
1260 config.query = "select * from nonexistent_table".to_string();
1261 config.resolve_defaults();
1262 let consumer = SqlConsumer::new(
1263 config.clone(),
1264 Arc::new(OnceCell::new()),
1265 None,
1266 Arc::new(RecordingRuntime::new(Arc::new(Mutex::new(Vec::new())))),
1267 );
1268 let template = parse_query_template(&config.query, config.placeholder).unwrap();
1269
1270 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(4);
1272 tokio::spawn(async move {
1273 while let Some(env) = rx.recv().await {
1274 if let Some(reply_tx) = env.reply_tx {
1275 let _ = reply_tx.send(Ok(env.exchange));
1276 }
1277 }
1278 });
1279 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1280
1281 consumer.handle_poll_result(&pool, &ctx, &template).await;
1282
1283 assert!(
1284 logs_contain("ERROR"),
1285 "unbridged handle_poll_result failure MUST emit ERROR (consumer owns signal)"
1286 );
1287 }
1288
1289 #[tokio::test]
1290 async fn stream_list_consumer_emits_ndjson_body() {
1291 let pool = sqlite_pool().await;
1292 sqlx::query("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)")
1293 .execute(&pool)
1294 .await
1295 .expect("create table");
1296 sqlx::query("INSERT INTO items (id, name) VALUES (1, 'alpha'), (2, 'beta'), (3, 'gamma')")
1297 .execute(&pool)
1298 .await
1299 .expect("seed rows");
1300
1301 let mut config = SqlEndpointConfig::from_uri(
1302 "sql:select id, name from items order by id?db_url=sqlite::memory:&outputType=StreamList&initialDelay=0&delay=1",
1303 )
1304 .unwrap();
1305 config.resolve_defaults();
1306
1307 let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
1308 let template = parse_query_template(&config.query, config.placeholder).unwrap();
1309
1310 let (tx, rx) = mpsc::channel::<ExchangeEnvelope>(8);
1311 let (result_tx, result_rx) = tokio::sync::oneshot::channel::<Exchange>();
1312 tokio::spawn(async move {
1313 let mut rx = rx;
1314 if let Some(env) = rx.recv().await {
1315 if let Some(reply_tx) = env.reply_tx {
1316 let _ = reply_tx.send(Ok(env.exchange.clone()));
1317 }
1318 let _ = result_tx.send(env.exchange);
1319 }
1320 });
1321 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1322
1323 consumer
1324 .poll_database(&pool, &ctx, &template)
1325 .await
1326 .expect("poll must succeed");
1327
1328 let exchange = result_rx.await.expect("should have received one exchange");
1329
1330 match exchange.input.body {
1331 Body::Stream(ref stream_body) => {
1332 let stream = stream_body.stream.clone();
1333 let mut guard = stream.lock().await;
1334 let stream_opt = guard.take();
1335 assert!(stream_opt.is_some(), "stream should be present");
1336
1337 use futures::StreamExt;
1338 let mut collected = Vec::new();
1339 let mut stream = stream_opt.unwrap();
1340 while let Some(chunk) = stream.next().await {
1341 let chunk = chunk.expect("stream chunk should not error");
1342 collected.extend_from_slice(&chunk);
1343 }
1344
1345 let ndjson = String::from_utf8(collected).expect("valid utf8");
1346 let lines: Vec<&str> = ndjson.trim().lines().collect();
1347 assert_eq!(lines.len(), 3, "should have 3 NDJSON lines");
1348
1349 let row0: serde_json::Value =
1350 serde_json::from_str(lines[0]).expect("valid json line 0");
1351 assert_eq!(row0["id"], 1);
1352 assert_eq!(row0["name"], "alpha");
1353
1354 let row1: serde_json::Value =
1355 serde_json::from_str(lines[1]).expect("valid json line 1");
1356 assert_eq!(row1["id"], 2);
1357 assert_eq!(row1["name"], "beta");
1358
1359 let row2: serde_json::Value =
1360 serde_json::from_str(lines[2]).expect("valid json line 2");
1361 assert_eq!(row2["id"], 3);
1362 assert_eq!(row2["name"], "gamma");
1363 }
1364 ref other => panic!("expected Body::Stream, got {:?}", other),
1365 }
1366 }
1367
1368 #[tokio::test]
1369 async fn stream_list_consumer_empty_result_set_emits_empty_stream() {
1370 let pool = sqlite_pool().await;
1371 sqlx::query("CREATE TABLE empty_items (id INTEGER PRIMARY KEY, name TEXT)")
1372 .execute(&pool)
1373 .await
1374 .expect("create table");
1375
1376 let mut config = SqlEndpointConfig::from_uri(
1377 "sql:select id, name from empty_items?db_url=sqlite::memory:&outputType=StreamList&initialDelay=0&delay=1",
1378 )
1379 .unwrap();
1380 config.resolve_defaults();
1381
1382 let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
1383 let template = parse_query_template(&config.query, config.placeholder).unwrap();
1384
1385 let (tx, rx) = tokio::sync::oneshot::channel();
1386 let (mpsc_tx, mut mpsc_rx) = mpsc::channel::<ExchangeEnvelope>(8);
1387 tokio::spawn(async move {
1388 #[allow(clippy::never_loop)]
1389 while let Some(env) = mpsc_rx.recv().await {
1390 if let Some(reply_tx) = env.reply_tx {
1391 let _ = reply_tx.send(Ok(env.exchange.clone()));
1392 }
1393 let _ = tx.send(env.exchange);
1394 break;
1395 }
1396 });
1397 let ctx = ConsumerContext::new(
1398 mpsc_tx,
1399 CancellationToken::new(),
1400 "sql-test-route".to_string(),
1401 );
1402
1403 consumer
1404 .poll_database(&pool, &ctx, &template)
1405 .await
1406 .expect("poll must succeed");
1407
1408 let exchange = rx
1409 .await
1410 .expect("StreamList should emit exchange even for empty results");
1411
1412 match exchange.input.body {
1413 Body::Stream(ref stream_body) => {
1414 let stream = stream_body.stream.clone();
1415 let mut guard = stream.lock().await;
1416 let stream_opt = guard.take();
1417
1418 use futures::StreamExt;
1419 let mut count = 0;
1420 if let Some(mut stream) = stream_opt {
1421 while let Some(chunk) = stream.next().await {
1422 let chunk = chunk.expect("stream chunk should not error");
1423 count += chunk.len();
1424 }
1425 }
1426 assert_eq!(count, 0, "empty table should produce zero stream bytes");
1427 }
1428 ref other => panic!("expected Body::Stream, got {:?}", other),
1429 }
1430 }
1431
1432 #[tokio::test]
1433 async fn break_on_empty_stops_after_drained_table() {
1434 let pool = sqlite_pool().await;
1435 seed_consumer_table(&pool).await;
1436
1437 let mut config = SqlEndpointConfig::from_uri(
1439 "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",
1440 )
1441 .unwrap();
1442 config.resolve_defaults();
1443
1444 let pool_cell = Arc::new(OnceCell::new());
1447 pool_cell
1448 .set(Arc::new(pool.clone()))
1449 .expect("pool cell set");
1450 let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
1451
1452 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1453 let route_cancel = CancellationToken::new();
1454 let received = Arc::new(std::sync::atomic::AtomicU32::new(0));
1458 let echo_cancel = route_cancel.clone();
1459 let counter = Arc::clone(&received);
1460 tokio::spawn(async move {
1462 loop {
1463 tokio::select! {
1464 _ = echo_cancel.cancelled() => break,
1465 Some(env) = rx.recv() => {
1466 counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1467 if let Some(reply_tx) = env.reply_tx {
1468 let _ = reply_tx.send(Ok(env.exchange));
1469 }
1470 }
1471 }
1472 }
1473 });
1474
1475 let ctx = ConsumerContext::new(tx, route_cancel.clone(), "sql-test-route".to_string());
1476
1477 let start = tokio::time::Instant::now();
1479 consumer.start(ctx).await.expect("start must succeed");
1480 let elapsed = start.elapsed();
1481
1482 let n = received.load(std::sync::atomic::Ordering::Relaxed);
1485 assert_eq!(
1486 n, 2,
1487 "productive poll should emit 2 envelopes (2 seeded rows), got {}",
1488 n
1489 );
1490
1491 let count_unprocessed: i64 =
1493 sqlx::query_scalar("select count(*) from jobs where processed = 0")
1494 .fetch_one(&pool)
1495 .await
1496 .expect("count");
1497 assert_eq!(count_unprocessed, 0);
1498
1499 assert!(
1502 elapsed < std::time::Duration::from_millis(500),
1503 "consumer should have stopped on empty poll, took {:?}",
1504 elapsed
1505 );
1506
1507 assert!(
1516 elapsed >= std::time::Duration::from_millis(100),
1517 "consumer must run a second (empty) poll before breaking on break_on_empty, \
1518 took {:?} — likely broke after the productive poll without seeing the empty one",
1519 elapsed
1520 );
1521 }
1522
1523 #[tokio::test]
1527 async fn handle_poll_result_error_does_not_signal_empty() {
1528 let pool = sqlite_pool().await;
1529
1530 let mut config = SqlEndpointConfig::from_uri(
1531 "sql:select * from this_table_does_not_exist?db_url=sqlite::memory:&breakOnEmpty=true&initialDelay=0&delay=1",
1532 )
1533 .unwrap();
1534 config.resolve_defaults();
1535
1536 let pool_cell = Arc::new(OnceCell::new());
1537 pool_cell.set(Arc::new(pool.clone())).unwrap();
1538 let consumer = SqlConsumer::new(
1541 config.clone(),
1542 pool_cell,
1543 None,
1544 Arc::new(RecordingRuntime::new(Arc::new(Mutex::new(Vec::new())))),
1545 );
1546
1547 let template = parse_query_template(&config.query, config.placeholder).unwrap();
1548
1549 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1550 tokio::spawn(async move {
1551 while let Some(env) = rx.recv().await {
1552 if let Some(reply_tx) = env.reply_tx {
1553 let _ = reply_tx.send(Ok(env.exchange));
1554 }
1555 }
1556 });
1557 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1558
1559 let outcome = consumer.handle_poll_result(&pool, &ctx, &template).await;
1560 assert!(
1561 !outcome.was_empty,
1562 "poll error must NOT signal empty (was_empty should be false)"
1563 );
1564 }
1565
1566 #[derive(Debug, Default)]
1570 struct RecordingHealth {
1571 forced: Arc<Mutex<Vec<(String, String, String)>>>,
1572 }
1573
1574 impl HealthCheckRegistry for RecordingHealth {
1575 fn force_unhealthy_for_route(&self, route_id: &str, name: &str, reason: &str) {
1576 self.forced.lock().unwrap().push((
1577 route_id.to_string(),
1578 name.to_string(),
1579 reason.to_string(),
1580 ));
1581 }
1582 }
1583
1584 struct NoopMetricsForConsumer;
1585
1586 impl MetricsCollector for NoopMetricsForConsumer {
1587 fn record_exchange_duration(&self, _: &str, _: Duration) {}
1588 fn increment_errors(&self, _: &str, _: &str) {}
1589 fn increment_exchanges(&self, _: &str) {}
1590 fn set_queue_depth(&self, _: &str, _: usize) {}
1591 fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
1592 }
1593
1594 struct RecordingRuntimeWithHealth {
1595 health: Arc<RecordingHealth>,
1596 }
1597
1598 impl RuntimeObservability for RecordingRuntimeWithHealth {
1599 fn metrics(&self) -> Arc<dyn MetricsCollector> {
1600 Arc::new(NoopMetricsForConsumer)
1601 }
1602 fn health(&self) -> Arc<dyn HealthCheckRegistry> {
1603 self.health.clone()
1604 }
1605 }
1606
1607 #[tokio::test]
1610 async fn consumer_pool_init_failure_calls_force_unhealthy_for_route() {
1611 let health = Arc::new(RecordingHealth::default());
1612 let recorded_health = health.clone();
1613 let rt: Arc<dyn RuntimeObservability> = Arc::new(RecordingRuntimeWithHealth { health });
1614
1615 let mut config = SqlEndpointConfig::from_uri(
1616 "sql:select 1?db_url=postgres://nonexistent-host:5432/nonexistent_db&retryEnabled=false&initialDelay=0&delay=1",
1617 )
1618 .unwrap();
1619 config.max_connections = Some(1);
1620 config.min_connections = Some(0);
1621 config.idle_timeout_secs = Some(300);
1622 config.max_lifetime_secs = Some(1800);
1623
1624 let mut consumer = SqlConsumer::new(config, Arc::new(OnceCell::new()), None, rt);
1625
1626 let (tx, _rx) = mpsc::channel(8);
1627 let ctx = ConsumerContext::new(
1628 tx,
1629 CancellationToken::new(),
1630 "sql-consumer-test-route".to_string(),
1631 );
1632
1633 let result = consumer.start(ctx).await;
1634 assert!(result.is_err(), "pool init should fail with bad db_url");
1635
1636 let forced = recorded_health.forced.lock().unwrap();
1637 assert_eq!(
1638 forced.len(),
1639 1,
1640 "expected one force_unhealthy_for_route call"
1641 );
1642 assert_eq!(forced[0].0, "sql-consumer-test-route");
1643 assert_eq!(forced[0].1, "g:sql:consumer-pool-init");
1644 assert!(!forced[0].2.is_empty(), "reason should be non-empty");
1645 }
1646
1647 #[tokio::test]
1651 async fn retry_loop_invokes_operation_exactly_max_attempts_times() {
1652 use camel_component_api::NetworkRetryPolicy;
1653 use std::sync::Arc;
1654 use std::sync::atomic::{AtomicU32, Ordering};
1655
1656 let policy = NetworkRetryPolicy {
1657 max_attempts: 3,
1658 initial_delay: Duration::from_millis(1),
1659 max_delay: Duration::from_millis(1),
1660 multiplier: 1.0,
1661 ..NetworkRetryPolicy::default()
1662 };
1663
1664 let calls = Arc::new(AtomicU32::new(0));
1665 let calls_clone = Arc::clone(&calls);
1666
1667 let mut attempt: u32 = 0;
1668 let _result: Result<(), ()> = loop {
1669 attempt += 1;
1670 calls_clone.fetch_add(1, Ordering::SeqCst);
1671 let op_result: Result<(), ()> = Err(());
1672 match op_result {
1673 Ok(v) => break Ok(v),
1674 Err(_) if policy.should_retry(attempt) => {
1675 let delay = policy.delay_for(attempt - 1);
1676 tokio::time::sleep(delay).await;
1677 continue;
1678 }
1679 Err(_) => break Err(()),
1680 }
1681 };
1682
1683 assert_eq!(
1684 calls.load(Ordering::SeqCst),
1685 3,
1686 "max_attempts=3 must yield exactly 3 invocations"
1687 );
1688 }
1689
1690 #[tokio::test]
1693 #[tracing_test::traced_test]
1694 async fn break_on_empty_ignored_in_streamlist() {
1695 let pool = sqlite_pool().await;
1696 seed_consumer_table(&pool).await;
1697
1698 let mut config = SqlEndpointConfig::from_uri(
1702 "sql:select id from jobs?db_url=sqlite::memory:&outputType=StreamList&initialDelay=0&delay=1&breakOnEmpty=true&repeatCount=3",
1703 )
1704 .unwrap();
1705 config.resolve_defaults();
1706 assert_eq!(config.output_type, SqlOutputType::StreamList);
1707 assert!(config.break_on_empty);
1708
1709 let pool_cell = Arc::new(OnceCell::new());
1710 pool_cell
1711 .set(Arc::new(pool.clone()))
1712 .expect("pool cell set");
1713 let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
1714
1715 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1716 let route_cancel = CancellationToken::new();
1717 let received = Arc::new(std::sync::atomic::AtomicU32::new(0));
1718 let echo_cancel = route_cancel.clone();
1719 let counter = Arc::clone(&received);
1720 tokio::spawn(async move {
1721 loop {
1722 tokio::select! {
1723 _ = echo_cancel.cancelled() => break,
1724 Some(env) = rx.recv() => {
1725 counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1726 if let Some(reply_tx) = env.reply_tx {
1727 let _ = reply_tx.send(Ok(env.exchange));
1728 }
1729 }
1730 }
1731 }
1732 });
1733
1734 let ctx = ConsumerContext::new(tx, route_cancel, "sql-test-route".to_string());
1735 consumer.start(ctx).await.expect("start must succeed");
1736
1737 assert!(
1740 logs_contain("breakOnEmpty"),
1741 "expected StreamList warn naming breakOnEmpty"
1742 );
1743
1744 assert!(
1748 received.load(std::sync::atomic::Ordering::Relaxed) >= 2,
1749 "StreamList must not break early with breakOnEmpty, got {} exchanges",
1750 received.load(std::sync::atomic::Ordering::Relaxed)
1751 );
1752 }
1753
1754 #[tokio::test]
1755 async fn break_on_empty_false_default_loops_on_empty() {
1756 let pool = sqlite_pool().await;
1757 seed_consumer_table(&pool).await;
1759 sqlx::query("delete from jobs")
1760 .execute(&pool)
1761 .await
1762 .expect("drain");
1763
1764 let mut config = SqlEndpointConfig::from_uri(
1767 "sql:select id from jobs where processed = 0?db_url=sqlite::memory:&initialDelay=0&delay=20&repeatCount=3",
1768 )
1769 .unwrap();
1770 config.resolve_defaults();
1771 assert!(!config.break_on_empty);
1772
1773 let pool_cell = Arc::new(OnceCell::new());
1774 pool_cell
1775 .set(Arc::new(pool.clone()))
1776 .expect("pool cell set");
1777 let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
1778
1779 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1780 let route_cancel = CancellationToken::new();
1781 let echo_cancel = route_cancel.clone();
1782 tokio::spawn(async move {
1783 loop {
1784 tokio::select! {
1785 _ = echo_cancel.cancelled() => break,
1786 Some(env) = rx.recv() => {
1787 if let Some(reply_tx) = env.reply_tx {
1788 let _ = reply_tx.send(Ok(env.exchange));
1789 }
1790 }
1791 }
1792 }
1793 });
1794
1795 let ctx = ConsumerContext::new(tx, route_cancel, "sql-test-route".to_string());
1796 let start = tokio::time::Instant::now();
1797 consumer.start(ctx).await.expect("start must succeed");
1798 let elapsed = start.elapsed();
1799
1800 assert!(
1804 elapsed >= std::time::Duration::from_millis(55),
1805 "consumer should run all 3 polls (breakOnEmpty=false), took {:?}",
1806 elapsed
1807 );
1808 }
1809
1810 #[tokio::test]
1811 async fn break_on_empty_with_route_empty_result_set() {
1812 let pool = sqlite_pool().await;
1813 seed_consumer_table(&pool).await;
1814 sqlx::query("delete from jobs")
1815 .execute(&pool)
1816 .await
1817 .expect("drain");
1818 sqlx::query("CREATE TABLE batch_marks (n INTEGER NOT NULL DEFAULT 0)")
1824 .execute(&pool)
1825 .await
1826 .expect("create batch_marks");
1827 sqlx::query("INSERT INTO batch_marks (n) VALUES (0)")
1828 .execute(&pool)
1829 .await
1830 .expect("seed batch_marks");
1831
1832 let mut config = SqlEndpointConfig::from_uri(
1837 "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",
1838 )
1839 .unwrap();
1840 config.resolve_defaults();
1841
1842 let pool_cell = Arc::new(OnceCell::new());
1843 pool_cell
1844 .set(Arc::new(pool.clone()))
1845 .expect("pool cell set");
1846 let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
1847
1848 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1849 let route_cancel = CancellationToken::new();
1850 let received = Arc::new(std::sync::atomic::AtomicU32::new(0));
1851 let echo_cancel = route_cancel.clone();
1852 let counter = Arc::clone(&received);
1853 tokio::spawn(async move {
1854 loop {
1855 tokio::select! {
1856 _ = echo_cancel.cancelled() => break,
1857 Some(env) = rx.recv() => {
1858 counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1859 if let Some(reply_tx) = env.reply_tx {
1860 let _ = reply_tx.send(Ok(env.exchange));
1861 }
1862 }
1863 }
1864 }
1865 });
1866
1867 let ctx = ConsumerContext::new(tx, route_cancel, "sql-test-route".to_string());
1868 let start = tokio::time::Instant::now();
1869 consumer.start(ctx).await.expect("start must succeed");
1870
1871 let n = received.load(std::sync::atomic::Ordering::Relaxed);
1875 assert_eq!(n, 1, "expected exactly 1 empty-result exchange, got {}", n);
1876 assert!(
1877 start.elapsed() < std::time::Duration::from_millis(500),
1878 "consumer should have stopped after the first empty poll, took {:?}",
1879 start.elapsed()
1880 );
1881
1882 let batch_fires: i64 = sqlx::query_scalar("select n from batch_marks")
1887 .fetch_one(&pool)
1888 .await
1889 .expect("batch_marks n");
1890 assert_eq!(
1891 batch_fires, 1,
1892 "onConsumeBatchComplete must fire exactly once on the empty-poll \
1893 fall-through before break_on_empty, got {}",
1894 batch_fires
1895 );
1896 }
1897
1898 #[tokio::test]
1899 async fn repeat_count_zero_polls_never() {
1900 let pool = sqlite_pool().await;
1901 seed_consumer_table(&pool).await;
1902
1903 let mut config = SqlEndpointConfig::from_uri(
1905 "sql:select id from jobs?db_url=sqlite::memory:&initialDelay=0&delay=1&repeatCount=0&onConsume=update jobs set processed=1 where id=:#id",
1906 )
1907 .unwrap();
1908 config.resolve_defaults();
1909
1910 let pool_cell = Arc::new(OnceCell::new());
1913 pool_cell
1914 .set(Arc::new(pool.clone()))
1915 .expect("pool cell set");
1916 let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
1917
1918 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1919 let route_cancel = CancellationToken::new();
1920 let echo_cancel = route_cancel.clone();
1921 tokio::spawn(async move {
1922 loop {
1923 tokio::select! {
1924 _ = echo_cancel.cancelled() => break,
1925 Some(env) = rx.recv() => {
1926 if let Some(reply_tx) = env.reply_tx {
1927 let _ = reply_tx.send(Ok(env.exchange));
1928 }
1929 }
1930 }
1931 }
1932 });
1933
1934 let ctx = ConsumerContext::new(tx, route_cancel, "sql-test-route".to_string());
1935 consumer.start(ctx).await.expect("start must succeed");
1936
1937 let count_processed: i64 =
1939 sqlx::query_scalar("select count(*) from jobs where processed = 1")
1940 .fetch_one(&pool)
1941 .await
1942 .expect("count");
1943 assert_eq!(count_processed, 0, "repeatCount=0 must not poll");
1944 }
1945}