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                    "sql",
457                    "consumer-pool-init",
458                    || {
459                        async {
460                            AnyPoolOptions::new()
461                                .max_connections(max_conn)
462                                .min_connections(min_conn)
463                                .idle_timeout(Duration::from_secs(idle_timeout))
464                                .max_lifetime(Duration::from_secs(max_lifetime))
465                                .connect(&db_url)
466                                .await
467                        }
468                    },
469                    is_retryable_sqlx_error,
470                    Some(self.runtime.metrics().as_ref()),
471                )
472                .await
473                .map_err(|e| {
474                    self.runtime.health().force_unhealthy_for_route(
475                        &route_id,
476                        "g:sql:consumer-pool-init",
477                        &e.to_string(),
478                    );
479                    // log-policy: outside-contract
480                    error!(error = %e, db_url = %redact_db_url(&self.config.db_url), "SQL connect failed, giving up");
481                    CamelError::EndpointCreationFailed(format!(
482                        "Failed to connect to database: {}",
483                        e
484                    ))
485                })?;
486                Ok(Arc::new(pool))
487            })
488            .await?;
489
490        // SQL-002: warn if Managed transaction mode requested
491        if self.config.transaction_mode == TransactionMode::Managed {
492            warn!("transactionManager not yet implemented; using Auto mode");
493        }
494
495        // SQL-017/SQL-018: log processing and poll strategies
496        if self.config.processing_strategy == ProcessingStrategy::Scheduled {
497            debug!(
498                "Processing strategy: Scheduled (rows dispatched individually via send_and_wait)"
499            );
500        }
501        if self.config.poll_strategy == PollStrategy::Burst {
502            debug!("Poll strategy: Burst (rapid successive polls)");
503        }
504
505        if self.config.output_type == SqlOutputType::StreamList
506            && (self.config.on_consume.is_some()
507                || self.config.on_consume_failed.is_some()
508                || self.config.on_consume_batch_complete.is_some()
509                || self.config.break_on_empty)
510        {
511            warn!(
512                "onConsume/onConsumeFailed/onConsumeBatchComplete/breakOnEmpty are not executed in \
513                 StreamList mode (rows are consumed lazily downstream)"
514            );
515        }
516
517        // Warn if no onConsume configured
518        if self.config.on_consume.is_none() {
519            warn!(
520                "SQL consumer started without onConsume configured — consumed rows will not be marked/deleted"
521            );
522        }
523
524        info!(
525            db_url = %redact_db_url(&self.config.db_url),
526            query_len = self.config.query.len(),
527            "SQL consumer started"
528        );
529
530        // Step 2: Parse query template once (avoid re-parsing every poll)
531        let template = parse_query_template(&self.config.query, self.config.placeholder)
532            .map_err(|e| CamelError::Config(format!("Invalid query template: {}", e)))?;
533
534        // Step 3: Initial delay before starting polling
535        if self.config.initial_delay_ms > 0 {
536            tokio::select! {
537                _ = context.cancelled() => {
538                    info!("SQL consumer stopped during initial delay");
539                    return Ok(());
540                }
541                _ = tokio::time::sleep(Duration::from_millis(self.config.initial_delay_ms)) => {}
542            }
543        }
544
545        // Step 4: Polling loop
546        //
547        // This is a POLLING LOOP with fixed cadence (delay_ms), NOT a
548        // retry loop. It polls the database until cancelled or repeat_count
549        // is reached — there is no "transient error → retry with backoff"
550        // contract at this level. retry_async / retry_async_cancelable do
551        // not apply because they are designed for bounded retry, not
552        // repeated polling with uniform delay.
553        //
554        // The pool-connect retry at startup (Step 1) was migrated to
555        // retry_async in rc-d2r. The per-poll error handling (poll_database
556        // failures) is an error-bridge pattern, not a retry loop.
557        //
558        // See camel-redis/src/consumer.rs:325 for a similar polling-loop
559        // justification.
560        let mut poll_count: u32 = 0;
561        loop {
562            // SQL-015: check repeat_count limit
563            if let Some(max_repeats) = self.config.repeat_count
564                && poll_count >= max_repeats
565            {
566                info!(
567                    repeat_count = max_repeats,
568                    "SQL consumer reached repeat_count limit, stopping"
569                );
570                break;
571            }
572
573            tokio::select! {
574                _ = context.cancelled() => {
575                    info!("SQL consumer stopped");
576                    break;
577                }
578                _ = tokio::time::sleep(Duration::from_millis(self.config.delay_ms)) => {
579                    poll_count += 1;
580                    let outcome = self.handle_poll_result(pool.as_ref(), &context, &template).await;
581                    if self.config.break_on_empty && outcome.was_empty {
582                        info!("SQL consumer stopping: break_on_empty triggered (poll returned 0 rows)");
583                        break;
584                    }
585                }
586            }
587        }
588
589        Ok(())
590    }
591
592    async fn stop(&mut self) -> Result<(), CamelError> {
593        // Double-stop is safe — no-op after first stop
594        if self.stopped {
595            debug!("SQL consumer stop called on already-stopped consumer");
596            return Ok(());
597        }
598
599        // Close the connection pool if it was initialized
600        if let Some(pool) = self.pool.get() {
601            debug!("SQL consumer closing connection pool");
602            pool.close().await;
603            debug!("SQL consumer pool closed");
604        }
605
606        self.stopped = true;
607        info!("SQL consumer stopped");
608        Ok(())
609    }
610
611    fn concurrency_model(&self) -> ConcurrencyModel {
612        // Sequential is correct for SQL consumers: concurrent polls would fetch
613        // duplicate rows. The design doc mentioned SharedState (which doesn't exist
614        // in this runtime) — Sequential is the correct equivalent.
615        ConcurrencyModel::Sequential
616    }
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622    use camel_api::MetricsCollector;
623    use camel_component_api::HealthCheckRegistry;
624    use camel_component_api::test_support::PanicRuntimeObservability;
625    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
626        std::sync::Arc::new(PanicRuntimeObservability)
627    }
628    use crate::config::SqlEndpointConfig;
629    use camel_component_api::ExchangeEnvelope;
630    use camel_component_api::UriConfig;
631    use sqlx::any::AnyPoolOptions;
632    use std::sync::Arc;
633    use std::sync::Mutex;
634    use std::time::Duration;
635    use tokio::sync::mpsc;
636    use tokio_util::sync::CancellationToken;
637
638    // -----------------------------------------------------------------------
639    // Recording metrics collector for testing increment_errors calls
640    // -----------------------------------------------------------------------
641
642    struct RecordingMetrics {
643        errors: Arc<Mutex<Vec<(String, String)>>>,
644    }
645
646    impl MetricsCollector for RecordingMetrics {
647        fn record_exchange_duration(&self, _: &str, _: Duration) {}
648        fn increment_errors(&self, route_id: &str, error_type: &str) {
649            self.errors
650                .lock()
651                .unwrap()
652                .push((route_id.to_string(), error_type.to_string()));
653        }
654        fn increment_exchanges(&self, _: &str) {}
655        fn set_queue_depth(&self, _: &str, _: usize) {}
656        fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
657    }
658
659    struct RecordingRuntime {
660        metrics_collector: Arc<RecordingMetrics>,
661    }
662
663    impl RecordingRuntime {
664        fn new(errors: Arc<Mutex<Vec<(String, String)>>>) -> Self {
665            Self {
666                metrics_collector: Arc::new(RecordingMetrics { errors }),
667            }
668        }
669    }
670
671    impl RuntimeObservability for RecordingRuntime {
672        fn metrics(&self) -> Arc<dyn MetricsCollector> {
673            self.metrics_collector.clone() as Arc<dyn MetricsCollector>
674        }
675        fn health(&self) -> Arc<dyn HealthCheckRegistry> {
676            panic!("RecordingRuntime::health not used in this test")
677        }
678    }
679
680    /// Regression test for ADR-0012: the record_post_process_failure helper
681    /// must increment the error metric with the correct route_id and label,
682    /// AND emit error! via tracing.
683    #[tracing_test::traced_test]
684    #[test]
685    fn record_post_process_failure_increments_errors_and_emits_error_log() {
686        let errors: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
687        let runtime = Arc::new(RecordingRuntime::new(Arc::clone(&errors)));
688        let error = CamelError::ProcessorError("test failure".to_string());
689
690        // Directly invoke the helper
691        record_post_process_failure(
692            runtime.as_ref(),
693            "test-route",
694            "b-prime:sql:on-consume",
695            &error,
696            "Post-processing failed",
697        );
698
699        // Verify MetricsCollector::increment_errors was called
700        let recorded = errors.lock().unwrap();
701        assert_eq!(recorded.len(), 1, "expected 1 increment_errors call");
702        assert_eq!(recorded[0].0, "test-route");
703        assert_eq!(recorded[0].1, "b-prime:sql:on-consume");
704        drop(recorded);
705
706        // Verify error! was emitted
707        assert!(logs_contain("ERROR"), "helper must emit error! log");
708        assert!(
709            logs_contain("Post-processing failed"),
710            "helper must include the message in the log"
711        );
712    }
713
714    async fn sqlite_pool() -> AnyPool {
715        sqlx::any::install_default_drivers();
716        AnyPoolOptions::new()
717            .max_connections(1)
718            .connect("sqlite::memory:")
719            .await
720            .expect("sqlite pool")
721    }
722
723    async fn seed_consumer_table(pool: &AnyPool) {
724        sqlx::query("CREATE TABLE jobs (id INTEGER PRIMARY KEY, processed INTEGER DEFAULT 0, failed INTEGER DEFAULT 0)")
725            .execute(pool)
726            .await
727            .expect("create table");
728        sqlx::query("INSERT INTO jobs (id, processed, failed) VALUES (1, 0, 0), (2, 0, 0)")
729            .execute(pool)
730            .await
731            .expect("seed rows");
732    }
733
734    fn config() -> SqlEndpointConfig {
735        let mut c =
736            SqlEndpointConfig::from_uri("sql:select * from t?db_url=postgres://localhost/test")
737                .unwrap();
738        c.resolve_defaults();
739        c
740    }
741
742    #[test]
743    fn consumer_concurrency_model() {
744        let c = SqlConsumer::new(config(), Arc::new(OnceCell::new()), None, test_rt());
745        assert_eq!(c.concurrency_model(), ConcurrencyModel::Sequential);
746    }
747
748    #[test]
749    fn consumer_stores_config() {
750        let mut config = SqlEndpointConfig::from_uri(
751            "sql:select * from t?db_url=postgres://localhost/test&delay=2000&onConsume=update t set done=true"
752        ).unwrap();
753        config.resolve_defaults();
754        let c = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
755        assert_eq!(c.config.delay_ms, 2000);
756        assert!(c.config.on_consume.is_some());
757    }
758
759    #[tokio::test]
760    async fn poll_database_runs_on_consume_for_successful_rows() {
761        let pool = sqlite_pool().await;
762        seed_consumer_table(&pool).await;
763
764        let mut config = SqlEndpointConfig::from_uri(
765            "sql:select id, processed, failed from jobs where processed = 0 order by id?db_url=sqlite::memory:&onConsume=update jobs set processed=1 where id=:#id&initialDelay=0&delay=1",
766        )
767        .unwrap();
768        config.resolve_defaults();
769
770        let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
771        let template = parse_query_template(&config.query, config.placeholder).unwrap();
772
773        let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
774        tokio::spawn(async move {
775            while let Some(env) = rx.recv().await {
776                if let Some(reply_tx) = env.reply_tx {
777                    let _ = reply_tx.send(Ok(env.exchange));
778                }
779            }
780        });
781        let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
782
783        consumer
784            .poll_database(&pool, &ctx, &template)
785            .await
786            .expect("poll must succeed");
787
788        let row = sqlx::query("select processed from jobs where id = 1")
789            .fetch_one(&pool)
790            .await
791            .expect("row 1");
792        let processed_1: i64 = sqlx::Row::try_get(&row, 0).expect("processed");
793
794        let row = sqlx::query("select processed from jobs where id = 2")
795            .fetch_one(&pool)
796            .await
797            .expect("row 2");
798        let processed_2: i64 = sqlx::Row::try_get(&row, 0).expect("processed");
799
800        assert_eq!(processed_1, 1);
801        assert_eq!(processed_2, 1);
802    }
803
804    #[tokio::test]
805    async fn poll_database_runs_on_consume_failed_when_downstream_fails() {
806        let pool = sqlite_pool().await;
807        seed_consumer_table(&pool).await;
808
809        let mut config = SqlEndpointConfig::from_uri(
810            "sql:select id, processed, failed from jobs where processed = 0 order by id?db_url=sqlite::memory:&onConsumeFailed=update jobs set failed=1 where id=:#id&initialDelay=0&delay=1",
811        )
812        .unwrap();
813        config.resolve_defaults();
814
815        let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
816        let template = parse_query_template(&config.query, config.placeholder).unwrap();
817
818        let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
819        tokio::spawn(async move {
820            while let Some(env) = rx.recv().await {
821                if let Some(reply_tx) = env.reply_tx {
822                    let _ =
823                        reply_tx.send(Err(CamelError::ProcessorError("downstream boom".into())));
824                }
825            }
826        });
827        let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
828
829        consumer
830            .poll_database(&pool, &ctx, &template)
831            .await
832            .expect("consumer should swallow downstream errors when breakBatchOnConsumeFail=false");
833
834        let row = sqlx::query("select failed from jobs where id = 1")
835            .fetch_one(&pool)
836            .await
837            .expect("row 1");
838        let failed_1: i64 = sqlx::Row::try_get(&row, 0).expect("failed");
839
840        let row = sqlx::query("select failed from jobs where id = 2")
841            .fetch_one(&pool)
842            .await
843            .expect("row 2");
844        let failed_2: i64 = sqlx::Row::try_get(&row, 0).expect("failed");
845
846        assert_eq!(failed_1, 1);
847        assert_eq!(failed_2, 1);
848    }
849
850    #[tokio::test]
851    async fn poll_database_breaks_batch_on_consume_fail() {
852        let pool = sqlite_pool().await;
853        seed_consumer_table(&pool).await;
854
855        let mut config = SqlEndpointConfig::from_uri(
856            "sql:select id, processed, failed from jobs where processed = 0 order by id?db_url=sqlite::memory:&onConsumeFailed=update jobs set failed=1 where id=:#id&breakBatchOnConsumeFail=true&initialDelay=0&delay=1",
857        )
858        .unwrap();
859        config.resolve_defaults();
860
861        let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
862        let template = parse_query_template(&config.query, config.placeholder).unwrap();
863
864        let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
865        tokio::spawn(async move {
866            while let Some(env) = rx.recv().await {
867                if let Some(reply_tx) = env.reply_tx {
868                    let _ =
869                        reply_tx.send(Err(CamelError::ProcessorError("downstream boom".into())));
870                }
871            }
872        });
873        let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
874
875        let err = consumer
876            .poll_database(&pool, &ctx, &template)
877            .await
878            .expect_err("must stop on first downstream failure");
879        assert!(err.to_string().contains("downstream boom"));
880
881        let row = sqlx::query("select failed from jobs where id = 1")
882            .fetch_one(&pool)
883            .await
884            .expect("row 1");
885        let failed_1: i64 = sqlx::Row::try_get(&row, 0).expect("failed");
886
887        let row = sqlx::query("select failed from jobs where id = 2")
888            .fetch_one(&pool)
889            .await
890            .expect("row 2");
891        let failed_2: i64 = sqlx::Row::try_get(&row, 0).expect("failed");
892
893        assert_eq!(failed_1, 1);
894        assert_eq!(failed_2, 0, "second row must not be processed");
895    }
896
897    // --- Phase B hardening tests ---
898
899    // SQL-001: Direct consumer construction without resolve_defaults does not panic.
900    // The consumer defensively calls resolve_defaults() during pool init, so the pool
901    // fields get resolved. This test verifies no panic occurs.
902    #[tokio::test]
903    async fn consumer_no_panic_without_prior_resolve_defaults() {
904        let config = SqlEndpointConfig::from_uri(
905            "sql:select 1?db_url=sqlite::memory:&initialDelay=0&delay=1",
906        )
907        .unwrap();
908        // Deliberately NOT calling resolve_defaults() — pool fields remain None
909        assert!(config.max_connections.is_none());
910
911        let mut consumer = SqlConsumer::new(
912            config,
913            Arc::new(OnceCell::new()),
914            None,
915            // Noop runtime: pool-init retries now record per-attempt
916            // telemetry by design, which the panic runtime would reject.
917            std::sync::Arc::new(camel_component_api::test_support::NoopRuntimeObservability),
918        );
919        let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
920        tokio::spawn(async move {
921            while let Some(env) = rx.recv().await {
922                if let Some(reply_tx) = env.reply_tx {
923                    let _ = reply_tx.send(Ok(env.exchange));
924                }
925            }
926        });
927        let token = CancellationToken::new();
928        let ctx = ConsumerContext::new(tx, token.clone(), "sql-test-route".to_string());
929
930        // Spawn the consumer and cancel it quickly — it should not panic
931        let consumer_handle = tokio::spawn(async move { consumer.start(ctx).await });
932
933        // Cancel after a short delay
934        tokio::time::sleep(Duration::from_millis(50)).await;
935        token.cancel();
936
937        let result = consumer_handle.await.expect("task should not panic");
938        // Should complete without panic (may be Ok or Err depending on timing)
939        let _ = result;
940    }
941
942    // SQL-008: stop() closes the pool
943    #[tokio::test]
944    async fn stop_closes_pool() {
945        let pool = sqlite_pool().await;
946        seed_consumer_table(&pool).await;
947
948        let mut config = SqlEndpointConfig::from_uri(
949            "sql:select id from jobs?db_url=sqlite::memory:&onConsume=update jobs set processed=1 where id=:#id&initialDelay=0&delay=1",
950        )
951        .unwrap();
952        config.resolve_defaults();
953
954        let pool_cell = Arc::new(OnceCell::new());
955        pool_cell.set(Arc::new(pool.clone())).unwrap();
956
957        let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
958        consumer.stop().await.expect("stop should succeed");
959
960        // After stop, the pool should be closed
961        assert!(
962            pool.is_closed(),
963            "Pool should be closed after consumer.stop()"
964        );
965    }
966
967    // SQL-008: double-stop is safe
968    #[tokio::test]
969    async fn double_stop_is_safe() {
970        let pool = sqlite_pool().await;
971        let mut config = SqlEndpointConfig::from_uri(
972            "sql:select 1?db_url=sqlite::memory:&initialDelay=0&delay=1",
973        )
974        .unwrap();
975        config.resolve_defaults();
976
977        let pool_cell = Arc::new(OnceCell::new());
978        pool_cell.set(Arc::new(pool.clone())).unwrap();
979
980        let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
981        consumer.stop().await.expect("first stop should succeed");
982        consumer
983            .stop()
984            .await
985            .expect("second stop should also succeed");
986    }
987
988    // SQL-008: start after stop is rejected
989    #[tokio::test]
990    async fn start_after_stop_rejected() {
991        let pool = sqlite_pool().await;
992        let mut config = SqlEndpointConfig::from_uri(
993            "sql:select 1?db_url=sqlite::memory:&initialDelay=0&delay=1",
994        )
995        .unwrap();
996        config.resolve_defaults();
997
998        let pool_cell = Arc::new(OnceCell::new());
999        pool_cell.set(Arc::new(pool.clone())).unwrap();
1000
1001        let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
1002        consumer.stop().await.expect("stop should succeed");
1003
1004        let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1005        tokio::spawn(async move {
1006            while let Some(env) = rx.recv().await {
1007                if let Some(reply_tx) = env.reply_tx {
1008                    let _ = reply_tx.send(Ok(env.exchange));
1009                }
1010            }
1011        });
1012        let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1013
1014        let result = consumer.start(ctx).await;
1015        assert!(result.is_err());
1016        let err_msg = result.unwrap_err().to_string();
1017        assert!(
1018            err_msg.contains("cannot be restarted") || err_msg.contains("after stop"),
1019            "Expected restart error, got: {}",
1020            err_msg
1021        );
1022    }
1023
1024    // SQL-021: batch mode per-row post-processing
1025    #[tokio::test]
1026    async fn batch_mode_per_row_post_processing() {
1027        let pool = sqlite_pool().await;
1028        seed_consumer_table(&pool).await;
1029
1030        let mut config = SqlEndpointConfig::from_uri(
1031            "sql:select id, processed, failed from jobs where processed = 0 order by id?db_url=sqlite::memory:&onConsume=update jobs set processed=1 where id=:#id&useIterator=false&initialDelay=0&delay=1",
1032        )
1033        .unwrap();
1034        config.resolve_defaults();
1035
1036        let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
1037        let template = parse_query_template(&config.query, config.placeholder).unwrap();
1038
1039        let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1040        tokio::spawn(async move {
1041            while let Some(env) = rx.recv().await {
1042                if let Some(reply_tx) = env.reply_tx {
1043                    let _ = reply_tx.send(Ok(env.exchange));
1044                }
1045            }
1046        });
1047        let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1048
1049        consumer
1050            .poll_database(&pool, &ctx, &template)
1051            .await
1052            .expect("poll must succeed");
1053
1054        // SQL-021: Each row should have been processed individually via onConsume
1055        let row = sqlx::query("select processed from jobs where id = 1")
1056            .fetch_one(&pool)
1057            .await
1058            .expect("row 1");
1059        let processed_1: i64 = sqlx::Row::try_get(&row, 0).expect("processed");
1060
1061        let row = sqlx::query("select processed from jobs where id = 2")
1062            .fetch_one(&pool)
1063            .await
1064            .expect("row 2");
1065        let processed_2: i64 = sqlx::Row::try_get(&row, 0).expect("processed");
1066
1067        assert_eq!(
1068            processed_1, 1,
1069            "row 1 should be marked processed via per-row onConsume"
1070        );
1071        assert_eq!(
1072            processed_2, 1,
1073            "row 2 should be marked processed via per-row onConsume"
1074        );
1075    }
1076
1077    // SQL-021: batch mode per-row onConsumeFailed when downstream fails
1078    #[tokio::test]
1079    async fn batch_mode_per_row_post_processing_on_failure() {
1080        let pool = sqlite_pool().await;
1081        seed_consumer_table(&pool).await;
1082
1083        let mut config = SqlEndpointConfig::from_uri(
1084            "sql:select id, processed, failed from jobs where processed = 0 order by id?db_url=sqlite::memory:&onConsumeFailed=update jobs set failed=1 where id=:#id&useIterator=false&initialDelay=0&delay=1",
1085        )
1086        .unwrap();
1087        config.resolve_defaults();
1088
1089        let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
1090        let template = parse_query_template(&config.query, config.placeholder).unwrap();
1091
1092        let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1093        tokio::spawn(async move {
1094            while let Some(env) = rx.recv().await {
1095                if let Some(reply_tx) = env.reply_tx {
1096                    let _ =
1097                        reply_tx.send(Err(CamelError::ProcessorError("downstream boom".into())));
1098                }
1099            }
1100        });
1101        let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1102
1103        consumer
1104            .poll_database(&pool, &ctx, &template)
1105            .await
1106            .expect("consumer should swallow downstream errors when breakBatchOnConsumeFail=false");
1107
1108        // SQL-021: Each row should have onConsumeFailed executed individually
1109        let row = sqlx::query("select failed from jobs where id = 1")
1110            .fetch_one(&pool)
1111            .await
1112            .expect("row 1");
1113        let failed_1: i64 = sqlx::Row::try_get(&row, 0).expect("failed");
1114
1115        let row = sqlx::query("select failed from jobs where id = 2")
1116            .fetch_one(&pool)
1117            .await
1118            .expect("row 2");
1119        let failed_2: i64 = sqlx::Row::try_get(&row, 0).expect("failed");
1120
1121        assert_eq!(
1122            failed_1, 1,
1123            "row 1 should be marked failed via per-row onConsumeFailed"
1124        );
1125        assert_eq!(
1126            failed_2, 1,
1127            "row 2 should be marked failed via per-row onConsumeFailed"
1128        );
1129    }
1130
1131    #[tokio::test]
1132    async fn bridge_error_handler_routes_poll_errors_to_exchange_error() {
1133        let mut config = config();
1134        config.bridge_error_handler = true;
1135        let consumer = SqlConsumer::new(config, Arc::new(OnceCell::new()), None, test_rt());
1136
1137        let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(4);
1138        tokio::spawn(async move {
1139            #[allow(clippy::never_loop)]
1140            while let Some(env) = rx.recv().await {
1141                assert!(env.exchange.error.is_some(), "exchange must carry error");
1142                if let Some(reply_tx) = env.reply_tx {
1143                    let _ = reply_tx.send(Ok(env.exchange));
1144                }
1145                break;
1146            }
1147        });
1148
1149        let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1150        consumer
1151            .bridge_poll_error(&ctx, CamelError::ProcessorError("poll failed".into()))
1152            .await
1153            .expect("bridging should succeed");
1154    }
1155
1156    /// Regression for ADR-0012: when bridge_error_handler=true, the poll
1157    /// failure must NOT emit error! (the route's error handler owns ERROR
1158    /// for bridged failures). Was previously duplicated at line 429 + 431.
1159    #[tracing_test::traced_test]
1160    #[tokio::test]
1161    async fn bridged_poll_failure_emits_warn_not_error() {
1162        let pool = sqlite_pool().await;
1163        // Do NOT create any table — the query against a non-existent
1164        // table will fail at fetch_all, returning Err BEFORE any
1165        // downstream send (so lines 103/205 are never reached).
1166
1167        let mut config = config();
1168        config.bridge_error_handler = true;
1169        // Query a non-existent table to trigger a query-failure poll error.
1170        config.query = "select * from nonexistent_table".to_string();
1171        config.resolve_defaults();
1172        let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
1173        let template = parse_query_template(&config.query, config.placeholder).unwrap();
1174
1175        // Healthy downstream — replies Ok so bridge_poll_error succeeds
1176        // and does NOT emit its own error!.
1177        let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(4);
1178        tokio::spawn(async move {
1179            while let Some(env) = rx.recv().await {
1180                if let Some(reply_tx) = env.reply_tx {
1181                    let _ = reply_tx.send(Ok(env.exchange));
1182                }
1183            }
1184        });
1185        let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1186
1187        // Drive poll — fetch_all will fail because the table is missing.
1188        consumer.handle_poll_result(&pool, &ctx, &template).await;
1189
1190        // The bridged path must NOT emit ERROR (handler owns it).
1191        assert!(
1192            !logs_contain("ERROR"),
1193            "bridged poll failure must not emit ERROR (handler owns it); check captured logs for stray ERROR lines"
1194        );
1195        // Sanity: warn! was emitted so the failure is still visible.
1196        assert!(
1197            logs_contain("WARN"),
1198            "bridged poll failure should emit warn! for operator visibility"
1199        );
1200    }
1201
1202    /// Regression for ADR-0012 "b-bridged discriminator": when
1203    /// send_and_wait returns Err on a NORMAL-DATA send (i.e., not a
1204    /// deliberate bridge_poll_error handoff), the route handler did NOT
1205    /// absorb the failure (consumer.rs:77-91 contract; error_handler.rs
1206    /// returns Ok in every branch). The consumer's error! is the only
1207    /// ERROR signal for the unhandled failure and MUST stay at error!.
1208    ///
1209    /// Protects consumer.rs:205 (StreamList downstream send) and any
1210    /// future site that uses send_and_wait on a non-bridge path.
1211    #[tracing_test::traced_test]
1212    #[tokio::test]
1213    async fn unbridged_send_and_wait_failure_emits_error_loud() {
1214        let pool = sqlite_pool().await;
1215        sqlx::query("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)")
1216            .execute(&pool)
1217            .await
1218            .expect("create table");
1219        sqlx::query("INSERT INTO items (id, name) VALUES (1, 'alpha')")
1220            .execute(&pool)
1221            .await
1222            .expect("seed rows");
1223
1224        let mut config = SqlEndpointConfig::from_uri(
1225            "sql:select id, name from items order by id?db_url=sqlite::memory:&outputType=StreamList&initialDelay=0&delay=1",
1226        )
1227        .unwrap();
1228        config.resolve_defaults();
1229        // Explicitly non-bridged: normal-data send path.
1230        config.bridge_error_handler = false;
1231        let consumer = SqlConsumer::new(
1232            config.clone(),
1233            Arc::new(OnceCell::new()),
1234            None,
1235            Arc::new(RecordingRuntime::new(Arc::new(Mutex::new(Vec::new())))),
1236        );
1237        let template = parse_query_template(&config.query, config.placeholder).unwrap();
1238
1239        // Downstream that returns Err — simulates unhandled route failure.
1240        let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1241        tokio::spawn(async move {
1242            while let Some(env) = rx.recv().await {
1243                if let Some(reply_tx) = env.reply_tx {
1244                    let _ = reply_tx.send(Err(CamelError::ProcessorError("boom".into())));
1245                }
1246            }
1247        });
1248        let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1249
1250        let _ = consumer.poll_database(&pool, &ctx, &template).await;
1251
1252        // The unbridged path MUST emit ERROR — consumer owns the signal.
1253        assert!(
1254            logs_contain("ERROR"),
1255            "unbridged send_and_wait failure MUST emit ERROR (consumer owns the signal)"
1256        );
1257    }
1258
1259    /// Regression for ADR-0012: when bridge_error_handler=false, the unbridged
1260    /// branch of handle_poll_result MUST emit ERROR for unhandled poll failure.
1261    #[tracing_test::traced_test]
1262    #[tokio::test]
1263    async fn unbridged_handle_poll_result_emits_error_loud() {
1264        let pool = sqlite_pool().await;
1265        // Do NOT create any table — fetch_all will fail in poll_database.
1266
1267        let mut config = config();
1268        config.bridge_error_handler = false;
1269        config.query = "select * from nonexistent_table".to_string();
1270        config.resolve_defaults();
1271        let consumer = SqlConsumer::new(
1272            config.clone(),
1273            Arc::new(OnceCell::new()),
1274            None,
1275            Arc::new(RecordingRuntime::new(Arc::new(Mutex::new(Vec::new())))),
1276        );
1277        let template = parse_query_template(&config.query, config.placeholder).unwrap();
1278
1279        // Healthy downstream task; should not be reached for this poll-failure path.
1280        let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(4);
1281        tokio::spawn(async move {
1282            while let Some(env) = rx.recv().await {
1283                if let Some(reply_tx) = env.reply_tx {
1284                    let _ = reply_tx.send(Ok(env.exchange));
1285                }
1286            }
1287        });
1288        let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1289
1290        consumer.handle_poll_result(&pool, &ctx, &template).await;
1291
1292        assert!(
1293            logs_contain("ERROR"),
1294            "unbridged handle_poll_result failure MUST emit ERROR (consumer owns signal)"
1295        );
1296    }
1297
1298    #[tokio::test]
1299    async fn stream_list_consumer_emits_ndjson_body() {
1300        let pool = sqlite_pool().await;
1301        sqlx::query("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)")
1302            .execute(&pool)
1303            .await
1304            .expect("create table");
1305        sqlx::query("INSERT INTO items (id, name) VALUES (1, 'alpha'), (2, 'beta'), (3, 'gamma')")
1306            .execute(&pool)
1307            .await
1308            .expect("seed rows");
1309
1310        let mut config = SqlEndpointConfig::from_uri(
1311            "sql:select id, name from items order by id?db_url=sqlite::memory:&outputType=StreamList&initialDelay=0&delay=1",
1312        )
1313        .unwrap();
1314        config.resolve_defaults();
1315
1316        let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
1317        let template = parse_query_template(&config.query, config.placeholder).unwrap();
1318
1319        let (tx, rx) = mpsc::channel::<ExchangeEnvelope>(8);
1320        let (result_tx, result_rx) = tokio::sync::oneshot::channel::<Exchange>();
1321        tokio::spawn(async move {
1322            let mut rx = rx;
1323            if let Some(env) = rx.recv().await {
1324                if let Some(reply_tx) = env.reply_tx {
1325                    let _ = reply_tx.send(Ok(env.exchange.clone()));
1326                }
1327                let _ = result_tx.send(env.exchange);
1328            }
1329        });
1330        let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1331
1332        consumer
1333            .poll_database(&pool, &ctx, &template)
1334            .await
1335            .expect("poll must succeed");
1336
1337        let exchange = result_rx.await.expect("should have received one exchange");
1338
1339        match exchange.input.body {
1340            Body::Stream(ref stream_body) => {
1341                let stream = stream_body.stream.clone();
1342                let mut guard = stream.lock().await;
1343                let stream_opt = guard.take();
1344                assert!(stream_opt.is_some(), "stream should be present");
1345
1346                use futures::StreamExt;
1347                let mut collected = Vec::new();
1348                let mut stream = stream_opt.unwrap();
1349                while let Some(chunk) = stream.next().await {
1350                    let chunk = chunk.expect("stream chunk should not error");
1351                    collected.extend_from_slice(&chunk);
1352                }
1353
1354                let ndjson = String::from_utf8(collected).expect("valid utf8");
1355                let lines: Vec<&str> = ndjson.trim().lines().collect();
1356                assert_eq!(lines.len(), 3, "should have 3 NDJSON lines");
1357
1358                let row0: serde_json::Value =
1359                    serde_json::from_str(lines[0]).expect("valid json line 0");
1360                assert_eq!(row0["id"], 1);
1361                assert_eq!(row0["name"], "alpha");
1362
1363                let row1: serde_json::Value =
1364                    serde_json::from_str(lines[1]).expect("valid json line 1");
1365                assert_eq!(row1["id"], 2);
1366                assert_eq!(row1["name"], "beta");
1367
1368                let row2: serde_json::Value =
1369                    serde_json::from_str(lines[2]).expect("valid json line 2");
1370                assert_eq!(row2["id"], 3);
1371                assert_eq!(row2["name"], "gamma");
1372            }
1373            ref other => panic!("expected Body::Stream, got {:?}", other),
1374        }
1375    }
1376
1377    #[tokio::test]
1378    async fn stream_list_consumer_empty_result_set_emits_empty_stream() {
1379        let pool = sqlite_pool().await;
1380        sqlx::query("CREATE TABLE empty_items (id INTEGER PRIMARY KEY, name TEXT)")
1381            .execute(&pool)
1382            .await
1383            .expect("create table");
1384
1385        let mut config = SqlEndpointConfig::from_uri(
1386            "sql:select id, name from empty_items?db_url=sqlite::memory:&outputType=StreamList&initialDelay=0&delay=1",
1387        )
1388        .unwrap();
1389        config.resolve_defaults();
1390
1391        let consumer = SqlConsumer::new(config.clone(), Arc::new(OnceCell::new()), None, test_rt());
1392        let template = parse_query_template(&config.query, config.placeholder).unwrap();
1393
1394        let (tx, rx) = tokio::sync::oneshot::channel();
1395        let (mpsc_tx, mut mpsc_rx) = mpsc::channel::<ExchangeEnvelope>(8);
1396        tokio::spawn(async move {
1397            #[allow(clippy::never_loop)]
1398            while let Some(env) = mpsc_rx.recv().await {
1399                if let Some(reply_tx) = env.reply_tx {
1400                    let _ = reply_tx.send(Ok(env.exchange.clone()));
1401                }
1402                let _ = tx.send(env.exchange);
1403                break;
1404            }
1405        });
1406        let ctx = ConsumerContext::new(
1407            mpsc_tx,
1408            CancellationToken::new(),
1409            "sql-test-route".to_string(),
1410        );
1411
1412        consumer
1413            .poll_database(&pool, &ctx, &template)
1414            .await
1415            .expect("poll must succeed");
1416
1417        let exchange = rx
1418            .await
1419            .expect("StreamList should emit exchange even for empty results");
1420
1421        match exchange.input.body {
1422            Body::Stream(ref stream_body) => {
1423                let stream = stream_body.stream.clone();
1424                let mut guard = stream.lock().await;
1425                let stream_opt = guard.take();
1426
1427                use futures::StreamExt;
1428                let mut count = 0;
1429                if let Some(mut stream) = stream_opt {
1430                    while let Some(chunk) = stream.next().await {
1431                        let chunk = chunk.expect("stream chunk should not error");
1432                        count += chunk.len();
1433                    }
1434                }
1435                assert_eq!(count, 0, "empty table should produce zero stream bytes");
1436            }
1437            ref other => panic!("expected Body::Stream, got {:?}", other),
1438        }
1439    }
1440
1441    #[tokio::test]
1442    async fn break_on_empty_stops_after_drained_table() {
1443        let pool = sqlite_pool().await;
1444        seed_consumer_table(&pool).await;
1445
1446        // onConsume marks rows processed=1; query selects only processed=0 → drains in one poll.
1447        let mut config = SqlEndpointConfig::from_uri(
1448            "sql:select id from jobs where processed = 0 order by id?db_url=sqlite::memory:&onConsume=update jobs set processed=1 where id=:#id&initialDelay=0&delay=50&breakOnEmpty=true&repeatCount=100",
1449        )
1450        .unwrap();
1451        config.resolve_defaults();
1452
1453        // Inject the seeded pool — start() otherwise self-initializes a disjoint
1454        // sqlite::memory: DB (per-connection private).
1455        let pool_cell = Arc::new(OnceCell::new());
1456        pool_cell
1457            .set(Arc::new(pool.clone()))
1458            .expect("pool cell set");
1459        let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
1460
1461        let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1462        let route_cancel = CancellationToken::new();
1463        // Count envelopes so we can pin the productive poll's row processing.
1464        // With use_iterator=true (default) and 2 seeded rows, the productive poll
1465        // must emit exactly 2 envelopes before the empty poll triggers the break.
1466        let received = Arc::new(std::sync::atomic::AtomicU32::new(0));
1467        let echo_cancel = route_cancel.clone();
1468        let counter = Arc::clone(&received);
1469        // Echo replies so the poll completes.
1470        tokio::spawn(async move {
1471            loop {
1472                tokio::select! {
1473                    _ = echo_cancel.cancelled() => break,
1474                    Some(env) = rx.recv() => {
1475                        counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1476                        if let Some(reply_tx) = env.reply_tx {
1477                            let _ = reply_tx.send(Ok(env.exchange));
1478                        }
1479                    }
1480                }
1481            }
1482        });
1483
1484        let ctx = ConsumerContext::new(tx, route_cancel.clone(), "sql-test-route".to_string());
1485
1486        // start() runs the poll loop to completion (returns when the loop breaks).
1487        let start = tokio::time::Instant::now();
1488        consumer.start(ctx).await.expect("start must succeed");
1489        let elapsed = start.elapsed();
1490
1491        // Productive poll must have emitted exactly 2 envelopes (one per row in
1492        // the seeded table). Locks "no rows skipped, no rows lost".
1493        let n = received.load(std::sync::atomic::Ordering::Relaxed);
1494        assert_eq!(
1495            n, 2,
1496            "productive poll should emit 2 envelopes (2 seeded rows), got {}",
1497            n
1498        );
1499
1500        // Both rows drained (processed=1) by the productive poll.
1501        let count_unprocessed: i64 =
1502            sqlx::query_scalar("select count(*) from jobs where processed = 0")
1503                .fetch_one(&pool)
1504                .await
1505                .expect("count");
1506        assert_eq!(count_unprocessed, 0);
1507
1508        // Upper bound: with delay=10ms and breakOnEmpty, the consumer should stop
1509        // well under the repeatCount=100 ceiling (which would take ~1s).
1510        assert!(
1511            elapsed < std::time::Duration::from_millis(500),
1512            "consumer should have stopped on empty poll, took {:?}",
1513            elapsed
1514        );
1515
1516        // Lower bound — LOCKS the [productive_poll, empty_poll] ordering.
1517        // With delay=50ms:
1518        //   - Correct: at least 2 full delays elapse (1st sleep + 2nd sleep) ≈ 100ms+,
1519        //     because the loop MUST run a second (empty) poll before breaking.
1520        //   - Buggy (break after the productive poll): only the 1st delay elapses
1521        //     ≈ 50ms+processing. 100ms threshold sits safely between the two and
1522        //     would FAIL if the consumer incorrectly set was_empty=true on the
1523        //     productive poll and broke early. Wide margin survives slow CI boxes.
1524        assert!(
1525            elapsed >= std::time::Duration::from_millis(100),
1526            "consumer must run a second (empty) poll before breaking on break_on_empty, \
1527             took {:?} — likely broke after the productive poll without seeing the empty one",
1528            elapsed
1529        );
1530    }
1531
1532    /// Regression: `handle_poll_result` with `break_on_empty=true` must NOT
1533    /// signal `was_empty: true` when `poll_database` returns an error — the
1534    /// loop should continue past the error.
1535    #[tokio::test]
1536    async fn handle_poll_result_error_does_not_signal_empty() {
1537        let pool = sqlite_pool().await;
1538
1539        let mut config = SqlEndpointConfig::from_uri(
1540            "sql:select * from this_table_does_not_exist?db_url=sqlite::memory:&breakOnEmpty=true&initialDelay=0&delay=1",
1541        )
1542        .unwrap();
1543        config.resolve_defaults();
1544
1545        let pool_cell = Arc::new(OnceCell::new());
1546        pool_cell.set(Arc::new(pool.clone())).unwrap();
1547        // Use RecordingRuntime (not test_rt/PanicRuntime) because
1548        // handle_poll_result calls record_post_process_failure → metrics().
1549        let consumer = SqlConsumer::new(
1550            config.clone(),
1551            pool_cell,
1552            None,
1553            Arc::new(RecordingRuntime::new(Arc::new(Mutex::new(Vec::new())))),
1554        );
1555
1556        let template = parse_query_template(&config.query, config.placeholder).unwrap();
1557
1558        let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1559        tokio::spawn(async move {
1560            while let Some(env) = rx.recv().await {
1561                if let Some(reply_tx) = env.reply_tx {
1562                    let _ = reply_tx.send(Ok(env.exchange));
1563                }
1564            }
1565        });
1566        let ctx = ConsumerContext::new(tx, CancellationToken::new(), "sql-test-route".to_string());
1567
1568        let outcome = consumer.handle_poll_result(&pool, &ctx, &template).await;
1569        assert!(
1570            !outcome.was_empty,
1571            "poll error must NOT signal empty (was_empty should be false)"
1572        );
1573    }
1574
1575    // ── ADR-0012 (g) regression tests ──────────────────────────────────
1576
1577    /// Fixture: captures `force_unhealthy_for_route` calls.
1578    #[derive(Debug, Default)]
1579    struct RecordingHealth {
1580        forced: Arc<Mutex<Vec<(String, String, String)>>>,
1581    }
1582
1583    impl HealthCheckRegistry for RecordingHealth {
1584        fn force_unhealthy_for_route(&self, route_id: &str, name: &str, reason: &str) {
1585            self.forced.lock().unwrap().push((
1586                route_id.to_string(),
1587                name.to_string(),
1588                reason.to_string(),
1589            ));
1590        }
1591    }
1592
1593    struct NoopMetricsForConsumer;
1594
1595    impl MetricsCollector for NoopMetricsForConsumer {
1596        fn record_exchange_duration(&self, _: &str, _: Duration) {}
1597        fn increment_errors(&self, _: &str, _: &str) {}
1598        fn increment_exchanges(&self, _: &str) {}
1599        fn set_queue_depth(&self, _: &str, _: usize) {}
1600        fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
1601    }
1602
1603    struct RecordingRuntimeWithHealth {
1604        health: Arc<RecordingHealth>,
1605    }
1606
1607    impl RuntimeObservability for RecordingRuntimeWithHealth {
1608        fn metrics(&self) -> Arc<dyn MetricsCollector> {
1609            Arc::new(NoopMetricsForConsumer)
1610        }
1611        fn health(&self) -> Arc<dyn HealthCheckRegistry> {
1612            self.health.clone()
1613        }
1614    }
1615
1616    /// Regression: consumer pool init failure calls force_unhealthy_for_route
1617    /// with correct route_id + name "g:sql:consumer-pool-init" + non-empty reason.
1618    #[tokio::test]
1619    async fn consumer_pool_init_failure_calls_force_unhealthy_for_route() {
1620        let health = Arc::new(RecordingHealth::default());
1621        let recorded_health = health.clone();
1622        let rt: Arc<dyn RuntimeObservability> = Arc::new(RecordingRuntimeWithHealth { health });
1623
1624        let mut config = SqlEndpointConfig::from_uri(
1625            "sql:select 1?db_url=postgres://nonexistent-host:5432/nonexistent_db&retryEnabled=false&initialDelay=0&delay=1",
1626        )
1627        .unwrap();
1628        config.max_connections = Some(1);
1629        config.min_connections = Some(0);
1630        config.idle_timeout_secs = Some(300);
1631        config.max_lifetime_secs = Some(1800);
1632
1633        let mut consumer = SqlConsumer::new(config, Arc::new(OnceCell::new()), None, rt);
1634
1635        let (tx, _rx) = mpsc::channel(8);
1636        let ctx = ConsumerContext::new(
1637            tx,
1638            CancellationToken::new(),
1639            "sql-consumer-test-route".to_string(),
1640        );
1641
1642        let result = consumer.start(ctx).await;
1643        assert!(result.is_err(), "pool init should fail with bad db_url");
1644
1645        let forced = recorded_health.forced.lock().unwrap();
1646        assert_eq!(
1647            forced.len(),
1648            1,
1649            "expected one force_unhealthy_for_route call"
1650        );
1651        assert_eq!(forced[0].0, "sql-consumer-test-route");
1652        assert_eq!(forced[0].1, "g:sql:consumer-pool-init");
1653        assert!(!forced[0].2.is_empty(), "reason should be non-empty");
1654    }
1655
1656    /// Regression: max_attempts=N → exactly N invocations (caught OpenSearch off-by-one 1f5c4c2a).
1657    /// Replicates the exact retry loop from SqlConsumer::start() (consumer.rs:343-367):
1658    ///   attempt starts at 0, incremented at top, should_retry(attempt), delay_for(attempt-1)
1659    #[tokio::test]
1660    async fn retry_loop_invokes_operation_exactly_max_attempts_times() {
1661        use camel_component_api::NetworkRetryPolicy;
1662        use std::sync::Arc;
1663        use std::sync::atomic::{AtomicU32, Ordering};
1664
1665        let policy = NetworkRetryPolicy {
1666            max_attempts: 3,
1667            initial_delay: Duration::from_millis(1),
1668            max_delay: Duration::from_millis(1),
1669            multiplier: 1.0,
1670            ..NetworkRetryPolicy::default()
1671        };
1672
1673        let calls = Arc::new(AtomicU32::new(0));
1674        let calls_clone = Arc::clone(&calls);
1675
1676        let mut attempt: u32 = 0;
1677        let _result: Result<(), ()> = loop {
1678            attempt += 1;
1679            calls_clone.fetch_add(1, Ordering::SeqCst);
1680            let op_result: Result<(), ()> = Err(());
1681            match op_result {
1682                Ok(v) => break Ok(v),
1683                Err(_) if policy.should_retry(attempt) => {
1684                    let delay = policy.delay_for(attempt - 1);
1685                    tokio::time::sleep(delay).await;
1686                    continue;
1687                }
1688                Err(_) => break Err(()),
1689            }
1690        };
1691
1692        assert_eq!(
1693            calls.load(Ordering::SeqCst),
1694            3,
1695            "max_attempts=3 must yield exactly 3 invocations"
1696        );
1697    }
1698
1699    // ── break_on_empty edge-case tests ──────────────────────────────────
1700
1701    #[tokio::test]
1702    #[tracing_test::traced_test]
1703    async fn break_on_empty_ignored_in_streamlist() {
1704        let pool = sqlite_pool().await;
1705        seed_consumer_table(&pool).await;
1706
1707        // StreamList + breakOnEmpty=true: warn must fire, breakOnEmpty ignored
1708        // (no break on empty — rows flow lazily). repeatCount=3 to distinguish
1709        // "ran 3 polls" from "broke on poll 1".
1710        let mut config = SqlEndpointConfig::from_uri(
1711            "sql:select id from jobs?db_url=sqlite::memory:&outputType=StreamList&initialDelay=0&delay=1&breakOnEmpty=true&repeatCount=3",
1712        )
1713        .unwrap();
1714        config.resolve_defaults();
1715        assert_eq!(config.output_type, SqlOutputType::StreamList);
1716        assert!(config.break_on_empty);
1717
1718        let pool_cell = Arc::new(OnceCell::new());
1719        pool_cell
1720            .set(Arc::new(pool.clone()))
1721            .expect("pool cell set");
1722        let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
1723
1724        let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1725        let route_cancel = CancellationToken::new();
1726        let received = Arc::new(std::sync::atomic::AtomicU32::new(0));
1727        let echo_cancel = route_cancel.clone();
1728        let counter = Arc::clone(&received);
1729        tokio::spawn(async move {
1730            loop {
1731                tokio::select! {
1732                    _ = echo_cancel.cancelled() => break,
1733                    Some(env) = rx.recv() => {
1734                        counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1735                        if let Some(reply_tx) = env.reply_tx {
1736                            let _ = reply_tx.send(Ok(env.exchange));
1737                        }
1738                    }
1739                }
1740            }
1741        });
1742
1743        let ctx = ConsumerContext::new(tx, route_cancel, "sql-test-route".to_string());
1744        consumer.start(ctx).await.expect("start must succeed");
1745
1746        // The startup warn must name breakOnEmpty (FAILS before Step 2 — current
1747        // warn message omits it).
1748        assert!(
1749            logs_contain("breakOnEmpty"),
1750            "expected StreamList warn naming breakOnEmpty"
1751        );
1752
1753        // Counter must prove the stream ran multiple polls (no early break).
1754        // repeatCount=3 with delay=1ms means 3 polls; even accounting for race
1755        // the counter must be >=2 if no early break.
1756        assert!(
1757            received.load(std::sync::atomic::Ordering::Relaxed) >= 2,
1758            "StreamList must not break early with breakOnEmpty, got {} exchanges",
1759            received.load(std::sync::atomic::Ordering::Relaxed)
1760        );
1761    }
1762
1763    #[tokio::test]
1764    async fn break_on_empty_false_default_loops_on_empty() {
1765        let pool = sqlite_pool().await;
1766        // Empty table (seed then drain) — every poll returns 0 rows.
1767        seed_consumer_table(&pool).await;
1768        sqlx::query("delete from jobs")
1769            .execute(&pool)
1770            .await
1771            .expect("drain");
1772
1773        // breakOnEmpty NOT set (default false); repeatCount=3 so the loop must run
1774        // all 3 polls (NOT break on the first empty poll). delay=20ms each.
1775        let mut config = SqlEndpointConfig::from_uri(
1776            "sql:select id from jobs where processed = 0?db_url=sqlite::memory:&initialDelay=0&delay=20&repeatCount=3",
1777        )
1778        .unwrap();
1779        config.resolve_defaults();
1780        assert!(!config.break_on_empty);
1781
1782        let pool_cell = Arc::new(OnceCell::new());
1783        pool_cell
1784            .set(Arc::new(pool.clone()))
1785            .expect("pool cell set");
1786        let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
1787
1788        let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1789        let route_cancel = CancellationToken::new();
1790        let echo_cancel = route_cancel.clone();
1791        tokio::spawn(async move {
1792            loop {
1793                tokio::select! {
1794                    _ = echo_cancel.cancelled() => break,
1795                    Some(env) = rx.recv() => {
1796                        if let Some(reply_tx) = env.reply_tx {
1797                            let _ = reply_tx.send(Ok(env.exchange));
1798                        }
1799                    }
1800                }
1801            }
1802        });
1803
1804        let ctx = ConsumerContext::new(tx, route_cancel, "sql-test-route".to_string());
1805        let start = tokio::time::Instant::now();
1806        consumer.start(ctx).await.expect("start must succeed");
1807        let elapsed = start.elapsed();
1808
1809        // Regression guard: with breakOnEmpty=false + repeatCount=3 + delay=20ms,
1810        // the loop runs all 3 polls (~60ms). If breakOnEmpty were mis-defaulted to
1811        // true, it would break on poll 1 (~20ms). Assert the full window ran.
1812        assert!(
1813            elapsed >= std::time::Duration::from_millis(55),
1814            "consumer should run all 3 polls (breakOnEmpty=false), took {:?}",
1815            elapsed
1816        );
1817    }
1818
1819    #[tokio::test]
1820    async fn break_on_empty_with_route_empty_result_set() {
1821        let pool = sqlite_pool().await;
1822        seed_consumer_table(&pool).await;
1823        sqlx::query("delete from jobs")
1824            .execute(&pool)
1825            .await
1826            .expect("drain");
1827        // Side-effect table for onConsumeBatchComplete: each fire of the
1828        // batch-complete callback increments `n` exactly once. With
1829        // breakOnEmpty=true on an empty table, the spec pins that the
1830        // empty-poll fall-through fires the callback exactly once before
1831        // termination.
1832        sqlx::query("CREATE TABLE batch_marks (n INTEGER NOT NULL DEFAULT 0)")
1833            .execute(&pool)
1834            .await
1835            .expect("create batch_marks");
1836        sqlx::query("INSERT INTO batch_marks (n) VALUES (0)")
1837            .execute(&pool)
1838            .await
1839            .expect("seed batch_marks");
1840
1841        // Empty table + routeEmptyResultSet=true: empty polls fall through to the
1842        // batch path (emit empty result) instead of early-returning. breakOnEmpty=true
1843        // must break AFTER that batch processing → exactly one downstream exchange.
1844        // onConsumeBatchComplete is wired so we can observe the empty-poll fall-through.
1845        let mut config = SqlEndpointConfig::from_uri(
1846            "sql:select id from jobs where processed = 0?db_url=sqlite::memory:&routeEmptyResultSet=true&breakOnEmpty=true&useIterator=false&onConsumeBatchComplete=update batch_marks set n = n + 1&initialDelay=0&delay=10&repeatCount=100",
1847        )
1848        .unwrap();
1849        config.resolve_defaults();
1850
1851        let pool_cell = Arc::new(OnceCell::new());
1852        pool_cell
1853            .set(Arc::new(pool.clone()))
1854            .expect("pool cell set");
1855        let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
1856
1857        let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1858        let route_cancel = CancellationToken::new();
1859        let received = Arc::new(std::sync::atomic::AtomicU32::new(0));
1860        let echo_cancel = route_cancel.clone();
1861        let counter = Arc::clone(&received);
1862        tokio::spawn(async move {
1863            loop {
1864                tokio::select! {
1865                    _ = echo_cancel.cancelled() => break,
1866                    Some(env) = rx.recv() => {
1867                        counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1868                        if let Some(reply_tx) = env.reply_tx {
1869                            let _ = reply_tx.send(Ok(env.exchange));
1870                        }
1871                    }
1872                }
1873            }
1874        });
1875
1876        let ctx = ConsumerContext::new(tx, route_cancel, "sql-test-route".to_string());
1877        let start = tokio::time::Instant::now();
1878        consumer.start(ctx).await.expect("start must succeed");
1879
1880        // Exactly one downstream exchange emitted (the first empty poll's empty result),
1881        // then breakOnEmpty stopped the loop. Must NOT be 0 (route_empty honored) and
1882        // must NOT be repeatCount=100 (break_on_empty honored).
1883        let n = received.load(std::sync::atomic::Ordering::Relaxed);
1884        assert_eq!(n, 1, "expected exactly 1 empty-result exchange, got {}", n);
1885        assert!(
1886            start.elapsed() < std::time::Duration::from_millis(500),
1887            "consumer should have stopped after the first empty poll, took {:?}",
1888            start.elapsed()
1889        );
1890
1891        // onConsumeBatchComplete must fire exactly once on the empty-poll
1892        // fall-through before the loop breaks. A buggy version that broke
1893        // before invoking the batch callback would leave n=0; a version
1894        // that looped through repeatCount=100 would leave n=100.
1895        let batch_fires: i64 = sqlx::query_scalar("select n from batch_marks")
1896            .fetch_one(&pool)
1897            .await
1898            .expect("batch_marks n");
1899        assert_eq!(
1900            batch_fires, 1,
1901            "onConsumeBatchComplete must fire exactly once on the empty-poll \
1902             fall-through before break_on_empty, got {}",
1903            batch_fires
1904        );
1905    }
1906
1907    #[tokio::test]
1908    async fn repeat_count_zero_polls_never() {
1909        let pool = sqlite_pool().await;
1910        seed_consumer_table(&pool).await;
1911
1912        // repeatCount=0 → consumer exits before the first poll (guard at loop top).
1913        let mut config = SqlEndpointConfig::from_uri(
1914            "sql:select id from jobs?db_url=sqlite::memory:&initialDelay=0&delay=1&repeatCount=0&onConsume=update jobs set processed=1 where id=:#id",
1915        )
1916        .unwrap();
1917        config.resolve_defaults();
1918
1919        // Inject the seeded pool — start() otherwise self-initializes a disjoint
1920        // sqlite::memory: DB (per-connection private). Pattern: consumer.rs:967-970.
1921        let pool_cell = Arc::new(OnceCell::new());
1922        pool_cell
1923            .set(Arc::new(pool.clone()))
1924            .expect("pool cell set");
1925        let mut consumer = SqlConsumer::new(config, pool_cell, None, test_rt());
1926
1927        let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(8);
1928        let route_cancel = CancellationToken::new();
1929        let echo_cancel = route_cancel.clone();
1930        tokio::spawn(async move {
1931            loop {
1932                tokio::select! {
1933                    _ = echo_cancel.cancelled() => break,
1934                    Some(env) = rx.recv() => {
1935                        if let Some(reply_tx) = env.reply_tx {
1936                            let _ = reply_tx.send(Ok(env.exchange));
1937                        }
1938                    }
1939                }
1940            }
1941        });
1942
1943        let ctx = ConsumerContext::new(tx, route_cancel, "sql-test-route".to_string());
1944        consumer.start(ctx).await.expect("start must succeed");
1945
1946        // No poll ran → rows are untouched (processed=0).
1947        let count_processed: i64 =
1948            sqlx::query_scalar("select count(*) from jobs where processed = 1")
1949                .fetch_one(&pool)
1950                .await
1951                .expect("count");
1952        assert_eq!(count_processed, 0, "repeatCount=0 must not poll");
1953    }
1954}