This is an automated email from the ASF dual-hosted git repository.

Abacn 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 a72d6cba37a [KafkaIO] Use consumer position and lag to estimate end 
offsets (#39285)
a72d6cba37a is described below

commit a72d6cba37ad8c741005edd477ade137f9454b19
Author: Steven van Rossum <[email protected]>
AuthorDate: Thu Jul 23 16:33:20 2026 +0200

    [KafkaIO] Use consumer position and lag to estimate end offsets (#39285)
    
    * Use currentLag and position to set end offset estimate
    
    * Ensure that end offset estimate can't precede consumed offset
---
 .../beam/sdk/io/kafka/ReadFromKafkaDoFn.java       | 114 ++++++---------------
 1 file changed, 34 insertions(+), 80 deletions(-)

diff --git 
a/sdks/java/io/kafka/src/main/java/org/apache/beam/sdk/io/kafka/ReadFromKafkaDoFn.java
 
b/sdks/java/io/kafka/src/main/java/org/apache/beam/sdk/io/kafka/ReadFromKafkaDoFn.java
index 27af3b67930..68453443475 100644
--- 
a/sdks/java/io/kafka/src/main/java/org/apache/beam/sdk/io/kafka/ReadFromKafkaDoFn.java
+++ 
b/sdks/java/io/kafka/src/main/java/org/apache/beam/sdk/io/kafka/ReadFromKafkaDoFn.java
@@ -19,7 +19,6 @@ package org.apache.beam.sdk.io.kafka;
 
 import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkState;
 
-import java.io.Closeable;
 import java.math.BigDecimal;
 import java.math.MathContext;
 import java.time.Duration;
@@ -27,7 +26,7 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
-import java.util.function.Supplier;
+import java.util.concurrent.atomic.AtomicLong;
 import org.apache.beam.sdk.coders.Coder;
 import org.apache.beam.sdk.io.kafka.KafkaIO.ReadSourceDescriptors;
 import org.apache.beam.sdk.io.kafka.KafkaIOUtils.MovingAvg;
@@ -49,7 +48,6 @@ import 
org.apache.beam.sdk.transforms.splittabledofn.UnsplittableRestrictionTrac
 import org.apache.beam.sdk.transforms.splittabledofn.WatermarkEstimator;
 import 
org.apache.beam.sdk.transforms.splittabledofn.WatermarkEstimators.MonotonicallyIncreasing;
 import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
-import org.apache.beam.sdk.util.ExpiringMemoizingSerializableSupplier;
 import org.apache.beam.sdk.util.MemoizingPerInstantiationSerializableSupplier;
 import org.apache.beam.sdk.util.Preconditions;
 import org.apache.beam.sdk.util.SerializableSupplier;
@@ -71,6 +69,8 @@ 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.common.KafkaException;
 import org.apache.kafka.common.PartitionInfo;
 import org.apache.kafka.common.TopicPartition;
 import org.apache.kafka.common.config.ConfigDef;
@@ -235,30 +235,12 @@ abstract class ReadFromKafkaDoFn<K, V>
                 CacheBuilder.newBuilder()
                     
.concurrencyLevel(Runtime.getRuntime().availableProcessors())
                     .weakValues()
-                    .removalListener(
-                        (RemovalNotification<KafkaSourceDescriptor, 
KafkaLatestOffsetEstimator>
-                                notification) -> {
-                          final @Nullable KafkaLatestOffsetEstimator value;
-                          if (notification.getCause() == RemovalCause.COLLECTED
-                              && (value = notification.getValue()) != null) {
-                            value.close();
-                          }
-                        })
                     .build(
-                        new CacheLoader<KafkaSourceDescriptor, 
KafkaLatestOffsetEstimator>() {
+                        new CacheLoader<KafkaSourceDescriptor, AtomicLong>() {
                           @Override
-                          public KafkaLatestOffsetEstimator load(
-                              final KafkaSourceDescriptor sourceDescriptor) {
-                            LOG.info(
-                                "Creating Kafka consumer for offset estimation 
for {}",
-                                sourceDescriptor);
-                            final Map<String, Object> config =
-                                KafkaIOUtils.overrideBootstrapServersConfig(
-                                    consumerConfig, sourceDescriptor);
-                            final Consumer<byte[], byte[]> consumer =
-                                consumerFactoryFn.apply(config);
-                            return new KafkaLatestOffsetEstimator(
-                                consumer, 
sourceDescriptor.getTopicPartition());
+                          public AtomicLong load(final KafkaSourceDescriptor 
sourceDescriptor) {
+                            LOG.info("Creating end offset estimator for {}", 
sourceDescriptor);
+                            return new AtomicLong(Long.MIN_VALUE);
                           }
                         }));
     this.pollConsumerCacheSupplier =
@@ -319,8 +301,7 @@ abstract class ReadFromKafkaDoFn<K, V>
   private final SerializableSupplier<LoadingCache<KafkaSourceDescriptor, 
MovingAvg>>
       avgRecordSizeCacheSupplier;
 
-  private final SerializableSupplier<
-          LoadingCache<KafkaSourceDescriptor, KafkaLatestOffsetEstimator>>
+  private final SerializableSupplier<LoadingCache<KafkaSourceDescriptor, 
AtomicLong>>
       latestOffsetEstimatorCacheSupplier;
 
   private final SerializableSupplier<LoadingCache<KafkaSourceDescriptor, 
Consumer<byte[], byte[]>>>
@@ -329,8 +310,7 @@ abstract class ReadFromKafkaDoFn<K, V>
   private transient @MonotonicNonNull LoadingCache<KafkaSourceDescriptor, 
MovingAvg>
       avgRecordSizeCache;
 
-  private transient @MonotonicNonNull LoadingCache<
-          KafkaSourceDescriptor, KafkaLatestOffsetEstimator>
+  private transient @MonotonicNonNull LoadingCache<KafkaSourceDescriptor, 
AtomicLong>
       latestOffsetEstimatorCache;
 
   private transient @MonotonicNonNull LoadingCache<KafkaSourceDescriptor, 
Consumer<byte[], byte[]>>
@@ -349,46 +329,6 @@ abstract class ReadFromKafkaDoFn<K, V>
   @VisibleForTesting
   static final String RAW_SIZE_METRIC_PREFIX = 
KafkaUnboundedReader.RAW_SIZE_METRIC_PREFIX;
 
-  /**
-   * A {@link GrowableOffsetRangeTracker.RangeEndEstimator} which uses a Kafka 
{@link Consumer} to
-   * fetch backlog.
-   */
-  private static class KafkaLatestOffsetEstimator
-      implements GrowableOffsetRangeTracker.RangeEndEstimator, Closeable {
-    private final Consumer<byte[], byte[]> offsetConsumer;
-    private final Supplier<Long> offsetSupplier;
-
-    KafkaLatestOffsetEstimator(
-        final Consumer<byte[], byte[]> offsetConsumer, final TopicPartition 
topicPartition) {
-      this.offsetConsumer = offsetConsumer;
-      this.offsetSupplier =
-          new ExpiringMemoizingSerializableSupplier<>(
-              () -> {
-                try {
-                  return offsetConsumer
-                      .endOffsets(Collections.singleton(topicPartition))
-                      .getOrDefault(topicPartition, Long.MIN_VALUE);
-                } catch (Throwable t) {
-                  LOG.error("Failed to get end offset for {}", topicPartition, 
t);
-                  return Long.MIN_VALUE;
-                }
-              },
-              Duration.ofSeconds(1),
-              Long.MIN_VALUE,
-              Duration.ZERO);
-    }
-
-    @Override
-    public long estimate() {
-      return offsetSupplier.get();
-    }
-
-    @Override
-    public void close() {
-      offsetConsumer.close();
-    }
-  }
-
   @GetInitialRestriction
   @RequiresNonNull({"pollConsumerCache"})
   public OffsetRange initialRestriction(@Element KafkaSourceDescriptor 
kafkaSourceDescriptor) {
@@ -500,8 +440,8 @@ abstract class ReadFromKafkaDoFn<K, V>
   @RequiresNonNull({"latestOffsetEstimatorCache"})
   public UnsplittableRestrictionTracker<OffsetRange, Long> restrictionTracker(
       @Element KafkaSourceDescriptor kafkaSourceDescriptor, @Restriction 
OffsetRange restriction) {
-    final LoadingCache<KafkaSourceDescriptor, KafkaLatestOffsetEstimator>
-        latestOffsetEstimatorCache = this.latestOffsetEstimatorCache;
+    final LoadingCache<KafkaSourceDescriptor, AtomicLong> 
latestOffsetEstimatorCache =
+        this.latestOffsetEstimatorCache;
 
     if (restriction.getTo() < Long.MAX_VALUE) {
       return new UnsplittableRestrictionTracker<>(new 
OffsetRangeTracker(restriction));
@@ -510,9 +450,10 @@ abstract class ReadFromKafkaDoFn<K, V>
     // OffsetEstimators are cached for each topic-partition because they hold 
a stateful connection,
     // so we want to minimize the amount of connections that we start and 
track with Kafka. Another
     // point is that it has a memoized backlog, and this should make that more 
reusable estimations.
+    final AtomicLong latestOffsetEstimator =
+        latestOffsetEstimatorCache.getUnchecked(kafkaSourceDescriptor);
     return new UnsplittableRestrictionTracker<>(
-        new GrowableOffsetRangeTracker(
-            restriction.getFrom(), 
latestOffsetEstimatorCache.getUnchecked(kafkaSourceDescriptor)));
+        new GrowableOffsetRangeTracker(restriction.getFrom(), 
latestOffsetEstimator::get));
   }
 
   @ProcessElement
@@ -525,14 +466,13 @@ abstract class ReadFromKafkaDoFn<K, V>
       throws Exception {
     final LoadingCache<KafkaSourceDescriptor, MovingAvg> avgRecordSizeCache =
         this.avgRecordSizeCache;
-    final LoadingCache<KafkaSourceDescriptor, KafkaLatestOffsetEstimator>
-        latestOffsetEstimatorCache = this.latestOffsetEstimatorCache;
+    final LoadingCache<KafkaSourceDescriptor, AtomicLong> 
latestOffsetEstimatorCache =
+        this.latestOffsetEstimatorCache;
     final LoadingCache<KafkaSourceDescriptor, Consumer<byte[], byte[]>> 
pollConsumerCache =
         this.pollConsumerCache;
 
     final MovingAvg avgRecordSize = 
avgRecordSizeCache.get(kafkaSourceDescriptor);
-    final KafkaLatestOffsetEstimator latestOffsetEstimator =
-        latestOffsetEstimatorCache.get(kafkaSourceDescriptor);
+    final AtomicLong latestOffsetEstimator = 
latestOffsetEstimatorCache.get(kafkaSourceDescriptor);
     final Consumer<byte[], byte[]> consumer = 
pollConsumerCache.get(kafkaSourceDescriptor);
     final Deserializer<K> keyDeserializerInstance =
         Preconditions.checkStateNotNull(this.keyDeserializerInstance);
@@ -580,6 +520,20 @@ abstract class ReadFromKafkaDoFn<K, V>
         // Fetch the next records.
         final ConsumerRecords<byte[], byte[]> rawRecords = 
consumer.poll(remainingTimeout);
         final Duration elapsed = pollTimer.elapsed();
+        try {
+          final long position = consumer.position(topicPartition);
+          consumer
+              .currentLag(topicPartition)
+              .ifPresent(lag -> latestOffsetEstimator.lazySet(position + lag));
+        } catch (InvalidOffsetException e) {
+          // The position is undefined or out of range.
+          latestOffsetEstimator.lazySet(Long.MIN_VALUE);
+        } catch (KafkaException 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.
+        }
+
         try {
           remainingTimeout = remainingTimeout.minus(elapsed);
         } catch (ArithmeticException e) {
@@ -687,7 +641,7 @@ abstract class ReadFromKafkaDoFn<K, V>
 
         final long estimatedBacklogBytes =
             (long)
-                (BigDecimal.valueOf(latestOffsetEstimator.estimate())
+                (BigDecimal.valueOf(Math.max(expectedOffset, 
latestOffsetEstimator.get()))
                         .subtract(BigDecimal.valueOf(expectedOffset), 
MathContext.DECIMAL128)
                         .doubleValue()
                     * avgRecordSize.get());
@@ -752,8 +706,8 @@ abstract class ReadFromKafkaDoFn<K, V>
   public void teardown() throws Exception {
     final LoadingCache<KafkaSourceDescriptor, MovingAvg> avgRecordSizeCache =
         this.avgRecordSizeCache;
-    final LoadingCache<KafkaSourceDescriptor, KafkaLatestOffsetEstimator>
-        latestOffsetEstimatorCache = this.latestOffsetEstimatorCache;
+    final LoadingCache<KafkaSourceDescriptor, AtomicLong> 
latestOffsetEstimatorCache =
+        this.latestOffsetEstimatorCache;
     final LoadingCache<KafkaSourceDescriptor, Consumer<byte[], byte[]>> 
pollConsumerCache =
         this.pollConsumerCache;
 

Reply via email to