gnodet-bot commented on code in PR #28730:
URL: https://github.com/apache/camel/pull/28730#discussion_r4432313767


##########
components/camel-kafka/src/main/java/org/apache/camel/component/kafka/consumer/support/streaming/KafkaRecordStreamingProcessor.java:
##########
@@ -104,6 +117,102 @@ public ProcessingResult processExchange(
         return result;
     }
 
+    /**
+     * A route that did not claim the source metadata with an exactly-once 
producer still has to advance the source
+     * offset (for example, a filter branch). Run this after route completion 
on the consumer polling thread.
+     */
+    private void completeExactlyOnceProcessing(Exchange exchange) {
+        if (!configuration.isExactlyOnce()) {
+            return;
+        }
+
+        KafkaExactlyOnceMetadata metadata = exchange.getProperty(
+                KafkaExactlyOnceMetadata.EXCHANGE_PROPERTY, 
KafkaExactlyOnceMetadata.class);
+        if (metadata != null && metadata.isClaimRejected()) {
+            if (exchange.getException() == null) {
+                exchange.setException(new IllegalStateException(
+                        "The Kafka exactlyOnce source metadata was rejected by 
another exchange or producer: "
+                                                                + 
metadata.getClaimRejectionReason()));
+            }
+            return;
+        }
+
+        boolean transactionAborted = Boolean.TRUE.equals(
+                
exchange.getProperty(KafkaExactlyOnceMetadata.TRANSACTION_ABORTED_PROPERTY));
+        if (transactionAborted || exchange.isRollbackOnly()) {
+            if (exchange.getException() == null) {
+                exchange.setException(new IllegalStateException(
+                        "The Kafka exactlyOnce transaction was rolled back; 
the source record must be replayed"));
+            }
+            return;
+        }
+        if (exchange.getException() != null) {
+            return;
+        }
+
+        Throwable sendFailure = metadata != null ? metadata.getSendFailure() : 
null;
+        if (sendFailure != null && !metadata.isClaimed()) {
+            // A producer failed before it could register a transaction 
synchronization. Do not mistake a handled
+            // configuration/startup failure for a filter branch and commit 
its source offset.
+            exchange.setException(new IllegalStateException(
+                    "The exactlyOnce Kafka producer failed before it could 
claim the source offsets", sendFailure));
+            return;
+        }
+        if (metadata == null) {
+            exchange.setException(new IllegalStateException(
+                    "exactlyOnce source metadata was not captured for the 
consumed record"));
+            return;
+        }
+        if (!metadata.isClaimed()) {
+            if (hasAsynchronousHandoff(exchange)) {
+                exchange.setException(new IllegalStateException(
+                        "An exactlyOnce Kafka source cannot commit offsets 
directly when the route hands the exchange "
+                                                                + "to an 
asynchronous endpoint before an exactly-once Kafka producer claims it; "
+                                                                + "invoke the 
destination synchronously from the source exchange"));
+                return;
+            }
+            try {
+                metadata.commitOffsets();
+            } catch (Exception commitFailure) {
+                exchange.setException(commitFailure);
+            }
+        }
+    }
+
+    /** Detect asynchronous route constructs when no synchronous Kafka 
producer claimed the source offsets. */
+    private boolean hasAsynchronousHandoff(Exchange exchange) {
+        String toEndpoint = 
exchange.getProperty(ExchangePropertyKey.TO_ENDPOINT, String.class);
+        if (toEndpoint != null && toEndpoint.startsWith("seda:")) {
+            return true;
+        }
+        String routeId = exchange.getFromRouteId();
+        if (routeId == null) {
+            return false;
+        }
+        org.apache.camel.Route route = exchange.getContext().getRoute(routeId);
+        return route != null && containsAsynchronousNode(route.getRoute());
+    }
+
+    private boolean containsAsynchronousNode(NamedNode node) {
+        if (node == null) {
+            return false;
+        }
+        String shortName = node.getShortName();
+        if ("wireTap".equals(shortName) || "threads".equals(shortName)) {
+            return true;
+        }
+        String label = node.getLabel();
+        if ("to".equals(shortName) && label != null && 
label.startsWith("to[seda:")) {
+            return true;
+        }
+        for (NamedNode child : node.getChildren()) {
+            if (containsAsynchronousNode(child)) {
+                return true;
+            }
+        }
+        return false;
+    }

Review Comment:
   💡 **Gap: `toD`, `recipientList`, and `routingSlip` are not detected as async 
handoffs**
   
   `containsAsynchronousNode()` checks for `"wireTap"`, `"threads"`, and static 
`"to[seda:...]"`, but misses:
   - `toD("seda:...")` — shortName is `"toD"`, not `"to"`, so line 216 never 
matches
   - `recipientList` / `routingSlip` — can dynamically route to a `seda:` 
endpoint at runtime
   
   A route using `toD("seda:my-queue")` would pass this check undetected. 
Consider either documenting the known gap (a `@implNote` or TODO), or expanding 
detection to cover `"toD"` with the same label check.



##########
components/camel-kafka/src/main/java/org/apache/camel/component/kafka/KafkaProducer.java:
##########
@@ -568,29 +600,216 @@ private void startKafkaTransaction(Exchange exchange) {
         UnitOfWork uow = exchange.getUnitOfWork();
 
         if (!uow.isTransactedBy(transactionId)) {
-            // A prior transaction may have hit a fatal error that closed the 
shared producer; rebuild it before
-            // starting a new transaction so the route recovers instead of 
staying wedged on a dead producer
-            // (CAMEL-24782).
-            recreateProducerIfClosed();
-            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).
-            kafkaProducer.beginTransaction();
-            uow.beginTransactedBy(transactionId);
-            uow.addSynchronization(new 
KafkaTransactionSynchronization(transactionId, kafkaProducer, this));
+            // Resolve and validate source metadata before opening the broker 
transaction. A failure here must not
+            // leave an open transaction without a registered synchronization.
+            KafkaExactlyOnceMetadata exactlyOnceMetadata
+                    = configuration.isExactlyOnce() ? 
createExactlyOnceMetadata(exchange) : null;
+            if (exactlyOnceMetadata != null) {
+                String registeredProducer = exchange.getProperty(
+                        
KafkaConstants.EXACTLY_ONCE_PRODUCER_TRANSACTION_ID_PROPERTY, String.class);
+                if (registeredProducer != null && 
!registeredProducer.equals(transactionId)) {
+                    String reason = "Only one exactlyOnce Kafka producer is 
supported per exchange/unit of work";
+                    exactlyOnceMetadata.rejectClaim(reason);
+                    throw new 
KafkaExactlyOnceMetadata.ClaimRejectedException(reason);
+                }
+                if (!activeExactlyOnceExchange.compareAndSet(null, 
exchange.getExchangeId())) {
+                    throw new KafkaExactlyOnceMetadata.ClaimRejectedException(
+                            "A Kafka exactlyOnce transaction is already active 
for exchange "
+                                                                              
+ activeExactlyOnceExchange.get()
+                                                                              
+ "; concurrent exchanges cannot share this transactional "
+                                                                              
+ "producer. Serialize processing before the destination producer.");
+                }
+            }
+
+            boolean transactionStarted = false;
+            boolean unitOfWorkMarked = false;
+            boolean synchronizationRegistered = false;
+            boolean metadataClaimed = false;
+            try {
+                if (exactlyOnceMetadata != null) {
+                    // The metadata object is shared by shallow exchange 
copies. Bind it to the original source exchange
+                    // and allow only one producer transaction to claim its 
offsets.
+                    exactlyOnceMetadata.claim(exchange.getExchangeId());
+                    metadataClaimed = true;
+                }
+                // A prior transaction may have hit a fatal error that closed 
the shared producer; rebuild it before
+                // starting a new transaction so the route recovers instead of 
staying wedged on a dead producer
+                // (CAMEL-24782).
+                recreateProducerIfClosed();
+                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).
+                kafkaProducer.beginTransaction();
+                transactionStarted = true;
+                uow.beginTransactedBy(transactionId);
+                unitOfWorkMarked = true;
+                uow.addSynchronization(new KafkaTransactionSynchronization(
+                        transactionId, kafkaProducer, this, 
exactlyOnceMetadata, exchange.getExchangeId()));
+                synchronizationRegistered = true;
+                if (exactlyOnceMetadata != null) {
+                    // A marker from an earlier attempt that failed before 
synchronization registration does not
+                    // belong to this transaction. Start the newly registered 
attempt with a clean send-failure state.
+                    exactlyOnceMetadata.clearSendFailures();
+                    
exchange.removeProperty(KafkaExactlyOnceMetadata.SEND_FAILURE_PROPERTY);
+                    
exchange.removeProperty(KafkaExactlyOnceMetadata.SECONDARY_SEND_FAILURE_PROPERTY);
+                    
exchange.setProperty(KafkaConstants.EXACTLY_ONCE_PRODUCER_TRANSACTION_ID_PROPERTY,
 transactionId);
+                }
+            } catch (RuntimeException | Error startFailure) {
+                // If no synchronization owns the transaction yet, clean up 
here. Once registered, onDone()
+                // owns both transaction completion and release of the 
exactly-once producer slot.
+                if (!synchronizationRegistered) {
+                    if (metadataClaimed) {
+                        exactlyOnceMetadata.releaseClaim();
+                    }
+                    if (unitOfWorkMarked) {
+                        try {
+                            uow.endTransactedBy(transactionId);
+                        } catch (RuntimeException cleanupFailure) {
+                            startFailure.addSuppressed(cleanupFailure);
+                        }
+                    }
+                    if (transactionStarted) {
+                        try {
+                            kafkaProducer.abortTransaction();
+                        } catch (Exception abortFailure) {
+                            startFailure.addSuppressed(abortFailure);
+                            try {
+                                kafkaProducer.close();
+                            } catch (Exception closeFailure) {
+                                startFailure.addSuppressed(closeFailure);
+                            } finally {
+                                markProducerClosedForRecreation();
+                            }
+                        }
+                    } else if 
(KafkaTransactionSynchronization.isFatal(startFailure)) {
+                        // beginTransaction() can itself fence or otherwise 
fatally invalidate the producer.
+                        try {
+                            kafkaProducer.close();
+                        } catch (Exception closeFailure) {
+                            startFailure.addSuppressed(closeFailure);
+                        } finally {
+                            markProducerClosedForRecreation();
+                        }
+                    }
+                    if (exactlyOnceMetadata != null) {
+                        
releaseExactlyOnceTransaction(exchange.getExchangeId());
+                    }
+                }
+                throw startFailure;
+            }
         } else {
+            if (configuration.isExactlyOnce()) {
+                Throwable previousSendFailure = exchange.getProperty(
+                        KafkaExactlyOnceMetadata.SEND_FAILURE_PROPERTY, 
Throwable.class);
+                if (previousSendFailure != null) {
+                    // Do not restart the transaction here: Camel redelivers 
from the failed processor, so output
+                    // records sent by earlier route steps in this transaction 
would be discarded without replay.
+                    // Roll back the whole source exchange and let the Kafka 
consumer replay it in a new transaction.
+                    // This synthetic rejection is not a second Kafka send 
failure. Preserve only the original
+                    // failure so a successfully handled non-Kafka DLQ can 
still take the offset-only skip path.
+                    
exchange.setProperty(KafkaExactlyOnceMetadata.SUPPRESS_NEXT_SEND_FAILURE_PROPERTY,
 Boolean.TRUE);
+                    exchange.setRollbackOnly(true);
+                    throw new IllegalStateException(
+                            "Cannot retry a Kafka exactlyOnce send within an 
active transaction; "
+                                                    + "the transaction is 
marked for rollback and the source record "
+                                                    + "must be replayed in a 
new transaction",
+                            previousSendFailure);
+                }
+            }
             LOG.debug("Using existing kafka transaction {} with exchange {}.",
                     transactionId, exchange.getExchangeId());
         }
     }
 
+    private void validateExactlyOnceDestination(Exchange exchange) {
+        if (exchange.getFromEndpoint() instanceof KafkaEndpoint sourceEndpoint
+                && sourceEndpoint.getConfiguration().isExactlyOnce()
+                && (!configuration.isExactlyOnce() || transactionId == null)
+                && !isSecondaryKafkaDeadLetterSend(exchange)) {
+            IllegalStateException failure = new IllegalStateException(
+                    "A Kafka exactlyOnce source requires every Kafka 
destination producer to set exactlyOnce=true "
+                                                                      + "and 
configure an explicit transactionalId");
+            KafkaTransactionSynchronization.recordSendFailure(exchange, 
failure);
+            throw failure;
+        }
+    }
+
+    /** A Kafka dead-letter producer is allowed only after the source producer 
has recorded a send failure. */
+    private boolean isSecondaryKafkaDeadLetterSend(Exchange exchange) {
+        KafkaExactlyOnceMetadata metadata = exchange.getProperty(
+                KafkaExactlyOnceMetadata.EXCHANGE_PROPERTY, 
KafkaExactlyOnceMetadata.class);
+        String sourceProducer = exchange.getProperty(
+                KafkaConstants.EXACTLY_ONCE_PRODUCER_TRANSACTION_ID_PROPERTY, 
String.class);
+        return metadata != null && metadata.getSendFailure() != null && 
sourceProducer != null
+                && !Objects.equals(sourceProducer, transactionId);
+    }
+
+    private KafkaExactlyOnceMetadata createExactlyOnceMetadata(Exchange 
exchange) {
+        KafkaExactlyOnceMetadata metadata
+                = 
exchange.getProperty(KafkaExactlyOnceMetadata.EXCHANGE_PROPERTY, 
KafkaExactlyOnceMetadata.class);
+
+        // The original source producer already owns the offsets and its 
transaction has failed. An exactly-once
+        // Kafka DLQ producer may now commit its DLQ record in its own 
transaction; the source synchronization is
+        // registered first and therefore commits offsets only after this 
later synchronization has completed.
+        if (isSecondaryKafkaDeadLetterSend(exchange)) {
+            return null;
+        }
+
+        if (!(exchange.getFromEndpoint() instanceof KafkaEndpoint 
sourceEndpoint)) {
+            if (metadata != null) {
+                metadata.rejectCopyClaim(
+                        "exactlyOnce source metadata cannot be claimed from a 
non-Kafka route or copied exchange");
+            }
+            throw new KafkaExactlyOnceMetadata.ClaimRejectedException(
+                    "exactlyOnce requires a Kafka consumer as the route 
source");
+        }
+
+        KafkaConfiguration source = sourceEndpoint.getConfiguration();
+        if (!source.isExactlyOnce() || !source.isAllowManualCommit()
+                || source.isAutoCommitEnable() || 
!source.isBreakOnFirstError()) {
+            if (metadata != null) {
+                metadata.rejectCopyClaim("exactlyOnce source endpoint 
configuration is incompatible with source metadata");
+            }
+            throw new KafkaExactlyOnceMetadata.ClaimRejectedException(
+                    "exactlyOnce requires the Kafka source endpoint to set 
exactlyOnce=true, "
+                                                                      + 
"allowManualCommit=true, autoCommitEnable=false and breakOnFirstError=true");
+        }
+
+        if (metadata == null) {
+            KafkaManualCommit manualCommit
+                    = 
exchange.getMessage().getHeader(KafkaConstants.MANUAL_COMMIT, 
KafkaManualCommit.class);
+            if (manualCommit == null) {
+                throw new IllegalStateException(
+                        "exactlyOnce requires source offset metadata; 
configure allowManualCommit=true "
+                                                + "on the Kafka source 
endpoint");
+            }
+            String sourceExchangeId = null;
+            if (manualCommit instanceof DefaultKafkaManualCommit defaultCommit
+                    && defaultCommit.getCamelExchangePayload() != null
+                    && defaultCommit.getCamelExchangePayload().exchange != 
null) {
+                sourceExchangeId = 
defaultCommit.getCamelExchangePayload().exchange.getExchangeId();
+            }
+            metadata = KafkaExactlyOnceMetadata.fromManualCommits(
+                    Collections.singletonList(manualCommit), sourceExchangeId);
+            exchange.setProperty(KafkaExactlyOnceMetadata.EXCHANGE_PROPERTY, 
metadata);
+        }
+
+        if (ObjectHelper.isNotEmpty(source.getGroupId())
+                && !Objects.equals(source.getGroupId(), 
metadata.getConsumerGroupMetadata().groupId())) {
+            String reason = "exactlyOnce source groupId does not match the 
consumer group metadata captured for the record";
+            metadata.rejectClaim(reason);
+            throw new IllegalStateException(reason);

Review Comment:
   ⚠️ **Bug: wrong exception type for `groupId` mismatch**
   
   Every other rejection path in `createExactlyOnceMetadata()` throws 
`ClaimRejectedException` (lines 764 and 774), but this path throws plain 
`IllegalStateException`. The consequence is silent misclassification: 
`recordSendFailure()` only filters out `ClaimRejectedException` — a plain 
`IllegalStateException` slips through and gets recorded as a Kafka send 
failure. `KafkaTransactionSynchronization.onDone()` then takes the offset-only 
commit path instead of rolling back and replaying.
   
   ```suggestion
               throw new 
KafkaExactlyOnceMetadata.ClaimRejectedException(reason);
   ```



-- 
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