Skip to main content

camel_component_sql/
consumer.rs

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
29/// Record a post-process (b′) failure for ADR-0012 outside-contract sites in this
30/// consumer. Increments the per-label error metric AND emits an `error!` log
31/// per ADR-0012 L57 + L70-72 (the metric is the operator signal; `error!`
32/// provides loud log visibility — b′ errors are NOT absorbed by route handlers).
33///
34/// Both the metric call and the `error!` live INSIDE this helper so that
35/// `lint-log-levels`'s `has_replacement_signal` (scripts/xtask/src/main.rs)
36/// sees both literals in the helper's function body. Call sites have NO
37/// `error!` of their own.
38///
39/// Regression-tested by:
40/// - `record_post_process_failure_increments_errors_and_emits_error_log` (helper unit)
41/// - `unbridged_send_and_wait_failure_emits_error_loud` (StreamList integration path)
42fn 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    // log-policy: outside-contract
51    error!(error = %error, "{message}");
52}
53
54/// Outcome of a single poll cycle. Carries whether the poll returned zero rows,
55/// threaded up to the poll loop for `break_on_empty` (without propagating errors,
56/// which are swallowed/bridged by `handle_poll_result`).
57#[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 observability for metrics and health — used by the
68    /// `record_post_process_failure` helper for ADR-0012 (b′) metric calls.
69    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    /// Poll the database for new rows and process them.
89    async fn poll_database(
90        &self,
91        pool: &AnyPool,
92        context: &ConsumerContext,
93        template: &QueryTemplate,
94    ) -> Result<PollOutcome, CamelError> {
95        // Capture route_id from ConsumerContext for ADR-0012 metrics
96        let route_id = context.route_id();
97
98        // Create an empty exchange for parameter resolution (consumer has no input)
99        let empty_exchange = Exchange::new(Message::default());
100
101        // Resolve parameters
102        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            // Process each row individually
135            for row in rows_to_process {
136                let row_json = row_to_json(&row)?;
137
138                // Create exchange with the row as JSON body
139                let mut msg = Message::new(Body::Json(row_json.clone()));
140
141                // Set individual column headers with CamelSql. prefix per Apache Camel convention
142                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                // Send and wait for processing
151                let result = context.send_and_wait(exchange).await;
152
153                // Handle post-processing (onConsume/onConsumeFailed)
154                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 downstream processing itself failed, honour break_batch_on_consume_fail
168                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            // Process all rows as a single batch
176            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            // Create exchange with array of rows
184            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            // Send and wait for result
190            let result = context.send_and_wait(exchange).await;
191
192            // SQL-021: Run per-row post-processing even in batch mode so that
193            // onConsume/onConsumeFailed queries can reference row-specific parameters
194            // (e.g. `:#id`). Each row gets its own post-processing query execution.
195            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 downstream processing itself failed, honour break_batch_on_consume_fail
211            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        // Execute on_consume_batch_complete if configured
219        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        // StreamList ignores break_on_empty, so it always returns was_empty=false
279        Ok(PollOutcome::default())
280    }
281
282    /// Handle post-processing after a row is processed (onConsume/onConsumeFailed).
283    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                // Success - execute onConsume if configured
292                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                // Failure - execute onConsumeFailed if configured
298                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    /// Execute a post-processing query with the row data as parameters.
308    async fn execute_post_query(
309        &self,
310        pool: &AnyPool,
311        query_str: &str,
312        row_json: &JsonValue,
313    ) -> Result<(), CamelError> {
314        // Parse the query template
315        let template = parse_query_template(query_str, self.config.placeholder)?;
316
317        // Create a temporary exchange with the row as body for parameter resolution
318        // Populate CamelSql.* headers so named params can reference them
319        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        // Resolve parameters
328        let prepared = resolve_params(&template, &temp_exchange, &self.config.in_separator)?;
329
330        // Build and execute the query
331        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        // Warn if 0 rows affected (the row may not have been marked correctly)
337        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    /// Handle the result of a single poll cycle, including bridging if configured.
348    /// Extracted from `run()` so tests can exercise the error-handling branch directly.
349    /// Returns `PollOutcome` so the poll loop can decide whether to break on empty.
350    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                // Swallow the poll error (do NOT propagate via ? — the loop continues).
360                if self.config.bridge_error_handler {
361                    // log-policy: handler-owned
362                    // (category b-bridged: error will be wrapped as Exchange
363                    // and flow into the route's error handler)
364                    warn!(error = %e, "SQL consumer poll failed (bridged)");
365                    if let Err(route_err) = self.bridge_poll_error(context, e).await {
366                        // (the bridge channel itself broke — route will CrashNotification per ADR-0007)
367                        // log-policy: system-broken
368                        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                // An error is NOT an empty poll — the loop must continue.
380                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        // Reject double-start
403        if self.stopped {
404            return Err(CamelError::Config(
405                "SQL consumer cannot be restarted after stop".into(),
406            ));
407        }
408
409        // Step 1: Initialize the connection pool
410        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        // SQL-014: resolve file-based query before pool init, regardless of pool source
415        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                // Catalog path: resolve shared pool from the datasource catalog
422                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                // Install all compiled-in sqlx drivers so AnyPool can resolve them.
428                // This is idempotent; safe to call multiple times.
429                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                    // log-policy: outside-contract
478                    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        // SQL-002: warn if Managed transaction mode requested
489        if self.config.transaction_mode == TransactionMode::Managed {
490            warn!("transactionManager not yet implemented; using Auto mode");
491        }
492
493        // SQL-017/SQL-018: log processing and poll strategies
494        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        // Warn if no onConsume configured
516        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        // Step 2: Parse query template once (avoid re-parsing every poll)
529        let template = parse_query_template(&self.config.query, self.config.placeholder)
530            .map_err(|e| CamelError::Config(format!("Invalid query template: {}", e)))?;
531
532        // Step 3: Initial delay before starting polling
533        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        // Step 4: Polling loop
544        //
545        // This is a POLLING LOOP with fixed cadence (delay_ms), NOT a
546        // retry loop. It polls the database until cancelled or repeat_count
547        // is reached — there is no "transient error → retry with backoff"
548        // contract at this level. retry_async / retry_async_cancelable do
549        // not apply because they are designed for bounded retry, not
550        // repeated polling with uniform delay.
551        //
552        // The pool-connect retry at startup (Step 1) was migrated to
553        // retry_async in rc-d2r. The per-poll error handling (poll_database
554        // failures) is an error-bridge pattern, not a retry loop.
555        //
556        // See camel-redis/src/consumer.rs:325 for a similar polling-loop
557        // justification.
558        let mut poll_count: u32 = 0;
559        loop {
560            // SQL-015: check repeat_count limit
561            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        // Double-stop is safe — no-op after first stop
592        if self.stopped {
593            debug!("SQL consumer stop called on already-stopped consumer");
594            return Ok(());
595        }
596
597        // Close the connection pool if it was initialized
598        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        // Sequential is correct for SQL consumers: concurrent polls would fetch
611        // duplicate rows. The design doc mentioned SharedState (which doesn't exist
612        // in this runtime) — Sequential is the correct equivalent.
613        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    // -----------------------------------------------------------------------
637    // Recording metrics collector for testing increment_errors calls
638    // -----------------------------------------------------------------------
639
640    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    /// Regression test for ADR-0012: the record_post_process_failure helper
679    /// must increment the error metric with the correct route_id and label,
680    /// AND emit error! via tracing.
681    #[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        // Directly invoke the helper
689        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        // Verify MetricsCollector::increment_errors was called
698        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        // Verify error! was emitted
705        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    // --- Phase B hardening tests ---
896
897    // SQL-001: Direct consumer construction without resolve_defaults does not panic.
898    // The consumer defensively calls resolve_defaults() during pool init, so the pool
899    // fields get resolved. This test verifies no panic occurs.
900    #[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        // Deliberately NOT calling resolve_defaults() — pool fields remain None
907        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        // Spawn the consumer and cancel it quickly — it should not panic
922        let consumer_handle = tokio::spawn(async move { consumer.start(ctx).await });
923
924        // Cancel after a short delay
925        tokio::time::sleep(Duration::from_millis(50)).await;
926        token.cancel();
927
928        let result = consumer_handle.await.expect("task should not panic");
929        // Should complete without panic (may be Ok or Err depending on timing)
930        let _ = result;
931    }
932
933    // SQL-008: stop() closes the pool
934    #[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        // After stop, the pool should be closed
952        assert!(
953            pool.is_closed(),
954            "Pool should be closed after consumer.stop()"
955        );
956    }
957
958    // SQL-008: double-stop is safe
959    #[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    // SQL-008: start after stop is rejected
980    #[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    // SQL-021: batch mode per-row post-processing
1016    #[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        // SQL-021: Each row should have been processed individually via onConsume
1046        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    // SQL-021: batch mode per-row onConsumeFailed when downstream fails
1069    #[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        // SQL-021: Each row should have onConsumeFailed executed individually
1100        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    /// Regression for ADR-0012: when bridge_error_handler=true, the poll
1148    /// failure must NOT emit error! (the route's error handler owns ERROR
1149    /// for bridged failures). Was previously duplicated at line 429 + 431.
1150    #[tracing_test::traced_test]
1151    #[tokio::test]
1152    async fn bridged_poll_failure_emits_warn_not_error() {
1153        let pool = sqlite_pool().await;
1154        // Do NOT create any table — the query against a non-existent
1155        // table will fail at fetch_all, returning Err BEFORE any
1156        // downstream send (so lines 103/205 are never reached).
1157
1158        let mut config = config();
1159        config.bridge_error_handler = true;
1160        // Query a non-existent table to trigger a query-failure poll error.
1161        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        // Healthy downstream — replies Ok so bridge_poll_error succeeds
1167        // and does NOT emit its own error!.
1168        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        // Drive poll — fetch_all will fail because the table is missing.
1179        consumer.handle_poll_result(&pool, &ctx, &template).await;
1180
1181        // The bridged path must NOT emit ERROR (handler owns it).
1182        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        // Sanity: warn! was emitted so the failure is still visible.
1187        assert!(
1188            logs_contain("WARN"),
1189            "bridged poll failure should emit warn! for operator visibility"
1190        );
1191    }
1192
1193    /// Regression for ADR-0012 "b-bridged discriminator": when
1194    /// send_and_wait returns Err on a NORMAL-DATA send (i.e., not a
1195    /// deliberate bridge_poll_error handoff), the route handler did NOT
1196    /// absorb the failure (consumer.rs:77-91 contract; error_handler.rs
1197    /// returns Ok in every branch). The consumer's error! is the only
1198    /// ERROR signal for the unhandled failure and MUST stay at error!.
1199    ///
1200    /// Protects consumer.rs:205 (StreamList downstream send) and any
1201    /// future site that uses send_and_wait on a non-bridge path.
1202    #[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        // Explicitly non-bridged: normal-data send path.
1221        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        // Downstream that returns Err — simulates unhandled route failure.
1231        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        // The unbridged path MUST emit ERROR — consumer owns the signal.
1244        assert!(
1245            logs_contain("ERROR"),
1246            "unbridged send_and_wait failure MUST emit ERROR (consumer owns the signal)"
1247        );
1248    }
1249
1250    /// Regression for ADR-0012: when bridge_error_handler=false, the unbridged
1251    /// branch of handle_poll_result MUST emit ERROR for unhandled poll failure.
1252    #[tracing_test::traced_test]
1253    #[tokio::test]
1254    async fn unbridged_handle_poll_result_emits_error_loud() {
1255        let pool = sqlite_pool().await;
1256        // Do NOT create any table — fetch_all will fail in poll_database.
1257
1258        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        // Healthy downstream task; should not be reached for this poll-failure path.
1271        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        // onConsume marks rows processed=1; query selects only processed=0 → drains in one poll.
1438        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        // Inject the seeded pool — start() otherwise self-initializes a disjoint
1445        // sqlite::memory: DB (per-connection private).
1446        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        // Count envelopes so we can pin the productive poll's row processing.
1455        // With use_iterator=true (default) and 2 seeded rows, the productive poll
1456        // must emit exactly 2 envelopes before the empty poll triggers the break.
1457        let received = Arc::new(std::sync::atomic::AtomicU32::new(0));
1458        let echo_cancel = route_cancel.clone();
1459        let counter = Arc::clone(&received);
1460        // Echo replies so the poll completes.
1461        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        // start() runs the poll loop to completion (returns when the loop breaks).
1478        let start = tokio::time::Instant::now();
1479        consumer.start(ctx).await.expect("start must succeed");
1480        let elapsed = start.elapsed();
1481
1482        // Productive poll must have emitted exactly 2 envelopes (one per row in
1483        // the seeded table). Locks "no rows skipped, no rows lost".
1484        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        // Both rows drained (processed=1) by the productive poll.
1492        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        // Upper bound: with delay=10ms and breakOnEmpty, the consumer should stop
1500        // well under the repeatCount=100 ceiling (which would take ~1s).
1501        assert!(
1502            elapsed < std::time::Duration::from_millis(500),
1503            "consumer should have stopped on empty poll, took {:?}",
1504            elapsed
1505        );
1506
1507        // Lower bound — LOCKS the [productive_poll, empty_poll] ordering.
1508        // With delay=50ms:
1509        //   - Correct: at least 2 full delays elapse (1st sleep + 2nd sleep) ≈ 100ms+,
1510        //     because the loop MUST run a second (empty) poll before breaking.
1511        //   - Buggy (break after the productive poll): only the 1st delay elapses
1512        //     ≈ 50ms+processing. 100ms threshold sits safely between the two and
1513        //     would FAIL if the consumer incorrectly set was_empty=true on the
1514        //     productive poll and broke early. Wide margin survives slow CI boxes.
1515        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    /// Regression: `handle_poll_result` with `break_on_empty=true` must NOT
1524    /// signal `was_empty: true` when `poll_database` returns an error — the
1525    /// loop should continue past the error.
1526    #[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        // Use RecordingRuntime (not test_rt/PanicRuntime) because
1539        // handle_poll_result calls record_post_process_failure → metrics().
1540        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    // ── ADR-0012 (g) regression tests ──────────────────────────────────
1567
1568    /// Fixture: captures `force_unhealthy_for_route` calls.
1569    #[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    /// Regression: consumer pool init failure calls force_unhealthy_for_route
1608    /// with correct route_id + name "g:sql:consumer-pool-init" + non-empty reason.
1609    #[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    /// Regression: max_attempts=N → exactly N invocations (caught OpenSearch off-by-one 1f5c4c2a).
1648    /// Replicates the exact retry loop from SqlConsumer::start() (consumer.rs:343-367):
1649    ///   attempt starts at 0, incremented at top, should_retry(attempt), delay_for(attempt-1)
1650    #[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    // ── break_on_empty edge-case tests ──────────────────────────────────
1691
1692    #[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        // StreamList + breakOnEmpty=true: warn must fire, breakOnEmpty ignored
1699        // (no break on empty — rows flow lazily). repeatCount=3 to distinguish
1700        // "ran 3 polls" from "broke on poll 1".
1701        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        // The startup warn must name breakOnEmpty (FAILS before Step 2 — current
1738        // warn message omits it).
1739        assert!(
1740            logs_contain("breakOnEmpty"),
1741            "expected StreamList warn naming breakOnEmpty"
1742        );
1743
1744        // Counter must prove the stream ran multiple polls (no early break).
1745        // repeatCount=3 with delay=1ms means 3 polls; even accounting for race
1746        // the counter must be >=2 if no early break.
1747        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        // Empty table (seed then drain) — every poll returns 0 rows.
1758        seed_consumer_table(&pool).await;
1759        sqlx::query("delete from jobs")
1760            .execute(&pool)
1761            .await
1762            .expect("drain");
1763
1764        // breakOnEmpty NOT set (default false); repeatCount=3 so the loop must run
1765        // all 3 polls (NOT break on the first empty poll). delay=20ms each.
1766        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        // Regression guard: with breakOnEmpty=false + repeatCount=3 + delay=20ms,
1801        // the loop runs all 3 polls (~60ms). If breakOnEmpty were mis-defaulted to
1802        // true, it would break on poll 1 (~20ms). Assert the full window ran.
1803        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        // Side-effect table for onConsumeBatchComplete: each fire of the
1819        // batch-complete callback increments `n` exactly once. With
1820        // breakOnEmpty=true on an empty table, the spec pins that the
1821        // empty-poll fall-through fires the callback exactly once before
1822        // termination.
1823        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        // Empty table + routeEmptyResultSet=true: empty polls fall through to the
1833        // batch path (emit empty result) instead of early-returning. breakOnEmpty=true
1834        // must break AFTER that batch processing → exactly one downstream exchange.
1835        // onConsumeBatchComplete is wired so we can observe the empty-poll fall-through.
1836        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        // Exactly one downstream exchange emitted (the first empty poll's empty result),
1872        // then breakOnEmpty stopped the loop. Must NOT be 0 (route_empty honored) and
1873        // must NOT be repeatCount=100 (break_on_empty honored).
1874        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        // onConsumeBatchComplete must fire exactly once on the empty-poll
1883        // fall-through before the loop breaks. A buggy version that broke
1884        // before invoking the batch callback would leave n=0; a version
1885        // that looped through repeatCount=100 would leave n=100.
1886        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        // repeatCount=0 → consumer exits before the first poll (guard at loop top).
1904        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        // Inject the seeded pool — start() otherwise self-initializes a disjoint
1911        // sqlite::memory: DB (per-connection private). Pattern: consumer.rs:967-970.
1912        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        // No poll ran → rows are untouched (processed=0).
1938        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}