Skip to content

Commit cad0e1c

Browse files
committed
feat: rate limiting (#1290)
1 parent 4706fc2 commit cad0e1c

25 files changed

+528
-59
lines changed

docs/documentation/features.md

+35
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,41 @@ intersections:
324324
will still happen, but won't reset the retry, will be still marked as the last attempt in the retry info. The point
325325
(1) still holds, but in case of an error, no retry will happen.
326326

327+
## Rate Limiting
328+
329+
It is possible to rate limit reconciliation on a per-resource basis. The rate limit also takes
330+
precedence over retry/re-schedule configurations: for example, even if a retry was scheduled for
331+
the next second but this request would make the resource go over its rate limit, the next
332+
reconciliation will be postponed according to the rate limiting rules. Note that the
333+
reconciliation is never cancelled, it will just be executed as early as possible based on rate
334+
limitations.
335+
336+
Rate limiting is by default turned **off**, since correct configuration depends on the reconciler
337+
implementation, in particular, on how long a typical reconciliation takes.
338+
(The parallelism of reconciliation itself can be
339+
limited [`ConfigurationService`](https://github.com/java-operator-sdk/java-operator-sdk/blob/ce4d996ee073ebef5715737995fc3d33f4751275/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java#L120-L120)
340+
by configuring the `ExecutorService` appropriately.)
341+
342+
A default rate limiter implementation is provided, see:
343+
[`PeriodRateLimiter`](https://github.com/java-operator-sdk/java-operator-sdk/blob/ce4d996ee073ebef5715737995fc3d33f4751275/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/PeriodRateLimiter.java#L14-L14)
344+
.
345+
Users can override it by implementing their own
346+
[`RateLimiter`](https://github.com/java-operator-sdk/java-operator-sdk/blob/ce4d996ee073ebef5715737995fc3d33f4751275/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/RateLimiter.java)
347+
.
348+
349+
To configure the default rate limiter use `@ControllerConfiguration` annotation. The following
350+
configuration limits
351+
each resource to reconcile at most twice within a 3 second interval:
352+
353+
`@ControllerConfiguration(rateLimit = @RateLimit(limitForPeriod = 2,refreshPeriod = 3,refreshPeriodTimeUnit = TimeUnit.SECONDS))`
354+
.
355+
356+
Thus, if a given resource was reconciled twice in one second, no further reconciliation for this
357+
resource will happen before two seconds have elapsed. Note that, since rate is limited on a
358+
per-resource basis, other resources can still be reconciled at the same time, as long, of course,
359+
that they stay within their own rate limits.
360+
361+
327362
## Handling Related Events with Event Sources
328363

329364
See also this [blog post](https://csviri.medium.com/java-operator-sdk-introduction-to-event-sources-a1aab5af4b7b).

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AnnotationControllerConfiguration.java

+13
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource;
2828
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResourceConfig;
2929
import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition;
30+
import io.javaoperatorsdk.operator.processing.event.rate.PeriodRateLimiter;
31+
import io.javaoperatorsdk.operator.processing.event.rate.RateLimiter;
3032
import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEventFilter;
3133
import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEventFilters;
3234
import io.javaoperatorsdk.operator.processing.event.source.filter.VoidGenericFilter;
@@ -150,6 +152,17 @@ public Optional<Duration> reconciliationMaxInterval() {
150152
}
151153
}
152154

155+
@Override
156+
public RateLimiter getRateLimiter() {
157+
if (annotation.rateLimit() != null) {
158+
return new PeriodRateLimiter(Duration.of(annotation.rateLimit().refreshPeriod(),
159+
annotation.rateLimit().refreshPeriodTimeUnit().toChronoUnit()),
160+
annotation.rateLimit().limitForPeriod());
161+
} else {
162+
return io.javaoperatorsdk.operator.api.config.ControllerConfiguration.super.getRateLimiter();
163+
}
164+
}
165+
153166
@Override
154167
@SuppressWarnings("unchecked")
155168
public Optional<Predicate<P>> onAddFilter() {

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java

+8
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,17 @@
88
import io.fabric8.kubernetes.api.model.HasMetadata;
99
import io.javaoperatorsdk.operator.ReconcilerUtils;
1010
import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec;
11+
import io.javaoperatorsdk.operator.processing.event.rate.PeriodRateLimiter;
12+
import io.javaoperatorsdk.operator.processing.event.rate.RateLimiter;
1113
import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEventFilter;
1214
import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEventFilters;
1315
import io.javaoperatorsdk.operator.processing.retry.GenericRetry;
1416
import io.javaoperatorsdk.operator.processing.retry.Retry;
1517

1618
public interface ControllerConfiguration<R extends HasMetadata> extends ResourceConfiguration<R> {
1719

20+
RateLimiter DEFAULT_RATE_LIMITER = new PeriodRateLimiter();
21+
1822
default String getName() {
1923
return ReconcilerUtils.getDefaultReconcilerName(getAssociatedReconcilerClassName());
2024
}
@@ -43,6 +47,10 @@ default RetryConfiguration getRetryConfiguration() {
4347
return RetryConfiguration.DEFAULT;
4448
}
4549

50+
default RateLimiter getRateLimiter() {
51+
return DEFAULT_RATE_LIMITER;
52+
}
53+
4654
/**
4755
* Allow controllers to filter events before they are passed to the
4856
* {@link io.javaoperatorsdk.operator.processing.event.EventHandler}.

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java

+9
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import io.fabric8.kubernetes.api.model.HasMetadata;
1414
import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec;
1515
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResourceConfig;
16+
import io.javaoperatorsdk.operator.processing.event.rate.RateLimiter;
1617
import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEventFilter;
1718
import io.javaoperatorsdk.operator.processing.retry.GenericRetry;
1819
import io.javaoperatorsdk.operator.processing.retry.Retry;
@@ -35,6 +36,7 @@ public class ControllerConfigurationOverrider<R extends HasMetadata> {
3536
private Predicate<R> onAddFilter;
3637
private BiPredicate<R, R> onUpdateFilter;
3738
private Predicate<R> genericFilter;
39+
private RateLimiter rateLimiter;
3840

3941
private ControllerConfigurationOverrider(ControllerConfiguration<R> original) {
4042
finalizer = original.getFinalizerName();
@@ -52,6 +54,7 @@ private ControllerConfigurationOverrider(ControllerConfiguration<R> original) {
5254
this.genericFilter = original.genericFilter().orElse(null);
5355
dependentResources.forEach(drs -> namedDependentResourceSpecs.put(drs.getName(), drs));
5456
this.original = original;
57+
this.rateLimiter = original.getRateLimiter();
5558
}
5659

5760
public ControllerConfigurationOverrider<R> withFinalizer(String finalizer) {
@@ -114,6 +117,11 @@ public ControllerConfigurationOverrider<R> withRetry(RetryConfiguration retry) {
114117
return this;
115118
}
116119

120+
public ControllerConfigurationOverrider<R> withRateLimiter(RateLimiter rateLimiter) {
121+
this.rateLimiter = rateLimiter;
122+
return this;
123+
}
124+
117125
public ControllerConfigurationOverrider<R> withLabelSelector(String labelSelector) {
118126
this.labelSelector = labelSelector;
119127
return this;
@@ -196,6 +204,7 @@ public ControllerConfiguration<R> build() {
196204
onAddFilter,
197205
onUpdateFilter,
198206
genericFilter,
207+
rateLimiter,
199208
newDependentSpecs);
200209
}
201210

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/DefaultControllerConfiguration.java

+9-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import io.fabric8.kubernetes.api.model.HasMetadata;
1212
import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec;
13+
import io.javaoperatorsdk.operator.processing.event.rate.RateLimiter;
1314
import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEventFilter;
1415
import io.javaoperatorsdk.operator.processing.retry.Retry;
1516

@@ -27,6 +28,7 @@ public class DefaultControllerConfiguration<R extends HasMetadata>
2728
private final ResourceEventFilter<R> resourceEventFilter;
2829
private final List<DependentResourceSpec> dependents;
2930
private final Duration reconciliationMaxInterval;
31+
private final RateLimiter rateLimiter;
3032

3133
// NOSONAR constructor is meant to provide all information
3234
public DefaultControllerConfiguration(
@@ -44,6 +46,7 @@ public DefaultControllerConfiguration(
4446
Predicate<R> onAddFilter,
4547
BiPredicate<R, R> onUpdateFilter,
4648
Predicate<R> genericFilter,
49+
RateLimiter rateLimiter,
4750
List<DependentResourceSpec> dependents) {
4851
super(labelSelector, resourceClass, onAddFilter, onUpdateFilter, genericFilter, namespaces);
4952
this.associatedControllerClassName = associatedControllerClassName;
@@ -57,7 +60,7 @@ public DefaultControllerConfiguration(
5760
? ControllerConfiguration.super.getRetry()
5861
: retry;
5962
this.resourceEventFilter = resourceEventFilter;
60-
63+
this.rateLimiter = rateLimiter;
6164
this.dependents = dependents != null ? dependents : Collections.emptyList();
6265
}
6366

@@ -105,4 +108,9 @@ public List<DependentResourceSpec> getDependentResources() {
105108
public Optional<Duration> reconciliationMaxInterval() {
106109
return Optional.ofNullable(reconciliationMaxInterval);
107110
}
111+
112+
@Override
113+
public RateLimiter getRateLimiter() {
114+
return rateLimiter;
115+
}
108116
}

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ControllerConfiguration.java

+3
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@
9191
ReconciliationMaxInterval reconciliationMaxInterval() default @ReconciliationMaxInterval(
9292
interval = 10);
9393

94+
95+
RateLimit rateLimit() default @RateLimit;
96+
9497
/**
9598
* Optional list of {@link Dependent} configurations which associate a resource type to a
9699
* {@link io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource} implementation
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package io.javaoperatorsdk.operator.api.reconciler;
2+
3+
import java.lang.annotation.ElementType;
4+
import java.lang.annotation.Retention;
5+
import java.lang.annotation.RetentionPolicy;
6+
import java.lang.annotation.Target;
7+
import java.util.concurrent.TimeUnit;
8+
9+
import io.javaoperatorsdk.operator.processing.event.rate.PeriodRateLimiter;
10+
11+
@Retention(RetentionPolicy.RUNTIME)
12+
@Target({ElementType.TYPE})
13+
public @interface RateLimit {
14+
15+
int limitForPeriod() default PeriodRateLimiter.NO_LIMIT_PERIOD;
16+
17+
int refreshPeriod() default PeriodRateLimiter.DEFAULT_REFRESH_PERIOD_SECONDS;
18+
19+
/**
20+
* @return time unit for max delay between reconciliations
21+
*/
22+
TimeUnit refreshPeriodTimeUnit() default TimeUnit.SECONDS;
23+
}

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java

+29-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.javaoperatorsdk.operator.processing.event;
22

3+
import java.time.Duration;
34
import java.util.HashMap;
45
import java.util.HashSet;
56
import java.util.Map;
@@ -20,6 +21,7 @@
2021
import io.javaoperatorsdk.operator.api.reconciler.RetryInfo;
2122
import io.javaoperatorsdk.operator.processing.LifecycleAware;
2223
import io.javaoperatorsdk.operator.processing.MDCUtils;
24+
import io.javaoperatorsdk.operator.processing.event.rate.RateLimiter;
2325
import io.javaoperatorsdk.operator.processing.event.source.Cache;
2426
import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceAction;
2527
import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent;
@@ -32,18 +34,20 @@
3234
class EventProcessor<R extends HasMetadata> implements EventHandler, LifecycleAware {
3335

3436
private static final Logger log = LoggerFactory.getLogger(EventProcessor.class);
37+
private static final long MINIMAL_RATE_LIMIT_RESCHEDULE_DURATION = 50;
3538

39+
private volatile boolean running;
3640
private final Set<ResourceID> underProcessing = new HashSet<>();
3741
private final ReconciliationDispatcher<R> reconciliationDispatcher;
3842
private final Retry retry;
3943
private final Map<ResourceID, RetryExecution> retryState = new HashMap<>();
4044
private final ExecutorService executor;
4145
private final String controllerName;
4246
private final Metrics metrics;
43-
private volatile boolean running;
4447
private final Cache<R> cache;
4548
private final EventSourceManager<R> eventSourceManager;
4649
private final EventMarker eventMarker = new EventMarker();
50+
private final RateLimiter rateLimiter;
4751

4852
EventProcessor(EventSourceManager<R> eventSourceManager) {
4953
this(
@@ -53,6 +57,7 @@ class EventProcessor<R extends HasMetadata> implements EventHandler, LifecycleAw
5357
new ReconciliationDispatcher<>(eventSourceManager.getController()),
5458
eventSourceManager.getController().getConfiguration().getRetry(),
5559
ConfigurationServiceProvider.instance().getMetrics(),
60+
eventSourceManager.getController().getConfiguration().getRateLimiter(),
5661
eventSourceManager);
5762
}
5863

@@ -61,6 +66,7 @@ class EventProcessor<R extends HasMetadata> implements EventHandler, LifecycleAw
6166
EventSourceManager<R> eventSourceManager,
6267
String relatedControllerName,
6368
Retry retry,
69+
RateLimiter rateLimiter,
6470
Metrics metrics) {
6571
this(
6672
eventSourceManager.getControllerResourceEventSource(),
@@ -69,6 +75,7 @@ class EventProcessor<R extends HasMetadata> implements EventHandler, LifecycleAw
6975
reconciliationDispatcher,
7076
retry,
7177
metrics,
78+
rateLimiter,
7279
eventSourceManager);
7380
}
7481

@@ -79,6 +86,7 @@ private EventProcessor(
7986
ReconciliationDispatcher<R> reconciliationDispatcher,
8087
Retry retry,
8188
Metrics metrics,
89+
RateLimiter rateLimiter,
8290
EventSourceManager<R> eventSourceManager) {
8391
this.running = false;
8492
this.executor =
@@ -92,6 +100,7 @@ private EventProcessor(
92100
this.cache = cache;
93101
this.metrics = metrics != null ? metrics : Metrics.NOOP;
94102
this.eventSourceManager = eventSourceManager;
103+
this.rateLimiter = rateLimiter;
95104
}
96105

97106
@Override
@@ -128,6 +137,11 @@ private void submitReconciliationExecution(ResourceID resourceID) {
128137
Optional<R> latest = cache.get(resourceID);
129138
latest.ifPresent(MDCUtils::addResourceInfo);
130139
if (!controllerUnderExecution && latest.isPresent()) {
140+
var rateLimiterPermission = rateLimiter.acquirePermission(resourceID);
141+
if (rateLimiterPermission.isPresent()) {
142+
handleRateLimitedSubmission(resourceID, rateLimiterPermission.get());
143+
return;
144+
}
131145
setUnderExecutionProcessing(resourceID);
132146
final var retryInfo = retryInfo(resourceID);
133147
ExecutionScope<R> executionScope = new ExecutionScope<>(latest.get(), retryInfo);
@@ -193,6 +207,14 @@ private boolean isResourceMarkedForDeletion(ResourceEvent resourceEvent) {
193207
return resourceEvent.getResource().map(HasMetadata::isMarkedForDeletion).orElse(false);
194208
}
195209

210+
private void handleRateLimitedSubmission(ResourceID resourceID, Duration minimalDuration) {
211+
var minimalDurationMillis = minimalDuration.toMillis();
212+
log.debug("Rate limited resource: {}, rescheduled in {} millis", resourceID,
213+
minimalDurationMillis);
214+
retryEventSource().scheduleOnce(resourceID,
215+
Math.max(minimalDurationMillis, MINIMAL_RATE_LIMIT_RESCHEDULE_DURATION));
216+
}
217+
196218
private RetryInfo retryInfo(ResourceID resourceID) {
197219
return retryState.get(resourceID);
198220
}
@@ -251,11 +273,10 @@ private void reScheduleExecutionIfInstructed(
251273
postExecutionControl
252274
.getReScheduleDelay()
253275
.ifPresent(delay -> {
254-
if (log.isDebugEnabled()) {
255-
log.debug("ReScheduling event for resource: {} with delay: {}",
256-
ResourceID.fromResource(customResource), delay);
257-
}
258-
retryEventSource().scheduleOnce(customResource, delay);
276+
var resourceID = ResourceID.fromResource(customResource);
277+
log.debug("ReScheduling event for resource: {} with delay: {}",
278+
resourceID, delay);
279+
retryEventSource().scheduleOnce(resourceID, delay);
259280
});
260281
}
261282

@@ -289,7 +310,7 @@ private void handleRetryOnException(
289310
delay,
290311
resourceID);
291312
metrics.failedReconciliation(resourceID, exception);
292-
retryEventSource().scheduleOnce(executionScope.getResource(), delay);
313+
retryEventSource().scheduleOnce(resourceID, delay);
293314
},
294315
() -> log.error("Exhausted retries for {}", executionScope));
295316
}
@@ -315,6 +336,7 @@ private RetryExecution getOrInitRetryExecution(ExecutionScope<R> executionScope)
315336
private void cleanupForDeletedEvent(ResourceID resourceID) {
316337
log.debug("Cleaning up for delete event for: {}", resourceID);
317338
eventMarker.cleanup(resourceID);
339+
rateLimiter.clear(resourceID);
318340
metrics.cleanupDoneFor(resourceID);
319341
}
320342

0 commit comments

Comments
 (0)