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

kfaraz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new c58e62b6be3 minor: Use running average values for processing rate and 
poll idle ratio (#20708)
c58e62b6be3 is described below

commit c58e62b6be346ef505ba30f8b13e041725059a9b
Author: Kashif Faraz <[email protected]>
AuthorDate: Wed Oct 28 17:33:23 2026 +0530

    minor: Use running average values for processing rate and poll idle ratio 
(#20708)
    
    Changes:
    - Use running average metrics for Kafka poll-idle-ratio and average 
processing rate (records / second)
    - The average is taken over the entire lifecycle of a task and is not 
exponentially weighted on purpose.
    - This makes it easy to reason about the metric, and is driven by the 
dominant behavior in the task duration.
    - The poll-idle-ratio is reported by the Kafka consumer but is averaged 
over a 30s window by default.
    This often leads to volatile metric values and the value at the exact time 
of the decision wins.
    In production setups, this is a die roll since even though the metric had 
been consistently reporting
    a certain value, a momentary spike or dip would cause the auto-scaler to 
make a bad decision.
    - The `usePollIdleRatio: false` setting was also unusable before this patch 
due to avg processing rate metric
    being volatile.
---
 .../CostBasedAutoScalerIntegrationTest.java        |  2 +-
 .../druid/indexing/kafka/KafkaConsumerMonitor.java | 47 +++++++++++++--
 .../druid/indexing/kafka/KafkaRecordSupplier.java  |  6 ++
 .../SeekableStreamIndexTaskRunner.java             | 68 +++++++++++++++++++++-
 .../seekablestream/common/RecordSupplier.java      |  9 +++
 .../supervisor/autoscaler/CostBasedAutoScaler.java | 60 +++++++++++++++++--
 .../autoscaler/CostBasedAutoScalerTest.java        | 22 ++++---
 7 files changed, 189 insertions(+), 25 deletions(-)

diff --git 
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/autoscaler/CostBasedAutoScalerIntegrationTest.java
 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/autoscaler/CostBasedAutoScalerIntegrationTest.java
index 879d90e6956..ae61b30859f 100644
--- 
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/autoscaler/CostBasedAutoScalerIntegrationTest.java
+++ 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/indexing/autoscaler/CostBasedAutoScalerIntegrationTest.java
@@ -255,6 +255,7 @@ public class CostBasedAutoScalerIntegrationTest extends 
StreamIndexTestBase
         .taskCountMax(4)
         .lagWeight(0.5)
         .idleWeight(0.5)
+        .usePollIdleRatio(false)
         .scaleActionPeriodMillis(10L)
         .minScaleDownDelay(Duration.standardSeconds(1))
         .build();
@@ -295,7 +296,6 @@ public class CostBasedAutoScalerIntegrationTest extends 
StreamIndexTestBase
                       .hasDimension(DruidMetrics.SUPERVISOR_ID, 
supervisor.getId())
                       .hasValueMatching(Matchers.equalTo(1L))
     );
-    Assertions.assertEquals(1, getCurrentTaskCount(supervisor.getId()));
 
     cluster.callApi().postSupervisor(supervisor.createSuspendedSpec());
     cluster.callApi().waitForAllSegmentsToBeAvailable(dataSource, coordinator, 
broker);
diff --git 
a/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaConsumerMonitor.java
 
b/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaConsumerMonitor.java
index 08f933a31b0..4a25c08d525 100644
--- 
a/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaConsumerMonitor.java
+++ 
b/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaConsumerMonitor.java
@@ -19,7 +19,6 @@
 
 package org.apache.druid.indexing.kafka;
 
-import com.google.common.util.concurrent.AtomicDouble;
 import org.apache.druid.error.DruidException;
 import org.apache.druid.java.util.emitter.service.ServiceEmitter;
 import org.apache.druid.java.util.emitter.service.ServiceMetricEvent;
@@ -144,7 +143,7 @@ public class KafkaConsumerMonitor extends AbstractMonitor
   private final Supplier<ServiceMetricEvent.Builder> metricBuilderSupplier;
 
   private final Map<MetricName, AtomicLong> counters = new HashMap<>();
-  private final AtomicDouble pollIdleRatioAvg = new AtomicDouble(1.0d);
+  private final RunningAverage pollIdleRatio = new RunningAverage();
 
   public KafkaConsumerMonitor(
       final KafkaConsumer<?, ?> consumer,
@@ -197,7 +196,7 @@ public class KafkaConsumerMonitor extends AbstractMonitor
       // Capture `poll-idle-ratio-avg` metric for autoscaler purposes.
       if (POLL_IDLE_RATIO_METRIC_NAME.equals(metricName.name())) {
         if (entry.getValue().metricValue() != null) {
-          pollIdleRatioAvg.set(((Number) 
entry.getValue().metricValue()).doubleValue());
+          pollIdleRatio.addSample(((Number) 
entry.getValue().metricValue()).doubleValue());
         }
       }
     }
@@ -211,12 +210,50 @@ public class KafkaConsumerMonitor extends AbstractMonitor
   }
 
   /**
-   * Average poll-to-idle ratio as reported by the Kafka consumer.
+   * poll-to-idle ratio as reported by the Kafka consumer averaged over a short
+   * sampling window (30s by default).
    * A value of 0 represents that the consumer is never idle, i.e. always 
consuming.
    * A value of 1 represents that the consumer is always idle, i.e. not 
receiving data.
    */
   public double getPollIdleRatioAvg()
   {
-    return pollIdleRatioAvg.get();
+    return pollIdleRatio.getLatest();
+  }
+
+  /**
+   * Cumulative average poll-to-idle ratio over the lifetime of the task
+   * as opposed to {@link #getPollIdleRatioAvg()} which only reflects Kafka's 
own short sampling window.
+   */
+  public double getRunningAvgPollIdleRatio()
+  {
+    return pollIdleRatio.getAvg();
+  }
+
+  private static class RunningAverage
+  {
+    private static final double UNKNOWN = 1.0d;
+
+    private double latest = UNKNOWN;
+    private double sum = 0.0d;
+    private long count = 0L;
+
+    synchronized void addSample(final double value)
+    {
+      if (!Double.isNaN(value) && value >= 0 && value <= 1) {
+        sum += value;
+        count++;
+      }
+      latest = value;
+    }
+
+    synchronized double getAvg()
+    {
+      return count == 0 ? UNKNOWN : sum / count;
+    }
+
+    synchronized double getLatest()
+    {
+      return latest;
+    }
   }
 }
diff --git 
a/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaRecordSupplier.java
 
b/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaRecordSupplier.java
index c20aa81f2c7..a1ff07d6ddb 100644
--- 
a/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaRecordSupplier.java
+++ 
b/extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/KafkaRecordSupplier.java
@@ -306,6 +306,12 @@ public class KafkaRecordSupplier implements 
RecordSupplier<KafkaTopicPartition,
     return monitor.getPollIdleRatioAvg();
   }
 
+  @Override
+  public double getRunningAvgPollIdleRatio()
+  {
+    return monitor.getRunningAvgPollIdleRatio();
+  }
+
   @Override
   public Set<KafkaTopicPartition> getPartitionIds(String stream)
   {
diff --git 
a/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/SeekableStreamIndexTaskRunner.java
 
b/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/SeekableStreamIndexTaskRunner.java
index 1239f9f1c7e..4e92c47fe06 100644
--- 
a/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/SeekableStreamIndexTaskRunner.java
+++ 
b/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/SeekableStreamIndexTaskRunner.java
@@ -135,6 +135,7 @@ import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.locks.Condition;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
@@ -152,6 +153,8 @@ public abstract class 
SeekableStreamIndexTaskRunner<PartitionIdType, SequenceOff
 {
   public static final String AUTOSCALER_METRICS_KEY = "autoscalerMetrics";
   public static final String POLL_IDLE_RATIO_KEY = "pollIdleRatio";
+  public static final String POLL_IDLE_RATIO_RUNNING_AVG = 
"pollIdleRatioRunningAvg";
+  public static final String PROCESSING_RATE_RUNNING_AVG = 
"processingRateRunningAvg";
 
   private static final String CTX_KEY_LOOKUP_TIER = "lookupTier";
 
@@ -238,6 +241,18 @@ public abstract class 
SeekableStreamIndexTaskRunner<PartitionIdType, SequenceOff
 
   private volatile DateTime startTime;
   private volatile Status status = Status.NOT_STARTED; // this is only ever 
set by the task runner thread (runThread)
+
+  /**
+   * Tracks cumulative time spent in {@link Status#READING}, i.e. active 
reading
+   * and processing records.
+   */
+  private final AtomicLong totalReadingMillis = new AtomicLong(0L);
+
+  /**
+   * A non-zero value implies that the task is currently in {@link 
Status#READING}.
+   */
+  private final AtomicLong readingSinceMillis = new AtomicLong(0L);
+
   private volatile TaskToolbox toolbox;
   private volatile Thread runThread;
   private volatile Appenderator appenderator;
@@ -635,6 +650,7 @@ public abstract class 
SeekableStreamIndexTaskRunner<PartitionIdType, SequenceOff
       // Could eventually support leader/follower mode (for keeping replicas 
more in sync)
       boolean stillReading = !assignment.isEmpty();
       status = Status.READING;
+      markReadingStarted();
       try {
         while (stillReading) {
           if (possiblyPause()) {
@@ -655,6 +671,7 @@ public abstract class 
SeekableStreamIndexTaskRunner<PartitionIdType, SequenceOff
           final SequenceMetadata<PartitionIdType, SequenceOffsetType> 
latestSequence = getLastSequenceMetadataOrNull();
           if (stopRequested.get() || latestSequence == null || 
latestSequence.isCheckpointed()) {
             status = Status.PUBLISHING;
+            markReadingStopped();
           }
 
           if (stopRequested.get()) {
@@ -864,6 +881,7 @@ public abstract class 
SeekableStreamIndexTaskRunner<PartitionIdType, SequenceOff
         }
 
         status = Status.PUBLISHING;
+        markReadingStopped();
       }
 
       // We need to copy sequences here, because the success callback in 
publishAndRegisterHandoff removes items from
@@ -1527,6 +1545,7 @@ public abstract class 
SeekableStreamIndexTaskRunner<PartitionIdType, SequenceOff
     try {
       if (pauseRequested) {
         status = Status.PAUSED;
+        markReadingStopped();
         hasPaused.signalAll();
 
         long pauseTime = System.currentTimeMillis();
@@ -1536,6 +1555,7 @@ public abstract class 
SeekableStreamIndexTaskRunner<PartitionIdType, SequenceOff
         }
 
         status = Status.READING;
+        markReadingStarted();
         shouldResume.signalAll();
         log.info("Received resume command, resuming ingestion.");
         task.emitMetric(toolbox.getEmitter(), "ingest/pause/time", 
System.currentTimeMillis() - pauseTime);
@@ -1554,6 +1574,28 @@ public abstract class 
SeekableStreamIndexTaskRunner<PartitionIdType, SequenceOff
     return status == Status.PAUSED;
   }
 
+  /**
+   * Marks the start of a period of actively reading and processing records. 
Must be paired with a subsequent
+   * call to {@link #markReadingStopped()} to accumulate {@link 
#totalReadingMillis}.
+   */
+  private void markReadingStarted()
+  {
+    readingSinceMillis.compareAndSet(0L, System.currentTimeMillis());
+  }
+
+  /**
+   * Marks the end of a period of actively reading and processing records, 
begun by the most recent call to
+   * {@link #markReadingStarted()}, and folds the elapsed duration into {@link 
#totalReadingMillis}. A no-op if
+   * reading was not in progress.
+   */
+  private void markReadingStopped()
+  {
+    final long readingSinceMillis = this.readingSinceMillis.getAndSet(0L);
+    if (readingSinceMillis > 0) {
+      totalReadingMillis.addAndGet(System.currentTimeMillis() - 
readingSinceMillis);
+    }
+  }
+
   private void requestPause()
   {
     pauseRequested = true;
@@ -1859,11 +1901,35 @@ public abstract class 
SeekableStreamIndexTaskRunner<PartitionIdType, SequenceOff
     returnMap.put("movingAverages", averagesMap);
     returnMap.put("totals", totalsMap);
     if (this.recordSupplier != null) {
-      returnMap.put(AUTOSCALER_METRICS_KEY, Map.of(POLL_IDLE_RATIO_KEY, 
this.recordSupplier.getPollIdleRatioMetric()));
+      returnMap.put(
+          AUTOSCALER_METRICS_KEY,
+          Map.of(
+              POLL_IDLE_RATIO_KEY, recordSupplier.getPollIdleRatioMetric(),
+              POLL_IDLE_RATIO_RUNNING_AVG, 
recordSupplier.getRunningAvgPollIdleRatio(),
+              PROCESSING_RATE_RUNNING_AVG, getRunningAvgProcessingRate()
+          )
+      );
     }
     return returnMap;
   }
 
+  /**
+   * Average number of records processed per second, over the cumulative time 
spent actively reading and
+   * processing records (i.e. {@link Status#READING}), excluding startup, 
paused and publishing time.
+   */
+  private double getRunningAvgProcessingRate()
+  {
+    long readingMillis = totalReadingMillis.get();
+    final long readingSince = readingSinceMillis.get();
+    if (readingSince > 0) {
+      readingMillis += System.currentTimeMillis() - readingSince;
+    }
+
+    return readingMillis <= 0
+           ? 0.0d
+           : rowIngestionMeters.getTotals().getProcessed() * 1000.0d / 
readingMillis;
+  }
+
   public Map<String, Object> doGetLiveReports()
   {
     Map<String, Object> ingestionStatsAndErrors = new HashMap<>();
diff --git 
a/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/common/RecordSupplier.java
 
b/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/common/RecordSupplier.java
index 7af4633102c..544ba9b2ea3 100644
--- 
a/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/common/RecordSupplier.java
+++ 
b/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/common/RecordSupplier.java
@@ -154,6 +154,15 @@ public interface RecordSupplier<PartitionIdType, 
SequenceOffsetType, RecordType
     return -1;
   }
 
+  /**
+   * Running average of the {@link #getPollIdleRatioMetric()} averaged over the
+   * entire lifecycle of the task.
+   */
+  default double getRunningAvgPollIdleRatio()
+  {
+    return -1;
+  }
+
   /**
    * close the RecordSupplier
    */
diff --git 
a/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/supervisor/autoscaler/CostBasedAutoScaler.java
 
b/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/supervisor/autoscaler/CostBasedAutoScaler.java
index e34ad386099..60f8a643aa2 100644
--- 
a/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/supervisor/autoscaler/CostBasedAutoScaler.java
+++ 
b/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/supervisor/autoscaler/CostBasedAutoScaler.java
@@ -463,6 +463,30 @@ public class CostBasedAutoScaler implements 
SupervisorTaskAutoScaler
     return result.toIntArray();
   }
 
+  /**
+   * Extracts the average processing rate (records per second) from the task 
stats.
+   *
+   * @param taskStats the stats map from supervisor.getStats()
+   */
+  static double extractAvgProcessingRate(Map<String, Map<String, Object>> 
taskStats)
+  {
+    if (taskStats == null || taskStats.isEmpty()) {
+      return -1.;
+    }
+
+    // First try using the running average if any of the tasks has started 
reporting it
+    final double runningAverage = extractAutoscalerMetric(
+        taskStats,
+        SeekableStreamIndexTaskRunner.PROCESSING_RATE_RUNNING_AVG
+    );
+    if (runningAverage >= 0) {
+      return runningAverage;
+    } else {
+      // Fall back to the old moving average metric
+      return extractMovingAverage(taskStats);
+    }
+  }
+
   /**
    * Extracts the average poll-idle-ratio metric from task stats.
    * This metric indicates how much time the consumer spends idle waiting for 
data.
@@ -476,6 +500,30 @@ public class CostBasedAutoScaler implements 
SupervisorTaskAutoScaler
       return -1.;
     }
 
+    // First try using running average metric if any of the task has started 
reporting it
+    final double runningAverage = extractAutoscalerMetric(
+        taskStats,
+        SeekableStreamIndexTaskRunner.POLL_IDLE_RATIO_RUNNING_AVG
+    );
+    if (runningAverage >= 0) {
+      return runningAverage;
+    } else {
+      // Fall back to the instantaneous metric
+      return extractAutoscalerMetric(
+          taskStats,
+          SeekableStreamIndexTaskRunner.POLL_IDLE_RATIO_KEY
+      );
+    }
+  }
+
+  /**
+   * Gets the value of the given metric averaged across all tasks that have 
reported the metric.
+   */
+  private static double extractAutoscalerMetric(
+      Map<String, Map<String, Object>> taskStats,
+      String metricName
+  )
+  {
     double sum = 0;
     int count = 0;
     for (Map<String, Object> groupMetrics : taskStats.values()) {
@@ -483,12 +531,12 @@ public class CostBasedAutoScaler implements 
SupervisorTaskAutoScaler
         if (taskMetric instanceof Map) {
           Object autoScalerMetricsMap = ((Map<?, ?>) 
taskMetric).get(SeekableStreamIndexTaskRunner.AUTOSCALER_METRICS_KEY);
           if (autoScalerMetricsMap instanceof Map) {
-            Object pollIdleRatioAvg = ((Map<?, ?>) 
autoScalerMetricsMap).get(SeekableStreamIndexTaskRunner.POLL_IDLE_RATIO_KEY);
-            if (pollIdleRatioAvg instanceof Number) {
-              double taskPollIdleRatio = ((Number) 
pollIdleRatioAvg).doubleValue();
+            Object metricValue = ((Map<?, ?>) 
autoScalerMetricsMap).get(metricName);
+            if (metricValue instanceof Number) {
+              double metricDoubleValue = ((Number) metricValue).doubleValue();
               // Do not include negative values
-              if (taskPollIdleRatio >= 0) {
-                sum += taskPollIdleRatio;
+              if (metricDoubleValue >= 0) {
+                sum += metricDoubleValue;
                 count++;
               }
             }
@@ -576,7 +624,7 @@ public class CostBasedAutoScaler implements 
SupervisorTaskAutoScaler
     final int partitionCount = supervisor.getPartitionCount();
 
     final Map<String, Map<String, Object>> taskStats = supervisor.getStats();
-    final double movingAvgRate = extractMovingAverage(taskStats);
+    final double movingAvgRate = extractAvgProcessingRate(taskStats);
     final double pollIdleRatio = extractPollIdleRatio(taskStats);
 
     if (!config.isUsePollIdleRatio() && aggregateLag > 0 && movingAvgRate > 0) 
{
diff --git 
a/indexing-service/src/test/java/org/apache/druid/indexing/seekablestream/supervisor/autoscaler/CostBasedAutoScalerTest.java
 
b/indexing-service/src/test/java/org/apache/druid/indexing/seekablestream/supervisor/autoscaler/CostBasedAutoScalerTest.java
index 374ecd3bfc5..471ba73dfad 100644
--- 
a/indexing-service/src/test/java/org/apache/druid/indexing/seekablestream/supervisor/autoscaler/CostBasedAutoScalerTest.java
+++ 
b/indexing-service/src/test/java/org/apache/druid/indexing/seekablestream/supervisor/autoscaler/CostBasedAutoScalerTest.java
@@ -441,7 +441,7 @@ public class CostBasedAutoScalerTest
     Map<String, Map<String, Object>> nonNumberRatio = new HashMap<>();
     Map<String, Object> taskStats3 = new HashMap<>();
     Map<String, Object> autoscalerMetrics = new HashMap<>();
-    autoscalerMetrics.put(SeekableStreamIndexTaskRunner.POLL_IDLE_RATIO_KEY, 
"not-a-number");
+    
autoscalerMetrics.put(SeekableStreamIndexTaskRunner.POLL_IDLE_RATIO_RUNNING_AVG,
 "not-a-number");
     taskStats3.put(SeekableStreamIndexTaskRunner.AUTOSCALER_METRICS_KEY, 
autoscalerMetrics);
     nonNumberRatio.put("0", Collections.singletonMap("task-0", taskStats3));
     Assertions.assertEquals(
@@ -487,7 +487,7 @@ public class CostBasedAutoScalerTest
     validStats.put("0", group);
     Assertions.assertEquals(
         1500.0,
-        CostBasedAutoScaler.extractMovingAverage(validStats),
+        CostBasedAutoScaler.extractAvgProcessingRate(validStats),
         0.0001,
         "Average 5-minute processing rate should be computed across tasks"
     );
@@ -856,7 +856,7 @@ public class CostBasedAutoScalerTest
   private Map<String, Object> buildTaskStatsWithPollIdle(double pollIdleRatio)
   {
     Map<String, Object> autoscalerMetrics = new HashMap<>();
-    autoscalerMetrics.put(SeekableStreamIndexTaskRunner.POLL_IDLE_RATIO_KEY, 
pollIdleRatio);
+    
autoscalerMetrics.put(SeekableStreamIndexTaskRunner.POLL_IDLE_RATIO_RUNNING_AVG,
 pollIdleRatio);
 
     Map<String, Object> taskStats = new HashMap<>();
     taskStats.put(SeekableStreamIndexTaskRunner.AUTOSCALER_METRICS_KEY, 
autoscalerMetrics);
@@ -865,15 +865,13 @@ public class CostBasedAutoScalerTest
 
   private Map<String, Object> buildTaskStatsWithMovingAverage(double 
processedRate)
   {
-    Map<String, Object> buildSegments = new HashMap<>();
-    buildSegments.put(FIVE_MINUTE_NAME, Map.of(RowIngestionMeters.PROCESSED, 
processedRate));
-
-    Map<String, Object> movingAverages = new HashMap<>();
-    movingAverages.put(RowIngestionMeters.BUILD_SEGMENTS, buildSegments);
-
-    Map<String, Object> taskStats = new HashMap<>();
-    taskStats.put("movingAverages", movingAverages);
-    return taskStats;
+    return Map.of(
+        SeekableStreamIndexTaskRunner.AUTOSCALER_METRICS_KEY,
+        Map.of(
+            SeekableStreamIndexTaskRunner.PROCESSING_RATE_RUNNING_AVG,
+            processedRate
+        )
+    );
   }
 
   private Map<String, Object> 
buildTaskStatsWithMovingAverageForInterval(String intervalName, double 
processedRate)


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to