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

rustyrazorblade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cassandra-easy-stress.git


The following commit(s) were added to refs/heads/main by this push:
     new 83ee78c  Fix the rate limiter optimizer's control loop
83ee78c is described below

commit 83ee78c7678b350b3c20f267616834817262ea06
Author: Jon Haddad <[email protected]>
AuthorDate: Fri Aug 28 17:18:00 2026 -0700

    Fix the rate limiter optimizer's control loop
    
    The optimizer read a p99 that lagged its own control interval by minutes, so
    every adjustment compounded on stale evidence. Twelve 10% cuts in a minute 
took
    the rate to 28% of capacity while the measured p99 had barely moved.
    
    - Give the operation timers a SlidingTimeWindowArrayReservoir of 10 seconds.
      The Dropwizard default still weights samples from about five minutes ago.
    - Wait one full latency window after any change, so each decision sees the 
rate
      it last set rather than the one before it.
    - Reduce against achieved throughput, not the nominal limit. Under overload 
the
      client falls behind its limit, so cutting the limit alone changed nothing.
    - Remove the guard that blocked reductions when utilization was low. It 
blocked
      the only corrective action in exactly the case that needed it.
    - Replace the cube-root increase gain with one based on the unused fraction 
of
      the latency budget. The old formula divided a cube root of milliseconds by
      milliseconds, so its response depended on the size of the target.
    - Add a rate floor at a hundredth of the starting rate.
    - Count deletions and populate operations towards the minimum-samples gate. 
The
      populate branch could never run before, and delete-only workloads never
      optimized.
    - Skip the optimizer entirely without a latency target. A plain --rate run 
no
      longer spends its first minute ramping towards a rate it was given.
    - Make its Timer a daemon and cancel it on shutdown; guard the shared state.
    
    The reported p99 now covers ten seconds rather than a five-minute decay. The
    console column, the Prometheus export, and the server API all change with 
it.
    The HDR and Parquet outputs are unaffected; they record raw operation 
timings.
---
 .../org/apache/cassandra/easystress/Metrics.kt     |  31 +++-
 .../cassandra/easystress/RateLimiterOptimizer.kt   | 173 ++++++++++++++-------
 .../apache/cassandra/easystress/commands/Run.kt    |  27 +++-
 .../org/apache/cassandra/easystress/MetricsTest.kt |  51 ++++++
 .../easystress/RateLimiterOptimizerTest.kt         | 104 +++++++++++++
 5 files changed, 318 insertions(+), 68 deletions(-)

diff --git a/src/main/kotlin/org/apache/cassandra/easystress/Metrics.kt 
b/src/main/kotlin/org/apache/cassandra/easystress/Metrics.kt
index f8686a5..736996a 100644
--- a/src/main/kotlin/org/apache/cassandra/easystress/Metrics.kt
+++ b/src/main/kotlin/org/apache/cassandra/easystress/Metrics.kt
@@ -19,6 +19,8 @@ package org.apache.cassandra.easystress
 
 import com.codahale.metrics.MetricRegistry
 import com.codahale.metrics.ScheduledReporter
+import com.codahale.metrics.SlidingTimeWindowArrayReservoir
+import com.codahale.metrics.Timer
 import io.prometheus.client.CollectorRegistry
 import io.prometheus.client.dropwizard.DropwizardExports
 import io.prometheus.client.exporter.HTTPServer
@@ -67,11 +69,11 @@ class Metrics(
     }
 
     var errors = metricRegistry.meter("errors")
-    val mutations = metricRegistry.timer("mutations")
-    val selects = metricRegistry.timer("selects")
-    val deletions = metricRegistry.timer("deletions")
+    val mutations = shortWindowTimer("mutations")
+    val selects = shortWindowTimer("selects")
+    val deletions = shortWindowTimer("deletions")
 
-    val populate = metricRegistry.timer("populateMutations")
+    val populate = shortWindowTimer("populateMutations")
 
     // Throughput trackers for metrics
     val selectThroughputTracker = getTracker { selects.count }.start()
@@ -79,6 +81,19 @@ class Metrics(
     val deletionThroughputTracker = getTracker { deletions.count }.start()
     val populateThroughputTracker = getTracker { populate.count }.start()
 
+    /**
+     * Creates a timer whose percentiles cover only the recent past.
+     *
+     * The Dropwizard default is an ExponentiallyDecayingReservoir, which 
still weights samples
+     * from about five minutes ago.  The rate limiter optimizer reads these 
percentiles every few
+     * seconds, so that default makes it react to a signal it has already 
acted on, and the console
+     * report shows a p99 that lags the run.  A short sliding window fixes 
both.
+     */
+    private fun shortWindowTimer(name: String) =
+        metricRegistry.timer(name) {
+            Timer(SlidingTimeWindowArrayReservoir(LATENCY_WINDOW_SECONDS, 
TimeUnit.SECONDS))
+        }
+
     /**
      * We track throughput using separate structures than Dropwizard
      */
@@ -102,4 +117,12 @@ class Metrics(
     fun getDeletionThroughput() = 
deletionThroughputTracker.getCurrentThroughput()
 
     fun getPopulateThroughput() = 
populateThroughputTracker.getCurrentThroughput()
+
+    companion object {
+        /**
+         * The window latency percentiles are calculated over.  This must stay 
short enough that the
+         * rate limiter optimizer sees the effect of its last adjustment 
before it makes the next one.
+         */
+        const val LATENCY_WINDOW_SECONDS = 10L
+    }
 }
diff --git 
a/src/main/kotlin/org/apache/cassandra/easystress/RateLimiterOptimizer.kt 
b/src/main/kotlin/org/apache/cassandra/easystress/RateLimiterOptimizer.kt
index d6d30e1..e349a30 100644
--- a/src/main/kotlin/org/apache/cassandra/easystress/RateLimiterOptimizer.kt
+++ b/src/main/kotlin/org/apache/cassandra/easystress/RateLimiterOptimizer.kt
@@ -19,11 +19,8 @@ package org.apache.cassandra.easystress
 
 import com.google.common.util.concurrent.RateLimiter
 import org.apache.logging.log4j.kotlin.logger
-import java.lang.Math.cbrt
-import java.lang.Math.min
 import java.util.Optional
 import java.util.concurrent.TimeUnit
-import kotlin.math.sqrt
 
 /**
  * Dynamically adjusts the rate limiter based on observed latency metrics.
@@ -32,6 +29,8 @@ import kotlin.math.sqrt
  * 1. Gradually increases load during an initial "step phase"
  * 2. Monitors latencies to ensure they stay within target thresholds
  * 3. Dynamically adjusts throughput based on current performance metrics
+ *
+ * The loop only acts once the latency metrics reflect its last change.  See 
[hasSettled].
  */
 class RateLimiterOptimizer(
     val rateLimiter: RateLimiter,
@@ -39,15 +38,51 @@ class RateLimiterOptimizer(
     val maxReadLatency: Long?,
     val maxWriteLatency: Long?,
     var isStepPhase: Boolean = true,
+    val clock: () -> Long = System::currentTimeMillis,
 ) {
     companion object {
         val log = logger()
+
+        /** Cut the rate to this fraction of achieved throughput when latency 
exceeds the target. */
+        const val REDUCTION_FACTOR = 0.90
+
+        /** The largest single increase, applied when latency is far below the 
target. */
+        const val MAX_INCREASE = 0.05
+
+        /** Hold the rate steady above this fraction of the target, so the 
loop does not hunt. */
+        const val DEAD_BAND = 0.90
+
+        /** Never raise a limit the client is not already using. */
+        const val MIN_UTILIZATION = 0.90
+
+        /** Wait for this many operations before trusting the latency 
percentiles. */
+        const val MIN_OPERATIONS = 100L
+
+        /** The step phase reaches the target rate in this many steps. */
+        const val STEPS = 10
+
+        /** The rate never falls below the starting rate divided by this. */
+        const val RATE_FLOOR_DIVISOR = 100.0
     }
 
     val durationFactor = 1.0 / TimeUnit.MILLISECONDS.toNanos(1)
 
     val initial: Double = rateLimiter.rate
-    val stepValue = initial / 10.0
+    val stepValue = initial / STEPS
+
+    /**
+     * A floor stops a run of reductions from driving the rate arbitrarily 
close to zero, which
+     * leaves the test stalled with no way back up.
+     */
+    private val minRate = initial / RATE_FLOOR_DIVISOR
+
+    /**
+     * Latency percentiles cover [Metrics.LATENCY_WINDOW_SECONDS].  Acting 
again before that window
+     * has passed means acting on the previous rate, which compounds every 
adjustment.
+     */
+    private val settleMs = 
TimeUnit.SECONDS.toMillis(Metrics.LATENCY_WINDOW_SECONDS)
+
+    private var lastChangeAtMs = 0L
 
     init {
         println("Stepping rate limiter by $stepValue to $initial")
@@ -62,6 +97,7 @@ class RateLimiterOptimizer(
      *
      * @return The updated rate limit value
      */
+    @Synchronized
     fun execute(): Double {
         // Handle fresh start or when reset() was called
         if (isStepPhase) {
@@ -69,11 +105,17 @@ class RateLimiterOptimizer(
         }
 
         // Skip optimization if we don't have enough metrics
-        if (getTotalOperations() < 100) {
+        if (getTotalOperations() < MIN_OPERATIONS) {
             log.info("Not enough operations performed yet to optimize")
             return rateLimiter.rate
         }
 
+        // Skip optimization until the metrics describe the rate we set last 
time
+        if (!hasSettled()) {
+            log.debug("Waiting for the latency window to catch up with the 
last change")
+            return rateLimiter.rate
+        }
+
         // Get current latency metrics and optimize if available
         return getCurrentAndMaxLatency()
             .map { (currentLatency, maxLatency) -> 
optimizeRateLimit(currentLatency, maxLatency) }
@@ -85,7 +127,7 @@ class RateLimiterOptimizer(
      */
     private fun handleStepPhase(): Double {
         log.info("Stepping rate limiter by $stepValue")
-        val newValue = min(rateLimiter.rate + stepValue, initial)
+        val newValue = minOf(rateLimiter.rate + stepValue, initial)
 
         // Check if we've reached the initial target
         if (newValue >= initial) {
@@ -93,7 +135,7 @@ class RateLimiterOptimizer(
             isStepPhase = false
         }
 
-        rateLimiter.rate = newValue
+        applyRate(newValue)
         log.info("New rate limiter value: ${rateLimiter.rate}")
         return rateLimiter.rate
     }
@@ -106,7 +148,7 @@ class RateLimiterOptimizer(
         maxLatency: Long,
     ): Double {
         val currentRate = rateLimiter.rate
-        val newLimit = getNextValue(currentRate, currentLatency, maxLatency)
+        val newLimit = getNextValue(currentRate, getCurrentTotalThroughput(), 
currentLatency, maxLatency)
 
         // No change needed
         if (newLimit == currentRate) {
@@ -114,43 +156,39 @@ class RateLimiterOptimizer(
             return currentRate
         }
 
-        // Get current throughput for decision making
-        val currentThroughput = getCurrentTotalThroughput()
-        val utilizationRatio = currentThroughput / currentRate
-
-        // Handle rate increases - only if we're utilizing enough of current 
capacity
-        if (newLimit > currentRate) {
-            if (utilizationRatio < 0.9) {
-                log.info(
-                    "Not increasing rate limiter, current utilization too low 
(${utilizationRatio.format(2)})" +
-                        " - throughput: $currentThroughput, limit: 
$currentRate",
-                )
-                return currentRate
-            }
-        } else if (newLimit < currentRate && utilizationRatio < 0.9) {
-            // Handle rate decreases - avoid oscillation
-            log.info(
-                "Not decreasing rate limiter despite high latency - throughput 
($currentThroughput) " +
-                    "is well below current limit ($currentRate)",
-            )
-            return currentRate
-        }
-
-        // Apply the new rate limit
         log.info("Updating rate limiter from $currentRate to $newLimit")
+        return applyRate(newLimit)
+    }
+
+    /**
+     * Sets the rate limiter and records when, so the next decision waits for 
metrics that describe it.
+     */
+    private fun applyRate(newLimit: Double): Double {
         rateLimiter.rate = newLimit
+        lastChangeAtMs = clock()
         return newLimit
     }
 
+    /**
+     * Reports whether a full latency window has passed since the last change.
+     */
+    private fun hasSettled(): Boolean = clock() - lastChangeAtMs >= settleMs
+
     /**
      * Format a double to specified decimal places
      */
     private fun Double.format(decimals: Int): String = 
"%.${decimals}f".format(this)
 
     /**
-     * Added to prevent the rate limiter from acting when queries aren't 
running, generally during populate phase
+     * Counts every operation type, so a workload of any shape can be 
optimized.
+     *
+     * The optimizer needs a meaningful number of samples behind its 
percentiles before it acts.
      */
-    fun getTotalOperations(): Long = metrics.mutations.count + 
metrics.selects.count
+    fun getTotalOperations(): Long =
+        metrics.mutations.count +
+            metrics.selects.count +
+            metrics.deletions.count +
+            metrics.populate.count
 
     fun getCurrentTotalThroughput(): Double =
         metrics.getSelectThroughput() +
@@ -239,55 +277,71 @@ class RateLimiterOptimizer(
     }
 
     /**
-     * Calculates the optimal rate limit value based on current performance 
metrics.
+     * Calculates the rate limit to use next, based on current performance 
metrics.
      *
      * This implements an adaptive algorithm with three cases:
-     * 1. If latency exceeds target: reduce throughput quickly (by 10%)
+     * 1. If latency exceeds target: cut back to below the throughput actually 
being achieved
      * 2. If within 90% of target latency: maintain current throughput to 
avoid oscillation
      * 3. If well below target: increase throughput proportionally to 
available headroom
      *
      * @param currentRate The current rate limit value
+     * @param currentThroughput The throughput actually being achieved 
(ops/sec)
      * @param currentLatency The current observed latency (in ms)
      * @param maxLatency The maximum acceptable latency (in ms)
-     * @return The calculated new rate limit
+     * @return The calculated new rate limit, or currentRate to leave it alone
      */
     fun getNextValue(
         currentRate: Double,
+        currentThroughput: Double,
         currentLatency: Double,
         maxLatency: Long,
     ): Double {
-        val maxLatencyDouble = maxLatency.toDouble()
-        val latencyRatio = currentLatency / maxLatencyDouble
+        val latencyRatio = currentLatency / maxLatency.toDouble()
 
-        // Case 1: Latency is too high - reduce throughput
+        // Case 1: Latency is too high - reduce throughput.
+        //
+        // The reduction applies to the throughput actually achieved, not to 
the nominal limit.
+        // Under overload the client falls behind its own limit, so a limit 
that sits above the
+        // achieved rate constrains nothing and cutting it changes nothing.
         if (latencyRatio > 1.0) {
-            val reductionFactor = 0.90
+            // A throughput tracker that has not warmed up yet reports zero.  
Falling back to the
+            // current rate stops that from slamming the limiter down to the 
floor.
+            val basis = if (currentThroughput > 0.0) minOf(currentRate, 
currentThroughput) else currentRate
+            val newLimit = (basis * REDUCTION_FACTOR).coerceAtLeast(minRate)
             log.info(
                 "Latency exceeded target: ${currentLatency.format(2)}ms > 
${maxLatency}ms, " +
-                    "reducing throughput by ${(1 - reductionFactor) * 100}%",
+                    "reducing from $currentRate to ${newLimit.format(1)} " +
+                    "(achieved throughput ${currentThroughput.format(1)})",
             )
-            return currentRate * reductionFactor
-        } else if (latencyRatio > 0.90) {
-            // Case 2: Within 90% of target - maintain current throughput
+            return newLimit
+        }
+
+        // Case 2: Within the dead band below target - maintain current 
throughput
+        if (latencyRatio > DEAD_BAND) {
             log.info("Latency approaching target (${(latencyRatio * 
100).format(1)}% of max), maintaining throughput")
             return currentRate
-        } else {
-            // Case 3: Well below target - increase proportionally to 
available headroom
-            // Calculate increase factor - more aggressive for low latencies, 
gentler as we approach target
-            // Uses cube root to provide a non-linear response curve
-            // Small latency requirements (< 10ms) will use smaller 
adjustments due to sensitivity
-            val maxIncreaseFactor = (1.0 + sqrt(maxLatencyDouble) / 
100.0).coerceAtMost(1.05)
-            val headroom = maxLatencyDouble - currentLatency
-            val adjustmentFactor = (1.0 + cbrt(headroom) / 
maxLatencyDouble).coerceAtMost(maxIncreaseFactor)
-
-            val newLimit = currentRate * adjustmentFactor
+        }
+
+        // Case 3: Well below target - increase, but only a limit we are 
already using.
+        val utilizationRatio = currentThroughput / currentRate
+        if (utilizationRatio < MIN_UTILIZATION) {
             log.info(
-                "Latency (${currentLatency.format(2)}ms) well below target 
(${maxLatency}ms): " +
-                    "increasing throughput by ${((adjustmentFactor - 1) * 
100).format(1)}% " +
-                    "from $currentRate to ${newLimit.format(1)}",
+                "Not increasing rate limiter, current utilization too low 
(${utilizationRatio.format(2)})" +
+                    " - throughput: $currentThroughput, limit: $currentRate",
             )
-            return newLimit
+            return currentRate
         }
+
+        // The increase scales with the fraction of the latency budget still 
unused.  That fraction
+        // is dimensionless, so the response does not change when the target 
does.
+        val adjustmentFactor = 1.0 + MAX_INCREASE * (1.0 - latencyRatio)
+        val newLimit = currentRate * adjustmentFactor
+        log.info(
+            "Latency (${currentLatency.format(2)}ms) well below target 
(${maxLatency}ms): " +
+                "increasing throughput by ${((adjustmentFactor - 1) * 
100).format(1)}% " +
+                "from $currentRate to ${newLimit.format(1)}",
+        )
+        return newLimit
     }
 
     /**
@@ -309,9 +363,10 @@ class RateLimiterOptimizer(
      * Resets the optimizer to its initial state, starting the step phase 
again.
      * This is typically called after a populate phase completes or when 
workload parameters change.
      */
+    @Synchronized
     fun reset() {
         log.info("Resetting rate limiter optimizer to step phase, starting 
rate: $stepValue")
         isStepPhase = true
-        rateLimiter.rate = stepValue
+        applyRate(stepValue)
     }
 }
diff --git a/src/main/kotlin/org/apache/cassandra/easystress/commands/Run.kt 
b/src/main/kotlin/org/apache/cassandra/easystress/commands/Run.kt
index 8b69696..b856788 100644
--- a/src/main/kotlin/org/apache/cassandra/easystress/commands/Run.kt
+++ b/src/main/kotlin/org/apache/cassandra/easystress/commands/Run.kt
@@ -471,16 +471,32 @@ class Run(
         val metrics = createMetrics()
         currentMetrics = metrics // Store for external access
 
+        // The optimizer steers the rate limiter towards a latency target.  
Without a target it has
+        // nothing to steer towards, so running it would only cost the user 
the ramp up.
+        val hasLatencyTarget = maxReadLatency != null || maxWriteLatency != 
null
+
+        if (useOptimizer && !hasLatencyTarget) {
+            println(
+                "No latency target set, running at a fixed rate of $rate. " +
+                    "Set --max-read-latency or --max-write-latency to let the 
optimizer find the rate.",
+            )
+        }
+
         // set up the rate limiter optimizer and put it on a schedule
+        var optimizerTimer: Timer? = null
         val optimizer =
-            if (useOptimizer) {
+            if (useOptimizer && hasLatencyTarget) {
                 val opt = RateLimiterOptimizer(rateLimiter, metrics, 
maxReadLatency, maxWriteLatency)
                 opt.reset()
 
-                // Schedule the optimizer to run periodically
-                Timer().schedule(10000, 5000) {
-                    opt.execute()
-                }
+                // Schedule the optimizer to run periodically.  It is a daemon 
so it can never hold
+                // the JVM open, and it is cancelled in the finally block 
below.
+                optimizerTimer =
+                    Timer("rate-limiter-optimizer", true).apply {
+                        schedule(10000, 5000) {
+                            opt.execute()
+                        }
+                    }
                 opt
             } else {
                 null
@@ -538,6 +554,7 @@ class Run(
         } finally {
             // we need to be able to run multiple tests in the same JVM
             // without this cleanup we could have the metrics runner still 
running and it will cause subsequent tests to fail
+            optimizerTimer?.cancel()
             metrics.shutdown()
             currentMetrics = null // Clear reference when done
             collector.close(context)
diff --git a/src/test/kotlin/org/apache/cassandra/easystress/MetricsTest.kt 
b/src/test/kotlin/org/apache/cassandra/easystress/MetricsTest.kt
new file mode 100644
index 0000000..5719ad6
--- /dev/null
+++ b/src/test/kotlin/org/apache/cassandra/easystress/MetricsTest.kt
@@ -0,0 +1,51 @@
+/*
+ * 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.cassandra.easystress
+
+import com.codahale.metrics.MetricRegistry
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Test
+import java.util.concurrent.TimeUnit
+
+class MetricsTest {
+    /**
+     * The rate limiter optimizer reads these percentiles every few seconds.  
The Dropwizard default
+     * reservoir weights samples from about five minutes ago, which makes the 
optimizer act on a
+     * signal it has already responded to.
+     *
+     * The default reservoir also caps itself at 1028 samples.  A sliding time 
window keeps every
+     * sample recorded inside the window, so the sample count tells the two 
apart.
+     */
+    @Test
+    fun latencyPercentilesCoverOnlyTheRecentPast() {
+        val metrics = Metrics(MetricRegistry(), emptyList(), 0)
+
+        try {
+            val timers = listOf(metrics.mutations, metrics.selects, 
metrics.deletions, metrics.populate)
+
+            for (timer in timers) {
+                repeat(2000) {
+                    timer.update(1, TimeUnit.MILLISECONDS)
+                }
+                assertThat(timer.snapshot.size()).isEqualTo(2000)
+            }
+        } finally {
+            metrics.shutdown()
+        }
+    }
+}
diff --git 
a/src/test/kotlin/org/apache/cassandra/easystress/RateLimiterOptimizerTest.kt 
b/src/test/kotlin/org/apache/cassandra/easystress/RateLimiterOptimizerTest.kt
index 0c800c4..069ff54 100644
--- 
a/src/test/kotlin/org/apache/cassandra/easystress/RateLimiterOptimizerTest.kt
+++ 
b/src/test/kotlin/org/apache/cassandra/easystress/RateLimiterOptimizerTest.kt
@@ -26,6 +26,7 @@ import org.assertj.core.api.Assertions.assertThat
 import org.junit.jupiter.api.Test
 import org.junit.jupiter.api.extension.ExtendWith
 import java.util.Optional
+import java.util.concurrent.TimeUnit
 
 @ExtendWith(MockKExtension::class)
 class RateLimiterOptimizerTest {
@@ -76,4 +77,107 @@ class RateLimiterOptimizerTest {
         val newRate = optimizer.execute()
         assertThat(newRate).isGreaterThan(10.0)
     }
+
+    /**
+     * Under overload the client falls behind its own limit, so the limit 
constrains nothing.
+     * Cutting the limit has to bring it below what the client is actually 
achieving.
+     */
+    @Test
+    fun testReducesAgainstAchievedThroughput() {
+        val maxLatency = 100L
+        val optimizer = spyk(RateLimiterOptimizer(rateLimiter, metrics, 
maxLatency, maxLatency, isStepPhase = false))
+        every { optimizer.getCurrentAndMaxLatency() } returns pair(200.0, 
maxLatency)
+        every { optimizer.getTotalOperations() } returns 100
+
+        // the limit is 1000, but the client is only managing 400 ops/sec
+        every { optimizer.getCurrentTotalThroughput() } returns 400.0
+
+        val newRate = optimizer.execute()
+
+        // 400 * 0.9, not 1000 * 0.9
+        assertThat(newRate).isEqualTo(360.0)
+    }
+
+    /**
+     * The throughput tracker reports zero until it has warmed up.  That must 
not be read as a
+     * client managing no operations at all.
+     */
+    @Test
+    fun testIgnoresAColdThroughputTracker() {
+        val maxLatency = 100L
+        val optimizer = spyk(RateLimiterOptimizer(rateLimiter, metrics, 
maxLatency, maxLatency, isStepPhase = false))
+        every { optimizer.getCurrentAndMaxLatency() } returns pair(200.0, 
maxLatency)
+        every { optimizer.getTotalOperations() } returns 100
+        every { optimizer.getCurrentTotalThroughput() } returns 0.0
+
+        val newRate = optimizer.execute()
+
+        // one ordinary reduction against the current rate, not a drop to the 
floor
+        assertThat(newRate).isEqualTo(900.0)
+    }
+
+    /**
+     * A run of reductions must not drive the rate arbitrarily close to zero.
+     */
+    @Test
+    fun testRateHasAFloor() {
+        val maxLatency = 100L
+        val optimizer = spyk(RateLimiterOptimizer(rateLimiter, metrics, 
maxLatency, maxLatency, isStepPhase = false))
+        every { optimizer.getCurrentAndMaxLatency() } returns pair(500.0, 
maxLatency)
+        every { optimizer.getTotalOperations() } returns 100
+        every { optimizer.getCurrentTotalThroughput() } returns 1.0
+
+        val newRate = optimizer.execute()
+
+        // the starting rate is 1000, so the floor is 10
+        assertThat(newRate).isEqualTo(10.0)
+    }
+
+    /**
+     * Acting again before the latency window has passed means acting on the 
previous rate, which
+     * compounds every adjustment.
+     */
+    @Test
+    fun testWaitsForTheLatencyWindowBeforeChangingAgain() {
+        val maxLatency = 100L
+        var now = 1_000_000L
+        val optimizer =
+            spyk(
+                RateLimiterOptimizer(
+                    rateLimiter,
+                    metrics,
+                    maxLatency,
+                    maxLatency,
+                    isStepPhase = false,
+                    clock = { now },
+                ),
+            )
+        every { optimizer.getCurrentAndMaxLatency() } returns pair(110.0, 
maxLatency)
+        every { optimizer.getTotalOperations() } returns 100
+        every { optimizer.getCurrentTotalThroughput() } returns 1000.0
+
+        val firstRate = optimizer.execute()
+        assertThat(firstRate).isEqualTo(900.0)
+
+        // no time has passed, so the metrics still describe the old rate
+        assertThat(optimizer.execute()).isEqualTo(900.0)
+
+        // one full window later the optimizer may act again
+        now += TimeUnit.SECONDS.toMillis(Metrics.LATENCY_WINDOW_SECONDS)
+        assertThat(optimizer.execute()).isLessThan(900.0)
+    }
+
+    /**
+     * The increase depends on the fraction of the latency budget still 
unused, not on the size of
+     * the budget.  The same fraction has to produce the same increase at any 
target.
+     */
+    @Test
+    fun testIncreaseDoesNotDependOnTheSizeOfTheTarget() {
+        val optimizer = RateLimiterOptimizer(rateLimiter, metrics, 100, 100, 
isStepPhase = false)
+
+        val smallTarget = optimizer.getNextValue(1000.0, 1000.0, 10.0, 100)
+        val largeTarget = optimizer.getNextValue(1000.0, 1000.0, 100.0, 1000)
+
+        assertThat(smallTarget).isEqualTo(largeTarget)
+    }
 }


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

Reply via email to