-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Throttle and Debounce implementations (#22)
* Throttle and Debounce implementations * Comments
- Loading branch information
Showing
5 changed files
with
339 additions
and
12 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
117 changes: 117 additions & 0 deletions
117
src/main/java/com/ginsberg/gatherers4j/ThrottlingGatherer.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
/* | ||
* Copyright 2024 Todd Ginsberg | ||
* | ||
* Licensed 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 com.ginsberg.gatherers4j; | ||
|
||
import java.time.Clock; | ||
import java.time.Duration; | ||
import java.util.concurrent.locks.LockSupport; | ||
import java.util.function.Supplier; | ||
import java.util.stream.Gatherer; | ||
|
||
import static com.ginsberg.gatherers4j.GathererUtils.NANOS_PER_MILLISECOND; | ||
import static com.ginsberg.gatherers4j.GathererUtils.mustNotBeNull; | ||
|
||
public class ThrottlingGatherer<INPUT> implements Gatherer<INPUT, ThrottlingGatherer.State, INPUT> { | ||
|
||
public enum LimitRule { | ||
Drop, | ||
Pause | ||
} | ||
|
||
private final LimitRule limitRule; | ||
private final Duration duration; | ||
private final int allowed; | ||
private Clock clock = Clock.systemUTC(); | ||
|
||
ThrottlingGatherer(final LimitRule limitRule, final int allowed, final Duration duration) { | ||
mustNotBeNull(duration, "Duration must not be null"); | ||
if (duration.toMillis() < 1) { | ||
throw new IllegalArgumentException("Minimum duration is 1ms"); | ||
} | ||
if (allowed <= 0) { | ||
throw new IllegalArgumentException("Allowed must be positive"); | ||
} | ||
this.limitRule = limitRule == null ? LimitRule.Pause : limitRule; | ||
this.duration = duration; | ||
this.allowed = allowed; | ||
} | ||
|
||
public ThrottlingGatherer<INPUT> withClock(final Clock clock) { | ||
mustNotBeNull(clock, "Clock must not be null"); | ||
this.clock = clock; | ||
return this; | ||
} | ||
|
||
@Override | ||
public Supplier<State> initializer() { | ||
return () -> new State(limitRule, duration, allowed, clock); | ||
} | ||
|
||
@Override | ||
public Integrator<State, INPUT, INPUT> integrator() { | ||
return (state, element, downstream) -> { | ||
if (!downstream.isRejecting() && state.attempt()) { | ||
downstream.push(element); | ||
} | ||
return !downstream.isRejecting(); | ||
}; | ||
} | ||
|
||
public static class State { | ||
final int allowedPerPeriod; | ||
final long periodDurationMillis; | ||
final LimitRule limitRule; | ||
final Clock clock; | ||
long thisPeriodEnd; | ||
int remainingPermits; | ||
|
||
State(final LimitRule limitRule, final Duration duration, final int allowed, final Clock clock) { | ||
this.limitRule = limitRule; | ||
this.allowedPerPeriod = allowed; | ||
this.periodDurationMillis = duration.toMillis(); | ||
this.clock = clock; | ||
resetPeriod(); | ||
} | ||
|
||
private void resetPeriod() { | ||
thisPeriodEnd = clock.millis() + periodDurationMillis; | ||
remainingPermits = allowedPerPeriod; | ||
} | ||
|
||
// Assuming this is not run in parallel. Gate with a lock if that assumption fails/changes. | ||
boolean attempt() { | ||
final long now = clock.millis(); | ||
if(now < thisPeriodEnd) { | ||
// The current period has not ended | ||
if(remainingPermits == 0) { | ||
if(limitRule == LimitRule.Drop) { | ||
return false; | ||
} | ||
// Wait until next period, reset counters, fall through to take permit. | ||
LockSupport.parkNanos((thisPeriodEnd - now) * NANOS_PER_MILLISECOND); | ||
resetPeriod(); | ||
} | ||
} else { | ||
// We're in a new period, reset the counters | ||
// and fall through to take permit. | ||
resetPeriod(); | ||
} | ||
remainingPermits--; | ||
return true; | ||
} | ||
} | ||
} |
164 changes: 164 additions & 0 deletions
164
src/test/java/com/ginsberg/gatherers4j/ThrottlingGathererTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,164 @@ | ||
/* | ||
* Copyright 2024 Todd Ginsberg | ||
* | ||
* Licensed 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 com.ginsberg.gatherers4j; | ||
|
||
import org.junit.jupiter.api.Test; | ||
|
||
import java.time.Clock; | ||
import java.time.Duration; | ||
import java.time.Instant; | ||
import java.time.ZoneId; | ||
import java.util.List; | ||
import java.util.concurrent.locks.LockSupport; | ||
import java.util.stream.Stream; | ||
|
||
import static org.assertj.core.api.Assertions.assertThat; | ||
import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||
|
||
class ThrottlingGathererTest { | ||
|
||
@Test | ||
void amountIsNegative() { | ||
assertThatThrownBy(() -> | ||
Stream.of("A").gather(Gatherers4j.throttle(-1, Duration.ofSeconds(1))) | ||
).isExactlyInstanceOf(IllegalArgumentException.class); | ||
} | ||
|
||
@Test | ||
void amountIsZero() { | ||
assertThatThrownBy(() -> | ||
Stream.of("A").gather(Gatherers4j.throttle(-1, Duration.ofSeconds(1))) | ||
).isExactlyInstanceOf(IllegalArgumentException.class); | ||
} | ||
|
||
@Test | ||
void clockMustNotBeNull() { | ||
assertThatThrownBy(() -> | ||
Stream.of("A").gather(Gatherers4j.throttle(1, Duration.ofSeconds(1)).withClock(null)) | ||
).isExactlyInstanceOf(IllegalArgumentException.class); | ||
} | ||
|
||
@Test | ||
void defaultsRuleToPause() { | ||
// Arrange/Act | ||
final ThrottlingGatherer<String> gatherer = new ThrottlingGatherer<>(null, 1, Duration.ofSeconds(1)); | ||
|
||
// Assert | ||
assertThat(gatherer).hasFieldOrPropertyWithValue("limitRule", ThrottlingGatherer.LimitRule.Pause); | ||
} | ||
|
||
@Test | ||
void durationIsNegative() { | ||
assertThatThrownBy(() -> | ||
Stream.of("A").gather(Gatherers4j.throttle(1, Duration.ofSeconds(-1))) | ||
).isExactlyInstanceOf(IllegalArgumentException.class); | ||
} | ||
|
||
@Test | ||
void durationIsNull() { | ||
assertThatThrownBy(() -> | ||
Stream.of("A").gather(Gatherers4j.throttle(1, null)) | ||
).isExactlyInstanceOf(IllegalArgumentException.class); | ||
} | ||
|
||
@Test | ||
void durationIsZero() { | ||
assertThatThrownBy(() -> | ||
Stream.of("A").gather(Gatherers4j.throttle(1, Duration.ofSeconds(0))) | ||
).isExactlyInstanceOf(IllegalArgumentException.class); | ||
} | ||
|
||
@Test | ||
void testThrottlingCrossesPeriod() { | ||
// Arrange | ||
final Stream<String> input = Stream.of("A", "B", "C"); | ||
final Duration duration = Duration.ofMillis(100); | ||
final Clock clock = new PredictableClock(0, 0, 0, 101, 0, 0); | ||
|
||
// Act | ||
final List<Long> output = input | ||
.gather(Gatherers4j.throttle(2, duration).withClock(clock)) | ||
.map(_ -> System.currentTimeMillis()) | ||
.toList(); | ||
|
||
// Assert | ||
assertThat(output.get(1) - output.get(0)).isLessThan(duration.toMillis()); | ||
assertThat(output.get(2) - output.get(0)).isGreaterThanOrEqualTo(duration.toMillis()); | ||
} | ||
|
||
@Test | ||
void testThrottlingWithDrop() { | ||
// Arrange | ||
final Stream<String> input = Stream.of("A", "B", "C"); | ||
final Duration duration = Duration.ofMillis(100); | ||
|
||
// Act | ||
final List<String> output = input | ||
.gather(Gatherers4j.debounce(2, duration)) | ||
.toList(); | ||
|
||
// Assert | ||
assertThat(output).containsExactly("A", "B"); | ||
} | ||
|
||
@Test | ||
void testThrottlingWithPause() { | ||
// Arrange | ||
final Stream<String> input = Stream.of("A", "B", "C"); | ||
final Duration duration = Duration.ofMillis(100); | ||
|
||
// Act | ||
final List<Long> output = input | ||
.gather(Gatherers4j.throttle(2, duration)) | ||
.map(_ -> System.currentTimeMillis()) | ||
.toList(); | ||
|
||
// Assert | ||
assertThat(output.get(1) - output.get(0)).isLessThan(duration.toMillis()); | ||
assertThat(output.get(2) - output.get(0)).isGreaterThanOrEqualTo(duration.toMillis()); | ||
} | ||
|
||
private static class PredictableClock extends Clock { | ||
|
||
private final int[] pauses; | ||
private int invocation; | ||
|
||
private PredictableClock(final int... pauses) { | ||
this.pauses = pauses; | ||
} | ||
|
||
@Override | ||
public ZoneId getZone() { | ||
return null; | ||
} | ||
|
||
@Override | ||
public Instant instant() { | ||
int when = pauses[invocation]; | ||
if (when > 0) { | ||
LockSupport.parkNanos(when * GathererUtils.NANOS_PER_MILLISECOND); | ||
} | ||
invocation = (invocation + 1) % pauses.length; | ||
return Instant.now(); | ||
} | ||
|
||
@Override | ||
public Clock withZone(ZoneId zone) { | ||
return null; | ||
} | ||
} | ||
} |