Merge "Replace Semaphore with AtomicInteger"
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/AccountLimitsFinder.java b/src/main/java/com/googlesource/gerrit/plugins/quota/AccountLimitsFinder.java
index 53651eb..2c24f1d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/AccountLimitsFinder.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/AccountLimitsFinder.java
@@ -23,7 +23,6 @@
import com.google.gerrit.extensions.restapi.TopLevelResource;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.account.GroupMembership;
-import com.google.gerrit.server.group.GroupResource;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.restapi.group.GroupsCollection;
import com.google.inject.Inject;
@@ -57,28 +56,35 @@
if (limits.isPresent()) {
GroupMembership memberShip = user.getEffectiveGroups();
for (String groupName : limits.get().keySet()) {
- try {
- GroupResource group =
- groupsCollection.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(groupName));
- Optional<GroupDescription.Internal> maybeInternalGroup = group.asInternalGroup();
- if (!maybeInternalGroup.isPresent()) {
- log.debug("Ignoring limits for non-internal group ''{}'' in quota.config", groupName);
- } else if (memberShip.contains(maybeInternalGroup.get().getGroupUUID())) {
- RateLimit groupLimit = limits.get().get(groupName);
- if (groupLimit != null) {
- return Optional.of(groupLimit);
- }
- }
- } catch (ResourceNotFoundException e) {
- log.debug("Ignoring limits for unknown group ''{}'' in quota.config", groupName);
- } catch (AuthException e) {
- log.debug("Ignoring limits for non-visible group ''{}'' in quota.config", groupName);
+ RateLimit groupLimit = limits.get().get(groupName);
+ if (groupLimit != null && isMatching(memberShip, groupName)) {
+ return Optional.of(groupLimit);
}
}
}
return Optional.empty();
}
+ public boolean isMatching(GroupMembership membership, String groupName) {
+ try {
+ Optional<GroupDescription.Internal> maybeInternalGroup =
+ groupsCollection
+ .parse(TopLevelResource.INSTANCE, IdString.fromDecoded(groupName))
+ .asInternalGroup();
+ if (!maybeInternalGroup.isPresent()) {
+ log.debug("Ignoring limits for non-internal group ''{}'' in quota.config", groupName);
+ } else if (membership.contains(maybeInternalGroup.get().getGroupUUID())) {
+ return true;
+ }
+ } catch (ResourceNotFoundException e) {
+ log.debug("Ignoring limits for unknown group ''{}'' in quota.config", groupName);
+ } catch (AuthException e) {
+ log.debug("Ignoring limits for non-visible group ''{}'' in quota.config", groupName);
+ }
+
+ return false;
+ }
+
/**
* @param type type of rate limit
* @return global rate limit
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/HttpModule.java b/src/main/java/com/googlesource/gerrit/plugins/quota/HttpModule.java
index 28c0a60..8f19b37 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/HttpModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/HttpModule.java
@@ -51,6 +51,7 @@
@Override
protected void configure() {
DynamicSet.bind(binder(), AllRequestFilter.class).to(RestApiRateLimiter.class);
+ DynamicSet.bind(binder(), AllRequestFilter.class).to(MaxConnectionsLimiter.class);
bindConstant()
.annotatedWith(Names.named(RateMsgHelper.RESTAPI_CONFIGURABLE_MSG_ANNOTATION))
.to(restapiLimitExceededMsg);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/MaxConnectionsLimiter.java b/src/main/java/com/googlesource/gerrit/plugins/quota/MaxConnectionsLimiter.java
new file mode 100644
index 0000000..f2b18cd
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/MaxConnectionsLimiter.java
@@ -0,0 +1,145 @@
+package com.googlesource.gerrit.plugins.quota;
+
+import com.google.gerrit.httpd.AllRequestFilter;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
+
+import java.io.IOException;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+@Singleton
+public class MaxConnectionsLimiter extends AllRequestFilter {
+ record Limit(String group, Integer restApiLimit) {}
+
+ private static final String GLOBAL_KEY = "global";
+ private static final String CONFIG_KEY = "maxConnectionsPerUserForTask";
+ private static final String CONFIG_REST_API = "rest-api";
+ private static final Pattern REST_API_REGEX = Pattern.compile("(\\d+)\\s+" + CONFIG_REST_API);
+
+ private static final Pattern REST_API_PATH_PATTERN =
+ Pattern.compile(
+ "^/(?:a/)?(access|accounts|changes|config|groups|plugins|projects|tools)/.*$");
+
+ private final Map<String, Integer> connectionsByUser = new ConcurrentHashMap<>();
+ private final Provider<CurrentUser> userProvider;
+ private final AccountLimitsFinder accountLimitsFinder;
+ private final List<Limit> limits = new ArrayList<>(2);
+ private Limit globalLimit;
+
+ @Inject
+ public MaxConnectionsLimiter(
+ AccountLimitsFinder accountLimitsFinder, Provider<CurrentUser> userProvider) {
+ this.accountLimitsFinder = accountLimitsFinder;
+ this.userProvider = userProvider;
+ }
+
+ @Inject
+ void init(ProjectCache projectCache) {
+ Config cfg = projectCache.getAllProjects().getConfig("quota.config").get();
+ for (String group : cfg.getSubsections(AccountLimitsConfig.GROUP_SECTION)) {
+ String val = cfg.getString(AccountLimitsConfig.GROUP_SECTION, group, CONFIG_KEY);
+ Matcher matcher = REST_API_REGEX.matcher(val);
+ if (matcher.matches()) {
+ limits.add(new Limit(group, Integer.parseInt(matcher.group(1))));
+ }
+ }
+
+ String globalConfig = cfg.getString(GLOBAL_KEY, null, CONFIG_KEY);
+ if (globalConfig != null) {
+ Matcher matcher = REST_API_REGEX.matcher(globalConfig);
+ if (matcher.matches()) {
+ globalLimit = new Limit(GLOBAL_KEY, Integer.parseInt(matcher.group(1)));
+ }
+ }
+ }
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+ throws IOException, ServletException {
+ if (isRestApiRequest(request)) {
+ CurrentUser currentUser = userProvider.get();
+
+ if (currentUser.isIdentifiedUser()) {
+ String userId = currentUser.getAccountId().toString();
+ Optional<Integer> limit = getEffectiveRestApiLimit(currentUser.asIdentifiedUser());
+
+ if (limit.isPresent()) {
+ if (!canPermitCall(userId, limit.get())) {
+ ((HttpServletResponse) response)
+ .sendError(429, "Too Many Requests: rate limited by " + CONFIG_KEY);
+ return;
+ }
+ try {
+ chain.doFilter(request, response);
+ } finally {
+ markCallComplete(userId);
+ }
+ return;
+ }
+ }
+ }
+
+ chain.doFilter(request, response);
+ }
+
+ private boolean isRestApiRequest(ServletRequest req) {
+ return req instanceof HttpServletRequest
+ && REST_API_PATH_PATTERN.matcher(((HttpServletRequest) req).getServletPath()).matches();
+ }
+
+ private Optional<Integer> getEffectiveRestApiLimit(IdentifiedUser user) {
+ List<Integer> result = new ArrayList<>();
+
+ for (Limit limit : limits) {
+ if (accountLimitsFinder.isMatching(user.getEffectiveGroups(), limit.group())) {
+ result.add(limit.restApiLimit());
+ break; // only consider the first matching group
+ }
+ }
+
+ if (globalLimit != null) {
+ result.add(globalLimit.restApiLimit());
+ }
+
+ return result.stream().filter(l -> l > 0).min(Integer::compareTo);
+ }
+
+ private boolean canPermitCall(String userId, int limit) {
+ AtomicBoolean permitted = new AtomicBoolean(false);
+ connectionsByUser.compute(
+ userId,
+ (u, c) -> {
+ int current = (c == null) ? 0 : c;
+ if (current < limit) {
+ permitted.setPlain(true);
+ return current + 1;
+ }
+ return current;
+ });
+ return permitted.getPlain();
+ }
+
+ private void markCallComplete(String userId) {
+ connectionsByUser.computeIfPresent(
+ userId,
+ (u, c) -> {
+ c--;
+ return c <= 0 ? null : c;
+ });
+ }
+}
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 76a0491..6ae88af 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -255,6 +255,19 @@
`Exceeded rate limit of ${rateLimit} REST API requests/hour (or idle `
`time used up in bursts of max ${burstsLimit} requests)` .
+<a id="maxConnectionsPerUserForTask" />
+`maxConnectionsPerUserForTask`
+: Even though we have ratelimiting over a window of period, costly restapis
+run concurrently by users can still bring down the server. Using the
+below config we could limit the concurrent calls. This helps in limiting the
+costly calls that can occupy threads for long periods and prevent other shorter
+operations by other users from being processed.
+
+```
+ [group "Registered Users"]
+ maxConnectionsPerUserForTask = 20 rest-api
+```
+
Task Quota
-----------