davsclaus commented on code in PR #26557:
URL: https://github.com/apache/camel/pull/26557#discussion_r4192893549


##########
components/camel-kafka/src/main/java/org/apache/camel/component/kafka/consumer/DefaultKafkaManualCommit.java:
##########
@@ -64,6 +65,15 @@ public long getCommitTimeout() {
         return kafkaRecordPayload.commitTimeout;
     }
 
+    /**
+     * Gets the consumer group metadata of the source consumer, for use with
+     * {@link 
org.apache.kafka.clients.producer.Producer#sendOffsetsToTransaction} when 
driving exactly-once semantics.
+     * Must be called on the consumer poll thread, as the Kafka consumer is 
not safe for multi-threaded access.
+     */
+    public ConsumerGroupMetadata getConsumerGroupMetadata() {
+        return camelExchangePayload.consumer.groupMetadata();

Review Comment:
   This reads `groupMetadata()` lazily, on the producer's thread. With 
`threads()` or `seda` between the consumer and the producer, that is not the 
poll thread (`KafkaConsumer` is not thread-safe) and the generation may be 
stale. A stale generation is rejected at `sendOffsetsToTransaction` as an 
abortable `CommitFailedException`, so no data is corrupted, but as agreed with 
Guillaume, please capture it on the poll thread (e.g. in `getManualCommit`), or 
reject an exchange whose thread is not the consumer's.



##########
components/camel-kafka/src/main/java/org/apache/camel/component/kafka/KafkaProducer.java:
##########
@@ -524,18 +533,75 @@ private void startKafkaTransaction(Exchange exchange) {
 
         if (!uow.isTransactedBy(transactionId)) {
             LOG.debug("Starting kafka transaction {} with exchange {}", 
transactionId, exchange.getExchangeId());
-            // Begin the broker transaction first, then mark the unit of work 
and register the
-            // synchronization. This way a failure in beginTransaction() does 
not leave the unit of work
-            // flagged as transacted without a synchronization to commit or 
roll it back (CAMEL-24780).
+            // Resolve the synchronization first: with exactlyOnce it reads 
the consumer offsets and may fail, and
+            // that must happen before the broker transaction is opened. Then 
begin the broker transaction, and only
+            // then mark the unit of work. This way a failure in either step 
does not leave the unit of work flagged
+            // as transacted without a synchronization to commit or roll it 
back (CAMEL-24780).
+            KafkaTransactionSynchronization synchronization = 
createTransactionSynchronization(exchange);
             kafkaProducer.beginTransaction();
             uow.beginTransactedBy(transactionId);
-            uow.addSynchronization(new 
KafkaTransactionSynchronization(transactionId, kafkaProducer));
+            uow.addSynchronization(synchronization);
         } else {
             LOG.debug("Using existing kafka transaction {} with exchange {}.",
                     transactionId, exchange.getExchangeId());
         }
     }
 
+    private KafkaTransactionSynchronization 
createTransactionSynchronization(Exchange exchange) {
+        if (!configuration.isExactlyOnce()) {
+            return new KafkaTransactionSynchronization(transactionId, 
kafkaProducer);
+        }
+
+        // Read the consumer offsets and group metadata on the consumer poll 
thread that is processing this exchange;
+        // the Kafka consumer is not safe for multi-threaded access. The 
offset to commit is the next offset to read,
+        // i.e. the processed record's offset + 1.
+        List<DefaultKafkaManualCommit> manualCommits = 
resolveManualCommits(exchange);
+        Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
+        for (DefaultKafkaManualCommit manualCommit : manualCommits) {
+            offsets.merge(manualCommit.getPartition(), new 
OffsetAndMetadata(manualCommit.getRecordOffset() + 1),
+                    (current, candidate) -> candidate.offset() > 
current.offset() ? candidate : current);
+        }
+        return new KafkaTransactionSynchronization(
+                transactionId, kafkaProducer, offsets, 
manualCommits.get(0).getConsumerGroupMetadata());
+    }
+
+    private List<DefaultKafkaManualCommit> resolveManualCommits(Exchange 
exchange) {
+        Object body = exchange.getMessage().getBody();
+        if (body instanceof List<?> batch && !batch.isEmpty() && batch.get(0) 
instanceof Exchange) {

Review Comment:
   Minor: batch offsets are only collected while the body is still a 
`List<Exchange>`. If the route transforms the body first, this silently falls 
back to the partition of the last record, and an aggregator producing a 
`List<Exchange>` also matches here (on another thread). Worth documenting both.



##########
components/camel-kafka/src/main/java/org/apache/camel/component/kafka/KafkaProducer.java:
##########
@@ -524,18 +533,75 @@ private void startKafkaTransaction(Exchange exchange) {
 
         if (!uow.isTransactedBy(transactionId)) {
             LOG.debug("Starting kafka transaction {} with exchange {}", 
transactionId, exchange.getExchangeId());
-            // Begin the broker transaction first, then mark the unit of work 
and register the
-            // synchronization. This way a failure in beginTransaction() does 
not leave the unit of work
-            // flagged as transacted without a synchronization to commit or 
roll it back (CAMEL-24780).
+            // Resolve the synchronization first: with exactlyOnce it reads 
the consumer offsets and may fail, and
+            // that must happen before the broker transaction is opened. Then 
begin the broker transaction, and only
+            // then mark the unit of work. This way a failure in either step 
does not leave the unit of work flagged
+            // as transacted without a synchronization to commit or roll it 
back (CAMEL-24780).
+            KafkaTransactionSynchronization synchronization = 
createTransactionSynchronization(exchange);
             kafkaProducer.beginTransaction();
             uow.beginTransactedBy(transactionId);
-            uow.addSynchronization(new 
KafkaTransactionSynchronization(transactionId, kafkaProducer));
+            uow.addSynchronization(synchronization);
         } else {
             LOG.debug("Using existing kafka transaction {} with exchange {}.",
                     transactionId, exchange.getExchangeId());
         }
     }
 
+    private KafkaTransactionSynchronization 
createTransactionSynchronization(Exchange exchange) {
+        if (!configuration.isExactlyOnce()) {
+            return new KafkaTransactionSynchronization(transactionId, 
kafkaProducer);
+        }
+
+        // Read the consumer offsets and group metadata on the consumer poll 
thread that is processing this exchange;
+        // the Kafka consumer is not safe for multi-threaded access. The 
offset to commit is the next offset to read,
+        // i.e. the processed record's offset + 1.
+        List<DefaultKafkaManualCommit> manualCommits = 
resolveManualCommits(exchange);
+        Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
+        for (DefaultKafkaManualCommit manualCommit : manualCommits) {
+            offsets.merge(manualCommit.getPartition(), new 
OffsetAndMetadata(manualCommit.getRecordOffset() + 1),
+                    (current, candidate) -> candidate.offset() > 
current.offset() ? candidate : current);
+        }
+        return new KafkaTransactionSynchronization(
+                transactionId, kafkaProducer, offsets, 
manualCommits.get(0).getConsumerGroupMetadata());
+    }
+
+    private List<DefaultKafkaManualCommit> resolveManualCommits(Exchange 
exchange) {
+        Object body = exchange.getMessage().getBody();
+        if (body instanceof List<?> batch && !batch.isEmpty() && batch.get(0) 
instanceof Exchange) {
+            // batching=true: the batch exchange only carries the 
manual-commit of the last record, but a poll batch
+            // can span several partitions, so every record in the batch has 
to contribute its own offset. Otherwise
+            // the partitions left out of the transaction would be reprocessed 
after a restart.
+            List<DefaultKafkaManualCommit> manualCommits = new 
ArrayList<>(batch.size());
+            for (Object child : batch) {
+                manualCommits.add(resolveManualCommit((Exchange) child));
+            }
+            return manualCommits;
+        }
+        return List.of(resolveManualCommit(exchange));
+    }
+
+    private DefaultKafkaManualCommit resolveManualCommit(Exchange exchange) {
+        KafkaManualCommit manual = 
exchange.getMessage().getHeader(KafkaConstants.MANUAL_COMMIT, 
KafkaManualCommit.class);

Review Comment:
   A split or multicast child also has this header (copied from the parent) and 
commits its own transaction with the same offset + 1, so the output of one 
source record is not atomic. See the review summary.



##########
components/camel-kafka/src/main/java/org/apache/camel/component/kafka/KafkaTransactionSynchronization.java:
##########
@@ -60,4 +96,24 @@ public void onDone(Exchange exchange) {
             exchange.getUnitOfWork().endTransactedBy(transactionId);
         }
     }
+
+    /**
+     * Discards the open transaction. A fatal producer error must not be 
aborted: the Kafka client contract requires the
+     * producer to be closed, and calling abortTransaction() on it only throws 
again, masking the original failure.
+     */
+    private void abortOrClose(KafkaException e) {

Review Comment:
   This closes the producer without rebuilding it, which is the wedge 
CAMEL-24782 (#27186) fixed on main with `recover()` / 
`markProducerClosedForRecreation()`. After the rebase, 
`sendOffsetsToTransaction` can go inside main's `commit()` try block, so a 
failure takes the same recovery path.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to