This is an automated email from the ASF dual-hosted git repository.
stankiewicz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git
The following commit(s) were added to refs/heads/master by this push:
new 0b6d68278b8 [KafkaIO] Use consumer position and lag to estimate end
offsets in KafkaUnboundedReader (#39830)
0b6d68278b8 is described below
commit 0b6d68278b878f674f9a8aafe769cdc408a99391
Author: Steven van Rossum <[email protected]>
AuthorDate: Tue Oct 6 11:41:12 2026 +0200
[KafkaIO] Use consumer position and lag to estimate end offsets in
KafkaUnboundedReader (#39830)
* Use currentLag and position to set end offset estimate
* Replace broken synchronized reads/writes with atomic reads/writes
---
.../beam/sdk/io/kafka/KafkaUnboundedReader.java | 181 +++++++++++----------
.../org/apache/beam/sdk/io/kafka/KafkaIOTest.java | 27 ---
.../org/apache/beam/sdk/io/kafka/KafkaMocks.java | 37 -----
3 files changed, 95 insertions(+), 150 deletions(-)
diff --git
a/sdks/java/io/kafka/src/main/java/org/apache/beam/sdk/io/kafka/KafkaUnboundedReader.java
b/sdks/java/io/kafka/src/main/java/org/apache/beam/sdk/io/kafka/KafkaUnboundedReader.java
index c3aa6f5418b..5e18ad83e2a 100644
---
a/sdks/java/io/kafka/src/main/java/org/apache/beam/sdk/io/kafka/KafkaUnboundedReader.java
+++
b/sdks/java/io/kafka/src/main/java/org/apache/beam/sdk/io/kafka/KafkaUnboundedReader.java
@@ -34,12 +34,12 @@ import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
-import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.atomic.AtomicReferenceFieldUpdater;
import java.util.stream.Collectors;
import org.apache.beam.sdk.io.UnboundedSource;
import org.apache.beam.sdk.io.UnboundedSource.CheckpointMark;
@@ -64,7 +64,9 @@ import org.apache.kafka.clients.consumer.Consumer;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
+import org.apache.kafka.clients.consumer.InvalidOffsetException;
import org.apache.kafka.clients.consumer.OffsetAndMetadata;
+import org.apache.kafka.common.KafkaException;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.errors.WakeupException;
import org.apache.kafka.common.serialization.Deserializer;
@@ -145,18 +147,6 @@ class KafkaUnboundedReader<K, V> extends
UnboundedReader<KafkaRecord<K, V>> {
// Note that consumer is not thread safe, should not be accessed out side
consumerPollLoop().
consumerPollThread.submit(this::consumerPollLoop);
- // offsetConsumer setup :
- Map<String, Object> offsetConsumerConfig =
- KafkaIOUtils.getOffsetConsumerConfig(
- name, spec.getOffsetConsumerConfig(), spec.getConsumerConfig());
-
- offsetConsumer = spec.getConsumerFactoryFn().apply(offsetConsumerConfig);
-
- // Fetch offsets once before running periodically.
- updateLatestOffsets();
-
- offsetFetcherThread.scheduleAtFixedRate(
- this::updateLatestOffsets, 0, OFFSET_UPDATE_INTERVAL_SECONDS,
TimeUnit.SECONDS);
return advance();
}
@@ -210,7 +200,7 @@ class KafkaUnboundedReader<K, V> extends
UnboundedReader<KafkaRecord<K, V>> {
int recordSize =
(rawRecord.key() == null ? 0 : rawRecord.key().length)
+ (rawRecord.value() == null ? 0 : rawRecord.value().length);
- pState.recordConsumed(rawRecord.offset(), recordSize);
+ pState.recordConsumed(rawRecord.offset(), recordSize, Instant.now());
bytesRead.inc(recordSize);
bytesReadBySplit.inc(recordSize);
@@ -409,16 +399,6 @@ class KafkaUnboundedReader<K, V> extends
UnboundedReader<KafkaRecord<K, V>> {
private AtomicBoolean closed = new AtomicBoolean(false);
private Instant nextAllowedCommitFailLogTime = Instant.ofEpochMilli(0);
- // Backlog support :
- // Kafka consumer does not have an API to fetch latest offset for topic. We
need to seekToEnd()
- // then look at position(). Use another consumer to do this so that the
primary consumer does
- // not need to be interrupted. The latest offsets are fetched periodically
on a thread. This is
- // still a bit of a hack, but so far there haven't been any issues reported
by the users.
- private @Nullable Consumer<byte[], byte[]> offsetConsumer = null;
- private final ScheduledExecutorService offsetFetcherThread =
- Executors.newSingleThreadScheduledExecutor();
- private static final int OFFSET_UPDATE_INTERVAL_SECONDS = 1;
-
private static final long UNINITIALIZED_OFFSET = -1;
/** watermark before any records have been read. */
@@ -457,25 +437,35 @@ class KafkaUnboundedReader<K, V> extends
UnboundedReader<KafkaRecord<K, V>> {
// maintains state of each assigned partition (buffered records, consumed
offset, etc)
private static class PartitionState<K, V> {
+ @SuppressWarnings("rawtypes")
+ private static final AtomicReferenceFieldUpdater<PartitionState, Instant>
LAST_UPDATED =
+ AtomicReferenceFieldUpdater.newUpdater(PartitionState.class,
Instant.class, "lastUpdated");
+
private final TopicPartition topicPartition;
+ // Note that nextOffset and latestOffset are updated on different threads.
+ // Writes are ordered by a subsequent write of lastUpdated.
+ // Reads are ordered by a preceding read of lastUpdated.
private long nextOffset;
private long latestOffset;
- private Instant latestOffsetFetchTime;
+ private volatile Instant lastUpdated;
private Instant lastWatermark; // As returned by timestampPolicy
private final TimestampPolicy<K, V> timestampPolicy;
private Iterator<ConsumerRecord<byte[], byte[]>> recordIter =
Collections.emptyIterator();
- private KafkaIOUtils.MovingAvg avgRecordSize = new
KafkaIOUtils.MovingAvg();
+ // Note that avgRecordSize is indepedently synchronized, but also needs to
be ordered in
+ // relation to lastUpdated to avoid observing independent states.
+ private final KafkaIOUtils.MovingAvg avgRecordSize = new
KafkaIOUtils.MovingAvg();
PartitionState(
TopicPartition partition, long nextOffset, TimestampPolicy<K, V>
timestampPolicy) {
this.topicPartition = partition;
this.nextOffset = nextOffset;
this.latestOffset = UNINITIALIZED_OFFSET;
- this.latestOffsetFetchTime = BoundedWindow.TIMESTAMP_MIN_VALUE;
this.lastWatermark = BoundedWindow.TIMESTAMP_MIN_VALUE;
this.timestampPolicy = timestampPolicy;
+ this.lastUpdated =
+ BoundedWindow.TIMESTAMP_MIN_VALUE; // volatile store (sequentially
consistent release)
}
public TopicPartition topicPartition() {
@@ -483,41 +473,51 @@ class KafkaUnboundedReader<K, V> extends
UnboundedReader<KafkaRecord<K, V>> {
}
// Update consumedOffset and avgRecordSize
- void recordConsumed(long offset, int size) {
- nextOffset = offset + 1;
- avgRecordSize.update(size);
+ void recordConsumed(long offset, int size, Instant consumeTime) {
+ nextOffset = offset + 1; // normal store
+ avgRecordSize.update(size); // independent ordered store (release)
+ LAST_UPDATED.lazySet(this, consumeTime); // ordered store (release)
}
- synchronized void setLatestOffset(long latestOffset, Instant fetchTime) {
- this.latestOffset = latestOffset;
- this.latestOffsetFetchTime = fetchTime;
- LOG.debug(
- "{}: latest offset update for {} : {} (consumer offset {}, avg
record size {})",
- this,
- topicPartition,
- latestOffset,
- nextOffset,
- avgRecordSize);
+ void setLatestOffset(long latestOffset, Instant fetchTime) {
+ this.latestOffset = latestOffset; // normal store
+ LAST_UPDATED.lazySet(this, fetchTime); // ordered store (release)
}
- synchronized long approxBacklogInBytes() {
+ long approxBacklogInBytes() {
// Note that is an estimate of uncompressed backlog.
long backlogMessageCount = backlogMessageCount();
if (backlogMessageCount == UnboundedReader.BACKLOG_UNKNOWN) {
return UnboundedReader.BACKLOG_UNKNOWN;
}
- return (long) (backlogMessageCount * avgRecordSize.get());
+ return (long)
+ (backlogMessageCount * avgRecordSize.get()); // independent volatile
load (acquire)
+ }
+
+ long backlogMessageCount() {
+ return backlogMessageCountInternal(this.lastUpdated); // volatile load
(acquire)
}
- synchronized long backlogMessageCount() {
- if (latestOffset < 0 || nextOffset < 0 || latestOffset < nextOffset) {
+ // This is ugly, but needed because mkTimestampPolicyContext would
otherwise issue two volatile
+ // reads that could be observed as holding different values.
+ // Note that the argument must always be the result of acquiring the
member lastUpdated.
+ @SuppressWarnings("ReferenceEquality")
+ private long backlogMessageCountInternal(Instant lastUpdated) {
+ final long nextOffset = this.nextOffset; // normal load
+ final long latestOffset = this.latestOffset; // normal load
+
+ if (lastUpdated == BoundedWindow.TIMESTAMP_MIN_VALUE
+ || latestOffset < 0
+ || nextOffset < 0
+ || latestOffset < nextOffset) {
return UnboundedReader.BACKLOG_UNKNOWN;
}
return latestOffset - nextOffset;
}
- synchronized TimestampPolicyContext mkTimestampPolicyContext() {
- return new TimestampPolicyContext(backlogMessageCount(),
latestOffsetFetchTime);
+ TimestampPolicyContext mkTimestampPolicyContext() {
+ final Instant lastUpdated = this.lastUpdated; // volatile load (acquire)
+ return new
TimestampPolicyContext(backlogMessageCountInternal(lastUpdated), lastUpdated);
}
Instant updateAndGetWatermark() {
@@ -586,9 +586,30 @@ class KafkaUnboundedReader<K, V> extends
UnboundedReader<KafkaRecord<K, V>> {
}
private void consumerPollLoop() {
- // Read in a loop and enqueue the batch of records, if any, to
availableRecordsQueue.
Consumer<byte[], byte[]> consumer =
Preconditions.checkStateNotNull(this.consumer);
+ List<TopicPartition> topicPartitions =
+ Preconditions.checkStateNotNull(source.getSpec().getTopicPartitions());
+
+ // Set initial latest offset for each partition.
+ try {
+ Instant fetchTime = Instant.now();
+ Map<TopicPartition, Long> endOffsets =
consumer.endOffsets(topicPartitions);
+ for (PartitionState<K, V> p : partitionStates) {
+ p.setLatestOffset(
+ endOffsets.getOrDefault(p.topicPartition, UNINITIALIZED_OFFSET),
fetchTime);
+ }
+ } catch (KafkaException e) {
+ if (!closed.get()) { // Ignore the exception if the reader is closed.
+ LOG.warn(
+ "{}: exception while fetching latest offset for partitions {}.
will be retried.",
+ this,
+ topicPartitions,
+ e);
+ }
+ // Don't update the latest offset.
+ }
+ // Read in a loop and enqueue the batch of records, if any, to
availableRecordsQueue.
try {
ConsumerRecords<byte[], byte[]> records = ConsumerRecords.empty();
while (!closed.get()) {
@@ -602,6 +623,32 @@ class KafkaUnboundedReader<K, V> extends
UnboundedReader<KafkaRecord<K, V>> {
kafkaTopic,
java.time.Duration.ofMillis(stopwatch.elapsed(TimeUnit.MILLISECONDS)));
}
+
+ // Fetch the current offsets and update the latest offset.
+ Instant fetchTime = Instant.now();
+ for (PartitionState<K, V> pState : partitionStates) {
+ try {
+ final long position = consumer.position(pState.topicPartition);
+ consumer
+ .currentLag(pState.topicPartition)
+ .ifPresent(lag -> pState.setLatestOffset(position + lag,
fetchTime));
+ } catch (InvalidOffsetException e) {
+ // The position is undefined or out of range.
+ pState.setLatestOffset(UNINITIALIZED_OFFSET, fetchTime);
+ } catch (KafkaException e) {
+ if (!closed.get()) { // Ignore the exception if the reader is
closed.
+ LOG.warn(
+ "{}: exception while fetching latest offset for
partitions {}. will be retried.",
+ this,
+ topicPartitions,
+ e);
+ }
+ // Wakeups, interrupts, authentication failures, authorization
failures, timeouts
+ // and unrecoverable errors can be ignored. This routine is
reattempted on every
+ // iteration and fallback methods would likely trigger the
same unrecoverable
+ // errors.
+ }
+ }
} else if (availableRecordsQueue.offer(
records, RECORDS_ENQUEUE_POLL_TIMEOUT.getMillis(),
TimeUnit.MILLISECONDS)) {
records = ConsumerRecords.empty();
@@ -789,37 +836,6 @@ class KafkaUnboundedReader<K, V> extends
UnboundedReader<KafkaRecord<K, V>> {
}
}
- // Update latest offset for each partition.
- // Called from setupInitialOffset() at the start and then periodically from
offsetFetcher thread.
- private void updateLatestOffsets() {
- Consumer<byte[], byte[]> offsetConsumer =
Preconditions.checkStateNotNull(this.offsetConsumer);
- List<TopicPartition> topicPartitions =
- Preconditions.checkStateNotNull(source.getSpec().getTopicPartitions());
- Instant fetchTime = Instant.now();
- try {
- Map<TopicPartition, Long> endOffsets =
offsetConsumer.endOffsets(topicPartitions);
- for (PartitionState<K, V> p : partitionStates) {
- p.setLatestOffset(
- Preconditions.checkStateNotNull(
- endOffsets.get(p.topicPartition),
- "No end offset found for partition %s.",
- p.topicPartition),
- fetchTime);
- }
- } catch (Exception e) {
- if (!closed.get()) { // Ignore the exception if the reader is closed.
- LOG.warn(
- "{}: exception while fetching latest offset for partitions {}.
will be retried.",
- this,
- topicPartitions,
- e);
- }
- // Don't update the latest offset.
- }
-
- LOG.debug("{}: backlog {}", this, getSplitBacklogBytes());
- }
-
private void reportBacklog() {
long splitBacklogBytes = getSplitBacklogBytes();
if (splitBacklogBytes < 0) {
@@ -861,7 +877,6 @@ class KafkaUnboundedReader<K, V> extends
UnboundedReader<KafkaRecord<K, V>> {
public void close() throws IOException {
closed.set(true);
consumerPollThread.shutdown();
- offsetFetcherThread.shutdown();
boolean isShutdown = false;
@@ -872,14 +887,9 @@ class KafkaUnboundedReader<K, V> extends
UnboundedReader<KafkaRecord<K, V>> {
if (consumer != null) {
consumer.wakeup();
}
- if (offsetConsumer != null) {
- offsetConsumer.wakeup();
- }
availableRecordsQueue.poll(); // drain unread batch, this unblocks
consumer thread.
try {
- isShutdown =
- consumerPollThread.awaitTermination(10, TimeUnit.SECONDS)
- && offsetFetcherThread.awaitTermination(10, TimeUnit.SECONDS);
+ isShutdown = consumerPollThread.awaitTermination(10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e); // not expected
@@ -896,7 +906,6 @@ class KafkaUnboundedReader<K, V> extends
UnboundedReader<KafkaRecord<K, V>> {
Closeables.close(keyDeserializerInstance, true);
Closeables.close(valueDeserializerInstance, true);
- Closeables.close(offsetConsumer, true);
Closeables.close(consumer, true);
}
diff --git
a/sdks/java/io/kafka/src/test/java/org/apache/beam/sdk/io/kafka/KafkaIOTest.java
b/sdks/java/io/kafka/src/test/java/org/apache/beam/sdk/io/kafka/KafkaIOTest.java
index 5e7a5b32643..1e320681414 100644
---
a/sdks/java/io/kafka/src/test/java/org/apache/beam/sdk/io/kafka/KafkaIOTest.java
+++
b/sdks/java/io/kafka/src/test/java/org/apache/beam/sdk/io/kafka/KafkaIOTest.java
@@ -78,7 +78,6 @@ import org.apache.beam.sdk.io.Read;
import org.apache.beam.sdk.io.UnboundedSource;
import org.apache.beam.sdk.io.UnboundedSource.UnboundedReader;
import org.apache.beam.sdk.io.kafka.KafkaIO.Read.FakeFlinkPipelineOptions;
-import org.apache.beam.sdk.io.kafka.KafkaMocks.EndOffsetErrorConsumerFactory;
import org.apache.beam.sdk.io.kafka.KafkaMocks.SendErrorProducerFactory;
import org.apache.beam.sdk.metrics.DistributionResult;
import org.apache.beam.sdk.metrics.Lineage;
@@ -1659,32 +1658,6 @@ public class KafkaIOTest {
commitsEnqueuedMetrics.getCounters().iterator().next().getAttempted(),
greaterThan(0L));
}
- @Test
- public void testUnboundedReaderLogsCommitFailure() throws Exception {
-
- List<String> topics = ImmutableList.of("topic_a");
-
- EndOffsetErrorConsumerFactory endOffsetErrorConsumerFactory =
- new EndOffsetErrorConsumerFactory();
-
- UnboundedSource<KafkaRecord<Integer, Long>, KafkaCheckpointMark> source =
- KafkaIO.<Integer, Long>read()
- .withBootstrapServers("myServer1:9092,myServer2:9092")
- .withTopics(topics)
- .withConsumerFactoryFn(endOffsetErrorConsumerFactory)
- .withKeyDeserializer(IntegerDeserializer.class)
- .withValueDeserializer(LongDeserializer.class)
- .makeSource();
-
- UnboundedReader<KafkaRecord<Integer, Long>> reader =
source.createReader(null, null);
-
- reader.start();
-
- unboundedReaderExpectedLogs.verifyWarn("exception while fetching latest
offset for partitions");
-
- reader.close();
- }
-
@Test
public void testSink() {
// Simply read from kafka source and write to kafka sink. Then verify the
records
diff --git
a/sdks/java/io/kafka/src/test/java/org/apache/beam/sdk/io/kafka/KafkaMocks.java
b/sdks/java/io/kafka/src/test/java/org/apache/beam/sdk/io/kafka/KafkaMocks.java
index 1303f1da3bc..8f52e4848c9 100644
---
a/sdks/java/io/kafka/src/test/java/org/apache/beam/sdk/io/kafka/KafkaMocks.java
+++
b/sdks/java/io/kafka/src/test/java/org/apache/beam/sdk/io/kafka/KafkaMocks.java
@@ -18,27 +18,20 @@
package org.apache.beam.sdk.io.kafka;
import java.io.Serializable;
-import java.util.Collection;
-import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Future;
import java.util.stream.Collectors;
import org.apache.beam.sdk.transforms.SerializableFunction;
import org.apache.beam.sdk.values.KV;
-import org.apache.kafka.clients.consumer.Consumer;
-import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.MockConsumer;
-import org.apache.kafka.clients.consumer.OffsetResetStrategy;
import org.apache.kafka.clients.producer.Callback;
import org.apache.kafka.clients.producer.MockProducer;
import org.apache.kafka.clients.producer.Producer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.clients.producer.RecordMetadata;
import org.apache.kafka.common.KafkaException;
-import org.apache.kafka.common.Node;
import org.apache.kafka.common.PartitionInfo;
-import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.serialization.IntegerSerializer;
import org.apache.kafka.common.serialization.LongSerializer;
@@ -67,36 +60,6 @@ public class KafkaMocks {
}
}
- public static final class EndOffsetErrorConsumerFactory
- implements SerializableFunction<Map<String, Object>, Consumer<byte[],
byte[]>> {
- public EndOffsetErrorConsumerFactory() {}
-
- @Override
- public MockConsumer<byte[], byte[]> apply(Map<String, Object> input) {
- final MockConsumer<byte[], byte[]> consumer;
- if (input.containsKey(ConsumerConfig.GROUP_ID_CONFIG)) {
- consumer =
- new MockConsumer<byte[], byte[]>(OffsetResetStrategy.EARLIEST) {
- @Override
- public synchronized Map<TopicPartition, Long> endOffsets(
- Collection<TopicPartition> partitions) {
- throw new KafkaException("fakeException");
- }
- };
- } else {
- consumer = new MockConsumer<byte[],
byte[]>(OffsetResetStrategy.EARLIEST);
- }
- consumer.updatePartitions(
- "topic_a",
- Collections.singletonList(
- new PartitionInfo("topic_a", 1, new Node(1, "myServer1", 9092),
null, null)));
- consumer.updateBeginningOffsets(
- Collections.singletonMap(new TopicPartition("topic_a", 1), 0L));
- consumer.updateEndOffsets(Collections.singletonMap(new
TopicPartition("topic_a", 1), 0L));
- return consumer;
- }
- }
-
public static final class PartitionGrowthMockConsumer extends
MockConsumer<byte[], byte[]>
implements Serializable {