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 f25f798b0828 CAMEL-25611: camel-kudu - Fail the exchange when Kudu
answers a row operation with a row error (#27798)
f25f798b0828 is described below
commit f25f798b0828be6b5fe55bc37637092fbeb4b706
Author: allthingssecurity <[email protected]>
AuthorDate: Wed Oct 14 17:16:23 2026 +0530
CAMEL-25611: camel-kudu - Fail the exchange when Kudu answers a row
operation with a row error (#27798)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../apache/camel/catalog/docs/kudu-component.adoc | 11 ++
components/camel-kudu/pom.xml | 6 +
.../camel-kudu/src/main/docs/kudu-component.adoc | 11 ++
.../apache/camel/component/kudu/KuduProducer.java | 41 ++++-
.../component/kudu/KuduRowErrorException.java | 53 ++++++
.../component/kudu/KuduProducerRowErrorTest.java | 200 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 15 ++
7 files changed, 333 insertions(+), 4 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/kudu-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/kudu-component.adoc
index 9f9ddea80cc8..c2173f4327f9 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/kudu-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/kudu-component.adoc
@@ -55,3 +55,14 @@ This map will represent a row of the table whose elements
are columns, where the
The output body format will be a `java.util.List<java.util.Map<String,
Object>>`.
Each element of the list will be a different row of the table.
Each row is a `Map<String, Object>` whose elements will be each pair of column
name and column value for that row.
+
+=== Row errors
+
+The producer fails the exchange with a `KuduRowErrorException` when the answer
to an insert, delete, update or upsert
+carries a row error. Its `getRowError()` returns the row error of the Kudu
client, whose status tells the kind of
+error. There are two cases:
+
+* a row that Kudu rejected: an insert of a row whose key is already in the
table, or an update or a delete of a row
+that is not in the table. The row was not written. Use the `upsert` operation
to write a row whether it exists or not.
+* a request that failed, for example because it timed out or because no tablet
server could be reached. The Kudu
+client reports it the same way. In this case it is not known whether the row
was written.
diff --git a/components/camel-kudu/pom.xml b/components/camel-kudu/pom.xml
index ac127ed973b5..dabf8fc5a85a 100644
--- a/components/camel-kudu/pom.xml
+++ b/components/camel-kudu/pom.xml
@@ -104,6 +104,12 @@
<artifactId>junit-jupiter-migrationsupport</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-core</artifactId>
+ <version>${mockito-version}</version>
+ <scope>test</scope>
+ </dependency>
<!-- Spin a kudu cluster to run integration tests -->
<!-- as described in
diff --git a/components/camel-kudu/src/main/docs/kudu-component.adoc
b/components/camel-kudu/src/main/docs/kudu-component.adoc
index 9f9ddea80cc8..c2173f4327f9 100644
--- a/components/camel-kudu/src/main/docs/kudu-component.adoc
+++ b/components/camel-kudu/src/main/docs/kudu-component.adoc
@@ -55,3 +55,14 @@ This map will represent a row of the table whose elements
are columns, where the
The output body format will be a `java.util.List<java.util.Map<String,
Object>>`.
Each element of the list will be a different row of the table.
Each row is a `Map<String, Object>` whose elements will be each pair of column
name and column value for that row.
+
+=== Row errors
+
+The producer fails the exchange with a `KuduRowErrorException` when the answer
to an insert, delete, update or upsert
+carries a row error. Its `getRowError()` returns the row error of the Kudu
client, whose status tells the kind of
+error. There are two cases:
+
+* a row that Kudu rejected: an insert of a row whose key is already in the
table, or an update or a delete of a row
+that is not in the table. The row was not written. Use the `upsert` operation
to write a row whether it exists or not.
+* a request that failed, for example because it timed out or because no tablet
server could be reached. The Kudu
+client reports it the same way. In this case it is not known whether the row
was written.
diff --git
a/components/camel-kudu/src/main/java/org/apache/camel/component/kudu/KuduProducer.java
b/components/camel-kudu/src/main/java/org/apache/camel/component/kudu/KuduProducer.java
index 7c20f2602e7d..6ef2ea99d7ad 100644
---
a/components/camel-kudu/src/main/java/org/apache/camel/component/kudu/KuduProducer.java
+++
b/components/camel-kudu/src/main/java/org/apache/camel/component/kudu/KuduProducer.java
@@ -30,7 +30,10 @@ import org.apache.kudu.client.Insert;
import org.apache.kudu.client.KuduClient;
import org.apache.kudu.client.KuduException;
import org.apache.kudu.client.KuduPredicate;
+import org.apache.kudu.client.KuduSession;
import org.apache.kudu.client.KuduTable;
+import org.apache.kudu.client.Operation;
+import org.apache.kudu.client.OperationResponse;
import org.apache.kudu.client.PartialRow;
import org.apache.kudu.client.Update;
import org.apache.kudu.client.Upsert;
@@ -103,7 +106,7 @@ public class KuduProducer extends DefaultProducer {
}
}
- connection.newSession().apply(insert);
+ apply(connection, insert);
}
private void doDelete(Exchange exchange, String tableName) throws
KuduException {
@@ -124,7 +127,7 @@ public class KuduProducer extends DefaultProducer {
}
}
- connection.newSession().apply(delete);
+ apply(connection, delete);
}
private void doUpdate(Exchange exchange, String tableName) throws
KuduException {
@@ -144,7 +147,7 @@ public class KuduProducer extends DefaultProducer {
}
}
- connection.newSession().apply(update);
+ apply(connection, update);
}
private void doUpsert(Exchange exchange, String tableName) throws
KuduException {
@@ -164,7 +167,37 @@ public class KuduProducer extends DefaultProducer {
}
}
- connection.newSession().apply(upsert);
+ apply(connection, upsert);
+ }
+
+ /**
+ * Applies one row operation with a session of its own and closes the
session.
+ * <p>
+ * A session has the flush mode AUTO_FLUSH_SYNC by default: the operation
is sent before the call returns, and the
+ * Kudu client does not throw but returns a response with a row error in
two cases: for a row that the tablet server
+ * rejected (the key is already present, the row is not found, ...), and
for a request that failed (it timed out, no
+ * tablet server could be reached, ...). In the second case it is not
known whether the row was written. Both fail
+ * the exchange with a {@link KuduRowErrorException}.
+ * <p>
+ * A session stays registered with its client until it is closed. After an
operation without a row error a failure
+ * of the close is thrown; in this flush mode the close has nothing left
to send.
+ */
+ private static void apply(KuduClient connection, Operation operation)
throws KuduException {
+ KuduSession session = connection.newSession();
+ try {
+ OperationResponse response = session.apply(operation);
+ if (response != null && response.hasRowError()) {
+ throw new KuduRowErrorException(response.getRowError());
+ }
+ } catch (KuduException | RuntimeException e) {
+ try {
+ session.close();
+ } catch (KuduException | RuntimeException closeFailure) {
+ e.addSuppressed(closeFailure);
+ }
+ throw e;
+ }
+ session.close();
}
private void doCreateTable(Exchange exchange, String tableName) throws
KuduException {
diff --git
a/components/camel-kudu/src/main/java/org/apache/camel/component/kudu/KuduRowErrorException.java
b/components/camel-kudu/src/main/java/org/apache/camel/component/kudu/KuduRowErrorException.java
new file mode 100644
index 000000000000..d1cb251d62e1
--- /dev/null
+++
b/components/camel-kudu/src/main/java/org/apache/camel/component/kudu/KuduRowErrorException.java
@@ -0,0 +1,53 @@
+/*
+ * 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.kudu;
+
+import org.apache.camel.RuntimeCamelException;
+import org.apache.kudu.client.RowError;
+
+/**
+ * Thrown by the producer when the response of an insert, delete, update or
upsert carries a row error. There are two
+ * cases, and the {@link RowError#getErrorStatus() status} of the row error
tells them apart:
+ * <ul>
+ * <li>a row that the tablet server rejected, for example because its key is
already present (insert) or because the row
+ * is not found (update, delete): the row was not written;</li>
+ * <li>a request that failed, for example because it timed out or because no
tablet server could be reached. The Kudu
+ * client reports such a request the same way in the default flush mode of a
session. In this case it is not known
+ * whether the row was written.</li>
+ * </ul>
+ * The message holds the status only. It does not include the row, because the
text of a {@link RowError} prints every
+ * column value of the row; {@link #getRowError()} gives access to it.
+ */
+public class KuduRowErrorException extends RuntimeCamelException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final transient RowError rowError;
+
+ public KuduRowErrorException(RowError rowError) {
+ super("Kudu reported a row error for the operation: " +
rowError.getErrorStatus());
+ this.rowError = rowError;
+ }
+
+ /**
+ * The row error of the Kudu client; its {@link RowError#getErrorStatus()
status} tells the kind of error (already
+ * present, not found, timed out, network error, ...). It is not
serialized with the exception.
+ */
+ public RowError getRowError() {
+ return rowError;
+ }
+}
diff --git
a/components/camel-kudu/src/test/java/org/apache/camel/component/kudu/KuduProducerRowErrorTest.java
b/components/camel-kudu/src/test/java/org/apache/camel/component/kudu/KuduProducerRowErrorTest.java
new file mode 100644
index 000000000000..031175554fbe
--- /dev/null
+++
b/components/camel-kudu/src/test/java/org/apache/camel/component/kudu/KuduProducerRowErrorTest.java
@@ -0,0 +1,200 @@
+/*
+ * 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.kudu;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.apache.kudu.ColumnSchema;
+import org.apache.kudu.Schema;
+import org.apache.kudu.Type;
+import org.apache.kudu.client.Delete;
+import org.apache.kudu.client.Insert;
+import org.apache.kudu.client.KuduClient;
+import org.apache.kudu.client.KuduSession;
+import org.apache.kudu.client.KuduTable;
+import org.apache.kudu.client.Operation;
+import org.apache.kudu.client.OperationResponse;
+import org.apache.kudu.client.RowError;
+import org.apache.kudu.client.Status;
+import org.apache.kudu.client.Update;
+import org.apache.kudu.client.Upsert;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Runs the producer against a mocked Kudu client, so it does not need the
Kudu harness of the other tests of this
+ * module. The session answers as a session with the default flush mode does:
a row that the tablet server rejected, and
+ * a request that failed (a timeout for example, after which it is not known
whether the row was written), are the row
+ * error of the returned response, not an exception.
+ */
+class KuduProducerRowErrorTest extends CamelTestSupport {
+
+ private final KuduClient client = mock(KuduClient.class);
+ private final KuduSession session = mock(KuduSession.class);
+ private final KuduTable table = mock(KuduTable.class);
+ private final OperationResponse response = mock(OperationResponse.class);
+ private final RowError rowError = mock(RowError.class);
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ Schema schema = new Schema(
+ Arrays.asList(
+ new ColumnSchema.ColumnSchemaBuilder("id",
Type.INT32).key(true).build(),
+ new ColumnSchema.ColumnSchemaBuilder("name",
Type.STRING).build()));
+
+ when(client.openTable("TestTable")).thenReturn(table);
+ when(client.newSession()).thenReturn(session);
+ when(table.getSchema()).thenReturn(schema);
+ when(table.newInsert()).thenAnswer(invocation ->
operation(Insert.class, schema));
+ when(table.newDelete()).thenAnswer(invocation ->
operation(Delete.class, schema));
+ when(table.newUpdate()).thenAnswer(invocation ->
operation(Update.class, schema));
+ when(table.newUpsert()).thenAnswer(invocation ->
operation(Upsert.class, schema));
+ when(session.apply(any(Operation.class))).thenReturn(response);
+ when(response.getRowError()).thenReturn(rowError);
+ when(rowError.getErrorStatus()).thenReturn(Status.AlreadyPresent("key
already present"));
+ when(rowError.toString())
+ .thenReturn("Row error for row=(int32 id=5, string
name=secret), status=Already present: key already present");
+
+ CamelContext context = super.createCamelContext();
+ KuduComponent kudu = new KuduComponent();
+ kudu.setKuduClient(client);
+ context.addComponent("kudu", kudu);
+ return context;
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ for (String operation : new String[] { "insert", "delete",
"update", "upsert" }) {
+ from("direct:" + operation)
+ .to("kudu:localhost:7051/TestTable?operation=" +
operation)
+ .to("mock:result");
+ }
+ }
+ };
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "insert", "delete", "update", "upsert" })
+ void rowErrorFailsTheExchange(String operation) throws Exception {
+ when(response.hasRowError()).thenReturn(true);
+ MockEndpoint result = getMockEndpoint("mock:result");
+ result.expectedMessageCount(0);
+
+ Exchange exchange = template.send("direct:" + operation, e ->
e.getIn().setBody(row()));
+
+ KuduRowErrorException failure =
assertInstanceOf(KuduRowErrorException.class, exchange.getException(),
+ "exception of an exchange whose " + operation + " was answered
with a row error");
+ assertSame(rowError, failure.getRowError());
+ // the status only: the text of the row error prints the column values
of the row
+ assertEquals("Kudu reported a row error for the operation: Already
present: key already present",
+ failure.getMessage());
+ result.assertIsSatisfied();
+ verify(session, times(1)).close();
+ }
+
+ @Test
+ void failedRequestFailsTheExchangeWithItsStatus() throws Exception {
+ // what the Kudu client returns for a request that timed out: a
response whose row error has the status of
+ // the failure
+ Status timedOut = Status.TimedOut("can not complete before timeout");
+ when(response.hasRowError()).thenReturn(true);
+ when(rowError.getErrorStatus()).thenReturn(timedOut);
+ MockEndpoint result = getMockEndpoint("mock:result");
+ result.expectedMessageCount(0);
+
+ Exchange exchange = template.send("direct:delete", e ->
e.getIn().setBody(row()));
+
+ KuduRowErrorException failure =
assertInstanceOf(KuduRowErrorException.class, exchange.getException(),
+ "exception of an exchange whose delete was answered with the
row error of a request that timed out");
+ assertSame(timedOut, failure.getRowError().getErrorStatus());
+ assertTrue(failure.getRowError().getErrorStatus().isTimedOut());
+ assertEquals("Kudu reported a row error for the operation: " +
timedOut, failure.getMessage());
+ result.assertIsSatisfied();
+ verify(session, times(1)).close();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "insert", "delete", "update", "upsert" })
+ void sessionIsClosedAfterAnAppliedRow(String operation) throws Exception {
+ when(response.hasRowError()).thenReturn(false);
+ MockEndpoint result = getMockEndpoint("mock:result");
+ result.expectedMessageCount(1);
+
+ Exchange exchange = template.send("direct:" + operation, e ->
e.getIn().setBody(row()));
+
+ assertNull(exchange.getException());
+ result.assertIsSatisfied();
+ verify(session, times(1)).apply(any(Operation.class));
+ verify(session, times(1)).close();
+ }
+
+ @Test
+ void sessionIsClosedWhenTheOperationFails() throws Exception {
+ IllegalStateException failure = new IllegalStateException("apply
failed");
+ IllegalStateException closeFailure = new IllegalStateException("close
failed too");
+ when(session.apply(any(Operation.class))).thenThrow(failure);
+ when(session.close()).thenThrow(closeFailure);
+ MockEndpoint result = getMockEndpoint("mock:result");
+ result.expectedMessageCount(0);
+
+ Exchange exchange = template.send("direct:insert", e ->
e.getIn().setBody(row()));
+
+ // the failure of the operation is reported, not the failure of the
close that follows it
+ assertSame(failure, exchange.getException());
+ assertArrayEquals(new Throwable[] { closeFailure },
failure.getSuppressed());
+ result.assertIsSatisfied();
+ verify(session, times(1)).close();
+ verify(response, never()).hasRowError();
+ }
+
+ private static Map<String, Object> row() {
+ Map<String, Object> row = new HashMap<>();
+ row.put("id", 5);
+ row.put("name", "Samuel");
+ return row;
+ }
+
+ private static <T extends Operation> T operation(Class<T> type, Schema
schema) {
+ T operation = mock(type);
+ when(operation.getRow()).thenReturn(schema.newPartialRow());
+ return operation;
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 35c93c0ad8e8..81ba4af58a03 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -269,6 +269,21 @@ poll returns only the tweets newer than the last one
consumed, and a page has `c
default of 15 applied before). Raise `count` and `numberOfPages` if a search
can match more tweets than that between
two polls. A consumer with `type=direct` returned nothing when keywords were
given, and now runs the search at startup.
+=== camel-kudu - a row error of Kudu fails the exchange
+
+The `insert`, `delete`, `update` and `upsert` operations of the producer
ignored the answer of Kudu. The Kudu client
+does not throw for a row that Kudu rejected; it returns a row error, for
example for an insert of a key that is already
+present, or for an update or a delete of a row that is not found. Such an
exchange completed as if the row had been
+written. A write that timed out or could not reach a tablet server was
reported the same way, and completed the
+exchange too; for such a write it is not known whether the row was written.
+The producer now fails the exchange in both cases with a
`KuduRowErrorException` (`getRowError()` returns the row
+error of the Kudu client, whose status tells the kind of error). Routes that
relied on rejected rows being skipped
+silently can use the `upsert` operation, or handle the exception, for example
with
+`onException(KuduRowErrorException.class).handled(true)`.
+
+The producer also closes the Kudu session of each operation. The sessions
stayed registered with the Kudu client until
+the client was closed, one more for every exchange.
+
=== camel-atom - sortEntries sorts by the updated date and routes the oldest
entry first
With `sortEntries=true` the `atom` consumer sorted the entries of a feed by
their published date and routed them from