This is an automated email from the ASF dual-hosted git repository.
MartijnVisser pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-connector-kafka.git
The following commit(s) were added to refs/heads/main by this push:
new 9c0ccf3b [FLINK-36434][Connectors/Kafka] Create the KafkaConsumer
lazily on the split fetcher thread
9c0ccf3b is described below
commit 9c0ccf3b1b52ccd98ccd9cd714330a522b19850b
Author: Edson <[email protected]>
AuthorDate: Sun Sep 20 02:01:40 2026 -0400
[FLINK-36434][Connectors/Kafka] Create the KafkaConsumer lazily on the
split fetcher thread
Create the consumer on the first consumer-touching call instead of in the
constructor, which by construction happens on the fetcher thread.
Generated-by: Claude Code (Opus 5)
---
.../source/reader/KafkaPartitionSplitReader.java | 118 ++++++++++--
...rtitionSplitReaderLazyConsumerCreationTest.java | 211 +++++++++++++++++++++
2 files changed, 308 insertions(+), 21 deletions(-)
diff --git
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/source/reader/KafkaPartitionSplitReader.java
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/source/reader/KafkaPartitionSplitReader.java
index bf8b0a97..b87ce1ce 100644
---
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/source/reader/KafkaPartitionSplitReader.java
+++
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/source/reader/KafkaPartitionSplitReader.java
@@ -65,7 +65,7 @@ public class KafkaPartitionSplitReader
implements SplitReader<ConsumerRecord<byte[], byte[]>,
KafkaPartitionSplit> {
private static final Logger LOG =
LoggerFactory.getLogger(KafkaPartitionSplitReader.class);
- private final KafkaConsumer<byte[], byte[]> consumer;
+ private final Properties consumerProps;
private final Map<TopicPartition, Long> stoppingOffsets;
private final String groupId;
private final int subtaskId;
@@ -73,6 +73,16 @@ public class KafkaPartitionSplitReader
private final KafkaSourceReaderMetrics kafkaSourceReaderMetrics;
+ /**
+ * The Kafka consumer. Created lazily on the first thread that actually
uses it — the split
+ * fetcher thread — rather than on the thread constructing this reader
(the source-reader or
+ * checkpoint thread). {@link KafkaConsumer} is not thread-safe, so it
must live on the thread
+ * that uses it (FLINK-36434). Only ever written by {@link
#ensureConsumer()} on the fetcher
+ * thread; {@code volatile} solely for the cross-thread reads in {@link
#wakeUp()} and {@link
+ * #close()}.
+ */
+ @Nullable private volatile KafkaConsumer<byte[], byte[]> consumer;
+
// Tracking empty splits that has not been added to finished splits in
fetch()
private final Set<String> emptySplits = new HashSet<>();
@@ -90,23 +100,58 @@ public class KafkaPartitionSplitReader
String rackIdSupplier) {
this.subtaskId = context.getIndexOfSubtask();
this.kafkaSourceReaderMetrics = kafkaSourceReaderMetrics;
- // Parsed before creating the consumer so that an invalid value does
not leak a consumer.
+ // Parsed eagerly so that an invalid value fails at construction
rather than on the
+ // fetcher thread when the consumer is first used.
this.pollTimeout = parsePollTimeout(props);
Properties consumerProps = new Properties();
consumerProps.putAll(props);
consumerProps.setProperty(ConsumerConfig.CLIENT_ID_CONFIG,
createConsumerClientId(props));
setConsumerClientRack(consumerProps, rackIdSupplier);
- this.consumer = new KafkaConsumer<>(consumerProps);
+ this.consumerProps = consumerProps;
this.stoppingOffsets = new HashMap<>();
this.groupId =
consumerProps.getProperty(ConsumerConfig.GROUP_ID_CONFIG);
+ }
- // Metric registration
- maybeRegisterKafkaConsumerMetrics(props, kafkaSourceReaderMetrics,
consumer);
- this.kafkaSourceReaderMetrics.registerNumBytesIn(consumer);
+ /**
+ * Returns the consumer, creating it on the calling thread on first use.
+ *
+ * <p>The reader is constructed on the source-reader (or, when a fetcher
is re-created for an
+ * offset commit, the checkpoint) thread, while the consumer is used
almost exclusively on the
+ * split fetcher thread. Creating the consumer eagerly in the constructor
therefore puts it on
+ * the wrong thread. All consumer-touching {@link SplitReader} methods run
on the fetcher
+ * thread, so deferring creation to the first such call keeps construction
and use on the same
+ * thread. The only cross-thread entry point remains {@link #wakeUp()},
which is the one call
+ * {@link KafkaConsumer} documents as thread-safe.
+ */
+ private KafkaConsumer<byte[], byte[]> ensureConsumer() {
+ // Single-writer: only the fetcher thread calls this, so the creation
branch needs no
+ // guard; the field is volatile solely for the cross-thread reads in
wakeUp() and close().
+ KafkaConsumer<byte[], byte[]> currentConsumer = this.consumer;
+ if (currentConsumer == null) {
+ currentConsumer = createConsumer(consumerProps);
+ // Published before metric registration: if registration throws,
close() still sees
+ // the consumer and releases it instead of leaking it.
+ this.consumer = currentConsumer;
+ maybeRegisterKafkaConsumerMetrics(
+ consumerProps, kafkaSourceReaderMetrics, currentConsumer);
+ kafkaSourceReaderMetrics.registerNumBytesIn(currentConsumer);
+ }
+ return currentConsumer;
+ }
+
+ /**
+ * Creates the {@link KafkaConsumer}. Overridable by same-package tests to
observe creation.
+ *
+ * <p>Deliberately not annotated with {@code @VisibleForTesting}: the
freezing ArchUnit rule
+ * flags new uses of the internal annotation in connector production code.
+ */
+ KafkaConsumer<byte[], byte[]> createConsumer(Properties consumerProps) {
+ return new KafkaConsumer<>(consumerProps);
}
@Override
public RecordsWithSplitIds<ConsumerRecord<byte[], byte[]>> fetch() throws
IOException {
+ final KafkaConsumer<byte[], byte[]> consumer = ensureConsumer();
ConsumerRecords<byte[], byte[]> consumerRecords;
try {
consumerRecords = consumer.poll(pollTimeout);
@@ -126,7 +171,8 @@ public class KafkaPartitionSplitReader
List<TopicPartition> finishedPartitions = new ArrayList<>();
for (TopicPartition tp : consumer.assignment()) {
long stoppingOffset = getStoppingOffset(tp);
- long consumerPosition = getConsumerPosition(tp, "retrieving
consumer position");
+ long consumerPosition =
+ getConsumerPosition(consumer, tp, "retrieving consumer
position");
// Stop fetching when the consumer's position reaches the
stoppingOffset.
// Control messages may follow the last record; therefore, using
the last record's
// offset as a stopping condition could result in indefinite
blocking.
@@ -155,7 +201,7 @@ public class KafkaPartitionSplitReader
// Unassign the partitions that has finished.
if (!finishedPartitions.isEmpty()) {
finishedPartitions.forEach(kafkaSourceReaderMetrics::removeRecordsLagMetric);
- unassignPartitions(finishedPartitions);
+ unassignPartitions(consumer, finishedPartitions);
}
// Update numBytesIn
@@ -182,6 +228,7 @@ public class KafkaPartitionSplitReader
"The SplitChange type of %s is not supported.",
splitsChange.getClass()));
}
+ final KafkaConsumer<byte[], byte[]> consumer = ensureConsumer();
// Assignment.
List<TopicPartition> newPartitionAssignments = new ArrayList<>();
@@ -216,32 +263,51 @@ public class KafkaPartitionSplitReader
// Seek on the newly assigned partitions to their stating offsets.
seekToStartingOffsets(
+ consumer,
partitionsStartingFromEarliest,
partitionsStartingFromLatest,
partitionsStartingFromSpecifiedOffsets);
// Setup the stopping offsets.
- acquireAndSetStoppingOffsets(partitionsStoppingAtLatest,
partitionsStoppingAtCommitted);
+ acquireAndSetStoppingOffsets(
+ consumer, partitionsStoppingAtLatest,
partitionsStoppingAtCommitted);
// After acquiring the starting and stopping offsets, remove the empty
splits if necessary.
- removeEmptySplits();
+ removeEmptySplits(consumer);
- maybeLogSplitChangesHandlingResult(splitsChange);
+ maybeLogSplitChangesHandlingResult(consumer, splitsChange);
}
@Override
public void wakeUp() {
- consumer.wakeup();
+ // The only method called from a thread other than the fetcher thread,
relying on
+ // KafkaConsumer.wakeup() being the one documented thread-safe
consumer call.
+ //
+ // wakeUp() only ever targets a running fetch task (see
SplitReader#wakeUp javadoc), which
+ // the base SplitFetcher can only schedule after handleSplitsChanges()
— and therefore
+ // ensureConsumer() — has run at least once on the fetcher thread. So
the consumer is
+ // always non-null here in practice; a pre-creation wakeup has nothing
to interrupt and is
+ // safely dropped. Note this leans on an implementation invariant of
+ // SplitFetcher/AddSplitsTask rather than a documented contract beyond
wakeUp()'s own
+ // javadoc scoping.
+ KafkaConsumer<byte[], byte[]> currentConsumer = this.consumer;
+ if (currentConsumer != null) {
+ currentConsumer.wakeup();
+ }
}
@Override
public void close() throws Exception {
- consumer.close();
+ KafkaConsumer<byte[], byte[]> currentConsumer = this.consumer;
+ if (currentConsumer != null) {
+ currentConsumer.close();
+ }
}
@Override
public void pauseOrResumeSplits(
Collection<KafkaPartitionSplit> splitsToPause,
Collection<KafkaPartitionSplit> splitsToResume) {
+ final KafkaConsumer<byte[], byte[]> consumer = ensureConsumer();
// Filter against current assignment to avoid IllegalStateException
when a partition
// was concurrently unassigned by fetch() or removeEmptySplits().
Set<TopicPartition> assigned = consumer.assignment();
@@ -272,12 +338,12 @@ public class KafkaPartitionSplitReader
public void notifyCheckpointComplete(
Map<TopicPartition, OffsetAndMetadata> offsetsToCommit,
OffsetCommitCallback offsetCommitCallback) {
- consumer.commitAsync(offsetsToCommit, offsetCommitCallback);
+ ensureConsumer().commitAsync(offsetsToCommit, offsetCommitCallback);
}
@VisibleForTesting
KafkaConsumer<byte[], byte[]> consumer() {
- return consumer;
+ return ensureConsumer();
}
@VisibleForTesting
@@ -316,7 +382,10 @@ public class KafkaPartitionSplitReader
}
}
- long getConsumerPosition(TopicPartition tp, String msg) {
+ // The helpers below take the consumer their entry point (fetch() /
handleSplitsChanges())
+ // already ensured, so consumer creation stays confined to the SplitReader
entry points.
+ private long getConsumerPosition(
+ KafkaConsumer<byte[], byte[]> consumer, TopicPartition tp, String
msg) {
return retryOnWakeup(() -> consumer.position(tp), msg);
}
@@ -364,6 +433,7 @@ public class KafkaPartitionSplitReader
}
private void seekToStartingOffsets(
+ KafkaConsumer<byte[], byte[]> consumer,
List<TopicPartition> partitionsStartingFromEarliest,
List<TopicPartition> partitionsStartingFromLatest,
Map<TopicPartition, Long> partitionsStartingFromSpecifiedOffsets) {
@@ -387,6 +457,7 @@ public class KafkaPartitionSplitReader
}
private void acquireAndSetStoppingOffsets(
+ KafkaConsumer<byte[], byte[]> consumer,
List<TopicPartition> partitionsStoppingAtLatest,
Set<TopicPartition> partitionsStoppingAtCommitted) {
Map<TopicPartition, Long> endOffset =
consumer.endOffsets(partitionsStoppingAtLatest);
@@ -408,12 +479,13 @@ public class KafkaPartitionSplitReader
}
}
- private void removeEmptySplits() {
+ private void removeEmptySplits(KafkaConsumer<byte[], byte[]> consumer) {
List<TopicPartition> emptyPartitions = new ArrayList<>();
// If none of the partitions have any records,
for (TopicPartition tp : consumer.assignment()) {
long startingOffset =
- getConsumerPosition(tp, "getting starting offset to check
if split is empty");
+ getConsumerPosition(
+ consumer, tp, "getting starting offset to check if
split is empty");
if (startingOffset >= getStoppingOffset(tp)) {
emptyPartitions.add(tp);
}
@@ -428,11 +500,12 @@ public class KafkaPartitionSplitReader
.map(KafkaPartitionSplit::toSplitId)
.collect(Collectors.toSet()));
// Un-assign partitions from Kafka consumer
- unassignPartitions(emptyPartitions);
+ unassignPartitions(consumer, emptyPartitions);
}
}
private void maybeLogSplitChangesHandlingResult(
+ KafkaConsumer<byte[], byte[]> consumer,
SplitsChange<KafkaPartitionSplit> splitsChange) {
if (LOG.isDebugEnabled()) {
StringJoiner splitsInfo = new StringJoiner(",");
@@ -443,7 +516,8 @@ public class KafkaPartitionSplitReader
}
long startingOffset =
- getConsumerPosition(split.getTopicPartition(),
"logging starting position");
+ getConsumerPosition(
+ consumer, split.getTopicPartition(), "logging
starting position");
long stoppingOffset =
getStoppingOffset(split.getTopicPartition());
splitsInfo.add(
@@ -455,7 +529,9 @@ public class KafkaPartitionSplitReader
}
}
- private void unassignPartitions(Collection<TopicPartition>
partitionsToUnassign) {
+ private void unassignPartitions(
+ KafkaConsumer<byte[], byte[]> consumer,
+ Collection<TopicPartition> partitionsToUnassign) {
Collection<TopicPartition> newAssignment = new
HashSet<>(consumer.assignment());
newAssignment.removeAll(partitionsToUnassign);
consumer.assign(newAssignment);
diff --git
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/source/reader/KafkaPartitionSplitReaderLazyConsumerCreationTest.java
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/source/reader/KafkaPartitionSplitReaderLazyConsumerCreationTest.java
new file mode 100644
index 00000000..93d6649d
--- /dev/null
+++
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/source/reader/KafkaPartitionSplitReaderLazyConsumerCreationTest.java
@@ -0,0 +1,211 @@
+/*
+ * 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.flink.connector.kafka.source.reader;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.connector.kafka.source.KafkaSourceOptions;
+import
org.apache.flink.connector.kafka.source.metrics.KafkaSourceReaderMetrics;
+import org.apache.flink.connector.testutils.source.reader.TestingReaderContext;
+import org.apache.flink.metrics.groups.UnregisteredMetricsGroup;
+
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.clients.consumer.KafkaConsumer;
+import org.apache.kafka.common.serialization.ByteArrayDeserializer;
+import org.junit.jupiter.api.Test;
+
+import java.util.Properties;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for FLINK-36434: the {@link KafkaConsumer} inside {@link
KafkaPartitionSplitReader}
+ * must be created lazily on the thread that uses it (the split fetcher
thread), not on the thread
+ * that constructs the reader (the source-reader or checkpoint thread),
because the consumer is not
+ * thread-safe.
+ *
+ * <p>These tests need no running Kafka cluster: constructing a {@link
KafkaConsumer} performs no
+ * network I/O, and {@link KafkaPartitionSplitReader#fetch()} tolerates the
{@code
+ * IllegalStateException}/{@code WakeupException} that polling without an
assignment raises.
+ */
+class KafkaPartitionSplitReaderLazyConsumerCreationTest {
+
+ /** Records the thread the consumer was created on and every {@code
wakeup()} call. */
+ private static class CreationRecordingReader extends
KafkaPartitionSplitReader {
+ private final AtomicReference<Thread> creationThread = new
AtomicReference<>();
+ private final AtomicInteger wakeups = new AtomicInteger();
+
+ private final AtomicInteger closes = new AtomicInteger();
+
+ CreationRecordingReader() {
+ this(
+ new KafkaSourceReaderMetrics(
+
UnregisteredMetricsGroup.createSourceReaderMetricGroup()));
+ }
+
+ CreationRecordingReader(KafkaSourceReaderMetrics metrics) {
+ super(
+ testProperties(),
+ new TestingReaderContext(
+ new Configuration(),
+
UnregisteredMetricsGroup.createSourceReaderMetricGroup()),
+ metrics);
+ }
+
+ @Override
+ KafkaConsumer<byte[], byte[]> createConsumer(Properties consumerProps)
{
+ creationThread.set(Thread.currentThread());
+ return new KafkaConsumer<byte[], byte[]>(consumerProps) {
+ @Override
+ public void wakeup() {
+ wakeups.incrementAndGet();
+ super.wakeup();
+ }
+
+ @Override
+ public void close() {
+ closes.incrementAndGet();
+ super.close();
+ }
+ };
+ }
+ }
+
+ /** Metrics whose consumer registration fails, standing in for a broken
metric group. */
+ private static class ThrowingRegistrationMetrics extends
KafkaSourceReaderMetrics {
+ ThrowingRegistrationMetrics() {
+ super(UnregisteredMetricsGroup.createSourceReaderMetricGroup());
+ }
+
+ @Override
+ public void registerNumBytesIn(KafkaConsumer<?, ?> consumer) {
+ throw new IllegalStateException("metric registration failed");
+ }
+ }
+
+ private static Properties testProperties() {
+ Properties props = new Properties();
+ // Never contacted: the consumer performs no network I/O until the
first poll-style call.
+ props.setProperty(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,
"localhost:1");
+ props.setProperty(ConsumerConfig.GROUP_ID_CONFIG, "test-group");
+ // Keep close() from attempting an offset auto-commit against the
unreachable broker.
+ props.setProperty(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
+ props.setProperty(
+ ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
+ ByteArrayDeserializer.class.getName());
+ props.setProperty(
+ ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
+ ByteArrayDeserializer.class.getName());
+ props.setProperty(KafkaSourceOptions.CLIENT_ID_PREFIX.key(),
"test-client");
+ return props;
+ }
+
+ @Test
+ void testConsumerIsCreatedLazilyOnTheUsingThread() throws Exception {
+ CreationRecordingReader reader = new CreationRecordingReader();
+
+ // Constructing the reader (source-reader / checkpoint thread) must
not create a consumer.
+ assertThat(reader.creationThread.get()).isNull();
+
+ // First use on a different thread (standing in for the split fetcher
thread).
+ Thread useThread =
+ CompletableFuture.runAsync(
+ () -> {
+ try {
+ reader.fetch();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ })
+ .thenApply(ignored -> reader.creationThread.get())
+ .get();
+
+ assertThat(useThread).isNotNull();
+ assertThat(useThread).isNotSameAs(Thread.currentThread());
+ reader.close();
+ }
+
+ @Test
+ void testWakeUpBeforeConsumerCreationIsSafelyDropped() throws Exception {
+ CreationRecordingReader reader = new CreationRecordingReader();
+
+ // wakeUp() before the consumer exists: no NPE, no consumer created.
The base
+ // SplitFetcher only wakes a running fetch task, which cannot exist
before
+ // handleSplitsChanges() has created the consumer, so a pre-creation
wakeup has
+ // nothing to interrupt and is dropped rather than deferred.
+ assertThatCode(reader::wakeUp).doesNotThrowAnyException();
+ assertThat(reader.creationThread.get()).isNull();
+ assertThat(reader.wakeups.get()).isZero();
+
+ // First use creates the consumer; the dropped wakeup is NOT replayed
against it.
+ CompletableFuture.runAsync(
+ () -> {
+ try {
+ reader.fetch();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ })
+ .get();
+
+ assertThat(reader.creationThread.get()).isNotNull();
+ assertThat(reader.wakeups.get()).isZero();
+ reader.close();
+ }
+
+ @Test
+ void testWakeUpAfterCreationDelegatesDirectly() throws Exception {
+ CreationRecordingReader reader = new CreationRecordingReader();
+ // Trigger creation on this thread via the test accessor.
+ reader.consumer();
+ assertThat(reader.wakeups.get()).isZero();
+
+ reader.wakeUp();
+ assertThat(reader.wakeups.get()).isEqualTo(1);
+ reader.close();
+ }
+
+ @Test
+ void testCloseWithoutUseNeverCreatesConsumer() {
+ CreationRecordingReader reader = new CreationRecordingReader();
+ assertThatCode(reader::close).doesNotThrowAnyException();
+ assertThat(reader.creationThread.get()).isNull();
+ }
+
+ @Test
+ void testConsumerIsReleasedWhenMetricRegistrationFails() throws Exception {
+ CreationRecordingReader reader =
+ new CreationRecordingReader(new ThrowingRegistrationMetrics());
+
+ // The consumer is published before metric registration, so the
failure surfaces to the
+ // caller but the created consumer is still owned by the reader...
+ assertThatThrownBy(reader::consumer)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("metric registration failed");
+ assertThat(reader.creationThread.get()).isNotNull();
+
+ // ...and close() releases it instead of leaking it.
+ reader.close();
+ assertThat(reader.closes.get()).isEqualTo(1);
+ }
+}