This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 6b79e833bb80 CAMEL-25221: camel-couchbase - remove a document only
after its exchange completed (#27347)
6b79e833bb80 is described below
commit 6b79e833bb80cba824d2f058bee3720c026e529c
Author: allthingssecurity <[email protected]>
AuthorDate: Mon Oct 5 22:37:58 2026 +0530
CAMEL-25221: camel-couchbase - remove a document only after its exchange
completed (#27347)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../component/couchbase/CouchbaseConsumer.java | 153 +++++--
.../CouchbaseConsumerProcessedStrategyTest.java | 448 +++++++++++++++++++++
.../component/couchbase/CouchbaseConsumerTest.java | 3 +-
.../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 24 +-
4 files changed, 588 insertions(+), 40 deletions(-)
diff --git
a/components/camel-couchbase/src/main/java/org/apache/camel/component/couchbase/CouchbaseConsumer.java
b/components/camel-couchbase/src/main/java/org/apache/camel/component/couchbase/CouchbaseConsumer.java
index 8200004c8cf7..897268a3b57f 100644
---
a/components/camel-couchbase/src/main/java/org/apache/camel/component/couchbase/CouchbaseConsumer.java
+++
b/components/camel-couchbase/src/main/java/org/apache/camel/component/couchbase/CouchbaseConsumer.java
@@ -18,6 +18,8 @@ package org.apache.camel.component.couchbase;
import java.util.ArrayDeque;
import java.util.Queue;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@@ -38,6 +40,7 @@ import org.apache.camel.Processor;
import org.apache.camel.resume.ResumeAware;
import org.apache.camel.resume.ResumeStrategy;
import org.apache.camel.support.ScheduledBatchPollingConsumer;
+import org.apache.camel.support.SynchronizationAdapter;
import org.apache.camel.support.resume.ResumeStrategyHelper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -55,6 +58,11 @@ public class CouchbaseConsumer extends
ScheduledBatchPollingConsumer implements
static final String SQL_DOCUMENT_ID_ALIAS = "__id";
private final Lock lock = new ReentrantLock();
+ /**
+ * IDs of the documents whose exchange was created by a poll and has not
completed or failed yet, with
+ * {@code consumerProcessedStrategy=delete}. See {@link
#isInFlight(String)}.
+ */
+ private final Set<String> inFlight = ConcurrentHashMap.newKeySet();
private final CouchbaseEndpoint endpoint;
private Bucket bucket;
private Scope scope;
@@ -144,17 +152,25 @@ public class CouchbaseConsumer extends
ScheduledBatchPollingConsumer implements
protected int poll() throws Exception {
lock.lock();
try {
- if (useSqlQuery) {
- return pollWithSqlQuery();
- } else {
- return pollWithView();
+ Queue<Object> exchanges = new ArrayDeque<>();
+ try {
+ if (useSqlQuery) {
+ pollWithSqlQuery(exchanges);
+ } else {
+ pollWithView(exchanges);
+ }
+ } catch (Exception e) {
+ // the poll failed part way, so none of the exchanges created
so far is handed to the route
+ releaseUndelivered(exchanges);
+ throw e;
}
+ return processBatch(exchanges);
} finally {
lock.unlock();
}
}
- private int pollWithSqlQuery() throws Exception {
+ private void pollWithSqlQuery(Queue<Object> exchanges) throws Exception {
QueryOptions queryOptions = QueryOptions.queryOptions()
.scanConsistency(QueryScanConsistency.REQUEST_PLUS);
@@ -167,7 +183,7 @@ public class CouchbaseConsumer extends
ScheduledBatchPollingConsumer implements
}
String consumerProcessedStrategy =
endpoint.getConsumerProcessedStrategy();
- Queue<Object> exchanges = new ArrayDeque<>();
+ boolean removeOnCompletion =
"delete".equalsIgnoreCase(consumerProcessedStrategy);
for (JsonObject row : result.rowsAsObject()) {
String id = row.getString(SQL_DOCUMENT_ID_ALIAS);
@@ -177,6 +193,9 @@ public class CouchbaseConsumer extends
ScheduledBatchPollingConsumer implements
SQL_DOCUMENT_ID_ALIAS, SQL_DOCUMENT_ID_ALIAS);
continue;
}
+ if (removeOnCompletion && isInFlight(id)) {
+ continue;
+ }
Object doc;
if (endpoint.isFullDocument()) {
@@ -190,12 +209,8 @@ public class CouchbaseConsumer extends
ScheduledBatchPollingConsumer implements
exchange.getIn().setBody(doc);
exchange.getIn().setHeader(HEADER_ID, id);
- if ("delete".equalsIgnoreCase(consumerProcessedStrategy)) {
- if (LOG.isTraceEnabled()) {
- LOG.trace("Deleting doc with ID {}", id);
- }
- CouchbaseCollectionOperation.removeDocument(collection, id,
endpoint.getWriteQueryTimeout(),
- endpoint.getConsumerRetryPause());
+ if (removeOnCompletion) {
+ removeDocumentOnCompletion(exchange, id);
} else if ("filter".equalsIgnoreCase(consumerProcessedStrategy)) {
if (LOG.isTraceEnabled()) {
LOG.trace("Filtering out ID {}", id);
@@ -212,12 +227,10 @@ public class CouchbaseConsumer extends
ScheduledBatchPollingConsumer implements
exchanges.add(exchange);
}
-
- return processBatch(exchanges);
}
@SuppressWarnings("deprecation")
- private int pollWithView() throws Exception {
+ private void pollWithView(Queue<Object> exchanges) throws Exception {
ViewResult result = bucket.viewQuery(endpoint.getDesignDocumentName(),
endpoint.getViewName(), this.viewOptions);
// okay we have some response from CouchBase so lets mark the consumer
as ready
@@ -228,11 +241,14 @@ public class CouchbaseConsumer extends
ScheduledBatchPollingConsumer implements
}
String consumerProcessedStrategy =
endpoint.getConsumerProcessedStrategy();
+ boolean removeOnCompletion =
"delete".equalsIgnoreCase(consumerProcessedStrategy);
- Queue<Object> exchanges = new ArrayDeque<>();
for (ViewRow row : result.rows()) {
Object doc;
String id = row.id().get();
+ if (removeOnCompletion && isInFlight(id)) {
+ continue;
+ }
if (endpoint.isFullDocument()) {
doc = CouchbaseCollectionOperation.getDocument(collection, id,
endpoint.getQueryTimeout(),
endpoint.getConsumerRetryPause());
@@ -255,12 +271,8 @@ public class CouchbaseConsumer extends
ScheduledBatchPollingConsumer implements
exchange.getIn().setHeader(HEADER_DESIGN_DOCUMENT_NAME,
designDocumentName);
exchange.getIn().setHeader(HEADER_VIEWNAME, viewName);
- if ("delete".equalsIgnoreCase(consumerProcessedStrategy)) {
- if (LOG.isTraceEnabled()) {
- LOG.trace("Deleting doc with ID {}", id);
- }
- CouchbaseCollectionOperation.removeDocument(collection, id,
endpoint.getWriteQueryTimeout(),
- endpoint.getConsumerRetryPause());
+ if (removeOnCompletion) {
+ removeDocumentOnCompletion(exchange, id);
} else if ("filter".equalsIgnoreCase(consumerProcessedStrategy)) {
if (LOG.isTraceEnabled()) {
LOG.trace("Filtering out ID {}", id);
@@ -273,8 +285,87 @@ public class CouchbaseConsumer extends
ScheduledBatchPollingConsumer implements
logDetails(id, doc, key, designDocumentName, viewName, exchange);
exchanges.add(exchange);
}
+ }
+
+ /**
+ * Whether the exchange of this document, created by an earlier poll, has
not completed or failed yet.
+ * <p/>
+ * With {@code consumerProcessedStrategy=delete} the document is removed
only when its exchange completes. If the
+ * route hands the exchange over to another thread (for example {@code
seda} with
+ * {@code waitForTaskToComplete=Never}, or an asynchronous producer), that
can be after the next poll has read the
+ * same document again, which would deliver it twice. Such a document is
skipped until its exchange is done, as the
+ * in-progress repository of the file and aws2-s3 consumers does. A
skipped row does not count towards
+ * {@code maxMessagesPerPoll}, as no exchange is created for it.
+ * <p/>
+ * The other strategies leave the document in place, so reading it again
on the next poll is what they do anyway;
+ * they are not guarded. The guard is kept per consumer, it does not stop
another consumer, for example on another
+ * node, from reading the same document.
+ */
+ private boolean isInFlight(String id) {
+ // only the poll adds an ID, under the poll lock, so checking first
and adding later does not race
+ if (inFlight.contains(id)) {
+ LOG.trace("Skipping document with ID {} as its exchange is still
in progress", id);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Removes the document once its exchange has been processed successfully.
+ * <p/>
+ * Removing it while the exchanges are built, before any of them is handed
to the route, loses the document when its
+ * exchange fails, or when it is never delivered at all because the batch
is cut short by {@code maxMessagesPerPoll}
+ * or by the consumer stopping. The on-completion runs only for an
exchange that went through the route and
+ * completed: a failed exchange keeps its document for the next poll, and
an undelivered one is not touched.
+ * <p/>
+ * The on-completion is handed over with the exchange, so that the
document is not removed before an asynchronous
+ * part of the route has processed it. Until then the document is marked
as in progress (see
+ * {@link #isInFlight(String)}), and it is released when the exchange
completes or fails, or by
+ * {@link #releaseUndelivered(Queue)} when the exchange is never handed to
the route.
+ */
+ private void removeDocumentOnCompletion(Exchange exchange, String id) {
+ inFlight.add(id);
+ exchange.getExchangeExtension().addOnCompletion(new
SynchronizationAdapter() {
+ @Override
+ public void onComplete(Exchange exchange) {
+ if (LOG.isTraceEnabled()) {
+ LOG.trace("Deleting doc with ID {}", id);
+ }
+ try {
+ CouchbaseCollectionOperation.removeDocument(collection,
id, endpoint.getWriteQueryTimeout(),
+ endpoint.getConsumerRetryPause());
+ } catch (Exception e) {
+ getExceptionHandler().handleException("Error removing
document with ID " + id, exchange, e);
+ } finally {
+ inFlight.remove(id);
+ }
+ }
- return processBatch(exchanges);
+ @Override
+ public void onFailure(Exchange exchange) {
+ // the document is kept, so a later poll may consume it again
+ inFlight.remove(id);
+ }
+ });
+ }
+
+ /**
+ * Releases exchanges that were created by a poll but never handed to the
route, and their documents.
+ * <p/>
+ * Nothing else will release these, and a pooled exchange that is never
released never returns to the pool. With
+ * {@code consumerProcessedStrategy=delete} their documents are left in
place, as the removal only runs on
+ * completion, and are no longer marked as in progress, so a later poll
picks them up.
+ */
+ private void releaseUndelivered(Queue<Object> exchanges) {
+ Object next;
+ while ((next = exchanges.poll()) != null) {
+ Exchange exchange = (Exchange) next;
+ String id = exchange.getIn().getHeader(HEADER_ID, String.class);
+ if (id != null) {
+ inFlight.remove(id);
+ }
+ releaseExchange(exchange, false);
+ }
}
@Override
@@ -297,16 +388,8 @@ public class CouchbaseConsumer extends
ScheduledBatchPollingConsumer implements
}
// Anything still queued was never handed to the route - the batch was
cut short because the consumer is
- // stopping, or the poll returned more rows than maxMessagesPerPoll.
Nothing else will release these, and a
- // pooled exchange that is never released never returns to the pool.
- //
- // Releasing them is not the same as handling them: with
consumerProcessedStrategy=delete the document was
- // already removed during the poll, above, so these rows are lost
rather than redelivered. That predates
- // this method and is not fixed here - see CAMEL-25221
- Exchange remaining;
- while ((remaining = (Exchange) exchanges.poll()) != null) {
- releaseExchange(remaining, false);
- }
+ // stopping, or the poll returned more rows than maxMessagesPerPoll
+ releaseUndelivered(exchanges);
return answer;
}
@@ -320,9 +403,7 @@ public class CouchbaseConsumer extends
ScheduledBatchPollingConsumer implements
* exchange had been delivered.
* <p/>
* The route's own error handler has already logged the exhausted failure
by this point, so this is not the only
- * record of it; what it adds is that the consumer no longer treats a
failed exchange as a delivered one. It matters
- * most with {@code consumerProcessedStrategy=delete}, where the document
is removed during the poll, before the
- * route runs, so a failure means the document is gone.
+ * record of it; what it adds is that the consumer no longer treats a
failed exchange as a delivered one.
*/
private void processExchange(Exchange exchange) {
try {
diff --git
a/components/camel-couchbase/src/test/java/org/apache/camel/component/couchbase/CouchbaseConsumerProcessedStrategyTest.java
b/components/camel-couchbase/src/test/java/org/apache/camel/component/couchbase/CouchbaseConsumerProcessedStrategyTest.java
new file mode 100644
index 000000000000..132e45cab608
--- /dev/null
+++
b/components/camel-couchbase/src/test/java/org/apache/camel/component/couchbase/CouchbaseConsumerProcessedStrategyTest.java
@@ -0,0 +1,448 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.couchbase;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import com.couchbase.client.java.Bucket;
+import com.couchbase.client.java.Cluster;
+import com.couchbase.client.java.ClusterOptions;
+import com.couchbase.client.java.Collection;
+import com.couchbase.client.java.Scope;
+import com.couchbase.client.java.json.JsonObject;
+import com.couchbase.client.java.kv.GetOptions;
+import com.couchbase.client.java.kv.RemoveOptions;
+import com.couchbase.client.java.query.QueryOptions;
+import com.couchbase.client.java.query.QueryResult;
+import com.couchbase.client.java.view.ViewOptions;
+import com.couchbase.client.java.view.ViewResult;
+import com.couchbase.client.java.view.ViewRow;
+import org.apache.camel.Exchange;
+import org.apache.camel.Processor;
+import org.apache.camel.ShutdownRunningTask;
+import org.apache.camel.builder.NotifyBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.seda.SedaEndpoint;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.spi.ExceptionHandler;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.mockingDetails;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * With {@code consumerProcessedStrategy=delete} a document must be removed
only once its exchange has been processed
+ * successfully: a failed exchange, or a row that is never handed to the
route, keeps its document for the next poll.
+ * <p/>
+ * The consumer runs in a real route, so the exchange goes through a unit of
work, but its scheduler is not started and
+ * the test calls {@code poll()} itself against a mocked cluster.
+ */
+class CouchbaseConsumerProcessedStrategyTest {
+
+ private static final String URI =
"couchbase:http://localhost:8091?bucket=bucket&username=user&password=secret"
+ +
"&consumerProcessedStrategy=delete&startScheduler=false";
+
+ private DefaultCamelContext context;
+ private MockedStatic<Cluster> clusters;
+ private Bucket bucket;
+ private Scope scope;
+ private Collection collection;
+
+ @BeforeEach
+ void setUp() {
+ bucket = mock(Bucket.class);
+ scope = mock(Scope.class);
+ collection = mock(Collection.class);
+ when(bucket.defaultScope()).thenReturn(scope);
+ when(bucket.defaultCollection()).thenReturn(collection);
+ Cluster cluster = mock(Cluster.class);
+ when(cluster.bucket(anyString())).thenReturn(bucket);
+ clusters = mockStatic(Cluster.class);
+ clusters.when(() -> Cluster.connect(anyString(),
any(ClusterOptions.class))).thenReturn(cluster);
+ context = new DefaultCamelContext();
+ }
+
+ @AfterEach
+ void tearDown() {
+ // exchanges still queued for a started route would hold up the
graceful shutdown
+ if (context.hasEndpoint("seda://async") instanceof SedaEndpoint seda) {
+ seda.purgeQueue();
+ }
+ context.stop();
+ clusters.close();
+ }
+
+ private CouchbaseConsumer startRoute(String uri, Processor processor)
throws Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from(uri).routeId("couchbase").process(processor);
+ }
+ });
+ context.start();
+ return (CouchbaseConsumer) context.getRoute("couchbase").getConsumer();
+ }
+
+ /**
+ * The couchbase route hands every exchange over to a {@code seda} queue
without waiting for it, so the
+ * on-completion that removes the document goes with the copy in the
queue. The route consuming that queue is
+ * started by the test when the handed over exchanges should complete, so
until then they are in flight.
+ */
+ private CouchbaseConsumer startHandoverRoutes(String uri, Processor
before, Processor async) throws Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+
from(uri).routeId("couchbase").process(before).to("seda:async?waitForTaskToComplete=Never");
+
from("seda:async").routeId("async").autoStartup(false).process(async);
+ }
+ });
+ context.start();
+ return (CouchbaseConsumer) context.getRoute("couchbase").getConsumer();
+ }
+
+ private CouchbaseConsumer startHandoverRoutes(String uri, Processor async)
throws Exception {
+ return startHandoverRoutes(uri, exchange -> {
+ }, async);
+ }
+
+ /** The IDs of the handed over exchanges still waiting in the queue, in
order. */
+ private List<Object> inFlightIds() {
+ return context.getEndpoint("seda:async",
SedaEndpoint.class).getQueue().stream()
+ .map(exchange ->
exchange.getMessage().getHeader(CouchbaseConstants.HEADER_ID))
+ .toList();
+ }
+
+ private void completeHandedOverExchanges(int count) throws Exception {
+ NotifyBuilder done = new
NotifyBuilder(context).fromRoute("async").whenDone(count).create();
+ context.getRouteController().startRoute("async");
+ // the on-completions run before the exchange is signalled as done
+ assertTrue(done.matches(10, TimeUnit.SECONDS), "the handed over
exchanges did not complete");
+ context.getRouteController().stopRoute("async");
+ }
+
+ private void queryReturns(String... ids) {
+ List<JsonObject> rows = new ArrayList<>();
+ for (String id : ids) {
+
rows.add(JsonObject.create().put(CouchbaseConsumer.SQL_DOCUMENT_ID_ALIAS,
id).put("name", id));
+ }
+ QueryResult result = mock(QueryResult.class);
+ when(result.rowsAsObject()).thenReturn(rows);
+ when(scope.query(anyString(),
any(QueryOptions.class))).thenReturn(result);
+ }
+
+ private void verifyRemoved(String id) {
+ verify(collection).remove(eq(id), any(RemoveOptions.class));
+ }
+
+ private void verifyNotRemoved(String id) {
+ verify(collection, never()).remove(eq(id), any(RemoveOptions.class));
+ }
+
+ private boolean removedYet() {
+ return mockingDetails(collection).getInvocations().stream()
+ .anyMatch(invocation ->
"remove".equals(invocation.getMethod().getName()));
+ }
+
+ @Test
+ void aProcessedDocumentIsRemovedAfterTheRouteHasRun() throws Exception {
+ queryReturns("doc-1");
+ List<Boolean> removedWhileRouting = new ArrayList<>();
+ CouchbaseConsumer consumer = startRoute(URI, exchange ->
removedWhileRouting.add(removedYet()));
+
+ assertEquals(1, consumer.poll());
+
+ assertEquals(List.of(false), removedWhileRouting, "the document must
still exist while the route processes it");
+ verifyRemoved("doc-1");
+ }
+
+ @Test
+ void aFailedExchangeKeepsItsDocument() throws Exception {
+ queryReturns("doc-1", "doc-2");
+ CouchbaseConsumer consumer = startRoute(URI, exchange -> {
+ if
("doc-1".equals(exchange.getMessage().getHeader(CouchbaseConstants.HEADER_ID)))
{
+ throw new IllegalStateException("the route blew up");
+ }
+ });
+
+ consumer.poll();
+
+ verifyNotRemoved("doc-1");
+ verifyRemoved("doc-2");
+ }
+
+ @Test
+ void rowsBeyondMaxMessagesPerPollKeepTheirDocuments() throws Exception {
+ queryReturns("doc-1", "doc-2", "doc-3");
+ CouchbaseConsumer consumer = startRoute(URI, exchange -> {
+ });
+ consumer.setMaxMessagesPerPoll(1);
+
+ consumer.poll();
+
+ verifyRemoved("doc-1");
+ verifyNotRemoved("doc-2");
+ verifyNotRemoved("doc-3");
+ }
+
+ @Test
+ void rowsLeftWhenTheConsumerStopsMidBatchKeepTheirDocuments() throws
Exception {
+ queryReturns("doc-1", "doc-2", "doc-3");
+ AtomicReference<CouchbaseConsumer> consumerRef = new
AtomicReference<>();
+ // what the shutdown strategy does to a consumer that should only
complete its current task
+ CouchbaseConsumer consumer = startRoute(URI,
+ exchange ->
consumerRef.get().deferShutdown(ShutdownRunningTask.CompleteCurrentTaskOnly));
+ consumerRef.set(consumer);
+
+ consumer.poll();
+
+ verifyRemoved("doc-1");
+ verifyNotRemoved("doc-2");
+ verifyNotRemoved("doc-3");
+ }
+
+ @Test
+ @SuppressWarnings("deprecation")
+ void aFailedExchangeKeepsItsDocumentWhenConsumingAView() throws Exception {
+ ViewRow failing = viewRow("doc-1");
+ ViewRow succeeding = viewRow("doc-2");
+ ViewResult result = mock(ViewResult.class);
+ when(result.rows()).thenReturn(List.of(failing, succeeding));
+ when(bucket.viewQuery(anyString(), anyString(),
any(ViewOptions.class))).thenReturn(result);
+
+ CouchbaseConsumer consumer = startRoute(URI +
"&useView=true&designDocumentName=dd&viewName=view", exchange -> {
+ if
("doc-1".equals(exchange.getMessage().getHeader(CouchbaseConstants.HEADER_ID)))
{
+ throw new IllegalStateException("the route blew up");
+ }
+ });
+
+ consumer.poll();
+
+ verifyNotRemoved("doc-1");
+ verifyRemoved("doc-2");
+ }
+
+ @Test
+ void aFailedRemovalIsReportedToTheExceptionHandler() throws Exception {
+ queryReturns("doc-1");
+ RuntimeException failure = new IllegalStateException("cannot remove");
+ when(collection.remove(eq("doc-1"),
any(RemoveOptions.class))).thenThrow(failure);
+ CouchbaseConsumer consumer = startRoute(URI, exchange -> {
+ });
+ AtomicReference<Throwable> reported = new AtomicReference<>();
+ consumer.setExceptionHandler(new CapturingExceptionHandler(reported));
+
+ consumer.poll();
+
+ verify(collection, times(1)).remove(eq("doc-1"),
any(RemoveOptions.class));
+ assertSame(failure, reported.get());
+ }
+
+ @Test
+ void otherStrategiesDoNotRemoveDocuments() throws Exception {
+ queryReturns("doc-1");
+ CouchbaseConsumer consumer = startRoute(URI.replace("=delete",
"=none"), exchange -> {
+ });
+
+ consumer.poll();
+
+ assertFalse(removedYet());
+ }
+
+ @Test
+ void aDocumentWhoseHandedOverExchangeIsInFlightIsNotDeliveredAgain()
throws Exception {
+ queryReturns("doc-1");
+ CouchbaseConsumer consumer = startHandoverRoutes(URI, exchange -> {
+ });
+
+ assertEquals(1, consumer.poll());
+ assertEquals(0, consumer.poll(), "the document must not be delivered
again while its exchange is in flight");
+
+ assertEquals(List.of("doc-1"), inFlightIds());
+ verifyNotRemoved("doc-1");
+ }
+
+ @Test
+ void
aDocumentWhoseHandedOverExchangeIsInFlightIsNotDeliveredAgainWhenConsumingAView()
throws Exception {
+ ViewRow row = viewRow("doc-1");
+ ViewResult result = mock(ViewResult.class);
+ when(result.rows()).thenReturn(List.of(row));
+ when(bucket.viewQuery(anyString(), anyString(),
any(ViewOptions.class))).thenReturn(result);
+ CouchbaseConsumer consumer = startHandoverRoutes(URI +
"&useView=true&designDocumentName=dd&viewName=view",
+ exchange -> {
+ });
+
+ assertEquals(1, consumer.poll());
+ assertEquals(0, consumer.poll(), "the document must not be delivered
again while its exchange is in flight");
+
+ assertEquals(List.of("doc-1"), inFlightIds());
+ }
+
+ @Test
+ void aHandedOverExchangeRemovesItsDocumentOnceWhenItCompletes() throws
Exception {
+ queryReturns("doc-1");
+ List<Object> processed = new CopyOnWriteArrayList<>();
+ CouchbaseConsumer consumer = startHandoverRoutes(URI,
+ exchange ->
processed.add(exchange.getMessage().getHeader(CouchbaseConstants.HEADER_ID)));
+
+ consumer.poll();
+ consumer.poll();
+ verifyNotRemoved("doc-1");
+
+ completeHandedOverExchanges(1);
+
+ assertEquals(List.of("doc-1"), processed);
+ verify(collection, times(1)).remove(eq("doc-1"),
any(RemoveOptions.class));
+ }
+
+ @Test
+ void aFailedHandedOverExchangeReleasesItsDocumentForTheNextPoll() throws
Exception {
+ queryReturns("doc-1");
+ CouchbaseConsumer consumer = startHandoverRoutes(URI, exchange -> {
+ throw new IllegalStateException("the route blew up");
+ });
+
+ assertEquals(1, consumer.poll());
+ assertEquals(0, consumer.poll());
+
+ completeHandedOverExchanges(1);
+
+ verifyNotRemoved("doc-1");
+ assertEquals(1, consumer.poll(), "a failed document must be consumed
again by the next poll");
+ assertEquals(List.of("doc-1"), inFlightIds());
+ }
+
+ @Test
+ void rowsBeyondMaxMessagesPerPollAreNotLeftInFlight() throws Exception {
+ queryReturns("doc-1", "doc-2", "doc-3");
+ CouchbaseConsumer consumer = startHandoverRoutes(URI, exchange -> {
+ });
+ consumer.setMaxMessagesPerPoll(1);
+
+ consumer.poll();
+ // doc-1 is still in flight and skipped, it does not take the place of
doc-2
+ consumer.poll();
+ consumer.poll();
+
+ assertEquals(List.of("doc-1", "doc-2", "doc-3"), inFlightIds());
+ }
+
+ @Test
+ void rowsLeftWhenTheConsumerStopsMidBatchAreNotLeftInFlight() throws
Exception {
+ queryReturns("doc-1", "doc-2", "doc-3");
+ AtomicReference<CouchbaseConsumer> consumerRef = new
AtomicReference<>();
+ AtomicBoolean stopping = new AtomicBoolean(true);
+ // what the shutdown strategy does to a consumer that should only
complete its current task
+ CouchbaseConsumer consumer = startHandoverRoutes(URI, exchange -> {
+ if (stopping.get()) {
+
consumerRef.get().deferShutdown(ShutdownRunningTask.CompleteCurrentTaskOnly);
+ }
+ }, exchange -> {
+ });
+ consumerRef.set(consumer);
+
+ consumer.poll();
+ assertEquals(List.of("doc-1"), inFlightIds());
+
+ // the consumer carries on, as after a shutdown that timed out and was
cancelled
+ stopping.set(false);
+ consumer.deferShutdown(ShutdownRunningTask.CompleteAllTasks);
+ consumer.poll();
+
+ assertEquals(List.of("doc-1", "doc-2", "doc-3"), inFlightIds());
+ }
+
+ @Test
+ void aPollThatFailsPartWayDoesNotLeaveItsRowsInFlight() throws Exception {
+ queryReturns("doc-1", "doc-2");
+ RuntimeException failure = new IllegalStateException("cannot get
document");
+ when(collection.get(eq("doc-2"),
any(GetOptions.class))).thenThrow(failure).thenReturn(null);
+ CouchbaseConsumer consumer = startHandoverRoutes(URI, exchange -> {
+ });
+
+ assertSame(failure, assertThrows(IllegalStateException.class,
consumer::poll));
+ assertEquals(List.of(), inFlightIds());
+
+ assertEquals(2, consumer.poll());
+ assertEquals(List.of("doc-1", "doc-2"), inFlightIds());
+ }
+
+ @Test
+ void otherStrategiesStillDeliverADocumentWhoseExchangeIsInFlight() throws
Exception {
+ queryReturns("doc-1");
+ CouchbaseConsumer consumer =
startHandoverRoutes(URI.replace("=delete", "=none"), exchange -> {
+ });
+
+ assertEquals(1, consumer.poll());
+ assertEquals(1, consumer.poll());
+
+ assertEquals(List.of("doc-1", "doc-1"), inFlightIds());
+ }
+
+ private static ViewRow viewRow(String id) {
+ ViewRow row = mock(ViewRow.class);
+ when(row.id()).thenReturn(Optional.of(id));
+ when(row.valueAs(Object.class)).thenReturn(Optional.of(id));
+ when(row.keyAs(String.class)).thenReturn(Optional.of(id));
+ return row;
+ }
+
+ private static final class CapturingExceptionHandler implements
ExceptionHandler {
+
+ private final AtomicReference<Throwable> captured;
+
+ private CapturingExceptionHandler(AtomicReference<Throwable> captured)
{
+ this.captured = captured;
+ }
+
+ @Override
+ public void handleException(Throwable exception) {
+ captured.set(exception);
+ }
+
+ @Override
+ public void handleException(String message, Throwable exception) {
+ captured.set(exception);
+ }
+
+ @Override
+ public void handleException(String message, Exchange exchange,
Throwable exception) {
+ captured.set(exception);
+ }
+ }
+}
diff --git
a/components/camel-couchbase/src/test/java/org/apache/camel/component/couchbase/CouchbaseConsumerTest.java
b/components/camel-couchbase/src/test/java/org/apache/camel/component/couchbase/CouchbaseConsumerTest.java
index bf3ffe78e972..16a89ae8c899 100644
---
a/components/camel-couchbase/src/test/java/org/apache/camel/component/couchbase/CouchbaseConsumerTest.java
+++
b/components/camel-couchbase/src/test/java/org/apache/camel/component/couchbase/CouchbaseConsumerTest.java
@@ -97,8 +97,7 @@ class CouchbaseConsumerTest {
/**
* A failing route does not throw out of {@code process()} - the failure
is left on the exchange - so a consumer
- * that only catches never learns about the common case. With {@code
consumerProcessedStrategy=delete} the document
- * is already gone by then, so an unreported failure loses the message
outright.
+ * that only catches never learns about the common case.
*/
@Test
void aRouteFailureIsReportedToTheExceptionHandler() throws Exception {
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index 46a3d152e3c9..b95f0c3b64ab 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -588,8 +588,28 @@ poll as if the exchange had been delivered.
This is not a change to error handling. An exchange that failed while being
routed has already passed through the
route's error handler, which logs the exhausted failure, and
`BridgeExceptionHandlerToErrorHandler` deliberately
falls back to its logging handler for such an exchange rather than bridging it
a second time. What changes is that
-the consumer itself no longer stays silent, and that with
`consumerProcessedStrategy=delete` the loss is now visible:
-the document is removed during the poll, before the route runs.
+the consumer itself no longer stays silent.
+
+==== consumerProcessedStrategy=delete
+
+With `consumerProcessedStrategy=delete` the consumer now removes a document
once its exchange has completed
+successfully. It used to remove every document of a poll while building the
exchanges, before any of them was handed
+to the route, so the document was gone even when its exchange failed, and also
when it was never handed to the route
+at all, because the consumer was stopping or the poll returned more rows than
`maxMessagesPerPoll`.
+
+A document whose exchange fails, and a document that was not handed to the
route, are now kept and consumed again by a
+later poll. A route that fails for the same document every time therefore
receives it on every poll; handle the
+failure in the route (for example with a dead letter channel or a handled
`onException`) so that the exchange
+completes and the document is removed. While the route runs, the document is
still in the bucket. A failure to remove
+the document is reported through the consumer's exception handler.
+
+If the route hands the exchange over to another thread (for example `seda`
with `waitForTaskToComplete=Never`, or an
+asynchronous producer), the document is removed when that exchange completes,
which can be after the next poll. Until
+its exchange has completed or failed, a document is skipped by later polls of
the same consumer, so it is not delivered
+twice, in the same way as the in-progress repository of the file and aws2-s3
consumers. A skipped document does not
+count towards `maxMessagesPerPoll`. This only applies to the same consumer:
another consumer reading the same bucket,
+for example on another node, can still read the document before it is removed.
With the other strategies the document
+stays in the bucket, so later polls read it again as before.
=== camel-smpp - received exchanges now come from the consumer