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 4e2f6f6445 Fix #1803: hold back sink offsets until the aggregated
exchange is delivered (#1821)
4e2f6f6445 is described below
commit 4e2f6f64456ba111a4a0139c6300ef86c5de93e3
Author: Andrea Cosentino <[email protected]>
AuthorDate: Tue Aug 25 06:38:24 2026 +0200
Fix #1803: hold back sink offsets until the aggregated exchange is
delivered (#1821)
put() sent each record synchronously and inspected the exchange, which is
sound
only while the route is fully synchronous. With aggregation configured, the
aggregate EIP completes the incoming exchange as soon as it has been merged
into
the buffer and delivers later on the aggregated exchange. So put() returned
clean, Kafka Connect committed the offset, and a failure of the aggregated
exchange was handled inside the route and dropped: no exception from put(),
no
reporter.report, no rewind. The whole batch was lost silently.
Track the records whose delivery is outstanding and implement preCommit to
commit only up to the oldest of them. DeliveryTrackingAggregationStrategy
wraps
the strategy named by camel.beans.aggregate so the records merged into an
aggregated exchange stay associated with it, and a completion on that
exchange
releases them once it has been delivered. A failed batch goes to the DLQ
when a
reporter is configured, and otherwise fails the task on the next call rather
than committing undelivered data.
Without aggregation the route is still synchronous, so records are released
as
put() returns and preCommit passes Kafka Connect's own offsets through
unchanged; testOffsetsAreNotHeldBackWithoutAggregation pins that.
Two things worth knowing for review. The strategy has to be decorated inside
configure() rather than in build(), because the bean named by
camel.beans.aggregate
is only bound once the context starts. And the accumulated records are
carried
from oldExchange on every call, because an aggregation strategy commonly
returns
newExchange, so the exchange carrying the batch changes identity as it
grows.
Signed-off-by: Andrea Cosentino <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../apache/camel/kafkaconnector/CamelSinkTask.java | 68 ++++++++++++++++
.../DeliveryTrackingAggregationStrategy.java | 90 ++++++++++++++++++++++
.../kafkaconnector/SinkRecordDeliveryTracker.java | 90 ++++++++++++++++++++++
.../camel/kafkaconnector/SinkRecordReference.java | 54 +++++++++++++
.../utils/CamelKafkaConnectMain.java | 29 ++++++-
.../camel/kafkaconnector/CamelSinkTaskTest.java | 71 +++++++++++++++++
6 files changed, 401 insertions(+), 1 deletion(-)
diff --git
a/core/src/main/java/org/apache/camel/kafkaconnector/CamelSinkTask.java
b/core/src/main/java/org/apache/camel/kafkaconnector/CamelSinkTask.java
index b36a738854..5ade2ccf72 100644
--- a/core/src/main/java/org/apache/camel/kafkaconnector/CamelSinkTask.java
+++ b/core/src/main/java/org/apache/camel/kafkaconnector/CamelSinkTask.java
@@ -18,6 +18,7 @@ package org.apache.camel.kafkaconnector;
import java.util.Collection;
import java.util.Collections;
+import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -31,6 +32,8 @@ import
org.apache.camel.kafkaconnector.utils.CamelKafkaConnectMain;
import org.apache.camel.kafkaconnector.utils.TaskHelper;
import org.apache.camel.support.DefaultExchange;
import org.apache.camel.util.StringHelper;
+import org.apache.kafka.clients.consumer.OffsetAndMetadata;
+import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.connect.data.Decimal;
import org.apache.kafka.connect.data.Schema;
import org.apache.kafka.connect.errors.ConnectException;
@@ -48,6 +51,9 @@ public class CamelSinkTask extends SinkTask {
public static final String HEADER_CAMEL_PREFIX = "CamelHeader.";
public static final String PROPERTY_CAMEL_PREFIX = "CamelProperty.";
+ /** Set on each exchange so an aggregated delivery can be traced back to
the records it carries. */
+ static final String RECORD_REFERENCE_PROPERTY =
"CamelKafkaConnectorSinkRecord";
+
private static final String CAMEL_SINK_ENDPOINT_PROPERTIES_PREFIX =
"camel.sink.endpoint.";
private static final String CAMEL_SINK_PATH_PROPERTIES_PREFIX =
"camel.sink.path.";
@@ -56,6 +62,9 @@ public class CamelSinkTask extends SinkTask {
private static final String LOCAL_URL = "direct:start";
private static final String DEFAULT_KAMELET_CKC_SINK = "kamelet:ckcSink";
private ErrantRecordReporter reporter;
+ private final SinkRecordDeliveryTracker deliveryTracker = new
SinkRecordDeliveryTracker();
+ private volatile Throwable aggregatedDeliveryFailure;
+ private boolean aggregationEnabled;
private CamelKafkaConnectMain cms;
private ProducerTemplate producer;
@@ -98,6 +107,7 @@ public class CamelSinkTask extends SinkTask {
final String marshaller =
config.getString(CamelSinkConnectorConfig.CAMEL_SINK_MARSHAL_CONF);
final String unmarshaller =
config.getString(CamelSinkConnectorConfig.CAMEL_SINK_UNMARSHAL_CONF);
final int size =
config.getInt(CamelSinkConnectorConfig.CAMEL_CONNECTOR_AGGREGATE_SIZE_CONF);
+ aggregationEnabled =
config.getString(CamelSinkConnectorConfig.CAMEL_CONNECTOR_AGGREGATE_CONF) !=
null;
final long timeout =
config.getLong(CamelSinkConnectorConfig.CAMEL_CONNECTOR_AGGREGATE_TIMEOUT_CONF);
final int maxRedeliveries =
config.getInt(CamelSinkConnectorConfig.CAMEL_CONNECTOR_ERROR_HANDLER_MAXIMUM_REDELIVERIES_CONF);
final long redeliveryDelay =
config.getLong(CamelSinkConnectorConfig.CAMEL_CONNECTOR_ERROR_HANDLER_REDELIVERY_DELAY_CONF);
@@ -139,6 +149,7 @@ public class CamelSinkTask extends SinkTask {
.withProperties(actualProps)
.withUnmarshallDataFormat(unmarshaller)
.withMarshallDataFormat(marshaller)
+ .withAggregationStrategyDecorator(configured -> new
DeliveryTrackingAggregationStrategy(configured, this::onAggregatedExchangeDone))
.withAggregationSize(size)
.withAggregationTimeout(timeout)
.withErrorHandler(errorHandler)
@@ -191,10 +202,16 @@ public class CamelSinkTask extends SinkTask {
@Override
public void put(Collection<SinkRecord> sinkRecords) {
+ failIfAnAggregatedDeliveryFailed();
+
for (SinkRecord record : sinkRecords) {
TaskHelper.logRecordContent(LOG, loggingLevel, record);
+ SinkRecordReference reference = new SinkRecordReference(record);
+ deliveryTracker.inFlight(reference.topicPartition(),
reference.offset());
+
Exchange exchange = new
DefaultExchange(producer.getCamelContext());
+ exchange.setProperty(RECORD_REFERENCE_PROPERTY, reference);
exchange.getMessage().setBody(record.value());
exchange.getMessage().setHeader(KAFKA_RECORD_KEY_HEADER,
record.key());
@@ -214,6 +231,9 @@ public class CamelSinkTask extends SinkTask {
producer.send(localEndpoint, exchange);
if (exchange.isFailed()) {
+ // the record never reached the endpoint, so its offset must
not be held back on its behalf
+ deliveryTracker.delivered(reference.topicPartition(),
reference.offset());
+
if (reporter == null) {
LOG.warn("A delivery has failed and the error reporting is
NOT enabled. Records may be lost or ignored");
throw new ConnectException("Exchange delivery has
failed!", exchange.getException());
@@ -221,10 +241,58 @@ public class CamelSinkTask extends SinkTask {
LOG.warn("A delivery has failed and the error reporting is
enabled. Sending record to the DLQ");
reporter.report(record, exchange.getException());
+ } else if (!aggregationEnabled) {
+ // without aggregation the route is synchronous, so returning
here means the record was delivered
+ deliveryTracker.delivered(reference.topicPartition(),
reference.offset());
}
}
}
+ /**
+ * Holds back the offsets of records whose delivery has not completed.
Without aggregation every record is
+ * delivered by the time {@link #put} returns, so this is the set Kafka
Connect proposed. With aggregation the
+ * data of a record sits in the aggregation buffer after put() returns,
and its offset is only released once the
+ * aggregated exchange carrying it has completed.
+ */
+ @Override
+ public Map<TopicPartition, OffsetAndMetadata>
preCommit(Map<TopicPartition, OffsetAndMetadata> currentOffsets) {
+ failIfAnAggregatedDeliveryFailed();
+ return deliveryTracker.safeOffsets(currentOffsets);
+ }
+
+ @Override
+ public void close(Collection<TopicPartition> partitions) {
+ deliveryTracker.forget(partitions);
+ super.close(partitions);
+ }
+
+ private void failIfAnAggregatedDeliveryFailed() {
+ Throwable failure = aggregatedDeliveryFailure;
+ if (failure != null) {
+ aggregatedDeliveryFailure = null;
+ throw new ConnectException("Delivery of an aggregated exchange has
failed!", failure);
+ }
+ }
+
+ /**
+ * Called when an aggregated exchange has completed, for every record that
went into it.
+ */
+ private void onAggregatedExchangeDone(List<SinkRecordReference>
references, Exchange exchange) {
+ if (exchange.isFailed()) {
+ if (reporter != null) {
+ LOG.warn("Delivery of an aggregated exchange failed and error
reporting is enabled. Sending {} record(s) to the DLQ",
+ references.size());
+ references.forEach(reference ->
reporter.report(reference.record(), exchange.getException()));
+ } else {
+ LOG.error("Delivery of an aggregated exchange carrying {}
record(s) failed and error reporting is NOT enabled",
+ references.size(), exchange.getException());
+ aggregatedDeliveryFailure = exchange.getException();
+ }
+ }
+
+ references.forEach(reference ->
deliveryTracker.delivered(reference.topicPartition(), reference.offset()));
+ }
+
@Override
public void stop() {
LOG.info("Stopping CamelSinkTask connector task");
diff --git
a/core/src/main/java/org/apache/camel/kafkaconnector/DeliveryTrackingAggregationStrategy.java
b/core/src/main/java/org/apache/camel/kafkaconnector/DeliveryTrackingAggregationStrategy.java
new file mode 100644
index 0000000000..09903ae27c
--- /dev/null
+++
b/core/src/main/java/org/apache/camel/kafkaconnector/DeliveryTrackingAggregationStrategy.java
@@ -0,0 +1,90 @@
+/*
+ * 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.kafkaconnector;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.BiConsumer;
+
+import org.apache.camel.AggregationStrategy;
+import org.apache.camel.Exchange;
+import org.apache.camel.support.SynchronizationAdapter;
+
+/**
+ * Wraps the aggregation strategy named by the connector configuration,
keeping each record associated with the
+ * aggregated exchange it was merged into.
+ *
+ * The aggregate EIP completes an incoming exchange as soon as it has been
merged into the buffer, while the data is
+ * delivered later on the aggregated exchange. Without this, a record's offset
would be committed while its data was
+ * still in the aggregation buffer, and a failure of the aggregated exchange
would never reach the task.
+ */
+class DeliveryTrackingAggregationStrategy implements AggregationStrategy {
+
+ static final String AGGREGATED_RECORDS_PROPERTY =
"CamelKafkaConnectorAggregatedRecords";
+ static final String COMPLETION_REGISTERED_PROPERTY =
"CamelKafkaConnectorAggregatedCompletionRegistered";
+
+ private final AggregationStrategy delegate;
+ private final BiConsumer<List<SinkRecordReference>, Exchange>
onAggregatedExchangeDone;
+
+ DeliveryTrackingAggregationStrategy(AggregationStrategy delegate,
+ BiConsumer<List<SinkRecordReference>,
Exchange> onAggregatedExchangeDone) {
+ this.delegate = delegate;
+ this.onAggregatedExchangeDone = onAggregatedExchangeDone;
+ }
+
+ @Override
+ public Exchange aggregate(Exchange oldExchange, Exchange newExchange) {
+ SinkRecordReference incoming = newExchange == null
+ ? null
+ :
newExchange.getProperty(CamelSinkTask.RECORD_REFERENCE_PROPERTY,
SinkRecordReference.class);
+
+ // An aggregation strategy commonly returns newExchange, so the
exchange carrying the batch changes identity on
+ // every call. Carry the accumulated records across so the list always
describes the whole batch.
+ List<SinkRecordReference> records = oldExchange == null ? new
ArrayList<>() : aggregatedRecords(oldExchange);
+
+ Exchange aggregated = delegate.aggregate(oldExchange, newExchange);
+
+ if (aggregated == null) {
+ return null;
+ }
+
+ if (incoming != null) {
+ records.add(incoming);
+ }
+ aggregated.setProperty(AGGREGATED_RECORDS_PROPERTY, records);
+
+ // Only the exchange that ends up leaving the aggregator completes, so
registering on each candidate is safe:
+ // the ones that are superseded never fire, and the one that does
holds the whole batch.
+ if (aggregated.getProperty(COMPLETION_REGISTERED_PROPERTY) == null) {
+ aggregated.setProperty(COMPLETION_REGISTERED_PROPERTY,
Boolean.TRUE);
+ aggregated.getExchangeExtension().addOnCompletion(new
SynchronizationAdapter() {
+ @Override
+ public void onDone(Exchange exchange) {
+ onAggregatedExchangeDone.accept(new ArrayList<>(records),
exchange);
+ }
+ });
+ }
+
+ return aggregated;
+ }
+
+ @SuppressWarnings("unchecked")
+ private List<SinkRecordReference> aggregatedRecords(Exchange exchange) {
+ List<SinkRecordReference> records =
exchange.getProperty(AGGREGATED_RECORDS_PROPERTY, List.class);
+ return records == null ? new ArrayList<>() : records;
+ }
+}
diff --git
a/core/src/main/java/org/apache/camel/kafkaconnector/SinkRecordDeliveryTracker.java
b/core/src/main/java/org/apache/camel/kafkaconnector/SinkRecordDeliveryTracker.java
new file mode 100644
index 0000000000..d4927c2f24
--- /dev/null
+++
b/core/src/main/java/org/apache/camel/kafkaconnector/SinkRecordDeliveryTracker.java
@@ -0,0 +1,90 @@
+/*
+ * 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.kafkaconnector;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.NavigableSet;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentSkipListSet;
+
+import org.apache.kafka.clients.consumer.OffsetAndMetadata;
+import org.apache.kafka.common.TopicPartition;
+
+/**
+ * Tracks which sink records have been handed to the Camel route but not yet
delivered, so that
+ * {@link org.apache.kafka.connect.sink.SinkTask#preCommit} can hold back the
offsets of records whose delivery has
+ * not completed.
+ *
+ * A record is in flight from the moment it is sent into the route until the
exchange carrying it - which, when
+ * aggregation is configured, is the aggregated exchange rather than the one
the record entered on - has completed.
+ */
+class SinkRecordDeliveryTracker {
+
+ private final Map<TopicPartition, NavigableSet<Long>> inFlight = new
ConcurrentHashMap<>();
+
+ void inFlight(TopicPartition partition, long offset) {
+ inFlight.computeIfAbsent(partition, p -> new
ConcurrentSkipListSet<>()).add(offset);
+ }
+
+ void delivered(TopicPartition partition, long offset) {
+ NavigableSet<Long> offsets = inFlight.get(partition);
+ if (offsets != null) {
+ offsets.remove(offset);
+ }
+ }
+
+ /**
+ * Offsets safe to commit: for a partition with records still in flight,
everything strictly before the oldest of
+ * them; otherwise whatever Kafka Connect proposed.
+ */
+ Map<TopicPartition, OffsetAndMetadata> safeOffsets(Map<TopicPartition,
OffsetAndMetadata> currentOffsets) {
+ Map<TopicPartition, OffsetAndMetadata> safe = new
HashMap<>(currentOffsets.size());
+
+ for (Map.Entry<TopicPartition, OffsetAndMetadata> entry :
currentOffsets.entrySet()) {
+ NavigableSet<Long> offsets = inFlight.get(entry.getKey());
+ Long oldestInFlight = offsets == null || offsets.isEmpty() ? null
: offsets.first();
+
+ if (oldestInFlight == null || oldestInFlight >=
entry.getValue().offset()) {
+ safe.put(entry.getKey(), entry.getValue());
+ } else {
+ // commit up to, but not including, the oldest record still
awaiting delivery
+ safe.put(entry.getKey(), new
OffsetAndMetadata(oldestInFlight));
+ }
+ }
+
+ return safe;
+ }
+
+ /**
+ * Drops any state for partitions that are no longer assigned to this task.
+ */
+ void forget(Collection<TopicPartition> partitions) {
+ partitions.forEach(inFlight::remove);
+ }
+
+ void clear() {
+ inFlight.clear();
+ }
+
+ // visible for testing
+ int inFlightCount(TopicPartition partition) {
+ NavigableSet<Long> offsets = inFlight.get(partition);
+ return offsets == null ? 0 : offsets.size();
+ }
+}
diff --git
a/core/src/main/java/org/apache/camel/kafkaconnector/SinkRecordReference.java
b/core/src/main/java/org/apache/camel/kafkaconnector/SinkRecordReference.java
new file mode 100644
index 0000000000..dd6a963b59
--- /dev/null
+++
b/core/src/main/java/org/apache/camel/kafkaconnector/SinkRecordReference.java
@@ -0,0 +1,54 @@
+/*
+ * 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.kafkaconnector;
+
+import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.connect.sink.SinkRecord;
+
+/**
+ * Identifies a sink record while its delivery is outstanding, so its offset
can be held back until the exchange
+ * carrying it has completed. The record itself is kept so a failed delivery
can still be routed to the DLQ.
+ */
+final class SinkRecordReference {
+
+ private final TopicPartition topicPartition;
+ private final long offset;
+ private final SinkRecord record;
+
+ SinkRecordReference(SinkRecord record) {
+ this.topicPartition = new TopicPartition(record.topic(),
record.kafkaPartition());
+ this.offset = record.kafkaOffset();
+ this.record = record;
+ }
+
+ TopicPartition topicPartition() {
+ return topicPartition;
+ }
+
+ long offset() {
+ return offset;
+ }
+
+ SinkRecord record() {
+ return record;
+ }
+
+ @Override
+ public String toString() {
+ return topicPartition + "@" + offset;
+ }
+}
diff --git
a/core/src/main/java/org/apache/camel/kafkaconnector/utils/CamelKafkaConnectMain.java
b/core/src/main/java/org/apache/camel/kafkaconnector/utils/CamelKafkaConnectMain.java
index 9d060769f0..87d2a825b3 100644
---
a/core/src/main/java/org/apache/camel/kafkaconnector/utils/CamelKafkaConnectMain.java
+++
b/core/src/main/java/org/apache/camel/kafkaconnector/utils/CamelKafkaConnectMain.java
@@ -20,8 +20,10 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
+import java.util.function.UnaryOperator;
import java.util.stream.Collectors;
+import org.apache.camel.AggregationStrategy;
import org.apache.camel.CamelContext;
import org.apache.camel.ConsumerTemplate;
import org.apache.camel.ProducerTemplate;
@@ -47,6 +49,7 @@ public class CamelKafkaConnectMain extends SimpleMain {
public static final String KAMELET_AGGREGATORL_TEMPLATE_PARAMETERS_PREFIX
= "camel.kamelet.ckcAggregator.";
public static final String KAMELET_IDEMPOTENT_TEMPLATE_PARAMETERS_PREFIX =
"camel.kamelet.ckcIdempotent.";
public static final String KAMELET_REMOVEHEADER_TEMPLATE_PARAMETERS_PREFIX
= "camel.kamelet.ckcRemoveHeader.";
+ public static final String CAMEL_CONNECTOR_TRACKED_AGGREGATE_NAME =
"ckcTrackedAggregate";
private static final Logger LOG =
LoggerFactory.getLogger(CamelKafkaConnectMain.class);
@@ -118,6 +121,7 @@ public class CamelKafkaConnectMain extends SimpleMain {
private int idempotentRepositoryKafkaPollDuration;
private String headersExcludePattern;
private boolean removeHeadersFirst;
+ private UnaryOperator<AggregationStrategy>
aggregationStrategyDecorator;
private boolean dumpRoutes = true;
public Builder(String from, String to) {
@@ -232,6 +236,16 @@ public class CamelKafkaConnectMain extends SimpleMain {
return this;
}
+ /**
+ * Wraps the aggregation strategy the configuration supplies, when
aggregation is in use. The sink task uses
+ * this to keep track of which records a given aggregated exchange
carries, since the aggregate EIP completes
+ * an incoming exchange before its data has been delivered.
+ */
+ public Builder
withAggregationStrategyDecorator(UnaryOperator<AggregationStrategy>
aggregationStrategyDecorator) {
+ this.aggregationStrategyDecorator = aggregationStrategyDecorator;
+ return this;
+ }
+
public Builder withDumpRoutes(boolean dumpRoutes) {
this.dumpRoutes = dumpRoutes;
return this;
@@ -320,6 +334,13 @@ public class CamelKafkaConnectMain extends SimpleMain {
camelMain.getCamelContext().getRegistry().bind("ckcIdempotentRepository",
idempotentRepo);
}
+ // The bean named by camel.beans.aggregate is only bound once the
context starts, so the strategy is
+ // decorated inside configure() below; the template just needs to
be pointed at the decorated name here.
+ if (aggregationStrategyDecorator != null) {
+
camelProperties.put(KAMELET_AGGREGATORL_TEMPLATE_PARAMETERS_PREFIX +
"aggregationStrategy",
+ CAMEL_CONNECTOR_TRACKED_AGGREGATE_NAME);
+ }
+
//remove headers
if (!ObjectHelper.isEmpty(headersExcludePattern)) {
camelProperties.put(KAMELET_REMOVEHEADER_TEMPLATE_PARAMETERS_PREFIX +
"headersExcludePattern", headersExcludePattern);
@@ -402,7 +423,13 @@ public class CamelKafkaConnectMain extends SimpleMain {
if (!ObjectHelper.isEmpty(unmarshallDataFormat)) {
rd = rd.kamelet("ckcUnMarshal");
}
- if (getContext().getRegistry().lookupByName("aggregate")
!= null) {
+ Object configuredAggregationStrategy =
+
getContext().getRegistry().lookupByName(CamelConnectorConfig.CAMEL_CONNECTOR_AGGREGATE_NAME);
+ if (configuredAggregationStrategy != null) {
+ if (aggregationStrategyDecorator != null &&
configuredAggregationStrategy instanceof AggregationStrategy) {
+
getContext().getRegistry().bind(CAMEL_CONNECTOR_TRACKED_AGGREGATE_NAME,
+
aggregationStrategyDecorator.apply((AggregationStrategy)
configuredAggregationStrategy));
+ }
rd = rd.kamelet("ckcAggregator");
}
if (idempotencyEnabled) {
diff --git
a/core/src/test/java/org/apache/camel/kafkaconnector/CamelSinkTaskTest.java
b/core/src/test/java/org/apache/camel/kafkaconnector/CamelSinkTaskTest.java
index eafcd45e15..26c76171ea 100644
--- a/core/src/test/java/org/apache/camel/kafkaconnector/CamelSinkTaskTest.java
+++ b/core/src/test/java/org/apache/camel/kafkaconnector/CamelSinkTaskTest.java
@@ -28,6 +28,8 @@ import java.util.Map;
import org.apache.camel.ConsumerTemplate;
import org.apache.camel.Exchange;
import org.apache.camel.LoggingLevel;
+import org.apache.kafka.clients.consumer.OffsetAndMetadata;
+import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.connect.data.Decimal;
import org.apache.kafka.connect.data.Schema;
import org.apache.kafka.connect.data.SchemaAndValue;
@@ -42,6 +44,7 @@ import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -1095,4 +1098,72 @@ class CamelSinkTaskTest {
Exchange exchange = consumer.receive(SEDA_URI, RECEIVE_TIMEOUT);
assertThat(exchange.getIn().getHeader(headerName)).isEqualTo(headerValue);
}
+
+ @Test
+ void testAggregatedRecordsOffsetsAreHeldBackUntilTheBatchIsDelivered() {
+ Map<String, String> props = new HashMap<>();
+ props.put(TOPIC_CONF, TOPIC_NAME);
+ props.put(CamelSinkConnectorConfig.CAMEL_SINK_URL_CONF, SEDA_URI);
+ props.put(CamelSinkConnectorConfig.CAMEL_CONNECTOR_AGGREGATE_CONF,
"#class:org.apache.camel.kafkaconnector.utils.SampleAggregator");
+
props.put(CamelSinkConnectorConfig.CAMEL_CONNECTOR_AGGREGATE_SIZE_CONF, "5");
+ sinkTask.start(props);
+
+ TopicPartition partition = new TopicPartition(TOPIC_NAME, 1);
+
+ // four records: fewer than the aggregation size, so their data is
still sitting in the buffer
+ List<SinkRecord> firstBatch = new ArrayList<>();
+ for (int i = 0; i < 4; i++) {
+ firstBatch.add(new SinkRecord(TOPIC_NAME, 1, null, "test", null,
"camel" + i, 10 + i));
+ }
+ sinkTask.put(firstBatch);
+
+ Map<TopicPartition, OffsetAndMetadata> proposed =
Collections.singletonMap(partition, new OffsetAndMetadata(14));
+ Map<TopicPartition, OffsetAndMetadata> safe =
sinkTask.preCommit(proposed);
+ assertEquals(10, safe.get(partition).offset(),
+ "offsets must be held at the oldest record whose data is still
in the aggregation buffer");
+
+ // the fifth record completes the batch, so the aggregated exchange is
delivered
+ sinkTask.put(Collections.singletonList(new SinkRecord(TOPIC_NAME, 1,
null, "test", null, "camel4", 14)));
+
+ ConsumerTemplate consumer = sinkTask.getCms().getConsumerTemplate();
+ Exchange exchange = consumer.receive(SEDA_URI, RECEIVE_TIMEOUT);
+ assertNotNull(exchange, "the aggregated exchange should have been
delivered");
+
+ Map<TopicPartition, OffsetAndMetadata> afterDelivery =
Collections.singletonMap(partition, new OffsetAndMetadata(15));
+ Map<TopicPartition, OffsetAndMetadata> safeAfter =
awaitPreCommit(afterDelivery, 15);
+ assertEquals(15, safeAfter.get(partition).offset(),
+ "once the aggregated exchange has been delivered every offset
in it can be committed");
+ }
+
+ @Test
+ void testOffsetsAreNotHeldBackWithoutAggregation() {
+ Map<String, String> props = new HashMap<>();
+ props.put(TOPIC_CONF, TOPIC_NAME);
+ props.put(CamelSinkConnectorConfig.CAMEL_SINK_URL_CONF, SEDA_URI);
+ sinkTask.start(props);
+
+ TopicPartition partition = new TopicPartition(TOPIC_NAME, 1);
+ sinkTask.put(Collections.singletonList(new SinkRecord(TOPIC_NAME, 1,
null, "test", null, "camel", 10)));
+
+ Map<TopicPartition, OffsetAndMetadata> proposed =
Collections.singletonMap(partition, new OffsetAndMetadata(11));
+ assertEquals(11, sinkTask.preCommit(proposed).get(partition).offset(),
+ "without aggregation the route is synchronous, so nothing is
held back");
+ }
+
+ private Map<TopicPartition, OffsetAndMetadata>
awaitPreCommit(Map<TopicPartition, OffsetAndMetadata> proposed, long expected) {
+ Map<TopicPartition, OffsetAndMetadata> safe = null;
+ for (int attempt = 0; attempt < 50; attempt++) {
+ safe = sinkTask.preCommit(proposed);
+ if (safe.values().iterator().next().offset() == expected) {
+ return safe;
+ }
+ try {
+ Thread.sleep(100);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+ return safe;
+ }
}