This is an automated email from the ASF dual-hosted git repository.

oscerd pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-kafka-connector.git


The following commit(s) were added to refs/heads/main by this push:
     new b240c5503b Fix #1730: apply the camel.source.pollingConsumer* options 
to the SEDA hand-off queue (#1870)
b240c5503b is described below

commit b240c5503b1925700c6d64ed213949d8701b315c
Author: Brian Hayden <[email protected]>
AuthorDate: Fri Nov 6 04:49:27 2026 -0500

    Fix #1730: apply the camel.source.pollingConsumer* options to the SEDA 
hand-off queue (#1870)
    
    The source task receives exchanges through seda:end, but the
    camel.source.pollingConsumer* options were only added to its URI as the
    generic DefaultEndpoint polling-consumer options, which SedaEndpoint
    ignores. The queue kept SEDA's defaults (size 1000, blockWhenFull=false),
    so a burst failed with IllegalStateException: Queue full.
    
    Pass the values as SEDA's own size, blockWhenFull and offerTimeout
    options too. A negative pollingConsumerBlockTimeout is clamped to 0,
    since a negative offerTimeout makes SEDA fall back to add() and throw
    when full; the option is documented as "0 or negative means no timeout".
    
    Behaviour change: with the default blockWhenFull=true the Camel consumer
    thread now blocks while the queue is full, and the endpoint size takes
    precedence over camel.component.seda.queueSize. A consumer blocked on a
    full queue holds an in-flight exchange, so stopping the task can wait up
    to camel.main.shutdownTimeout, longer than Kafka Connect's default
    task.shutdown.graceful.timeout.ms of 5s. The user guide documents this.
    
    Signed-off-by: Brian Hayden <[email protected]>
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 core/pom.xml                                       |   6 +
 .../camel/kafkaconnector/CamelSourceTask.java      |   8 +-
 .../camel/kafkaconnector/CamelSourceTaskTest.java  | 152 +++++++++++++++++++++
 .../ROOT/pages/user-guide/basic-configuration.adoc |  11 ++
 4 files changed, 176 insertions(+), 1 deletion(-)

diff --git a/core/pom.xml b/core/pom.xml
index 5f6f2706b3..e13b342070 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -125,6 +125,12 @@
             <artifactId>camel-syslog</artifactId>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.awaitility</groupId>
+            <artifactId>awaitility</artifactId>
+            <version>${awaitility-version}</version>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <groupId>org.apache.camel</groupId>
             <artifactId>camel-cassandraql</artifactId>
diff --git 
a/core/src/main/java/org/apache/camel/kafkaconnector/CamelSourceTask.java 
b/core/src/main/java/org/apache/camel/kafkaconnector/CamelSourceTask.java
index 218bd17c1a..dbc330871f 100644
--- a/core/src/main/java/org/apache/camel/kafkaconnector/CamelSourceTask.java
+++ b/core/src/main/java/org/apache/camel/kafkaconnector/CamelSourceTask.java
@@ -439,7 +439,13 @@ public class CamelSourceTask extends SourceTask {
 
     private String getLocalUrlWithPollingOptions(long 
pollingConsumerQueueSize, long pollingConsumerBlockTimeout, boolean 
pollingConsumerBlockWhenFull) {
         return LOCAL_URL + "?pollingConsumerQueueSize=" + 
pollingConsumerQueueSize + "&pollingConsumerBlockTimeout=" + 
pollingConsumerBlockTimeout
-               + "&pollingConsumerBlockWhenFull=" + 
pollingConsumerBlockWhenFull;
+               + "&pollingConsumerBlockWhenFull=" + 
pollingConsumerBlockWhenFull
+               // SedaEndpoint polls its own queue and ignores the 
pollingConsumer* options above, so the hand-off queue
+               // only gets these values through SEDA's own options. A 
negative offerTimeout makes SEDA fall back to
+               // add(), which throws when the queue is full, so it is clamped 
to 0 (block with no timeout), matching
+               // the documented "0 or negative then no timeout is in use".
+               + "&size=" + pollingConsumerQueueSize + "&blockWhenFull=" + 
pollingConsumerBlockWhenFull
+               + "&offerTimeout=" + Math.max(0L, pollingConsumerBlockTimeout);
     }
 
     /**
diff --git 
a/core/src/test/java/org/apache/camel/kafkaconnector/CamelSourceTaskTest.java 
b/core/src/test/java/org/apache/camel/kafkaconnector/CamelSourceTaskTest.java
index 38e929adac..80c3f952ac 100644
--- 
a/core/src/test/java/org/apache/camel/kafkaconnector/CamelSourceTaskTest.java
+++ 
b/core/src/test/java/org/apache/camel/kafkaconnector/CamelSourceTaskTest.java
@@ -19,6 +19,7 @@ package org.apache.camel.kafkaconnector;
 import java.io.ByteArrayInputStream;
 import java.math.BigDecimal;
 import java.nio.charset.StandardCharsets;
+import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -29,6 +30,8 @@ import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
@@ -36,6 +39,7 @@ import java.util.stream.IntStream;
 import org.apache.camel.Exchange;
 import org.apache.camel.LoggingLevel;
 import org.apache.camel.ProducerTemplate;
+import org.apache.camel.component.seda.SedaEndpoint;
 import org.apache.camel.converter.stream.InputStreamCache;
 import org.apache.camel.kafkaconnector.utils.StringJoinerAggregator;
 import org.apache.camel.support.SynchronizationAdapter;
@@ -50,6 +54,7 @@ import org.junit.jupiter.api.Test;
 
 import static org.apache.camel.util.CollectionHelper.mapOf;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -379,9 +384,156 @@ public class CamelSourceTaskTest {
                     
assertTrue(e.getEndpointUri().contains("pollingConsumerBlockWhenFull=false"));
                 });
 
+        // SedaEndpoint ignores the generic pollingConsumer* options, the 
values must reach its own options
+        SedaEndpoint seda = sedaEndpoint(sourceTask);
+        assertEquals(10, seda.getSize());
+        assertFalse(seda.isBlockWhenFull());
+        assertEquals(10L, seda.getOfferTimeout());
+
+        sourceTask.stop();
+    }
+
+    @Test
+    public void testSourcePollingConsumerDefaultOptions() {
+        Map<String, String> props = new HashMap<>();
+        props.put(CamelSourceConnectorConfig.TOPIC_CONF, TOPIC_NAME);
+        props.put(CamelSourceConnectorConfig.CAMEL_SOURCE_URL_CONF, 
DIRECT_URI);
+
+        CamelSourceTask sourceTask = new CamelSourceTask();
+        sourceTask.start(props);
+
+        SedaEndpoint seda = sedaEndpoint(sourceTask);
+        
assertEquals(CamelSourceConnectorConfig.CAMEL_SOURCE_POLLING_CONSUMER_QUEUE_SIZE_DEFAULT.intValue(),
 seda.getSize());
+        assertTrue(seda.isBlockWhenFull());
+        assertEquals(0L, seda.getOfferTimeout());
+
+        sourceTask.stop();
+    }
+
+    @Test
+    public void testSourcePollingConsumerNegativeBlockTimeoutMeansNoTimeout() {
+        Map<String, String> props = new HashMap<>();
+        props.put(CamelSourceConnectorConfig.TOPIC_CONF, TOPIC_NAME);
+        props.put(CamelSourceConnectorConfig.CAMEL_SOURCE_URL_CONF, 
DIRECT_URI);
+        
props.put(CamelSourceConnectorConfig.CAMEL_SOURCE_POLLING_CONSUMER_BLOCK_TIMEOUT_CONF,
 "-1");
+
+        CamelSourceTask sourceTask = new CamelSourceTask();
+        sourceTask.start(props);
+
+        // a negative offerTimeout would make SEDA use add(), which throws 
"Queue full" instead of blocking
+        assertEquals(0L, sedaEndpoint(sourceTask).getOfferTimeout());
+
+        sourceTask.stop();
+    }
+
+    @Test
+    public void testSourcePollingConsumerQueueSizeWinsOverComponentQueueSize() 
{
+        Map<String, String> props = new HashMap<>();
+        props.put(CamelSourceConnectorConfig.TOPIC_CONF, TOPIC_NAME);
+        props.put(CamelSourceConnectorConfig.CAMEL_SOURCE_URL_CONF, 
DIRECT_URI);
+        // camel.source.pollingConsumerQueueSize is left at its default of 1000
+        props.put("camel.component.seda.queueSize", "7");
+
+        CamelSourceTask sourceTask = new CamelSourceTask();
+        sourceTask.start(props);
+
+        sendBatchOfRecords(sourceTask, 1);
+        SedaEndpoint seda = sedaEndpoint(sourceTask);
+        assertEquals(1, seda.getQueue().size());
+        assertEquals(999, seda.getQueue().remainingCapacity());
+
         sourceTask.stop();
     }
 
+    @Test
+    public void testSourcePollingConsumerBlocksWhenFull() throws Exception {
+        Map<String, String> props = new HashMap<>();
+        props.put(CamelSourceConnectorConfig.TOPIC_CONF, TOPIC_NAME);
+        props.put(CamelSourceConnectorConfig.CAMEL_SOURCE_URL_CONF, 
DIRECT_URI);
+        
props.put(CamelSourceConnectorConfig.CAMEL_SOURCE_POLLING_CONSUMER_QUEUE_SIZE_CONF,
 "2");
+
+        CamelSourceTask sourceTask = new CamelSourceTask();
+        sourceTask.start(props);
+        SedaEndpoint seda = sedaEndpoint(sourceTask);
+
+        ExecutorService sender = Executors.newSingleThreadExecutor();
+        try {
+            AtomicInteger sent = new AtomicInteger();
+            Future<?> sending = sender.submit(() -> {
+                ProducerTemplate template = 
sourceTask.getCms().getProducerTemplate();
+                for (int i = 0; i < 5; i++) {
+                    template.sendBody(DIRECT_URI, "test" + i);
+                    sent.incrementAndGet();
+                }
+            });
+
+            // the queue fills up and the third send stays blocked instead of 
failing with "Queue full"
+            
await().atMost(Duration.ofSeconds(10)).during(Duration.ofMillis(500))
+                    .until(() -> sent.get() == 2 && seda.getQueue().size() == 
2 && !sending.isDone());
+
+            // draining the queue releases the producer
+            List<SourceRecord> records = new ArrayList<>();
+            await().atMost(Duration.ofSeconds(10)).until(() -> {
+                List<SourceRecord> polled = sourceTask.poll();
+                if (polled != null) {
+                    records.addAll(polled);
+                }
+                return records.size() == 5;
+            });
+            sending.get(10, TimeUnit.SECONDS);
+            assertEquals(5, sent.get());
+            assertEquals(Arrays.asList("test0", "test1", "test2", "test3", 
"test4"),
+                    
records.stream().map(SourceRecord::value).collect(Collectors.toList()));
+        } finally {
+            sender.shutdownNow();
+            sourceTask.stop();
+        }
+    }
+
+    @Test
+    public void testSourceStopWithProducerBlockedOnFullQueue() throws 
Exception {
+        Map<String, String> props = new HashMap<>();
+        props.put(CamelSourceConnectorConfig.TOPIC_CONF, TOPIC_NAME);
+        props.put(CamelSourceConnectorConfig.CAMEL_SOURCE_URL_CONF, 
DIRECT_URI);
+        
props.put(CamelSourceConnectorConfig.CAMEL_SOURCE_POLLING_CONSUMER_QUEUE_SIZE_CONF,
 "1");
+        props.put("camel.main.shutdownTimeout", "1");
+
+        CamelSourceTask sourceTask = new CamelSourceTask();
+        sourceTask.start(props);
+        SedaEndpoint seda = sedaEndpoint(sourceTask);
+
+        ExecutorService sender = Executors.newSingleThreadExecutor();
+        boolean stopped = false;
+        try {
+            Future<?> sending = sender.submit(() -> 
sendBatchOfRecords(sourceTask, 2));
+            
await().atMost(Duration.ofSeconds(10)).during(Duration.ofMillis(300))
+                    .until(() -> seda.getQueue().remainingCapacity() == 0 && 
!sending.isDone());
+
+            // the blocked send keeps an exchange in flight, so stopping waits 
for camel.main.shutdownTimeout
+            long start = System.nanoTime();
+            stopped = true;
+            sourceTask.stop();
+            long stopMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() 
- start);
+            
assertThat(stopMillis).isGreaterThanOrEqualTo(900L).isLessThan(10_000L);
+            // the forced stop does not interrupt the blocked thread: the 
thread that owns it has to
+            assertFalse(sending.isDone());
+        } finally {
+            sender.shutdownNow();
+            if (!stopped) {
+                sourceTask.stop();
+            }
+        }
+    }
+
+    private static SedaEndpoint sedaEndpoint(CamelSourceTask sourceTask) {
+        return sourceTask.getCms().getCamelContext().getEndpoints().stream()
+                .filter(SedaEndpoint.class::isInstance)
+                .map(SedaEndpoint.class::cast)
+                .filter(e -> "end".equals(e.getName()))
+                .findFirst()
+                .orElseThrow();
+    }
+
     @Test
     public void testSourceUsingComponentProperties() {
         Map<String, String> props = new HashMap<>();
diff --git a/docs/modules/ROOT/pages/user-guide/basic-configuration.adoc 
b/docs/modules/ROOT/pages/user-guide/basic-configuration.adoc
index a9aacc2f7a..ed872625b3 100644
--- a/docs/modules/ROOT/pages/user-guide/basic-configuration.adoc
+++ b/docs/modules/ROOT/pages/user-guide/basic-configuration.adoc
@@ -88,6 +88,17 @@ 
transforms.toString.type=org.apache.camel.kafkaconnector.transforms.CamelTypeCon
 transforms.toString.target.type=java.lang.String
 ----
 
+[[BasicConfiguration-SourceHandOffQueue]]
+== Source Hand-off Queue
+
+A source task receives messages from the Camel route through an internal SEDA 
queue. The `camel.source.pollingConsumer*` options configure it:
+
+* `camel.source.pollingConsumerQueueSize` is the queue capacity. It takes 
precedence over `camel.component.seda.queueSize`. A value of `0` or less does 
not disable the queue: it falls back to `camel.component.seda.queueSize`, or to 
1000.
+* With `camel.source.pollingConsumerBlockWhenFull=true` (the default), the 
Camel consumer thread waits while the queue is full, until the task has polled 
messages from it. With `false`, a message that arrives while the queue is full 
fails with `IllegalStateException: Queue full`.
+* `camel.source.pollingConsumerBlockTimeout` limits that wait, in 
milliseconds. When it expires, the message fails instead. `0` or a negative 
value waits with no limit.
+
+A consumer thread that is waiting on a full queue holds an in-flight exchange. 
When the task stops, Camel's graceful shutdown waits for it, up to 
`camel.main.shutdownTimeout` (45 seconds by default), then forces the routes to 
stop. Kafka Connect only waits `task.shutdown.graceful.timeout.ms` (5 seconds 
by default) for the task to stop and logs an error when that runs out, so 
consider setting `camel.main.shutdownTimeout` below it, or a non-zero 
`camel.source.pollingConsumerBlockTimeout`.  [...]
+
 [[BasicConfiguration-MessageLevelHeaders]]
 == Message Level Headers
 

Reply via email to