Skip to content

fix: report remaining, not elapsed, time from LinearRateLimiter - #3521

Merged
csviri merged 2 commits into
operator-framework:mainfrom
csviri:fix/rate-limiter-remaining-duration
Aug 3, 2026
Merged

fix: report remaining, not elapsed, time from LinearRateLimiter#3521
csviri merged 2 commits into
operator-framework:mainfrom
csviri:fix/rate-limiter-remaining-duration

Conversation

@csviri

@csviri csviri commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

RateLimiter.isLimited is documented to return the "minimal duration
until a permission could be acquired again", but LinearRateLimiter
returned the time elapsed since the current period started:

Duration.between(actualState.getLastRefreshTime(), LocalDateTime.now())

The two are inverted. Measured with a 1000ms refresh period:

moment                  reported    correct
right after limit hit      19ms     ~1000ms
800ms into the period     804ms      ~200ms

EventProcessor.handleRateLimitedSubmission feeds this value straight
into TimerEventSource.scheduleOnce, so a rate-limited resource is
rescheduled almost immediately after the limit is reached (floored at
MINIMAL_RATE_LIMIT_RESCHEDULE_DURATION), gets rate-limited again, and
repeats — producing a burst of pointless timer events. Conversely, a
resource limited near the end of a period waits roughly a full extra
period after a permission was already available.

Now returns the time until the current period ends, clamped at zero.

returnsMinimalDurationToAcquirePermission only asserted
isLessThan(REFRESH_PERIOD), which held for both the correct and the
inverted value; it now also asserts the reported wait is close to the
full period. A second test asserts the reported duration shrinks as the
period elapses, which is what distinguishes remaining from elapsed. Both
fail without this change.

Part of #3517

Copilot AI review requested due to automatic review settings July 30, 2026 09:04
@openshift-ci openshift-ci Bot added the do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. label Jul 30, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes LinearRateLimiter#isLimited to report the remaining time until a permission can be acquired (as documented), preventing overly-early rescheduling of rate-limited resources.

Changes:

  • Update LinearRateLimiter.isLimited to compute remaining time in the current refresh period (clamped at zero).
  • Strengthen returnsMinimalDurationToAcquirePermission to assert the reported wait is close to the full refresh period.
  • Add a regression test ensuring the reported duration decreases as the refresh period elapses.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/LinearRateLimiter.java Switches isLimited from reporting elapsed time to reporting remaining time in the refresh period.
operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/rate/LinearRateLimiterTest.java Updates and adds tests to distinguish “remaining” vs “elapsed” duration behavior.

@csviri
csviri marked this pull request as ready for review August 3, 2026 07:50
@openshift-ci openshift-ci Bot removed the do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. label Aug 3, 2026
@openshift-ci
openshift-ci Bot requested review from metacosm and xstefank August 3, 2026 07:50
`RateLimiter.isLimited` is documented to return the "minimal duration
until a permission could be acquired again", but `LinearRateLimiter`
returned the time *elapsed* since the current period started:

    Duration.between(actualState.getLastRefreshTime(), LocalDateTime.now())

The two are inverted. Measured with a 1000ms refresh period:

    moment                  reported    correct
    right after limit hit      19ms     ~1000ms
    800ms into the period     804ms      ~200ms

`EventProcessor.handleRateLimitedSubmission` feeds this value straight
into `TimerEventSource.scheduleOnce`, so a rate-limited resource is
rescheduled almost immediately after the limit is reached (floored at
MINIMAL_RATE_LIMIT_RESCHEDULE_DURATION), gets rate-limited again, and
repeats — producing a burst of pointless timer events. Conversely, a
resource limited near the end of a period waits roughly a full extra
period after a permission was already available.

Now returns the time until the current period ends, clamped at zero.

`returnsMinimalDurationToAcquirePermission` only asserted
`isLessThan(REFRESH_PERIOD)`, which held for both the correct and the
inverted value; it now also asserts the reported wait is close to the
full period. A second test asserts the reported duration shrinks as the
period elapses, which is what distinguishes remaining from elapsed. Both
fail without this change.
Copilot AI review requested due to automatic review settings August 3, 2026 07:50
@csviri
csviri force-pushed the fix/rate-limiter-remaining-duration branch from aaf985a to f78c082 Compare August 3, 2026 07:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (1)

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/LinearRateLimiter.java:70

  • isLimited calls LocalDateTime.now() multiple times and can therefore return Optional.of(Duration.ZERO) (or clamp a negative value to zero) even though the refresh period has effectively just expired between the two now() calls. In that case the caller treats the resource as still rate-limited and reschedules, even though a permission could already be acquired. Consider capturing now once and deciding expiration based on the same timestamp (and treating expiry-at-boundary as not limited).
      var remaining =
          Duration.between(
              LocalDateTime.now(), actualState.getLastRefreshTime().plus(refreshPeriod));
      return Optional.of(remaining.isNegative() ? Duration.ZERO : remaining);

Signed-off-by: Attila Mészáros <a_meszaros@apple.com>
Copilot AI review requested due to automatic review settings August 3, 2026 07:56

@csviri csviri left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (2)

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/LinearRateLimiter.java:66

  • isLimited returns Optional.of(Duration.ZERO) when lastRefreshTime is exactly now.minus(refreshPeriod) (period boundary) because the reset branch uses a strict isBefore check. Per the RateLimiter contract, when the minimal wait is 0 a permission can be acquired now, so this should return Optional.empty() (and increment the new period count) rather than being treated as still rate-limited (which in EventProcessor triggers a timer reschedule with a non-zero floor).
    var now = LocalDateTime.now();
    if (actualState.getCount() < limitForPeriod) {
      actualState.increaseCount();
      return Optional.empty();
    } else if (actualState.getLastRefreshTime().isBefore(now.minus(refreshPeriod))) {
      actualState.reset();
      actualState.increaseCount();
      return Optional.empty();
    } else {
      var remaining = Duration.between(now, actualState.getLastRefreshTime().plus(refreshPeriod));
      return Optional.of(remaining.isNegative() ? Duration.ZERO : remaining);

operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/rate/LinearRateLimiterTest.java:68

  • The new test relies on Thread.sleep(REFRESH_PERIOD / 2) with REFRESH_PERIOD set to 300ms and then assumes the second isLimited call is still within the same period (orElseThrow()). On slow/loaded CI runners, oversleep/jitter can exceed the remaining ~150ms and the limiter can reset, making isLimited return empty and causing a sporadic failure. Consider using a longer refresh period just for this test to reduce flakiness.
  void reportedDurationIsTheTimeRemainingNotTheTimeElapsed() throws InterruptedException {
    var rl = new LinearRateLimiter(REFRESH_PERIOD, 1);
    assertThat(rl.isLimited(state)).isEmpty();

    var justAfterLimit = rl.isLimited(state).orElseThrow();
    Thread.sleep(REFRESH_PERIOD.toMillis() / 2);
    var halfWayThroughPeriod = rl.isLimited(state).orElseThrow();

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (2)

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/LinearRateLimiter.java:60

  • The refresh-period reset check excludes the exact boundary case (when lastRefreshTime equals now.minus(refreshPeriod)), which causes isLimited to return a 0-duration wait instead of allowing a permission immediately at the period boundary.
    } else if (actualState.getLastRefreshTime().isBefore(now.minus(refreshPeriod))) {

operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/rate/LinearRateLimiterTest.java:72

  • This test uses Thread.sleep with a relatively short refresh period, which can be flaky on loaded CI runners (oversleeping past the period makes the second isLimited call return empty and the test fail). Consider making it deterministic by constructing RateState instances with controlled lastRefreshTime values instead of sleeping.
    assertThat(rl.isLimited(state)).isEmpty();

    var justAfterLimit = rl.isLimited(state).orElseThrow();
    Thread.sleep(REFRESH_PERIOD.toMillis() / 2);
    var halfWayThroughPeriod = rl.isLimited(state).orElseThrow();

@csviri
csviri merged commit 2bc85a0 into operator-framework:main Aug 3, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants