Skip to content

Introduce operations rate limits - #1901

Open
IvanBorislavovDimitrov wants to merge 10 commits into
masterfrom
LMCROSSITXSADEPLOY-3360-operation-rate-limits
Open

IvanBorislavovDimitrov wants to merge 10 commits into
masterfrom
LMCROSSITXSADEPLOY-3360-operation-rate-limits

Conversation

@IvanBorislavovDimitrov

Copy link
Copy Markdown
Contributor

@IvanBorislavovDimitrov IvanBorislavovDimitrov changed the title Lmcrossitxsadeploy 3360 operation rate limits Introduce operations rate limits Aug 24, 2026
Add an application-level limiter that bounds the START of MTA operations
along two independent keys (per CF user and per space) using two
complementary mechanisms:

- a token-bucket RATE limiter (bucket4j) whose state is persisted and
  synchronized in PostgreSQL via a SELECT FOR UPDATE proxy manager, so
  the limit holds across all service instances sharing the database; and
- a concurrency cap on simultaneously non-final operations, counted over
  the shared operation table.

The limiter is gated behind a feature flag (disabled by default) and all
caps are configurable via environment variables. When triggered, the
operation-start endpoint returns HTTP 429 with a Retry-After header and
no process is started.

Developed test-first: unit tests for config, key derivation, the limiter
(mocked bucket + concurrency), and the 429 wiring; a Testcontainers
integration test proves the cross-instance PostgreSQL synchronization.
Rows in operation_rate_limit_bucket were never removed: one row per space
and per (space,user) key was inserted on first use and left forever, since
the token-bucket store set no expiration and nothing swept the table.

Set an expiration strategy so each row records when its bucket would be
fully refilled (i.e. indistinguishable from a fresh bucket), and add a
scheduled cleaner that deletes expired rows in batches. The cleaner runs on
a single instance, only while rate limiting is enabled, and swallows/logs
failures so it never disrupts the scheduler.
Replace the three inline exception-message string literals in the rate
limiter with named constants in the web Messages class, matching the
existing exception-message convention. No behavior change.
Increase the sweep batch to 1000 and the iteration cap to 10000 so a
single cleanup run can clear far more expired rows, matching landscapes
that accumulate many distinct rate-limit keys.
Relocate OperationRateLimitBucketCleaner into the process module's jobs
package, alongside the existing clean-up jobs, and move its BucketStore /
PostgresBucketStore collaborators into the process util package. The
bucket4j dependency, the Postgres integration test, and the failsafe
plugin move to the process module accordingly; the web limiter now uses
the bucket store transitively. The three cleaner log messages move to the
process Messages class. No behavior change.
Match the constructor-injection convention of the other @nAmed beans in
the process util package.
OperationRateLimiter now emits an INFO log line on every rejection
with user, spaceGuid, and reason in a fixed format that Dynatrace can
parse as structured fields:

  Operation start rejected: user="<u>" spaceGuid="<s>" reason="<r>"

This covers all three rejection paths: per-space active-op cap,
per-user active-op cap, and token-bucket exhaustion.

Also restores bucket4j + testcontainers dependencies in the web
module pom that were dropped during a prior rebase.
Adds OperationRateLimitMetricsMBean / OperationRateLimitMetrics with two
attributes:
- RateLimitRejectionCount — all-time total since last restart
- RateLimitRejectionCountInWindow — rolling window counter (resets on each
  Dynatrace poll cycle, consistent with UploadDurationMetrics)

OperationRateLimiter calls metrics.recordRejection() from the shared
rejectAndLog helper so every rejection — active-op cap, space bucket, or
user bucket — is counted.  Registered in JmxConfiguration under the object
name org.cloudfoundry.multiapps.controller.web.monitoring:type=Metrics,name=OperationRateLimitMetricsMBean.
@Yavor16
Yavor16 force-pushed the LMCROSSITXSADEPLOY-3360-operation-rate-limits branch from 47df5f3 to 08b0c76 Compare September 11, 2026 10:40
@sonarqubecloud

Copy link
Copy Markdown

void testSpaceKeyIsStableAcrossRuns() {
long firstValue = OperationRateLimitKeys.spaceKey(SPACE_GUID);
long secondValue = OperationRateLimitKeys.spaceKey(SPACE_GUID);
assertTrue(firstValue == secondValue);

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.

Why isn't assertEquals used here?

Comment on lines +77 to +80
private void checkTokenBuckets(String user, String spaceGuid) {
consumeSpaceToken(spaceGuid, user);
consumeUserToken(spaceGuid, user);
}

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.

What happens if the space token is consumed but after that the user bucket is exhausted? Wouldn't that lead to losing space tokens for other users in the space? Would checking the user first make more sense in that case?

private static final String SPACE_NAMESPACE_PREFIX = "space:";
private static final String USER_NAMESPACE_PREFIX = "user:";
private static final String SEGMENT_SEPARATOR = ":";
private static final HashFunction HASH_FUNCTION = Hashing.sha256();

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.

We shouldn't use sha256 to be compliant according to internal cryptography requirements. com.google.common.hash.Hashing provides sha384 - https://guava.dev/releases/33.5.0-jre/api/docs/com/google/common/hash/Hashing.html#sha384() which is compliant.


static String hashUser(String user) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")

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.

We should avoid using SHA-256 for internal cryptography requirements. Please use a compliant algorithm such as SHA-384.

Comment on lines +58 to +71
int activeOperationsPerSpace = operationService.createQuery()
.spaceId(spaceGuid)
.inNonFinalState()
.list()
.size();
if (activeOperationsPerSpace >= applicationConfiguration.getMaxActiveOperationsPerSpace()) {
rejectAndLog(user, spaceGuid, Messages.TOO_MANY_ACTIVE_OPERATIONS_IN_SPACE, NO_RETRY_AFTER_SECONDS);
}
int activeOperationsPerUser = operationService.createQuery()
.user(user)
.spaceId(spaceGuid)
.inNonFinalState()
.list()
.size();

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.

The second query is a subset of the first - both filter by spaceId + inNonFinalState. Could the user count be derived from the already-fetched list to avoid the second DB query?

Comment on lines +82 to +94
private void consumeSpaceToken(String spaceGuid, String user) {
BucketConfiguration configuration = buildBucketConfiguration(applicationConfiguration.getOperationRateLimitPerSpaceCapacity(),
applicationConfiguration.getOperationRateLimitPerSpaceRefillPerHour());
Bucket bucket = bucketStore.getBucket(OperationRateLimitKeys.spaceKey(spaceGuid), configuration);
consumeToken(bucket, user, spaceGuid);
}

private void consumeUserToken(String spaceGuid, String user) {
BucketConfiguration configuration = buildBucketConfiguration(applicationConfiguration.getOperationRateLimitPerUserCapacity(),
applicationConfiguration.getOperationRateLimitPerUserRefillPerHour());
Bucket bucket = bucketStore.getBucket(OperationRateLimitKeys.userKey(spaceGuid, user), configuration);
consumeToken(bucket, user, spaceGuid);
}

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.

BucketConfiguration is built on every call to checkStartAllowed - once for space, once for user. Since ApplicationConfiguration values don't change at runtime, could these be built once and reused?

this.mBeanServer = mBeanServer;
}

@Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES)

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.

Should we add the same instance guard - SELECTED_INSTANCE_FOR_CLEAN_UP from OperationRateLimitBucketCleaner in order to not run queries on every instance?

Comment on lines +55 to +71
private Map<String, Long> countActiveOperationsByUser() {
Map<String, Long> counts = new HashMap<>();
operationService.createQuery()
.inNonFinalState()
.list()
.forEach(op -> counts.merge(hashUser(op.getUser()), 1L, Long::sum));
return counts;
}

private Map<String, Long> countActiveOperationsBySpace() {
Map<String, Long> counts = new HashMap<>();
operationService.createQuery()
.inNonFinalState()
.list()
.forEach(op -> counts.merge(op.getSpaceId(), 1L, Long::sum));
return counts;
}

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.

Consider implementing a count query instead of list() to avoid loading all operations into memory just to count them.


@Test
void testIsOperationRateLimitingEnabled() {
Mockito.when(environment.getBoolean(ApplicationConfiguration.CFG_OPERATION_RATE_LIMITING_ENABLED,

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.

maybe remove the "Mockito." and make it a static import in order to be consistent with how it is in the OperationRateLimitBucketCleanerTest

Comment on lines +28 to +44
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
<configuration>
<includes>
<include>**/*IntegrationTest</include>
</includes>
</configuration>
</plugin>

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.

is that needed?

}

@Test
void testStartOperationWhenRateLimitAllowsStartsProcess() {

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.

just to remove "Mockito." for when and verify and make it static

Comment thread pom.xml
Comment on lines +180 to +185
<!-- https://mvnrepository.com/artifact/org.apache.maven.plugins/maven-failsafe-plugin -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.5.4</version>
</plugin>

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.

probably related to the other thing with the integration tests, is it needed?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants