gnodet-bot commented on code in PR #27495: URL: https://github.com/apache/camel/pull/27495#discussion_r4209067863
########## components/camel-kafka-share/src/main/java/org/apache/camel/component/kafka/share/KafkaShareFetchRecords.java: ########## @@ -0,0 +1,438 @@ +/* + * 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.kafka.share; + +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.camel.Exchange; +import org.apache.camel.Message; +import org.apache.camel.component.kafka.KafkaClientConfiguration; +import org.apache.camel.component.kafka.KafkaConsumerFatalException; +import org.apache.camel.component.kafka.KafkaReconnectSupport; +import org.apache.camel.component.kafka.PollOnError; +import org.apache.camel.component.kafka.TaskHealthState; +import org.apache.camel.component.kafka.consumer.support.KafkaRecordProcessor; +import org.apache.camel.support.BridgeExceptionHandlerToErrorHandler; +import org.apache.camel.support.task.BackgroundTask; +import org.apache.camel.support.task.TaskRunFailureException; +import org.apache.camel.util.ObjectHelper; +import org.apache.camel.util.TimeUtils; +import org.apache.kafka.clients.CommonClientConfigs; +import org.apache.kafka.clients.consumer.AcknowledgeType; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.ShareConsumer; +import org.apache.kafka.common.KafkaException; +import org.apache.kafka.common.TopicIdPartition; +import org.apache.kafka.common.errors.AuthenticationException; +import org.apache.kafka.common.errors.AuthorizationException; +import org.apache.kafka.common.errors.InterruptException; +import org.apache.kafka.common.errors.WakeupException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Polls one Kafka share consumer and routes its records. Every record of a poll is acknowledged on this thread, before + * the next poll, as the share consumer uses explicit acknowledgement. + */ +class KafkaShareFetchRecords implements Runnable { + + private static final Logger LOG = LoggerFactory.getLogger(KafkaShareFetchRecords.class); + + private final KafkaShareConsumer kafkaShareConsumer; + private final KafkaShareConfiguration configuration; + private final BridgeExceptionHandlerToErrorHandler bridge; + private final List<String> topics; + private final String threadId; + private final Properties kafkaProps; + private final RecordProcessor recordProcessor = new RecordProcessor(); + // held while processing the records of a poll, so that stop() waits for the records in flight + private final ReentrantLock lock = new ReentrantLock(); + + private ShareConsumer<Object, Object> consumer; + private ScheduledExecutorService reconnectPool; + + // read from other threads, such as the health check + private volatile String clientId; + private volatile boolean connected; + private volatile boolean terminated; + private volatile Exception lastError; + private volatile long currentBackoffInterval; + + KafkaShareFetchRecords(KafkaShareConsumer kafkaShareConsumer, BridgeExceptionHandlerToErrorHandler bridge, String id, + Properties kafkaProps) { + this.kafkaShareConsumer = kafkaShareConsumer; + this.configuration = kafkaShareConsumer.getEndpoint().getConfiguration(); + this.bridge = bridge; + this.topics = Arrays.stream(configuration.getTopic().split(",")).map(String::trim).filter(t -> !t.isEmpty()) + .toList(); + this.threadId = configuration.getTopic() + "-Thread " + id; + this.kafkaProps = kafkaProps; + } + + @Override + public void run() { + try { + while (isRunAllowed() && !terminated) { + if (!connected && !connect()) { + // gave up creating the share consumer + terminated = true; + break; + } + lastError = null; + pollAndProcess(); + } + LOG.info("Terminating Kafka share consumer thread {} receiving from {}", threadId, topics); + } finally { + closeConsumer(); + if (reconnectPool != null) { + kafkaShareConsumer.getEndpoint().getCamelContext().getExecutorServiceManager().shutdown(reconnectPool); + reconnectPool = null; + } + } + } + + /** + * Creates and subscribes the share consumer, retrying with the create consumer backoff of the component. + * + * @return false if it gave up + */ + private boolean connect() { + currentBackoffInterval = kafkaShareConsumer.getEndpoint().getComponent().getCreateConsumerBackoffInterval(); + int maxAttempts = kafkaShareConsumer.getEndpoint().getComponent().getCreateConsumerBackoffMaxAttempts(); + if (reconnectPool == null) { + reconnectPool = kafkaShareConsumer.getEndpoint().getCamelContext().getExecutorServiceManager() + .newSingleThreadScheduledExecutor(this, "KafkaShareReconnect"); + } + BackgroundTask task = KafkaReconnectSupport.createReconnectTask( + reconnectPool, "KafkaShareReconnect-" + configuration.getTopic(), maxAttempts, currentBackoffInterval); + boolean success = task.run(kafkaShareConsumer.getEndpoint().getCamelContext(), this::createConsumer); + if (!success) { + String msg = "Gave up creating/subscribing the Kafka share consumer " + threadId + " to " + topics + + " after " + maxAttempts + " attempts (elapsed: " + + TimeUtils.printDuration(task.elapsed(), true) + ")."; + LOG.warn(msg); + lastError = new KafkaConsumerFatalException(msg, lastError); + } + return success; + } + + private boolean createConsumer() { + if (!isRunAllowed()) { + // stop retrying when the consumer is stopping + return true; + } + ClassLoader threadClassLoader = Thread.currentThread().getContextClassLoader(); + try { + closeConsumer(); + // Kafka uses reflection for loading authentication settings, use its classloader + Thread.currentThread().setContextClassLoader(ShareConsumer.class.getClassLoader()); + + LOG.info("{} Kafka share consumer thread ID {} for share group {}", + consumer == null ? "Connecting" : "Reconnecting", + threadId, configuration.getGroupId()); + String krbLocation = configuration.getKerberosConfigLocation(); + if (krbLocation != null) { + System.setProperty("java.security.krb5.conf", krbLocation); + } + + ShareConsumer<Object, Object> created + = kafkaShareConsumer.getEndpoint().getKafkaShareClientFactory().getShareConsumer(kafkaProps); + consumer = created; Review Comment: Race condition: `consumer = created` (this line) makes the consumer visible to `stop()` before `created.subscribe(topics)` at line 170. If `stop()` runs in that window, it captures the non-null consumer and calls `wakeup()` on an unsubscribed consumer — the first `poll()` will throw `WakeupException` immediately, causing a spurious reconnect. Move the volatile assignment after `subscribe()` and `clientId` initialization: ```suggestion ShareConsumer<Object, Object> created = kafkaShareConsumer.getEndpoint().getKafkaShareClientFactory().getShareConsumer(kafkaProps); if (configuration.getCommitMode() == KafkaShareCommitMode.ASYNC) { created.setAcknowledgementCommitCallback(this::onAcknowledgementCommit); } created.subscribe(topics); if (clientId == null) { clientId = kafkaProps.getProperty(CommonClientConfigs.CLIENT_ID_CONFIG, ""); } consumer = created; connected = true; ``` ########## components/camel-kafka-share/src/main/java/org/apache/camel/component/kafka/share/KafkaShareConfiguration.java: ########## @@ -0,0 +1,343 @@ +/* + * 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.kafka.share; + +import java.util.List; +import java.util.Properties; + +import org.apache.camel.component.kafka.KafkaClientConfiguration; +import org.apache.camel.component.kafka.PollOnError; +import org.apache.camel.spi.Metadata; +import org.apache.camel.spi.UriParam; +import org.apache.camel.spi.UriParams; +import org.apache.camel.spi.UriPath; +import org.apache.kafka.clients.consumer.ConsumerConfig; + +@UriParams +public class KafkaShareConfiguration extends KafkaClientConfiguration { + + /** + * The value of share.acknowledgement.mode: every record is acknowledged by the consumer. + */ + static final String EXPLICIT_ACKNOWLEDGEMENT = "explicit"; + + /** + * The consumer group options that a share consumer rejects (see ShareConsumerConfig in kafka-clients). + */ + static final List<String> SHARE_GROUP_UNSUPPORTED_CONFIGS = List.of( + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, + ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, + ConsumerConfig.GROUP_INSTANCE_ID_CONFIG, + ConsumerConfig.ISOLATION_LEVEL_CONFIG, + ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG, + ConsumerConfig.INTERCEPTOR_CLASSES_CONFIG, + ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, + ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, + ConsumerConfig.GROUP_PROTOCOL_CONFIG, + ConsumerConfig.GROUP_REMOTE_ASSIGNOR_CONFIG); + + @UriPath(label = "common") + @Metadata(required = true) + private String topic; + @UriParam(label = "consumer") + @Metadata(required = true) + private String groupId; + @UriParam(label = "consumer", defaultValue = "1") + private int consumersCount = 1; + @UriParam(label = "consumer", defaultValue = "5000", javaType = "java.time.Duration") + private Long pollTimeoutMs = 5000L; + // max.poll.records + @UriParam(label = "consumer", defaultValue = "500") + private Integer maxPollRecords = 500; + // share.acquire.mode + @UriParam(label = "consumer,advanced", defaultValue = "batch_optimized", enums = "batch_optimized,record_limit") + private String acquireMode = "batch_optimized"; + // fetch.min.bytes + @UriParam(label = "consumer", defaultValue = "1") + private Integer fetchMinBytes = 1; + // fetch.max.bytes + @UriParam(label = "consumer", defaultValue = "52428800") + private Integer fetchMaxBytes = 50 * 1024 * 1024; + // fetch.max.wait.ms + @UriParam(label = "consumer", defaultValue = "500") + private Integer fetchWaitMaxMs = 500; + // max.partition.fetch.bytes + @UriParam(label = "consumer", defaultValue = "1048576") + private Integer maxPartitionFetchBytes = 1048576; + // request.timeout.ms + @UriParam(label = "consumer", defaultValue = "30000") + private Integer consumerRequestTimeoutMs = 30000; + // check.crcs + @UriParam(label = "consumer", defaultValue = "true") + private Boolean checkCrcs = true; + @UriParam(label = "consumer", defaultValue = "RELEASE", enums = "ACCEPT,RELEASE,REJECT") + private KafkaShareAcknowledgeType onFailure = KafkaShareAcknowledgeType.RELEASE; + @UriParam(label = "consumer", defaultValue = "SYNC", enums = "SYNC,ASYNC") + private KafkaShareCommitMode commitMode = KafkaShareCommitMode.SYNC; + @UriParam(label = "consumer", defaultValue = "5000", javaType = "java.time.Duration") + private Long commitTimeoutMs = 5000L; + @UriParam(label = "consumer", defaultValue = "ERROR_HANDLER", enums = "DISCARD,ERROR_HANDLER,RECONNECT,RETRY,STOP") + private PollOnError pollOnError = PollOnError.ERROR_HANDLER; Review Comment: The `pollOnError` options `RETRY` and `DISCARD` are semantically different in `camel-kafka` (record-level retry vs. silent discard), but for share consumers both fall through to the `default` branch in `handlePollException()` (poll again) — there is no record to retry or discard when `poll()` itself fails. Worth adding a note in the setter Javadoc so operators know `RETRY` does not provide record-level retry semantics here. -- 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]
